From 4f0402e3fff98ba38db3900bac098d12d241ed99 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 9 May 2026 00:01:31 +0200 Subject: [PATCH 1/5] refactor(store): extract DUPLICATE_OFFSET_DOTS constant The literal 20 was repeated three times across duplicateObject, duplicateSelectedObjects, and pasteObjects as the per-copy positional offset. Name it once with a comment that explains the dots/mm relation so future tweaks happen in one place. --- src/store/labelStore.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index dd159a1..5ee7f25 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -131,6 +131,12 @@ function updateCurrentObjects( }; } +/** Stagger duplicate / paste offsets so consecutive copies don't overlap. + * Multiplied by the running count so the Nth copy lands N×offset from the + * source. 20 dots ≈ 2.5 mm at 8dpmm — large enough to be visible without + * pushing copies off-canvas. */ +const DUPLICATE_OFFSET_DOTS = 20; + function migrateLegacy(persistedState: unknown, version: number): unknown { if (!persistedState || typeof persistedState !== 'object') return persistedState; let s = persistedState as Record; @@ -219,8 +225,8 @@ export const useLabelStore = create()( const copy: LabelObject = { ...src, id: crypto.randomUUID(), - x: src.x + 20, - y: src.y + 20, + x: src.x + DUPLICATE_OFFSET_DOTS, + y: src.y + DUPLICATE_OFFSET_DOTS, }; return { ...updateCurrentObjects(state, (curr) => [...curr, copy]), @@ -233,7 +239,7 @@ export const useLabelStore = create()( if (state.selectedIds.length === 0) return {}; const objs = currentObjects(state); const duplicateCount = state.duplicateCount + 1; - const offset = duplicateCount * 20; + const offset = duplicateCount * DUPLICATE_OFFSET_DOTS; const copies: LabelObject[] = state.selectedIds.flatMap((id) => { const src = objs.find((o) => o.id === id); if (!src) return []; @@ -260,7 +266,7 @@ export const useLabelStore = create()( set((state) => { if (state.clipboard.length === 0) return {}; const pasteCount = state.pasteCount + 1; - const offset = pasteCount * 20; + const offset = pasteCount * DUPLICATE_OFFSET_DOTS; const copies: LabelObject[] = state.clipboard.map((src) => ({ ...src, id: crypto.randomUUID(), From d1a69a762e9da8bd0bf4a0bf74381322a31630f0 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 9 May 2026 00:01:37 +0200 Subject: [PATCH 2/5] i18n(properties): localise multi-select header and arrow-keys hint PropertiesPanel rendered '{n} objects selected' and 'use arrow keys to move' as hardcoded English in an otherwise fully-localised UI. Add two keys (multipleSelectedFmt, multipleSelectedHint) under properties; the former carries an {n} placeholder substituted at render time. All 32 locales filled in via add_locale_key.local.py. --- src/components/Properties/PropertiesPanel.tsx | 4 ++-- src/locales/ar.ts | 2 ++ src/locales/bg.ts | 2 ++ src/locales/cs.ts | 2 ++ src/locales/da.ts | 2 ++ src/locales/de.ts | 2 ++ src/locales/el.ts | 2 ++ src/locales/en.ts | 2 ++ src/locales/es.ts | 2 ++ src/locales/et.ts | 2 ++ src/locales/fa.ts | 2 ++ src/locales/fi.ts | 2 ++ src/locales/fr.ts | 2 ++ src/locales/he.ts | 2 ++ src/locales/hr.ts | 2 ++ src/locales/hu.ts | 2 ++ src/locales/it.ts | 2 ++ src/locales/ja.ts | 2 ++ src/locales/ko.ts | 2 ++ src/locales/lt.ts | 2 ++ src/locales/lv.ts | 2 ++ src/locales/nl.ts | 2 ++ src/locales/no.ts | 2 ++ src/locales/pl.ts | 2 ++ src/locales/pt.ts | 2 ++ src/locales/ro.ts | 2 ++ src/locales/sk.ts | 2 ++ src/locales/sl.ts | 2 ++ src/locales/sr.ts | 2 ++ src/locales/sv.ts | 2 ++ src/locales/tr.ts | 2 ++ src/locales/zh-hans.ts | 2 ++ src/locales/zh-hant.ts | 2 ++ 33 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/components/Properties/PropertiesPanel.tsx b/src/components/Properties/PropertiesPanel.tsx index 89d264e..8bde7bd 100644 --- a/src/components/Properties/PropertiesPanel.tsx +++ b/src/components/Properties/PropertiesPanel.tsx @@ -37,11 +37,11 @@ export function PropertiesPanel() {
- {selectedIds.length} objects selected + {t.properties.multipleSelectedFmt.replace('{n}', String(selectedIds.length))}

- {t.properties.x} / {t.properties.y}: use arrow keys to move + {t.properties.x} / {t.properties.y}: {t.properties.multipleSelectedHint}

); diff --git a/src/locales/ar.ts b/src/locales/ar.ts index 67c29dd..866de5f 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -49,6 +49,8 @@ const ar = { x: 'X', y: 'Y', comment: 'تعليق', + multipleSelectedFmt: '{n} عناصر مختارة', + multipleSelectedHint: 'استخدم أسهم الاتجاه للتحريك', }, label: { diff --git a/src/locales/bg.ts b/src/locales/bg.ts index fb749e1..3e00ea7 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -49,6 +49,8 @@ const bg = { x: 'X', y: 'Y', comment: 'Коментар', + multipleSelectedFmt: 'Избрани обекти: {n}', + multipleSelectedHint: 'със стрелките местиш', }, label: { diff --git a/src/locales/cs.ts b/src/locales/cs.ts index e7d9f47..22fc2dd 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -49,6 +49,8 @@ const cs = { x: 'X', y: 'Y', comment: 'Komentář', + multipleSelectedFmt: 'Vybráno objektů: {n}', + multipleSelectedHint: 'šipkami posunete', }, label: { diff --git a/src/locales/da.ts b/src/locales/da.ts index 7484692..3e62111 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -49,6 +49,8 @@ const da = { x: 'X', y: 'Y', comment: 'Kommentar', + multipleSelectedFmt: '{n} objekter valgt', + multipleSelectedHint: 'piletaster flytter', }, label: { diff --git a/src/locales/de.ts b/src/locales/de.ts index 118eeb7..e9ea97b 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -49,6 +49,8 @@ const de = { x: 'X', y: 'Y', comment: 'Kommentar', + multipleSelectedFmt: '{n} Objekte ausgewählt', + multipleSelectedHint: 'Pfeiltasten zum Verschieben', }, label: { diff --git a/src/locales/el.ts b/src/locales/el.ts index 1bdb6ec..3314283 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -49,6 +49,8 @@ const el = { x: 'X', y: 'Y', comment: 'Σχόλιο', + multipleSelectedFmt: '{n} αντικείμενα επιλέχθηκαν', + multipleSelectedHint: 'τα βέλη μετακινούν', }, label: { diff --git a/src/locales/en.ts b/src/locales/en.ts index 1634091..99685ee 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -49,6 +49,8 @@ const en = { x: 'X', y: 'Y', comment: 'Comment', + multipleSelectedFmt: '{n} objects selected', + multipleSelectedHint: 'use arrow keys to move', }, label: { diff --git a/src/locales/es.ts b/src/locales/es.ts index 74fa2ba..e8e6659 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -49,6 +49,8 @@ const es = { x: 'X', y: 'Y', comment: 'Comentario', + multipleSelectedFmt: '{n} objetos seleccionados', + multipleSelectedHint: 'flechas para mover', }, label: { diff --git a/src/locales/et.ts b/src/locales/et.ts index 5429632..10b7ddf 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -49,6 +49,8 @@ const et = { x: 'X', y: 'Y', comment: 'Kommentaar', + multipleSelectedFmt: '{n} objekti valitud', + multipleSelectedHint: 'nooltega liigutad', }, label: { diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 11abfa9..bfd2d57 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -49,6 +49,8 @@ const fa = { x: 'X', y: 'Y', comment: 'توضیح', + multipleSelectedFmt: '{n} مورد انتخاب شده', + multipleSelectedHint: 'با کلیدهای جهت‌دار جابه‌جا کنید', }, label: { diff --git a/src/locales/fi.ts b/src/locales/fi.ts index 966b361..591448b 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -49,6 +49,8 @@ const fi = { x: 'X', y: 'Y', comment: 'Kommentti', + multipleSelectedFmt: '{n} objektia valittu', + multipleSelectedHint: 'nuolinäppäimillä siirrät', }, label: { diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 68b4e6b..9a3d884 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -49,6 +49,8 @@ const fr = { x: 'X', y: 'Y', comment: 'Commentaire', + multipleSelectedFmt: '{n} objets sélectionnés', + multipleSelectedHint: 'flèches pour déplacer', }, label: { diff --git a/src/locales/he.ts b/src/locales/he.ts index bde8f4a..b81a469 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -49,6 +49,8 @@ const he = { x: 'X', y: 'Y', comment: 'הערה', + multipleSelectedFmt: '{n} פריטים נבחרו', + multipleSelectedHint: 'מקשי החצים מזיזים', }, label: { diff --git a/src/locales/hr.ts b/src/locales/hr.ts index 9b14941..b743134 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -49,6 +49,8 @@ const hr = { x: 'X', y: 'Y', comment: 'Komentar', + multipleSelectedFmt: 'Odabrano objekata: {n}', + multipleSelectedHint: 'strelicama pomičeš', }, label: { diff --git a/src/locales/hu.ts b/src/locales/hu.ts index 1c2818d..83258c7 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -49,6 +49,8 @@ const hu = { x: 'X', y: 'Y', comment: 'Megjegyzés', + multipleSelectedFmt: '{n} objektum kijelölve', + multipleSelectedHint: 'nyilakkal mozgasd', }, label: { diff --git a/src/locales/it.ts b/src/locales/it.ts index 5da27a5..0f8c278 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -49,6 +49,8 @@ const it = { x: 'X', y: 'Y', comment: 'Commento', + multipleSelectedFmt: '{n} oggetti selezionati', + multipleSelectedHint: 'frecce per spostare', }, label: { diff --git a/src/locales/ja.ts b/src/locales/ja.ts index f78b7ae..b736ba7 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -49,6 +49,8 @@ const ja = { x: 'X', y: 'Y', comment: 'コメント', + multipleSelectedFmt: '{n} 個のオブジェクトが選択されました', + multipleSelectedHint: '矢印キーで移動', }, label: { diff --git a/src/locales/ko.ts b/src/locales/ko.ts index a7d37b4..dab88f3 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -49,6 +49,8 @@ const ko = { x: 'X', y: 'Y', comment: '설명', + multipleSelectedFmt: '{n}개 항목 선택됨', + multipleSelectedHint: '화살표 키로 이동', }, label: { diff --git a/src/locales/lt.ts b/src/locales/lt.ts index f0a32e3..6e51ac7 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -49,6 +49,8 @@ const lt = { x: 'X', y: 'Y', comment: 'Komentaras', + multipleSelectedFmt: 'Pasirinkta objektų: {n}', + multipleSelectedHint: 'rodyklėmis perkeli', }, label: { diff --git a/src/locales/lv.ts b/src/locales/lv.ts index 7799f0f..59bd7d2 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -49,6 +49,8 @@ const lv = { x: 'X', y: 'Y', comment: 'Komentārs', + multipleSelectedFmt: 'Atlasīti {n} objekti', + multipleSelectedHint: 'ar bultiņām pārvietot', }, label: { diff --git a/src/locales/nl.ts b/src/locales/nl.ts index d93c0c5..f30bd17 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -49,6 +49,8 @@ const nl = { x: 'X', y: 'Y', comment: 'Opmerking', + multipleSelectedFmt: '{n} objecten geselecteerd', + multipleSelectedHint: 'pijltoetsen om te verplaatsen', }, label: { diff --git a/src/locales/no.ts b/src/locales/no.ts index 12365a2..b7d930f 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -49,6 +49,8 @@ const no = { x: 'X', y: 'Y', comment: 'Kommentar', + multipleSelectedFmt: '{n} objekter valgt', + multipleSelectedHint: 'piltaster flytter', }, label: { diff --git a/src/locales/pl.ts b/src/locales/pl.ts index 538f990..d80bbd8 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -49,6 +49,8 @@ const pl = { x: 'X', y: 'Y', comment: 'Komentarz', + multipleSelectedFmt: 'Wybrano obiektów: {n}', + multipleSelectedHint: 'strzałki przesuwają', }, label: { diff --git a/src/locales/pt.ts b/src/locales/pt.ts index 9c6e02e..3eac66f 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -49,6 +49,8 @@ const pt = { x: 'X', y: 'Y', comment: 'Comentário', + multipleSelectedFmt: '{n} objetos selecionados', + multipleSelectedHint: 'setas para mover', }, label: { diff --git a/src/locales/ro.ts b/src/locales/ro.ts index ad6c341..2ece619 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -49,6 +49,8 @@ const ro = { x: 'X', y: 'Y', comment: 'Comentariu', + multipleSelectedFmt: '{n} obiecte selectate', + multipleSelectedHint: 'săgeți pentru mutare', }, label: { diff --git a/src/locales/sk.ts b/src/locales/sk.ts index 55e6031..df3315c 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -49,6 +49,8 @@ const sk = { x: 'X', y: 'Y', comment: 'Komentár', + multipleSelectedFmt: 'Vybraných objektov: {n}', + multipleSelectedHint: 'šípkami posuniete', }, label: { diff --git a/src/locales/sl.ts b/src/locales/sl.ts index 7237c73..8ce35eb 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -49,6 +49,8 @@ const sl = { x: 'X', y: 'Y', comment: 'Komentar', + multipleSelectedFmt: 'Izbranih objektov: {n}', + multipleSelectedHint: 's puščicami premikaš', }, label: { diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 2f469d5..1f66a19 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -49,6 +49,8 @@ const sr = { x: 'X', y: 'Y', comment: 'Коментар', + multipleSelectedFmt: 'Изабрано објеката: {n}', + multipleSelectedHint: 'стрелицама померај', }, label: { diff --git a/src/locales/sv.ts b/src/locales/sv.ts index 3781c21..fcfe937 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -49,6 +49,8 @@ const sv = { x: 'X', y: 'Y', comment: 'Kommentar', + multipleSelectedFmt: '{n} objekt markerade', + multipleSelectedHint: 'pilar för att flytta', }, label: { diff --git a/src/locales/tr.ts b/src/locales/tr.ts index b9e46e5..ff375fc 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -49,6 +49,8 @@ const tr = { x: 'X', y: 'Y', comment: 'Yorum', + multipleSelectedFmt: '{n} nesne seçildi', + multipleSelectedHint: 'oklarla taşı', }, label: { diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index b11c4f6..f7e242c 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -49,6 +49,8 @@ const zhHans = { x: 'X', y: 'Y', comment: '备注', + multipleSelectedFmt: '已选择 {n} 个对象', + multipleSelectedHint: '方向键移动', }, label: { diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index 63790e7..8a4fd1f 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -49,6 +49,8 @@ const zhHant = { x: 'X', y: 'Y', comment: '備註', + multipleSelectedFmt: '已選擇 {n} 個物件', + multipleSelectedHint: '方向鍵移動', }, label: { From 6947cd17670d6e6e5026eedf39e261d30cf19fa2 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 9 May 2026 00:11:50 +0200 Subject: [PATCH 3/5] fix(store): linear stagger in duplicateSelectedObjects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini caught a bug surfaced by the constant extraction: the previous 'duplicateCount * DUPLICATE_OFFSET_DOTS' produced quadratic stagger (positions 20, 60, 120, 200…) because the selection moves to each new copy, then the next call multiplies by an ever-growing duplicateCount on top of that already-shifted source. The selection-follows-copy mechanic already produces linear stagger on its own — the multiplier was unwarranted. Use a constant offset. duplicateCount state and its selection-reset bookkeeping become dead; remove them. pasteObjects keeps its multiplier: the clipboard source is static, so multiplication is the only thing producing stagger. Add regression tests for duplicateSelectedObjects (none existed, which is how the bug shipped). Update the constant docstring to reflect the now-correct two-mode behaviour. --- src/store/labelStore.test.ts | 38 +++++++++++++++++++++++++++++++++++- src/store/labelStore.ts | 30 +++++++++++++++------------- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/src/store/labelStore.test.ts b/src/store/labelStore.test.ts index 17a4e3a..5c0a703 100644 --- a/src/store/labelStore.test.ts +++ b/src/store/labelStore.test.ts @@ -12,7 +12,6 @@ function reset() { selectedIds: [], clipboard: [], pasteCount: 0, - duplicateCount: 0, canvasSettings: { showGrid: false, snapEnabled: false, @@ -138,6 +137,43 @@ describe('duplicateObject', () => { }); }); +// ── duplicateSelectedObjects ────────────────────────────────────────────────── + +describe('duplicateSelectedObjects', () => { + it('staggers consecutive duplicates linearly (+20 from current selection)', () => { + state().addObject('text', { x: 100, y: 100 }); + state().selectObject(defined(objs()[0]).id); + + state().duplicateSelectedObjects(); + state().duplicateSelectedObjects(); + state().duplicateSelectedObjects(); + + expect(objs()).toHaveLength(4); + // Selection follows the new copy each time, so the offsets compound + // linearly: 100, 120, 140, 160 — never quadratic. + expect(objs().map((o) => o.x)).toEqual([100, 120, 140, 160]); + expect(objs().map((o) => o.y)).toEqual([100, 120, 140, 160]); + }); + + it('selects only the new copies', () => { + state().addObject('text'); + state().addObject('text'); + state().selectObjects(objs().map((o) => o.id)); + + state().duplicateSelectedObjects(); + + expect(state().selectedIds).toHaveLength(2); + expect(state().selectedIds).toEqual([objs()[2]!.id, objs()[3]!.id]); + }); + + it('is a no-op when nothing is selected', () => { + state().addObject('text'); + state().selectObject(null); + state().duplicateSelectedObjects(); + expect(objs()).toHaveLength(1); + }); +}); + // ── copy / paste ────────────────────────────────────────────────────────────── describe('copy / paste', () => { diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index 5ee7f25..f83f7d0 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -76,7 +76,6 @@ interface LabelState { clipboard: LabelObject[]; pasteCount: number; - duplicateCount: number; addObject: (type: string, position?: { x: number; y: number }) => void; updateObject: (id: string, changes: ObjectChanges) => void; @@ -131,10 +130,12 @@ function updateCurrentObjects( }; } -/** Stagger duplicate / paste offsets so consecutive copies don't overlap. - * Multiplied by the running count so the Nth copy lands N×offset from the - * source. 20 dots ≈ 2.5 mm at 8dpmm — large enough to be visible without - * pushing copies off-canvas. */ +/** Base offset (in dots) used to stagger duplicate / paste copies so they + * don't sit exactly on top of the source. 20 dots ≈ 2.5 mm at 8dpmm — + * visible without pushing copies off-canvas. duplicateObject and + * duplicateSelectedObjects apply it as a constant (the selection follows + * the new copy, so subsequent duplicates stagger naturally); pasteObjects + * multiplies it by pasteCount because the clipboard source stays put. */ const DUPLICATE_OFFSET_DOTS = 20; function migrateLegacy(persistedState: unknown, version: number): unknown { @@ -167,7 +168,6 @@ export const useLabelStore = create()( selectedIds: [], clipboard: [], pasteCount: 0, - duplicateCount: 0, locale: detectLocale(), theme: detectInitialTheme(), thirdParty: thirdPartyDefaults(), @@ -238,17 +238,19 @@ export const useLabelStore = create()( set((state) => { if (state.selectedIds.length === 0) return {}; const objs = currentObjects(state); - const duplicateCount = state.duplicateCount + 1; - const offset = duplicateCount * DUPLICATE_OFFSET_DOTS; const copies: LabelObject[] = state.selectedIds.flatMap((id) => { const src = objs.find((o) => o.id === id); if (!src) return []; - return [{ ...src, id: crypto.randomUUID(), x: src.x + offset, y: src.y + offset } as LabelObject]; + return [{ + ...src, + id: crypto.randomUUID(), + x: src.x + DUPLICATE_OFFSET_DOTS, + y: src.y + DUPLICATE_OFFSET_DOTS, + } as LabelObject]; }); return { ...updateCurrentObjects(state, (curr) => [...curr, ...copies]), selectedIds: copies.map((c) => c.id), - duplicateCount, }; }), @@ -286,8 +288,8 @@ export const useLabelStore = create()( const same = state.selectedIds.length === next.length && state.selectedIds[0] === next[0]; - if (same && state.duplicateCount === 0) return {}; - return { selectedIds: next, duplicateCount: 0 }; + if (same) return {}; + return { selectedIds: next }; }), toggleSelectObject: (id) => @@ -302,8 +304,8 @@ export const useLabelStore = create()( const same = state.selectedIds.length === ids.length && state.selectedIds.every((id, i) => id === ids[i]); - if (same && state.duplicateCount === 0) return {}; - return { selectedIds: ids, duplicateCount: 0 }; + if (same) return {}; + return { selectedIds: ids }; }), removeSelectedObjects: () => From ef83a8de2d2107f36d14b03a94bcd3d9f717448a Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 9 May 2026 00:14:12 +0200 Subject: [PATCH 4/5] refactor(store): extract buildOffsetCopies helper duplicateObject and duplicateSelectedObjects had near-identical bodies after the previous fix unified them on a constant offset. Pull the shared logic (find by id, spread + new id + offset, drop missing) into a single helper so both actions reduce to selection-shape concerns. --- src/store/labelStore.ts | 43 ++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index f83f7d0..7236111 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -138,6 +138,22 @@ function updateCurrentObjects( * multiplies it by pasteCount because the clipboard source stays put. */ const DUPLICATE_OFFSET_DOTS = 20; +/** Build offset copies of objects identified by `ids`. Missing ids are + * silently dropped. Used by duplicateObject (single id) and + * duplicateSelectedObjects (current selection). */ +function buildOffsetCopies(objs: LabelObject[], ids: readonly string[]): LabelObject[] { + return ids.flatMap((id) => { + const src = objs.find((o) => o.id === id); + if (!src) return []; + return [{ + ...src, + id: crypto.randomUUID(), + x: src.x + DUPLICATE_OFFSET_DOTS, + y: src.y + DUPLICATE_OFFSET_DOTS, + } as LabelObject]; + }); +} + function migrateLegacy(persistedState: unknown, version: number): unknown { if (!persistedState || typeof persistedState !== 'object') return persistedState; let s = persistedState as Record; @@ -219,35 +235,18 @@ export const useLabelStore = create()( duplicateObject: (id) => set((state) => { - const objs = currentObjects(state); - const src = objs.find((o) => o.id === id); - if (!src) return {}; - const copy: LabelObject = { - ...src, - id: crypto.randomUUID(), - x: src.x + DUPLICATE_OFFSET_DOTS, - y: src.y + DUPLICATE_OFFSET_DOTS, - }; + const copies = buildOffsetCopies(currentObjects(state), [id]); + if (copies.length === 0) return {}; return { - ...updateCurrentObjects(state, (curr) => [...curr, copy]), - selectedIds: [copy.id], + ...updateCurrentObjects(state, (curr) => [...curr, ...copies]), + selectedIds: copies.map((c) => c.id), }; }), duplicateSelectedObjects: () => set((state) => { if (state.selectedIds.length === 0) return {}; - const objs = currentObjects(state); - const copies: LabelObject[] = state.selectedIds.flatMap((id) => { - const src = objs.find((o) => o.id === id); - if (!src) return []; - return [{ - ...src, - id: crypto.randomUUID(), - x: src.x + DUPLICATE_OFFSET_DOTS, - y: src.y + DUPLICATE_OFFSET_DOTS, - } as LabelObject]; - }); + const copies = buildOffsetCopies(currentObjects(state), state.selectedIds); return { ...updateCurrentObjects(state, (curr) => [...curr, ...copies]), selectedIds: copies.map((c) => c.id), From 56dc087867571ca682b42bb158e37a813dbd9a90 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 9 May 2026 00:19:49 +0200 Subject: [PATCH 5/5] refactor(store): clone props and use Map lookup in buildOffsetCopies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two improvements from a Gemini review: - Shallow-clone props on copy. Matches the copySelectedObjects pattern. Today nothing mutates props directly (updateObject builds a fresh object via Object.assign), so the shared reference is invisible — but cheap to defend now rather than wait for a future contributor's in-place edit to leak across copies. - Index objs in a Map before the flatMap pass. The previous nested find was O(N×M); for typical labels both are tiny so it's not a real performance issue, but the Map form reads as 'looking things up by id' rather than 'scanning each time'. --- src/store/labelStore.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index 7236111..956c5f9 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -139,17 +139,20 @@ function updateCurrentObjects( const DUPLICATE_OFFSET_DOTS = 20; /** Build offset copies of objects identified by `ids`. Missing ids are - * silently dropped. Used by duplicateObject (single id) and - * duplicateSelectedObjects (current selection). */ + * silently dropped. Props are shallow-cloned to match the pattern in + * copySelectedObjects — even though no current code path mutates props, + * sharing the reference would be a hidden trap for future contributors. */ function buildOffsetCopies(objs: LabelObject[], ids: readonly string[]): LabelObject[] { + const byId = new Map(objs.map((o) => [o.id, o])); return ids.flatMap((id) => { - const src = objs.find((o) => o.id === id); + const src = byId.get(id); if (!src) return []; return [{ ...src, id: crypto.randomUUID(), x: src.x + DUPLICATE_OFFSET_DOTS, y: src.y + DUPLICATE_OFFSET_DOTS, + props: { ...src.props }, } as LabelObject]; }); }