From 64e02941675560bdba5edc2564b8839a4a0ea14a Mon Sep 17 00:00:00 2001 From: Jonas Prokop Date: Sat, 10 Oct 2026 14:31:07 +0200 Subject: [PATCH 1/7] feat: add project-owned custom cursor uploads --- electron/ai-edition/document-service.test.ts | 27 +++ .../services/compositorViewService.test.ts | 10 + .../services/compositorViewService.ts | 12 +- .../ai-edition/CursorPane.levels.test.tsx | 8 +- .../ai-edition/CursorPane.preview.test.tsx | 180 +++++++++++++- .../ai-edition/NewEditorShell.module.css | 52 +++++ src/components/ai-edition/RightPanes.tsx | 219 +++++++++++++++++- src/i18n/locales/ar/settings.json | 8 +- src/i18n/locales/cs/settings.json | 8 +- src/i18n/locales/de/settings.json | 8 +- src/i18n/locales/en/settings.json | 8 +- src/i18n/locales/es/settings.json | 8 +- src/i18n/locales/fr/settings.json | 8 +- src/i18n/locales/it/settings.json | 8 +- src/i18n/locales/ja-JP/settings.json | 8 +- src/i18n/locales/ko-KR/settings.json | 8 +- src/i18n/locales/pt-BR/settings.json | 8 +- src/i18n/locales/ru/settings.json | 8 +- src/i18n/locales/tr/settings.json | 8 +- src/i18n/locales/vi/settings.json | 8 +- src/i18n/locales/zh-CN/settings.json | 8 +- src/i18n/locales/zh-TW/settings.json | 8 +- .../ai-edition/store/editorSettings.test.ts | 30 ++- src/lib/ai-edition/store/editorSettings.ts | 31 ++- src/lib/ai-edition/stylePresets.ts | 18 ++ src/lib/ai-edition/stylePresetsEditor.test.ts | 33 ++- src/lib/ai-edition/stylePresetsEditor.ts | 6 + src/lib/cursor/cursorThemes.test.ts | 31 +++ src/lib/cursor/cursorThemes.ts | 21 +- src/native/sceneDescription.test.ts | 12 + src/native/sceneDescription.ts | 7 +- .../testing/manual-e2e-checklist.md | 1 + 32 files changed, 784 insertions(+), 34 deletions(-) diff --git a/electron/ai-edition/document-service.test.ts b/electron/ai-edition/document-service.test.ts index 3a2e6d0ac..f6cb9f078 100644 --- a/electron/ai-edition/document-service.test.ts +++ b/electron/ai-edition/document-service.test.ts @@ -37,6 +37,33 @@ describe("DocumentService", () => { await fs.rm(mediaDir, { recursive: true, force: true }); }); + it("keeps custom cursor images in the project across save, reload, and style changes", async () => { + const created = await service.createProject("Custom cursor"); + const customTheme = { + arrow: "data:image/png;base64,YXJyb3c=", + pointer: "data:image/png;base64,aGFuZA==", + text: "data:image/jpeg;base64,dGV4dA==", + }; + await service.saveProject( + patchEditorSettings(created, { + cursor: { theme: "custom", customTheme }, + }), + ); + const reopened = await new DocumentService(tempDir, mediaDir).getProject(created.project.id); + expect(getEditorSettings(reopened).cursorTheme).toBe("custom"); + expect(getEditorSettings(reopened).cursorCustomTheme).toEqual(customTheme); + expect( + getEditorSettings(await service.createProject("Inherited custom look")).cursorCustomTheme, + ).toEqual(customTheme); + await service.saveProject(patchEditorSettings(reopened, { cursor: { theme: "default" } })); + const switched = await service.getProject(created.project.id); + expect(getEditorSettings(switched).cursorCustomTheme).toEqual(customTheme); + await service.saveProject(patchEditorSettings(switched, { cursor: { customTheme: {} } })); + expect( + getEditorSettings(await service.getProject(created.project.id)).cursorCustomTheme, + ).toEqual({}); + }); + describe("createProject", () => { it("creates a v8 doc with the given title and writes it to disk", async () => { const doc = await service.createProject("Demo Project"); diff --git a/electron/native-bridge/services/compositorViewService.test.ts b/electron/native-bridge/services/compositorViewService.test.ts index 01817e826..51daf104c 100644 --- a/electron/native-bridge/services/compositorViewService.test.ts +++ b/electron/native-bridge/services/compositorViewService.test.ts @@ -801,6 +801,16 @@ describe("resolveSceneAssetPaths", () => { expect(out.background.path).toBe(dataUrl); }); + it("leaves custom cursor upload data URLs untouched", () => { + const dataUrl = "data:image/png;base64,SGkh"; + const sprites = resolved({ + cursor: { theme: "custom", customTheme: { pointer: dataUrl } }, + }).cursor.cursorSprites; + + expect(sprites.pointer.path).toBe(dataUrl); + expect(sprites.arrow.path).toBe(path.join(resources, "cursors", "default", "arrow.png")); + }); + it("gives the default theme the built-in arrow, not a placeholder", () => { // Regression: "default" used to resolve to no sprite at all, and the compositor drew // its dot-and-ring fallback — a dot in a circle where the standard arrow belongs. diff --git a/electron/native-bridge/services/compositorViewService.ts b/electron/native-bridge/services/compositorViewService.ts index ebab7e0ba..e0f6097d1 100644 --- a/electron/native-bridge/services/compositorViewService.ts +++ b/electron/native-bridge/services/compositorViewService.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { app, sharedTexture, type WebFrameMain } from "electron"; import { type CursorKind, + type CustomCursorTheme, readCursorAsArrow, resolveCursorSprites, } from "../../../src/lib/cursor/cursorThemes"; @@ -121,10 +122,15 @@ function resolveCursorSpritePaths( themeId: string, asArrow: readonly CursorKind[], model3d = false, + customTheme?: CustomCursorTheme, ): Record { const resolved: Record = {}; - for (const [type, sprite] of Object.entries(resolveCursorSprites(themeId, asArrow, model3d))) { - const absolute = resolveSceneAssetPath(sprite.assetPath); + for (const [type, sprite] of Object.entries( + resolveCursorSprites(themeId, asArrow, model3d, customTheme), + )) { + const absolute = sprite.assetPath.startsWith("data:image/") + ? sprite.assetPath + : resolveSceneAssetPath(sprite.assetPath); if (absolute) { resolved[type] = { path: absolute, @@ -151,6 +157,7 @@ export function resolveSceneAssetPaths(sceneJson: string): string { theme?: string; asArrow?: unknown; model3d?: boolean; + customTheme?: CustomCursorTheme; cursorSprites?: Record; }; webcamEffect?: { @@ -187,6 +194,7 @@ export function resolveSceneAssetPaths(sceneJson: string): string { scene.cursor.theme, readCursorAsArrow(scene.cursor.asArrow, undefined, []), scene.cursor.model3d === true, + scene.cursor.customTheme, ); changed = true; } diff --git a/src/components/ai-edition/CursorPane.levels.test.tsx b/src/components/ai-edition/CursorPane.levels.test.tsx index bc714e2f7..4832804da 100644 --- a/src/components/ai-edition/CursorPane.levels.test.tsx +++ b/src/components/ai-edition/CursorPane.levels.test.tsx @@ -151,10 +151,12 @@ describe("CursorPane cursor types", () => { expect(asArrow()).toEqual(["pointer"]); }); - it("offers nothing to redraw when the video only shows the arrow", async () => { + it("keeps the hand and text controls when the video only shows the arrow", async () => { renderWithRecording({}, ["arrow", null]); await waitFor(() => expect(nativeBridgeClient.cursor.getRecordingData).toHaveBeenCalled()); - expect(screen.queryByRole("group", { name: "Cursor types" })).toBeNull(); + const group = screen.getByRole("group", { name: "Cursor types" }); + expect(within(group).getByRole("button", { name: "Hand" })).toBeTruthy(); + expect(within(group).getByRole("button", { name: "Text" })).toBeTruthy(); }); // A cursor file still being written right after a take must not hide the row all session. @@ -165,7 +167,7 @@ describe("CursorPane cursor types", () => { await waitFor(() => expect(read).toHaveBeenCalledTimes(1)); // Let the failed read settle before the pane opens again. await new Promise((resolve) => setTimeout(resolve, 0)); - expect(screen.queryByRole("group", { name: "Cursor types" })).toBeNull(); + expect(screen.getByRole("group", { name: "Cursor types" })).toBeTruthy(); cleanup(); renderProject({}, originalPath); diff --git a/src/components/ai-edition/CursorPane.preview.test.tsx b/src/components/ai-edition/CursorPane.preview.test.tsx index 0aa86791f..81649136d 100644 --- a/src/components/ai-edition/CursorPane.preview.test.tsx +++ b/src/components/ai-edition/CursorPane.preview.test.tsx @@ -1,9 +1,14 @@ // @vitest-environment jsdom -import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { toast } from "sonner"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { I18nProvider } from "@/contexts/I18nContext"; import { LOCALE_STORAGE_KEY } from "@/i18n/config"; -import { CURSOR_THEMES } from "@/lib/cursor/cursorThemes"; +import { createEmptyDocument } from "@/lib/ai-edition/schema"; +import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings"; +import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { CURSOR_THEMES, DEFAULT_CURSOR_SPRITES } from "@/lib/cursor/cursorThemes"; +import { nativeBridgeClient } from "@/native"; import { CursorPane } from "./RightPanes"; function stubStorage() { @@ -30,12 +35,28 @@ function stubStorage() { beforeEach(() => { stubStorage(); window.localStorage.setItem(LOCALE_STORAGE_KEY, "en"); + vi.spyOn(nativeBridgeClient.aiEdition, "save").mockImplementation(async (document) => ({ + success: true, + document, + })); }); afterEach(() => { cleanup(); + useProjectStore.getState().clear(); + vi.restoreAllMocks(); }); +function renderWithProject(legacyEditor: Record = {}) { + const document = { ...createEmptyDocument({ projectId: "p", title: "t" }), legacyEditor }; + useProjectStore.setState({ projectId: "p", document }); + return render( + + + , + ); +} + describe("CursorPane theme picker", () => { it("shows the original cursor themes beside the default", () => { expect(CURSOR_THEMES).toHaveLength(6); @@ -50,4 +71,157 @@ describe("CursorPane theme picker", () => { expect(button.querySelectorAll("img")).toHaveLength(1); } }); + + it("opens a three-slot custom cursor upload panel from the plus button", () => { + renderWithProject(); + fireEvent.click(screen.getByRole("button", { name: "Add custom cursor" })); + const panel = screen.getByRole("group", { name: "Custom cursor" }); + expect(within(panel).getByRole("button", { name: "Arrow" })).toBeTruthy(); + expect(within(panel).getByRole("button", { name: "Hand" })).toBeTruthy(); + expect(within(panel).getByRole("button", { name: "Text" })).toBeTruthy(); + expect(panel.textContent).toBe(""); + for (const [label, kind] of [ + ["Arrow", "arrow"], + ["Hand", "pointer"], + ["Text", "text"], + ] as const) { + expect( + within(panel) + .getByRole("button", { name: label }) + .querySelector("img") + ?.getAttribute("src"), + ).toContain(DEFAULT_CURSOR_SPRITES[kind].assetPath); + } + fireEvent.pointerDown(within(panel).getByRole("button", { name: "Text" })); + expect(screen.getByRole("group", { name: "Custom cursor" })).toBeTruthy(); + fireEvent.pointerDown(document.body); + expect(screen.queryByRole("group", { name: "Custom cursor" })).toBeNull(); + }); + + it("closes with Escape and toggles from the plus button", () => { + renderWithProject(); + const add = screen.getByRole("button", { name: "Add custom cursor" }); + fireEvent.click(add); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("group", { name: "Custom cursor" })).toBeNull(); + fireEvent.click(add); + fireEvent.pointerDown(add); + fireEvent.click(add); + expect(screen.queryByRole("group", { name: "Custom cursor" })).toBeNull(); + }); + + it("uploads one image with a blank MIME type, then adds other states without losing it", async () => { + const { container } = renderWithProject(); + fireEvent.click(screen.getByRole("button", { name: "Add custom cursor" })); + const panel = screen.getByRole("group", { name: "Custom cursor" }); + const input = container.querySelector('input[type="file"]'); + if (!input) throw new Error("Missing cursor upload input"); + let uploads = 0; + const upload = async (label: string, name: string, contents: string) => { + uploads += 1; + fireEvent.click(within(panel).getByRole("button", { name: label })); + fireEvent.change(input, { target: { files: [new File([contents], name)] } }); + await waitFor(() => expect(nativeBridgeClient.aiEdition.save).toHaveBeenCalledTimes(uploads)); + }; + await upload("Arrow", "arrow.PNG", "arrow-image"); + const arrow = getEditorSettings(useProjectStore.getState().document).cursorCustomTheme.arrow; + expect(arrow).toMatch(/^data:image\/png;base64,/); + expect(screen.getByRole("button", { name: "Custom" }).getAttribute("aria-pressed")).toBe( + "true", + ); + await upload("Hand", "hand.png", "hand-image"); + await upload("Text", "text.jpg", "text-image"); + const theme = getEditorSettings(useProjectStore.getState().document).cursorCustomTheme; + expect(theme.arrow).toBe(arrow); + expect(theme.pointer).toMatch(/^data:image\/png;base64,/); + expect(theme.text).toMatch(/^data:image\/jpeg;base64,/); + await upload("Replace Arrow image", "replacement.png", "replacement-image"); + const replaced = getEditorSettings(useProjectStore.getState().document).cursorCustomTheme; + expect(replaced.arrow).not.toBe(arrow); + expect(replaced.pointer).toBe(theme.pointer); + expect(replaced.text).toBe(theme.text); + expect(screen.getAllByRole("button", { name: "Custom" })).toHaveLength(1); + fireEvent.click(screen.getByRole("button", { name: "Default" })); + await waitFor(() => + expect(getEditorSettings(useProjectStore.getState().document).cursorTheme).toBe("default"), + ); + fireEvent.click(screen.getByRole("button", { name: "Custom" })); + await waitFor(() => + expect(getEditorSettings(useProjectStore.getState().document).cursorTheme).toBe("custom"), + ); + expect(getEditorSettings(useProjectStore.getState().document).cursorCustomTheme).toEqual( + replaced, + ); + }); + + it("removes one custom image while keeping the other images and the selected style", async () => { + const arrow = "data:image/png;base64,YXJyb3c="; + const pointer = "data:image/png;base64,aGFuZA=="; + renderWithProject({ cursorTheme: "custom", cursorCustomTheme: { arrow, pointer } }); + fireEvent.click(screen.getByRole("button", { name: "Add custom cursor" })); + fireEvent.click(screen.getByRole("button", { name: "Remove Hand image" })); + await waitFor(() => { + const settings = getEditorSettings(useProjectStore.getState().document); + expect(settings.cursorCustomTheme).toEqual({ arrow }); + expect(settings.cursorTheme).toBe("custom"); + }); + expect(screen.queryByRole("button", { name: "Remove Hand image" })).toBeNull(); + expect( + within(screen.getByRole("group", { name: "Custom cursor" })).getByRole("button", { + name: "Hand", + }), + ).toBeTruthy(); + }); + + it.each([ + "custom", + "studio-ink", + ])("removes the last custom image safely while %s is selected", async (selected) => { + renderWithProject({ + cursorTheme: selected, + cursorCustomTheme: { text: "data:image/png;base64,dGV4dA==" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Add custom cursor" })); + fireEvent.click(screen.getByRole("button", { name: "Remove Text image" })); + await waitFor(() => { + const settings = getEditorSettings(useProjectStore.getState().document); + expect(settings.cursorCustomTheme).toEqual({}); + expect(settings.cursorTheme).toBe(selected === "custom" ? "default" : selected); + }); + expect(screen.queryByRole("button", { name: "Custom" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Delete custom cursor set" })).toBeNull(); + }); + + it("deletes the whole custom cursor set without closing the upload panel", async () => { + renderWithProject({ + cursorTheme: "custom", + cursorCustomTheme: { + arrow: "data:image/png;base64,YXJyb3c=", + pointer: "data:image/png;base64,aGFuZA==", + }, + }); + fireEvent.click(screen.getByRole("button", { name: "Add custom cursor" })); + fireEvent.click(screen.getByRole("button", { name: "Delete custom cursor set" })); + await waitFor(() => + expect(getEditorSettings(useProjectStore.getState().document).cursorCustomTheme).toEqual({}), + ); + expect(getEditorSettings(useProjectStore.getState().document).cursorTheme).toBe("default"); + expect(screen.queryByRole("button", { name: "Custom" })).toBeNull(); + expect( + within(screen.getByRole("group", { name: "Custom cursor" })).getAllByRole("button"), + ).toHaveLength(3); + }); + + it("rejects unsupported files without changing the project", () => { + const error = vi.spyOn(toast, "error"); + const { container } = renderWithProject(); + const input = container.querySelector('input[type="file"]'); + if (!input) throw new Error("Missing cursor upload input"); + fireEvent.change(input, { + target: { files: [new File(["text"], "fake.png", { type: "text/plain" })] }, + }); + expect(error).toHaveBeenCalled(); + expect(nativeBridgeClient.aiEdition.save).not.toHaveBeenCalled(); + expect(getEditorSettings(useProjectStore.getState().document).cursorCustomTheme).toEqual({}); + }); }); diff --git a/src/components/ai-edition/NewEditorShell.module.css b/src/components/ai-edition/NewEditorShell.module.css index bbeeaacef..8d5e5f8fc 100644 --- a/src/components/ai-edition/NewEditorShell.module.css +++ b/src/components/ai-edition/NewEditorShell.module.css @@ -1448,6 +1448,58 @@ textarea.control { .cursorCellOff img { opacity: 0.35; transition: opacity 150ms ease; } .cursorCellOff:hover:not(:disabled) img { opacity: 0.7; } .cursorCellFixed { cursor: default; } +.customCursorPanel { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + margin: calc(-1 * var(--sp-2)) var(--sp-4) var(--sp-4); + padding: var(--sp-2); + border: 1px solid var(--border-soft); + border-radius: var(--r-md); + background: color-mix(in oklab, var(--fg) 4%, transparent); +} +.customCursorSlot { + min-width: 0; + width: 100%; + aspect-ratio: 1; + border: 1px solid transparent; + border-radius: var(--r-sm); + background: color-mix(in oklab, var(--fg) 5%, transparent); + color: var(--muted); + display: grid; + place-items: center; + gap: var(--sp-1); + padding: var(--sp-2); + font: 600 0.72rem var(--font-body); + cursor: pointer; + transition: border-color 150ms ease, color 150ms ease, background-color 150ms ease; +} +.customCursorSlot:hover { + border-color: var(--border-hi); + color: var(--fg-2); +} +.customCursorSlot:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.customCursorUploadSlot { position: relative; min-width: 0; } +.customCursorSlot.isActive { border-color: var(--accent); } +.customCursorRemove, .customCursorDelete { + display: grid; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--border-soft); + border-radius: var(--r-sm); + background: var(--surface-2); + color: var(--muted); + cursor: pointer; +} +.customCursorRemove { position: absolute; top: 2px; right: 2px; } +.customCursorDelete { width: 28px; height: 28px; } +.customCursorRemove:hover, .customCursorDelete:hover { color: var(--danger); border-color: var(--danger); } +.customCursorActions { grid-column: 1 / -1; display: flex; justify-content: flex-end; } /* A row that belongs to the grid right above it: the 3D switch under the cursor style. */ .paneRowTight { margin-top: calc(-1 * var(--sp-2)); } /* Same cell, reused for the icon+label rows (camera shape, camera background). diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 17f335048..4bbfc03eb 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -20,6 +20,7 @@ import { Monitor, MousePointerClick, Music, + Plus, RotateCcw, Sliders, Smartphone, @@ -111,6 +112,7 @@ import { CURSOR_KIND_IDS, CURSOR_KINDS, CURSOR_THEMES, + CUSTOM_CURSOR_THEME_ID, type CursorKind, DEFAULT_CURSOR_SPRITES, DEFAULT_CURSOR_THEME_ID, @@ -3670,6 +3672,7 @@ export function AudioTrackPane({ tl, onClose }: { tl: TimelineApi; onClose?: () // ─── Cursor ─────────────────────────────────────────────────────── function safeAssetUrl(relativePath: string): string { + if (relativePath.startsWith("data:image/")) return relativePath; try { return getAssetPath(relativePath); } catch { @@ -3677,9 +3680,116 @@ function safeAssetUrl(relativePath: string): string { } } +type CustomCursorUploadSlot = "arrow" | "pointer" | "text"; + +const CUSTOM_CURSOR_UPLOAD_SLOTS = [ + { id: "arrow", kind: "arrow" }, + { id: "pointer", kind: "pointer" }, + { id: "text", kind: "text" }, +] as const satisfies readonly { + id: CustomCursorUploadSlot; + kind: keyof typeof DEFAULT_CURSOR_SPRITES; +}[]; + +function useCursorFileInput(onPicked: (slot: CustomCursorUploadSlot, dataUrl: string) => void): { + pick: (slot: CustomCursorUploadSlot) => void; + input: ReactNode; +} { + const ts = useScopedT("settings"); + const ref = useRef(null); + const slotRef = useRef("arrow"); + + const handleFileSelected = (e: ChangeEvent) => { + const file = e.target.files?.[0]; + const slot = slotRef.current; + e.target.value = ""; + if (!file) return; + if (!isSupportedBackgroundImage(file.type, file.name)) { + toast.error(ts("background.unsupportedImage")); + return; + } + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = typeof reader.result === "string" ? reader.result : ""; + if (!dataUrl) { + toast.error(ts("background.imageReadFailed")); + return; + } + onPicked(slot, dataUrl); + }; + reader.onerror = () => toast.error(ts("background.imageReadFailed")); + const mime = file.type || (/\.png$/i.test(file.name) ? "image/png" : "image/jpeg"); + reader.readAsDataURL(new Blob([file], { type: mime })); + }; + + return { + pick: (slot) => { + slotRef.current = slot; + ref.current?.click(); + }, + input: ( + + ), + }; +} + export function CursorPane() { const ts = useScopedT("settings"); const { settings, set, setLive, commit, hasDocument } = useEditorSettings(); + const [customPanelOpen, setCustomPanelOpen] = useState(false); + const customPanelRef = useRef(null); + const customButtonRef = useRef(null); + useEffect(() => { + if (!customPanelOpen) return; + const dismissOutside = (event: PointerEvent) => { + if ( + event.target instanceof Node && + !customPanelRef.current?.contains(event.target) && + !customButtonRef.current?.contains(event.target) + ) + setCustomPanelOpen(false); + }; + const dismissOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setCustomPanelOpen(false); + }; + document.addEventListener("pointerdown", dismissOutside); + document.addEventListener("keydown", dismissOnEscape); + return () => { + document.removeEventListener("pointerdown", dismissOutside); + document.removeEventListener("keydown", dismissOnEscape); + }; + }, [customPanelOpen]); + const customCursorUploaded = Object.keys(settings.cursorCustomTheme).length > 0; + const customCursorSprites = useMemo( + () => resolveCursorSprites(CUSTOM_CURSOR_THEME_ID, [], false, settings.cursorCustomTheme), + [settings.cursorCustomTheme], + ); + const { pick: pickCursorFile, input: cursorFileInput } = useCursorFileInput((slot, dataUrl) => { + void set({ + cursor: { + theme: CUSTOM_CURSOR_THEME_ID, + customTheme: { ...settings.cursorCustomTheme, [slot]: dataUrl }, + }, + }); + }); + const removeCustomCursor = (slot?: CustomCursorUploadSlot) => { + const customTheme = slot ? { ...settings.cursorCustomTheme } : {}; + if (slot) delete customTheme[slot]; + void set({ + cursor: { + customTheme, + ...(Object.keys(customTheme).length === 0 && settings.cursorTheme === CUSTOM_CURSOR_THEME_ID + ? { theme: DEFAULT_CURSOR_THEME_ID } + : {}), + }, + }); + }; // Push cursor settings into the native compositor (initial + on view activation); the // handlers below push diffs live. Sizes are sent as direct scales (1 = fixture default). @@ -3699,17 +3809,35 @@ export function CursorPane() { })), [ts], ); + const cursorThemeCells = useMemo( + () => + customCursorUploaded + ? [ + ...cursorThemeOptions, + { + id: CUSTOM_CURSOR_THEME_ID, + name: ts("cursor.themeCustom"), + previewUrl: safeAssetUrl( + customCursorSprites.arrow.assetPath || DEFAULT_CURSOR_SPRITES.arrow.assetPath, + ), + }, + ] + : cursorThemeOptions, + [customCursorSprites.arrow.assetPath, customCursorUploaded, cursorThemeOptions, ts], + ); // What the preview draws for each state: the theme's art, the built-in art where it has none. const cursorSprites = useMemo( - () => resolveCursorSprites(settings.cursorTheme), - [settings.cursorTheme], + () => resolveCursorSprites(settings.cursorTheme, [], false, settings.cursorCustomTheme), + [settings.cursorTheme, settings.cursorCustomTheme], ); const recordedTypes = useRecordedCursorTypes(); const clickSound = useClickSound(); - // Each kind the video shows, pictured by the first of its states it shows. + // Keep the primary types available even when the recording never enters a text field. const recordedKinds = CURSOR_KIND_IDS.flatMap((kind) => { - const type = CURSOR_KINDS[kind].find((state) => recordedTypes?.has(state)); + const type = + CURSOR_KINDS[kind].find((state) => recordedTypes?.has(state)) ?? + (hasDocument && (kind === "pointer" || kind === "text") ? CURSOR_KINDS[kind][0] : undefined); return type ? [{ kind, type }] : []; }); const cursorKindLabels: Record = { @@ -3730,6 +3858,7 @@ export function CursorPane() { icon={} helpText={ts("cursor.help")} > + {cursorFileInput}
{ts("cursor.show")}
{ts("cursor.theme")}
- {cursorThemeOptions.map((option) => { + {cursorThemeCells.map((option) => { const isActive = settings.cursorTheme === option.id; return ( @@ -3787,7 +3916,87 @@ export function CursorPane() { ); })} +
+ {customPanelOpen ? ( +
+ {CUSTOM_CURSOR_UPLOAD_SLOTS.map(({ id, kind }) => { + const label = + id === "arrow" + ? ts("cursor.typeArrow") + : id === "pointer" + ? ts("cursor.typePointer") + : ts("cursor.typeText"); + const uploaded = Boolean(settings.cursorCustomTheme[id]); + const uploadLabel = uploaded ? ts("cursor.replaceCustom", { type: label }) : label; + const removeLabel = ts("cursor.removeCustom", { type: label }); + return ( +
+ + + + {uploaded ? ( + + + + ) : null} +
+ ); + })} + {customCursorUploaded ? ( +
+ + + +
+ ) : null} +
+ ) : null} ) : null} {/* A hidden cursor has nothing to model or redraw, so these rows are not offered then. */} diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 0810409f1..f465b2f9f 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -438,7 +438,13 @@ "autoHideTip": "يتلاشى المؤشر عندما يتوقف عن الحركة", "bounceNone": "بلا", "bounceLight": "خفيف", - "bounceStrong": "قوي" + "bounceStrong": "قوي", + "themeCustom": "مخصص", + "addCustom": "إضافة مؤشر مخصص", + "customPanel": "مؤشر مخصص", + "replaceCustom": "استبدال صورة {{type}}", + "removeCustom": "إزالة صورة {{type}}", + "deleteCustom": "حذف مجموعة المؤشرات المخصصة" }, "export": { "gifButton": "تصدير GIF", diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index 80eb9b268..0963cdf1c 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -438,7 +438,13 @@ "autoHideTip": "Kurzor postupně zmizí, když se přestane hýbat", "bounceNone": "Žádný", "bounceLight": "Lehký", - "bounceStrong": "Výrazný" + "bounceStrong": "Výrazný", + "themeCustom": "Vlastní", + "addCustom": "Přidat vlastní kurzor", + "customPanel": "Vlastní kurzor", + "replaceCustom": "Nahradit obrázek {{type}}", + "removeCustom": "Odstranit obrázek {{type}}", + "deleteCustom": "Smazat vlastní sadu kurzorů" }, "export": { "gifButton": "Exportovat GIF", diff --git a/src/i18n/locales/de/settings.json b/src/i18n/locales/de/settings.json index 715d3c9bb..84667cce9 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -438,7 +438,13 @@ "autoHideTip": "Der Cursor blendet sich aus, wenn er stillsteht", "bounceNone": "Keiner", "bounceLight": "Leicht", - "bounceStrong": "Stark" + "bounceStrong": "Stark", + "themeCustom": "Benutzerdefiniert", + "addCustom": "Eigenen Cursor hinzufügen", + "customPanel": "Benutzerdefinierter Cursor", + "replaceCustom": "Bild für {{type}} ersetzen", + "removeCustom": "Bild für {{type}} entfernen", + "deleteCustom": "Benutzerdefiniertes Cursor-Set löschen" }, "export": { "gifButton": "GIF exportieren", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index be8b13a69..4b4245c9a 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -438,7 +438,13 @@ "autoHideTip": "The cursor fades out when it stops moving", "bounceNone": "None", "bounceLight": "Light", - "bounceStrong": "Strong" + "bounceStrong": "Strong", + "themeCustom": "Custom", + "addCustom": "Add custom cursor", + "customPanel": "Custom cursor", + "replaceCustom": "Replace {{type}} image", + "removeCustom": "Remove {{type}} image", + "deleteCustom": "Delete custom cursor set" }, "export": { "gifButton": "Export GIF", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index f9efcfab3..e5cef2f56 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -438,7 +438,13 @@ "autoHideTip": "El cursor se desvanece cuando deja de moverse", "bounceNone": "Ninguno", "bounceLight": "Suave", - "bounceStrong": "Marcado" + "bounceStrong": "Marcado", + "themeCustom": "Personalizado", + "addCustom": "Añadir cursor personalizado", + "customPanel": "Cursor personalizado", + "replaceCustom": "Reemplazar imagen de {{type}}", + "removeCustom": "Eliminar imagen de {{type}}", + "deleteCustom": "Eliminar conjunto de cursores personalizados" }, "export": { "gifButton": "Exportar GIF", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index cbd053699..9614d2602 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -438,7 +438,13 @@ "autoHideTip": "Le curseur s'estompe quand il ne bouge plus", "bounceNone": "Aucun", "bounceLight": "Léger", - "bounceStrong": "Marqué" + "bounceStrong": "Marqué", + "themeCustom": "Personnalisé", + "addCustom": "Ajouter un curseur personnalisé", + "customPanel": "Curseur personnalisé", + "replaceCustom": "Remplacer l’image {{type}}", + "removeCustom": "Supprimer l’image {{type}}", + "deleteCustom": "Supprimer le jeu de curseurs personnalisés" }, "export": { "gifButton": "Exporter le GIF", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index fd37547e2..9e7a19f24 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -438,7 +438,13 @@ "autoHideTip": "Il cursore svanisce quando smette di muoversi", "bounceNone": "Nessuno", "bounceLight": "Leggero", - "bounceStrong": "Marcato" + "bounceStrong": "Marcato", + "themeCustom": "Personalizzato", + "addCustom": "Aggiungi cursore personalizzato", + "customPanel": "Cursore personalizzato", + "replaceCustom": "Sostituisci immagine {{type}}", + "removeCustom": "Rimuovi immagine {{type}}", + "deleteCustom": "Elimina il set di cursori personalizzati" }, "export": { "gifButton": "Esporta GIF", diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 3b8811256..9b865cc39 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -438,7 +438,13 @@ "autoHideTip": "カーソルは動きが止まると消えます", "bounceNone": "なし", "bounceLight": "弱", - "bounceStrong": "強" + "bounceStrong": "強", + "themeCustom": "カスタム", + "addCustom": "カスタムカーソルを追加", + "customPanel": "カスタムカーソル", + "replaceCustom": "{{type}}の画像を置き換える", + "removeCustom": "{{type}}の画像を削除", + "deleteCustom": "カスタムカーソルセットを削除" }, "export": { "gifButton": "GIF をエクスポート", diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index c464581da..87425b447 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -438,7 +438,13 @@ "autoHideTip": "커서가 멈추면 서서히 사라집니다", "bounceNone": "없음", "bounceLight": "약하게", - "bounceStrong": "강하게" + "bounceStrong": "강하게", + "themeCustom": "사용자 지정", + "addCustom": "사용자 지정 커서 추가", + "customPanel": "사용자 지정 커서", + "replaceCustom": "{{type}} 이미지 교체", + "removeCustom": "{{type}} 이미지 제거", + "deleteCustom": "사용자 지정 커서 세트 삭제" }, "export": { "gifButton": "GIF 내보내기", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index 53a4c89de..c60f6abdb 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -438,7 +438,13 @@ "autoHideTip": "O cursor desaparece quando para de se mover", "bounceNone": "Nenhum", "bounceLight": "Leve", - "bounceStrong": "Forte" + "bounceStrong": "Forte", + "themeCustom": "Personalizado", + "addCustom": "Adicionar cursor personalizado", + "customPanel": "Cursor personalizado", + "replaceCustom": "Substituir imagem de {{type}}", + "removeCustom": "Remover imagem de {{type}}", + "deleteCustom": "Excluir conjunto de cursores personalizados" }, "export": { "gifButton": "Exportar GIF", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 08c1a76c9..c386c875f 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -438,7 +438,13 @@ "autoHideTip": "Курсор исчезает, когда перестаёт двигаться", "bounceNone": "Нет", "bounceLight": "Лёгкий", - "bounceStrong": "Сильный" + "bounceStrong": "Сильный", + "themeCustom": "Пользовательский", + "addCustom": "Добавить свой курсор", + "customPanel": "Пользовательский курсор", + "replaceCustom": "Заменить изображение {{type}}", + "removeCustom": "Удалить изображение {{type}}", + "deleteCustom": "Удалить пользовательский набор курсоров" }, "export": { "gifButton": "Экспорт GIF", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 2f3fd2c67..2c7c18e6f 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -438,7 +438,13 @@ "autoHideTip": "İmleç durduğunda yavaşça kaybolur", "bounceNone": "Yok", "bounceLight": "Hafif", - "bounceStrong": "Belirgin" + "bounceStrong": "Belirgin", + "themeCustom": "Özel", + "addCustom": "Özel imleç ekle", + "customPanel": "Özel imleç", + "replaceCustom": "{{type}} görselini değiştir", + "removeCustom": "{{type}} görselini kaldır", + "deleteCustom": "Özel imleç setini sil" }, "export": { "gifButton": "GIF Olarak Dışa Aktar", diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index ece372040..f25ff8869 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -438,7 +438,13 @@ "autoHideTip": "Con trỏ mờ dần khi ngừng di chuyển", "bounceNone": "Không", "bounceLight": "Nhẹ", - "bounceStrong": "Mạnh" + "bounceStrong": "Mạnh", + "themeCustom": "Tùy chỉnh", + "addCustom": "Thêm con trỏ tùy chỉnh", + "customPanel": "Con trỏ tùy chỉnh", + "replaceCustom": "Thay ảnh {{type}}", + "removeCustom": "Xóa ảnh {{type}}", + "deleteCustom": "Xóa bộ con trỏ tùy chỉnh" }, "export": { "gifButton": "Xuất GIF", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 3aa4b4b48..8e8ec1577 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -438,7 +438,13 @@ "autoHideTip": "光标停止移动后逐渐隐藏", "bounceNone": "无", "bounceLight": "轻", - "bounceStrong": "强" + "bounceStrong": "强", + "themeCustom": "自定义", + "addCustom": "添加自定义光标", + "customPanel": "自定义光标", + "replaceCustom": "替换{{type}}图像", + "removeCustom": "移除{{type}}图像", + "deleteCustom": "删除自定义光标集" }, "export": { "gifButton": "导出 GIF", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index f33f5439d..f2873fb75 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -438,7 +438,13 @@ "autoHideTip": "游標停止移動後會逐漸消失", "bounceNone": "無", "bounceLight": "輕", - "bounceStrong": "強" + "bounceStrong": "強", + "themeCustom": "自訂", + "addCustom": "新增自訂游標", + "customPanel": "自訂游標", + "replaceCustom": "替換{{type}}圖片", + "removeCustom": "移除{{type}}圖片", + "deleteCustom": "刪除自訂游標組" }, "export": { "gifButton": "匯出 GIF", diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts index 7acecab2a..bd8ad346d 100644 --- a/src/lib/ai-edition/store/editorSettings.test.ts +++ b/src/lib/ai-edition/store/editorSettings.test.ts @@ -5,7 +5,7 @@ import { DEFAULT_WEBCAM_LAYOUT_PRESET, DEFAULT_WEBCAM_MASK_SHAPE, } from "@/components/video-editor/types"; -import { DEFAULT_CURSOR_THEME_ID } from "@/lib/cursor/cursorThemes"; +import { CUSTOM_CURSOR_THEME_ID, DEFAULT_CURSOR_THEME_ID } from "@/lib/cursor/cursorThemes"; import { SETTING_BOUNDS } from "@/lib/projectDefaults"; import { ROUNDNESS_SLIDER_MAX_PX } from "@/native/paramUnits"; import type { AxcutDocument } from "../schema"; @@ -49,6 +49,7 @@ describe("getEditorSettings", () => { expect(snap.webcamLayoutPreset).toBe(DEFAULT_WEBCAM_LAYOUT_PRESET); expect(snap.webcamMaskShape).toBe(DEFAULT_WEBCAM_MASK_SHAPE); expect(snap.cursor.size).toBe(DEFAULT_CURSOR_SIZE); + expect(snap.cursorCustomTheme).toEqual({}); }); it("reads every appearance number into its bound, whatever wrote it", () => { @@ -114,6 +115,33 @@ describe("getEditorSettings", () => { expect(snap.backgroundBlur).toBe(0); }); + it("reads and writes uploaded custom cursor sprites", () => { + const dataUrl = "data:image/png;base64,QQ=="; + const doc = patchEditorSettings(baseDoc, { + cursor: { theme: CUSTOM_CURSOR_THEME_ID, customTheme: { pointer: dataUrl } }, + }); + expect(doc.legacyEditor).toMatchObject({ + cursorTheme: CUSTOM_CURSOR_THEME_ID, + cursorCustomTheme: { pointer: dataUrl }, + }); + expect(getEditorSettings(doc).cursorCustomTheme).toEqual({ pointer: dataUrl }); + }); + + it("ignores invalid custom sprite values and unknown states from a project", () => { + const snap = getEditorSettings({ + ...baseDoc, + legacyEditor: { + cursorCustomTheme: { + arrow: "data:image/png;base64,QQ==", + pointer: "/outside/project.png", + text: 42, + unknown: "data:image/png;base64,QQ==", + }, + }, + }); + expect(snap.cursorCustomTheme).toEqual({ arrow: "data:image/png;base64,QQ==" }); + }); + it("reads the old background blur switch as the amount it drew", () => { const legacy = (showBlur: boolean): AxcutDocument => ({ ...baseDoc, diff --git a/src/lib/ai-edition/store/editorSettings.ts b/src/lib/ai-edition/store/editorSettings.ts index 2fc464ad6..00d5e1453 100644 --- a/src/lib/ai-edition/store/editorSettings.ts +++ b/src/lib/ai-edition/store/editorSettings.ts @@ -21,7 +21,11 @@ import { type WebcamPosition, type WebcamSizePreset, } from "@/components/video-editor/types"; -import { normalizeCursorThemeId, readCursorAsArrow } from "@/lib/cursor/cursorThemes"; +import { + type CustomCursorTheme, + normalizeCursorThemeId, + readCursorAsArrow, +} from "@/lib/cursor/cursorThemes"; import { DEFAULT_PROJECT_APPEARANCE, type FrameTheme, @@ -130,6 +134,7 @@ export interface EditorSettingsSnapshot { cursorShow: boolean; cursorAutoHide: boolean; cursorTheme: string; + cursorCustomTheme: CustomCursorTheme; autoFocusAll: boolean; } @@ -145,6 +150,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettingsSnapshot = { webcamCropPan: DEFAULT_CROP_PAN, audioGainDb: 0, formatFollowCursor: null, + cursorCustomTheme: {}, }; interface LegacyShape { @@ -194,6 +200,7 @@ interface LegacyShape { cursorShow?: boolean; cursorAutoHide?: boolean; cursorTheme?: string; + cursorCustomTheme?: unknown; autoFocusAll?: boolean; } function isShape(value: unknown): value is LegacyShape { @@ -210,6 +217,19 @@ function isString(v: unknown): v is string { return typeof v === "string"; } +function readCustomCursorTheme(value: unknown): CustomCursorTheme { + if (!isShape(value)) return {}; + const source = value as Record; + const theme: CustomCursorTheme = {}; + for (const key of ["arrow", "pointer", "text"] as const) { + const asset = source[key]; + if (typeof asset === "string" && asset.startsWith("data:image/")) { + theme[key] = asset; + } + } + return theme; +} + export function getEditorSettings(doc: AxcutDocument | null | undefined): EditorSettingsSnapshot { const legacy = isShape(doc?.legacyEditor) ? (doc.legacyEditor as LegacyShape) : null; const num = (v: unknown, fallback: number) => (isNumber(v) ? v : fallback); @@ -337,6 +357,7 @@ export function getEditorSettings(doc: AxcutDocument | null | undefined): Editor // draws for it anyway. Left raw, the id would also switch off the modelled cursor: the // compositor only builds it for the default theme. cursorTheme: normalizeCursorThemeId(legacy?.cursorTheme), + cursorCustomTheme: readCustomCursorTheme(legacy?.cursorCustomTheme), autoFocusAll: bool(legacy?.autoFocusAll, DEFAULT_EDITOR_SETTINGS.autoFocusAll), }; } @@ -367,7 +388,12 @@ export interface EditorSettingsPatch { webcamBackgroundMode?: WebcamBackgroundMode; webcamWallpaper?: string; webcamBlurIntensity?: number; - cursor?: Partial & { theme?: string; show?: boolean; autoHide?: boolean }; + cursor?: Partial & { + theme?: string; + customTheme?: CustomCursorTheme; + show?: boolean; + autoHide?: boolean; + }; cursorAutoHide?: boolean; autoFocusAll?: boolean; } @@ -402,6 +428,7 @@ function nextLegacy(current: LegacyShape | null, patch: EditorSettingsPatch): Le if (c.clickSound !== undefined) next.cursorClickSound = c.clickSound; if (c.clickSoundGainDb !== undefined) next.cursorClickSoundGainDb = c.clickSoundGainDb; if (c.theme !== undefined) next.cursorTheme = c.theme; + if (c.customTheme !== undefined) next.cursorCustomTheme = c.customTheme; if (c.show !== undefined) next.cursorShow = c.show; if (c.autoHide !== undefined) next.cursorAutoHide = c.autoHide; } diff --git a/src/lib/ai-edition/stylePresets.ts b/src/lib/ai-edition/stylePresets.ts index d7d90b542..eee42a4a2 100644 --- a/src/lib/ai-edition/stylePresets.ts +++ b/src/lib/ai-edition/stylePresets.ts @@ -22,6 +22,7 @@ import { type AspectRatio, isAspectRatio } from "../../utils/aspectRatioUtils"; import { CURSOR_THEME_IDS, type CursorKind, + type CustomCursorTheme, DEFAULT_CURSOR_THEME_ID, readCursorAsArrow, } from "../cursor/cursorThemes"; @@ -76,6 +77,7 @@ export interface StylePresetAppearance { cursorShow: boolean; cursorAutoHide: boolean; cursorTheme: string; + cursorCustomTheme?: CustomCursorTheme; } export interface StylePreset { @@ -303,6 +305,20 @@ export function parseStylePresetAppearance(value: unknown): StylePresetAppearanc if (typeof value.cursorTheme !== "string") { throw new TypeError("Style preset cursorTheme must be a string."); } + const cursorCustomTheme: CustomCursorTheme = {}; + if (value.cursorCustomTheme !== undefined) { + if (!isRecord(value.cursorCustomTheme)) { + throw new TypeError("Style preset cursorCustomTheme must be an object."); + } + for (const type of ["arrow", "pointer", "text"] as const) { + const image = value.cursorCustomTheme[type]; + if (image === undefined) continue; + if (typeof image !== "string" || !image.startsWith("data:image/")) { + throw new TypeError(`Style preset cursorCustomTheme.${type} must be an image data URL.`); + } + cursorCustomTheme[type] = parseStylePresetWallpaper(image, `cursorCustomTheme.${type}`); + } + } return { wallpaper: parseStylePresetWallpaper(value.wallpaper), wallpaperMotion: @@ -356,6 +372,7 @@ export function parseStylePresetAppearance(value: unknown): StylePresetAppearanc cursorTheme: CURSOR_THEME_IDS.has(value.cursorTheme) ? value.cursorTheme : DEFAULT_CURSOR_THEME_ID, + ...(Object.keys(cursorCustomTheme).length > 0 ? { cursorCustomTheme } : {}), }; } @@ -450,6 +467,7 @@ export const LOOK_LEGACY_EDITOR_KEYS = [ "cursorShow", "cursorAutoHide", "cursorTheme", + "cursorCustomTheme", ] as const; /** A preset's appearance as `legacyEditor` fields, format left out. */ diff --git a/src/lib/ai-edition/stylePresetsEditor.test.ts b/src/lib/ai-edition/stylePresetsEditor.test.ts index 0716284e1..a7bbb01f6 100644 --- a/src/lib/ai-edition/stylePresetsEditor.test.ts +++ b/src/lib/ai-edition/stylePresetsEditor.test.ts @@ -115,6 +115,35 @@ describe("stylePresetsEditor", () => { expect(getEditorSettings(patchEditorSettings(vertical, patch)).aspectRatio).toBe("16:9"); }); + it("keeps custom cursor images in a serialized preset and in an inherited project look", () => { + const customTheme = { arrow: "data:image/png;base64,YXJyb3c=" }; + const source = patchEditorSettings(createEmptyDocument({ projectId: "a", title: "A" }), { + cursor: { theme: "custom", customTheme }, + }); + const appearance = stylePresetAppearanceFromSettings(getEditorSettings(source)); + const reloaded = parseStylePresetAppearance(JSON.parse(JSON.stringify(appearance))); + expect(reloaded.cursorCustomTheme).toEqual(customTheme); + const target = patchEditorSettings( + createEmptyDocument({ projectId: "b", title: "B" }), + stylePresetPatch(reloaded), + ); + expect(getEditorSettings(target).cursorCustomTheme).toEqual(customTheme); + expect( + getEditorSettings({ ...source, legacyEditor: stylePresetLegacyEditor(reloaded) }) + .cursorCustomTheme, + ).toEqual(customTheme); + expect( + getEditorSettings({ ...source, legacyEditor: lookFromLegacyEditor(source.legacyEditor) }) + .cursorCustomTheme, + ).toEqual(customTheme); + expect(() => + parseStylePresetAppearance({ + ...appearance, + cursorCustomTheme: { arrow: "/outside/project.png" }, + }), + ).toThrow("image data URL"); + }); + it("tells two looks apart by their format alone", () => { const look = stylePresetAppearanceFromSettings(styledSettings()); expect(sameStylePresetLook(look, { ...look, aspectRatio: "1:1" })).toBe(false); @@ -138,7 +167,9 @@ describe("new-project look (main-process side)", () => { // Every look key but the old always-arrow switch: only a project saved before the cursor // kinds holds that one. expect(Object.keys(legacy).sort()).toEqual( - LOOK_LEGACY_EDITOR_KEYS.filter((key) => key !== "cursorAlwaysArrow").sort(), + LOOK_LEGACY_EDITOR_KEYS.filter( + (key) => key !== "cursorAlwaysArrow" && key !== "cursorCustomTheme", + ).sort(), ); const read = getEditorSettings(docWith(legacy)); expect(stylePresetAppearanceFromSettings(read)).toEqual({ diff --git a/src/lib/ai-edition/stylePresetsEditor.ts b/src/lib/ai-edition/stylePresetsEditor.ts index 1b7086e3a..786f025d1 100644 --- a/src/lib/ai-edition/stylePresetsEditor.ts +++ b/src/lib/ai-edition/stylePresetsEditor.ts @@ -8,6 +8,7 @@ import { type EditorSettingsSnapshot, } from "@/lib/ai-edition/store/editorSettings"; import type { StylePresetAppearance } from "@/lib/ai-edition/stylePresets"; +import { CUSTOM_CURSOR_THEME_ID } from "@/lib/cursor/cursorThemes"; /** Copies the appearance fields out of a settings snapshot — nothing footage-dependent. */ export function stylePresetAppearanceFromSettings( @@ -48,6 +49,10 @@ export function stylePresetAppearanceFromSettings( cursorShow: settings.cursorShow, cursorAutoHide: settings.cursorAutoHide, cursorTheme: settings.cursorTheme, + ...(settings.cursorTheme === CUSTOM_CURSOR_THEME_ID && + Object.keys(settings.cursorCustomTheme).length > 0 + ? { cursorCustomTheme: { ...settings.cursorCustomTheme } } + : {}), }; } @@ -112,6 +117,7 @@ export function stylePresetPatch(appearance: StylePresetAppearance): EditorSetti cursor: { ...appearance.cursor, theme: appearance.cursorTheme, + ...(appearance.cursorCustomTheme ? { customTheme: appearance.cursorCustomTheme } : {}), show: appearance.cursorShow, autoHide: appearance.cursorAutoHide, }, diff --git a/src/lib/cursor/cursorThemes.test.ts b/src/lib/cursor/cursorThemes.test.ts index 63707c75e..637d9d455 100644 --- a/src/lib/cursor/cursorThemes.test.ts +++ b/src/lib/cursor/cursorThemes.test.ts @@ -6,6 +6,7 @@ import { CURSOR_KIND_IDS, CURSOR_KINDS, CURSOR_THEMES, + CUSTOM_CURSOR_THEME_ID, type CursorTheme, DEFAULT_CURSOR_SPRITES, DEFAULT_CURSOR_THEME_ID, @@ -48,6 +49,10 @@ describe("normalizeCursorThemeId", () => { expect(normalizeCursorThemeId(42)).toBe(DEFAULT_CURSOR_THEME_ID); }); + it("keeps the custom upload theme id", () => { + expect(normalizeCursorThemeId(CUSTOM_CURSOR_THEME_ID)).toBe(CUSTOM_CURSOR_THEME_ID); + }); + it.each(CURSOR_THEMES)("keeps the original $name theme", (theme) => { expect(normalizeCursorThemeId(theme.id)).toBe(theme.id); const sprites = resolveCursorSprites(theme.id); @@ -212,4 +217,30 @@ describe("themePickerPreviewAssets", () => { expect(preview.pointer).toBeNull(); } }); + + it("applies uploaded custom sprites only to the uploaded cursor states", () => { + const arrow = "data:image/png;base64,QQ=="; + const sprites = resolveCursorSprites(CUSTOM_CURSOR_THEME_ID, [], false, { arrow }); + expect(sprites.arrow.assetPath).toBe(arrow); + expect(sprites.arrow.hotspotX).toBe(DEFAULT_CURSOR_SPRITES.arrow.hotspotX); + expect(sprites.pointer.assetPath).toBe(DEFAULT_CURSOR_SPRITES.pointer.assetPath); + }); + + it("keeps custom artwork while switching kinds to the arrow, with no bundled model", () => { + const arrow = "data:image/png;base64,YXJyb3c="; + const pointer = "data:image/png;base64,aGFuZA=="; + const text = "data:image/png;base64,dGV4dA=="; + const sprites = resolveCursorSprites(CUSTOM_CURSOR_THEME_ID, ["pointer"], true, { + arrow, + pointer, + text, + }); + expect(sprites.pointer).toEqual(sprites.arrow); + expect(sprites.text.assetPath).toBe(text); + expect(sprites.arrow.sculpt).toBeUndefined(); + expect(sprites.arrow.glass).toBeUndefined(); + expect(resolveCursorSprites(DEFAULT_CURSOR_THEME_ID, [], false, { arrow }).arrow).toEqual( + DEFAULT_CURSOR_SPRITES.arrow, + ); + }); }); diff --git a/src/lib/cursor/cursorThemes.ts b/src/lib/cursor/cursorThemes.ts index 88bd6a260..98636ef22 100644 --- a/src/lib/cursor/cursorThemes.ts +++ b/src/lib/cursor/cursorThemes.ts @@ -38,6 +38,10 @@ export interface CursorTheme { /** Sentinel id for the built-in cursor art (no theme override). */ export const DEFAULT_CURSOR_THEME_ID = "default"; +/** Sentinel id for the user's uploaded cursor art. */ +export const CUSTOM_CURSOR_THEME_ID = "custom"; + +export type CustomCursorTheme = Partial>; /** * One sprite as the native compositor consumes it: a path under the public asset root, @@ -289,6 +293,7 @@ export const CURSOR_THEMES: readonly CursorTheme[] = [ /** All selectable theme ids, including the built-in default. */ export const CURSOR_THEME_IDS: ReadonlySet = new Set([ DEFAULT_CURSOR_THEME_ID, + CUSTOM_CURSOR_THEME_ID, ...CURSOR_THEMES.map((theme) => theme.id), ]); @@ -312,7 +317,7 @@ export function themePickerPreviewAssets(theme: CursorTheme | null): { /** Returns the theme for `id`, or null for the default / unknown ids. */ export function getCursorTheme(id: string | null | undefined): CursorTheme | null { - if (!id || id === DEFAULT_CURSOR_THEME_ID) { + if (!id || id === DEFAULT_CURSOR_THEME_ID || id === CUSTOM_CURSOR_THEME_ID) { return null; } return CURSOR_THEMES.find((theme) => theme.id === id) ?? null; @@ -340,6 +345,7 @@ export function resolveCursorSprites( themeId: string | null | undefined, asArrow: readonly CursorKind[] = [], model3d = false, + customTheme: CustomCursorTheme | null | undefined = null, ): Record { const sprites = { ...DEFAULT_CURSOR_SPRITES }; for (const [type, asset] of Object.entries(getCursorTheme(themeId)?.assets ?? {})) { @@ -356,6 +362,19 @@ export function resolveCursorSprites( ...(asset.glass ? { glass: `${themeId}/${type}` } : {}), }; } + if (themeId === CUSTOM_CURSOR_THEME_ID && customTheme) { + for (const type of ["arrow", "pointer", "text"] as const) { + const assetPath = customTheme[type]; + if (typeof assetPath !== "string" || !assetPath.startsWith("data:image/")) { + continue; + } + sprites[type] = { + ...DEFAULT_CURSOR_SPRITES[type], + assetPath, + sculpt: undefined, + }; + } + } // A kind drawn as the arrow takes the arrow's sprite, with the theme's art and its 3D model. // Done on the table the compositor reads, so it needs no mode of its own. for (const kind of asArrow) { diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index 0d84461b5..3a701333a 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -1391,6 +1391,18 @@ describe("buildSceneDescription.settings mapping", () => { expect(cursor.asArrow).toEqual(["pointer", "text"]); }); + it("carries project-owned custom cursor images into the native scene", () => { + const customTheme = { arrow: "data:image/png;base64,YXJyb3c=" }; + const scene = buildSceneDescription( + makeDoc({ + legacyEditor: { cursorTheme: "custom", cursorCustomTheme: customTheme }, + }), + ); + expect(scene.cursor.theme).toBe("custom"); + expect(scene.cursor.customTheme).toEqual(customTheme); + expect(buildSceneDescription(makeDoc({})).cursor.customTheme).toBeUndefined(); + }); + // The single switch that came before the kinds: on drew every one of them as the arrow. With // neither, only the hand keeps its own sprite. it("reads the old always-arrow switch as every cursor kind", () => { diff --git a/src/native/sceneDescription.ts b/src/native/sceneDescription.ts index 5cf2a8e5d..e8a40e81a 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -60,7 +60,7 @@ import { resolveWebcamReactiveZoom, webcamSizeToFraction, } from "@/lib/compositeLayout"; -import type { CursorKind } from "@/lib/cursor/cursorThemes"; +import type { CursorKind, CustomCursorTheme } from "@/lib/cursor/cursorThemes"; import { parseCssGradient, resolveLinearGradientAngle } from "@/lib/exporter/gradientParser"; import type { FrameTheme, RecordingFrame, WebcamAnchor } from "@/lib/projectDefaults"; import { resolveTextFontFamily } from "@/lib/textFonts"; @@ -459,6 +459,8 @@ export interface SceneCursor { clipToBounds: boolean; /** Cursor theme id (sprite set). */ theme: string; + /** User-uploaded sprites for the custom cursor theme. */ + customTheme?: CustomCursorTheme; } /** Everything native needs to compose the scene, serialized from one document. */ @@ -1307,6 +1309,9 @@ export function buildSceneDescription( // background. The field stays because the compositor's scene requires it. clipToBounds: false, theme: settings.cursorTheme, + ...(Object.keys(settings.cursorCustomTheme).length > 0 + ? { customTheme: settings.cursorCustomTheme } + : {}), }, audio: { gainDb: settings.audioGainDb, diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 256a111de..e22e5ea20 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -811,6 +811,7 @@ On macOS 15.2+ it opens at launch, until it has been closed once, while one of i | Date | Build / tag | Platform | Pass/fail | Notes | |------|-------------|----------|-----------|-------| +| 2026-10-10 | local packaged `2.0.0`, `codex/custom-cursor-upload` based on `82ea9ea0`; rebuilt current Metal compositor and Swift capture helpers; ad-hoc signed arm64 QA app | macOS 26.7.1 (25G241), Apple Silicon | Partial: custom cursor slice passed | Real OS input through computer-use, separate QA userData, synthetic 3 s 640x480 H.264 fixture with arrow/pointer/text telemetry. Passed: image-only upload panel, one-image upload with default fallbacks, replacing the arrow without creating another set, adding hand/text images, removing one image, deleting the set with default selection restored, Undo after both deletions, Escape and outside-click dismissal. Saved and quit/relaunched the app; all three embedded images and the selected custom style survived and rendered in native preview. Saved a style preset and inspected its embedded cursor data. Native MP4 (640x480, 60 fps) and GIF (640x480, 15 fps) exports completed; decoded frames at 0.5/1.5/2.5 s showed the independently uploaded arrow/hand/text art. QA screenshots inspected; test app stopped and test preset moved out of the normal presets folder. Skipped: fresh screen/audio/webcam capture, 3D cursor comparison, other editor features, Windows/Linux and release promotion; this pass targets cursor upload/manage/persistence/export only. | | 2026-09-23 | dev `fix/725-hidden-cursor-auto-zoom`; rebuilt compositor addon SHA256 `a0bcd61e17084c010141049a9978121e45be2985449ff1618ab6a3145f8381f6` | Windows 11 Home 26200 | Partial — issue #725 slider slice passed | Human-driven real recording in the Electron dev build: moving a slider whose application cursor becomes hidden kept the reconstructed cursor hidden in the Studio recording and did not create a click-driven automatic zoom for that press. A visible control click still produced the normal automatic zoom. Sidecar inspection independently confirmed the hidden interval (`visible: false`) and that no zoom was centred on the press immediately preceding it. Recording, stop and editor open passed. Not covered: export, packaged build, audio/webcam, tray/HUD, other editor features, macOS or Linux. | | 2026-09-12 | dev `fix/643-gif-export-cancel`, based on `fbe461e9`; rebuilt compositor addon SHA256 `128c89fe65a459b3f17721d610ea9a78e0d3e980e9eb0b5fb2b0e5d018619e15` | Windows | Partial — export cancellation passed | Computer-use OS input, real Electron and native save dialog, imported synthetic 8 s source. GIF 15 FPS / Medium (720p) / loop on: observed 55/120 frames, clicked Cancel while rendering (pending snapshot 104/120), returned to the same options; no final or partial output remained. Retried in that dialog to the same path and received Saved to; resulting GIF is 1280×720, 120 frames, 8,200,202 bytes, fully decodes with ffmpeg (exit 0). Automated real-addon tests separately pass existing-destination preservation, GIF retry, ordinary GIF and MP4 export. Desktop adapter required screenshot-based coordinates and Ctrl+O to reach the editor; HUD reachability is not claimed. An unrelated process error window partly obscured the retry success screenshot; file verification independently passed. Skipped: recording, audio/mic, webcam, tray/HUD, effect/caption comparisons, second viewer, macOS/Linux and release promotion. Task-local profile and evidence retained; test app stopped. | | 2026-07-31 | dev build, `claude/e2e-tests-v1-8-0-474894` (e9578f09) | macOS 26.5, M1 | Partial — 1 defect | Ran launch/HUD, media, modifier anchoring, and export. **Defect: a dangling asset blanks the preview.** Modifier anchoring across a reorder verified in preview and in the exported frames. macOS export produced 1280×720 h264 + AAC at ~2× realtime. Chat sections skipped: no AI provider configured. HUD drag not runnable under computer-use (drop point is the desktop). | From e1bac39912aa2a31facf611411c09e87c5473f2c Mon Sep 17 00:00:00 2001 From: Jonas Prokop Date: Sat, 10 Oct 2026 14:48:53 +0200 Subject: [PATCH 2/7] feat: support multiple numbered custom cursor sets --- electron/ai-edition/document-service.test.ts | 21 ++++- .../ai-edition/CursorPane.preview.test.tsx | 81 +++++++++++++++---- src/components/ai-edition/RightPanes.tsx | 75 ++++++++++------- .../ai-edition/store/editorSettings.test.ts | 60 +++++++++++++- src/lib/ai-edition/store/editorSettings.ts | 73 ++++++++++++++++- src/lib/ai-edition/stylePresets.ts | 8 +- src/lib/ai-edition/stylePresetsEditor.test.ts | 21 ++++- src/lib/ai-edition/stylePresetsEditor.ts | 4 +- src/lib/cursor/cursorThemes.ts | 19 ++++- .../testing/manual-e2e-checklist.md | 2 +- 10 files changed, 302 insertions(+), 62 deletions(-) diff --git a/electron/ai-edition/document-service.test.ts b/electron/ai-edition/document-service.test.ts index f6cb9f078..7f5ca7ad1 100644 --- a/electron/ai-edition/document-service.test.ts +++ b/electron/ai-edition/document-service.test.ts @@ -57,13 +57,32 @@ describe("DocumentService", () => { ).toEqual(customTheme); await service.saveProject(patchEditorSettings(reopened, { cursor: { theme: "default" } })); const switched = await service.getProject(created.project.id); - expect(getEditorSettings(switched).cursorCustomTheme).toEqual(customTheme); + expect(getEditorSettings(switched).cursorCustomThemes[0].images).toEqual(customTheme); await service.saveProject(patchEditorSettings(switched, { cursor: { customTheme: {} } })); expect( getEditorSettings(await service.getProject(created.project.id)).cursorCustomTheme, ).toEqual({}); }); + it("saves and reopens five independent custom cursor sets", async () => { + let doc = await service.createProject("Five cursor sets"); + for (let number = 1; number <= 5; number++) { + doc = patchEditorSettings(doc, { + cursor: { + theme: `custom:pack-${number}`, + customTheme: { arrow: `data:image/png;base64,${number}` }, + }, + }); + } + await service.saveProject(doc); + const restored = await new DocumentService(tempDir, mediaDir).getProject(doc.project.id); + expect(getEditorSettings(restored).cursorCustomThemes).toEqual( + getEditorSettings(doc).cursorCustomThemes, + ); + expect(getEditorSettings(restored).cursorCustomThemes).toHaveLength(5); + expect(getEditorSettings(restored).cursorTheme).toBe("custom:pack-5"); + }); + describe("createProject", () => { it("creates a v8 doc with the given title and writes it to disk", async () => { const doc = await service.createProject("Demo Project"); diff --git a/src/components/ai-edition/CursorPane.preview.test.tsx b/src/components/ai-edition/CursorPane.preview.test.tsx index 81649136d..a3a36258e 100644 --- a/src/components/ai-edition/CursorPane.preview.test.tsx +++ b/src/components/ai-edition/CursorPane.preview.test.tsx @@ -126,7 +126,7 @@ describe("CursorPane theme picker", () => { await upload("Arrow", "arrow.PNG", "arrow-image"); const arrow = getEditorSettings(useProjectStore.getState().document).cursorCustomTheme.arrow; expect(arrow).toMatch(/^data:image\/png;base64,/); - expect(screen.getByRole("button", { name: "Custom" }).getAttribute("aria-pressed")).toBe( + expect(screen.getByRole("button", { name: "Custom 1" }).getAttribute("aria-pressed")).toBe( "true", ); await upload("Hand", "hand.png", "hand-image"); @@ -140,14 +140,16 @@ describe("CursorPane theme picker", () => { expect(replaced.arrow).not.toBe(arrow); expect(replaced.pointer).toBe(theme.pointer); expect(replaced.text).toBe(theme.text); - expect(screen.getAllByRole("button", { name: "Custom" })).toHaveLength(1); + expect(screen.getAllByRole("button", { name: "Custom 1" })).toHaveLength(1); fireEvent.click(screen.getByRole("button", { name: "Default" })); await waitFor(() => expect(getEditorSettings(useProjectStore.getState().document).cursorTheme).toBe("default"), ); - fireEvent.click(screen.getByRole("button", { name: "Custom" })); + fireEvent.click(screen.getByRole("button", { name: "Custom 1" })); await waitFor(() => - expect(getEditorSettings(useProjectStore.getState().document).cursorTheme).toBe("custom"), + expect(getEditorSettings(useProjectStore.getState().document).cursorTheme).toMatch( + /^custom:/, + ), ); expect(getEditorSettings(useProjectStore.getState().document).cursorCustomTheme).toEqual( replaced, @@ -158,7 +160,7 @@ describe("CursorPane theme picker", () => { const arrow = "data:image/png;base64,YXJyb3c="; const pointer = "data:image/png;base64,aGFuZA=="; renderWithProject({ cursorTheme: "custom", cursorCustomTheme: { arrow, pointer } }); - fireEvent.click(screen.getByRole("button", { name: "Add custom cursor" })); + fireEvent.click(screen.getByRole("button", { name: "Custom 1" })); fireEvent.click(screen.getByRole("button", { name: "Remove Hand image" })); await waitFor(() => { const settings = getEditorSettings(useProjectStore.getState().document); @@ -173,22 +175,19 @@ describe("CursorPane theme picker", () => { ).toBeTruthy(); }); - it.each([ - "custom", - "studio-ink", - ])("removes the last custom image safely while %s is selected", async (selected) => { + it("removes the last custom image safely", async () => { renderWithProject({ - cursorTheme: selected, + cursorTheme: "custom", cursorCustomTheme: { text: "data:image/png;base64,dGV4dA==" }, }); - fireEvent.click(screen.getByRole("button", { name: "Add custom cursor" })); + fireEvent.click(screen.getByRole("button", { name: "Custom 1" })); fireEvent.click(screen.getByRole("button", { name: "Remove Text image" })); await waitFor(() => { const settings = getEditorSettings(useProjectStore.getState().document); expect(settings.cursorCustomTheme).toEqual({}); - expect(settings.cursorTheme).toBe(selected === "custom" ? "default" : selected); + expect(settings.cursorTheme).toBe("default"); }); - expect(screen.queryByRole("button", { name: "Custom" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Custom 1" })).toBeNull(); expect(screen.queryByRole("button", { name: "Delete custom cursor set" })).toBeNull(); }); @@ -200,18 +199,70 @@ describe("CursorPane theme picker", () => { pointer: "data:image/png;base64,aGFuZA==", }, }); - fireEvent.click(screen.getByRole("button", { name: "Add custom cursor" })); + fireEvent.click(screen.getByRole("button", { name: "Custom 1" })); fireEvent.click(screen.getByRole("button", { name: "Delete custom cursor set" })); await waitFor(() => expect(getEditorSettings(useProjectStore.getState().document).cursorCustomTheme).toEqual({}), ); expect(getEditorSettings(useProjectStore.getState().document).cursorTheme).toBe("default"); - expect(screen.queryByRole("button", { name: "Custom" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Custom 1" })).toBeNull(); expect( within(screen.getByRole("group", { name: "Custom cursor" })).getAllByRole("button"), ).toHaveLength(3); }); + it.each([ + "en", + "de", + ])("keeps five independent sets and localized stable numbers in %s", async (locale) => { + window.localStorage.setItem(LOCALE_STORAGE_KEY, locale); + const { container } = renderWithProject(); + const addLabel = locale === "de" ? "Eigenen Cursor hinzufügen" : "Add custom cursor"; + const name = (number: number) => + `${locale === "de" ? "Benutzerdefiniert" : "Custom"} ${number}`; + const input = container.querySelector('input[type="file"]'); + if (!input) throw new Error("Missing cursor upload input"); + for (let number = 1; number <= 5; number++) { + fireEvent.click(screen.getByRole("button", { name: addLabel })); + fireEvent.click(screen.getByRole("button", { name: locale === "de" ? "Pfeil" : "Arrow" })); + fireEvent.change(input, { target: { files: [new File([`image-${number}`], "arrow.png")] } }); + await waitFor(() => expect(screen.getByRole("button", { name: name(number) })).toBeTruthy()); + } + const original = getEditorSettings(useProjectStore.getState().document).cursorCustomThemes; + expect(original).toHaveLength(5); + expect(new Set(original.map((entry) => entry.id)).size).toBe(5); + fireEvent.click(screen.getByRole("button", { name: name(2) })); + fireEvent.click( + screen.getByRole("button", { + name: locale === "de" ? "Bild für Pfeil ersetzen" : "Replace Arrow image", + }), + ); + fireEvent.change(input, { target: { files: [new File(["replacement"], "arrow.png")] } }); + await waitFor(() => + expect( + getEditorSettings(useProjectStore.getState().document).cursorCustomTheme.arrow, + ).not.toBe(original[1].images.arrow), + ); + expect( + getEditorSettings(useProjectStore.getState().document).cursorCustomThemes.filter( + (entry) => entry.id !== original[1].id, + ), + ).toEqual(original.filter((entry) => entry.id !== original[1].id)); + fireEvent.click( + screen.getByRole("button", { + name: + locale === "de" ? "Benutzerdefiniertes Cursor-Set löschen" : "Delete custom cursor set", + }), + ); + await waitFor(() => expect(screen.queryByRole("button", { name: name(2) })).toBeNull()); + for (const number of [1, 3, 4, 5]) + expect(screen.getByRole("button", { name: name(number) })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: name(1) })); + expect(getEditorSettings(useProjectStore.getState().document).cursorCustomTheme).toEqual( + original[0].images, + ); + }); + it("rejects unsupported files without changing the project", () => { const error = vi.spyOn(toast, "error"); const { container } = renderWithProject(); diff --git a/src/components/ai-edition/RightPanes.tsx b/src/components/ai-edition/RightPanes.tsx index 4bbfc03eb..0d7e6b15d 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -112,10 +112,10 @@ import { CURSOR_KIND_IDS, CURSOR_KINDS, CURSOR_THEMES, - CUSTOM_CURSOR_THEME_ID, type CursorKind, DEFAULT_CURSOR_SPRITES, DEFAULT_CURSOR_THEME_ID, + isCustomCursorThemeId, resolveCursorSprites, themePickerPreviewAssets, } from "@/lib/cursor/cursorThemes"; @@ -3742,7 +3742,8 @@ function useCursorFileInput(onPicked: (slot: CustomCursorUploadSlot, dataUrl: st export function CursorPane() { const ts = useScopedT("settings"); const { settings, set, setLive, commit, hasDocument } = useEditorSettings(); - const [customPanelOpen, setCustomPanelOpen] = useState(false); + const [customPanelTarget, setCustomPanelTarget] = useState(null); + const customPanelOpen = customPanelTarget !== null; const customPanelRef = useRef(null); const customButtonRef = useRef(null); useEffect(() => { @@ -3753,10 +3754,10 @@ export function CursorPane() { !customPanelRef.current?.contains(event.target) && !customButtonRef.current?.contains(event.target) ) - setCustomPanelOpen(false); + setCustomPanelTarget(null); }; const dismissOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") setCustomPanelOpen(false); + if (event.key === "Escape") setCustomPanelTarget(null); }; document.addEventListener("pointerdown", dismissOutside); document.addEventListener("keydown", dismissOnEscape); @@ -3765,26 +3766,31 @@ export function CursorPane() { document.removeEventListener("keydown", dismissOnEscape); }; }, [customPanelOpen]); - const customCursorUploaded = Object.keys(settings.cursorCustomTheme).length > 0; - const customCursorSprites = useMemo( - () => resolveCursorSprites(CUSTOM_CURSOR_THEME_ID, [], false, settings.cursorCustomTheme), - [settings.cursorCustomTheme], - ); + const panelImages = + settings.cursorCustomThemes.find((set) => set.id === customPanelTarget)?.images ?? {}; + const customCursorUploaded = Object.keys(panelImages).length > 0; const { pick: pickCursorFile, input: cursorFileInput } = useCursorFileInput((slot, dataUrl) => { + if (!customPanelTarget) return; void set({ cursor: { - theme: CUSTOM_CURSOR_THEME_ID, - customTheme: { ...settings.cursorCustomTheme, [slot]: dataUrl }, + theme: customPanelTarget, + customTheme: { ...panelImages, [slot]: dataUrl }, }, }); }); const removeCustomCursor = (slot?: CustomCursorUploadSlot) => { - const customTheme = slot ? { ...settings.cursorCustomTheme } : {}; + const customTheme = slot ? { ...panelImages } : {}; if (slot) delete customTheme[slot]; void set({ cursor: { - customTheme, - ...(Object.keys(customTheme).length === 0 && settings.cursorTheme === CUSTOM_CURSOR_THEME_ID + customThemes: settings.cursorCustomThemes.flatMap((entry) => + entry.id === customPanelTarget + ? Object.keys(customTheme).length + ? [{ ...entry, images: customTheme }] + : [] + : [entry], + ), + ...(Object.keys(customTheme).length === 0 && settings.cursorTheme === customPanelTarget ? { theme: DEFAULT_CURSOR_THEME_ID } : {}), }, @@ -3810,20 +3816,20 @@ export function CursorPane() { [ts], ); const cursorThemeCells = useMemo( - () => - customCursorUploaded - ? [ - ...cursorThemeOptions, - { - id: CUSTOM_CURSOR_THEME_ID, - name: ts("cursor.themeCustom"), - previewUrl: safeAssetUrl( - customCursorSprites.arrow.assetPath || DEFAULT_CURSOR_SPRITES.arrow.assetPath, - ), - }, - ] - : cursorThemeOptions, - [customCursorSprites.arrow.assetPath, customCursorUploaded, cursorThemeOptions, ts], + () => [ + ...cursorThemeOptions, + ...settings.cursorCustomThemes.map((entry) => ({ + id: entry.id, + name: `${ts("cursor.themeCustom")} ${entry.number}`, + previewUrl: safeAssetUrl( + entry.images.arrow ?? + entry.images.pointer ?? + entry.images.text ?? + DEFAULT_CURSOR_SPRITES.arrow.assetPath, + ), + })), + ], + [settings.cursorCustomThemes, cursorThemeOptions, ts], ); // What the preview draws for each state: the theme's art, the built-in art where it has none. @@ -3904,7 +3910,10 @@ export function CursorPane() { aria-label={option.name} aria-pressed={isActive} disabled={!hasDocument} - onClick={() => void set({ cursor: { theme: option.id } })} + onClick={() => { + void set({ cursor: { theme: option.id } }); + setCustomPanelTarget(isCustomCursorThemeId(option.id) ? option.id : null); + }} > setCustomPanelOpen((open) => !open)} + onClick={() => + setCustomPanelTarget( + customPanelOpen && !customCursorUploaded ? null : `custom:${crypto.randomUUID()}`, + ) + } >