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() {
- {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: { 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 dd159a1..956c5f9 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,6 +130,33 @@ function updateCurrentObjects( }; } +/** 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; + +/** Build offset copies of objects identified by `ids`. Missing ids are + * 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 = 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]; + }); +} + function migrateLegacy(persistedState: unknown, version: number): unknown { if (!persistedState || typeof persistedState !== 'object') return persistedState; let s = persistedState as Record