diff --git a/electron/ai-edition/document-service.test.ts b/electron/ai-edition/document-service.test.ts index 3a2e6d0ac..a27addc3f 100644 --- a/electron/ai-edition/document-service.test.ts +++ b/electron/ai-edition/document-service.test.ts @@ -37,6 +37,53 @@ 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).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 with their hotspots", async () => { + let doc = await service.createProject("Five cursor sets"); + for (let number = 1; number <= 5; number++) { + doc = patchEditorSettings(doc, { + cursor: { + theme: `custom:pack-${number}`, + customHotspots: { arrow: { x: number / 10, y: 0.5 } }, + 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/electron/native-bridge/services/compositorViewService.test.ts b/electron/native-bridge/services/compositorViewService.test.ts index 01817e826..f8b838a7e 100644 --- a/electron/native-bridge/services/compositorViewService.test.ts +++ b/electron/native-bridge/services/compositorViewService.test.ts @@ -801,6 +801,21 @@ 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 }, + customHotspots: { pointer: { x: 0.2, y: 0.4 } }, + }, + }).cursor.cursorSprites; + + expect(sprites.pointer.path).toBe(dataUrl); + expect(sprites.pointer).toMatchObject({ hotspotX: 0.2, hotspotY: 0.4 }); + 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..c6137dc64 100644 --- a/electron/native-bridge/services/compositorViewService.ts +++ b/electron/native-bridge/services/compositorViewService.ts @@ -5,6 +5,8 @@ import { fileURLToPath } from "node:url"; import { app, sharedTexture, type WebFrameMain } from "electron"; import { type CursorKind, + type CustomCursorHotspots, + type CustomCursorTheme, readCursorAsArrow, resolveCursorSprites, } from "../../../src/lib/cursor/cursorThemes"; @@ -121,10 +123,16 @@ function resolveCursorSpritePaths( themeId: string, asArrow: readonly CursorKind[], model3d = false, + customTheme?: CustomCursorTheme, + customHotspots?: CustomCursorHotspots, ): 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, customHotspots), + )) { + const absolute = sprite.assetPath.startsWith("data:image/") + ? sprite.assetPath + : resolveSceneAssetPath(sprite.assetPath); if (absolute) { resolved[type] = { path: absolute, @@ -151,6 +159,8 @@ export function resolveSceneAssetPaths(sceneJson: string): string { theme?: string; asArrow?: unknown; model3d?: boolean; + customTheme?: CustomCursorTheme; + customHotspots?: CustomCursorHotspots; cursorSprites?: Record; }; webcamEffect?: { @@ -187,6 +197,8 @@ export function resolveSceneAssetPaths(sceneJson: string): string { scene.cursor.theme, readCursorAsArrow(scene.cursor.asArrow, undefined, []), scene.cursor.model3d === true, + scene.cursor.customTheme, + scene.cursor.customHotspots, ); changed = true; } diff --git a/src/components/ai-edition/CursorHotspotEditor.module.css b/src/components/ai-edition/CursorHotspotEditor.module.css new file mode 100644 index 000000000..57e145e34 --- /dev/null +++ b/src/components/ai-edition/CursorHotspotEditor.module.css @@ -0,0 +1,26 @@ +.overlay { position: fixed; inset: 0; z-index: 9999; background: var(--overlay-dark); backdrop-filter: blur(4px); } +.dialog { position: fixed; z-index: 10000; left: 50%; top: 50%; transform: translate(-50%, -50%); margin: 0; width: min(720px, calc(100vw - 32px)); max-height: calc(100vh - 32px); } +.views { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 24px; } +.view { min-width: 0; } +.view h3 { margin: 0 0 12px; font: 600 13px/1.4 var(--font-body); } +.imageStage { height: 280px; display: flex; align-items: center; justify-content: center; } +.imagePlane { position: relative; max-width: 100%; padding: 0; border: 0; cursor: crosshair; touch-action: none; background-color: var(--surface); background-image: conic-gradient(var(--surface-3) 25%, transparent 0 50%, var(--surface-3) 0 75%, transparent 0); background-size: 20px 20px; } +.imagePlane img { display: block; width: 100%; height: 100%; pointer-events: none; } +.marker { position: absolute; transform: translate(-50%, -50%); color: var(--accent); filter: drop-shadow(0 0 1px white) drop-shadow(0 0 1px black); pointer-events: none; } +.coordinates { display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap; margin-top: 12px; } +.coordinates label { display: grid; gap: 4px; font-size: 12px; flex: 1; min-width: 72px; } +.coordinates input { width: 100%; min-width: 0; border: 1px solid var(--border); border-radius: 6px; padding: 7px; background: var(--surface); color: var(--fg); } +.iconButton { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--fg); cursor: pointer; } +.preview { position: relative; height: 280px; overflow: hidden; background: #f8fafc; color: #697386; border: 1px solid var(--border); border-radius: 6px; } +.preview.dark { background: #25272b; color: #cdd3dc; } +.target { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); } +.preview img { position: absolute; left: 50%; top: 50%; max-width: none; } +.size { display: grid; gap: 8px; margin-top: 12px; font-size: 12px; } +.zoomLabel { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.zoomLabel output { min-width: 52px; text-align: right; font-variant-numeric: tabular-nums; } +.size input { width: 100%; accent-color: var(--accent); } +.darkToggle { display: flex; gap: 8px; align-items: center; margin-top: 10px; font-size: 12px; } +.darkToggle input { accent-color: var(--accent); } +.footer { display: flex; justify-content: flex-end; gap: 8px; } +.error { color: var(--danger); margin: 0; font-size: 13px; } +@media (max-width: 560px) { .views { grid-template-columns: minmax(0, 1fr); } .preview { height: 180px; } } diff --git a/src/components/ai-edition/CursorHotspotEditor.test.tsx b/src/components/ai-edition/CursorHotspotEditor.test.tsx new file mode 100644 index 000000000..1a815d394 --- /dev/null +++ b/src/components/ai-edition/CursorHotspotEditor.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import { LOCALE_STORAGE_KEY } from "@/i18n/config"; +import { defaultCursorHotspot } from "@/lib/cursor/cursorThemes"; +import { CursorHotspotEditor } from "./CursorHotspotEditor"; + +beforeEach(() => { + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => (key === LOCALE_STORAGE_KEY ? "en" : null), + setItem: vi.fn(), + }, + }); +}); +afterEach(cleanup); + +function setup(kind: "arrow" | "pointer" | "text" = "arrow") { + const onApply = vi.fn().mockResolvedValue(true); + const onClose = vi.fn(); + render( + + + , + ); + const image = screen.getByRole("img", { + name: kind === "arrow" ? "Arrow" : kind === "pointer" ? "Hand" : "Text", + }); + Object.defineProperties(image, { naturalWidth: { value: 200 }, naturalHeight: { value: 100 } }); + fireEvent.load(image); + return { onApply, onClose, plane: screen.getByRole("button", { name: "Hotspot" }) }; +} + +describe("CursorHotspotEditor", () => { + it.each([ + "arrow", + "pointer", + "text", + ] as const)("starts and resets at the default %s point", (kind) => { + setup(kind); + fireEvent.change(screen.getByRole("spinbutton", { name: "X %" }), { target: { value: "99" } }); + fireEvent.click(screen.getByRole("button", { name: "Reset to default hotspot" })); + expect( + Number((screen.getByRole("spinbutton", { name: "X %" }) as HTMLInputElement).value), + ).toBeCloseTo(defaultCursorHotspot(kind).x * 100, 2); + }); + it("moves by a source-image pixel with arrow keys, clamps, and applies one point", async () => { + const { plane, onApply } = setup(); + fireEvent.change(screen.getByRole("spinbutton", { name: "X %" }), { target: { value: "50" } }); + fireEvent.change(screen.getByRole("spinbutton", { name: "Y %" }), { target: { value: "50" } }); + fireEvent.keyDown(plane, { key: "ArrowRight" }); + fireEvent.keyDown(plane, { key: "ArrowDown", shiftKey: true }); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + await waitFor(() => expect(onApply).toHaveBeenCalledWith({ x: 0.505, y: 0.6 })); + }); + it("maps dragging to the actual image rectangle, including its edges", () => { + const { plane } = setup(); + vi.spyOn(plane, "getBoundingClientRect").mockReturnValue({ + left: 10, + top: 20, + width: 260, + height: 130, + } as DOMRect); + Object.assign(plane, { + setPointerCapture: vi.fn(), + hasPointerCapture: () => true, + releasePointerCapture: vi.fn(), + }); + const pointer = (type: string, x: number, y: number) => { + const event = new Event(type, { bubbles: true }); + Object.assign(event, { button: 0, pointerId: 1, clientX: x, clientY: y }); + fireEvent(plane, event); + }; + pointer("pointerdown", 140, 85); + expect((screen.getByRole("spinbutton", { name: "X %" }) as HTMLInputElement).value).toBe("50"); + expect((screen.getByRole("spinbutton", { name: "Y %" }) as HTMLInputElement).value).toBe("50"); + pointer("pointermove", -50, 500); + expect((screen.getByRole("spinbutton", { name: "X %" }) as HTMLInputElement).value).toBe("0"); + expect((screen.getByRole("spinbutton", { name: "Y %" }) as HTMLInputElement).value).toBe("100"); + }); + it("starts at matching 100% scale and zooms to 200% without changing the point", async () => { + const { onApply } = setup(); + const slider = screen.getByRole("slider", { name: "Preview zoom" }) as HTMLInputElement; + const preview = screen.getByRole("img", { name: "Preview" }); + expect([slider.min, slider.max, slider.value]).toEqual(["100", "200", "100"]); + expect(preview.style.width).toBe("260px"); + expect(preview.style.height).toBe("130px"); + const anchor = preview.style.transform; + fireEvent.change(slider, { + target: { value: "200" }, + }); + expect(preview.style.width).toBe("520px"); + expect(preview.style.height).toBe("260px"); + expect(preview.style.transform).toBe(anchor); + expect(screen.getByText("200 %")).toBeTruthy(); + fireEvent.click(screen.getByRole("checkbox", { name: "Dark background" })); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + await waitFor(() => expect(onApply).toHaveBeenCalledWith(defaultCursorHotspot("arrow"))); + }); + it("keeps the dialog open when saving fails", async () => { + const { onApply, onClose } = setup(); + onApply.mockResolvedValue(false); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + await screen.findByRole("alert"); + expect(onClose).not.toHaveBeenCalled(); + }); + it("blocks applying an image that cannot be decoded", () => { + setup(); + fireEvent.error(screen.getByRole("img", { name: "Arrow" })); + expect((screen.getByRole("button", { name: "Apply" }) as HTMLButtonElement).disabled).toBe( + true, + ); + expect(screen.getByRole("alert")).toBeTruthy(); + }); +}); diff --git a/src/components/ai-edition/CursorHotspotEditor.tsx b/src/components/ai-edition/CursorHotspotEditor.tsx new file mode 100644 index 000000000..2d13e4458 --- /dev/null +++ b/src/components/ai-edition/CursorHotspotEditor.tsx @@ -0,0 +1,295 @@ +import * as Dialog from "@radix-ui/react-dialog"; +import { Crosshair, RotateCcw, X } from "lucide-react"; +import { type CSSProperties, type PointerEvent, useLayoutEffect, useRef, useState } from "react"; +import { useScopedT } from "@/contexts/I18nContext"; +import { + type CursorHotspot, + type CustomCursorKind, + defaultCursorHotspot, +} from "@/lib/cursor/cursorThemes"; +import styles from "./CursorHotspotEditor.module.css"; +import shell from "./NewEditorShell.module.css"; + +export interface CursorHotspotDraft { + setId: string; + kind: CustomCursorKind; + image: string; + hotspot: CursorHotspot; +} + +export function CursorHotspotEditor({ + draft, + onClose, + onApply, +}: { + draft: CursorHotspotDraft; + onClose: () => void; + onApply: (point: CursorHotspot) => Promise; +}) { + const ts = useScopedT("settings"); + const tc = useScopedT("common"); + const [point, setPoint] = useState({ ...draft.hotspot }); + const [dimensions, setDimensions] = useState({ width: 256, height: 256 }); + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(""); + const [saving, setSaving] = useState(false); + const [previewZoom, setPreviewZoom] = useState(100); + const [previewBaseExtent, setPreviewBaseExtent] = useState(260); + const [dark, setDark] = useState(false); + const planeRef = useRef(null); + useLayoutEffect(() => { + const plane = planeRef.current; + if (!loaded || !plane) return; + const measure = () => { + const rect = plane.getBoundingClientRect(); + const extent = Math.max(rect.width, rect.height); + if (extent > 0) setPreviewBaseExtent(extent); + }; + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(plane); + return () => observer.disconnect(); + }, [loaded]); + const kindLabel = ts( + draft.kind === "arrow" + ? "cursor.typeArrow" + : draft.kind === "pointer" + ? "cursor.typePointer" + : "cursor.typeText", + ); + const clamp = (value: number) => Math.max(0, Math.min(1, value)); + const movePointer = (event: PointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + setPoint({ + x: clamp((event.clientX - rect.left) / rect.width), + y: clamp((event.clientY - rect.top) / rect.height), + }); + }; + const apply = async () => { + if (!loaded || saving) return; + setSaving(true); + try { + if (await onApply(point)) onClose(); + else setError(ts("cursor.hotspot.saveFailed")); + } catch { + setError(ts("cursor.hotspot.saveFailed")); + } finally { + setSaving(false); + } + }; + const scale = 260 / Math.max(dimensions.width, dimensions.height); + return ( + { + if (!open && !saving) onClose(); + }} + > + + + event.preventDefault()} + onEscapeKeyDown={(event) => { + event.stopPropagation(); + if (saving) event.preventDefault(); + }} + > +
+ +

