Merge branch 'main' into feature/add-russian-localization
This commit is contained in:
@@ -41,6 +41,7 @@ jobs:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test
|
||||
- run: npm run test:browser:install
|
||||
- run: npm run test:browser
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# Writing Tests
|
||||
|
||||
This project uses [Vitest](https://vitest.dev/) for both unit/integration tests and browser tests. There are two separate configs — each targets a different set of files.
|
||||
|
||||
## Unit tests
|
||||
|
||||
**Config:** `vitest.config.ts`
|
||||
**Runs in:** jsdom (simulated DOM, no real browser)
|
||||
**File pattern:** `src/**/*.test.ts` — anything that does **not** end in `.browser.test.ts`
|
||||
**CI command:** `npm run test`
|
||||
|
||||
Use unit tests for pure logic, utility functions, data transformations, and anything that doesn't need real browser APIs (Canvas, WebCodecs, MediaRecorder, etc.).
|
||||
|
||||
### File placement
|
||||
|
||||
Co-locate the test file next to the source file, or put it in a `__tests__/` folder in the same directory.
|
||||
|
||||
```
|
||||
src/lib/compositeLayout.ts
|
||||
src/lib/compositeLayout.test.ts # co-located
|
||||
|
||||
src/i18n/__tests__/tutorialHelpTranslations.test.ts # grouped
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeCompositeLayout } from "./compositeLayout";
|
||||
|
||||
describe("computeCompositeLayout", () => {
|
||||
it("anchors the overlay in the lower-right corner", () => {
|
||||
const layout = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
screenSize: { width: 1920, height: 1080 },
|
||||
webcamSize: { width: 1280, height: 720 },
|
||||
});
|
||||
|
||||
expect(layout).not.toBeNull();
|
||||
expect(layout!.webcamRect!.x).toBeGreaterThan(1920 / 2);
|
||||
expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Path aliases
|
||||
|
||||
The `@/` alias resolves to `src/`. Use it for imports that would otherwise need long relative paths.
|
||||
|
||||
```ts
|
||||
import { SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
```
|
||||
|
||||
### Running locally
|
||||
|
||||
```bash
|
||||
npm run test # run once
|
||||
npm run test:watch # watch mode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Browser tests
|
||||
|
||||
**Config:** `vitest.browser.config.ts`
|
||||
**Runs in:** real Chromium via Playwright (headless)
|
||||
**File pattern:** `src/**/*.browser.test.ts`
|
||||
**CI commands:** `npm run test:browser:install` then `npm run test:browser`
|
||||
|
||||
Use browser tests when the code under test depends on real browser APIs that jsdom doesn't implement: `VideoDecoder`, `VideoEncoder`, `MediaRecorder`, `OffscreenCanvas`, `WebGL`, etc.
|
||||
|
||||
### File placement
|
||||
|
||||
Name the file `<subject>.browser.test.ts` and place it next to the source file.
|
||||
|
||||
```
|
||||
src/lib/exporter/videoExporter.ts
|
||||
src/lib/exporter/videoExporter.browser.test.ts
|
||||
```
|
||||
|
||||
### Loading fixture assets
|
||||
|
||||
Static assets (video files, images) live in `tests/fixtures/`. Import them with Vite's `?url` suffix so Vite serves them through the dev server.
|
||||
|
||||
```ts
|
||||
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url";
|
||||
import { VideoExporter } from "./videoExporter";
|
||||
|
||||
describe("VideoExporter (real browser)", () => {
|
||||
it("exports a valid MP4 blob from a real video", async () => {
|
||||
const exporter = new VideoExporter({
|
||||
videoUrl: sampleVideoUrl,
|
||||
width: 320,
|
||||
height: 180,
|
||||
frameRate: 15,
|
||||
bitrate: 1_000_000,
|
||||
wallpaper: "#1a1a2e",
|
||||
zoomRegions: [],
|
||||
showShadow: false,
|
||||
shadowIntensity: 0,
|
||||
showBlur: false,
|
||||
cropRegion: { x: 0, y: 0, width: 1, height: 1 },
|
||||
});
|
||||
|
||||
const result = await exporter.export();
|
||||
|
||||
expect(result.success, result.error).toBe(true);
|
||||
expect(result.blob).toBeInstanceOf(Blob);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Timeouts
|
||||
|
||||
Browser tests have a default timeout of 120 seconds per test and 30 seconds per hook (set in `vitest.browser.config.ts`). Export operations are slow — prefer small fixture dimensions (320×180) and low bitrates to keep tests fast.
|
||||
|
||||
### Running locally
|
||||
|
||||
First install the browser (one-time):
|
||||
|
||||
```bash
|
||||
npm run test:browser:install
|
||||
```
|
||||
|
||||
Then run the tests:
|
||||
|
||||
```bash
|
||||
npm run test:browser
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Choosing the right type
|
||||
|
||||
| Situation | Use |
|
||||
|---|---|
|
||||
| Pure function / data transformation | Unit test |
|
||||
| i18n key coverage | Unit test |
|
||||
| React hook logic (no real browser APIs) | Unit test |
|
||||
| `VideoDecoder` / `VideoEncoder` / `MediaRecorder` | Browser test |
|
||||
| `OffscreenCanvas` / WebGL / Pixi.js rendering | Browser test |
|
||||
| File export producing a real `Blob` | Browser test |
|
||||
@@ -51,6 +51,7 @@
|
||||
"NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.",
|
||||
"NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.",
|
||||
"NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.",
|
||||
"NSScreenCaptureUsageDescription": "OpenScreen needs screen recording permission to detect and capture windows.",
|
||||
"NSCameraUseContinuityCameraDeviceType": true,
|
||||
"com.apple.security.device.audio-input": true
|
||||
}
|
||||
|
||||
Vendored
+16
-1
@@ -82,7 +82,14 @@ interface Window {
|
||||
saveExportedVideo: (
|
||||
videoData: ArrayBuffer,
|
||||
fileName: string,
|
||||
) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
|
||||
exportFolder?: string,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
message?: string;
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
|
||||
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
|
||||
setCurrentRecordingSession: (
|
||||
@@ -149,7 +156,15 @@ interface Window {
|
||||
setMicrophoneExpanded: (expanded: boolean) => void;
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||
onRequestSaveBeforeClose: (callback: () => Promise<boolean> | boolean) => () => void;
|
||||
onRequestCloseConfirm: (callback: () => void) => () => void;
|
||||
sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => void;
|
||||
setLocale: (locale: string) => Promise<void>;
|
||||
saveDiagnostic: (payload: {
|
||||
error: string;
|
||||
stack?: string;
|
||||
projectState: unknown;
|
||||
logs: string[];
|
||||
}) => Promise<{ success: boolean; path?: string; canceled?: boolean; error?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+111
-46
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -985,58 +986,81 @@ export function registerIpcHandlers(
|
||||
* @returns Object with success status, optional file path, and error details.
|
||||
*/
|
||||
|
||||
ipcMain.handle("save-exported-video", async (_, videoData: ArrayBuffer, fileName: string) => {
|
||||
try {
|
||||
// Determine file type from extension
|
||||
const isGif = fileName.toLowerCase().endsWith(".gif");
|
||||
const filters = isGif
|
||||
? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }]
|
||||
: [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }];
|
||||
ipcMain.handle(
|
||||
"save-exported-video",
|
||||
async (_, videoData: ArrayBuffer, fileName: string, exportFolder?: string) => {
|
||||
try {
|
||||
// Determine file type from extension
|
||||
const isGif = fileName.toLowerCase().endsWith(".gif");
|
||||
const filters = isGif
|
||||
? [{ name: mainT("dialogs", "fileDialogs.gifImage"), extensions: ["gif"] }]
|
||||
: [{ name: mainT("dialogs", "fileDialogs.mp4Video"), extensions: ["mp4"] }];
|
||||
|
||||
const dialogOptions = buildDialogOptions(
|
||||
{
|
||||
title: isGif
|
||||
? mainT("dialogs", "fileDialogs.saveGif")
|
||||
: mainT("dialogs", "fileDialogs.saveVideo"),
|
||||
defaultPath: path.join(app.getPath("downloads"), fileName),
|
||||
filters,
|
||||
properties: ["createDirectory", "showOverwriteConfirmation"],
|
||||
},
|
||||
getMainWindow(),
|
||||
);
|
||||
const result = await dialog.showSaveDialog(dialogOptions);
|
||||
// Prefer the user's last export folder if it still exists, otherwise fall
|
||||
// back to ~/Downloads. Validation must happen here because the renderer
|
||||
// can't stat the filesystem.
|
||||
let defaultDir = app.getPath("downloads");
|
||||
if (exportFolder) {
|
||||
try {
|
||||
const stats = await fs.stat(exportFolder);
|
||||
if (stats.isDirectory()) {
|
||||
defaultDir = exportFolder;
|
||||
}
|
||||
} catch (err) {
|
||||
// Stat can fail because the folder was moved/deleted (expected) or
|
||||
// because of a permission error (worth surfacing). Either way we
|
||||
// fall back to Downloads, but log so debugging isn't blind.
|
||||
console.warn(
|
||||
`Could not access remembered export folder "${exportFolder}", falling back to Downloads:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
const dialogOptions = buildDialogOptions(
|
||||
{
|
||||
title: isGif
|
||||
? mainT("dialogs", "fileDialogs.saveGif")
|
||||
: mainT("dialogs", "fileDialogs.saveVideo"),
|
||||
defaultPath: path.join(defaultDir, fileName),
|
||||
filters,
|
||||
properties: ["createDirectory", "showOverwriteConfirmation"],
|
||||
},
|
||||
getMainWindow(),
|
||||
);
|
||||
const result = await dialog.showSaveDialog(dialogOptions);
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
if (result.canceled || !result.filePath) {
|
||||
return {
|
||||
success: false,
|
||||
canceled: true,
|
||||
message: "Export canceled",
|
||||
};
|
||||
}
|
||||
|
||||
// --- FIX: Normalize the path for Windows compatibility ---
|
||||
const normalizedPath = path.normalize(result.filePath);
|
||||
|
||||
// Ensure the parent directory exists (Windows may fail if the folder is missing)
|
||||
await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
|
||||
// --- END FIX ---
|
||||
|
||||
await fs.writeFile(normalizedPath, Buffer.from(videoData));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: normalizedPath,
|
||||
message: "Video exported successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to save exported video:", error);
|
||||
return {
|
||||
success: false,
|
||||
canceled: true,
|
||||
message: "Export canceled",
|
||||
message: "Failed to save exported video",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
|
||||
// --- FIX: Normalize the path for Windows compatibility ---
|
||||
const normalizedPath = path.normalize(result.filePath);
|
||||
|
||||
// Ensure the parent directory exists (Windows may fail if the folder is missing)
|
||||
await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
|
||||
// --- END FIX ---
|
||||
|
||||
await fs.writeFile(normalizedPath, Buffer.from(videoData));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: normalizedPath,
|
||||
message: "Video exported successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to save exported video:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to save exported video",
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
ipcMain.handle("open-video-file-picker", async () => {
|
||||
try {
|
||||
const dialogOptions = buildDialogOptions(
|
||||
@@ -1317,4 +1341,45 @@ export function registerIpcHandlers(
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"save-diagnostic",
|
||||
async (
|
||||
_,
|
||||
payload: { error: string; stack?: string; projectState: unknown; logs: string[] },
|
||||
) => {
|
||||
const { filePath, canceled } = await dialog.showSaveDialog({
|
||||
title: "Save Diagnostic File",
|
||||
defaultPath: `openscreen-diagnostic-${Date.now()}.json`,
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
});
|
||||
|
||||
if (canceled || !filePath) return { success: false, canceled: true };
|
||||
|
||||
const diagnostic = {
|
||||
timestamp: new Date().toISOString(),
|
||||
appVersion: app.getVersion(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
osRelease: os.release(),
|
||||
osVersion: os.version(),
|
||||
totalMemoryMB: Math.round(os.totalmem() / 1024 / 1024),
|
||||
nodeVersion: process.versions.node,
|
||||
electronVersion: process.versions.electron,
|
||||
chromeVersion: process.versions.chrome,
|
||||
error: payload.error,
|
||||
stack: payload.stack,
|
||||
projectState: payload.projectState,
|
||||
recentLogs: payload.logs,
|
||||
};
|
||||
|
||||
try {
|
||||
await fs.writeFile(filePath, JSON.stringify(diagnostic, null, 2), "utf-8");
|
||||
return { success: true, path: filePath };
|
||||
} catch (error) {
|
||||
console.error("Failed to write diagnostic file:", error);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+23
-27
@@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
dialog,
|
||||
ipcMain,
|
||||
Menu,
|
||||
nativeImage,
|
||||
@@ -333,6 +332,7 @@ function updateTrayMenu(recording: boolean = false) {
|
||||
|
||||
let editorHasUnsavedChanges = false;
|
||||
let isForceClosing = false;
|
||||
let isCloseConfirmInFlight = false;
|
||||
|
||||
ipcMain.on("set-has-unsaved-changes", (_, hasChanges: boolean) => {
|
||||
editorHasUnsavedChanges = hasChanges;
|
||||
@@ -364,39 +364,35 @@ function createEditorWindowWrapper() {
|
||||
editorHasUnsavedChanges = false;
|
||||
|
||||
mainWindow.on("close", (event) => {
|
||||
if (isForceClosing || !editorHasUnsavedChanges) return;
|
||||
if (isForceClosing || !editorHasUnsavedChanges || isCloseConfirmInFlight) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const choice = dialog.showMessageBoxSync(mainWindow!, {
|
||||
type: "warning",
|
||||
buttons: [
|
||||
mainT("dialogs", "unsavedChanges.saveAndClose"),
|
||||
mainT("dialogs", "unsavedChanges.discardAndClose"),
|
||||
mainT("common", "actions.cancel"),
|
||||
],
|
||||
defaultId: 0,
|
||||
cancelId: 2,
|
||||
title: mainT("dialogs", "unsavedChanges.title"),
|
||||
message: mainT("dialogs", "unsavedChanges.message"),
|
||||
detail: mainT("dialogs", "unsavedChanges.detail"),
|
||||
});
|
||||
isCloseConfirmInFlight = true;
|
||||
|
||||
const windowToClose = mainWindow;
|
||||
if (!windowToClose || windowToClose.isDestroyed()) return;
|
||||
|
||||
if (choice === 0) {
|
||||
// Save & Close — tell renderer to save, then close
|
||||
windowToClose.webContents.send("request-save-before-close");
|
||||
ipcMain.once("save-before-close-done", (_, shouldClose: boolean) => {
|
||||
if (!shouldClose) return;
|
||||
// Ask renderer to show the custom in-app dialog
|
||||
windowToClose.webContents.send("request-close-confirm");
|
||||
|
||||
ipcMain.once("close-confirm-response", (event, choice: "save" | "discard" | "cancel") => {
|
||||
if (event.sender.id !== windowToClose?.webContents.id) return;
|
||||
isCloseConfirmInFlight = false;
|
||||
if (!windowToClose || windowToClose.isDestroyed()) return;
|
||||
|
||||
if (choice === "save") {
|
||||
// Tell renderer to save the project, then close when done
|
||||
windowToClose.webContents.send("request-save-before-close");
|
||||
ipcMain.once("save-before-close-done", (event, shouldClose: boolean) => {
|
||||
if (event.sender.id !== windowToClose?.webContents.id) return;
|
||||
if (!shouldClose) return;
|
||||
forceCloseEditorWindow(windowToClose);
|
||||
});
|
||||
} else if (choice === "discard") {
|
||||
forceCloseEditorWindow(windowToClose);
|
||||
});
|
||||
} else if (choice === 1) {
|
||||
// Discard & Close
|
||||
forceCloseEditorWindow(windowToClose);
|
||||
}
|
||||
// choice === 2: Cancel — do nothing, window stays open
|
||||
}
|
||||
// "cancel": flag reset, window stays open
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+18
-2
@@ -71,8 +71,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
openExternalUrl: (url: string) => {
|
||||
return ipcRenderer.invoke("open-external-url", url);
|
||||
},
|
||||
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke("save-exported-video", videoData, fileName);
|
||||
saveExportedVideo: (videoData: ArrayBuffer, fileName: string, exportFolder?: string) => {
|
||||
return ipcRenderer.invoke("save-exported-video", videoData, fileName, exportFolder);
|
||||
},
|
||||
openVideoFilePicker: () => {
|
||||
return ipcRenderer.invoke("open-video-file-picker");
|
||||
@@ -134,6 +134,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
setLocale: (locale: string) => {
|
||||
return ipcRenderer.invoke("set-locale", locale);
|
||||
},
|
||||
saveDiagnostic: (payload: {
|
||||
error: string;
|
||||
stack?: string;
|
||||
projectState: unknown;
|
||||
logs: string[];
|
||||
}) => {
|
||||
return ipcRenderer.invoke("save-diagnostic", payload);
|
||||
},
|
||||
setMicrophoneExpanded: (expanded: boolean) => {
|
||||
ipcRenderer.send("hud:setMicrophoneExpanded", expanded);
|
||||
},
|
||||
@@ -166,4 +174,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
ipcRenderer.on("request-save-before-close", listener);
|
||||
return () => ipcRenderer.removeListener("request-save-before-close", listener);
|
||||
},
|
||||
onRequestCloseConfirm: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("request-close-confirm", listener);
|
||||
return () => ipcRenderer.removeListener("request-close-confirm", listener);
|
||||
},
|
||||
sendCloseConfirmResponse: (choice: "save" | "discard" | "cancel") => {
|
||||
ipcRenderer.send("close-confirm-response", choice);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,5 +21,9 @@
|
||||
<!-- Camera (webcam capture) -->
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
|
||||
<!-- Screen recording (required for desktopCapturer.getSources() on macOS 10.15+) -->
|
||||
<key>com.apple.security.device.screen-capture</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Generated
+36
-55
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "openscreen",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openscreen",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@fix-webm-duration/fix": "^1.0.1",
|
||||
"@pixi/filter-drop-shadow": "^5.2.0",
|
||||
@@ -187,6 +188,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -395,6 +397,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -718,6 +721,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
@@ -766,6 +770,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
@@ -1197,7 +1202,6 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1219,7 +1223,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1236,7 +1239,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1251,7 +1253,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -1961,7 +1962,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz",
|
||||
"integrity": "sha512-a6R+bXKeXMDcRmjYQoBIK+v2EYqxSX49wcjAY579EYM/WrFKS98nSees6lqVUcLKrcQh2DT9srJHX7XMny3voQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pixi/colord": "^2.9.6"
|
||||
}
|
||||
@@ -1976,8 +1976,7 @@
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.4.3.tgz",
|
||||
"integrity": "sha512-QGmwJUNQy/vVEHzL6VGQvnwawLZ1wceZMI8HwJAT4/I2uAzbBeFDdmCS8WsTpSWLZjF/DszDc1D8BFp4pVJ5UQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pixi/core": {
|
||||
"version": "7.4.3",
|
||||
@@ -2004,8 +2003,7 @@
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.4.3.tgz",
|
||||
"integrity": "sha512-FhoiYkHQEDYHUE7wXhqfsTRz6KxLXjuMbSiAwnLb9uG1vAgp6q6qd6HEsf4X30YaZbLFY8a4KY6hFZWjF+4Fdw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pixi/filter-drop-shadow": {
|
||||
"version": "5.2.0",
|
||||
@@ -2032,22 +2030,19 @@
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz",
|
||||
"integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pixi/runner": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.4.3.tgz",
|
||||
"integrity": "sha512-TJyfp7y23u5vvRAyYhVSa7ytq0PdKSvPLXu4G3meoFh1oxTLHH6g/RIzLuxUAThPG2z7ftthuW3qWq6dRV+dhw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pixi/settings": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.4.3.tgz",
|
||||
"integrity": "sha512-SmGK8smc0PxRB9nr0UJioEtE9hl4gvj9OedCvZx3bxBwA3omA5BmP3CyhQfN8XJ29+o2OUL01r3zAPVol4l4lA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pixi/constants": "7.4.3",
|
||||
"@types/css-font-loading-module": "^0.0.12",
|
||||
@@ -2059,7 +2054,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.4.3.tgz",
|
||||
"integrity": "sha512-tHsAD0iOUb6QSGGw+c8cyRBvxsq/NlfzIFBZLEHhWZ+Bx4a0MmXup6I/yJDGmyPCYE+ctCcAfY13wKAzdiVFgQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pixi/extensions": "7.4.3",
|
||||
"@pixi/settings": "7.4.3",
|
||||
@@ -2071,7 +2065,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.4.3.tgz",
|
||||
"integrity": "sha512-NO3Y9HAn2UKS1YdxffqsPp+kDpVm8XWvkZcS/E+rBzY9VTLnNOI7cawSRm+dacdET3a8Jad3aDKEDZ0HmAqAFA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pixi/color": "7.4.3",
|
||||
"@pixi/constants": "7.4.3",
|
||||
@@ -3650,8 +3643,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -3726,8 +3718,7 @@
|
||||
"version": "0.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz",
|
||||
"integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.13",
|
||||
@@ -3765,8 +3756,7 @@
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz",
|
||||
"integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
@@ -3865,6 +3855,7 @@
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -3876,6 +3867,7 @@
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
@@ -4157,6 +4149,7 @@
|
||||
"integrity": "sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/browser": "4.1.5",
|
||||
"@vitest/mocker": "4.1.5",
|
||||
@@ -4349,6 +4342,7 @@
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -4874,6 +4868,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -5055,7 +5050,6 @@
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
@@ -5406,8 +5400,7 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
@@ -5697,6 +5690,7 @@
|
||||
"integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.8.1",
|
||||
"builder-util": "26.8.1",
|
||||
@@ -5789,8 +5783,7 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
@@ -5839,8 +5832,7 @@
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
|
||||
"integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==",
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ejs": {
|
||||
"version": "3.1.10",
|
||||
@@ -6023,7 +6015,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -6044,7 +6035,6 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -6287,8 +6277,7 @@
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
@@ -7700,7 +7689,6 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -7920,7 +7908,6 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -8234,7 +8221,6 @@
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -8443,6 +8429,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.18.1.tgz",
|
||||
"integrity": "sha512-6LUPWYgulZhp/w4kam2XHXB0QedISZIqrJbRdHLLQ3csn5a38uzKxAp6B5j6s89QFYaIJbg95kvgTRcbgpO1ow==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
"examples",
|
||||
"playground"
|
||||
@@ -8488,6 +8475,7 @@
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
@@ -8558,6 +8546,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -8702,7 +8691,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -8720,7 +8708,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -8731,7 +8718,6 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -8747,7 +8733,6 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -8861,7 +8846,6 @@
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
@@ -8920,6 +8904,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -8932,6 +8917,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -8968,8 +8954,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
@@ -9290,7 +9275,6 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -9497,7 +9481,6 @@
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
@@ -9517,7 +9500,6 @@
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
@@ -9534,7 +9516,6 @@
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -9553,7 +9534,6 @@
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -9894,6 +9874,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -9977,7 +9958,6 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -10378,7 +10358,6 @@
|
||||
"resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz",
|
||||
"integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"punycode": "^1.4.1",
|
||||
"qs": "^6.12.3"
|
||||
@@ -10391,8 +10370,7 @@
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
|
||||
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
@@ -10485,6 +10463,7 @@
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -10574,7 +10553,8 @@
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
|
||||
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/vite/node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
@@ -10597,6 +10577,7 @@
|
||||
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.5",
|
||||
"@vitest/mocker": "4.1.5",
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ChevronDown,
|
||||
Crop,
|
||||
Download,
|
||||
FileDown,
|
||||
Film,
|
||||
Image,
|
||||
Lock,
|
||||
@@ -240,6 +241,7 @@ interface SettingsPanelProps {
|
||||
webcamSizePreset?: WebcamSizePreset;
|
||||
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
|
||||
onWebcamSizePresetCommit?: () => void;
|
||||
onSaveDiagnostic?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default SettingsPanel;
|
||||
@@ -327,6 +329,7 @@ export function SettingsPanel({
|
||||
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
|
||||
onWebcamSizePresetChange,
|
||||
onWebcamSizePresetCommit,
|
||||
onSaveDiagnostic,
|
||||
}: SettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
// Resolved URLs are for DOM rendering only (backgroundImage). The canonical
|
||||
@@ -1682,6 +1685,16 @@ export function SettingsPanel({
|
||||
<Bug className="w-3 h-3 text-[#34B27B]" />
|
||||
{t("links.reportBug")}
|
||||
</button>
|
||||
{onSaveDiagnostic && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSaveDiagnostic}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
|
||||
>
|
||||
<FileDown className="w-3 h-3 text-slate-400" />
|
||||
Save Diagnostics
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Save, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useScopedT } from "@/contexts/I18nContext";
|
||||
|
||||
interface UnsavedChangesDialogProps {
|
||||
isOpen: boolean;
|
||||
onSaveAndClose: () => void;
|
||||
onDiscardAndClose: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function UnsavedChangesDialog({
|
||||
isOpen,
|
||||
onSaveAndClose,
|
||||
onDiscardAndClose,
|
||||
onCancel,
|
||||
}: UnsavedChangesDialogProps) {
|
||||
const td = useScopedT("dialogs");
|
||||
const tc = useScopedT("common");
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
|
||||
<DialogContent className="bg-[#09090b] border-white/10 rounded-2xl max-w-sm p-6 gap-0">
|
||||
<DialogHeader className="mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src="./openscreen.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="w-9 h-9 rounded-xl flex-shrink-0"
|
||||
/>
|
||||
<DialogTitle className="text-base font-semibold text-slate-200 leading-tight">
|
||||
{td("unsavedChanges.title")}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-slate-300 mb-1">{td("unsavedChanges.message")}</p>
|
||||
<DialogDescription className="text-sm text-slate-500 mb-6">
|
||||
{td("unsavedChanges.detail")}
|
||||
</DialogDescription>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSaveAndClose}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-[#34B27B] hover:bg-[#2d9e6c] active:bg-[#27885c] text-white font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B] focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{td("unsavedChanges.saveAndClose")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDiscardAndClose}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-white/5 hover:bg-red-500/15 border border-white/10 hover:border-red-500/30 text-slate-300 hover:text-red-400 font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/30 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{td("unsavedChanges.discardAndClose")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg hover:bg-white/5 text-slate-500 hover:text-slate-300 font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/20 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]"
|
||||
>
|
||||
{tc("actions.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,12 @@ import {
|
||||
import { computeFrameStepTime } from "@/lib/frameStep";
|
||||
import type { ProjectMedia } from "@/lib/recordingSession";
|
||||
import { matchesShortcut } from "@/lib/shortcuts";
|
||||
import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences";
|
||||
import {
|
||||
getExportFolder,
|
||||
loadUserPreferences,
|
||||
parentDirectoryOf,
|
||||
saveUserPreferences,
|
||||
} from "@/lib/userPreferences";
|
||||
import { BackgroundLoadError } from "@/lib/wallpaper";
|
||||
import {
|
||||
getAspectRatioValue,
|
||||
@@ -75,6 +80,7 @@ import {
|
||||
type ZoomFocusMode,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
import { UnsavedChangesDialog } from "./UnsavedChangesDialog";
|
||||
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
|
||||
|
||||
export default function VideoEditor() {
|
||||
@@ -147,6 +153,7 @@ export default function VideoEditor() {
|
||||
format: string;
|
||||
} | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false);
|
||||
|
||||
const playerContainerRef = useRef<HTMLDivElement>(null);
|
||||
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
|
||||
@@ -538,6 +545,28 @@ export default function VideoEditor() {
|
||||
return () => cleanup();
|
||||
}, [saveProject]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.electronAPI.onRequestCloseConfirm(() => {
|
||||
setShowCloseConfirmDialog(true);
|
||||
});
|
||||
return () => cleanup();
|
||||
}, []);
|
||||
|
||||
const handleCloseConfirmSave = useCallback(() => {
|
||||
setShowCloseConfirmDialog(false);
|
||||
window.electronAPI.sendCloseConfirmResponse("save");
|
||||
}, []);
|
||||
|
||||
const handleCloseConfirmDiscard = useCallback(() => {
|
||||
setShowCloseConfirmDialog(false);
|
||||
window.electronAPI.sendCloseConfirmResponse("discard");
|
||||
}, []);
|
||||
|
||||
const handleCloseConfirmCancel = useCallback(() => {
|
||||
setShowCloseConfirmDialog(false);
|
||||
window.electronAPI.sendCloseConfirmResponse("cancel");
|
||||
}, []);
|
||||
|
||||
const handleSaveProject = useCallback(async () => {
|
||||
await saveProject(false);
|
||||
}, [saveProject]);
|
||||
@@ -1319,6 +1348,10 @@ export default function VideoEditor() {
|
||||
const handleExportSaved = useCallback(
|
||||
(formatLabel: "GIF" | "Video", filePath: string) => {
|
||||
setExportedFilePath(filePath);
|
||||
const folder = parentDirectoryOf(filePath);
|
||||
if (folder) {
|
||||
saveUserPreferences({ exportFolder: folder });
|
||||
}
|
||||
toast.success(
|
||||
t("export.exportedSuccessfully", {
|
||||
format: formatLabel,
|
||||
@@ -1343,6 +1376,7 @@ export default function VideoEditor() {
|
||||
const saveResult = await window.electronAPI.saveExportedVideo(
|
||||
unsavedExport.arrayBuffer,
|
||||
unsavedExport.fileName,
|
||||
getExportFolder(),
|
||||
);
|
||||
if (saveResult.canceled) {
|
||||
toast.info("Export canceled");
|
||||
@@ -1446,7 +1480,11 @@ export default function VideoEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
||||
const saveResult = await window.electronAPI.saveExportedVideo(
|
||||
arrayBuffer,
|
||||
fileName,
|
||||
getExportFolder(),
|
||||
);
|
||||
|
||||
if (saveResult.canceled) {
|
||||
setUnsavedExport({ arrayBuffer, fileName, format: "gif" });
|
||||
@@ -1588,7 +1626,11 @@ export default function VideoEditor() {
|
||||
}
|
||||
}
|
||||
|
||||
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
|
||||
const saveResult = await window.electronAPI.saveExportedVideo(
|
||||
arrayBuffer,
|
||||
fileName,
|
||||
getExportFolder(),
|
||||
);
|
||||
|
||||
if (saveResult.canceled) {
|
||||
setUnsavedExport({ arrayBuffer, fileName, format: "mp4" });
|
||||
@@ -1730,6 +1772,19 @@ export default function VideoEditor() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSaveDiagnostic = useCallback(async () => {
|
||||
const result = await window.electronAPI.saveDiagnostic({
|
||||
error: exportError ?? "Manual diagnostic export",
|
||||
projectState: editorState,
|
||||
logs: [],
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success("Diagnostic file saved");
|
||||
} else if (!result.canceled) {
|
||||
toast.error("Failed to save diagnostic file");
|
||||
}
|
||||
}, [exportError, editorState]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-background">
|
||||
@@ -2100,6 +2155,7 @@ export default function VideoEditor() {
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
unsavedExport={unsavedExport}
|
||||
onSaveUnsavedExport={handleSaveUnsavedExport}
|
||||
onSaveDiagnostic={handleSaveDiagnostic}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2117,6 +2173,13 @@ export default function VideoEditor() {
|
||||
exportedFilePath ? () => void handleShowExportedFile(exportedFilePath) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<UnsavedChangesDialog
|
||||
isOpen={showCloseConfirmDialog}
|
||||
onSaveAndClose={handleCloseConfirmSave}
|
||||
onDiscardAndClose={handleCloseConfirmDiscard}
|
||||
onCancel={handleCloseConfirmCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
import arDialogs from "@/i18n/locales/ar/dialogs.json";
|
||||
import enDialogs from "@/i18n/locales/en/dialogs.json";
|
||||
import esDialogs from "@/i18n/locales/es/dialogs.json";
|
||||
import frDialogs from "@/i18n/locales/fr/dialogs.json";
|
||||
import jaJPDialogs from "@/i18n/locales/ja-JP/dialogs.json";
|
||||
import koKRDialogs from "@/i18n/locales/ko-KR/dialogs.json";
|
||||
import ruDialogs from "@/i18n/locales/ru/dialogs.json";
|
||||
import trDialogs from "@/i18n/locales/tr/dialogs.json";
|
||||
import zhCNDialogs from "@/i18n/locales/zh-CN/dialogs.json";
|
||||
import zhTWDialogs from "@/i18n/locales/zh-TW/dialogs.json";
|
||||
|
||||
const tutorialHelpKeys = [
|
||||
"triggerLabel",
|
||||
@@ -36,11 +39,14 @@ const keysThatMayBeEmpty = new Set<(typeof tutorialHelpKeys)[number]>(["step1Des
|
||||
const dialogsByLocale = {
|
||||
en: enDialogs,
|
||||
"zh-CN": zhCNDialogs,
|
||||
"zh-TW": zhTWDialogs,
|
||||
es: esDialogs,
|
||||
fr: frDialogs,
|
||||
tr: trDialogs,
|
||||
"ko-KR": koKRDialogs,
|
||||
ru: ruDialogs,
|
||||
"ja-JP": jaJPDialogs,
|
||||
ar: arDialogs,
|
||||
} satisfies Record<Locale, { tutorial: Record<string, unknown> }>;
|
||||
|
||||
describe("TutorialHelp translations", () => {
|
||||
|
||||
@@ -75,6 +75,6 @@ describe("blur color helpers", () => {
|
||||
intensity: 12,
|
||||
blockSize: 12,
|
||||
}),
|
||||
).toBe("rgba(0, 0, 0, 0.18)");
|
||||
).toBe("rgba(0, 0, 0, 0.56)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parentDirectoryOf } from "./userPreferences";
|
||||
|
||||
describe("parentDirectoryOf", () => {
|
||||
it("returns the directory for a POSIX path", () => {
|
||||
expect(parentDirectoryOf("/Users/me/Movies/clip.mp4")).toBe("/Users/me/Movies");
|
||||
});
|
||||
|
||||
it("returns the directory for a Windows path", () => {
|
||||
expect(parentDirectoryOf("C:\\Users\\me\\Movies\\clip.mp4")).toBe("C:\\Users\\me\\Movies");
|
||||
});
|
||||
|
||||
it("preserves the POSIX root when the file is at /", () => {
|
||||
expect(parentDirectoryOf("/video.mp4")).toBe("/");
|
||||
});
|
||||
|
||||
it("preserves the Windows drive root with its trailing separator", () => {
|
||||
expect(parentDirectoryOf("C:\\video.mp4")).toBe("C:\\");
|
||||
expect(parentDirectoryOf("D:/video.mp4")).toBe("D:/");
|
||||
});
|
||||
|
||||
it("returns null when no separator is present", () => {
|
||||
expect(parentDirectoryOf("video.mp4")).toBeNull();
|
||||
expect(parentDirectoryOf("")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,8 @@ export interface UserPreferences {
|
||||
exportQuality: ExportQuality;
|
||||
/** Default export format */
|
||||
exportFormat: ExportFormat;
|
||||
/** Folder used for the most recent successful export, if any */
|
||||
exportFolder: string | null;
|
||||
}
|
||||
|
||||
const DEFAULT_PREFS: UserPreferences = {
|
||||
@@ -30,6 +32,7 @@ const DEFAULT_PREFS: UserPreferences = {
|
||||
aspectRatio: "16:9",
|
||||
exportQuality: "good",
|
||||
exportFormat: "mp4",
|
||||
exportFolder: null,
|
||||
};
|
||||
|
||||
function safeJsonParse(text: string | null): Record<string, unknown> | null {
|
||||
@@ -76,9 +79,47 @@ export function loadUserPreferences(): UserPreferences {
|
||||
raw.exportFormat === "gif" || raw.exportFormat === "mp4"
|
||||
? (raw.exportFormat as ExportFormat)
|
||||
: DEFAULT_PREFS.exportFormat,
|
||||
exportFolder:
|
||||
typeof raw.exportFolder === "string" && raw.exportFolder.length > 0
|
||||
? raw.exportFolder
|
||||
: DEFAULT_PREFS.exportFolder,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the parent directory from a saved file path. Handles both POSIX
|
||||
* and Windows separators since the path comes from the OS save dialog.
|
||||
*
|
||||
* Root directories are preserved with their trailing separator so that the
|
||||
* value is still a valid directory path:
|
||||
* "/video.mp4" -> "/"
|
||||
* "C:\\video.mp4" -> "C:\\"
|
||||
*
|
||||
* Returns null if no separator is found.
|
||||
*/
|
||||
export function parentDirectoryOf(filePath: string): string | null {
|
||||
const lastSep = Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\"));
|
||||
if (lastSep < 0) return null;
|
||||
|
||||
// POSIX root, e.g. "/video.mp4" -> "/"
|
||||
if (lastSep === 0) return filePath[0];
|
||||
|
||||
// Windows drive root, e.g. "C:\\video.mp4" -> "C:\\"
|
||||
if (lastSep === 2 && /^[A-Za-z]:[/\\]/.test(filePath)) {
|
||||
return filePath.slice(0, lastSep + 1);
|
||||
}
|
||||
|
||||
return filePath.slice(0, lastSep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the remembered export folder as `string | undefined`, suitable for
|
||||
* passing directly to IPC handlers that treat absence as "use the default".
|
||||
*/
|
||||
export function getExportFolder(): string | undefined {
|
||||
return loadUserPreferences().exportFolder ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist user preferences to localStorage.
|
||||
* Only the explicitly provided fields are updated.
|
||||
|
||||
@@ -6,6 +6,7 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
|
||||
exclude: ["src/**/*.browser.test.{ts,tsx}"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user