Skip to content

Commit e1bac39

Browse files
feat: support multiple numbered custom cursor sets
1 parent 64e0294 commit e1bac39

10 files changed

Lines changed: 302 additions & 62 deletions

File tree

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,32 @@ describe("DocumentService", () => {
5757
).toEqual(customTheme);
5858
await service.saveProject(patchEditorSettings(reopened, { cursor: { theme: "default" } }));
5959
const switched = await service.getProject(created.project.id);
60-
expect(getEditorSettings(switched).cursorCustomTheme).toEqual(customTheme);
60+
expect(getEditorSettings(switched).cursorCustomThemes[0].images).toEqual(customTheme);
6161
await service.saveProject(patchEditorSettings(switched, { cursor: { customTheme: {} } }));
6262
expect(
6363
getEditorSettings(await service.getProject(created.project.id)).cursorCustomTheme,
6464
).toEqual({});
6565
});
6666

67+
it("saves and reopens five independent custom cursor sets", async () => {
68+
let doc = await service.createProject("Five cursor sets");
69+
for (let number = 1; number <= 5; number++) {
70+
doc = patchEditorSettings(doc, {
71+
cursor: {
72+
theme: `custom:pack-${number}`,
73+
customTheme: { arrow: `data:image/png;base64,${number}` },
74+
},
75+
});
76+
}
77+
await service.saveProject(doc);
78+
const restored = await new DocumentService(tempDir, mediaDir).getProject(doc.project.id);
79+
expect(getEditorSettings(restored).cursorCustomThemes).toEqual(
80+
getEditorSettings(doc).cursorCustomThemes,
81+
);
82+
expect(getEditorSettings(restored).cursorCustomThemes).toHaveLength(5);
83+
expect(getEditorSettings(restored).cursorTheme).toBe("custom:pack-5");
84+
});
85+
6786
describe("createProject", () => {
6887
it("creates a v8 doc with the given title and writes it to disk", async () => {
6988
const doc = await service.createProject("Demo Project");

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

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ describe("CursorPane theme picker", () => {
126126
await upload("Arrow", "arrow.PNG", "arrow-image");
127127
const arrow = getEditorSettings(useProjectStore.getState().document).cursorCustomTheme.arrow;
128128
expect(arrow).toMatch(/^data:image\/png;base64,/);
129-
expect(screen.getByRole("button", { name: "Custom" }).getAttribute("aria-pressed")).toBe(
129+
expect(screen.getByRole("button", { name: "Custom 1" }).getAttribute("aria-pressed")).toBe(
130130
"true",
131131
);
132132
await upload("Hand", "hand.png", "hand-image");
@@ -140,14 +140,16 @@ describe("CursorPane theme picker", () => {
140140
expect(replaced.arrow).not.toBe(arrow);
141141
expect(replaced.pointer).toBe(theme.pointer);
142142
expect(replaced.text).toBe(theme.text);
143-
expect(screen.getAllByRole("button", { name: "Custom" })).toHaveLength(1);
143+
expect(screen.getAllByRole("button", { name: "Custom 1" })).toHaveLength(1);
144144
fireEvent.click(screen.getByRole("button", { name: "Default" }));
145145
await waitFor(() =>
146146
expect(getEditorSettings(useProjectStore.getState().document).cursorTheme).toBe("default"),
147147
);
148-
fireEvent.click(screen.getByRole("button", { name: "Custom" }));
148+
fireEvent.click(screen.getByRole("button", { name: "Custom 1" }));
149149
await waitFor(() =>
150-
expect(getEditorSettings(useProjectStore.getState().document).cursorTheme).toBe("custom"),
150+
expect(getEditorSettings(useProjectStore.getState().document).cursorTheme).toMatch(
151+
/^custom:/,
152+
),
151153
);
152154
expect(getEditorSettings(useProjectStore.getState().document).cursorCustomTheme).toEqual(
153155
replaced,
@@ -158,7 +160,7 @@ describe("CursorPane theme picker", () => {
158160
const arrow = "data:image/png;base64,YXJyb3c=";
159161
const pointer = "data:image/png;base64,aGFuZA==";
160162
renderWithProject({ cursorTheme: "custom", cursorCustomTheme: { arrow, pointer } });
161-
fireEvent.click(screen.getByRole("button", { name: "Add custom cursor" }));
163+
fireEvent.click(screen.getByRole("button", { name: "Custom 1" }));
162164
fireEvent.click(screen.getByRole("button", { name: "Remove Hand image" }));
163165
await waitFor(() => {
164166
const settings = getEditorSettings(useProjectStore.getState().document);
@@ -173,22 +175,19 @@ describe("CursorPane theme picker", () => {
173175
).toBeTruthy();
174176
});
175177

176-
it.each([
177-
"custom",
178-
"studio-ink",
179-
])("removes the last custom image safely while %s is selected", async (selected) => {
178+
it("removes the last custom image safely", async () => {
180179
renderWithProject({
181-
cursorTheme: selected,
180+
cursorTheme: "custom",
182181
cursorCustomTheme: { text: "data:image/png;base64,dGV4dA==" },
183182
});
184-
fireEvent.click(screen.getByRole("button", { name: "Add custom cursor" }));
183+
fireEvent.click(screen.getByRole("button", { name: "Custom 1" }));
185184
fireEvent.click(screen.getByRole("button", { name: "Remove Text image" }));
186185
await waitFor(() => {
187186
const settings = getEditorSettings(useProjectStore.getState().document);
188187
expect(settings.cursorCustomTheme).toEqual({});
189-
expect(settings.cursorTheme).toBe(selected === "custom" ? "default" : selected);
188+
expect(settings.cursorTheme).toBe("default");
190189
});
191-
expect(screen.queryByRole("button", { name: "Custom" })).toBeNull();
190+
expect(screen.queryByRole("button", { name: "Custom 1" })).toBeNull();
192191
expect(screen.queryByRole("button", { name: "Delete custom cursor set" })).toBeNull();
193192
});
194193

@@ -200,18 +199,70 @@ describe("CursorPane theme picker", () => {
200199
pointer: "data:image/png;base64,aGFuZA==",
201200
},
202201
});
203-
fireEvent.click(screen.getByRole("button", { name: "Add custom cursor" }));
202+
fireEvent.click(screen.getByRole("button", { name: "Custom 1" }));
204203
fireEvent.click(screen.getByRole("button", { name: "Delete custom cursor set" }));
205204
await waitFor(() =>
206205
expect(getEditorSettings(useProjectStore.getState().document).cursorCustomTheme).toEqual({}),
207206
);
208207
expect(getEditorSettings(useProjectStore.getState().document).cursorTheme).toBe("default");
209-
expect(screen.queryByRole("button", { name: "Custom" })).toBeNull();
208+
expect(screen.queryByRole("button", { name: "Custom 1" })).toBeNull();
210209
expect(
211210
within(screen.getByRole("group", { name: "Custom cursor" })).getAllByRole("button"),
212211
).toHaveLength(3);
213212
});
214213

214+
it.each([
215+
"en",
216+
"de",
217+
])("keeps five independent sets and localized stable numbers in %s", async (locale) => {
218+
window.localStorage.setItem(LOCALE_STORAGE_KEY, locale);
219+
const { container } = renderWithProject();
220+
const addLabel = locale === "de" ? "Eigenen Cursor hinzufügen" : "Add custom cursor";
221+
const name = (number: number) =>
222+
`${locale === "de" ? "Benutzerdefiniert" : "Custom"} ${number}`;
223+
const input = container.querySelector<HTMLInputElement>('input[type="file"]');
224+
if (!input) throw new Error("Missing cursor upload input");
225+
for (let number = 1; number <= 5; number++) {
226+
fireEvent.click(screen.getByRole("button", { name: addLabel }));
227+
fireEvent.click(screen.getByRole("button", { name: locale === "de" ? "Pfeil" : "Arrow" }));
228+
fireEvent.change(input, { target: { files: [new File([`image-${number}`], "arrow.png")] } });
229+
await waitFor(() => expect(screen.getByRole("button", { name: name(number) })).toBeTruthy());
230+
}
231+
const original = getEditorSettings(useProjectStore.getState().document).cursorCustomThemes;
232+
expect(original).toHaveLength(5);
233+
expect(new Set(original.map((entry) => entry.id)).size).toBe(5);
234+
fireEvent.click(screen.getByRole("button", { name: name(2) }));
235+
fireEvent.click(
236+
screen.getByRole("button", {
237+
name: locale === "de" ? "Bild für Pfeil ersetzen" : "Replace Arrow image",
238+
}),
239+
);
240+
fireEvent.change(input, { target: { files: [new File(["replacement"], "arrow.png")] } });
241+
await waitFor(() =>
242+
expect(
243+
getEditorSettings(useProjectStore.getState().document).cursorCustomTheme.arrow,
244+
).not.toBe(original[1].images.arrow),
245+
);
246+
expect(
247+
getEditorSettings(useProjectStore.getState().document).cursorCustomThemes.filter(
248+
(entry) => entry.id !== original[1].id,
249+
),
250+
).toEqual(original.filter((entry) => entry.id !== original[1].id));
251+
fireEvent.click(
252+
screen.getByRole("button", {
253+
name:
254+
locale === "de" ? "Benutzerdefiniertes Cursor-Set löschen" : "Delete custom cursor set",
255+
}),
256+
);
257+
await waitFor(() => expect(screen.queryByRole("button", { name: name(2) })).toBeNull());
258+
for (const number of [1, 3, 4, 5])
259+
expect(screen.getByRole("button", { name: name(number) })).toBeTruthy();
260+
fireEvent.click(screen.getByRole("button", { name: name(1) }));
261+
expect(getEditorSettings(useProjectStore.getState().document).cursorCustomTheme).toEqual(
262+
original[0].images,
263+
);
264+
});
265+
215266
it("rejects unsupported files without changing the project", () => {
216267
const error = vi.spyOn(toast, "error");
217268
const { container } = renderWithProject();

‎src/components/ai-edition/RightPanes.tsx‎

Lines changed: 44 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,10 @@ import {
112112
CURSOR_KIND_IDS,
113113
CURSOR_KINDS,
114114
CURSOR_THEMES,
115-
CUSTOM_CURSOR_THEME_ID,
116115
type CursorKind,
117116
DEFAULT_CURSOR_SPRITES,
118117
DEFAULT_CURSOR_THEME_ID,
118+
isCustomCursorThemeId,
119119
resolveCursorSprites,
120120
themePickerPreviewAssets,
121121
} from "@/lib/cursor/cursorThemes";
@@ -3742,7 +3742,8 @@ function useCursorFileInput(onPicked: (slot: CustomCursorUploadSlot, dataUrl: st
37423742
export function CursorPane() {
37433743
const ts = useScopedT("settings");
37443744
const { settings, set, setLive, commit, hasDocument } = useEditorSettings();
3745-
const [customPanelOpen, setCustomPanelOpen] = useState(false);
3745+
const [customPanelTarget, setCustomPanelTarget] = useState<string | null>(null);
3746+
const customPanelOpen = customPanelTarget !== null;
37463747
const customPanelRef = useRef<HTMLDivElement>(null);
37473748
const customButtonRef = useRef<HTMLButtonElement>(null);
37483749
useEffect(() => {
@@ -3753,10 +3754,10 @@ export function CursorPane() {
37533754
!customPanelRef.current?.contains(event.target) &&
37543755
!customButtonRef.current?.contains(event.target)
37553756
)
3756-
setCustomPanelOpen(false);
3757+
setCustomPanelTarget(null);
37573758
};
37583759
const dismissOnEscape = (event: KeyboardEvent) => {
3759-
if (event.key === "Escape") setCustomPanelOpen(false);
3760+
if (event.key === "Escape") setCustomPanelTarget(null);
37603761
};
37613762
document.addEventListener("pointerdown", dismissOutside);
37623763
document.addEventListener("keydown", dismissOnEscape);
@@ -3765,26 +3766,31 @@ export function CursorPane() {
37653766
document.removeEventListener("keydown", dismissOnEscape);
37663767
};
37673768
}, [customPanelOpen]);
3768-
const customCursorUploaded = Object.keys(settings.cursorCustomTheme).length > 0;
3769-
const customCursorSprites = useMemo(
3770-
() => resolveCursorSprites(CUSTOM_CURSOR_THEME_ID, [], false, settings.cursorCustomTheme),
3771-
[settings.cursorCustomTheme],
3772-
);
3769+
const panelImages =
3770+
settings.cursorCustomThemes.find((set) => set.id === customPanelTarget)?.images ?? {};
3771+
const customCursorUploaded = Object.keys(panelImages).length > 0;
37733772
const { pick: pickCursorFile, input: cursorFileInput } = useCursorFileInput((slot, dataUrl) => {
3773+
if (!customPanelTarget) return;
37743774
void set({
37753775
cursor: {
3776-
theme: CUSTOM_CURSOR_THEME_ID,
3777-
customTheme: { ...settings.cursorCustomTheme, [slot]: dataUrl },
3776+
theme: customPanelTarget,
3777+
customTheme: { ...panelImages, [slot]: dataUrl },
37783778
},
37793779
});
37803780
});
37813781
const removeCustomCursor = (slot?: CustomCursorUploadSlot) => {
3782-
const customTheme = slot ? { ...settings.cursorCustomTheme } : {};
3782+
const customTheme = slot ? { ...panelImages } : {};
37833783
if (slot) delete customTheme[slot];
37843784
void set({
37853785
cursor: {
3786-
customTheme,
3787-
...(Object.keys(customTheme).length === 0 && settings.cursorTheme === CUSTOM_CURSOR_THEME_ID
3786+
customThemes: settings.cursorCustomThemes.flatMap((entry) =>
3787+
entry.id === customPanelTarget
3788+
? Object.keys(customTheme).length
3789+
? [{ ...entry, images: customTheme }]
3790+
: []
3791+
: [entry],
3792+
),
3793+
...(Object.keys(customTheme).length === 0 && settings.cursorTheme === customPanelTarget
37883794
? { theme: DEFAULT_CURSOR_THEME_ID }
37893795
: {}),
37903796
},
@@ -3810,20 +3816,20 @@ export function CursorPane() {
38103816
[ts],
38113817
);
38123818
const cursorThemeCells = useMemo(
3813-
() =>
3814-
customCursorUploaded
3815-
? [
3816-
...cursorThemeOptions,
3817-
{
3818-
id: CUSTOM_CURSOR_THEME_ID,
3819-
name: ts("cursor.themeCustom"),
3820-
previewUrl: safeAssetUrl(
3821-
customCursorSprites.arrow.assetPath || DEFAULT_CURSOR_SPRITES.arrow.assetPath,
3822-
),
3823-
},
3824-
]
3825-
: cursorThemeOptions,
3826-
[customCursorSprites.arrow.assetPath, customCursorUploaded, cursorThemeOptions, ts],
3819+
() => [
3820+
...cursorThemeOptions,
3821+
...settings.cursorCustomThemes.map((entry) => ({
3822+
id: entry.id,
3823+
name: `${ts("cursor.themeCustom")} ${entry.number}`,
3824+
previewUrl: safeAssetUrl(
3825+
entry.images.arrow ??
3826+
entry.images.pointer ??
3827+
entry.images.text ??
3828+
DEFAULT_CURSOR_SPRITES.arrow.assetPath,
3829+
),
3830+
})),
3831+
],
3832+
[settings.cursorCustomThemes, cursorThemeOptions, ts],
38273833
);
38283834

38293835
// What the preview draws for each state: the theme's art, the built-in art where it has none.
@@ -3904,7 +3910,10 @@ export function CursorPane() {
39043910
aria-label={option.name}
39053911
aria-pressed={isActive}
39063912
disabled={!hasDocument}
3907-
onClick={() => void set({ cursor: { theme: option.id } })}
3913+
onClick={() => {
3914+
void set({ cursor: { theme: option.id } });
3915+
setCustomPanelTarget(isCustomCursorThemeId(option.id) ? option.id : null);
3916+
}}
39083917
>
39093918
<img
39103919
src={option.previewUrl}
@@ -3924,7 +3933,11 @@ export function CursorPane() {
39243933
aria-label={ts("cursor.addCustom")}
39253934
aria-expanded={customPanelOpen}
39263935
disabled={!hasDocument}
3927-
onClick={() => setCustomPanelOpen((open) => !open)}
3936+
onClick={() =>
3937+
setCustomPanelTarget(
3938+
customPanelOpen && !customCursorUploaded ? null : `custom:${crypto.randomUUID()}`,
3939+
)
3940+
}
39283941
>
39293942
<Plus size={18} aria-hidden="true" />
39303943
</button>
@@ -3943,7 +3956,7 @@ export function CursorPane() {
39433956
: id === "pointer"
39443957
? ts("cursor.typePointer")
39453958
: ts("cursor.typeText");
3946-
const uploaded = Boolean(settings.cursorCustomTheme[id]);
3959+
const uploaded = Boolean(panelImages[id]);
39473960
const uploadLabel = uploaded ? ts("cursor.replaceCustom", { type: label }) : label;
39483961
const removeLabel = ts("cursor.removeCustom", { type: label });
39493962
return (

‎src/lib/ai-edition/store/editorSettings.test.ts‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ describe("getEditorSettings", () => {
122122
});
123123
expect(doc.legacyEditor).toMatchObject({
124124
cursorTheme: CUSTOM_CURSOR_THEME_ID,
125-
cursorCustomTheme: { pointer: dataUrl },
125+
cursorCustomThemes: [{ id: "custom", number: 1, images: { pointer: dataUrl } }],
126126
});
127127
expect(getEditorSettings(doc).cursorCustomTheme).toEqual({ pointer: dataUrl });
128128
});
@@ -131,6 +131,7 @@ describe("getEditorSettings", () => {
131131
const snap = getEditorSettings({
132132
...baseDoc,
133133
legacyEditor: {
134+
cursorTheme: "custom",
134135
cursorCustomTheme: {
135136
arrow: "data:image/png;base64,QQ==",
136137
pointer: "/outside/project.png",
@@ -142,6 +143,63 @@ describe("getEditorSettings", () => {
142143
expect(snap.cursorCustomTheme).toEqual({ arrow: "data:image/png;base64,QQ==" });
143144
});
144145

146+
it("migrates old uploads, edits one of five sets, and persists deletion without reviving legacy images", () => {
147+
let doc = {
148+
...baseDoc,
149+
legacyEditor: {
150+
cursorTheme: "custom",
151+
cursorCustomTheme: { arrow: "data:image/png;base64,QQ==" },
152+
},
153+
} as AxcutDocument;
154+
for (let index = 2; index <= 5; index++) {
155+
doc = patchEditorSettings(doc, {
156+
cursor: {
157+
theme: `custom:set-${index}`,
158+
customTheme: { text: `data:image/png;base64,${index}` },
159+
},
160+
});
161+
}
162+
const sets = getEditorSettings(doc).cursorCustomThemes;
163+
expect(sets).toHaveLength(5);
164+
expect(sets[0]).toEqual({
165+
id: "custom",
166+
number: 1,
167+
images: { arrow: "data:image/png;base64,QQ==" },
168+
});
169+
doc = patchEditorSettings(doc, {
170+
cursor: { theme: "custom:set-2", customTheme: { pointer: "data:image/png;base64,Qg==" } },
171+
});
172+
expect(getEditorSettings(doc).cursorCustomThemes[1]).toEqual({
173+
id: "custom:set-2",
174+
number: 2,
175+
images: { pointer: "data:image/png;base64,Qg==" },
176+
});
177+
doc = patchEditorSettings(doc, { cursor: { customThemes: [] } });
178+
expect(getEditorSettings(JSON.parse(JSON.stringify(doc))).cursorCustomThemes).toEqual([]);
179+
expect(getEditorSettings(doc).cursorTheme).toBe("default");
180+
expect(doc.legacyEditor).not.toHaveProperty("cursorCustomTheme");
181+
});
182+
183+
it("filters invalid and duplicate set IDs and normalizes their numbers", () => {
184+
const images = { arrow: "data:image/png;base64,QQ==" };
185+
const snap = getEditorSettings({
186+
...baseDoc,
187+
legacyEditor: {
188+
cursorCustomThemes: [
189+
{ id: "custom:one", number: 3, images },
190+
{ id: "custom:one", number: 9, images },
191+
{ id: "custom:two", number: 3, images },
192+
{ id: "studio-ink", number: 5, images },
193+
{ id: "custom:empty", number: 6, images: {} },
194+
],
195+
},
196+
});
197+
expect(snap.cursorCustomThemes.map((entry) => [entry.id, entry.number])).toEqual([
198+
["custom:one", 3],
199+
["custom:two", 4],
200+
]);
201+
});
202+
145203
it("reads the old background blur switch as the amount it drew", () => {
146204
const legacy = (showBlur: boolean): AxcutDocument => ({
147205
...baseDoc,

0 commit comments

Comments
 (0)