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 dd159a1c..5ee7f255 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 89d264e2..8bde7bd0 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 67c29ddf..866de5f1 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 fb749e15..3e00ea7b 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 e7d9f47c..22fc2ddc 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 74846928..3e62111c 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 118eeb78..e9ea97b0 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 1bdb6ec1..3314283b 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 16340912..99685ee0 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 74fa2ba6..e8e66596 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 54296320..10b7ddfd 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 11abfa9d..bfd2d578 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 966b3616..591448ba 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 68b4e6b4..9a3d8848 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 bde8f4ab..b81a4697 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 9b149419..b743134b 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 1c2818d0..83258c7b 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 5da27a51..0f8c2786 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 f78b7aef..b736ba77 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 a7d37b4c..dab88f33 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 f0a32e3e..6e51ac7e 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 7799f0f2..59bd7d23 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 d93c0c5e..f30bd17e 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 12365a21..b7d930f7 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 538f9906..d80bbd89 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 9c6e02e6..3eac66f4 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 ad6c3418..2ece6198 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 55e6031d..df3315cc 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 7237c733..8ce35eb9 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 2f469d54..1f66a197 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 3781c214..fcfe937f 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 b9e46e59..ff375fca 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 b11c4f67..f7e242c2 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 63790e74..8a4fd1f8 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 17a4e3a9..5c0a7031 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 5ee7f255..f83f7d09 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 f83f7d09..7236111a 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 7236111a..956c5f94 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]; }); }