{ts("cursor.hotspot.title", { type: kindLabel })}

+
+ +
+
+
+
+

{ts("cursor.hotspot.point")}

+
+ +
+
+ {(["x", "y"] as const).map((axis) => ( + + ))} + +
+
+
+

{ts("cursor.hotspot.preview")}

+
+
+ + +
+
+ {error ? ( +

+ {error} +

+ ) : null} +
+
+ + +
+
+
+
+ ); +} 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..3a4cd3031 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,39 @@ 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( + + + , + ); +} + +async function applyUploadedHotspot(locale = "en") { + const dialog = await screen.findByRole("dialog"); + const image = dialog.querySelector("button img"); + if (!image) throw new Error("Missing uploaded image"); + fireEvent.load(image); + fireEvent.click( + within(dialog).getByRole("button", { name: locale === "de" ? "Übernehmen" : "Apply" }), + ); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); +} + describe("CursorPane theme picker", () => { it("shows the original cursor themes beside the default", () => { expect(CURSOR_THEMES).toHaveLength(6); @@ -50,4 +82,310 @@ 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 applyUploadedHotspot(); + 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 1" }).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; + for (const [label, key] of [ + ["Arrow", "arrow"], + ["Hand", "pointer"], + ["Text", "text"], + ] as const) { + expect( + within(panel) + .getByRole("button", { name: `Replace ${label} image` }) + .querySelector("img") + ?.getAttribute("src"), + ).toBe(theme[key]); + } + 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( + within(panel) + .getByRole("button", { name: "Replace Arrow image" }) + .querySelector("img") + ?.getAttribute("src"), + ).toBe(replaced.arrow); + expect(replaced.pointer).toBe(theme.pointer); + expect(replaced.text).toBe(theme.text); + 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 1" })); + await waitFor(() => + expect(getEditorSettings(useProjectStore.getState().document).cursorTheme).toMatch( + /^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: "Custom 1" })); + 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("previews the edited set's uploaded image and defaults for an empty new set", () => { + const first = "data:image/png;base64,Zmlyc3Q="; + const second = "data:image/png;base64,c2Vjb25k"; + renderWithProject({ + cursorTheme: "custom:second", + cursorCustomThemes: [ + { id: "custom:first", number: 1, images: { arrow: first } }, + { id: "custom:second", number: 2, images: { arrow: second } }, + ], + }); + fireEvent.click(screen.getByRole("button", { name: "Custom 1" })); + let panel = screen.getByRole("group", { name: "Custom cursor" }); + expect( + within(panel) + .getByRole("button", { name: "Replace Arrow image" }) + .querySelector("img") + ?.getAttribute("src"), + ).toBe(first); + expect( + within(panel).getByRole("button", { name: "Hand" }).querySelector("img")?.getAttribute("src"), + ).toContain(DEFAULT_CURSOR_SPRITES.pointer.assetPath); + fireEvent.click(screen.getByRole("button", { name: "Add custom cursor" })); + panel = screen.getByRole("group", { name: "Custom cursor" }); + expect( + within(panel) + .getByRole("button", { name: "Arrow" }) + .querySelector("img") + ?.getAttribute("src"), + ).toContain(DEFAULT_CURSOR_SPRITES.arrow.assetPath); + }); + + it("removes the last custom image safely", async () => { + renderWithProject({ + cursorTheme: "custom", + cursorCustomTheme: { text: "data:image/png;base64,dGV4dA==" }, + }); + 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("default"); + }); + expect(screen.queryByRole("button", { name: "Custom 1" })).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: "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 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 applyUploadedHotspot(locale); + 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 applyUploadedHotspot(locale); + 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("opens the selected set for editing without another save or undo entry", () => { + renderWithProject({ + cursorTheme: "custom", + cursorCustomTheme: { arrow: "data:image/png;base64,QQ==" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Custom 1" })); + expect(screen.getByRole("button", { name: "Edit Arrow hotspot" })).toBeTruthy(); + expect(nativeBridgeClient.aiEdition.save).not.toHaveBeenCalled(); + }); + + it("does not persist a new upload until its hotspot is applied", async () => { + const { container } = renderWithProject(); + fireEvent.click(screen.getByRole("button", { name: "Add custom cursor" })); + fireEvent.click( + within(screen.getByRole("group", { name: "Custom cursor" })).getByRole("button", { + name: "Hand", + }), + ); + fireEvent.change(container.querySelector('input[type="file"]')!, { + target: { files: [new File(["image"], "hand.png")] }, + }); + await screen.findByRole("dialog", { name: "Hotspot: Hand" }); + expect(nativeBridgeClient.aiEdition.save).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(getEditorSettings(useProjectStore.getState().document).cursorCustomThemes).toEqual([]); + }); + + it("edits an existing point without replacing its image and cancels a subsequent draft", async () => { + const arrow = "data:image/png;base64,QQ=="; + renderWithProject({ cursorTheme: "custom", cursorCustomTheme: { arrow } }); + fireEvent.click(screen.getByRole("button", { name: "Custom 1" })); + fireEvent.click(screen.getByRole("button", { name: "Edit Arrow hotspot" })); + fireEvent.load(screen.getByRole("img", { name: "Arrow" })); + fireEvent.change(screen.getByRole("spinbutton", { name: "X %" }), { target: { value: "75" } }); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(getEditorSettings(useProjectStore.getState().document).cursorCustomTheme).toEqual({ + arrow, + }); + expect( + getEditorSettings(useProjectStore.getState().document).cursorCustomHotspots.arrow?.x, + ).toBe(0.75); + fireEvent.click(screen.getByRole("button", { name: "Edit Arrow hotspot" })); + fireEvent.change(screen.getByRole("spinbutton", { name: "X %" }), { target: { value: "10" } }); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect( + getEditorSettings(useProjectStore.getState().document).cursorCustomHotspots.arrow?.x, + ).toBe(0.75); + }); + + 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..a642924f4 100644 --- a/src/components/ai-edition/NewEditorShell.module.css +++ b/src/components/ai-edition/NewEditorShell.module.css @@ -1448,6 +1448,59 @@ 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, .customCursorEdit { + 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; } +.customCursorEdit { position: absolute; top: 2px; left: 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..322fcb421 100644 --- a/src/components/ai-edition/RightPanes.tsx +++ b/src/components/ai-edition/RightPanes.tsx @@ -20,6 +20,8 @@ import { Monitor, MousePointerClick, Music, + Pencil, + Plus, RotateCcw, Sliders, Smartphone, @@ -79,10 +81,11 @@ import { AUDIO_TRACK_GAIN_DB_MAX, AUDIO_TRACK_GAIN_DB_MIN, DEFAULT_EDITOR_SETTINGS, + getEditorSettings, } from "@/lib/ai-edition/store/editorSettings"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { useCaptions } from "@/lib/ai-edition/store/useCaptions"; -import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings"; +import { applySettingsPatch, useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings"; import type { useTimeline } from "@/lib/ai-edition/store/useTimeline"; import { buildAggregatedSections, @@ -114,6 +117,8 @@ import { type CursorKind, DEFAULT_CURSOR_SPRITES, DEFAULT_CURSOR_THEME_ID, + defaultCursorHotspot, + isCustomCursorThemeId, resolveCursorSprites, themePickerPreviewAssets, } from "@/lib/cursor/cursorThemes"; @@ -152,6 +157,7 @@ import { useClickSound } from "../../hooks/useClickSound"; import { useCanSegmentCamera } from "../../native/hooks/useSegmentationSupport"; import { CaptionsPane } from "./CaptionsPane"; import { ColorField } from "./ColorField"; +import { type CursorHotspotDraft, CursorHotspotEditor } from "./CursorHotspotEditor"; import { insertionsEnabled } from "./insertionsEnabled"; import styles from "./NewEditorShell.module.css"; import { useRecordedCursorTypes } from "./recordedCursorTypes"; @@ -3670,6 +3676,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 +3684,132 @@ 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 [customPanelTarget, setCustomPanelTarget] = useState(null); + const [hotspotDraft, setHotspotDraft] = useState(null); + const projectId = useProjectStore((state) => state.projectId); + // biome-ignore lint/correctness/useExhaustiveDependencies: a project switch discards its unsaved dialog draft. + useEffect(() => { + setHotspotDraft(null); + setCustomPanelTarget(null); + }, [projectId]); + const customPanelOpen = customPanelTarget !== null; + const customPanelRef = useRef(null); + const customButtonRef = useRef(null); + useEffect(() => { + if (!customPanelOpen || hotspotDraft) return; + const dismissOutside = (event: PointerEvent) => { + if ( + event.target instanceof Node && + !customPanelRef.current?.contains(event.target) && + !customButtonRef.current?.contains(event.target) + ) + setCustomPanelTarget(null); + }; + const dismissOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setCustomPanelTarget(null); + }; + document.addEventListener("pointerdown", dismissOutside); + document.addEventListener("keydown", dismissOnEscape); + return () => { + document.removeEventListener("pointerdown", dismissOutside); + document.removeEventListener("keydown", dismissOnEscape); + }; + }, [customPanelOpen, hotspotDraft]); + const panelSet = settings.cursorCustomThemes.find((entry) => entry.id === customPanelTarget); + 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; + setHotspotDraft({ + setId: customPanelTarget, + kind: slot, + image: dataUrl, + hotspot: panelSet?.hotspots?.[slot] ?? defaultCursorHotspot(slot), + }); + }); + const removeCustomCursor = (slot?: CustomCursorUploadSlot) => { + const customTheme = slot ? { ...panelImages } : {}; + if (slot) delete customTheme[slot]; + const hotspots = { ...panelSet?.hotspots }; + if (slot) delete hotspots[slot]; + void set({ + cursor: { + customThemes: settings.cursorCustomThemes.flatMap((entry) => + entry.id === customPanelTarget + ? Object.keys(customTheme).length + ? [{ ...entry, images: customTheme, hotspots }] + : [] + : [entry], + ), + ...(Object.keys(customTheme).length === 0 && settings.cursorTheme === customPanelTarget + ? { 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 +3829,42 @@ export function CursorPane() { })), [ts], ); + const cursorThemeCells = useMemo( + () => [ + ...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. const cursorSprites = useMemo( - () => resolveCursorSprites(settings.cursorTheme), - [settings.cursorTheme], + () => + resolveCursorSprites( + settings.cursorTheme, + [], + false, + settings.cursorCustomTheme, + settings.cursorCustomHotspots, + ), + [settings.cursorTheme, settings.cursorCustomTheme, settings.cursorCustomHotspots], ); 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 +3885,28 @@ export function CursorPane() { icon={} helpText={ts("cursor.help")} > + {cursorFileInput} + {hotspotDraft ? ( + setHotspotDraft(null)} + onApply={async (point) => { + const store = useProjectStore.getState(); + if (!store.document || store.projectId !== projectId) return false; + const entry = getEditorSettings(store.document).cursorCustomThemes.find( + (value) => value.id === hotspotDraft.setId, + ); + const next = applySettingsPatch(store.document, { + cursor: { + theme: hotspotDraft.setId, + customTheme: { ...entry?.images, [hotspotDraft.kind]: hotspotDraft.image }, + customHotspots: { ...entry?.hotspots, [hotspotDraft.kind]: point }, + }, + }); + return store.saveDocument(next, { history: true }); + }} + /> + ) : null}
{ts("cursor.show")}
{ts("cursor.theme")}
- {cursorThemeOptions.map((option) => { + {cursorThemeCells.map((option) => { const isActive = settings.cursorTheme === option.id; return ( @@ -3775,7 +3952,10 @@ export function CursorPane() { aria-label={option.name} aria-pressed={isActive} disabled={!hasDocument} - onClick={() => void set({ cursor: { theme: option.id } })} + onClick={() => { + if (!isActive) void set({ cursor: { theme: option.id } }); + setCustomPanelTarget(isCustomCursorThemeId(option.id) ? option.id : null); + }} > ); })} +
+ {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(panelImages[id]); + const uploadLabel = uploaded ? ts("cursor.replaceCustom", { type: label }) : label; + const removeLabel = ts("cursor.removeCustom", { type: label }); + return ( +
+ + + + {uploaded ? ( + + + + ) : null} + {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..2e72bb72d 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -438,7 +438,24 @@ "autoHideTip": "يتلاشى المؤشر عندما يتوقف عن الحركة", "bounceNone": "بلا", "bounceLight": "خفيف", - "bounceStrong": "قوي" + "bounceStrong": "قوي", + "themeCustom": "مخصص", + "hotspot": { + "title": "نقطة النقر: {{type}}", + "point": "نقطة النقر", + "preview": "معاينة", + "previewZoom": "تكبير المعاينة", + "darkBackground": "خلفية داكنة", + "reset": "استعادة نقطة النقر الافتراضية", + "edit": "تعديل نقطة النقر لـ {{type}}", + "apply": "تطبيق", + "saveFailed": "تعذر حفظ نقطة النقر." + }, + "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..12e3fcd2a 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -438,7 +438,24 @@ "autoHideTip": "Kurzor postupně zmizí, když se přestane hýbat", "bounceNone": "Žádný", "bounceLight": "Lehký", - "bounceStrong": "Výrazný" + "bounceStrong": "Výrazný", + "themeCustom": "Vlastní", + "hotspot": { + "title": "Aktivní bod: {{type}}", + "point": "Aktivní bod", + "preview": "Náhled", + "previewZoom": "Přiblížení náhledu", + "darkBackground": "Tmavé pozadí", + "reset": "Obnovit výchozí aktivní bod", + "edit": "Upravit aktivní bod pro {{type}}", + "apply": "Použít", + "saveFailed": "Aktivní bod se nepodařilo uložit." + }, + "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..cdc558534 100644 --- a/src/i18n/locales/de/settings.json +++ b/src/i18n/locales/de/settings.json @@ -438,7 +438,24 @@ "autoHideTip": "Der Cursor blendet sich aus, wenn er stillsteht", "bounceNone": "Keiner", "bounceLight": "Leicht", - "bounceStrong": "Stark" + "bounceStrong": "Stark", + "themeCustom": "Benutzerdefiniert", + "hotspot": { + "title": "Klickpunkt: {{type}}", + "point": "Klickpunkt", + "preview": "Vorschau", + "previewZoom": "Vorschauzoom", + "darkBackground": "Dunkler Hintergrund", + "reset": "Standard-Klickpunkt wiederherstellen", + "edit": "Klickpunkt für {{type}} bearbeiten", + "apply": "Übernehmen", + "saveFailed": "Der Klickpunkt konnte nicht gespeichert werden." + }, + "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..00828301d 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -438,7 +438,24 @@ "autoHideTip": "The cursor fades out when it stops moving", "bounceNone": "None", "bounceLight": "Light", - "bounceStrong": "Strong" + "bounceStrong": "Strong", + "themeCustom": "Custom", + "hotspot": { + "title": "Hotspot: {{type}}", + "point": "Hotspot", + "preview": "Preview", + "previewZoom": "Preview zoom", + "darkBackground": "Dark background", + "reset": "Reset to default hotspot", + "edit": "Edit {{type}} hotspot", + "apply": "Apply", + "saveFailed": "Could not save the hotspot." + }, + "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..1438e34c7 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -438,7 +438,24 @@ "autoHideTip": "El cursor se desvanece cuando deja de moverse", "bounceNone": "Ninguno", "bounceLight": "Suave", - "bounceStrong": "Marcado" + "bounceStrong": "Marcado", + "themeCustom": "Personalizado", + "hotspot": { + "title": "Punto activo: {{type}}", + "point": "Punto activo", + "preview": "Vista previa", + "previewZoom": "Zoom de vista previa", + "darkBackground": "Fondo oscuro", + "reset": "Restablecer el punto activo predeterminado", + "edit": "Editar el punto activo de {{type}}", + "apply": "Aplicar", + "saveFailed": "No se pudo guardar el punto activo." + }, + "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..7c8c89467 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -438,7 +438,24 @@ "autoHideTip": "Le curseur s'estompe quand il ne bouge plus", "bounceNone": "Aucun", "bounceLight": "Léger", - "bounceStrong": "Marqué" + "bounceStrong": "Marqué", + "themeCustom": "Personnalisé", + "hotspot": { + "title": "Point actif : {{type}}", + "point": "Point actif", + "preview": "Aperçu", + "previewZoom": "Zoom de l’aperçu", + "darkBackground": "Fond sombre", + "reset": "Rétablir le point actif par défaut", + "edit": "Modifier le point actif de {{type}}", + "apply": "Appliquer", + "saveFailed": "Impossible d’enregistrer le point actif." + }, + "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..2686557fc 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -438,7 +438,24 @@ "autoHideTip": "Il cursore svanisce quando smette di muoversi", "bounceNone": "Nessuno", "bounceLight": "Leggero", - "bounceStrong": "Marcato" + "bounceStrong": "Marcato", + "themeCustom": "Personalizzato", + "hotspot": { + "title": "Punto attivo: {{type}}", + "point": "Punto attivo", + "preview": "Anteprima", + "previewZoom": "Zoom anteprima", + "darkBackground": "Sfondo scuro", + "reset": "Ripristina il punto attivo predefinito", + "edit": "Modifica il punto attivo di {{type}}", + "apply": "Applica", + "saveFailed": "Impossibile salvare il punto attivo." + }, + "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..f88cf4040 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -438,7 +438,24 @@ "autoHideTip": "カーソルは動きが止まると消えます", "bounceNone": "なし", "bounceLight": "弱", - "bounceStrong": "強" + "bounceStrong": "強", + "themeCustom": "カスタム", + "hotspot": { + "title": "クリック位置: {{type}}", + "point": "クリック位置", + "preview": "プレビュー", + "previewZoom": "プレビューのズーム", + "darkBackground": "暗い背景", + "reset": "既定のクリック位置に戻す", + "edit": "{{type}}のクリック位置を編集", + "apply": "適用", + "saveFailed": "クリック位置を保存できませんでした。" + }, + "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..452c040aa 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -438,7 +438,24 @@ "autoHideTip": "커서가 멈추면 서서히 사라집니다", "bounceNone": "없음", "bounceLight": "약하게", - "bounceStrong": "강하게" + "bounceStrong": "강하게", + "themeCustom": "사용자 지정", + "hotspot": { + "title": "클릭 지점: {{type}}", + "point": "클릭 지점", + "preview": "미리 보기", + "previewZoom": "미리 보기 확대", + "darkBackground": "어두운 배경", + "reset": "기본 클릭 지점 복원", + "edit": "{{type}} 클릭 지점 편집", + "apply": "적용", + "saveFailed": "클릭 지점을 저장하지 못했습니다." + }, + "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..bbc340386 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -438,7 +438,24 @@ "autoHideTip": "O cursor desaparece quando para de se mover", "bounceNone": "Nenhum", "bounceLight": "Leve", - "bounceStrong": "Forte" + "bounceStrong": "Forte", + "themeCustom": "Personalizado", + "hotspot": { + "title": "Ponto ativo: {{type}}", + "point": "Ponto ativo", + "preview": "Prévia", + "previewZoom": "Zoom da prévia", + "darkBackground": "Fundo escuro", + "reset": "Restaurar o ponto ativo padrão", + "edit": "Editar o ponto ativo de {{type}}", + "apply": "Aplicar", + "saveFailed": "Não foi possível salvar o ponto ativo." + }, + "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..e6bf49450 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -438,7 +438,24 @@ "autoHideTip": "Курсор исчезает, когда перестаёт двигаться", "bounceNone": "Нет", "bounceLight": "Лёгкий", - "bounceStrong": "Сильный" + "bounceStrong": "Сильный", + "themeCustom": "Пользовательский", + "hotspot": { + "title": "Точка нажатия: {{type}}", + "point": "Точка нажатия", + "preview": "Предпросмотр", + "previewZoom": "Масштаб предпросмотра", + "darkBackground": "Тёмный фон", + "reset": "Восстановить стандартную точку нажатия", + "edit": "Изменить точку нажатия для {{type}}", + "apply": "Применить", + "saveFailed": "Не удалось сохранить точку нажатия." + }, + "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..a56745ddc 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -438,7 +438,24 @@ "autoHideTip": "İmleç durduğunda yavaşça kaybolur", "bounceNone": "Yok", "bounceLight": "Hafif", - "bounceStrong": "Belirgin" + "bounceStrong": "Belirgin", + "themeCustom": "Özel", + "hotspot": { + "title": "Etkin nokta: {{type}}", + "point": "Etkin nokta", + "preview": "Önizleme", + "previewZoom": "Önizleme yakınlaştırması", + "darkBackground": "Koyu arka plan", + "reset": "Varsayılan etkin noktayı geri yükle", + "edit": "{{type}} etkin noktasını düzenle", + "apply": "Uygula", + "saveFailed": "Etkin nokta kaydedilemedi." + }, + "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..b854a9cfc 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -438,7 +438,24 @@ "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", + "hotspot": { + "title": "Điểm nhấp: {{type}}", + "point": "Điểm nhấp", + "preview": "Xem trước", + "previewZoom": "Thu phóng xem trước", + "darkBackground": "Nền tối", + "reset": "Khôi phục điểm nhấp mặc định", + "edit": "Chỉnh sửa điểm nhấp của {{type}}", + "apply": "Áp dụng", + "saveFailed": "Không thể lưu điểm nhấp." + }, + "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..e90357709 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -438,7 +438,24 @@ "autoHideTip": "光标停止移动后逐渐隐藏", "bounceNone": "无", "bounceLight": "轻", - "bounceStrong": "强" + "bounceStrong": "强", + "themeCustom": "自定义", + "hotspot": { + "title": "点击点:{{type}}", + "point": "点击点", + "preview": "预览", + "previewZoom": "预览缩放", + "darkBackground": "深色背景", + "reset": "恢复默认点击点", + "edit": "编辑{{type}}的点击点", + "apply": "应用", + "saveFailed": "无法保存点击点。" + }, + "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..862108938 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -438,7 +438,24 @@ "autoHideTip": "游標停止移動後會逐漸消失", "bounceNone": "無", "bounceLight": "輕", - "bounceStrong": "強" + "bounceStrong": "強", + "themeCustom": "自訂", + "hotspot": { + "title": "點擊點:{{type}}", + "point": "點擊點", + "preview": "預覽", + "previewZoom": "預覽縮放", + "darkBackground": "深色背景", + "reset": "還原預設點擊點", + "edit": "編輯{{type}}的點擊點", + "apply": "套用", + "saveFailed": "無法儲存點擊點。" + }, + "addCustom": "新增自訂游標", + "customPanel": "自訂游標", + "replaceCustom": "替換{{type}}圖片", + "removeCustom": "移除{{type}}圖片", + "deleteCustom": "刪除自訂游標組" }, "export": { "gifButton": "匯出 GIF", diff --git a/src/lib/ai-edition/store/documentWriteAudit.test.ts b/src/lib/ai-edition/store/documentWriteAudit.test.ts index 3f11af684..1cf3d5f00 100644 --- a/src/lib/ai-edition/store/documentWriteAudit.test.ts +++ b/src/lib/ai-edition/store/documentWriteAudit.test.ts @@ -238,6 +238,7 @@ const DECLARED: WritePath[] = [ // Editor settings — same live/commit split, same reasoning. w("src/lib/ai-edition/store/useEditorSettings.ts", "commit", "save", "gesture"), + w("src/components/ai-edition/RightPanes.tsx", "CursorPane", "save", "gesture"), w("src/lib/ai-edition/store/useEditorSettings.ts", "set", "save", "gesture"), w("src/lib/ai-edition/store/useEditorSettings.ts", "set", "set", "automatic"), w("src/lib/ai-edition/store/useEditorSettings.ts", "setLive", "set", "automatic"), diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts index 7acecab2a..edfd2138b 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,91 @@ 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, + cursorCustomThemes: [{ id: "custom", number: 1, images: { 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: { + cursorTheme: "custom", + 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("migrates old uploads, edits one of five sets, and persists deletion without reviving legacy images", () => { + let doc = { + ...baseDoc, + legacyEditor: { + cursorTheme: "custom", + cursorCustomTheme: { arrow: "data:image/png;base64,QQ==" }, + }, + } as AxcutDocument; + for (let index = 2; index <= 5; index++) { + doc = patchEditorSettings(doc, { + cursor: { + theme: `custom:set-${index}`, + customTheme: { text: `data:image/png;base64,${index}` }, + }, + }); + } + const sets = getEditorSettings(doc).cursorCustomThemes; + expect(sets).toHaveLength(5); + expect(sets[0]).toEqual({ + id: "custom", + number: 1, + images: { arrow: "data:image/png;base64,QQ==" }, + }); + doc = patchEditorSettings(doc, { + cursor: { theme: "custom:set-2", customTheme: { pointer: "data:image/png;base64,Qg==" } }, + }); + expect(getEditorSettings(doc).cursorCustomThemes[1]).toEqual({ + id: "custom:set-2", + number: 2, + images: { pointer: "data:image/png;base64,Qg==" }, + }); + doc = patchEditorSettings(doc, { cursor: { customThemes: [] } }); + expect(getEditorSettings(JSON.parse(JSON.stringify(doc))).cursorCustomThemes).toEqual([]); + expect(getEditorSettings(doc).cursorTheme).toBe("default"); + expect(doc.legacyEditor).not.toHaveProperty("cursorCustomTheme"); + }); + + it("filters invalid and duplicate set IDs and normalizes their numbers", () => { + const images = { arrow: "data:image/png;base64,QQ==" }; + const snap = getEditorSettings({ + ...baseDoc, + legacyEditor: { + cursorCustomThemes: [ + { id: "custom:one", number: 3, images }, + { id: "custom:one", number: 9, images }, + { id: "custom:two", number: 3, images }, + { id: "studio-ink", number: 5, images }, + { id: "custom:empty", number: 6, images: {} }, + ], + }, + }); + expect(snap.cursorCustomThemes.map((entry) => [entry.id, entry.number])).toEqual([ + ["custom:one", 3], + ["custom:two", 4], + ]); + }); + 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..e974d0285 100644 --- a/src/lib/ai-edition/store/editorSettings.ts +++ b/src/lib/ai-edition/store/editorSettings.ts @@ -21,7 +21,15 @@ import { type WebcamPosition, type WebcamSizePreset, } from "@/components/video-editor/types"; -import { normalizeCursorThemeId, readCursorAsArrow } from "@/lib/cursor/cursorThemes"; +import { + type CustomCursorHotspots, + type CustomCursorSet, + type CustomCursorTheme, + isCustomCursorThemeId, + normalizeCursorThemeId, + readCursorAsArrow, + readCustomCursorHotspots, +} from "@/lib/cursor/cursorThemes"; import { DEFAULT_PROJECT_APPEARANCE, type FrameTheme, @@ -130,6 +138,9 @@ export interface EditorSettingsSnapshot { cursorShow: boolean; cursorAutoHide: boolean; cursorTheme: string; + cursorCustomTheme: CustomCursorTheme; + cursorCustomHotspots: CustomCursorHotspots; + cursorCustomThemes: CustomCursorSet[]; autoFocusAll: boolean; } @@ -145,6 +156,9 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettingsSnapshot = { webcamCropPan: DEFAULT_CROP_PAN, audioGainDb: 0, formatFollowCursor: null, + cursorCustomTheme: {}, + cursorCustomHotspots: {}, + cursorCustomThemes: [], }; interface LegacyShape { @@ -194,6 +208,9 @@ interface LegacyShape { cursorShow?: boolean; cursorAutoHide?: boolean; cursorTheme?: string; + cursorCustomTheme?: unknown; + cursorCustomHotspots?: unknown; + cursorCustomThemes?: unknown; autoFocusAll?: boolean; } function isShape(value: unknown): value is LegacyShape { @@ -210,8 +227,64 @@ 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; +} + +function readCustomCursorSets(legacy: LegacyShape | null): CustomCursorSet[] { + if (Array.isArray(legacy?.cursorCustomThemes)) { + const sets: CustomCursorSet[] = []; + for (const raw of legacy.cursorCustomThemes) { + if (!isShape(raw)) continue; + const value = raw as Record; + if (!isCustomCursorThemeId(value.id) || sets.some((set) => set.id === value.id)) continue; + const images = readCustomCursorTheme(value.images); + if (Object.keys(images).length === 0) continue; + const number = + typeof value.number === "number" && + Number.isSafeInteger(value.number) && + value.number > 0 && + !sets.some((set) => set.number === value.number) + ? value.number + : Math.max(0, ...sets.map((set) => set.number)) + 1; + const hotspots = readCustomCursorHotspots(value.hotspots); + sets.push({ + id: value.id, + number, + images, + ...(Object.keys(hotspots).length ? { hotspots } : {}), + }); + } + return sets; + } + const images = readCustomCursorTheme(legacy?.cursorCustomTheme); + return Object.keys(images).length + ? [ + { + id: isCustomCursorThemeId(legacy?.cursorTheme) ? legacy.cursorTheme : "custom", + number: 1, + images, + ...(Object.keys(readCustomCursorHotspots(legacy?.cursorCustomHotspots)).length + ? { hotspots: readCustomCursorHotspots(legacy?.cursorCustomHotspots) } + : {}), + }, + ] + : []; +} + export function getEditorSettings(doc: AxcutDocument | null | undefined): EditorSettingsSnapshot { const legacy = isShape(doc?.legacyEditor) ? (doc.legacyEditor as LegacyShape) : null; + const cursorCustomThemes = readCustomCursorSets(legacy); + const cursorTheme = normalizeCursorThemeId(legacy?.cursorTheme); const num = (v: unknown, fallback: number) => (isNumber(v) ? v : fallback); const bool = (v: unknown, fallback: boolean) => (isBoolean(v) ? v : fallback); const str = (v: unknown, fallback: string) => (isString(v) ? v : fallback); @@ -336,7 +409,14 @@ export function getEditorSettings(doc: AxcutDocument | null | undefined): Editor // A pack the app no longer ships reads as the default art, which is what the renderer // 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), + cursorTheme: + isCustomCursorThemeId(cursorTheme) && + !cursorCustomThemes.some((set) => set.id === cursorTheme) + ? "default" + : cursorTheme, + cursorCustomTheme: cursorCustomThemes.find((set) => set.id === cursorTheme)?.images ?? {}, + cursorCustomHotspots: cursorCustomThemes.find((set) => set.id === cursorTheme)?.hotspots ?? {}, + cursorCustomThemes, autoFocusAll: bool(legacy?.autoFocusAll, DEFAULT_EDITOR_SETTINGS.autoFocusAll), }; } @@ -367,7 +447,14 @@ export interface EditorSettingsPatch { webcamBackgroundMode?: WebcamBackgroundMode; webcamWallpaper?: string; webcamBlurIntensity?: number; - cursor?: Partial & { theme?: string; show?: boolean; autoHide?: boolean }; + cursor?: Partial & { + theme?: string; + customTheme?: CustomCursorTheme; + customHotspots?: CustomCursorHotspots; + customThemes?: CustomCursorSet[]; + show?: boolean; + autoHide?: boolean; + }; cursorAutoHide?: boolean; autoFocusAll?: boolean; } @@ -402,6 +489,35 @@ 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.customThemes !== undefined) { + next.cursorCustomThemes = c.customThemes; + delete next.cursorCustomTheme; + delete next.cursorCustomHotspots; + } + if (c.customTheme !== undefined || c.customHotspots !== undefined) { + const sets = readCustomCursorSets(c.customThemes !== undefined ? next : base); + const id = isCustomCursorThemeId(next.cursorTheme) ? next.cursorTheme : "custom"; + const existing = sets.find((set) => set.id === id); + const images = readCustomCursorTheme(c.customTheme ?? existing?.images); + const hotspots = readCustomCursorHotspots(c.customHotspots ?? existing?.hotspots); + for (const kind of ["arrow", "pointer", "text"] as const) + if (!images[kind]) delete hotspots[kind]; + const replacement = { + id, + number: existing?.number ?? Math.max(0, ...sets.map((set) => set.number)) + 1, + images, + ...(Object.keys(hotspots).length ? { hotspots } : {}), + }; + next.cursorCustomThemes = existing + ? sets.flatMap((set) => + set.id === id ? (Object.keys(images).length ? [replacement] : []) : [set], + ) + : Object.keys(images).length + ? [...sets, replacement] + : sets; + delete next.cursorCustomTheme; + delete next.cursorCustomHotspots; + } 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..344b03223 100644 --- a/src/lib/ai-edition/stylePresets.ts +++ b/src/lib/ai-edition/stylePresets.ts @@ -20,10 +20,12 @@ import type { } from "../../components/video-editor/types"; import { type AspectRatio, isAspectRatio } from "../../utils/aspectRatioUtils"; import { - CURSOR_THEME_IDS, type CursorKind, - DEFAULT_CURSOR_THEME_ID, + type CustomCursorHotspots, + type CustomCursorTheme, + normalizeCursorThemeId, readCursorAsArrow, + readCustomCursorHotspots, } from "../cursor/cursorThemes"; import { clampToBound, @@ -76,6 +78,8 @@ export interface StylePresetAppearance { cursorShow: boolean; cursorAutoHide: boolean; cursorTheme: string; + cursorCustomTheme?: CustomCursorTheme; + cursorCustomHotspots?: CustomCursorHotspots; } export interface StylePreset { @@ -303,6 +307,28 @@ export function parseStylePresetAppearance(value: unknown): StylePresetAppearanc if (typeof value.cursorTheme !== "string") { throw new TypeError("Style preset cursorTheme must be a string."); } + const cursorCustomTheme: CustomCursorTheme = {}; + const cursorCustomHotspots = readCustomCursorHotspots(value.cursorCustomHotspots); + if ( + value.cursorCustomHotspots !== undefined && + (!isRecord(value.cursorCustomHotspots) || + Object.keys(value.cursorCustomHotspots).length !== Object.keys(cursorCustomHotspots).length) + ) { + throw new TypeError("Style preset cursorCustomHotspots must contain valid normalized points."); + } + 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: @@ -353,9 +379,9 @@ export function parseStylePresetAppearance(value: unknown): StylePresetAppearanc }, cursorShow: readBoolean(value, "cursorShow"), cursorAutoHide: readBoolean(value, "cursorAutoHide"), - cursorTheme: CURSOR_THEME_IDS.has(value.cursorTheme) - ? value.cursorTheme - : DEFAULT_CURSOR_THEME_ID, + cursorTheme: normalizeCursorThemeId(value.cursorTheme), + ...(Object.keys(cursorCustomTheme).length > 0 ? { cursorCustomTheme } : {}), + ...(Object.keys(cursorCustomHotspots).length > 0 ? { cursorCustomHotspots } : {}), }; } @@ -450,6 +476,9 @@ export const LOOK_LEGACY_EDITOR_KEYS = [ "cursorShow", "cursorAutoHide", "cursorTheme", + "cursorCustomTheme", + "cursorCustomThemes", + "cursorCustomHotspots", ] 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..ab2403d9d 100644 --- a/src/lib/ai-edition/stylePresetsEditor.test.ts +++ b/src/lib/ai-edition/stylePresetsEditor.test.ts @@ -115,6 +115,83 @@ describe("stylePresetsEditor", () => { expect(getEditorSettings(patchEditorSettings(vertical, patch)).aspectRatio).toBe("16:9"); }); + it("applies a custom-set preset without discarding other project sets", () => { + const source = patchEditorSettings(createEmptyDocument({ projectId: "a", title: "A" }), { + cursor: { theme: "custom:preset", customTheme: { arrow: "data:image/png;base64,QQ==" } }, + }); + const preset = parseStylePresetAppearance( + JSON.parse(JSON.stringify(stylePresetAppearanceFromSettings(getEditorSettings(source)))), + ); + const target = patchEditorSettings(source, { + cursor: { theme: "custom:another", customTheme: { pointer: "data:image/png;base64,Qg==" } }, + }); + const applied = getEditorSettings(patchEditorSettings(target, stylePresetPatch(preset))); + expect(applied.cursorTheme).toBe("custom:preset"); + expect(applied.cursorCustomTheme).toEqual({ arrow: "data:image/png;base64,QQ==" }); + expect(applied.cursorCustomThemes).toHaveLength(2); + }); + + it("round-trips custom points through presets and clears an old point when a preset has none", () => { + const source = patchEditorSettings(createEmptyDocument({ projectId: "a", title: "A" }), { + cursor: { + theme: "custom:point", + customTheme: { arrow: "data:image/png;base64,QQ==" }, + customHotspots: { arrow: { x: 0.2, y: 0.3 } }, + }, + }); + const preset = parseStylePresetAppearance( + JSON.parse(JSON.stringify(stylePresetAppearanceFromSettings(getEditorSettings(source)))), + ); + expect(preset.cursorCustomHotspots).toEqual({ arrow: { x: 0.2, y: 0.3 } }); + expect( + getEditorSettings(patchEditorSettings(source, stylePresetPatch(preset))).cursorCustomHotspots, + ).toEqual(preset.cursorCustomHotspots); + expect( + getEditorSettings({ ...source, legacyEditor: stylePresetLegacyEditor(preset) }) + .cursorCustomHotspots, + ).toEqual(preset.cursorCustomHotspots); + expect( + getEditorSettings( + patchEditorSettings( + source, + stylePresetPatch({ ...preset, cursorCustomHotspots: undefined }), + ), + ).cursorCustomHotspots, + ).toEqual({}); + expect(() => + parseStylePresetAppearance({ ...preset, cursorCustomHotspots: { arrow: { x: -1, y: 0 } } }), + ).toThrow("normalized points"); + }); + + 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 +215,13 @@ 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" && + key !== "cursorCustomThemes" && + key !== "cursorCustomHotspots", + ).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..493d3c953 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 { isCustomCursorThemeId } from "@/lib/cursor/cursorThemes"; /** Copies the appearance fields out of a settings snapshot — nothing footage-dependent. */ export function stylePresetAppearanceFromSettings( @@ -48,6 +49,15 @@ export function stylePresetAppearanceFromSettings( cursorShow: settings.cursorShow, cursorAutoHide: settings.cursorAutoHide, cursorTheme: settings.cursorTheme, + ...(isCustomCursorThemeId(settings.cursorTheme) && + Object.keys(settings.cursorCustomTheme).length > 0 + ? { + cursorCustomTheme: { ...settings.cursorCustomTheme }, + ...(Object.keys(settings.cursorCustomHotspots).length + ? { cursorCustomHotspots: settings.cursorCustomHotspots } + : {}), + } + : {}), }; } @@ -112,6 +122,12 @@ export function stylePresetPatch(appearance: StylePresetAppearance): EditorSetti cursor: { ...appearance.cursor, theme: appearance.cursorTheme, + ...(appearance.cursorCustomTheme + ? { + customTheme: appearance.cursorCustomTheme, + customHotspots: appearance.cursorCustomHotspots ?? {}, + } + : {}), show: appearance.cursorShow, autoHide: appearance.cursorAutoHide, }, diff --git a/src/lib/cursor/cursorThemes.test.ts b/src/lib/cursor/cursorThemes.test.ts index 63707c75e..10ec34afb 100644 --- a/src/lib/cursor/cursorThemes.test.ts +++ b/src/lib/cursor/cursorThemes.test.ts @@ -6,11 +6,13 @@ import { CURSOR_KIND_IDS, CURSOR_KINDS, CURSOR_THEMES, + CUSTOM_CURSOR_THEME_ID, type CursorTheme, DEFAULT_CURSOR_SPRITES, DEFAULT_CURSOR_THEME_ID, normalizeCursorThemeId, readCursorAsArrow, + readCustomCursorHotspots, resolveCursorSprites, themePickerPreviewAssets, } from "./cursorThemes"; @@ -48,6 +50,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 +218,59 @@ describe("themePickerPreviewAssets", () => { expect(preview.pointer).toBeNull(); } }); + + it.each([ + false, + true, + ])("uses per-image custom hotspots in 3D=%s, including arrow replacement", (model3d) => { + const sprites = resolveCursorSprites( + "custom:test", + ["pointer"], + model3d, + { arrow: "data:image/png;base64,QQ==", text: "data:image/png;base64,Qg==" }, + { arrow: { x: 0.25, y: 0.5 }, text: { x: 0.7, y: 0.8 }, pointer: { x: 0.1, y: 0.2 } }, + ); + expect(sprites.arrow).toMatchObject({ hotspotX: 0.25, hotspotY: 0.5 }); + expect(sprites.pointer).toEqual(sprites.arrow); + expect(sprites.text).toMatchObject({ hotspotX: 0.7, hotspotY: 0.8 }); + expect(sprites.arrow.sculpt).toBeUndefined(); + expect(sprites.arrow.glass).toBeUndefined(); + }); + + it("rejects non-finite, out-of-range and unknown hotspot values", () => { + expect( + readCustomCursorHotspots({ + arrow: { x: NaN, y: 0 }, + pointer: { x: 1.1, y: 0 }, + text: { x: 0, y: 1 }, + wait: { x: 0.5, y: 0.5 }, + }), + ).toEqual({ text: { x: 0, y: 1 } }); + }); + + 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..683c582c2 100644 --- a/src/lib/cursor/cursorThemes.ts +++ b/src/lib/cursor/cursorThemes.ts @@ -38,6 +38,57 @@ 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>; +export type CustomCursorKind = keyof CustomCursorTheme; +export interface CursorHotspot { + x: number; + y: number; +} +export type CustomCursorHotspots = Partial>; + +export function readCustomCursorHotspots(value: unknown): CustomCursorHotspots { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const result: CustomCursorHotspots = {}; + for (const kind of ["arrow", "pointer", "text"] as const) { + const point = (value as Record)[kind]; + if (!point || typeof point !== "object") continue; + const { x, y } = point as Record; + if ( + typeof x === "number" && + typeof y === "number" && + Number.isFinite(x) && + Number.isFinite(y) && + x >= 0 && + x <= 1 && + y >= 0 && + y <= 1 + ) + result[kind] = { x, y }; + } + return result; +} + +export function defaultCursorHotspot(kind: CustomCursorKind): CursorHotspot { + const sprite = DEFAULT_CURSOR_SPRITES[kind]; + return { x: sprite.hotspotX, y: sprite.hotspotY }; +} + +export interface CustomCursorSet { + id: string; + number: number; + images: CustomCursorTheme; + hotspots?: CustomCursorHotspots; +} + +export function isCustomCursorThemeId(id: unknown): id is string { + return ( + typeof id === "string" && + (id === CUSTOM_CURSOR_THEME_ID || /^custom:[a-zA-Z0-9-]{1,64}$/.test(id)) + ); +} /** * One sprite as the native compositor consumes it: a path under the public asset root, @@ -289,6 +340,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 +364,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; @@ -323,7 +375,9 @@ export function getCursorTheme(id: string | null | undefined): CursorTheme | nul * default for anything unrecognized. */ export function normalizeCursorThemeId(id: unknown): string { - return typeof id === "string" && CURSOR_THEME_IDS.has(id) ? id : DEFAULT_CURSOR_THEME_ID; + return typeof id === "string" && (CURSOR_THEME_IDS.has(id) || isCustomCursorThemeId(id)) + ? id + : DEFAULT_CURSOR_THEME_ID; } /** @@ -340,6 +394,8 @@ export function resolveCursorSprites( themeId: string | null | undefined, asArrow: readonly CursorKind[] = [], model3d = false, + customTheme: CustomCursorTheme | null | undefined = null, + customHotspots: CustomCursorHotspots | null | undefined = null, ): Record { const sprites = { ...DEFAULT_CURSOR_SPRITES }; for (const [type, asset] of Object.entries(getCursorTheme(themeId)?.assets ?? {})) { @@ -356,6 +412,22 @@ export function resolveCursorSprites( ...(asset.glass ? { glass: `${themeId}/${type}` } : {}), }; } + if (isCustomCursorThemeId(themeId) && customTheme) { + const hotspots = readCustomCursorHotspots(customHotspots); + 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, + hotspotX: hotspots[type]?.x ?? DEFAULT_CURSOR_SPRITES[type].hotspotX, + hotspotY: hotspots[type]?.y ?? DEFAULT_CURSOR_SPRITES[type].hotspotY, + 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..39bbe7343 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -1391,6 +1391,23 @@ 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, + cursorCustomHotspots: { arrow: { x: 0.3, y: 0.6 } }, + }, + }), + ); + expect(scene.cursor.theme).toBe("custom"); + expect(scene.cursor.customTheme).toEqual(customTheme); + expect(scene.cursor.customHotspots).toEqual({ arrow: { x: 0.3, y: 0.6 } }); + 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..584be7e82 100644 --- a/src/native/sceneDescription.ts +++ b/src/native/sceneDescription.ts @@ -60,7 +60,11 @@ import { resolveWebcamReactiveZoom, webcamSizeToFraction, } from "@/lib/compositeLayout"; -import type { CursorKind } from "@/lib/cursor/cursorThemes"; +import type { + CursorKind, + CustomCursorHotspots, + 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 +463,9 @@ export interface SceneCursor { clipToBounds: boolean; /** Cursor theme id (sprite set). */ theme: string; + /** User-uploaded sprites for the custom cursor theme. */ + customTheme?: CustomCursorTheme; + customHotspots?: CustomCursorHotspots; } /** Everything native needs to compose the scene, serialized from one document. */ @@ -1307,6 +1314,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, customHotspots: settings.cursorCustomHotspots } + : {}), }, audio: { gainDb: settings.audioGainDb, diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index 256a111de..c1871ef98 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -811,6 +811,10 @@ 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-hotspot-local`, set-thumbnail follow-up; ad-hoc signed arm64 Custom app | macOS 26.7.1 (25G241), Apple Silicon | Partial: saved-set thumbnail slice passed | Real OS input through computer-use in the updated installed Custom app. Opened the selected custom set and inspected its uploaded green arrow in the editable arrow slot; missing hand/text images still show their standard icons. Pencil and remove controls remain visible. Left the panel open without changing project settings. Automated tests cover all three uploaded types, replacement, targeting a different saved set, and default icons in a new empty set; full unit suite passed (4484 tests, 1 skipped). Skipped: capture/export and other native checks (UI thumbnails only), Windows/Linux and release promotion. Local only; no push or PR update. | +| 2026-10-10 | local packaged `2.0.0`, `codex/custom-cursor-hotspot-local`, preview-zoom follow-up; ad-hoc signed arm64 Custom app | macOS 26.7.1 (25G241), Apple Silicon | Partial: dialog zoom slice passed | Real OS input through computer-use in the installed Custom app. Verified initial zoom 100% matches the editing image's displayed scale; End selects 200%, enlarges around the same target, and shows 200% in the label; Home restores 100%. X/Y stayed at 12.05%/8.81% throughout. Screenshots at both endpoints inspected. Dialog left open at 100%; no hotspot edit applied. Automated tests cover doubling a non-square image and preserving the point. Skipped: capture, export and unrelated editor/native rendering checks (only the dialog preview scale changed), Windows/Linux and release promotion. Local only; no push or PR update. | +| 2026-10-10 | local packaged `2.0.0`, `codex/custom-cursor-hotspot-local` at `c93dc9d6`; existing current Metal compositor and Swift helpers; ad-hoc signed arm64 QA app | macOS 26.7.1 (25G241), Apple Silicon | Partial: hotspot click/keyboard/persistence/export slice passed | Real OS input through computer-use against the separate QA profile and 3 s 640x480 fixture. Passed: localized editor with checkerboard, default arrow point 12.05%/8.81%, setting by image click, one-source-pixel arrow-key adjustment, exact numeric entry with OS key presses, light/dark preview, visible size slider, Apply, Undo/Redo, reset then Cancel preserving the stored point, and a real PNG upload opening the editor that creates no set when canceled. All five prior sets preserved. Set 5's point 50%/50% survived save, quit and relaunch, verified in the dialog and project JSON. Native 2D and extruded 3D preview and MP4 export (640x480, 60 fps) passed. Decoded the 0.5 s frames: the flat image moved 18 px left and 20 px up compared with default hotspot, matching the expected 18.2/19.8 px shift of its 48 px sprite; 3D art/shadow also visible in decoded export. QA app stopped. Skipped: reliable real drag verification (computer-use drag did not move the normal range slider either; pointer-drag mapping/clamping is unit-tested), GIF and preset desktop reruns, Glass/custom-material behavior, real capture/audio/webcam, Windows/Linux and release promotion. Local feature only; no PR update or push. | +| 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. Initial upload/manage pass: image-only panel, one-image upload with default fallbacks, replacing arrow, adding hand/text, removing one image, deleting the set with default selection restored, Undo, Escape/outside dismissal, save/restart, native preview, saved preset containing image data. MP4 (640x480, 60 fps) and GIF (640x480, 15 fps) decoded frames at 0.5/1.5/2.5 s showed the uploaded arrow/hand/text art. Follow-up on the multiple-set build: migrated the existing set, uploaded four additional sets, selected/replaced set 2 without touching others, deleted only set 2 (others kept numbers 1/3/4/5), restored it with Undo, and saved/restarted with all five sets and selected set 5 intact. Native preview and a new MP4 export showed set 5's uploaded art; decoded the 0.5 s frame. German numbered labels inspected; English/German five-set flows additionally covered by unit tests. QA screenshots inspected; test app stopped and test preset moved out of normal presets. Skipped: fresh screen/audio/webcam capture, 3D cursor comparison, other editor features, Windows/Linux and release promotion; GIF/preset desktop checks were not repeated after adding multiple sets. This is feature QA, not release-promotion QA. | | 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). |