Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions electron/ai-edition/document-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
15 changes: 15 additions & 0 deletions electron/native-bridge/services/compositorViewService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 14 additions & 2 deletions electron/native-bridge/services/compositorViewService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -121,10 +123,16 @@ function resolveCursorSpritePaths(
themeId: string,
asArrow: readonly CursorKind[],
model3d = false,
customTheme?: CustomCursorTheme,
customHotspots?: CustomCursorHotspots,
): Record<string, SceneCursorSprite> {
const resolved: Record<string, SceneCursorSprite> = {};
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,
Expand All @@ -151,6 +159,8 @@ export function resolveSceneAssetPaths(sceneJson: string): string {
theme?: string;
asArrow?: unknown;
model3d?: boolean;
customTheme?: CustomCursorTheme;
customHotspots?: CustomCursorHotspots;
cursorSprites?: Record<string, SceneCursorSprite>;
};
webcamEffect?: {
Expand Down Expand Up @@ -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;
}
Expand Down
26 changes: 26 additions & 0 deletions src/components/ai-edition/CursorHotspotEditor.module.css
Original file line number Diff line number Diff line change
@@ -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; } }
126 changes: 126 additions & 0 deletions src/components/ai-edition/CursorHotspotEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<I18nProvider>
<CursorHotspotEditor
draft={{
setId: "custom:test",
kind,
image: "data:image/png;base64,QQ==",
hotspot: defaultCursorHotspot(kind),
}}
onClose={onClose}
onApply={onApply}
/>
</I18nProvider>,
);
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();
});
});
Loading
Loading