Skip to content

Commit 64e0294

Browse files
feat: add project-owned custom cursor uploads
1 parent 82ea9ea commit 64e0294

32 files changed

Lines changed: 784 additions & 34 deletions

‎electron/ai-edition/document-service.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,33 @@ describe("DocumentService", () => {
3737
await fs.rm(mediaDir, { recursive: true, force: true });
3838
});
3939

40+
it("keeps custom cursor images in the project across save, reload, and style changes", async () => {
41+
const created = await service.createProject("Custom cursor");
42+
const customTheme = {
43+
arrow: "data:image/png;base64,YXJyb3c=",
44+
pointer: "data:image/png;base64,aGFuZA==",
45+
text: "data:image/jpeg;base64,dGV4dA==",
46+
};
47+
await service.saveProject(
48+
patchEditorSettings(created, {
49+
cursor: { theme: "custom", customTheme },
50+
}),
51+
);
52+
const reopened = await new DocumentService(tempDir, mediaDir).getProject(created.project.id);
53+
expect(getEditorSettings(reopened).cursorTheme).toBe("custom");
54+
expect(getEditorSettings(reopened).cursorCustomTheme).toEqual(customTheme);
55+
expect(
56+
getEditorSettings(await service.createProject("Inherited custom look")).cursorCustomTheme,
57+
).toEqual(customTheme);
58+
await service.saveProject(patchEditorSettings(reopened, { cursor: { theme: "default" } }));
59+
const switched = await service.getProject(created.project.id);
60+
expect(getEditorSettings(switched).cursorCustomTheme).toEqual(customTheme);
61+
await service.saveProject(patchEditorSettings(switched, { cursor: { customTheme: {} } }));
62+
expect(
63+
getEditorSettings(await service.getProject(created.project.id)).cursorCustomTheme,
64+
).toEqual({});
65+
});
66+
4067
describe("createProject", () => {
4168
it("creates a v8 doc with the given title and writes it to disk", async () => {
4269
const doc = await service.createProject("Demo Project");

‎electron/native-bridge/services/compositorViewService.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,16 @@ describe("resolveSceneAssetPaths", () => {
801801
expect(out.background.path).toBe(dataUrl);
802802
});
803803

804+
it("leaves custom cursor upload data URLs untouched", () => {
805+
const dataUrl = "data:image/png;base64,SGkh";
806+
const sprites = resolved({
807+
cursor: { theme: "custom", customTheme: { pointer: dataUrl } },
808+
}).cursor.cursorSprites;
809+
810+
expect(sprites.pointer.path).toBe(dataUrl);
811+
expect(sprites.arrow.path).toBe(path.join(resources, "cursors", "default", "arrow.png"));
812+
});
813+
804814
it("gives the default theme the built-in arrow, not a placeholder", () => {
805815
// Regression: "default" used to resolve to no sprite at all, and the compositor drew
806816
// its dot-and-ring fallback — a dot in a circle where the standard arrow belongs.

‎electron/native-bridge/services/compositorViewService.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
55
import { app, sharedTexture, type WebFrameMain } from "electron";
66
import {
77
type CursorKind,
8+
type CustomCursorTheme,
89
readCursorAsArrow,
910
resolveCursorSprites,
1011
} from "../../../src/lib/cursor/cursorThemes";
@@ -121,10 +122,15 @@ function resolveCursorSpritePaths(
121122
themeId: string,
122123
asArrow: readonly CursorKind[],
123124
model3d = false,
125+
customTheme?: CustomCursorTheme,
124126
): Record<string, SceneCursorSprite> {
125127
const resolved: Record<string, SceneCursorSprite> = {};
126-
for (const [type, sprite] of Object.entries(resolveCursorSprites(themeId, asArrow, model3d))) {
127-
const absolute = resolveSceneAssetPath(sprite.assetPath);
128+
for (const [type, sprite] of Object.entries(
129+
resolveCursorSprites(themeId, asArrow, model3d, customTheme),
130+
)) {
131+
const absolute = sprite.assetPath.startsWith("data:image/")
132+
? sprite.assetPath
133+
: resolveSceneAssetPath(sprite.assetPath);
128134
if (absolute) {
129135
resolved[type] = {
130136
path: absolute,
@@ -151,6 +157,7 @@ export function resolveSceneAssetPaths(sceneJson: string): string {
151157
theme?: string;
152158
asArrow?: unknown;
153159
model3d?: boolean;
160+
customTheme?: CustomCursorTheme;
154161
cursorSprites?: Record<string, SceneCursorSprite>;
155162
};
156163
webcamEffect?: {
@@ -187,6 +194,7 @@ export function resolveSceneAssetPaths(sceneJson: string): string {
187194
scene.cursor.theme,
188195
readCursorAsArrow(scene.cursor.asArrow, undefined, []),
189196
scene.cursor.model3d === true,
197+
scene.cursor.customTheme,
190198
);
191199
changed = true;
192200
}

‎src/components/ai-edition/CursorPane.levels.test.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,12 @@ describe("CursorPane cursor types", () => {
151151
expect(asArrow()).toEqual(["pointer"]);
152152
});
153153

154-
it("offers nothing to redraw when the video only shows the arrow", async () => {
154+
it("keeps the hand and text controls when the video only shows the arrow", async () => {
155155
renderWithRecording({}, ["arrow", null]);
156156
await waitFor(() => expect(nativeBridgeClient.cursor.getRecordingData).toHaveBeenCalled());
157-
expect(screen.queryByRole("group", { name: "Cursor types" })).toBeNull();
157+
const group = screen.getByRole("group", { name: "Cursor types" });
158+
expect(within(group).getByRole("button", { name: "Hand" })).toBeTruthy();
159+
expect(within(group).getByRole("button", { name: "Text" })).toBeTruthy();
158160
});
159161

160162
// 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", () => {
165167
await waitFor(() => expect(read).toHaveBeenCalledTimes(1));
166168
// Let the failed read settle before the pane opens again.
167169
await new Promise((resolve) => setTimeout(resolve, 0));
168-
expect(screen.queryByRole("group", { name: "Cursor types" })).toBeNull();
170+
expect(screen.getByRole("group", { name: "Cursor types" })).toBeTruthy();
169171

170172
cleanup();
171173
renderProject({}, originalPath);

‎src/components/ai-edition/CursorPane.preview.test.tsx‎

Lines changed: 177 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
// @vitest-environment jsdom
2-
import { cleanup, render, screen } from "@testing-library/react";
3-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
2+
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
3+
import { toast } from "sonner";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
45
import { I18nProvider } from "@/contexts/I18nContext";
56
import { LOCALE_STORAGE_KEY } from "@/i18n/config";
6-
import { CURSOR_THEMES } from "@/lib/cursor/cursorThemes";
7+
import { createEmptyDocument } from "@/lib/ai-edition/schema";
8+
import { getEditorSettings } from "@/lib/ai-edition/store/editorSettings";
9+
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
10+
import { CURSOR_THEMES, DEFAULT_CURSOR_SPRITES } from "@/lib/cursor/cursorThemes";
11+
import { nativeBridgeClient } from "@/native";
712
import { CursorPane } from "./RightPanes";
813

914
function stubStorage() {
@@ -30,12 +35,28 @@ function stubStorage() {
3035
beforeEach(() => {
3136
stubStorage();
3237
window.localStorage.setItem(LOCALE_STORAGE_KEY, "en");
38+
vi.spyOn(nativeBridgeClient.aiEdition, "save").mockImplementation(async (document) => ({
39+
success: true,
40+
document,
41+
}));
3342
});
3443

3544
afterEach(() => {
3645
cleanup();
46+
useProjectStore.getState().clear();
47+
vi.restoreAllMocks();
3748
});
3849

50+
function renderWithProject(legacyEditor: Record<string, unknown> = {}) {
51+
const document = { ...createEmptyDocument({ projectId: "p", title: "t" }), legacyEditor };
52+
useProjectStore.setState({ projectId: "p", document });
53+
return render(
54+
<I18nProvider>
55+
<CursorPane />
56+
</I18nProvider>,
57+
);
58+
}
59+
3960
describe("CursorPane theme picker", () => {
4061
it("shows the original cursor themes beside the default", () => {
4162
expect(CURSOR_THEMES).toHaveLength(6);
@@ -50,4 +71,157 @@ describe("CursorPane theme picker", () => {
5071
expect(button.querySelectorAll("img")).toHaveLength(1);
5172
}
5273
});
74+
75+
it("opens a three-slot custom cursor upload panel from the plus button", () => {
76+
renderWithProject();
77+
fireEvent.click(screen.getByRole("button", { name: "Add custom cursor" }));
78+
const panel = screen.getByRole("group", { name: "Custom cursor" });
79+
expect(within(panel).getByRole("button", { name: "Arrow" })).toBeTruthy();
80+
expect(within(panel).getByRole("button", { name: "Hand" })).toBeTruthy();
81+
expect(within(panel).getByRole("button", { name: "Text" })).toBeTruthy();
82+
expect(panel.textContent).toBe("");
83+
for (const [label, kind] of [
84+
["Arrow", "arrow"],
85+
["Hand", "pointer"],
86+
["Text", "text"],
87+
] as const) {
88+
expect(
89+
within(panel)
90+
.getByRole("button", { name: label })
91+
.querySelector("img")
92+
?.getAttribute("src"),
93+
).toContain(DEFAULT_CURSOR_SPRITES[kind].assetPath);
94+
}
95+
fireEvent.pointerDown(within(panel).getByRole("button", { name: "Text" }));
96+
expect(screen.getByRole("group", { name: "Custom cursor" })).toBeTruthy();
97+
fireEvent.pointerDown(document.body);
98+
expect(screen.queryByRole("group", { name: "Custom cursor" })).toBeNull();
99+
});
100+
101+
it("closes with Escape and toggles from the plus button", () => {
102+
renderWithProject();
103+
const add = screen.getByRole("button", { name: "Add custom cursor" });
104+
fireEvent.click(add);
105+
fireEvent.keyDown(document, { key: "Escape" });
106+
expect(screen.queryByRole("group", { name: "Custom cursor" })).toBeNull();
107+
fireEvent.click(add);
108+
fireEvent.pointerDown(add);
109+
fireEvent.click(add);
110+
expect(screen.queryByRole("group", { name: "Custom cursor" })).toBeNull();
111+
});
112+
113+
it("uploads one image with a blank MIME type, then adds other states without losing it", async () => {
114+
const { container } = renderWithProject();
115+
fireEvent.click(screen.getByRole("button", { name: "Add custom cursor" }));
116+
const panel = screen.getByRole("group", { name: "Custom cursor" });
117+
const input = container.querySelector<HTMLInputElement>('input[type="file"]');
118+
if (!input) throw new Error("Missing cursor upload input");
119+
let uploads = 0;
120+
const upload = async (label: string, name: string, contents: string) => {
121+
uploads += 1;
122+
fireEvent.click(within(panel).getByRole("button", { name: label }));
123+
fireEvent.change(input, { target: { files: [new File([contents], name)] } });
124+
await waitFor(() => expect(nativeBridgeClient.aiEdition.save).toHaveBeenCalledTimes(uploads));
125+
};
126+
await upload("Arrow", "arrow.PNG", "arrow-image");
127+
const arrow = getEditorSettings(useProjectStore.getState().document).cursorCustomTheme.arrow;
128+
expect(arrow).toMatch(/^data:image\/png;base64,/);
129+
expect(screen.getByRole("button", { name: "Custom" }).getAttribute("aria-pressed")).toBe(
130+
"true",
131+
);
132+
await upload("Hand", "hand.png", "hand-image");
133+
await upload("Text", "text.jpg", "text-image");
134+
const theme = getEditorSettings(useProjectStore.getState().document).cursorCustomTheme;
135+
expect(theme.arrow).toBe(arrow);
136+
expect(theme.pointer).toMatch(/^data:image\/png;base64,/);
137+
expect(theme.text).toMatch(/^data:image\/jpeg;base64,/);
138+
await upload("Replace Arrow image", "replacement.png", "replacement-image");
139+
const replaced = getEditorSettings(useProjectStore.getState().document).cursorCustomTheme;
140+
expect(replaced.arrow).not.toBe(arrow);
141+
expect(replaced.pointer).toBe(theme.pointer);
142+
expect(replaced.text).toBe(theme.text);
143+
expect(screen.getAllByRole("button", { name: "Custom" })).toHaveLength(1);
144+
fireEvent.click(screen.getByRole("button", { name: "Default" }));
145+
await waitFor(() =>
146+
expect(getEditorSettings(useProjectStore.getState().document).cursorTheme).toBe("default"),
147+
);
148+
fireEvent.click(screen.getByRole("button", { name: "Custom" }));
149+
await waitFor(() =>
150+
expect(getEditorSettings(useProjectStore.getState().document).cursorTheme).toBe("custom"),
151+
);
152+
expect(getEditorSettings(useProjectStore.getState().document).cursorCustomTheme).toEqual(
153+
replaced,
154+
);
155+
});
156+
157+
it("removes one custom image while keeping the other images and the selected style", async () => {
158+
const arrow = "data:image/png;base64,YXJyb3c=";
159+
const pointer = "data:image/png;base64,aGFuZA==";
160+
renderWithProject({ cursorTheme: "custom", cursorCustomTheme: { arrow, pointer } });
161+
fireEvent.click(screen.getByRole("button", { name: "Add custom cursor" }));
162+
fireEvent.click(screen.getByRole("button", { name: "Remove Hand image" }));
163+
await waitFor(() => {
164+
const settings = getEditorSettings(useProjectStore.getState().document);
165+
expect(settings.cursorCustomTheme).toEqual({ arrow });
166+
expect(settings.cursorTheme).toBe("custom");
167+
});
168+
expect(screen.queryByRole("button", { name: "Remove Hand image" })).toBeNull();
169+
expect(
170+
within(screen.getByRole("group", { name: "Custom cursor" })).getByRole("button", {
171+
name: "Hand",
172+
}),
173+
).toBeTruthy();
174+
});
175+
176+
it.each([
177+
"custom",
178+
"studio-ink",
179+
])("removes the last custom image safely while %s is selected", async (selected) => {
180+
renderWithProject({
181+
cursorTheme: selected,
182+
cursorCustomTheme: { text: "data:image/png;base64,dGV4dA==" },
183+
});
184+
fireEvent.click(screen.getByRole("button", { name: "Add custom cursor" }));
185+
fireEvent.click(screen.getByRole("button", { name: "Remove Text image" }));
186+
await waitFor(() => {
187+
const settings = getEditorSettings(useProjectStore.getState().document);
188+
expect(settings.cursorCustomTheme).toEqual({});
189+
expect(settings.cursorTheme).toBe(selected === "custom" ? "default" : selected);
190+
});
191+
expect(screen.queryByRole("button", { name: "Custom" })).toBeNull();
192+
expect(screen.queryByRole("button", { name: "Delete custom cursor set" })).toBeNull();
193+
});
194+
195+
it("deletes the whole custom cursor set without closing the upload panel", async () => {
196+
renderWithProject({
197+
cursorTheme: "custom",
198+
cursorCustomTheme: {
199+
arrow: "data:image/png;base64,YXJyb3c=",
200+
pointer: "data:image/png;base64,aGFuZA==",
201+
},
202+
});
203+
fireEvent.click(screen.getByRole("button", { name: "Add custom cursor" }));
204+
fireEvent.click(screen.getByRole("button", { name: "Delete custom cursor set" }));
205+
await waitFor(() =>
206+
expect(getEditorSettings(useProjectStore.getState().document).cursorCustomTheme).toEqual({}),
207+
);
208+
expect(getEditorSettings(useProjectStore.getState().document).cursorTheme).toBe("default");
209+
expect(screen.queryByRole("button", { name: "Custom" })).toBeNull();
210+
expect(
211+
within(screen.getByRole("group", { name: "Custom cursor" })).getAllByRole("button"),
212+
).toHaveLength(3);
213+
});
214+
215+
it("rejects unsupported files without changing the project", () => {
216+
const error = vi.spyOn(toast, "error");
217+
const { container } = renderWithProject();
218+
const input = container.querySelector<HTMLInputElement>('input[type="file"]');
219+
if (!input) throw new Error("Missing cursor upload input");
220+
fireEvent.change(input, {
221+
target: { files: [new File(["text"], "fake.png", { type: "text/plain" })] },
222+
});
223+
expect(error).toHaveBeenCalled();
224+
expect(nativeBridgeClient.aiEdition.save).not.toHaveBeenCalled();
225+
expect(getEditorSettings(useProjectStore.getState().document).cursorCustomTheme).toEqual({});
226+
});
53227
});

‎src/components/ai-edition/NewEditorShell.module.css‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1448,6 +1448,58 @@ textarea.control {
14481448
.cursorCellOff img { opacity: 0.35; transition: opacity 150ms ease; }
14491449
.cursorCellOff:hover:not(:disabled) img { opacity: 0.7; }
14501450
.cursorCellFixed { cursor: default; }
1451+
.customCursorPanel {
1452+
display: grid;
1453+
grid-template-columns: repeat(3, minmax(0, 1fr));
1454+
gap: 6px;
1455+
margin: calc(-1 * var(--sp-2)) var(--sp-4) var(--sp-4);
1456+
padding: var(--sp-2);
1457+
border: 1px solid var(--border-soft);
1458+
border-radius: var(--r-md);
1459+
background: color-mix(in oklab, var(--fg) 4%, transparent);
1460+
}
1461+
.customCursorSlot {
1462+
min-width: 0;
1463+
width: 100%;
1464+
aspect-ratio: 1;
1465+
border: 1px solid transparent;
1466+
border-radius: var(--r-sm);
1467+
background: color-mix(in oklab, var(--fg) 5%, transparent);
1468+
color: var(--muted);
1469+
display: grid;
1470+
place-items: center;
1471+
gap: var(--sp-1);
1472+
padding: var(--sp-2);
1473+
font: 600 0.72rem var(--font-body);
1474+
cursor: pointer;
1475+
transition: border-color 150ms ease, color 150ms ease, background-color 150ms ease;
1476+
}
1477+
.customCursorSlot:hover {
1478+
border-color: var(--border-hi);
1479+
color: var(--fg-2);
1480+
}
1481+
.customCursorSlot:disabled {
1482+
opacity: 0.5;
1483+
cursor: not-allowed;
1484+
}
1485+
.customCursorUploadSlot { position: relative; min-width: 0; }
1486+
.customCursorSlot.isActive { border-color: var(--accent); }
1487+
.customCursorRemove, .customCursorDelete {
1488+
display: grid;
1489+
place-items: center;
1490+
width: 24px;
1491+
height: 24px;
1492+
padding: 0;
1493+
border: 1px solid var(--border-soft);
1494+
border-radius: var(--r-sm);
1495+
background: var(--surface-2);
1496+
color: var(--muted);
1497+
cursor: pointer;
1498+
}
1499+
.customCursorRemove { position: absolute; top: 2px; right: 2px; }
1500+
.customCursorDelete { width: 28px; height: 28px; }
1501+
.customCursorRemove:hover, .customCursorDelete:hover { color: var(--danger); border-color: var(--danger); }
1502+
.customCursorActions { grid-column: 1 / -1; display: flex; justify-content: flex-end; }
14511503
/* A row that belongs to the grid right above it: the 3D switch under the cursor style. */
14521504
.paneRowTight { margin-top: calc(-1 * var(--sp-2)); }
14531505
/* Same cell, reused for the icon+label rows (camera shape, camera background).

0 commit comments

Comments
 (0)