From e6e6b41457955e3fc05113558d1db53b5088f7b6 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 14 May 2026 11:04:23 +0200 Subject: [PATCH 01/37] feat(groups): add GroupObject type and tree-walk helpers Introduces the structural primitive for grouping: GroupObject is a non-leaf node carrying children: LabelObject[]. LabelObject becomes LeafObject | GroupObject so consumers can narrow with isGroup. No render, store, or UI wiring yet. Registry stays leaf-only by design: groups have no toZPL, no defaultSize, no PropertiesPanel. Helpers (walkObjects, getAllLeaves, findObjectById, findAncestors) are the surface every future consumer talks to instead of recursing ad-hoc. --- src/registry/index.ts | 11 ++++- src/types/Group.test.ts | 102 ++++++++++++++++++++++++++++++++++++++++ src/types/Group.ts | 79 +++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 src/types/Group.test.ts create mode 100644 src/types/Group.ts diff --git a/src/registry/index.ts b/src/registry/index.ts index 99dc06ad..5888bda8 100644 --- a/src/registry/index.ts +++ b/src/registry/index.ts @@ -1,4 +1,5 @@ import type { ObjectTypeDefinition, LabelObjectBase } from '../types/ObjectType'; +import type { GroupObject } from '../types/Group'; import { text } from './text.tsx'; import type { TextProps } from './text.tsx'; import { code128 } from './code128.tsx'; @@ -62,7 +63,10 @@ import type { MicroPdf417Props } from './micropdf417.tsx'; import { codablock } from './codablock.tsx'; import type { CodablockProps } from './codablock.tsx'; -export type LabelObject = +/** Leaf objects: every registry-backed type. These render to ZPL and + * have a PropertiesPanel. Groups (see `LabelObject`) are structural + * only and intentionally outside this union. */ +export type LeafObject = | (LabelObjectBase & { type: 'text'; props: TextProps }) | (LabelObjectBase & { type: 'code128'; props: Code128Props }) | (LabelObjectBase & { type: 'code39'; props: Code39Props }) @@ -95,6 +99,11 @@ export type LabelObject = | (LabelObjectBase & { type: 'micropdf417'; props: MicroPdf417Props }) | (LabelObjectBase & { type: 'codablock'; props: CodablockProps }); +/** Any node in the object tree: either a leaf (registry-backed) or a + * group container. Most code paths should operate on this; use + * `isGroup` from `types/Group` to narrow. */ +export type LabelObject = LeafObject | GroupObject; + export const BARCODE_1D_TYPES = new Set([ 'code128', 'code39', 'ean13', 'ean8', 'upca', 'upce', 'interleaved2of5', 'code93', 'code11', 'industrial2of5', 'standard2of5', 'codabar', 'logmars', 'msi', 'plessey', diff --git a/src/types/Group.test.ts b/src/types/Group.test.ts new file mode 100644 index 00000000..99bdb3d5 --- /dev/null +++ b/src/types/Group.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { + isGroup, + walkObjects, + getAllLeaves, + findObjectById, + findAncestors, + type GroupObject, +} from './Group'; +import type { LabelObject } from '../registry'; + +function leaf(id: string): LabelObject { + return { + id, + type: 'text', + x: 0, + y: 0, + rotation: 0, + props: { text: '', fontHeight: 20, font: '0', interpretation: false }, + } as LabelObject; +} + +function group(id: string, children: LabelObject[]): GroupObject { + return { id, type: 'group', x: 0, y: 0, rotation: 0, children }; +} + +describe('Group helpers', () => { + describe('isGroup', () => { + it('discriminates leaves from groups', () => { + expect(isGroup(leaf('a'))).toBe(false); + expect(isGroup(group('g', []))).toBe(true); + }); + }); + + describe('walkObjects', () => { + it('yields nodes depth-first, parent before children', () => { + const tree: LabelObject[] = [ + leaf('a'), + group('g1', [leaf('b'), group('g2', [leaf('c')]), leaf('d')]), + leaf('e'), + ]; + const ids = [...walkObjects(tree)].map((o) => o.id); + expect(ids).toEqual(['a', 'g1', 'b', 'g2', 'c', 'd', 'e']); + }); + + it('handles empty input', () => { + expect([...walkObjects([])]).toEqual([]); + }); + }); + + describe('getAllLeaves', () => { + it('returns only leaves, skipping group nodes', () => { + const tree: LabelObject[] = [ + leaf('a'), + group('g1', [leaf('b'), group('g2', [leaf('c')])]), + ]; + expect(getAllLeaves(tree).map((o) => o.id)).toEqual(['a', 'b', 'c']); + }); + + it('returns empty for a tree of only empty groups', () => { + expect(getAllLeaves([group('g', [group('g2', [])])])).toEqual([]); + }); + }); + + describe('findObjectById', () => { + it('finds top-level leaves', () => { + expect(findObjectById([leaf('a')], 'a')?.id).toBe('a'); + }); + + it('finds nested leaves', () => { + const tree = [group('g', [group('g2', [leaf('deep')])])]; + expect(findObjectById(tree, 'deep')?.id).toBe('deep'); + }); + + it('finds groups themselves', () => { + const tree = [group('g', [leaf('child')])]; + expect(findObjectById(tree, 'g')?.type).toBe('group'); + }); + + it('returns undefined for missing ids', () => { + expect(findObjectById([leaf('a')], 'missing')).toBeUndefined(); + }); + }); + + describe('findAncestors', () => { + it('returns empty for top-level objects', () => { + expect(findAncestors([leaf('a')], 'a')).toEqual([]); + }); + + it('returns the group chain outermost first', () => { + const inner = group('g2', [leaf('deep')]); + const outer = group('g1', [inner]); + const tree = [outer]; + const ancestors = findAncestors(tree, 'deep'); + expect(ancestors.map((g) => g.id)).toEqual(['g1', 'g2']); + }); + + it('returns empty for missing ids', () => { + expect(findAncestors([leaf('a')], 'missing')).toEqual([]); + }); + }); +}); diff --git a/src/types/Group.ts b/src/types/Group.ts new file mode 100644 index 00000000..ba4ac3fb --- /dev/null +++ b/src/types/Group.ts @@ -0,0 +1,79 @@ +import type { LabelObject } from '../registry'; +import type { LabelObjectBase } from './ObjectType'; + +/** + * A Group is the only non-leaf node in the object tree. Leaves render and + * export themselves; groups exist purely as structural containers that + * cascade lock / visibility / inclusion to their descendants and let the + * user move, select and reorder a set of objects together. + * + * `type: 'group'` is intentionally outside the registry: groups have no + * `toZPL`, no `defaultSize`, no `PropertiesPanel` — they are handled by + * tree-walking consumers (render dispatch, ZPL export, layers panel). + */ +export type GroupObject = LabelObjectBase & { + type: 'group'; + children: LabelObject[]; +}; + +export function isGroup(obj: LabelObject): obj is GroupObject { + return obj.type === 'group'; +} + +/** + * Depth-first walk over a tree of objects. Yields every node (groups and + * leaves) in render order — children come after their parent so consumers + * that build z-order arrays can push as they go. + */ +export function* walkObjects(objects: LabelObject[]): Iterable { + for (const obj of objects) { + yield obj; + if (isGroup(obj)) { + yield* walkObjects(obj.children); + } + } +} + +/** Flat list of every leaf descendant of `objects`. Skips group nodes themselves. */ +export function getAllLeaves(objects: LabelObject[]): LabelObject[] { + const out: LabelObject[] = []; + for (const obj of walkObjects(objects)) { + if (!isGroup(obj)) out.push(obj); + } + return out; +} + +/** Find a node by id anywhere in the tree, or undefined if not present. */ +export function findObjectById( + objects: LabelObject[], + id: string, +): LabelObject | undefined { + for (const obj of walkObjects(objects)) { + if (obj.id === id) return obj; + } + return undefined; +} + +/** + * Returns the chain of group ancestors of the node with `id`, outermost + * first. Empty when the node is at the top level or not found. + */ +export function findAncestors( + objects: LabelObject[], + id: string, +): GroupObject[] { + const trail: GroupObject[] = []; + const visit = (nodes: LabelObject[]): boolean => { + for (const n of nodes) { + if (n.id === id) return true; + if (isGroup(n)) { + trail.push(n); + if (visit(n.children)) return true; + trail.pop(); + } + } + return false; + }; + visit(objects); + return trail; +} From 60971df974c5b6199d52555ea2c9948b1610f5d7 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 14 May 2026 12:06:37 +0200 Subject: [PATCH 02/37] feat(groups): add groupSelection and ungroup store actions groupSelection wraps every selected top-level, unlocked object in a new GroupObject, inserted at the topmost selected position so the group lands where the user's eye is. ungroup splices a selected group's children back at its index. V1 scope: top-level only. Grouping items already inside another group, or ungrouping nested groups, is out of scope until selection behaviour for nested children lands. No UI binding yet. Consumers (render, ZPL export, layers panel) are unchanged because no UI path can create a group; subsequent commits wire those up alongside the keyboard shortcut. --- src/store/labelStore.test.ts | 100 +++++++++++++++++++++++++++++++++++ src/store/labelStore.ts | 78 +++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) diff --git a/src/store/labelStore.test.ts b/src/store/labelStore.test.ts index ed2f3c9b..db7b113b 100644 --- a/src/store/labelStore.test.ts +++ b/src/store/labelStore.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { useLabelStore, currentObjects } from './labelStore'; import type { LabelObject } from '../registry'; +import { isGroup } from '../types/Group'; import { defined, props } from '../test/helpers'; /** Reset store to clean state before each test. */ @@ -590,3 +591,102 @@ describe('per-page object isolation', () => { expect(objs()).toHaveLength(1); }); }); + +// ── groupSelection / ungroup ────────────────────────────────────────────────── + +describe('groupSelection', () => { + it('wraps selected objects in a single group and selects it', () => { + state().addObject('text'); + state().addObject('box'); + const [a, b] = objs(); + state().selectObjects([defined(a).id, defined(b).id]); + state().groupSelection(); + expect(objs()).toHaveLength(1); + const g = defined(objs()[0]); + expect(isGroup(g)).toBe(true); + if (isGroup(g)) expect(g.children).toHaveLength(2); + expect(state().selectedIds).toEqual([g.id]); + }); + + it('inserts the group at the topmost selected position', () => { + state().addObject('text'); + state().addObject('box'); + state().addObject('circle'); + const [a, b, c] = objs(); + // Select first and second; group should land where the second one was. + state().selectObjects([defined(a).id, defined(b).id]); + state().groupSelection(); + expect(objs().map((o) => o.type)).toEqual(['group', 'circle']); + expect(defined(objs()[1]).id).toBe(defined(c).id); + }); + + it('is a no-op with empty selection', () => { + state().addObject('text'); + state().selectObject(null); + state().groupSelection(); + expect(objs()).toHaveLength(1); + expect(isGroup(defined(objs()[0]))).toBe(false); + }); + + it('skips locked objects', () => { + state().addObject('text'); + state().addObject('box'); + const [a, b] = objs(); + state().updateObject(defined(a).id, { locked: true }); + state().selectObjects([defined(a).id, defined(b).id]); + state().groupSelection(); + // Only the box made it into a group; the locked text stayed at top level. + const objects = objs(); + expect(objects).toHaveLength(2); + const grp = objects.find((o) => isGroup(o)); + expect(grp).toBeDefined(); + if (grp && isGroup(grp)) { + expect(grp.children).toHaveLength(1); + expect(defined(grp.children[0]).type).toBe('box'); + } + }); + + it('allows grouping a single object (idiomatic in design tools)', () => { + state().addObject('text'); + state().selectObject(defined(objs()[0]).id); + state().groupSelection(); + expect(objs()).toHaveLength(1); + expect(isGroup(defined(objs()[0]))).toBe(true); + }); +}); + +describe('ungroup', () => { + it('replaces a selected group with its children at the same position', () => { + state().addObject('text'); + state().addObject('box'); + state().addObject('circle'); + const [, b, c] = objs(); + state().selectObjects([defined(b).id, defined(c).id]); + state().groupSelection(); + const groupId = defined(state().selectedIds[0]); + state().selectObject(groupId); + state().ungroup(); + expect(objs().map((o) => o.type)).toEqual(['text', 'box', 'circle']); + // Selection follows the freed children. + expect(state().selectedIds).toHaveLength(2); + }); + + it('is a no-op when no group is selected', () => { + state().addObject('text'); + state().selectObject(defined(objs()[0]).id); + state().ungroup(); + expect(objs()).toHaveLength(1); + expect(isGroup(defined(objs()[0]))).toBe(false); + }); + + it('skips locked groups', () => { + state().addObject('text'); + state().selectObject(defined(objs()[0]).id); + state().groupSelection(); + const gid = defined(state().selectedIds[0]); + state().updateObject(gid, { locked: true }); + state().ungroup(); + expect(objs()).toHaveLength(1); + expect(isGroup(defined(objs()[0]))).toBe(true); + }); +}); diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index 359a7316..a26abb9c 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -6,6 +6,7 @@ import type { Unit } from '../lib/units'; import type { ViewRotation } from '../components/Canvas/rotationGeometry'; import { ObjectRegistry } from '../registry'; import type { LabelObject } from '../registry'; +import { isGroup, type GroupObject } from '../types/Group'; import { locales } from '../locales'; import type { LocaleCode } from '../locales'; import { isDefaultLabelaryHost } from '../lib/labelary'; @@ -101,6 +102,13 @@ interface LabelState { toggleSelectObject: (id: string) => void; selectObjects: (ids: string[]) => void; removeSelectedObjects: () => void; + /** Wraps every selected top-level, unlocked object in a new GroupObject + * at the position of the topmost (last-in-array) selected item. + * No-op if fewer than one such object is selected. */ + groupSelection: () => void; + /** Replaces every selected top-level group with its children, splicing + * them in at the group's former index. No-op on non-group selections. */ + ungroup: () => void; setLabelConfig: (config: Partial) => void; setLocale: (locale: LocaleCode) => void; setTheme: (theme: ThemePreference) => void; @@ -350,6 +358,76 @@ export const useLabelStore = create()( }; }), + groupSelection: () => + set((state) => { + const objs = currentObjects(state); + const sel = new Set(state.selectedIds); + // Only consider top-level objects of the current page. Nested + // children of an existing group are out of scope for v1 — the + // user would have to ungroup the parent first. + const candidates = objs.flatMap((o) => + sel.has(o.id) && !o.locked ? [o] : [], + ); + if (candidates.length === 0) return {}; + const candidateIds = new Set(candidates.map((o) => o.id)); + // Insert at the position of the last (topmost in z-order) + // selected item so the group lands where the user's eye is. + const lastIndex = objs.reduce( + (acc, o, i) => (candidateIds.has(o.id) ? i : acc), + -1, + ); + const group: GroupObject = { + id: crypto.randomUUID(), + type: 'group', + x: 0, + y: 0, + rotation: 0, + children: candidates, + }; + const remaining = objs.filter((o) => !candidateIds.has(o.id)); + // lastIndex is computed on the pre-filter array; convert it to + // the post-filter insertion point by counting how many of the + // removed items were before it. + const removedBefore = objs + .slice(0, lastIndex + 1) + .filter((o) => candidateIds.has(o.id)).length; + const insertAt = lastIndex + 1 - removedBefore; + const next = [ + ...remaining.slice(0, insertAt), + group, + ...remaining.slice(insertAt), + ]; + return { + ...updateCurrentObjects(state, () => next), + selectedIds: [group.id], + }; + }), + + ungroup: () => + set((state) => { + const sel = new Set(state.selectedIds); + const objs = currentObjects(state); + const targets = objs.flatMap((o) => + sel.has(o.id) && isGroup(o) && !o.locked ? [o] : [], + ); + if (targets.length === 0) return {}; + const targetIds = new Set(targets.map((g) => g.id)); + const next: LabelObject[] = []; + const newSelection: string[] = []; + for (const o of objs) { + if (targetIds.has(o.id) && isGroup(o)) { + next.push(...o.children); + newSelection.push(...o.children.map((c) => c.id)); + } else { + next.push(o); + } + } + return { + ...updateCurrentObjects(state, () => next), + selectedIds: newSelection, + }; + }), + moveObjectToFront: (id) => set((state) => { const objs = currentObjects(state); From cff375c15c8ecc8670bf3a75a685527c65b65748 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 14 May 2026 14:45:21 +0200 Subject: [PATCH 03/37] feat(groups): make ZPL export and canvas render group-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZPL export recurses through groups so wrapping leaves changes nothing in the output. includeInExport=false on a group skips the whole subtree; otherwise per-leaf includeInExport still applies. Visible flag cascades the same way on the canvas render path. LayersPanel intentionally untouched: it operates on top-level objects and reorderObject works on top-level indices. A group renders as a plain row with type 'group' for now — hierarchy-aware layers are a later commit. --- src/components/Canvas/LabelCanvas.tsx | 19 +++++++++++- src/lib/zplGenerator.test.ts | 42 +++++++++++++++++++++++++++ src/lib/zplGenerator.ts | 15 +++++++--- 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 06c32af0..319acf5b 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -13,6 +13,7 @@ import type { PaletteDragData } from "../../dnd/types"; import { Stage, Layer, Group, Rect, Transformer } from "react-konva"; import type Konva from "konva"; import { useLabelStore, useCurrentObjects, currentObjects, getCurrentObjects } from "../../store/labelStore"; +import { isGroup } from "../../types/Group"; import { pxToDots, SCREEN_PX_PER_MM } from "../../lib/coordinates"; import { SNAP_OPTIONS } from "../../lib/units"; import type { Unit } from "../../lib/units"; @@ -122,6 +123,22 @@ export const LabelCanvas = forwardRef(function LabelCa } = useLabelStore(); const objects = useCurrentObjects(); + // Render path operates on visible leaves only: groups emit no node of + // their own (v1 has no group transform), and a group with visible=false + // hides its whole subtree. + const visibleLeaves = useMemo(() => { + const out: LabelObject[] = []; + const walk = (nodes: LabelObject[]) => { + for (const n of nodes) { + if (n.visible === false) continue; + if (isGroup(n)) walk(n.children); + else out.push(n); + } + }; + walk(objects); + return out; + }, [objects]); + useEffect(() => { const el = containerRef.current; if (!el) return; @@ -771,7 +788,7 @@ export const LabelCanvas = forwardRef(function LabelCa /> )} - {objects.map((obj) => obj.visible === false ? null : ( + {visibleLeaves.map((obj) => ( { expect(labelConfig.defaultFontHeight).toBe(25); }); }); + +// ── groups ──────────────────────────────────────────────────────────────────── + +function textLeaf(id: string, content: string): LabelObject { + return { + id, + type: 'text', + x: 0, + y: 0, + rotation: 0, + props: { content, fontHeight: 20, fontWidth: 0, font: '0', interpretation: false }, + } as LabelObject; +} + +function group(id: string, children: LabelObject[], extras: Partial = {}): GroupObject { + return { id, type: 'group', x: 0, y: 0, rotation: 0, children, ...extras }; +} + +describe('generateZPL — groups', () => { + it('emits a grouped leaf identically to an ungrouped one', () => { + const leaf = textLeaf('a', 'Hi'); + const flat = generateZPL(BASE_LABEL, [leaf]); + const wrapped = generateZPL(BASE_LABEL, [group('g', [leaf])]); + expect(wrapped).toBe(flat); + }); + + it('skips the whole subtree when the group is excluded from export', () => { + const leaf = textLeaf('a', 'Hi'); + const zpl = generateZPL(BASE_LABEL, [group('g', [leaf], { includeInExport: false })]); + expect(zpl).not.toContain('Hi'); + }); + + it('still respects per-leaf includeInExport inside an included group', () => { + const visible = textLeaf('a', 'Visible'); + const hidden = { ...textLeaf('b', 'Hidden'), includeInExport: false } as LabelObject; + const zpl = generateZPL(BASE_LABEL, [group('g', [visible, hidden])]); + expect(zpl).toContain('Visible'); + expect(zpl).not.toContain('Hidden'); + }); +}); diff --git a/src/lib/zplGenerator.ts b/src/lib/zplGenerator.ts index 88c5a7b6..b163e1f1 100644 --- a/src/lib/zplGenerator.ts +++ b/src/lib/zplGenerator.ts @@ -4,6 +4,7 @@ import { stripZplCommandChars } from '../registry/zplHelpers'; import type { LabelConfig } from '../types/ObjectType'; import type { LabelObject } from '../registry'; import type { Page } from '../store/labelStore'; +import { isGroup } from '../types/Group'; /** * Concatenates `generateZPL` output for every page. Each page becomes its own @@ -41,12 +42,18 @@ export function generateZPL(label: LabelConfig, objects: LabelObject[]): string } if (label.labelShift) lines.push(`^LS${label.labelShift}`); - lines.push(...objects.flatMap((obj) => { + // Groups are structural only — they emit no ZPL of their own. A group + // with includeInExport=false cascades the skip to its whole subtree; + // otherwise we recurse and let each leaf decide. + const emitLeaf = (obj: LabelObject): string[] => { if (obj.includeInExport === false) return []; + if (isGroup(obj)) return obj.children.flatMap(emitLeaf); const zpl = ObjectRegistry[obj.type]?.toZPL(obj) ?? ''; - if (!obj.comment) return [zpl]; - return [`^FX${stripZplCommandChars(obj.comment)}\n${zpl}`]; - })); + return obj.comment + ? [`^FX${stripZplCommandChars(obj.comment)}\n${zpl}`] + : [zpl]; + }; + lines.push(...objects.flatMap(emitLeaf)); if (label.printQuantity && label.printQuantity > 1) { lines.push(`^PQ${label.printQuantity}`); From 3e52d4bbbae634848ed5a88c4e2772f397ad5ed6 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 14 May 2026 15:39:57 +0200 Subject: [PATCH 04/37] feat(groups): bind Ctrl+G / Ctrl+Shift+G to group and ungroup Ctrl+G calls groupSelection, Ctrl+Shift+G calls ungroup. Listed before the bare-G grid toggle and the bare branch now requires no modifier so the two no longer collide. First user-visible step of the groups feature: keyboard shortcut exists and creates persisted groups, even though selection and drag behaviour still treat children individually. --- src/hooks/useGlobalShortcuts.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/hooks/useGlobalShortcuts.ts b/src/hooks/useGlobalShortcuts.ts index c6c0d80d..60eb6d0a 100644 --- a/src/hooks/useGlobalShortcuts.ts +++ b/src/hooks/useGlobalShortcuts.ts @@ -10,6 +10,8 @@ export function useGlobalShortcuts() { const setCanvasSettings = useLabelStore((s) => s.setCanvasSettings); const setCurrentPage = useLabelStore((s) => s.setCurrentPage); const updateObjects = useLabelStore((s) => s.updateObjects); + const groupSelection = useLabelStore((s) => s.groupSelection); + const ungroup = useLabelStore((s) => s.ungroup); const { undo, redo } = useHistory(); useEffect(() => { @@ -57,7 +59,15 @@ export function useGlobalShortcuts() { updateObjects(ids.map((id) => ({ id, changes: { locked } }))); return; } - if (e.code === "KeyG") { + if (mod && e.code === "KeyG") { + // Ctrl+G groups the current selection, Ctrl+Shift+G ungroups. + // Listed before the bare-G grid toggle so the modifier wins. + e.preventDefault(); + if (e.shiftKey) ungroup(); + else groupSelection(); + return; + } + if (e.code === "KeyG" && !mod) { e.preventDefault(); setCanvasSettings({ showGrid: !useLabelStore.getState().canvasSettings.showGrid }); } @@ -83,5 +93,5 @@ export function useGlobalShortcuts() { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [undo, redo, duplicateSelectedObjects, copySelectedObjects, pasteObjects, selectObjects, setCanvasSettings, setCurrentPage, updateObjects]); + }, [undo, redo, duplicateSelectedObjects, copySelectedObjects, pasteObjects, selectObjects, setCanvasSettings, setCurrentPage, updateObjects, groupSelection, ungroup]); } From 8fe9f25553e7345cbf2df9f42e149a4307aa8c33 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 14 May 2026 15:46:35 +0200 Subject: [PATCH 05/37] fix(groups): guard getStepRotation against missing props Selecting a group crashed the canvas because getStepRotation reached through obj.props on a node that has none. The rotate-button overlay calls it on every selected object; before this fix, the crash also left selectedIds pointing at the now-existing group, so subsequent clicks reopened the same code path and selection felt broken. Loosens the helper's input type to { props?: object } so the same fallback (return null) covers leaves with no rotation prop and groups with no props field at all. --- src/registry/rotation.test.ts | 4 ++++ src/registry/rotation.ts | 9 +++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/registry/rotation.test.ts b/src/registry/rotation.test.ts index 3ef53082..a07338a4 100644 --- a/src/registry/rotation.test.ts +++ b/src/registry/rotation.test.ts @@ -50,4 +50,8 @@ describe("getStepRotation", () => { expect(getStepRotation({ props: { rotation: "L" } })).toBeNull(); expect(getStepRotation({ props: { rotation: 90 } })).toBeNull(); }); + + it("returns null for objects without props (e.g. groups)", () => { + expect(getStepRotation({})).toBeNull(); + }); }); diff --git a/src/registry/rotation.ts b/src/registry/rotation.ts index ac2195fd..346353b9 100644 --- a/src/registry/rotation.ts +++ b/src/registry/rotation.ts @@ -31,11 +31,12 @@ export function nextZplRotation(r: ZplRotation): ZplRotation { /** * Returns the object's step-rotation if it has one, else `null`. Step-rotation * objects (text, serial, all barcodes) declare a `rotation: 'N'|'R'|'I'|'B'` - * prop; box/ellipse/circle/line/image do not. Lets callers gate UI affordances - * (e.g. the canvas quick-rotate button) without inspecting `props` shapes - * themselves. + * prop; box/ellipse/circle/line/image do not, and groups carry no `props` + * at all. Lets callers gate UI affordances (e.g. the canvas quick-rotate + * button) without inspecting `props` shapes themselves. */ -export function getStepRotation(obj: { props: object }): ZplRotation | null { +export function getStepRotation(obj: { props?: object }): ZplRotation | null { + if (!obj.props) return null; const r = (obj.props as { rotation?: unknown }).rotation; return typeof r === 'string' && isZplRotation(r) ? r : null; } From 19759281f7cc7f520449c3013eec036c8b4475db Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 14 May 2026 16:18:38 +0200 Subject: [PATCH 06/37] feat(groups): auto-select-parent on click, group drag via attachable leaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click on a child of a group now selects the outermost containing group (Figma-style auto-select-parent). The store keeps the intent-level selection (group id), while the Konva transformer, multi-drag propagation, lasso, and per-frame snap all walk an 'attachable' projection of the selection — the flat list of leaf ids that actually own Konva nodes. The store gains mapObjectById for tree-walking writes so updateObject and updateObjects reach leaves nested inside groups; drag and arrow keys both rely on this. Snap during a child's drag iterates getAllLeaves so it sees siblings outside the group as snap targets, matching the ungrouped behaviour. Lasso intersects leaf nodes and promotes hits to their outermost group, so a marquee over grouped children selects the group as one unit instead of a fragmented multi-selection. A group's lock cascades to its children for the lasso opt-out only; further lock cascade is out of scope for v1. --- src/components/Canvas/LabelCanvas.tsx | 39 ++++++++++---- src/components/Canvas/hooks/useCanvasLasso.ts | 18 ++++++- .../Canvas/hooks/useKonvaTransformer.ts | 3 +- src/store/labelStore.test.ts | 29 ++++++++++ src/store/labelStore.ts | 20 ++++--- src/types/Group.test.ts | 45 ++++++++++++++++ src/types/Group.ts | 54 +++++++++++++++++++ 7 files changed, 186 insertions(+), 22 deletions(-) diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 319acf5b..08ba8c47 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -13,7 +13,7 @@ import type { PaletteDragData } from "../../dnd/types"; import { Stage, Layer, Group, Rect, Transformer } from "react-konva"; import type Konva from "konva"; import { useLabelStore, useCurrentObjects, currentObjects, getCurrentObjects } from "../../store/labelStore"; -import { isGroup } from "../../types/Group"; +import { isGroup, getAllLeaves, expandSelection, selectionTargetId, findObjectById } from "../../types/Group"; import { pxToDots, SCREEN_PX_PER_MM } from "../../lib/coordinates"; import { SNAP_OPTIONS } from "../../lib/units"; import type { Unit } from "../../lib/units"; @@ -139,6 +139,16 @@ export const LabelCanvas = forwardRef(function LabelCa return out; }, [objects]); + // Konva-side machinery (transformer, snap snapshots) lives on leaves; + // groups have no node of their own. Expanding the selection here is + // what makes "click a child → group is selected" feel like a Figma + // multi-drag without a second drag pathway. + const allLeaves = useMemo(() => getAllLeaves(objects), [objects]); + const attachableIds = useMemo( + () => expandSelection(objects, selectedIds), + [objects, selectedIds], + ); + useEffect(() => { const el = containerRef.current; if (!el) return; @@ -388,8 +398,8 @@ export const LabelCanvas = forwardRef(function LabelCa } = useKonvaTransformer({ transformerRef, stageRef, - selectedIds, - objects, + selectedIds: attachableIds, + objects: allLeaves, scale, dpmm: label.dpmm, objectsOffsetX, @@ -464,15 +474,18 @@ export const LabelCanvas = forwardRef(function LabelCa // Multi-select: propagate position delta to all other selected objects. // Read fresh state (getState) to avoid stale closure when multiple DragEnd events // fire simultaneously during a Transformer group drag. + // expandSelection lets a selected group behave like a multi-selection + // of its leaves here, so dragging one leaf moves the whole group via + // the same delta-propagation path used by shift-click selections. const state = useLabelStore.getState(); - const selIds = state.selectedIds; const currentObjs = currentObjects(state); + const selIds = expandSelection(currentObjs, state.selectedIds); if ( selIds.length > 1 && selIds.includes(id) && (finalChanges.x !== undefined || finalChanges.y !== undefined) ) { - const srcObj = currentObjs.find((o) => o.id === id); + const srcObj = findObjectById(currentObjs, id); if (srcObj) { const ddx = finalChanges.x !== undefined ? finalChanges.x - srcObj.x : 0; const ddy = finalChanges.y !== undefined ? finalChanges.y - srcObj.y : 0; @@ -481,7 +494,7 @@ export const LabelCanvas = forwardRef(function LabelCa ...selIds .filter((sid) => sid !== id) .flatMap((sid) => { - const other = currentObjs.find((o) => o.id === sid); + const other = findObjectById(currentObjs, sid); return other ? [{ id: sid, changes: { x: other.x + ddx, y: other.y + ddy } }] : []; @@ -501,7 +514,7 @@ export const LabelCanvas = forwardRef(function LabelCa if (!objId || !stageRef.current) return; const objs = getCurrentObjects(); - const obj = objs.find((o) => o.id === objId); + const obj = findObjectById(objs, objId); if (!obj) return; const stage = stageRef.current; @@ -509,7 +522,7 @@ export const LabelCanvas = forwardRef(function LabelCa const draggedRect = { id: objId, x: dr.x, y: dr.y, width: dr.width, height: dr.height }; const otherRects = []; - for (const o of objs) { + for (const o of getAllLeaves(objs)) { if (o.id === objId) continue; const n = stage.findOne(`#${o.id}`); if (!n) continue; @@ -796,11 +809,15 @@ export const LabelCanvas = forwardRef(function LabelCa dpmm={label.dpmm} offsetX={objectsOffsetX} offsetY={labelOffsetY} - isSelected={selectedIds.includes(obj.id)} + isSelected={attachableIds.includes(obj.id)} onSelect={(add) => { + // Auto-select-parent: clicking a child of a group + // surfaces the outermost containing group as the + // selection target. Top-level leaves pass through. + const target = selectionTargetId(objects, obj.id); if (obj.locked) handleLockedClick(add); - else if (add) toggleSelectObject(obj.id); - else selectObject(obj.id); + else if (add) toggleSelectObject(target); + else selectObject(target); }} onChange={(changes) => handleObjectChange(obj.id, changes)} snap={snap} diff --git a/src/components/Canvas/hooks/useCanvasLasso.ts b/src/components/Canvas/hooks/useCanvasLasso.ts index ea25461e..c1e57454 100644 --- a/src/components/Canvas/hooks/useCanvasLasso.ts +++ b/src/components/Canvas/hooks/useCanvasLasso.ts @@ -1,6 +1,7 @@ import { useState, useRef } from "react"; import type Konva from "konva"; import { getCurrentObjects } from "../../../store/labelStore"; +import { getAllLeaves, selectionTargetId, findObjectById, isGroup } from "../../../types/Group"; import { getIdsIntersectingRect, type LassoRect } from "../lassoGeometry"; interface Options { @@ -62,8 +63,21 @@ export function useCanvasLasso({ containerRef, stageRef, spaceDown, selectObject // be moved or transformed, so grabbing them into a marquee selection // would make the post-lasso drag feel dead. Direct click and the // LayersPanel still target locked items, so bulk-unlock stays possible. - const ids = getCurrentObjects().flatMap((o) => o.locked ? [] : [o.id]); - selectObjects(getIdsIntersectingRect(stageRef.current, ids, rect)); + // Leaves are the only Konva-rendered things; intersect on those, then + // map each captured leaf to its outermost group so a lasso over a + // grouped child surfaces the group as the selection unit. + const objects = getCurrentObjects(); + const leafIds = getAllLeaves(objects).flatMap((o) => { + if (o.locked) return []; + const top = findObjectById(objects, selectionTargetId(objects, o.id)); + // A top-level group's lock cascades to its descendants for the + // purposes of lasso selection. + if (top && isGroup(top) && top.locked) return []; + return [o.id]; + }); + const hits = getIdsIntersectingRect(stageRef.current, leafIds, rect); + const promoted = new Set(hits.map((id) => selectionTargetId(objects, id))); + selectObjects([...promoted]); }; const onStageMouseDown = (e: Konva.KonvaEventObject) => { diff --git a/src/components/Canvas/hooks/useKonvaTransformer.ts b/src/components/Canvas/hooks/useKonvaTransformer.ts index 3b91aa12..9a354429 100644 --- a/src/components/Canvas/hooks/useKonvaTransformer.ts +++ b/src/components/Canvas/hooks/useKonvaTransformer.ts @@ -5,6 +5,7 @@ import { getCurrentObjects } from "../../../store/labelStore"; import { BARCODE_1D_TYPES, STACKED_2D_TYPES, ObjectRegistry } from "../../../registry"; import type { LabelObject } from "../../../registry"; import type { ObjectChanges } from "../../../store/labelStore"; +import { findObjectById } from "../../../types/Group"; import { applyHeightSnap, pinInactiveEdges, @@ -303,7 +304,7 @@ export function useKonvaTransformer({ const nodeHeight = node.height(); node.scaleX(1); node.scaleY(1); - const obj = getCurrentObjects().find((o) => o.id === singleId); + const obj = findObjectById(getCurrentObjects(), singleId); if (!obj) { cleanupTransformState(); return; diff --git a/src/store/labelStore.test.ts b/src/store/labelStore.test.ts index db7b113b..74d086c0 100644 --- a/src/store/labelStore.test.ts +++ b/src/store/labelStore.test.ts @@ -655,6 +655,35 @@ describe('groupSelection', () => { }); }); +describe('updateObject — leaves inside groups', () => { + it('reaches into a group to update a nested leaf', () => { + state().addObject('text', { x: 10, y: 10 }); + state().addObject('box', { x: 50, y: 50 }); + const [a, b] = objs(); + state().selectObjects([defined(a).id, defined(b).id]); + state().groupSelection(); + state().updateObject(defined(a).id, { x: 999 }); + // Group still at top level; updated leaf is found via tree walk. + const grp = defined(objs()[0]); + if (!isGroup(grp)) throw new Error('expected group'); + const updated = grp.children.find((c) => c.id === defined(a).id); + expect(defined(updated).x).toBe(999); + }); + + it('keeps unrelated leaves and siblings unchanged', () => { + state().addObject('text'); + state().addObject('box'); + const [a, b] = objs(); + state().selectObjects([defined(a).id, defined(b).id]); + state().groupSelection(); + state().updateObject(defined(a).id, { x: 123 }); + const grp = defined(objs()[0]); + if (!isGroup(grp)) throw new Error('expected group'); + const sibling = grp.children.find((c) => c.id === defined(b).id); + expect(defined(sibling).x).toBe(50); // default position from addObject + }); +}); + describe('ungroup', () => { it('replaces a selected group with its children at the same position', () => { state().addObject('text'); diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index a26abb9c..44f18995 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -6,7 +6,7 @@ import type { Unit } from '../lib/units'; import type { ViewRotation } from '../components/Canvas/rotationGeometry'; import { ObjectRegistry } from '../registry'; import type { LabelObject } from '../registry'; -import { isGroup, type GroupObject } from '../types/Group'; +import { isGroup, mapObjectById, type GroupObject } from '../types/Group'; import { locales } from '../locales'; import type { LocaleCode } from '../locales'; import { isDefaultLabelaryHost } from '../lib/labelary'; @@ -244,18 +244,22 @@ export const useLabelStore = create()( updateObject: (id, changes) => set((state) => updateCurrentObjects(state, (objs) => - objs.map((obj) => obj.id === id ? applyObjectChanges(obj, changes) : obj) - ) + mapObjectById(objs, id, (obj) => applyObjectChanges(obj, changes)), + ), ), updateObjects: (updates) => set((state) => { - const updateMap = new Map(updates.map((u) => [u.id, u.changes])); + // Apply updates one at a time through mapObjectById so leaves + // nested inside groups receive the same treatment as top-level + // objects. The list of updates is small in practice (typically + // the active selection) so the O(updates × tree) cost is fine. return updateCurrentObjects(state, (objs) => - objs.map((obj) => { - const changes = updateMap.get(obj.id); - return changes ? applyObjectChanges(obj, changes) : obj; - }) + updates.reduce( + (acc, { id, changes }) => + mapObjectById(acc, id, (obj) => applyObjectChanges(obj, changes)), + objs, + ), ); }), diff --git a/src/types/Group.test.ts b/src/types/Group.test.ts index 99bdb3d5..d129cb24 100644 --- a/src/types/Group.test.ts +++ b/src/types/Group.test.ts @@ -5,6 +5,8 @@ import { getAllLeaves, findObjectById, findAncestors, + selectionTargetId, + expandSelection, type GroupObject, } from './Group'; import type { LabelObject } from '../registry'; @@ -99,4 +101,47 @@ describe('Group helpers', () => { expect(findAncestors([leaf('a')], 'missing')).toEqual([]); }); }); + + describe('selectionTargetId', () => { + it('passes top-level leaves through unchanged', () => { + expect(selectionTargetId([leaf('a')], 'a')).toBe('a'); + }); + + it('promotes a clicked child to its outermost group', () => { + const tree = [group('g1', [group('g2', [leaf('deep')])])]; + expect(selectionTargetId(tree, 'deep')).toBe('g1'); + }); + + it('returns the id itself when not found (no surprises for callers)', () => { + expect(selectionTargetId([leaf('a')], 'missing')).toBe('missing'); + }); + }); + + describe('expandSelection', () => { + it('passes leaf ids through unchanged', () => { + expect(expandSelection([leaf('a'), leaf('b')], ['a'])).toEqual(['a']); + }); + + it('expands a group id to its leaf descendants', () => { + const tree = [group('g', [leaf('x'), leaf('y')])]; + expect(expandSelection(tree, ['g'])).toEqual(['x', 'y']); + }); + + it('expands nested groups depth-first', () => { + const tree = [group('g', [leaf('a'), group('inner', [leaf('b')])])]; + expect(expandSelection(tree, ['g'])).toEqual(['a', 'b']); + }); + + it('handles mixed leaf + group selection', () => { + const tree: ReturnType[] = [ + leaf('a'), + group('g', [leaf('b'), leaf('c')]), + ]; + expect(expandSelection(tree, ['a', 'g'])).toEqual(['a', 'b', 'c']); + }); + + it('silently drops unknown ids', () => { + expect(expandSelection([leaf('a')], ['missing'])).toEqual([]); + }); + }); }); diff --git a/src/types/Group.ts b/src/types/Group.ts index ba4ac3fb..58e8cfee 100644 --- a/src/types/Group.ts +++ b/src/types/Group.ts @@ -77,3 +77,57 @@ export function findAncestors( visit(objects); return trail; } + +/** + * Resolve the click target for a node hit at `id`: the outermost group + * containing it, or `id` itself when the node is at the top level. The + * Figma "auto-select-parent" rule — single click on a child surfaces the + * group as the unit of interaction. + */ +export function selectionTargetId(objects: LabelObject[], id: string): string { + return findAncestors(objects, id)[0]?.id ?? id; +} + +/** + * Returns a new tree with the node identified by `id` replaced by + * `mapper(node)`. Walks recursively into groups so this is the one + * code path the store needs to mutate either top-level objects or + * leaves nested inside groups. Unmatched leaves keep their object + * identity so per-object React memoisation still works. + */ +export function mapObjectById( + objects: LabelObject[], + id: string, + mapper: (obj: LabelObject) => LabelObject, +): LabelObject[] { + return objects.map((o) => { + if (o.id === id) return mapper(o); + if (isGroup(o)) return { ...o, children: mapObjectById(o.children, id, mapper) }; + return o; + }); +} + +/** + * Map an intent-level selection (which may include group ids) to the + * flat list of Konva-node ids the renderer and transformer can attach + * to. Group ids expand to their descendant leaves; leaf ids pass + * through. Order follows the input. + */ +export function expandSelection( + objects: LabelObject[], + selectedIds: readonly string[], +): string[] { + const byId = new Map(); + for (const n of walkObjects(objects)) byId.set(n.id, n); + const out: string[] = []; + for (const id of selectedIds) { + const node = byId.get(id); + if (!node) continue; + if (isGroup(node)) { + for (const leaf of getAllLeaves(node.children)) out.push(leaf.id); + } else { + out.push(id); + } + } + return out; +} From cfbf207c55d10917edfdabdafc203c860076a3b9 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 14 May 2026 17:30:54 +0200 Subject: [PATCH 07/37] feat(groups): cascade lock to children, expand selection for arrow keys / align A locked group now actually behaves locked: lock cascades into descendants for click handling (the locked-click passthrough fires when the resolved selection target is locked, not just the clicked leaf) and for drag (visibleLeaves stamps an effective locked flag onto leaves inside a locked group so the per-leaf draggable check sees one consistent value). Arrow keys and align-to-label now expand the selection through groups the same way drag does, so moving a selected group with the keyboard or aligning it to the label actually shifts its children instead of writing to the group's conventionally-zero x/y. --- src/components/Canvas/LabelCanvas.tsx | 48 ++++++++++++++++++++------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 08ba8c47..d4c7cc38 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -125,17 +125,27 @@ export const LabelCanvas = forwardRef(function LabelCa // Render path operates on visible leaves only: groups emit no node of // their own (v1 has no group transform), and a group with visible=false - // hides its whole subtree. + // hides its whole subtree. Lock cascades the same way — a leaf inside + // a locked group is stamped as effectively locked so the per-leaf + // draggable / locked-click checks all see one consistent value + // without each consumer having to walk ancestors. const visibleLeaves = useMemo(() => { const out: LabelObject[] = []; - const walk = (nodes: LabelObject[]) => { + const walk = (nodes: LabelObject[], inheritedLocked: boolean, inheritedHidden: boolean) => { for (const n of nodes) { - if (n.visible === false) continue; - if (isGroup(n)) walk(n.children); - else out.push(n); + const locked = inheritedLocked || !!n.locked; + const hidden = inheritedHidden || n.visible === false; + if (hidden) continue; + if (isGroup(n)) { + walk(n.children, locked, hidden); + } else { + // Preserve object identity when nothing was inherited so React + // memoisation keeps unaffected leaves stable across renders. + out.push(locked && !n.locked ? ({ ...n, locked: true } as LabelObject) : n); + } } }; - walk(objects); + walk(objects, false, false); return out; }, [objects]); @@ -207,9 +217,13 @@ export const LabelCanvas = forwardRef(function LabelCa // always move the object the way the user sees it on the rotated view. const [dx, dy] = inverseRotateDelta(screenDx, screenDy, viewRotation); + // Expand so arrow keys move every leaf of a selected group, not + // the group node itself (whose x/y is conventionally 0 and has no + // effect on rendered children). + const expanded = expandSelection(objs, ids); updateObjects( - ids.flatMap((sid) => { - const obj = objs.find((o) => o.id === sid); + expanded.flatMap((sid) => { + const obj = findObjectById(objs, sid); if (!obj || obj.locked) return []; return [{ id: sid, changes: { x: obj.x + dx, y: obj.y + dy } }]; }), @@ -347,8 +361,12 @@ export const LabelCanvas = forwardRef(function LabelCa const ids = state.selectedIds; if (ids.length === 0) return; const objs = currentObjects(state); + // Konva nodes only exist for leaves; align operates on the + // measured rendered bboxes, so expand any selected group to its + // leaf ids and feed those into the Konva lookup. + const attachable = expandSelection(objs, ids); - const boxes = ids.flatMap((id) => { + const boxes = attachable.flatMap((id) => { const node = stage.findOne(`#${id}`); if (!node) return []; const r = node.getClientRect({ relativeTo: stage }); @@ -375,8 +393,8 @@ export const LabelCanvas = forwardRef(function LabelCa const dxDots = Math.round(layoutDx / pxPerDot); const dyDots = Math.round(layoutDy / pxPerDot); - const updates = ids.flatMap((id) => { - const obj = objs.find((o) => o.id === id); + const updates = attachable.flatMap((id) => { + const obj = findObjectById(objs, id); if (!obj) return []; return [ { id, changes: { x: obj.x + dxDots, y: obj.y + dyDots } }, @@ -815,7 +833,13 @@ export const LabelCanvas = forwardRef(function LabelCa // surfaces the outermost containing group as the // selection target. Top-level leaves pass through. const target = selectionTargetId(objects, obj.id); - if (obj.locked) handleLockedClick(add); + // Lock cascades from the group: a click on a child + // of a locked group routes through handleLockedClick + // (so the next non-locked hit wins) instead of + // selecting through to a leaf the user can't move. + const targetObj = + target === obj.id ? obj : findObjectById(objects, target); + if (targetObj?.locked) handleLockedClick(add); else if (add) toggleSelectObject(target); else selectObject(target); }} From 508cd810bf2a2a9fd6c8576f081cfdc247dc28eb Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 14 May 2026 17:33:37 +0200 Subject: [PATCH 08/37] feat(groups): localised label and folder glyph for the group row Both PropertiesPanel header and LayersPanel row now show a folder glyph plus the localised 'Group' label instead of rendering blank (no registry icon) and falling back to the raw type-string 'group'. Adds the 'group' key to all 32 locales via the standard add_locale_key.local.py path so the entry lands consistently. --- src/components/Properties/LayersPanel.tsx | 10 ++++++++-- src/components/Properties/PropertiesPanel.tsx | 5 ++++- src/locales/ar.ts | 1 + src/locales/bg.ts | 1 + src/locales/cs.ts | 1 + src/locales/da.ts | 1 + src/locales/de.ts | 1 + src/locales/el.ts | 1 + src/locales/en.ts | 1 + src/locales/es.ts | 1 + src/locales/et.ts | 1 + src/locales/fa.ts | 1 + src/locales/fi.ts | 1 + src/locales/fr.ts | 1 + src/locales/he.ts | 1 + src/locales/hr.ts | 1 + src/locales/hu.ts | 1 + src/locales/it.ts | 1 + src/locales/ja.ts | 1 + src/locales/ko.ts | 1 + src/locales/lt.ts | 1 + src/locales/lv.ts | 1 + src/locales/nl.ts | 1 + src/locales/no.ts | 1 + src/locales/pl.ts | 1 + src/locales/pt.ts | 1 + src/locales/ro.ts | 1 + src/locales/sk.ts | 1 + src/locales/sl.ts | 1 + src/locales/sr.ts | 1 + src/locales/sv.ts | 1 + src/locales/tr.ts | 1 + src/locales/zh-hans.ts | 1 + src/locales/zh-hant.ts | 1 + 34 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index c30d3e08..361decf8 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -28,6 +28,7 @@ interface RowProps { tUnlock: string; tShow: string; tHide: string; + tGroup: string; } function SortableLayerRow({ @@ -42,8 +43,10 @@ function SortableLayerRow({ tUnlock, tShow, tHide, + tGroup, }: RowProps) { const def = ObjectRegistry[obj.type]; + const isGroupRow = obj.type === 'group'; const isLocked = !!obj.locked; const isHidden = obj.visible === false; // Locked rows opt out of @dnd-kit's sortable listeners so the drag handle @@ -82,10 +85,12 @@ function SortableLayerRow({ className={`w-2 h-3.5 shrink-0 text-muted transition-opacity ${isLocked ? 'opacity-0' : 'opacity-0 group-hover:opacity-60'}`} /> - {def?.icon} + {isGroupRow ? '⊞' : def?.icon}
- {def?.label ?? obj.type} + + {isGroupRow ? tGroup : (def?.label ?? obj.type)} + {obj.id.slice(0, 8)}
+ ) : ( + // Reserve the chevron slot on leaf rows so icons align across types. + + )} + + {groupRow ? '⊞' : def?.icon} + +
+ + {groupRow ? tGroup : (def?.label ?? obj.type)} + + {obj.id.slice(0, 8)} +
+ {groupRow && ( - + )} + + + + ); +} + +interface SortableRowProps extends CommonRowProps { + isOver: boolean; +} + +function SortableLayerRow(props: SortableRowProps) { + const isLocked = !!props.obj.locked; + const { attributes, listeners, setNodeRef, isDragging } = useSortable({ + id: props.obj.id, + disabled: isLocked, + }); + + return ( + <> +
+ + } + /> ); } +function NestedLayerRow(props: CommonRowProps) { + // Children of an expanded group don't participate in drag-reorder yet + // (that lands with the cross-group drag feature). The leading slot + // stays empty so the row visually aligns with sortable siblings. + return } />; +} + export function LayersPanel() { const t = useT(); - const { selectedIds, selectObject, toggleSelectObject, reorderObject, updateObjects } = useLabelStore(); + const { + selectedIds, + selectObject, + toggleSelectObject, + reorderObject, + updateObjects, + ungroupIds, + } = useLabelStore(); const objects = useCurrentObjects(); const [overId, setOverId] = useState(null); + const [expandedIds, setExpandedIds] = useState>(new Set()); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), ); + // Flat list of every node anywhere in the tree, used for bulk toggle + // state lookup when the user clicks a row's eye / lock icon: the + // selection broadcast logic needs to inspect the clicked object's + // current value regardless of whether it's nested. + const allNodes = useMemo(() => [...walkObjects(objects)], [objects]); + const toggleField = (clickedId: string, field: ToggleField) => { - const updates = buildBulkToggleUpdates(objects, selectedIds, clickedId, field); + const updates = buildBulkToggleUpdates(allNodes, selectedIds, clickedId, field); if (updates.length > 0) updateObjects(updates); }; + const toggleExpand = (id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const rows = useMemo(() => buildFlatRows(objects, expandedIds), [objects, expandedIds]); + if (objects.length === 0) { return (
@@ -140,8 +287,10 @@ export function LayersPanel() { ); } - // Reverse so topmost layer (last in array = front) appears first - const reversed = [...objects].reverse(); + // Drag-reorder only operates on top-level objects (depth=0). Nested + // rows render but are not part of the SortableContext, so dnd-kit + // ignores them as drag sources or drop targets. + const topLevelIds = objects.map((o) => o.id).reverse(); const n = objects.length; const handleDragOver = ({ over }: DragOverEvent) => @@ -150,12 +299,34 @@ export function LayersPanel() { const handleDragEnd = ({ active, over }: DragEndEvent) => { setOverId(null); if (!over || active.id === over.id) return; - const toVisualIndex = reversed.findIndex((o) => o.id === over.id); + const toVisualIndex = topLevelIds.indexOf(over.id as string); + if (toVisualIndex === -1) return; reorderObject(active.id as string, n - 1 - toVisualIndex); }; const handleDragCancel = () => setOverId(null); + const commonRowProps = (obj: LabelObject, depth: number): CommonRowProps => ({ + obj, + depth, + isSelected: selectedIds.includes(obj.id), + isExpanded: expandedIds.has(obj.id), + onSelect: () => selectObject(obj.id), + onToggle: () => toggleSelectObject(obj.id), + onToggleLock: () => toggleField(obj.id, 'locked'), + onToggleVisible: () => toggleField(obj.id, 'visible'), + onToggleExpand: () => toggleExpand(obj.id), + onUngroup: () => ungroupIds([obj.id]), + tLock: t.layers.lock, + tUnlock: t.layers.unlock, + tShow: t.layers.show, + tHide: t.layers.hide, + tGroup: t.types.group, + tExpand: t.app.expand, + tCollapse: t.app.collapse, + tUngroupLabel: t.layers.ungroup, + }); + return ( - o.id)} strategy={verticalListSortingStrategy}> +
- {reversed.map((obj) => ( - selectObject(obj.id)} - onToggle={() => toggleSelectObject(obj.id)} - onToggleLock={() => toggleField(obj.id, 'locked')} - onToggleVisible={() => toggleField(obj.id, 'visible')} - tLock={t.layers.lock} - tUnlock={t.layers.unlock} - tShow={t.layers.show} - tHide={t.layers.hide} - tGroup={t.types.group} - /> - ))} + {rows.map(({ obj, depth }) => + depth === 0 ? ( + + ) : ( + + ), + )}
diff --git a/src/locales/ar.ts b/src/locales/ar.ts index feae37b7..8cf411ac 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -406,6 +406,7 @@ const ar = { toBack: 'إرسال للخلف', lock: 'قفل', unlock: 'إلغاء القفل', + ungroup: 'فك المجموعة', show: 'إظهار', hide: 'إخفاء', }, diff --git a/src/locales/bg.ts b/src/locales/bg.ts index 8796fcd7..7abfa29f 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -406,6 +406,7 @@ const bg = { toBack: 'Премести най-назад', lock: 'Заключване', unlock: 'Отключване', + ungroup: 'Разгрупиране', show: 'Покажи', hide: 'Скрий', }, diff --git a/src/locales/cs.ts b/src/locales/cs.ts index fbd8c349..551dbe1e 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -406,6 +406,7 @@ const cs = { toBack: 'Odeslat do pozadí', lock: 'Uzamknout', unlock: 'Odemknout', + ungroup: 'Zrušit skupinu', show: 'Zobrazit', hide: 'Skrýt', }, diff --git a/src/locales/da.ts b/src/locales/da.ts index fe79408a..545c1c7e 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -406,6 +406,7 @@ const da = { toBack: 'Send til baggrunden', lock: 'Lås', unlock: 'Lås op', + ungroup: 'Ophæv gruppering', show: 'Vis', hide: 'Skjul', }, diff --git a/src/locales/de.ts b/src/locales/de.ts index e3981f50..f39bcf3c 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -427,6 +427,7 @@ const de = { toBack: 'In den Hintergrund', lock: 'Sperren', unlock: 'Entsperren', + ungroup: 'Gruppierung aufheben', show: 'Einblenden', hide: 'Ausblenden', }, diff --git a/src/locales/el.ts b/src/locales/el.ts index fbfaf145..4078978e 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -406,6 +406,7 @@ const el = { toBack: 'Αποστολή στο πίσω', lock: 'Κλείδωμα', unlock: 'Ξεκλείδωμα', + ungroup: 'Κατάργηση ομαδοποίησης', show: 'Εμφάνιση', hide: 'Απόκρυψη', }, diff --git a/src/locales/en.ts b/src/locales/en.ts index 177fda9d..51bdb52e 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -427,6 +427,7 @@ const en = { toBack: 'Send to Back', lock: 'Lock', unlock: 'Unlock', + ungroup: 'Ungroup', show: 'Show', hide: 'Hide', }, diff --git a/src/locales/es.ts b/src/locales/es.ts index b621678f..e0d4ab30 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -406,6 +406,7 @@ const es = { toBack: 'Enviar al fondo', lock: 'Bloquear', unlock: 'Desbloquear', + ungroup: 'Desagrupar', show: 'Mostrar', hide: 'Ocultar', }, diff --git a/src/locales/et.ts b/src/locales/et.ts index fcff7f1a..68efaab0 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -406,6 +406,7 @@ const et = { toBack: 'Saada taha', lock: 'Lukusta', unlock: 'Eemalda lukk', + ungroup: 'Tühista rühm', show: 'Näita', hide: 'Peida', }, diff --git a/src/locales/fa.ts b/src/locales/fa.ts index c8757906..1d2f86fe 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -406,6 +406,7 @@ const fa = { toBack: 'ارسال به عقب', lock: 'قفل', unlock: 'بازکردن قفل', + ungroup: 'لغو گروه‌بندی', show: 'نمایش', hide: 'پنهان', }, diff --git a/src/locales/fi.ts b/src/locales/fi.ts index e7307578..1910fd67 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -406,6 +406,7 @@ const fi = { toBack: 'Lähetä taustalle', lock: 'Lukitse', unlock: 'Avaa lukitus', + ungroup: 'Poista ryhmittely', show: 'Näytä', hide: 'Piilota', }, diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 43ea6d35..550b2930 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -406,6 +406,7 @@ const fr = { toBack: "Envoyer à l'arrière-plan", lock: 'Verrouiller', unlock: 'Déverrouiller', + ungroup: 'Dégrouper', show: 'Afficher', hide: 'Masquer', }, diff --git a/src/locales/he.ts b/src/locales/he.ts index f0e8146f..9fe401fe 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -406,6 +406,7 @@ const he = { toBack: 'שלח לאחור', lock: 'נעל', unlock: 'בטל נעילה', + ungroup: 'ביטול קיבוץ', show: 'הצג', hide: 'הסתר', }, diff --git a/src/locales/hr.ts b/src/locales/hr.ts index 6cbc540f..0a00c4fd 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -406,6 +406,7 @@ const hr = { toBack: 'Pomakni nazad', lock: 'Zaključaj', unlock: 'Otključaj', + ungroup: 'Razgrupiraj', show: 'Prikaži', hide: 'Sakrij', }, diff --git a/src/locales/hu.ts b/src/locales/hu.ts index 16fee52c..333d73eb 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -406,6 +406,7 @@ const hu = { toBack: 'Hátra küldés', lock: 'Zárolás', unlock: 'Feloldás', + ungroup: 'Csoport bontása', show: 'Megjelenítés', hide: 'Elrejtés', }, diff --git a/src/locales/it.ts b/src/locales/it.ts index f42f8cc4..99e61a91 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -406,6 +406,7 @@ const it = { toBack: 'Porta in secondo piano', lock: 'Blocca', unlock: 'Sblocca', + ungroup: 'Separa', show: 'Mostra', hide: 'Nascondi', }, diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 890c2ab0..5fe9f999 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -406,6 +406,7 @@ const ja = { toBack: '最背面へ', lock: 'ロック', unlock: 'ロック解除', + ungroup: 'グループ解除', show: '表示', hide: '非表示', }, diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 466fab78..80d4c7cc 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -406,6 +406,7 @@ const ko = { toBack: '맨 뒤로', lock: '잠금', unlock: '잠금 해제', + ungroup: '그룹 해제', show: '표시', hide: '숨기기', }, diff --git a/src/locales/lt.ts b/src/locales/lt.ts index 0cd5ff0c..51a4ab2c 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -406,6 +406,7 @@ const lt = { toBack: 'Perkelti į galą', lock: 'Užrakinti', unlock: 'Atrakinti', + ungroup: 'Išgrupuoti', show: 'Rodyti', hide: 'Slėpti', }, diff --git a/src/locales/lv.ts b/src/locales/lv.ts index 34d29f6e..fe0318c3 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -406,6 +406,7 @@ const lv = { toBack: 'Pārvietot uz aizmuguri', lock: 'Bloķēt', unlock: 'Atbloķēt', + ungroup: 'Atgrupēt', show: 'Rādīt', hide: 'Slēpt', }, diff --git a/src/locales/nl.ts b/src/locales/nl.ts index e139313b..09740dad 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -406,6 +406,7 @@ const nl = { toBack: 'Naar de achtergrond', lock: 'Vergrendelen', unlock: 'Ontgrendelen', + ungroup: 'Groepering opheffen', show: 'Tonen', hide: 'Verbergen', }, diff --git a/src/locales/no.ts b/src/locales/no.ts index 16ed4e20..efa4f1ca 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -406,6 +406,7 @@ const no = { toBack: 'Flytt bakerst', lock: 'Lås', unlock: 'Lås opp', + ungroup: 'Del opp', show: 'Vis', hide: 'Skjul', }, diff --git a/src/locales/pl.ts b/src/locales/pl.ts index 40f50f53..624a7513 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -406,6 +406,7 @@ const pl = { toBack: 'Przesuń na spód', lock: 'Zablokuj', unlock: 'Odblokuj', + ungroup: 'Rozgrupuj', show: 'Pokaż', hide: 'Ukryj', }, diff --git a/src/locales/pt.ts b/src/locales/pt.ts index ffb57db5..4815376b 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -406,6 +406,7 @@ const pt = { toBack: 'Enviar para o fundo', lock: 'Bloquear', unlock: 'Desbloquear', + ungroup: 'Desagrupar', show: 'Mostrar', hide: 'Ocultar', }, diff --git a/src/locales/ro.ts b/src/locales/ro.ts index cad0376c..0e90dd3d 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -406,6 +406,7 @@ const ro = { toBack: 'Trimite în fundal', lock: 'Blochează', unlock: 'Deblochează', + ungroup: 'Anulează grupare', show: 'Afișează', hide: 'Ascunde', }, diff --git a/src/locales/sk.ts b/src/locales/sk.ts index a1618785..8d5bf270 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -406,6 +406,7 @@ const sk = { toBack: 'Odoslať do pozadia', lock: 'Uzamknúť', unlock: 'Odomknúť', + ungroup: 'Zrušiť skupinu', show: 'Zobraziť', hide: 'Skryť', }, diff --git a/src/locales/sl.ts b/src/locales/sl.ts index 5deb7e63..6f7e1f1d 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -406,6 +406,7 @@ const sl = { toBack: 'Premakni v ozadje', lock: 'Zakleni', unlock: 'Odkleni', + ungroup: 'Razdruži', show: 'Prikaži', hide: 'Skrij', }, diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 5844cf3e..1d825c7e 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -406,6 +406,7 @@ const sr = { toBack: 'Pomeri nazad', lock: 'Закључај', unlock: 'Откључај', + ungroup: 'Razgrupiši', show: 'Прикажи', hide: 'Сакриј', }, diff --git a/src/locales/sv.ts b/src/locales/sv.ts index b1b0f3cc..48485e28 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -406,6 +406,7 @@ const sv = { toBack: 'Flytta längst bak', lock: 'Lås', unlock: 'Lås upp', + ungroup: 'Dela upp', show: 'Visa', hide: 'Dölj', }, diff --git a/src/locales/tr.ts b/src/locales/tr.ts index 68894393..24500bd1 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -406,6 +406,7 @@ const tr = { toBack: 'Arkaya Gönder', lock: 'Kilitle', unlock: 'Kilidi aç', + ungroup: 'Grubu çöz', show: 'Göster', hide: 'Gizle', }, diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 1d2abd4e..46547891 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -406,6 +406,7 @@ const zhHans = { toBack: '置于底层', lock: '锁定', unlock: '解锁', + ungroup: '取消组合', show: '显示', hide: '隐藏', }, diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index f60025fd..6e05da76 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -406,6 +406,7 @@ const zhHant = { toBack: '移至最下層', lock: '鎖定', unlock: '解鎖', + ungroup: '取消群組', show: '顯示', hide: '隱藏', }, diff --git a/src/store/labelStore.test.ts b/src/store/labelStore.test.ts index 74d086c0..c0b8edee 100644 --- a/src/store/labelStore.test.ts +++ b/src/store/labelStore.test.ts @@ -718,4 +718,19 @@ describe('ungroup', () => { expect(objs()).toHaveLength(1); expect(isGroup(defined(objs()[0]))).toBe(true); }); + + it('ungroupIds operates on the passed list, not the current selection', () => { + state().addObject('text'); + state().addObject('box'); + const [a, b] = objs(); + state().selectObjects([defined(a).id, defined(b).id]); + state().groupSelection(); + const gid = defined(state().selectedIds[0]); + // Move selection elsewhere; the layers-panel button calls ungroupIds + // without changing what the user has selected. + state().selectObject(null); + state().ungroupIds([gid]); + expect(objs()).toHaveLength(2); + expect(objs().every((o) => !isGroup(o))).toBe(true); + }); }); diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index 44f18995..26b06f68 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -109,6 +109,10 @@ interface LabelState { /** Replaces every selected top-level group with its children, splicing * them in at the group's former index. No-op on non-group selections. */ ungroup: () => void; + /** Like `ungroup`, but operates on an explicit id list instead of the + * active selection. Used by the layers panel's per-row ungroup + * button so the user doesn't have to select the group first. */ + ungroupIds: (ids: readonly string[]) => void; setLabelConfig: (config: Partial) => void; setLocale: (locale: LocaleCode) => void; setTheme: (theme: ThemePreference) => void; @@ -407,12 +411,14 @@ export const useLabelStore = create()( }; }), - ungroup: () => + ungroup: () => get().ungroupIds(get().selectedIds), + + ungroupIds: (ids) => set((state) => { - const sel = new Set(state.selectedIds); + const wanted = new Set(ids); const objs = currentObjects(state); const targets = objs.flatMap((o) => - sel.has(o.id) && isGroup(o) && !o.locked ? [o] : [], + wanted.has(o.id) && isGroup(o) && !o.locked ? [o] : [], ); if (targets.length === 0) return {}; const targetIds = new Set(targets.map((g) => g.id)); From ad650f86ffa8737eac2ad124914623886d6af008 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 14 May 2026 20:50:32 +0200 Subject: [PATCH 10/37] feat(groups): drag layers in and out of groups Every visible row in the layers panel is now sortable and carries its container id. Dragging a row onto a sibling reorders it; dragging onto a collapsed group drops the item into that group; dragging into the visible children of an expanded group lands the item beside its new siblings; dragging a child back to a top-level position extracts it from its group. The drop target gets a soft accent outline so the user sees which group will receive the dragged item. Store gains reparentObject(id, { parentId, index }) backed by detachObjectById and an isSelfOrDescendant cycle check that prevents moving a group into one of its own descendants. Both helpers live alongside the existing tree-walk utilities in types/Group. --- src/components/Properties/LayersPanel.tsx | 243 +++++++++++----------- src/store/labelStore.test.ts | 62 ++++++ src/store/labelStore.ts | 53 ++++- src/types/Group.test.ts | 54 +++++ src/types/Group.ts | 44 ++++ 5 files changed, 330 insertions(+), 126 deletions(-) diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index a7a2c0d8..325fe50c 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -20,42 +20,46 @@ import { import { useLabelStore, useCurrentObjects } from '../../store/labelStore'; import { ObjectRegistry } from '../../registry'; import type { LabelObject } from '../../registry'; -import { isGroup, walkObjects } from '../../types/Group'; +import { isGroup, walkObjects, findObjectById } from '../../types/Group'; import { useT } from '../../lib/useT'; import { buildBulkToggleUpdates, type ToggleField } from '../../lib/bulkToggle'; import { DragHandleIcon } from '../ui/DragHandleIcon'; -/** Layers panel renders a tree but the data model carries a flat top-level - * array; this is the projection consumers iterate over. `depth` controls - * the row's indent and whether it participates in drag-reorder. */ +/** Sentinel container id for the top-level objects list. Group containers + * use the group's own id, so the root needs a value that can't collide. */ +const ROOT_CONTAINER = '__root__'; + interface FlatRow { obj: LabelObject; depth: number; + containerId: string; } /** Walk the tree depth-first, reversed at each level so the topmost item * (last in the array = front-most in render order) appears first in the - * panel. A group is only expanded if its id is in `expanded`; collapsed - * groups still render as one row but their children are skipped. */ + * panel. Each row carries its container id so drag-and-drop can decide + * whether a move is a sibling reorder or a cross-container reparent. */ function buildFlatRows(objects: LabelObject[], expanded: Set): FlatRow[] { const out: FlatRow[] = []; - const walk = (nodes: LabelObject[], depth: number) => { + const walk = (nodes: LabelObject[], depth: number, containerId: string) => { for (let i = nodes.length - 1; i >= 0; i--) { const obj = nodes[i]; if (!obj) continue; - out.push({ obj, depth }); - if (isGroup(obj) && expanded.has(obj.id)) walk(obj.children, depth + 1); + out.push({ obj, depth, containerId }); + if (isGroup(obj) && expanded.has(obj.id)) walk(obj.children, depth + 1, obj.id); } }; - walk(objects, 0); + walk(objects, 0, ROOT_CONTAINER); return out; } -interface CommonRowProps { +interface RowProps { obj: LabelObject; depth: number; + containerId: string; isSelected: boolean; isExpanded: boolean; + isDropTarget: boolean; onSelect: () => void; onToggle: () => void; onToggleLock: () => void; @@ -72,32 +76,19 @@ interface CommonRowProps { tUngroupLabel: string; } -interface RowBodyProps extends CommonRowProps { - /** Slot for the row's leading affordance (drag handle for sortable rows, - * empty placeholder for nested rows that don't participate in drag). */ - leading: React.ReactNode; - /** Forwarded to the row's root element. */ - rootRef?: React.Ref; - /** Spread on root: @dnd-kit's `attributes`/`listeners` for sortable rows. */ - rootAttrs?: React.HTMLAttributes; - isDragging?: boolean; -} - -function RowBody({ +function LayerRow({ obj, depth, + containerId, isSelected, isExpanded, + isDropTarget, onSelect, onToggle, onToggleLock, onToggleVisible, onToggleExpand, onUngroup, - leading, - rootRef, - rootAttrs, - isDragging, tLock, tUnlock, tShow, @@ -106,39 +97,41 @@ function RowBody({ tExpand, tCollapse, tUngroupLabel, -}: RowBodyProps) { +}: RowProps) { const def = ObjectRegistry[obj.type]; const groupRow = isGroup(obj); const isLocked = !!obj.locked; const isHidden = obj.visible === false; + const { attributes, listeners, setNodeRef, isDragging } = useSortable({ + id: obj.id, + data: { containerId }, + disabled: isLocked, + }); const stopRowClick = (e: React.MouseEvent) => e.stopPropagation(); - // Nested rows share the row click but don't participate in drag, so the - // cursor stays the default click affordance. - const cursor = depth > 0 - ? 'cursor-pointer' - : isLocked - ? 'cursor-pointer' - : 'cursor-grab active:cursor-grabbing'; return (
0 ? depth * 16 + 8 : undefined }} - {...rootAttrs} + {...attributes} + {...(isLocked ? {} : listeners)} onClick={(e) => { if (e.shiftKey || e.ctrlKey || e.metaKey) onToggle(); else onSelect(); }} className={` flex items-center gap-2 px-2 py-1.5 - ${cursor} + ${isLocked ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'} border-b border-border group transition-colors hover:bg-surface-2 ${isSelected ? 'bg-surface-2 border-l-2 border-l-accent' : 'border-l-2 border-l-transparent'} ${isDragging ? 'opacity-40' : ''} ${isHidden ? 'opacity-50' : ''} + ${isDropTarget ? 'bg-accent/15 outline outline-1 outline-accent/60' : ''} `} > - {leading} + {groupRow ? ( ) : ( - // Reserve the chevron slot on leaf rows so icons align across types. )} @@ -202,53 +194,15 @@ function RowBody({ ); } -interface SortableRowProps extends CommonRowProps { - isOver: boolean; -} - -function SortableLayerRow(props: SortableRowProps) { - const isLocked = !!props.obj.locked; - const { attributes, listeners, setNodeRef, isDragging } = useSortable({ - id: props.obj.id, - disabled: isLocked, - }); - - return ( - <> -
- - } - /> - - ); -} - -function NestedLayerRow(props: CommonRowProps) { - // Children of an expanded group don't participate in drag-reorder yet - // (that lands with the cross-group drag feature). The leading slot - // stays empty so the row visually aligns with sortable siblings. - return } />; -} - export function LayersPanel() { const t = useT(); const { selectedIds, selectObject, toggleSelectObject, - reorderObject, updateObjects, ungroupIds, + reparentObject, } = useLabelStore(); const objects = useCurrentObjects(); const [overId, setOverId] = useState(null); @@ -257,11 +211,13 @@ export function LayersPanel() { useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), ); - // Flat list of every node anywhere in the tree, used for bulk toggle - // state lookup when the user clicks a row's eye / lock icon: the - // selection broadcast logic needs to inspect the clicked object's - // current value regardless of whether it's nested. const allNodes = useMemo(() => [...walkObjects(objects)], [objects]); + const rows = useMemo(() => buildFlatRows(objects, expandedIds), [objects, expandedIds]); + const rowsById = useMemo(() => { + const m = new Map(); + for (const r of rows) m.set(r.obj.id, r); + return m; + }, [rows]); const toggleField = (clickedId: string, field: ToggleField) => { const updates = buildBulkToggleUpdates(allNodes, selectedIds, clickedId, field); @@ -277,8 +233,6 @@ export function LayersPanel() { }); }; - const rows = useMemo(() => buildFlatRows(objects, expandedIds), [objects, expandedIds]); - if (objects.length === 0) { return (
@@ -287,11 +241,7 @@ export function LayersPanel() { ); } - // Drag-reorder only operates on top-level objects (depth=0). Nested - // rows render but are not part of the SortableContext, so dnd-kit - // ignores them as drag sources or drop targets. - const topLevelIds = objects.map((o) => o.id).reverse(); - const n = objects.length; + const allRowIds = rows.map((r) => r.obj.id); const handleDragOver = ({ over }: DragOverEvent) => setOverId((over?.id as string) ?? null); @@ -299,33 +249,62 @@ export function LayersPanel() { const handleDragEnd = ({ active, over }: DragEndEvent) => { setOverId(null); if (!over || active.id === over.id) return; - const toVisualIndex = topLevelIds.indexOf(over.id as string); - if (toVisualIndex === -1) return; - reorderObject(active.id as string, n - 1 - toVisualIndex); + const activeId = active.id as string; + const overRow = rowsById.get(over.id as string); + if (!overRow) return; + + // Dropping on a collapsed group: treat as "drop into" so the user + // can move things into a group without expanding it first. Expanded + // groups don't need this — the user can drop directly onto any + // child row inside, which lands the item inside the group via the + // sibling code path below. + if (isGroup(overRow.obj) && !expandedIds.has(overRow.obj.id)) { + reparentObject(activeId, { + parentId: overRow.obj.id, + index: overRow.obj.children.length, + }); + return; + } + + // Sibling case: place active where over currently sits in its + // container, in data order. The layers panel displays containers + // reversed (topmost row = last in array), so a "drop above over" + // in display = "after over in data order"; using over's own data + // index produces that effect because the existing occupant shifts + // down one position in data order (up one in display). + const targetParent = overRow.containerId === ROOT_CONTAINER + ? null + : overRow.containerId; + const containerChildren = targetParent === null + ? objects + : (() => { + const g = findObjectById(objects, targetParent); + return g && isGroup(g) ? g.children : null; + })(); + if (!containerChildren) return; + let dataIndex = containerChildren.findIndex((c) => c.id === overRow.obj.id); + if (dataIndex === -1) return; + // Same-container moves shift indices: if active currently sits + // before over in data order, detaching it drops over's index by 1. + const activeRow = rowsById.get(activeId); + if (activeRow && activeRow.containerId === overRow.containerId) { + const activeDataIndex = containerChildren.findIndex((c) => c.id === activeId); + if (activeDataIndex >= 0 && activeDataIndex < dataIndex) dataIndex -= 1; + } + reparentObject(activeId, { parentId: targetParent, index: dataIndex }); }; const handleDragCancel = () => setOverId(null); - const commonRowProps = (obj: LabelObject, depth: number): CommonRowProps => ({ - obj, - depth, - isSelected: selectedIds.includes(obj.id), - isExpanded: expandedIds.has(obj.id), - onSelect: () => selectObject(obj.id), - onToggle: () => toggleSelectObject(obj.id), - onToggleLock: () => toggleField(obj.id, 'locked'), - onToggleVisible: () => toggleField(obj.id, 'visible'), - onToggleExpand: () => toggleExpand(obj.id), - onUngroup: () => ungroupIds([obj.id]), - tLock: t.layers.lock, - tUnlock: t.layers.unlock, - tShow: t.layers.show, - tHide: t.layers.hide, - tGroup: t.types.group, - tExpand: t.app.expand, - tCollapse: t.app.collapse, - tUngroupLabel: t.layers.ungroup, - }); + // The collapsed group that the active drag is currently over, if any. + // Used to render a "drop into" highlight on that single row. + const dropIntoTargetId = (() => { + if (!overId) return null; + const r = rowsById.get(overId); + if (!r || !isGroup(r.obj)) return null; + if (expandedIds.has(r.obj.id)) return null; + return r.obj.id; + })(); return ( - +
- {rows.map(({ obj, depth }) => - depth === 0 ? ( - - ) : ( - - ), - )} + {rows.map(({ obj, depth, containerId }) => ( + selectObject(obj.id)} + onToggle={() => toggleSelectObject(obj.id)} + onToggleLock={() => toggleField(obj.id, 'locked')} + onToggleVisible={() => toggleField(obj.id, 'visible')} + onToggleExpand={() => toggleExpand(obj.id)} + onUngroup={() => ungroupIds([obj.id])} + tLock={t.layers.lock} + tUnlock={t.layers.unlock} + tShow={t.layers.show} + tHide={t.layers.hide} + tGroup={t.types.group} + tExpand={t.app.expand} + tCollapse={t.app.collapse} + tUngroupLabel={t.layers.ungroup} + /> + ))}
diff --git a/src/store/labelStore.test.ts b/src/store/labelStore.test.ts index c0b8edee..d4050ed0 100644 --- a/src/store/labelStore.test.ts +++ b/src/store/labelStore.test.ts @@ -719,6 +719,68 @@ describe('ungroup', () => { expect(isGroup(defined(objs()[0]))).toBe(true); }); + it('reparentObject moves a top-level leaf into a group', () => { + state().addObject('text'); // a + state().addObject('box'); // b + state().addObject('circle'); // c + const [a, b, c] = objs(); + // Group b and c + state().selectObjects([defined(b).id, defined(c).id]); + state().groupSelection(); + const gid = defined(state().selectedIds[0]); + // Move 'a' into the group + state().reparentObject(defined(a).id, { parentId: gid, index: 1 }); + expect(objs()).toHaveLength(1); // only the group at top level + const grp = defined(objs()[0]); + if (!isGroup(grp)) throw new Error('expected group'); + expect(grp.children.map((c) => c.id)).toEqual([ + defined(b).id, defined(a).id, defined(c).id, + ]); + }); + + it('reparentObject moves a child out of a group to top level', () => { + state().addObject('text'); + state().addObject('box'); + const [a, b] = objs(); + state().selectObjects([defined(a).id, defined(b).id]); + state().groupSelection(); + const gid = defined(state().selectedIds[0]); + // Extract 'a' to top level at index 0 (before the group) + state().reparentObject(defined(a).id, { parentId: null, index: 0 }); + expect(objs()).toHaveLength(2); + expect(defined(objs()[0]).id).toBe(defined(a).id); + const grp = defined(objs()[1]); + if (!isGroup(grp)) throw new Error('expected group'); + expect(grp.children.map((c) => c.id)).toEqual([defined(b).id]); + expect(grp.id).toBe(gid); + }); + + it('reparentObject refuses to move a group into itself', () => { + state().addObject('text'); + state().selectObject(defined(objs()[0]).id); + state().groupSelection(); + const gid = defined(state().selectedIds[0]); + const before = JSON.stringify(objs()); + state().reparentObject(gid, { parentId: gid, index: 0 }); + expect(JSON.stringify(objs())).toBe(before); + }); + + it('reparentObject refuses to move a group into one of its descendants', () => { + state().addObject('text'); + state().addObject('box'); + const [a, b] = objs(); + state().selectObjects([defined(a).id, defined(b).id]); + state().groupSelection(); + const outerGid = defined(state().selectedIds[0]); + // Create an inner group containing only 'a' (manually via grouping + // the existing children would need ungroup-then-group; this test + // simulates the cycle case by trying to move outerGid into 'a'.) + const before = JSON.stringify(objs()); + state().reparentObject(outerGid, { parentId: defined(a).id, index: 0 }); + // No-op because 'a' is a leaf, not a group → defensive check. + expect(JSON.stringify(objs())).toBe(before); + }); + it('ungroupIds operates on the passed list, not the current selection', () => { state().addObject('text'); state().addObject('box'); diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index 26b06f68..4e8f3faf 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -6,7 +6,14 @@ import type { Unit } from '../lib/units'; import type { ViewRotation } from '../components/Canvas/rotationGeometry'; import { ObjectRegistry } from '../registry'; import type { LabelObject } from '../registry'; -import { isGroup, mapObjectById, type GroupObject } from '../types/Group'; +import { + isGroup, + mapObjectById, + detachObjectById, + findObjectById, + isSelfOrDescendant, + type GroupObject, +} from '../types/Group'; import { locales } from '../locales'; import type { LocaleCode } from '../locales'; import { isDefaultLabelaryHost } from '../lib/labelary'; @@ -113,6 +120,11 @@ interface LabelState { * active selection. Used by the layers panel's per-row ungroup * button so the user doesn't have to select the group first. */ ungroupIds: (ids: readonly string[]) => void; + /** Move `id` to a new position in the tree. `parentId: null` means the + * top level; any other value targets a group. `index` is the + * insertion position inside the target's children list. Silently + * refuses cycles (moving a group into its own descendant). */ + reparentObject: (id: string, target: { parentId: string | null; index: number }) => void; setLabelConfig: (config: Partial) => void; setLocale: (locale: LocaleCode) => void; setTheme: (theme: ThemePreference) => void; @@ -411,6 +423,45 @@ export const useLabelStore = create()( }; }), + reparentObject: (id, target) => + set((state) => { + const objs = currentObjects(state); + // Forbid cycles: moving a group into itself or one of its + // descendants would orphan the rest of the tree. + if (target.parentId && isSelfOrDescendant(objs, id, target.parentId)) { + return {}; + } + // Refuse drops into something that isn't a group — the layers + // panel should never produce this, but a defensive check + // keeps the model from picking up bogus state if a caller + // passes a leaf id. + if (target.parentId !== null) { + const parent = findObjectById(objs, target.parentId); + if (!parent || !isGroup(parent)) return {}; + } + const { removed, rest } = detachObjectById(objs, id); + if (!removed) return {}; + const node = removed; + if (target.parentId === null) { + const clamped = Math.max(0, Math.min(target.index, rest.length)); + const next = [...rest.slice(0, clamped), node, ...rest.slice(clamped)]; + return updateCurrentObjects(state, () => next); + } + const next = mapObjectById(rest, target.parentId, (p) => { + if (!isGroup(p)) return p; + const clamped = Math.max(0, Math.min(target.index, p.children.length)); + return { + ...p, + children: [ + ...p.children.slice(0, clamped), + node, + ...p.children.slice(clamped), + ], + }; + }); + return updateCurrentObjects(state, () => next); + }), + ungroup: () => get().ungroupIds(get().selectedIds), ungroupIds: (ids) => diff --git a/src/types/Group.test.ts b/src/types/Group.test.ts index d129cb24..a5246dc6 100644 --- a/src/types/Group.test.ts +++ b/src/types/Group.test.ts @@ -7,6 +7,8 @@ import { findAncestors, selectionTargetId, expandSelection, + detachObjectById, + isSelfOrDescendant, type GroupObject, } from './Group'; import type { LabelObject } from '../registry'; @@ -117,6 +119,58 @@ describe('Group helpers', () => { }); }); + describe('detachObjectById', () => { + it('removes a top-level leaf', () => { + const tree = [leaf('a'), leaf('b')]; + const { removed, rest } = detachObjectById(tree, 'a'); + expect(removed?.id).toBe('a'); + expect(rest.map((o) => o.id)).toEqual(['b']); + }); + + it('removes a nested leaf and rebuilds the parent group', () => { + const tree = [group('g', [leaf('a'), leaf('b')])]; + const { removed, rest } = detachObjectById(tree, 'a'); + expect(removed?.id).toBe('a'); + const grp = rest[0]; + expect(grp && isGroup(grp) ? grp.children.map((c) => c.id) : null).toEqual(['b']); + }); + + it('removes a whole group node', () => { + const tree = [leaf('a'), group('g', [leaf('x')])]; + const { removed, rest } = detachObjectById(tree, 'g'); + expect(removed?.id).toBe('g'); + expect(rest.map((o) => o.id)).toEqual(['a']); + }); + + it('returns null and the original tree when id is unknown', () => { + const tree = [leaf('a')]; + const { removed, rest } = detachObjectById(tree, 'missing'); + expect(removed).toBeNull(); + expect(rest.map((o) => o.id)).toEqual(['a']); + }); + }); + + describe('isSelfOrDescendant', () => { + it('identifies the node itself', () => { + expect(isSelfOrDescendant([leaf('a')], 'a', 'a')).toBe(true); + }); + + it('identifies a descendant of a group', () => { + const tree = [group('g', [group('inner', [leaf('deep')])])]; + expect(isSelfOrDescendant(tree, 'g', 'deep')).toBe(true); + expect(isSelfOrDescendant(tree, 'g', 'inner')).toBe(true); + }); + + it('returns false for unrelated nodes', () => { + const tree = [group('g1', [leaf('a')]), group('g2', [leaf('b')])]; + expect(isSelfOrDescendant(tree, 'g1', 'b')).toBe(false); + }); + + it('returns false when the root id is missing', () => { + expect(isSelfOrDescendant([leaf('a')], 'missing', 'a')).toBe(false); + }); + }); + describe('expandSelection', () => { it('passes leaf ids through unchanged', () => { expect(expandSelection([leaf('a'), leaf('b')], ['a'])).toEqual(['a']); diff --git a/src/types/Group.ts b/src/types/Group.ts index 58e8cfee..2e417d1a 100644 --- a/src/types/Group.ts +++ b/src/types/Group.ts @@ -107,6 +107,50 @@ export function mapObjectById( }); } +/** + * Returns the tree with `id` removed and the removed node, or `null` + * for the node when nothing matched. Used by reparenting flows that + * need both the detached node and the tree-without-it. + */ +export function detachObjectById( + objects: LabelObject[], + id: string, +): { removed: LabelObject | null; rest: LabelObject[] } { + let removed: LabelObject | null = null; + const visit = (nodes: LabelObject[]): LabelObject[] => { + const out: LabelObject[] = []; + for (const n of nodes) { + if (n.id === id) { + removed = n; + continue; + } + if (isGroup(n)) out.push({ ...n, children: visit(n.children) }); + else out.push(n); + } + return out; + }; + const rest = visit(objects); + return { removed, rest }; +} + +/** + * True when `ancestorId` is `id` itself or sits anywhere in the + * subtree rooted at `id`. Reparenting flows use this to forbid the + * cycle `move(g, into = g_or_descendant_of_g)`. + */ +export function isSelfOrDescendant( + objects: LabelObject[], + id: string, + ancestorId: string, +): boolean { + const node = findObjectById(objects, id); + if (!node) return false; + for (const n of walkObjects([node])) { + if (n.id === ancestorId) return true; + } + return false; +} + /** * Map an intent-level selection (which may include group ids) to the * flat list of Konva-node ids the renderer and transformer can attach From 60e355c091054d4152a396be1b7407a29e3bb0e6 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 14 May 2026 21:12:06 +0200 Subject: [PATCH 11/37] feat(groups): drop-position preview in the layers panel While dragging a layer row, the panel now distinguishes the two drop modes: - An accent insertion line above the over-row signals a sibling drop. The line is indented to the over-row's nesting level so the user can see at which depth the item will land. - A soft outline on the over-row body signals a drop INTO a collapsed group; this stays separate from the insertion line so the two intentions don't visually compete. --- src/components/Properties/LayersPanel.tsx | 43 ++++++++++++++++++----- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index 325fe50c..9cc1751d 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -59,7 +59,11 @@ interface RowProps { containerId: string; isSelected: boolean; isExpanded: boolean; + /** Highlight the row body — used for "drop into this group". */ isDropTarget: boolean; + /** Show an accent line above this row — used for sibling drops so the + * user sees the exact landing slot before releasing. */ + showInsertionLine: boolean; onSelect: () => void; onToggle: () => void; onToggleLock: () => void; @@ -83,6 +87,7 @@ function LayerRow({ isSelected, isExpanded, isDropTarget, + showInsertionLine, onSelect, onToggle, onToggleLock, @@ -108,8 +113,18 @@ function LayerRow({ disabled: isLocked, }); const stopRowClick = (e: React.MouseEvent) => e.stopPropagation(); + // Indent the insertion line so it visually aligns with the indented + // row, signalling that the drop will land at that nesting level. + const linePadLeft = depth > 0 ? depth * 16 + 16 : 8; return ( + <> +
0 ? depth * 16 + 8 : undefined }} @@ -191,6 +206,7 @@ function LayerRow({ {isLocked ? : }
+ ); } @@ -296,15 +312,23 @@ export function LayersPanel() { const handleDragCancel = () => setOverId(null); - // The collapsed group that the active drag is currently over, if any. - // Used to render a "drop into" highlight on that single row. - const dropIntoTargetId = (() => { - if (!overId) return null; - const r = rowsById.get(overId); - if (!r || !isGroup(r.obj)) return null; - if (expandedIds.has(r.obj.id)) return null; - return r.obj.id; - })(); + // While dragging, `overId` is the row the cursor is currently on top + // of. Translate that into one of two visual modes: + // + // dropIntoTargetId – the row's body gets an outline because the + // drop will dive INTO it (collapsed group case). + // insertionLineRowId – the row gets a thin accent line above it + // because the drop will land as a sibling immediately before it + // (in display order). Suppressed when the active is already at + // that exact slot, so the indicator only shows when releasing + // would actually change the model. + const overRow = overId ? rowsById.get(overId) ?? null : null; + const dropIntoTargetId = + overRow && isGroup(overRow.obj) && !expandedIds.has(overRow.obj.id) + ? overRow.obj.id + : null; + const insertionLineRowId = + overRow && !dropIntoTargetId ? overRow.obj.id : null; return ( selectObject(obj.id)} onToggle={() => toggleSelectObject(obj.id)} onToggleLock={() => toggleField(obj.id, 'locked')} From 65c78f15a9f9e4485dd2736f14cb4c1964b93ed2 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 14 May 2026 21:26:23 +0200 Subject: [PATCH 12/37] fix(groups): empty groups accept drops, drop position matches insertion line Two related layers-panel bugs: - A group that lost its last child could not be a drop target again. The drop-into rule fired only on collapsed groups; expand-state is moot when the group has no children to drop between, so the rule now also matches empty groups regardless of their expanded flag. The 'into' outline highlights the same case. - The insertion line and the actual landing slot disagreed when dragging downward. The line was rendered in the gap above the over-row, but the drop used over's data index, which (after the display reversal) lands the active at over's display slot with over shifted up. Switched the sibling formula to insert in the gap ABOVE over in display: insertionIndex = overDataIndex + 1 for cross-container or active-below-over drops, overDataIndex when same-container with active above over (detaching shifts over's effective index by one). --- src/components/Properties/LayersPanel.tsx | 56 ++++++++++++++--------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index 9cc1751d..4bded16c 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -269,12 +269,14 @@ export function LayersPanel() { const overRow = rowsById.get(over.id as string); if (!overRow) return; - // Dropping on a collapsed group: treat as "drop into" so the user - // can move things into a group without expanding it first. Expanded - // groups don't need this — the user can drop directly onto any - // child row inside, which lands the item inside the group via the - // sibling code path below. - if (isGroup(overRow.obj) && !expandedIds.has(overRow.obj.id)) { + // "Drop into group" target: a group that has no expanded children + // to drop between — either collapsed, or expanded but empty. + // Otherwise the user can drop on any child row inside, which lands + // the item inside the group via the sibling code path below. + if ( + isGroup(overRow.obj) && + (!expandedIds.has(overRow.obj.id) || overRow.obj.children.length === 0) + ) { reparentObject(activeId, { parentId: overRow.obj.id, index: overRow.obj.children.length, @@ -282,12 +284,14 @@ export function LayersPanel() { return; } - // Sibling case: place active where over currently sits in its - // container, in data order. The layers panel displays containers - // reversed (topmost row = last in array), so a "drop above over" - // in display = "after over in data order"; using over's own data - // index produces that effect because the existing occupant shifts - // down one position in data order (up one in display). + // Sibling case: active lands in the gap ABOVE over in display order, + // which matches the rendered insertion line. The panel reverses + // each container (topmost row = last in array), so the gap above + // over in display sits right AFTER over in data order. After + // detaching active from a same-container source, over's effective + // data index drops by one when active was previously above over in + // data; the conditional below adjusts for that so the final + // landing slot stays the visual gap the user saw. const targetParent = overRow.containerId === ROOT_CONTAINER ? null : overRow.containerId; @@ -298,16 +302,24 @@ export function LayersPanel() { return g && isGroup(g) ? g.children : null; })(); if (!containerChildren) return; - let dataIndex = containerChildren.findIndex((c) => c.id === overRow.obj.id); - if (dataIndex === -1) return; - // Same-container moves shift indices: if active currently sits - // before over in data order, detaching it drops over's index by 1. + const overDataIndex = containerChildren.findIndex( + (c) => c.id === overRow.obj.id, + ); + if (overDataIndex === -1) return; const activeRow = rowsById.get(activeId); - if (activeRow && activeRow.containerId === overRow.containerId) { - const activeDataIndex = containerChildren.findIndex((c) => c.id === activeId); - if (activeDataIndex >= 0 && activeDataIndex < dataIndex) dataIndex -= 1; + const sameContainer = + activeRow?.containerId === overRow.containerId; + let insertionIndex: number; + if (sameContainer) { + const activeDataIndex = containerChildren.findIndex( + (c) => c.id === activeId, + ); + insertionIndex = + activeDataIndex < overDataIndex ? overDataIndex : overDataIndex + 1; + } else { + insertionIndex = overDataIndex + 1; } - reparentObject(activeId, { parentId: targetParent, index: dataIndex }); + reparentObject(activeId, { parentId: targetParent, index: insertionIndex }); }; const handleDragCancel = () => setOverId(null); @@ -324,7 +336,9 @@ export function LayersPanel() { // would actually change the model. const overRow = overId ? rowsById.get(overId) ?? null : null; const dropIntoTargetId = - overRow && isGroup(overRow.obj) && !expandedIds.has(overRow.obj.id) + overRow && + isGroup(overRow.obj) && + (!expandedIds.has(overRow.obj.id) || overRow.obj.children.length === 0) ? overRow.obj.id : null; const insertionLineRowId = From 6860ab78b50272b9e947bda5c3971b4282ec8bc9 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 14 May 2026 21:35:21 +0200 Subject: [PATCH 13/37] fix(groups): bottom drop zone so the last row can be extracted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bottom-most row in the panel had no row below it to use as a drop target, so a child of a group sitting at the very end of display had no way to leave the group by dragging downward. Added a thin droppable zone after the last row that maps to top-level insertion at data index 0 — the same position the user would reach by dropping on a hypothetical row 'after everything'. The zone highlights softly while a drag hovers it. --- src/components/Properties/LayersPanel.tsx | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index 4bded16c..ee58201d 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -3,6 +3,7 @@ import { DndContext, PointerSensor, closestCenter, + useDroppable, useSensor, useSensors, } from '@dnd-kit/core'; @@ -29,6 +30,12 @@ import { DragHandleIcon } from '../ui/DragHandleIcon'; * use the group's own id, so the root needs a value that can't collide. */ const ROOT_CONTAINER = '__root__'; +/** Droppable id for the implicit drop zone rendered after the last row. + * Lets the user pull a child out of a group by dragging downward past + * the bottom of the panel — without this, the bottom-most element has + * no row below it to use as a drop target. */ +const BOTTOM_DROP_ZONE = '__bottom_drop__'; + interface FlatRow { obj: LabelObject; depth: number; @@ -210,6 +217,21 @@ function LayerRow({ ); } +function BottomDropZone({ active }: { active: boolean }) { + const { setNodeRef } = useDroppable({ id: BOTTOM_DROP_ZONE }); + // 12px is enough to be a comfortable hit target without adding visible + // panel padding. Highlights softly while a drag is hovering it so the + // user sees the extract-to-top-level affordance. + return ( +
+ ); +} + export function LayersPanel() { const t = useT(); const { @@ -266,6 +288,15 @@ export function LayersPanel() { setOverId(null); if (!over || active.id === over.id) return; const activeId = active.id as string; + + // Bottom drop zone: move the active to the very bottom of the + // top-level list (data index 0 in display-reversed terms). Lets a + // user extract a child by dragging downward past the last row. + if (over.id === BOTTOM_DROP_ZONE) { + reparentObject(activeId, { parentId: null, index: 0 }); + return; + } + const overRow = rowsById.get(over.id as string); if (!overRow) return; @@ -380,6 +411,7 @@ export function LayersPanel() { tUngroupLabel={t.layers.ungroup} /> ))} +
From 555a41c542ca64eb382dc94b96daa82a0d3b05ad Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 14 May 2026 21:49:11 +0200 Subject: [PATCH 14/37] feat(groups): horizontal-indent drag, drop the bottom sentinel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bottom drop zone was the wrong shape for this problem — it's a one-off UI element that only addresses the bottom-of-panel case and leaves the broader 'where does this row land' question to the user's guess. Replaced with the indent-drag idiom every tree-aware design tool uses (Figma, Sketch, VSCode): while dragging, the cursor's X position selects how deep in the container chain the drop lands, and the rendered insertion line slides left or right in real time to preview the target depth. Mechanics: capture clientX at drag start, follow it via dnd-kit's onDragMove deltas, quantise (cursorX - INDENT_DEAD_ZONE) / INDENT_STEP into a depth, clamp to the over row's own depth, then climb the container chain by (overDepth - effectiveDepth) levels. The climbed group node becomes the effective over for insertion-position math; the line renders at the climbed depth so the user sees the future landing slot. Same store reparentObject path handles the write. --- src/components/Properties/LayersPanel.tsx | 217 ++++++++++++++-------- 1 file changed, 143 insertions(+), 74 deletions(-) diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index ee58201d..27ece73d 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -1,13 +1,17 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { DndContext, PointerSensor, closestCenter, - useDroppable, useSensor, useSensors, } from '@dnd-kit/core'; -import type { DragEndEvent, DragOverEvent } from '@dnd-kit/core'; +import type { + DragEndEvent, + DragMoveEvent, + DragOverEvent, + DragStartEvent, +} from '@dnd-kit/core'; import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { EyeIcon, @@ -21,7 +25,7 @@ import { import { useLabelStore, useCurrentObjects } from '../../store/labelStore'; import { ObjectRegistry } from '../../registry'; import type { LabelObject } from '../../registry'; -import { isGroup, walkObjects, findObjectById } from '../../types/Group'; +import { isGroup, walkObjects, findObjectById, findAncestors } from '../../types/Group'; import { useT } from '../../lib/useT'; import { buildBulkToggleUpdates, type ToggleField } from '../../lib/bulkToggle'; import { DragHandleIcon } from '../ui/DragHandleIcon'; @@ -30,11 +34,15 @@ import { DragHandleIcon } from '../ui/DragHandleIcon'; * use the group's own id, so the root needs a value that can't collide. */ const ROOT_CONTAINER = '__root__'; -/** Droppable id for the implicit drop zone rendered after the last row. - * Lets the user pull a child out of a group by dragging downward past - * the bottom of the panel — without this, the bottom-most element has - * no row below it to use as a drop target. */ -const BOTTOM_DROP_ZONE = '__bottom_drop__'; +/** Horizontal pixels per nesting level — matches the row's own paddingLeft + * step so the insertion line lines up visually with the target row's + * content column. Changing this means changing the row indent too. */ +const INDENT_STEP = 16; + +/** Pixel bias subtracted from the cursor X before quantising to depth so a + * user has to drag a little before the target depth changes. Tuned to feel + * like Figma's "you mean it" threshold. */ +const INDENT_DEAD_ZONE = 6; interface FlatRow { obj: LabelObject; @@ -71,6 +79,10 @@ interface RowProps { /** Show an accent line above this row — used for sibling drops so the * user sees the exact landing slot before releasing. */ showInsertionLine: boolean; + /** Visual depth at which to render the insertion line. Diverges from + * the row's own depth while the user drags horizontally to climb out + * of a deeply nested container. */ + insertionLineDepth: number | null; onSelect: () => void; onToggle: () => void; onToggleLock: () => void; @@ -95,6 +107,7 @@ function LayerRow({ isExpanded, isDropTarget, showInsertionLine, + insertionLineDepth, onSelect, onToggle, onToggleLock, @@ -120,9 +133,10 @@ function LayerRow({ disabled: isLocked, }); const stopRowClick = (e: React.MouseEvent) => e.stopPropagation(); - // Indent the insertion line so it visually aligns with the indented - // row, signalling that the drop will land at that nesting level. - const linePadLeft = depth > 0 ? depth * 16 + 16 : 8; + // The line indent follows the *target* depth, not the row's own depth, + // so as the user drags left the line slides left in real time. + const lineDepth = insertionLineDepth ?? depth; + const linePadLeft = lineDepth > 0 ? lineDepth * INDENT_STEP + 16 : 8; return ( <> @@ -134,7 +148,7 @@ function LayerRow({ />
0 ? depth * 16 + 8 : undefined }} + style={{ touchAction: 'none', paddingLeft: depth > 0 ? depth * INDENT_STEP + 8 : undefined }} {...attributes} {...(isLocked ? {} : listeners)} onClick={(e) => { @@ -217,21 +231,6 @@ function LayerRow({ ); } -function BottomDropZone({ active }: { active: boolean }) { - const { setNodeRef } = useDroppable({ id: BOTTOM_DROP_ZONE }); - // 12px is enough to be a comfortable hit target without adding visible - // panel padding. Highlights softly while a drag is hovering it so the - // user sees the extract-to-top-level affordance. - return ( -
- ); -} - export function LayersPanel() { const t = useT(); const { @@ -245,6 +244,12 @@ export function LayersPanel() { const objects = useCurrentObjects(); const [overId, setOverId] = useState(null); const [expandedIds, setExpandedIds] = useState>(new Set()); + // Cursor X within the panel during a drag — drives indent-style depth + // selection so dragging left climbs out of a container the same way + // Figma / VSCode tree views handle it. + const [dragCursorX, setDragCursorX] = useState(null); + const dragStartScreenXRef = useRef(null); + const panelRef = useRef(null); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), ); @@ -271,6 +276,57 @@ export function LayersPanel() { }); }; + /** + * Walk up the container chain by N levels. Returns the container at the + * target depth and the group node that lives AT that climbed level — the + * latter is the row whose visual position the insertion will sit above. + */ + const climbContainer = ( + fromContainerId: string, + levels: number, + ): { containerId: string; overObj: LabelObject | null } => { + if (levels <= 0) return { containerId: fromContainerId, overObj: null }; + let current = fromContainerId; + let overObj: LabelObject | null = null; + for (let i = 0; i < levels && current !== ROOT_CONTAINER; i++) { + const groupNode = findObjectById(objects, current); + if (!groupNode) break; + overObj = groupNode; + const ancestors = findAncestors(objects, current); + const parent = ancestors[ancestors.length - 1]; + current = parent ? parent.id : ROOT_CONTAINER; + } + return { containerId: current, overObj }; + }; + + /** + * Resolve the cursor position + over-row into the actual drop target: + * which container to write into, which row the insertion sits above + * (for the visual line), and at which visual depth the line sits. + * Returns null when there's nothing actionable (no over, depth mismatch). + */ + const resolveDropTarget = (overRow: FlatRow): { + targetParent: string | null; + overObj: LabelObject; + effectiveDepth: number; + } => { + const cursorDepth = dragCursorX !== null + ? Math.max(0, Math.floor((dragCursorX - INDENT_DEAD_ZONE) / INDENT_STEP)) + : overRow.depth; + const effectiveDepth = Math.min(cursorDepth, overRow.depth); + const levels = overRow.depth - effectiveDepth; + if (levels === 0) { + const parent = overRow.containerId === ROOT_CONTAINER ? null : overRow.containerId; + return { targetParent: parent, overObj: overRow.obj, effectiveDepth }; + } + const climbed = climbContainer(overRow.containerId, levels); + return { + targetParent: climbed.containerId === ROOT_CONTAINER ? null : climbed.containerId, + overObj: climbed.overObj ?? overRow.obj, + effectiveDepth, + }; + }; + if (objects.length === 0) { return (
@@ -281,29 +337,45 @@ export function LayersPanel() { const allRowIds = rows.map((r) => r.obj.id); + const handleDragStart = (e: DragStartEvent) => { + const native = e.activatorEvent; + if ( + native && + typeof (native as PointerEvent).clientX === 'number' + ) { + dragStartScreenXRef.current = (native as PointerEvent).clientX; + } + }; + + const handleDragMove = (e: DragMoveEvent) => { + const startX = dragStartScreenXRef.current; + const panelEl = panelRef.current; + if (startX === null || !panelEl) return; + const screenX = startX + e.delta.x; + const rect = panelEl.getBoundingClientRect(); + setDragCursorX(screenX - rect.left); + }; + const handleDragOver = ({ over }: DragOverEvent) => setOverId((over?.id as string) ?? null); - const handleDragEnd = ({ active, over }: DragEndEvent) => { + const clearDragState = () => { setOverId(null); + setDragCursorX(null); + dragStartScreenXRef.current = null; + }; + + const handleDragEnd = ({ active, over }: DragEndEvent) => { + clearDragState(); if (!over || active.id === over.id) return; const activeId = active.id as string; - - // Bottom drop zone: move the active to the very bottom of the - // top-level list (data index 0 in display-reversed terms). Lets a - // user extract a child by dragging downward past the last row. - if (over.id === BOTTOM_DROP_ZONE) { - reparentObject(activeId, { parentId: null, index: 0 }); - return; - } - const overRow = rowsById.get(over.id as string); if (!overRow) return; // "Drop into group" target: a group that has no expanded children - // to drop between — either collapsed, or expanded but empty. - // Otherwise the user can drop on any child row inside, which lands - // the item inside the group via the sibling code path below. + // to drop between — either collapsed, or expanded but empty. The + // indent-drag path is skipped here because there's no "land beside" + // semantic on an empty container; the only meaningful drop is INTO. if ( isGroup(overRow.obj) && (!expandedIds.has(overRow.obj.id) || overRow.obj.children.length === 0) @@ -315,17 +387,11 @@ export function LayersPanel() { return; } - // Sibling case: active lands in the gap ABOVE over in display order, - // which matches the rendered insertion line. The panel reverses - // each container (topmost row = last in array), so the gap above - // over in display sits right AFTER over in data order. After - // detaching active from a same-container source, over's effective - // data index drops by one when active was previously above over in - // data; the conditional below adjusts for that so the final - // landing slot stays the visual gap the user saw. - const targetParent = overRow.containerId === ROOT_CONTAINER - ? null - : overRow.containerId; + // Indent-aware sibling drop: the cursor's X position selects how + // deep in the container chain the drop lands. Dragging left climbs + // out of nested groups; the resolveDropTarget call already did the + // ancestor walk and gave us the effective over row. + const { targetParent, overObj } = resolveDropTarget(overRow); const containerChildren = targetParent === null ? objects : (() => { @@ -333,18 +399,20 @@ export function LayersPanel() { return g && isGroup(g) ? g.children : null; })(); if (!containerChildren) return; - const overDataIndex = containerChildren.findIndex( - (c) => c.id === overRow.obj.id, - ); + const overDataIndex = containerChildren.findIndex((c) => c.id === overObj.id); if (overDataIndex === -1) return; + // Drops land in the gap ABOVE overObj in display order (= directly + // after overObj in data order). Same-container moves shift the + // effective index down by one when active was previously above + // over in data — without the shift the row would end up one slot + // off from where the insertion line implied. const activeRow = rowsById.get(activeId); - const sameContainer = - activeRow?.containerId === overRow.containerId; + const activeContainer = activeRow?.containerId ?? null; + const targetContainerId = targetParent ?? ROOT_CONTAINER; + const sameContainer = activeContainer === targetContainerId; let insertionIndex: number; if (sameContainer) { - const activeDataIndex = containerChildren.findIndex( - (c) => c.id === activeId, - ); + const activeDataIndex = containerChildren.findIndex((c) => c.id === activeId); insertionIndex = activeDataIndex < overDataIndex ? overDataIndex : overDataIndex + 1; } else { @@ -353,18 +421,12 @@ export function LayersPanel() { reparentObject(activeId, { parentId: targetParent, index: insertionIndex }); }; - const handleDragCancel = () => setOverId(null); + const handleDragCancel = () => clearDragState(); // While dragging, `overId` is the row the cursor is currently on top - // of. Translate that into one of two visual modes: - // - // dropIntoTargetId – the row's body gets an outline because the - // drop will dive INTO it (collapsed group case). - // insertionLineRowId – the row gets a thin accent line above it - // because the drop will land as a sibling immediately before it - // (in display order). Suppressed when the active is already at - // that exact slot, so the indicator only shows when releasing - // would actually change the model. + // of. resolveDropTarget translates that plus the cursor X into the + // actual drop slot — the row whose gap-above will host the insertion + // line, and the depth at which the line should sit. const overRow = overId ? rowsById.get(overId) ?? null : null; const dropIntoTargetId = overRow && @@ -372,19 +434,24 @@ export function LayersPanel() { (!expandedIds.has(overRow.obj.id) || overRow.obj.children.length === 0) ? overRow.obj.id : null; - const insertionLineRowId = - overRow && !dropIntoTargetId ? overRow.obj.id : null; + const previewSlot = overRow && !dropIntoTargetId + ? resolveDropTarget(overRow) + : null; + const insertionLineRowId = previewSlot?.overObj.id ?? null; + const insertionLineDepth = previewSlot?.effectiveDepth ?? null; return ( -
+
{rows.map(({ obj, depth, containerId }) => ( selectObject(obj.id)} onToggle={() => toggleSelectObject(obj.id)} onToggleLock={() => toggleField(obj.id, 'locked')} @@ -411,7 +481,6 @@ export function LayersPanel() { tUngroupLabel={t.layers.ungroup} /> ))} -
From ec7bf6c9dbb24f373775d50d5fec312533663d7a Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 14 May 2026 22:21:51 +0200 Subject: [PATCH 15/37] fix(groups): pointer-direct cursor tracking for indent drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version derived cursor X from dnd-kit's activatorEvent plus the drag-move delta, which silently fails when activatorEvent has no clientX (synthesised events, some sensor configurations) — dragCursorX never updated, the indent path stayed dormant and the new behaviour felt indistinguishable from the old one. Replaced with a direct document-level pointermove listener that runs only while a drag is active. Cursor X relative to the panel feeds the existing depth-from-X logic unchanged. --- src/components/Properties/LayersPanel.tsx | 48 +++++++++-------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index 27ece73d..c8149bba 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { DndContext, PointerSensor, @@ -6,12 +6,7 @@ import { useSensor, useSensors, } from '@dnd-kit/core'; -import type { - DragEndEvent, - DragMoveEvent, - DragOverEvent, - DragStartEvent, -} from '@dnd-kit/core'; +import type { DragEndEvent, DragOverEvent } from '@dnd-kit/core'; import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { EyeIcon, @@ -246,10 +241,23 @@ export function LayersPanel() { const [expandedIds, setExpandedIds] = useState>(new Set()); // Cursor X within the panel during a drag — drives indent-style depth // selection so dragging left climbs out of a container the same way - // Figma / VSCode tree views handle it. + // Figma / VSCode tree views handle it. Tracked via a document-level + // pointermove listener that runs while a drag is active because + // dnd-kit's activatorEvent / delta path is not always available + // (e.g. activator events are sometimes synthesised without clientX). const [dragCursorX, setDragCursorX] = useState(null); - const dragStartScreenXRef = useRef(null); + const [dragActive, setDragActive] = useState(false); const panelRef = useRef(null); + + useEffect(() => { + if (!dragActive) return; + const onMove = (e: PointerEvent) => { + const rect = panelRef.current?.getBoundingClientRect(); + if (rect) setDragCursorX(e.clientX - rect.left); + }; + document.addEventListener('pointermove', onMove); + return () => document.removeEventListener('pointermove', onMove); + }, [dragActive]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), ); @@ -337,24 +345,7 @@ export function LayersPanel() { const allRowIds = rows.map((r) => r.obj.id); - const handleDragStart = (e: DragStartEvent) => { - const native = e.activatorEvent; - if ( - native && - typeof (native as PointerEvent).clientX === 'number' - ) { - dragStartScreenXRef.current = (native as PointerEvent).clientX; - } - }; - - const handleDragMove = (e: DragMoveEvent) => { - const startX = dragStartScreenXRef.current; - const panelEl = panelRef.current; - if (startX === null || !panelEl) return; - const screenX = startX + e.delta.x; - const rect = panelEl.getBoundingClientRect(); - setDragCursorX(screenX - rect.left); - }; + const handleDragStart = () => setDragActive(true); const handleDragOver = ({ over }: DragOverEvent) => setOverId((over?.id as string) ?? null); @@ -362,7 +353,7 @@ export function LayersPanel() { const clearDragState = () => { setOverId(null); setDragCursorX(null); - dragStartScreenXRef.current = null; + setDragActive(false); }; const handleDragEnd = ({ active, over }: DragEndEvent) => { @@ -445,7 +436,6 @@ export function LayersPanel() { sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} - onDragMove={handleDragMove} onDragOver={handleDragOver} onDragEnd={handleDragEnd} onDragCancel={handleDragCancel} From 4777d7f728530fc8d7ee327f49ea30c213d75f71 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 14 May 2026 22:46:17 +0200 Subject: [PATCH 16/37] fix(groups): indent drag drops onto its own row when depth changes The active-equals-over early return blocked the most common indent-drag motion: grabbing a row and pulling it left without moving up or down so the cursor stays on the row's own gap. The preview line correctly slid to depth 0, but the release was discarded as a no-op and the row stayed where it was. Reworked the guard around resolveDropTarget's effective overObj: when indent climbing produced an ancestor as the effective over, the drop now proceeds even though active.id === over.id at the row level. The pure no-op (same row, no climb) still short-circuits exactly as before. --- src/components/Properties/LayersPanel.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index c8149bba..1e66799f 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -358,7 +358,7 @@ export function LayersPanel() { const handleDragEnd = ({ active, over }: DragEndEvent) => { clearDragState(); - if (!over || active.id === over.id) return; + if (!over) return; const activeId = active.id as string; const overRow = rowsById.get(over.id as string); if (!overRow) return; @@ -383,6 +383,12 @@ export function LayersPanel() { // out of nested groups; the resolveDropTarget call already did the // ancestor walk and gave us the effective over row. const { targetParent, overObj } = resolveDropTarget(overRow); + // True no-op only when the effective over is the active row itself + // — that means cursor didn't climb out of the row's own container, + // so dropping is a self-on-self with no depth change. If indent + // climbing produced an ancestor overObj, the drop still proceeds + // even when active === over at the row level. + if (overObj.id === activeId) return; const containerChildren = targetParent === null ? objects : (() => { From cbf275fa3ff1533451f6dd4c2dcede87469de575 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 14 May 2026 23:43:25 +0200 Subject: [PATCH 17/37] refactor(groups): inline useT() in LayerRow instead of threading 8 t-strings LayerRow was getting eight translation strings (tLock, tUnlock, tShow, tHide, tGroup, tExpand, tCollapse, tUngroupLabel) drilled through its props purely for display purposes. The rest of the codebase calls useT() inside the consuming component for exactly this case. Switched to that pattern: LayerRow's prop surface is now back to data + handlers. --- src/components/Properties/LayersPanel.tsx | 43 ++++++----------------- 1 file changed, 10 insertions(+), 33 deletions(-) diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index 1e66799f..c070f2c9 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -84,14 +84,6 @@ interface RowProps { onToggleVisible: () => void; onToggleExpand: () => void; onUngroup: () => void; - tLock: string; - tUnlock: string; - tShow: string; - tHide: string; - tGroup: string; - tExpand: string; - tCollapse: string; - tUngroupLabel: string; } function LayerRow({ @@ -109,15 +101,8 @@ function LayerRow({ onToggleVisible, onToggleExpand, onUngroup, - tLock, - tUnlock, - tShow, - tHide, - tGroup, - tExpand, - tCollapse, - tUngroupLabel, }: RowProps) { + const t = useT(); const def = ObjectRegistry[obj.type]; const groupRow = isGroup(obj); const isLocked = !!obj.locked; @@ -168,8 +153,8 @@ function LayerRow({ type="button" onPointerDown={stopRowClick} onClick={(e) => { stopRowClick(e); onToggleExpand(); }} - title={isExpanded ? tCollapse : tExpand} - aria-label={isExpanded ? tCollapse : tExpand} + title={isExpanded ? t.app.collapse : t.app.expand} + aria-label={isExpanded ? t.app.collapse : t.app.expand} aria-expanded={isExpanded} className="w-4 h-4 flex items-center justify-center rounded text-muted hover:text-text hover:bg-surface shrink-0" > @@ -185,7 +170,7 @@ function LayerRow({
- {groupRow ? tGroup : (def?.label ?? obj.type)} + {groupRow ? t.types.group : (def?.label ?? obj.type)} {obj.id.slice(0, 8)}
@@ -194,8 +179,8 @@ function LayerRow({ type="button" onPointerDown={stopRowClick} onClick={(e) => { stopRowClick(e); onUngroup(); }} - title={tUngroupLabel} - aria-label={tUngroupLabel} + title={t.layers.ungroup} + aria-label={t.layers.ungroup} className="w-5 h-5 flex items-center justify-center rounded transition-colors text-muted opacity-0 group-hover:opacity-100 hover:text-text hover:bg-surface" > @@ -205,8 +190,8 @@ function LayerRow({ type="button" onPointerDown={stopRowClick} onClick={(e) => { stopRowClick(e); onToggleVisible(); }} - title={isHidden ? tShow : tHide} - aria-label={isHidden ? tShow : tHide} + title={isHidden ? t.layers.show : t.layers.hide} + aria-label={isHidden ? t.layers.show : t.layers.hide} className={`w-5 h-5 flex items-center justify-center rounded transition-colors ${isHidden ? 'text-accent' : 'text-muted opacity-0 group-hover:opacity-100'} hover:text-text hover:bg-surface`} > {isHidden ? : } @@ -215,8 +200,8 @@ function LayerRow({ type="button" onPointerDown={stopRowClick} onClick={(e) => { stopRowClick(e); onToggleLock(); }} - title={isLocked ? tUnlock : tLock} - aria-label={isLocked ? tUnlock : tLock} + title={isLocked ? t.layers.unlock : t.layers.lock} + aria-label={isLocked ? t.layers.unlock : t.layers.lock} className={`w-5 h-5 flex items-center justify-center rounded transition-colors ${isLocked ? 'text-accent' : 'text-muted opacity-0 group-hover:opacity-100'} hover:text-text hover:bg-surface`} > {isLocked ? : } @@ -467,14 +452,6 @@ export function LayersPanel() { onToggleVisible={() => toggleField(obj.id, 'visible')} onToggleExpand={() => toggleExpand(obj.id)} onUngroup={() => ungroupIds([obj.id])} - tLock={t.layers.lock} - tUnlock={t.layers.unlock} - tShow={t.layers.show} - tHide={t.layers.hide} - tGroup={t.types.group} - tExpand={t.app.expand} - tCollapse={t.app.collapse} - tUngroupLabel={t.layers.ungroup} /> ))}
From 165e3285ccaafc05f1fe53bc2e63f5f314ac93f6 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 14 May 2026 23:46:39 +0200 Subject: [PATCH 18/37] refactor(groups): extract useLayerDnd hook from LayersPanel The layers panel was carrying its drag-and-drop machinery inline: cursor tracking, the document-level pointermove listener, climbContainer / resolveDropTarget helpers (rebuilt every render as closures), four DndContext handlers, and the live preview derivation. Pulled all of it into a dedicated hook with a small return surface (sensors, panelRef, four handlers, a preview object). climbContainer and resolveDropTarget moved to module-level pure functions taking objects + cursor as explicit parameters, so they no longer allocate per render. The preview is now memoised off the same resolution path that the on-release commit uses, which keeps the rendered insertion line guaranteed to match where the drop will land. LayersPanel is back to render + bulk-toggle + expand-state responsibilities. --- src/components/Properties/LayersPanel.tsx | 245 ++------------------ src/components/Properties/useLayerDnd.ts | 265 ++++++++++++++++++++++ 2 files changed, 288 insertions(+), 222 deletions(-) create mode 100644 src/components/Properties/useLayerDnd.ts diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index c070f2c9..d6e64d86 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -1,12 +1,5 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import { - DndContext, - PointerSensor, - closestCenter, - useSensor, - useSensors, -} from '@dnd-kit/core'; -import type { DragEndEvent, DragOverEvent } from '@dnd-kit/core'; +import { useMemo, useState } from 'react'; +import { DndContext } from '@dnd-kit/core'; import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { EyeIcon, @@ -20,48 +13,11 @@ import { import { useLabelStore, useCurrentObjects } from '../../store/labelStore'; import { ObjectRegistry } from '../../registry'; import type { LabelObject } from '../../registry'; -import { isGroup, walkObjects, findObjectById, findAncestors } from '../../types/Group'; +import { isGroup, walkObjects } from '../../types/Group'; import { useT } from '../../lib/useT'; import { buildBulkToggleUpdates, type ToggleField } from '../../lib/bulkToggle'; import { DragHandleIcon } from '../ui/DragHandleIcon'; - -/** Sentinel container id for the top-level objects list. Group containers - * use the group's own id, so the root needs a value that can't collide. */ -const ROOT_CONTAINER = '__root__'; - -/** Horizontal pixels per nesting level — matches the row's own paddingLeft - * step so the insertion line lines up visually with the target row's - * content column. Changing this means changing the row indent too. */ -const INDENT_STEP = 16; - -/** Pixel bias subtracted from the cursor X before quantising to depth so a - * user has to drag a little before the target depth changes. Tuned to feel - * like Figma's "you mean it" threshold. */ -const INDENT_DEAD_ZONE = 6; - -interface FlatRow { - obj: LabelObject; - depth: number; - containerId: string; -} - -/** Walk the tree depth-first, reversed at each level so the topmost item - * (last in the array = front-most in render order) appears first in the - * panel. Each row carries its container id so drag-and-drop can decide - * whether a move is a sibling reorder or a cross-container reparent. */ -function buildFlatRows(objects: LabelObject[], expanded: Set): FlatRow[] { - const out: FlatRow[] = []; - const walk = (nodes: LabelObject[], depth: number, containerId: string) => { - for (let i = nodes.length - 1; i >= 0; i--) { - const obj = nodes[i]; - if (!obj) continue; - out.push({ obj, depth, containerId }); - if (isGroup(obj) && expanded.has(obj.id)) walk(obj.children, depth + 1, obj.id); - } - }; - walk(objects, 0, ROOT_CONTAINER); - return out; -} +import { buildFlatRows, useLayerDnd, INDENT_STEP, type FlatRow } from './useLayerDnd'; interface RowProps { obj: LabelObject; @@ -222,30 +178,7 @@ export function LayersPanel() { reparentObject, } = useLabelStore(); const objects = useCurrentObjects(); - const [overId, setOverId] = useState(null); const [expandedIds, setExpandedIds] = useState>(new Set()); - // Cursor X within the panel during a drag — drives indent-style depth - // selection so dragging left climbs out of a container the same way - // Figma / VSCode tree views handle it. Tracked via a document-level - // pointermove listener that runs while a drag is active because - // dnd-kit's activatorEvent / delta path is not always available - // (e.g. activator events are sometimes synthesised without clientX). - const [dragCursorX, setDragCursorX] = useState(null); - const [dragActive, setDragActive] = useState(false); - const panelRef = useRef(null); - - useEffect(() => { - if (!dragActive) return; - const onMove = (e: PointerEvent) => { - const rect = panelRef.current?.getBoundingClientRect(); - if (rect) setDragCursorX(e.clientX - rect.left); - }; - document.addEventListener('pointermove', onMove); - return () => document.removeEventListener('pointermove', onMove); - }, [dragActive]); - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), - ); const allNodes = useMemo(() => [...walkObjects(objects)], [objects]); const rows = useMemo(() => buildFlatRows(objects, expandedIds), [objects, expandedIds]); @@ -255,6 +188,17 @@ export function LayersPanel() { return m; }, [rows]); + const { + sensors, + collisionDetection, + panelRef, + onDragStart, + onDragOver, + onDragEnd, + onDragCancel, + preview, + } = useLayerDnd({ objects, rowsById, expandedIds, reparentObject }); + const toggleField = (clickedId: string, field: ToggleField) => { const updates = buildBulkToggleUpdates(allNodes, selectedIds, clickedId, field); if (updates.length > 0) updateObjects(updates); @@ -269,57 +213,6 @@ export function LayersPanel() { }); }; - /** - * Walk up the container chain by N levels. Returns the container at the - * target depth and the group node that lives AT that climbed level — the - * latter is the row whose visual position the insertion will sit above. - */ - const climbContainer = ( - fromContainerId: string, - levels: number, - ): { containerId: string; overObj: LabelObject | null } => { - if (levels <= 0) return { containerId: fromContainerId, overObj: null }; - let current = fromContainerId; - let overObj: LabelObject | null = null; - for (let i = 0; i < levels && current !== ROOT_CONTAINER; i++) { - const groupNode = findObjectById(objects, current); - if (!groupNode) break; - overObj = groupNode; - const ancestors = findAncestors(objects, current); - const parent = ancestors[ancestors.length - 1]; - current = parent ? parent.id : ROOT_CONTAINER; - } - return { containerId: current, overObj }; - }; - - /** - * Resolve the cursor position + over-row into the actual drop target: - * which container to write into, which row the insertion sits above - * (for the visual line), and at which visual depth the line sits. - * Returns null when there's nothing actionable (no over, depth mismatch). - */ - const resolveDropTarget = (overRow: FlatRow): { - targetParent: string | null; - overObj: LabelObject; - effectiveDepth: number; - } => { - const cursorDepth = dragCursorX !== null - ? Math.max(0, Math.floor((dragCursorX - INDENT_DEAD_ZONE) / INDENT_STEP)) - : overRow.depth; - const effectiveDepth = Math.min(cursorDepth, overRow.depth); - const levels = overRow.depth - effectiveDepth; - if (levels === 0) { - const parent = overRow.containerId === ROOT_CONTAINER ? null : overRow.containerId; - return { targetParent: parent, overObj: overRow.obj, effectiveDepth }; - } - const climbed = climbContainer(overRow.containerId, levels); - return { - targetParent: climbed.containerId === ROOT_CONTAINER ? null : climbed.containerId, - overObj: climbed.overObj ?? overRow.obj, - effectiveDepth, - }; - }; - if (objects.length === 0) { return (
@@ -330,106 +223,14 @@ export function LayersPanel() { const allRowIds = rows.map((r) => r.obj.id); - const handleDragStart = () => setDragActive(true); - - const handleDragOver = ({ over }: DragOverEvent) => - setOverId((over?.id as string) ?? null); - - const clearDragState = () => { - setOverId(null); - setDragCursorX(null); - setDragActive(false); - }; - - const handleDragEnd = ({ active, over }: DragEndEvent) => { - clearDragState(); - if (!over) return; - const activeId = active.id as string; - const overRow = rowsById.get(over.id as string); - if (!overRow) return; - - // "Drop into group" target: a group that has no expanded children - // to drop between — either collapsed, or expanded but empty. The - // indent-drag path is skipped here because there's no "land beside" - // semantic on an empty container; the only meaningful drop is INTO. - if ( - isGroup(overRow.obj) && - (!expandedIds.has(overRow.obj.id) || overRow.obj.children.length === 0) - ) { - reparentObject(activeId, { - parentId: overRow.obj.id, - index: overRow.obj.children.length, - }); - return; - } - - // Indent-aware sibling drop: the cursor's X position selects how - // deep in the container chain the drop lands. Dragging left climbs - // out of nested groups; the resolveDropTarget call already did the - // ancestor walk and gave us the effective over row. - const { targetParent, overObj } = resolveDropTarget(overRow); - // True no-op only when the effective over is the active row itself - // — that means cursor didn't climb out of the row's own container, - // so dropping is a self-on-self with no depth change. If indent - // climbing produced an ancestor overObj, the drop still proceeds - // even when active === over at the row level. - if (overObj.id === activeId) return; - const containerChildren = targetParent === null - ? objects - : (() => { - const g = findObjectById(objects, targetParent); - return g && isGroup(g) ? g.children : null; - })(); - if (!containerChildren) return; - const overDataIndex = containerChildren.findIndex((c) => c.id === overObj.id); - if (overDataIndex === -1) return; - // Drops land in the gap ABOVE overObj in display order (= directly - // after overObj in data order). Same-container moves shift the - // effective index down by one when active was previously above - // over in data — without the shift the row would end up one slot - // off from where the insertion line implied. - const activeRow = rowsById.get(activeId); - const activeContainer = activeRow?.containerId ?? null; - const targetContainerId = targetParent ?? ROOT_CONTAINER; - const sameContainer = activeContainer === targetContainerId; - let insertionIndex: number; - if (sameContainer) { - const activeDataIndex = containerChildren.findIndex((c) => c.id === activeId); - insertionIndex = - activeDataIndex < overDataIndex ? overDataIndex : overDataIndex + 1; - } else { - insertionIndex = overDataIndex + 1; - } - reparentObject(activeId, { parentId: targetParent, index: insertionIndex }); - }; - - const handleDragCancel = () => clearDragState(); - - // While dragging, `overId` is the row the cursor is currently on top - // of. resolveDropTarget translates that plus the cursor X into the - // actual drop slot — the row whose gap-above will host the insertion - // line, and the depth at which the line should sit. - const overRow = overId ? rowsById.get(overId) ?? null : null; - const dropIntoTargetId = - overRow && - isGroup(overRow.obj) && - (!expandedIds.has(overRow.obj.id) || overRow.obj.children.length === 0) - ? overRow.obj.id - : null; - const previewSlot = overRow && !dropIntoTargetId - ? resolveDropTarget(overRow) - : null; - const insertionLineRowId = previewSlot?.overObj.id ?? null; - const insertionLineDepth = previewSlot?.effectiveDepth ?? null; - return (
@@ -441,10 +242,10 @@ export function LayersPanel() { containerId={containerId} isSelected={selectedIds.includes(obj.id)} isExpanded={expandedIds.has(obj.id)} - isDropTarget={dropIntoTargetId === obj.id} - showInsertionLine={insertionLineRowId === obj.id} + isDropTarget={preview.dropIntoTargetId === obj.id} + showInsertionLine={preview.insertionLineRowId === obj.id} insertionLineDepth={ - insertionLineRowId === obj.id ? insertionLineDepth : null + preview.insertionLineRowId === obj.id ? preview.insertionLineDepth : null } onSelect={() => selectObject(obj.id)} onToggle={() => toggleSelectObject(obj.id)} diff --git a/src/components/Properties/useLayerDnd.ts b/src/components/Properties/useLayerDnd.ts new file mode 100644 index 00000000..34a20961 --- /dev/null +++ b/src/components/Properties/useLayerDnd.ts @@ -0,0 +1,265 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core'; +import type { DragEndEvent, DragOverEvent } from '@dnd-kit/core'; +import { isGroup, findObjectById, findAncestors } from '../../types/Group'; +import type { LabelObject } from '../../registry'; + +/** Sentinel container id for the top-level objects list. Group containers + * use the group's own id, so the root needs a value that can't collide. */ +export const ROOT_CONTAINER = '__root__'; + +/** Horizontal pixels per nesting level — matches the row's own paddingLeft + * step so the insertion line lines up visually with the target row's + * content column. Changing this means changing the row indent too. */ +export const INDENT_STEP = 16; + +/** Pixel bias subtracted from the cursor X before quantising to depth so a + * user has to drag a little before the target depth changes. Tuned to feel + * like Figma's "you mean it" threshold. */ +const INDENT_DEAD_ZONE = 6; + +export interface FlatRow { + obj: LabelObject; + depth: number; + containerId: string; +} + +/** Walk the tree depth-first, reversed at each level so the topmost item + * (last in the array = front-most in render order) appears first in the + * panel. Each row carries its container id so drag-and-drop can decide + * whether a move is a sibling reorder or a cross-container reparent. */ +export function buildFlatRows(objects: LabelObject[], expanded: Set): FlatRow[] { + const out: FlatRow[] = []; + const walk = (nodes: LabelObject[], depth: number, containerId: string) => { + for (let i = nodes.length - 1; i >= 0; i--) { + const obj = nodes[i]; + if (!obj) continue; + out.push({ obj, depth, containerId }); + if (isGroup(obj) && expanded.has(obj.id)) walk(obj.children, depth + 1, obj.id); + } + }; + walk(objects, 0, ROOT_CONTAINER); + return out; +} + +/** + * Walk up the container chain by N levels. Returns the container at the + * target depth and the group node that lives AT that climbed level — the + * latter is the row whose visual position the insertion will sit above. + */ +function climbContainer( + objects: LabelObject[], + fromContainerId: string, + levels: number, +): { containerId: string; overObj: LabelObject | null } { + if (levels <= 0) return { containerId: fromContainerId, overObj: null }; + let current = fromContainerId; + let overObj: LabelObject | null = null; + for (let i = 0; i < levels && current !== ROOT_CONTAINER; i++) { + const groupNode = findObjectById(objects, current); + if (!groupNode) break; + overObj = groupNode; + const ancestors = findAncestors(objects, current); + const parent = ancestors[ancestors.length - 1]; + current = parent ? parent.id : ROOT_CONTAINER; + } + return { containerId: current, overObj }; +} + +/** + * Resolve the cursor position + over-row into the actual drop target: + * which container to write into, which row the insertion sits above (for + * the visual line), and at which visual depth the line sits. Pure + * function so the same logic powers both the live preview and the + * on-release write. + */ +function resolveDropTarget( + objects: LabelObject[], + overRow: FlatRow, + dragCursorX: number | null, +): { targetParent: string | null; overObj: LabelObject; effectiveDepth: number } { + const cursorDepth = dragCursorX !== null + ? Math.max(0, Math.floor((dragCursorX - INDENT_DEAD_ZONE) / INDENT_STEP)) + : overRow.depth; + const effectiveDepth = Math.min(cursorDepth, overRow.depth); + const levels = overRow.depth - effectiveDepth; + if (levels === 0) { + const parent = overRow.containerId === ROOT_CONTAINER ? null : overRow.containerId; + return { targetParent: parent, overObj: overRow.obj, effectiveDepth }; + } + const climbed = climbContainer(objects, overRow.containerId, levels); + return { + targetParent: climbed.containerId === ROOT_CONTAINER ? null : climbed.containerId, + overObj: climbed.overObj ?? overRow.obj, + effectiveDepth, + }; +} + +interface DropPreview { + /** Row whose body should be outlined as a "drop into" target. */ + dropIntoTargetId: string | null; + /** Row above which the insertion line appears. */ + insertionLineRowId: string | null; + /** Visual depth at which the insertion line should render. */ + insertionLineDepth: number | null; +} + +interface UseLayerDndArgs { + objects: LabelObject[]; + rowsById: Map; + expandedIds: Set; + reparentObject: (id: string, target: { parentId: string | null; index: number }) => void; +} + +interface UseLayerDndResult { + sensors: ReturnType; + collisionDetection: typeof closestCenter; + panelRef: React.RefObject; + onDragStart: () => void; + onDragOver: (e: DragOverEvent) => void; + onDragEnd: (e: DragEndEvent) => void; + onDragCancel: () => void; + preview: DropPreview; +} + +/** + * Owns every piece of drag/drop state for the layers panel: cursor + * tracking for indent-style depth selection, the over-row id, the live + * preview (drop-into outline vs. insertion line), and the commit on + * release. The panel component receives a ready-to-spread surface and + * a per-frame preview object — nothing about the dnd protocol leaks + * out of here. + */ +export function useLayerDnd({ + objects, + rowsById, + expandedIds, + reparentObject, +}: UseLayerDndArgs): UseLayerDndResult { + const [overId, setOverId] = useState(null); + // Cursor X within the panel during a drag — drives indent-style depth + // selection so dragging left climbs out of a container the same way + // Figma / VSCode tree views handle it. Tracked via a document-level + // pointermove listener that runs while a drag is active because + // dnd-kit's activatorEvent / delta path is not always available. + const [dragCursorX, setDragCursorX] = useState(null); + const [dragActive, setDragActive] = useState(false); + const panelRef = useRef(null); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + ); + + useEffect(() => { + if (!dragActive) return; + const onMove = (e: PointerEvent) => { + const rect = panelRef.current?.getBoundingClientRect(); + if (rect) setDragCursorX(e.clientX - rect.left); + }; + document.addEventListener('pointermove', onMove); + return () => document.removeEventListener('pointermove', onMove); + }, [dragActive]); + + const clearDragState = () => { + setOverId(null); + setDragCursorX(null); + setDragActive(false); + }; + + const onDragStart = () => setDragActive(true); + const onDragOver = ({ over }: DragOverEvent) => + setOverId((over?.id as string) ?? null); + const onDragCancel = () => clearDragState(); + + const onDragEnd = ({ active, over }: DragEndEvent) => { + clearDragState(); + if (!over) return; + const activeId = active.id as string; + const overRow = rowsById.get(over.id as string); + if (!overRow) return; + + // "Drop into group" target: a group that has no expanded children + // to drop between — either collapsed, or expanded but empty. + if ( + isGroup(overRow.obj) && + (!expandedIds.has(overRow.obj.id) || overRow.obj.children.length === 0) + ) { + reparentObject(activeId, { + parentId: overRow.obj.id, + index: overRow.obj.children.length, + }); + return; + } + + const { targetParent, overObj } = resolveDropTarget(objects, overRow, dragCursorX); + // Pure no-op (cursor stayed on its own row, no climb) → exit. If + // indent climbing produced an ancestor as the effective over, the + // drop still proceeds even when active === over at the row level. + if (overObj.id === activeId) return; + + const containerChildren = targetParent === null + ? objects + : (() => { + const g = findObjectById(objects, targetParent); + return g && isGroup(g) ? g.children : null; + })(); + if (!containerChildren) return; + const overDataIndex = containerChildren.findIndex((c) => c.id === overObj.id); + if (overDataIndex === -1) return; + // Drops land in the gap ABOVE overObj in display order (= directly + // after overObj in data order). Same-container moves shift the + // effective index down by one when active was previously above + // over in data. + const activeRow = rowsById.get(activeId); + const activeContainer = activeRow?.containerId ?? null; + const targetContainerId = targetParent ?? ROOT_CONTAINER; + const sameContainer = activeContainer === targetContainerId; + let insertionIndex: number; + if (sameContainer) { + const activeDataIndex = containerChildren.findIndex((c) => c.id === activeId); + insertionIndex = + activeDataIndex < overDataIndex ? overDataIndex : overDataIndex + 1; + } else { + insertionIndex = overDataIndex + 1; + } + reparentObject(activeId, { parentId: targetParent, index: insertionIndex }); + }; + + // Derive preview from the same resolution path used by the commit + // logic above, so the rendered line / outline is guaranteed to match + // where a release would land. + const preview = useMemo(() => { + const overRow = overId ? rowsById.get(overId) ?? null : null; + if (!overRow) { + return { dropIntoTargetId: null, insertionLineRowId: null, insertionLineDepth: null }; + } + if ( + isGroup(overRow.obj) && + (!expandedIds.has(overRow.obj.id) || overRow.obj.children.length === 0) + ) { + return { + dropIntoTargetId: overRow.obj.id, + insertionLineRowId: null, + insertionLineDepth: null, + }; + } + const slot = resolveDropTarget(objects, overRow, dragCursorX); + return { + dropIntoTargetId: null, + insertionLineRowId: slot.overObj.id, + insertionLineDepth: slot.effectiveDepth, + }; + // rowsById is the projection of rows, so depending on it covers + // expand/collapse reshuffles without needing rows as a separate dep. + }, [objects, rowsById, expandedIds, overId, dragCursorX]); + + return { + sensors, + collisionDetection: closestCenter, + panelRef, + onDragStart, + onDragOver, + onDragEnd, + onDragCancel, + preview, + }; +} From 6f9dcef96afc92f942cf5b54619ea6a14004ca41 Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 15 May 2026 00:02:57 +0200 Subject: [PATCH 19/37] refactor(groups): extract shouldDropInto predicate The 'group with no expandable children to land between' rule was duplicated in both the commit path and the live preview derivation. Pulled it into a named helper so the predicate has a single home and the two callers can't drift. --- src/components/Properties/useLayerDnd.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/components/Properties/useLayerDnd.ts b/src/components/Properties/useLayerDnd.ts index 34a20961..b4ecbb98 100644 --- a/src/components/Properties/useLayerDnd.ts +++ b/src/components/Properties/useLayerDnd.ts @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core'; import type { DragEndEvent, DragOverEvent } from '@dnd-kit/core'; import { isGroup, findObjectById, findAncestors } from '../../types/Group'; +import type { GroupObject } from '../../types/Group'; import type { LabelObject } from '../../registry'; /** Sentinel container id for the top-level objects list. Group containers @@ -95,6 +96,14 @@ function resolveDropTarget( }; } +/** A group becomes a "drop into" target when it has no expanded children + * to drop between — either collapsed, or expanded but empty. Used by + * both the live preview and the on-release commit so the two stay in + * lockstep; without this helper a change in one would silently drift. */ +function shouldDropInto(group: GroupObject, expandedIds: Set): boolean { + return !expandedIds.has(group.id) || group.children.length === 0; +} + interface DropPreview { /** Row whose body should be outlined as a "drop into" target. */ dropIntoTargetId: string | null; @@ -177,12 +186,7 @@ export function useLayerDnd({ const overRow = rowsById.get(over.id as string); if (!overRow) return; - // "Drop into group" target: a group that has no expanded children - // to drop between — either collapsed, or expanded but empty. - if ( - isGroup(overRow.obj) && - (!expandedIds.has(overRow.obj.id) || overRow.obj.children.length === 0) - ) { + if (isGroup(overRow.obj) && shouldDropInto(overRow.obj, expandedIds)) { reparentObject(activeId, { parentId: overRow.obj.id, index: overRow.obj.children.length, @@ -232,10 +236,7 @@ export function useLayerDnd({ if (!overRow) { return { dropIntoTargetId: null, insertionLineRowId: null, insertionLineDepth: null }; } - if ( - isGroup(overRow.obj) && - (!expandedIds.has(overRow.obj.id) || overRow.obj.children.length === 0) - ) { + if (isGroup(overRow.obj) && shouldDropInto(overRow.obj, expandedIds)) { return { dropIntoTargetId: overRow.obj.id, insertionLineRowId: null, From 3e1911ca27eb6a721f88bbbf60be5f100eb2d068 Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 15 May 2026 00:05:38 +0200 Subject: [PATCH 20/37] feat(groups): new-group button in the layers panel header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discoverability fix for the groups feature: there was no UI affordance to create a group — only Ctrl+G. Added a small FolderPlus button to a new header row in the layers panel. Smart click: when the current selection has any unlocked top-level item, it groups them (same as Ctrl+G); otherwise it creates an empty group at the top and selects it so the user can drag items into it via the existing indent drag. Store gains addGroup() for the empty-group case, alongside the existing groupSelection / ungroup / ungroupIds. The header stays visible on an empty design so the affordance is reachable up front. --- src/components/Properties/LayersPanel.tsx | 37 +++++++++++++++++++++-- src/locales/ar.ts | 1 + src/locales/bg.ts | 1 + src/locales/cs.ts | 1 + src/locales/da.ts | 1 + src/locales/de.ts | 1 + src/locales/el.ts | 1 + src/locales/en.ts | 1 + src/locales/es.ts | 1 + src/locales/et.ts | 1 + src/locales/fa.ts | 1 + src/locales/fi.ts | 1 + src/locales/fr.ts | 1 + src/locales/he.ts | 1 + src/locales/hr.ts | 1 + src/locales/hu.ts | 1 + src/locales/it.ts | 1 + src/locales/ja.ts | 1 + src/locales/ko.ts | 1 + src/locales/lt.ts | 1 + src/locales/lv.ts | 1 + src/locales/nl.ts | 1 + src/locales/no.ts | 1 + src/locales/pl.ts | 1 + src/locales/pt.ts | 1 + src/locales/ro.ts | 1 + src/locales/sk.ts | 1 + src/locales/sl.ts | 1 + src/locales/sr.ts | 1 + src/locales/sv.ts | 1 + src/locales/tr.ts | 1 + src/locales/zh-hans.ts | 1 + src/locales/zh-hant.ts | 1 + src/store/labelStore.test.ts | 19 ++++++++++++ src/store/labelStore.ts | 21 +++++++++++++ 35 files changed, 107 insertions(+), 2 deletions(-) diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index d6e64d86..1ae30b16 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -9,6 +9,7 @@ import { ChevronRightIcon, ChevronDownIcon, LinkSlashIcon, + FolderPlusIcon, } from '@heroicons/react/16/solid'; import { useLabelStore, useCurrentObjects } from '../../store/labelStore'; import { ObjectRegistry } from '../../registry'; @@ -174,6 +175,8 @@ export function LayersPanel() { selectObject, toggleSelectObject, updateObjects, + groupSelection, + addGroup, ungroupIds, reparentObject, } = useLabelStore(); @@ -204,6 +207,18 @@ export function LayersPanel() { if (updates.length > 0) updateObjects(updates); }; + // Smart "New group" button: prefer grouping the current top-level + // selection (matches the Ctrl+G shortcut), fall back to creating an + // empty group at the top so the affordance is also useful before + // any items exist or have been selected. + const hasTopLevelGroupable = selectedIds.some((id) => + objects.some((o) => o.id === id && !o.locked), + ); + const onNewGroup = () => { + if (hasTopLevelGroupable) groupSelection(); + else addGroup(); + }; + const toggleExpand = (id: string) => { setExpandedIds((prev) => { const next = new Set(prev); @@ -213,10 +228,27 @@ export function LayersPanel() { }); }; + const header = ( +
+ +
+ ); + if (objects.length === 0) { return ( -
- {t.layers.empty} +
+ {header} +
+ {t.layers.empty} +
); } @@ -232,6 +264,7 @@ export function LayersPanel() { onDragEnd={onDragEnd} onDragCancel={onDragCancel} > + {header}
{rows.map(({ obj, depth, containerId }) => ( diff --git a/src/locales/ar.ts b/src/locales/ar.ts index 8cf411ac..2135ad19 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -407,6 +407,7 @@ const ar = { lock: 'قفل', unlock: 'إلغاء القفل', ungroup: 'فك المجموعة', + newGroup: 'مجموعة جديدة', show: 'إظهار', hide: 'إخفاء', }, diff --git a/src/locales/bg.ts b/src/locales/bg.ts index 7abfa29f..50fd5d3b 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -407,6 +407,7 @@ const bg = { lock: 'Заключване', unlock: 'Отключване', ungroup: 'Разгрупиране', + newGroup: 'Нова група', show: 'Покажи', hide: 'Скрий', }, diff --git a/src/locales/cs.ts b/src/locales/cs.ts index 551dbe1e..cf37e867 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -407,6 +407,7 @@ const cs = { lock: 'Uzamknout', unlock: 'Odemknout', ungroup: 'Zrušit skupinu', + newGroup: 'Nová skupina', show: 'Zobrazit', hide: 'Skrýt', }, diff --git a/src/locales/da.ts b/src/locales/da.ts index 545c1c7e..7984ee23 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -407,6 +407,7 @@ const da = { lock: 'Lås', unlock: 'Lås op', ungroup: 'Ophæv gruppering', + newGroup: 'Ny gruppe', show: 'Vis', hide: 'Skjul', }, diff --git a/src/locales/de.ts b/src/locales/de.ts index f39bcf3c..674291b0 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -428,6 +428,7 @@ const de = { lock: 'Sperren', unlock: 'Entsperren', ungroup: 'Gruppierung aufheben', + newGroup: 'Neue Gruppe', show: 'Einblenden', hide: 'Ausblenden', }, diff --git a/src/locales/el.ts b/src/locales/el.ts index 4078978e..934dd05c 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -407,6 +407,7 @@ const el = { lock: 'Κλείδωμα', unlock: 'Ξεκλείδωμα', ungroup: 'Κατάργηση ομαδοποίησης', + newGroup: 'Νέα ομάδα', show: 'Εμφάνιση', hide: 'Απόκρυψη', }, diff --git a/src/locales/en.ts b/src/locales/en.ts index 51bdb52e..80843127 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -428,6 +428,7 @@ const en = { lock: 'Lock', unlock: 'Unlock', ungroup: 'Ungroup', + newGroup: 'New group', show: 'Show', hide: 'Hide', }, diff --git a/src/locales/es.ts b/src/locales/es.ts index e0d4ab30..8fc3956c 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -407,6 +407,7 @@ const es = { lock: 'Bloquear', unlock: 'Desbloquear', ungroup: 'Desagrupar', + newGroup: 'Nuevo grupo', show: 'Mostrar', hide: 'Ocultar', }, diff --git a/src/locales/et.ts b/src/locales/et.ts index 68efaab0..3d137e65 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -407,6 +407,7 @@ const et = { lock: 'Lukusta', unlock: 'Eemalda lukk', ungroup: 'Tühista rühm', + newGroup: 'Uus rühm', show: 'Näita', hide: 'Peida', }, diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 1d2f86fe..12cc9701 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -407,6 +407,7 @@ const fa = { lock: 'قفل', unlock: 'بازکردن قفل', ungroup: 'لغو گروه‌بندی', + newGroup: 'گروه جدید', show: 'نمایش', hide: 'پنهان', }, diff --git a/src/locales/fi.ts b/src/locales/fi.ts index 1910fd67..42b7ea9d 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -407,6 +407,7 @@ const fi = { lock: 'Lukitse', unlock: 'Avaa lukitus', ungroup: 'Poista ryhmittely', + newGroup: 'Uusi ryhmä', show: 'Näytä', hide: 'Piilota', }, diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 550b2930..6ab54919 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -407,6 +407,7 @@ const fr = { lock: 'Verrouiller', unlock: 'Déverrouiller', ungroup: 'Dégrouper', + newGroup: 'Nouveau groupe', show: 'Afficher', hide: 'Masquer', }, diff --git a/src/locales/he.ts b/src/locales/he.ts index 9fe401fe..3bfd4370 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -407,6 +407,7 @@ const he = { lock: 'נעל', unlock: 'בטל נעילה', ungroup: 'ביטול קיבוץ', + newGroup: 'קבוצה חדשה', show: 'הצג', hide: 'הסתר', }, diff --git a/src/locales/hr.ts b/src/locales/hr.ts index 0a00c4fd..6390f73e 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -407,6 +407,7 @@ const hr = { lock: 'Zaključaj', unlock: 'Otključaj', ungroup: 'Razgrupiraj', + newGroup: 'Nova grupa', show: 'Prikaži', hide: 'Sakrij', }, diff --git a/src/locales/hu.ts b/src/locales/hu.ts index 333d73eb..9706fe88 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -407,6 +407,7 @@ const hu = { lock: 'Zárolás', unlock: 'Feloldás', ungroup: 'Csoport bontása', + newGroup: 'Új csoport', show: 'Megjelenítés', hide: 'Elrejtés', }, diff --git a/src/locales/it.ts b/src/locales/it.ts index 99e61a91..7656c5f3 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -407,6 +407,7 @@ const it = { lock: 'Blocca', unlock: 'Sblocca', ungroup: 'Separa', + newGroup: 'Nuovo gruppo', show: 'Mostra', hide: 'Nascondi', }, diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 5fe9f999..07b0b41c 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -407,6 +407,7 @@ const ja = { lock: 'ロック', unlock: 'ロック解除', ungroup: 'グループ解除', + newGroup: '新規グループ', show: '表示', hide: '非表示', }, diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 80d4c7cc..37d20478 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -407,6 +407,7 @@ const ko = { lock: '잠금', unlock: '잠금 해제', ungroup: '그룹 해제', + newGroup: '새 그룹', show: '표시', hide: '숨기기', }, diff --git a/src/locales/lt.ts b/src/locales/lt.ts index 51a4ab2c..e359b43d 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -407,6 +407,7 @@ const lt = { lock: 'Užrakinti', unlock: 'Atrakinti', ungroup: 'Išgrupuoti', + newGroup: 'Nauja grupė', show: 'Rodyti', hide: 'Slėpti', }, diff --git a/src/locales/lv.ts b/src/locales/lv.ts index fe0318c3..55bdcd3f 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -407,6 +407,7 @@ const lv = { lock: 'Bloķēt', unlock: 'Atbloķēt', ungroup: 'Atgrupēt', + newGroup: 'Jauna grupa', show: 'Rādīt', hide: 'Slēpt', }, diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 09740dad..3a57b4c4 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -407,6 +407,7 @@ const nl = { lock: 'Vergrendelen', unlock: 'Ontgrendelen', ungroup: 'Groepering opheffen', + newGroup: 'Nieuwe groep', show: 'Tonen', hide: 'Verbergen', }, diff --git a/src/locales/no.ts b/src/locales/no.ts index efa4f1ca..95885fbe 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -407,6 +407,7 @@ const no = { lock: 'Lås', unlock: 'Lås opp', ungroup: 'Del opp', + newGroup: 'Ny gruppe', show: 'Vis', hide: 'Skjul', }, diff --git a/src/locales/pl.ts b/src/locales/pl.ts index 624a7513..ce6dd000 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -407,6 +407,7 @@ const pl = { lock: 'Zablokuj', unlock: 'Odblokuj', ungroup: 'Rozgrupuj', + newGroup: 'Nowa grupa', show: 'Pokaż', hide: 'Ukryj', }, diff --git a/src/locales/pt.ts b/src/locales/pt.ts index 4815376b..c94c1f60 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -407,6 +407,7 @@ const pt = { lock: 'Bloquear', unlock: 'Desbloquear', ungroup: 'Desagrupar', + newGroup: 'Novo grupo', show: 'Mostrar', hide: 'Ocultar', }, diff --git a/src/locales/ro.ts b/src/locales/ro.ts index 0e90dd3d..f3bc0df2 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -407,6 +407,7 @@ const ro = { lock: 'Blochează', unlock: 'Deblochează', ungroup: 'Anulează grupare', + newGroup: 'Grup nou', show: 'Afișează', hide: 'Ascunde', }, diff --git a/src/locales/sk.ts b/src/locales/sk.ts index 8d5bf270..984ae19f 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -407,6 +407,7 @@ const sk = { lock: 'Uzamknúť', unlock: 'Odomknúť', ungroup: 'Zrušiť skupinu', + newGroup: 'Nová skupina', show: 'Zobraziť', hide: 'Skryť', }, diff --git a/src/locales/sl.ts b/src/locales/sl.ts index 6f7e1f1d..a969e010 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -407,6 +407,7 @@ const sl = { lock: 'Zakleni', unlock: 'Odkleni', ungroup: 'Razdruži', + newGroup: 'Nova skupina', show: 'Prikaži', hide: 'Skrij', }, diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 1d825c7e..c3927fd6 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -407,6 +407,7 @@ const sr = { lock: 'Закључај', unlock: 'Откључај', ungroup: 'Razgrupiši', + newGroup: 'Nova grupa', show: 'Прикажи', hide: 'Сакриј', }, diff --git a/src/locales/sv.ts b/src/locales/sv.ts index 48485e28..afe12843 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -407,6 +407,7 @@ const sv = { lock: 'Lås', unlock: 'Lås upp', ungroup: 'Dela upp', + newGroup: 'Ny grupp', show: 'Visa', hide: 'Dölj', }, diff --git a/src/locales/tr.ts b/src/locales/tr.ts index 24500bd1..28caad42 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -407,6 +407,7 @@ const tr = { lock: 'Kilitle', unlock: 'Kilidi aç', ungroup: 'Grubu çöz', + newGroup: 'Yeni grup', show: 'Göster', hide: 'Gizle', }, diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 46547891..94cf51e1 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -407,6 +407,7 @@ const zhHans = { lock: '锁定', unlock: '解锁', ungroup: '取消组合', + newGroup: '新建组', show: '显示', hide: '隐藏', }, diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index 6e05da76..9559095a 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -407,6 +407,7 @@ const zhHant = { lock: '鎖定', unlock: '解鎖', ungroup: '取消群組', + newGroup: '新增群組', show: '顯示', hide: '隱藏', }, diff --git a/src/store/labelStore.test.ts b/src/store/labelStore.test.ts index d4050ed0..9080e5e4 100644 --- a/src/store/labelStore.test.ts +++ b/src/store/labelStore.test.ts @@ -781,6 +781,25 @@ describe('ungroup', () => { expect(JSON.stringify(objs())).toBe(before); }); + it('addGroup appends an empty group and selects it', () => { + state().addGroup(); + expect(objs()).toHaveLength(1); + const g = defined(objs()[0]); + expect(isGroup(g)).toBe(true); + if (isGroup(g)) expect(g.children).toEqual([]); + expect(state().selectedIds).toEqual([g.id]); + }); + + it('addGroup leaves existing top-level objects in place', () => { + state().addObject('text'); + const textId = defined(objs()[0]).id; + state().addGroup(); + expect(objs()).toHaveLength(2); + // Group is appended at the end of the array = topmost in display. + expect(defined(objs()[0]).id).toBe(textId); + expect(isGroup(defined(objs()[1]))).toBe(true); + }); + it('ungroupIds operates on the passed list, not the current selection', () => { state().addObject('text'); state().addObject('box'); diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index 4e8f3faf..f24b1618 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -125,6 +125,11 @@ interface LabelState { * insertion position inside the target's children list. Silently * refuses cycles (moving a group into its own descendant). */ reparentObject: (id: string, target: { parentId: string | null; index: number }) => void; + /** Append an empty group at the top level (end of the objects array = + * front-most layer = topmost row in the layers panel) and select it. + * Lets the user create a group up-front and drag items in afterwards + * via the layers panel, instead of having to select-then-shortcut. */ + addGroup: () => void; setLabelConfig: (config: Partial) => void; setLocale: (locale: LocaleCode) => void; setTheme: (theme: ThemePreference) => void; @@ -462,6 +467,22 @@ export const useLabelStore = create()( return updateCurrentObjects(state, () => next); }), + addGroup: () => + set((state) => { + const group: GroupObject = { + id: crypto.randomUUID(), + type: 'group', + x: 0, + y: 0, + rotation: 0, + children: [], + }; + return { + ...updateCurrentObjects(state, (objs) => [...objs, group]), + selectedIds: [group.id], + }; + }), + ungroup: () => get().ungroupIds(get().selectedIds), ungroupIds: (ids) => From ccfa3f2831202440ea69aa5ac03cd8c259fa8b7c Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 15 May 2026 00:09:34 +0200 Subject: [PATCH 21/37] feat(groups): inline-rename groups via double-click in the layers panel LabelObjectBase gains an optional name field; for now only groups read it so the panel can show 'Header' instead of the generic 'Group' label. Other consumers see the field via the base schema without behaviour change, leaving leaf renaming as a future UI-only addition. Layers panel: double-clicking a group row replaces the label with an inline input. Enter commits, Escape cancels, blur commits. The input intercepts pointerdown so the sortable's drag activation doesn't kick in mid-rename. Empty input clears the name back to the default fallback. designFile loader relaxes the schema: props is now optional and children is accepted so designs containing groups survive a save/load round trip. The runtime registry was already permissive to unknown prop shapes, so no per-type schema work was needed. --- src/components/Properties/LayersPanel.tsx | 54 +++++++++++++++++++++-- src/lib/designFile.ts | 7 ++- src/locales/ar.ts | 1 + src/locales/bg.ts | 1 + src/locales/cs.ts | 1 + src/locales/da.ts | 1 + src/locales/de.ts | 1 + src/locales/el.ts | 1 + src/locales/en.ts | 1 + src/locales/es.ts | 1 + src/locales/et.ts | 1 + src/locales/fa.ts | 1 + src/locales/fi.ts | 1 + src/locales/fr.ts | 1 + src/locales/he.ts | 1 + src/locales/hr.ts | 1 + src/locales/hu.ts | 1 + src/locales/it.ts | 1 + src/locales/ja.ts | 1 + src/locales/ko.ts | 1 + src/locales/lt.ts | 1 + src/locales/lv.ts | 1 + src/locales/nl.ts | 1 + src/locales/no.ts | 1 + src/locales/pl.ts | 1 + src/locales/pt.ts | 1 + src/locales/ro.ts | 1 + src/locales/sk.ts | 1 + src/locales/sl.ts | 1 + src/locales/sr.ts | 1 + src/locales/sv.ts | 1 + src/locales/tr.ts | 1 + src/locales/zh-hans.ts | 1 + src/locales/zh-hant.ts | 1 + src/store/labelStore.ts | 2 +- src/types/ObjectType.ts | 5 +++ 36 files changed, 94 insertions(+), 6 deletions(-) diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index 1ae30b16..6fd0ae84 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { DndContext } from '@dnd-kit/core'; import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { @@ -41,6 +41,8 @@ interface RowProps { onToggleVisible: () => void; onToggleExpand: () => void; onUngroup: () => void; + /** Commit the new name; empty string clears it back to the default. */ + onRename: (name: string | undefined) => void; } function LayerRow({ @@ -58,10 +60,31 @@ function LayerRow({ onToggleVisible, onToggleExpand, onUngroup, + onRename, }: RowProps) { const t = useT(); const def = ObjectRegistry[obj.type]; const groupRow = isGroup(obj); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + if (editing) inputRef.current?.select(); + }, [editing]); + + const beginEdit = () => { + setDraft(obj.name ?? ''); + setEditing(true); + }; + const commitEdit = () => { + const trimmed = draft.trim(); + if ((obj.name ?? '') !== trimmed) onRename(trimmed || undefined); + setEditing(false); + }; + const cancelEdit = () => setEditing(false); + const defaultLabel = groupRow ? t.types.group : (def?.label ?? obj.type); + const displayName = obj.name ?? defaultLabel; const isLocked = !!obj.locked; const isHidden = obj.visible === false; const { attributes, listeners, setNodeRef, isDragging } = useSortable({ @@ -126,9 +149,30 @@ function LayerRow({ {groupRow ? '⊞' : def?.icon}
- - {groupRow ? t.types.group : (def?.label ?? obj.type)} - + {editing && groupRow ? ( + setDraft(e.target.value)} + onBlur={commitEdit} + onKeyDown={(e) => { + if (e.key === 'Enter') commitEdit(); + else if (e.key === 'Escape') cancelEdit(); + }} + onClick={stopRowClick} + onPointerDown={stopRowClick} + placeholder={defaultLabel} + className="text-xs text-text bg-surface-2 border border-border rounded px-1 py-0 -my-0.5 focus:border-accent focus:outline-none w-full" + /> + ) : ( + { e.stopPropagation(); beginEdit(); } : undefined} + title={groupRow ? t.layers.rename : undefined} + > + {displayName} + + )} {obj.id.slice(0, 8)}
{groupRow && ( @@ -174,6 +218,7 @@ export function LayersPanel() { selectedIds, selectObject, toggleSelectObject, + updateObject, updateObjects, groupSelection, addGroup, @@ -286,6 +331,7 @@ export function LayersPanel() { onToggleVisible={() => toggleField(obj.id, 'visible')} onToggleExpand={() => toggleExpand(obj.id)} onUngroup={() => ungroupIds([obj.id])} + onRename={(name) => updateObject(obj.id, { name })} /> ))}
diff --git a/src/lib/designFile.ts b/src/lib/designFile.ts index 6272e204..3f0d9d5d 100644 --- a/src/lib/designFile.ts +++ b/src/lib/designFile.ts @@ -7,8 +7,13 @@ export type DesignFileError = "parse_error" | "invalid_schema"; export interface DesignFilePage { objects: LabelObject[] } export interface DesignFile { label: LabelConfig; pages: DesignFilePage[] } +// Leaves carry `props`; groups carry `children` instead and skip `props`. +// Both shapes share the base fields. Per-field children validation would +// need a recursive schema; for now we accept any array (the registry-aware +// runtime ignores unknown shapes regardless). const labelObjectSchema = labelObjectBaseSchema.extend({ - props: z.record(z.string(), z.unknown()), + props: z.record(z.string(), z.unknown()).optional(), + children: z.array(z.unknown()).optional(), }); const pageSchema = z.object({ objects: z.array(labelObjectSchema) }); diff --git a/src/locales/ar.ts b/src/locales/ar.ts index 2135ad19..df4b32ac 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -408,6 +408,7 @@ const ar = { unlock: 'إلغاء القفل', ungroup: 'فك المجموعة', newGroup: 'مجموعة جديدة', + rename: 'انقر نقرًا مزدوجًا لإعادة التسمية', show: 'إظهار', hide: 'إخفاء', }, diff --git a/src/locales/bg.ts b/src/locales/bg.ts index 50fd5d3b..49bb1bc1 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -408,6 +408,7 @@ const bg = { unlock: 'Отключване', ungroup: 'Разгрупиране', newGroup: 'Нова група', + rename: 'Двукратно щракване за преименуване', show: 'Покажи', hide: 'Скрий', }, diff --git a/src/locales/cs.ts b/src/locales/cs.ts index cf37e867..a3b00842 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -408,6 +408,7 @@ const cs = { unlock: 'Odemknout', ungroup: 'Zrušit skupinu', newGroup: 'Nová skupina', + rename: 'Dvojklikem přejmenovat', show: 'Zobrazit', hide: 'Skrýt', }, diff --git a/src/locales/da.ts b/src/locales/da.ts index 7984ee23..d118d2f0 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -408,6 +408,7 @@ const da = { unlock: 'Lås op', ungroup: 'Ophæv gruppering', newGroup: 'Ny gruppe', + rename: 'Dobbeltklik for at omdøbe', show: 'Vis', hide: 'Skjul', }, diff --git a/src/locales/de.ts b/src/locales/de.ts index 674291b0..8eec5360 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -429,6 +429,7 @@ const de = { unlock: 'Entsperren', ungroup: 'Gruppierung aufheben', newGroup: 'Neue Gruppe', + rename: 'Doppelklick zum Umbenennen', show: 'Einblenden', hide: 'Ausblenden', }, diff --git a/src/locales/el.ts b/src/locales/el.ts index 934dd05c..99787c1d 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -408,6 +408,7 @@ const el = { unlock: 'Ξεκλείδωμα', ungroup: 'Κατάργηση ομαδοποίησης', newGroup: 'Νέα ομάδα', + rename: 'Διπλό κλικ για μετονομασία', show: 'Εμφάνιση', hide: 'Απόκρυψη', }, diff --git a/src/locales/en.ts b/src/locales/en.ts index 80843127..149ae548 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -429,6 +429,7 @@ const en = { unlock: 'Unlock', ungroup: 'Ungroup', newGroup: 'New group', + rename: 'Double-click to rename', show: 'Show', hide: 'Hide', }, diff --git a/src/locales/es.ts b/src/locales/es.ts index 8fc3956c..d57d3e5f 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -408,6 +408,7 @@ const es = { unlock: 'Desbloquear', ungroup: 'Desagrupar', newGroup: 'Nuevo grupo', + rename: 'Doble clic para renombrar', show: 'Mostrar', hide: 'Ocultar', }, diff --git a/src/locales/et.ts b/src/locales/et.ts index 3d137e65..ab33acaa 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -408,6 +408,7 @@ const et = { unlock: 'Eemalda lukk', ungroup: 'Tühista rühm', newGroup: 'Uus rühm', + rename: 'Topeltklõpsake ümbernimetamiseks', show: 'Näita', hide: 'Peida', }, diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 12cc9701..522fa9cf 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -408,6 +408,7 @@ const fa = { unlock: 'بازکردن قفل', ungroup: 'لغو گروه‌بندی', newGroup: 'گروه جدید', + rename: 'برای تغییر نام دو بار کلیک کنید', show: 'نمایش', hide: 'پنهان', }, diff --git a/src/locales/fi.ts b/src/locales/fi.ts index 42b7ea9d..f5dd5b71 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -408,6 +408,7 @@ const fi = { unlock: 'Avaa lukitus', ungroup: 'Poista ryhmittely', newGroup: 'Uusi ryhmä', + rename: 'Nimeä uudelleen kaksoisnapsauttamalla', show: 'Näytä', hide: 'Piilota', }, diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 6ab54919..bb4d3c30 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -408,6 +408,7 @@ const fr = { unlock: 'Déverrouiller', ungroup: 'Dégrouper', newGroup: 'Nouveau groupe', + rename: 'Double-cliquer pour renommer', show: 'Afficher', hide: 'Masquer', }, diff --git a/src/locales/he.ts b/src/locales/he.ts index 3bfd4370..f98512f6 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -408,6 +408,7 @@ const he = { unlock: 'בטל נעילה', ungroup: 'ביטול קיבוץ', newGroup: 'קבוצה חדשה', + rename: 'לחיצה כפולה לשינוי שם', show: 'הצג', hide: 'הסתר', }, diff --git a/src/locales/hr.ts b/src/locales/hr.ts index 6390f73e..8ad38eb7 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -408,6 +408,7 @@ const hr = { unlock: 'Otključaj', ungroup: 'Razgrupiraj', newGroup: 'Nova grupa', + rename: 'Dvoklik za preimenovanje', show: 'Prikaži', hide: 'Sakrij', }, diff --git a/src/locales/hu.ts b/src/locales/hu.ts index 9706fe88..71a8d061 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -408,6 +408,7 @@ const hu = { unlock: 'Feloldás', ungroup: 'Csoport bontása', newGroup: 'Új csoport', + rename: 'Átnevezéshez kattintson duplán', show: 'Megjelenítés', hide: 'Elrejtés', }, diff --git a/src/locales/it.ts b/src/locales/it.ts index 7656c5f3..797ddb55 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -408,6 +408,7 @@ const it = { unlock: 'Sblocca', ungroup: 'Separa', newGroup: 'Nuovo gruppo', + rename: 'Doppio clic per rinominare', show: 'Mostra', hide: 'Nascondi', }, diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 07b0b41c..33513cdd 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -408,6 +408,7 @@ const ja = { unlock: 'ロック解除', ungroup: 'グループ解除', newGroup: '新規グループ', + rename: 'ダブルクリックで名前変更', show: '表示', hide: '非表示', }, diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 37d20478..d79879e7 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -408,6 +408,7 @@ const ko = { unlock: '잠금 해제', ungroup: '그룹 해제', newGroup: '새 그룹', + rename: '이름 바꾸려면 두 번 클릭', show: '표시', hide: '숨기기', }, diff --git a/src/locales/lt.ts b/src/locales/lt.ts index e359b43d..0a3b2931 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -408,6 +408,7 @@ const lt = { unlock: 'Atrakinti', ungroup: 'Išgrupuoti', newGroup: 'Nauja grupė', + rename: 'Dukart spustelėkite, kad pervadintumėte', show: 'Rodyti', hide: 'Slėpti', }, diff --git a/src/locales/lv.ts b/src/locales/lv.ts index 55bdcd3f..348f154b 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -408,6 +408,7 @@ const lv = { unlock: 'Atbloķēt', ungroup: 'Atgrupēt', newGroup: 'Jauna grupa', + rename: 'Dubultklikšķiniet, lai pārdēvētu', show: 'Rādīt', hide: 'Slēpt', }, diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 3a57b4c4..217ee5ec 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -408,6 +408,7 @@ const nl = { unlock: 'Ontgrendelen', ungroup: 'Groepering opheffen', newGroup: 'Nieuwe groep', + rename: 'Dubbelklik om te hernoemen', show: 'Tonen', hide: 'Verbergen', }, diff --git a/src/locales/no.ts b/src/locales/no.ts index 95885fbe..52dcf582 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -408,6 +408,7 @@ const no = { unlock: 'Lås opp', ungroup: 'Del opp', newGroup: 'Ny gruppe', + rename: 'Dobbeltklikk for å endre navn', show: 'Vis', hide: 'Skjul', }, diff --git a/src/locales/pl.ts b/src/locales/pl.ts index ce6dd000..3fce5fdd 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -408,6 +408,7 @@ const pl = { unlock: 'Odblokuj', ungroup: 'Rozgrupuj', newGroup: 'Nowa grupa', + rename: 'Kliknij dwukrotnie, aby zmienić nazwę', show: 'Pokaż', hide: 'Ukryj', }, diff --git a/src/locales/pt.ts b/src/locales/pt.ts index c94c1f60..66d32695 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -408,6 +408,7 @@ const pt = { unlock: 'Desbloquear', ungroup: 'Desagrupar', newGroup: 'Novo grupo', + rename: 'Duplo clique para renomear', show: 'Mostrar', hide: 'Ocultar', }, diff --git a/src/locales/ro.ts b/src/locales/ro.ts index f3bc0df2..aa3206f7 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -408,6 +408,7 @@ const ro = { unlock: 'Deblochează', ungroup: 'Anulează grupare', newGroup: 'Grup nou', + rename: 'Clic dublu pentru redenumire', show: 'Afișează', hide: 'Ascunde', }, diff --git a/src/locales/sk.ts b/src/locales/sk.ts index 984ae19f..cc119905 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -408,6 +408,7 @@ const sk = { unlock: 'Odomknúť', ungroup: 'Zrušiť skupinu', newGroup: 'Nová skupina', + rename: 'Dvojklikom premenovať', show: 'Zobraziť', hide: 'Skryť', }, diff --git a/src/locales/sl.ts b/src/locales/sl.ts index a969e010..f1d98b1b 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -408,6 +408,7 @@ const sl = { unlock: 'Odkleni', ungroup: 'Razdruži', newGroup: 'Nova skupina', + rename: 'Dvokliknite za preimenovanje', show: 'Prikaži', hide: 'Skrij', }, diff --git a/src/locales/sr.ts b/src/locales/sr.ts index c3927fd6..7cf2ccba 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -408,6 +408,7 @@ const sr = { unlock: 'Откључај', ungroup: 'Razgrupiši', newGroup: 'Nova grupa', + rename: 'Dvoklik za preimenovanje', show: 'Прикажи', hide: 'Сакриј', }, diff --git a/src/locales/sv.ts b/src/locales/sv.ts index afe12843..d45b3ed7 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -408,6 +408,7 @@ const sv = { unlock: 'Lås upp', ungroup: 'Dela upp', newGroup: 'Ny grupp', + rename: 'Dubbelklicka för att byta namn', show: 'Visa', hide: 'Dölj', }, diff --git a/src/locales/tr.ts b/src/locales/tr.ts index 28caad42..8e5eba38 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -408,6 +408,7 @@ const tr = { unlock: 'Kilidi aç', ungroup: 'Grubu çöz', newGroup: 'Yeni grup', + rename: 'Yeniden adlandırmak için çift tıklayın', show: 'Göster', hide: 'Gizle', }, diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 94cf51e1..36b68079 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -408,6 +408,7 @@ const zhHans = { unlock: '解锁', ungroup: '取消组合', newGroup: '新建组', + rename: '双击重命名', show: '显示', hide: '隐藏', }, diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index 9559095a..0e29fa0e 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -408,6 +408,7 @@ const zhHant = { unlock: '解鎖', ungroup: '取消群組', newGroup: '新增群組', + rename: '雙擊以重新命名', show: '顯示', hide: '隱藏', }, diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index f24b1618..965b2264 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -27,7 +27,7 @@ export interface Page { /** Meta fields that remain editable on a locked object so the user can * release the lock or annotate without unlocking first. Everything else * (position, props, rotation, positionType) is blocked. */ -const LOCK_BYPASS_KEYS = new Set(['locked', 'visible', 'includeInExport', 'comment']); +const LOCK_BYPASS_KEYS = new Set(['locked', 'visible', 'includeInExport', 'comment', 'name']); function isLockBypass(changes: ObjectChanges): boolean { const keys = Object.keys(changes); diff --git a/src/types/ObjectType.ts b/src/types/ObjectType.ts index 2aeb41a7..f36c3e33 100644 --- a/src/types/ObjectType.ts +++ b/src/types/ObjectType.ts @@ -41,6 +41,11 @@ export const labelObjectBaseSchema = z.object({ * visible so a designer can preview placement without shipping. Defaults * to true. */ includeInExport: z.boolean().optional(), + /** Optional user-supplied label. Used by groups so the layers panel and + * properties panel can show "Header" instead of the generic "Group". + * Leaves currently fall back to their registry label; the field lives + * on the base so naming leaves later is a UI-only change. */ + name: z.string().optional(), }); export type LabelObjectBase = z.infer; From 7108c58bc65ccb8efaf8179fffb612c752df8b9b Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 15 May 2026 00:17:31 +0200 Subject: [PATCH 22/37] refactor(groups): split LayerRow into its own file, unify panel render path LayersPanel.tsx was carrying both the panel container (state, dnd wiring, header, sortable context) and the LayerRow component (sortable attachment, inline rename state, button bar) in one file. Split into LayerRow.tsx alongside the existing useLayerDnd.ts so each module has one concern. Panel drops to 128 lines, row stands alone at 213. Render structure: header and DndContext are now always present, with the empty-state message and the SortableContext + rows toggling inside the same outer wrapper. Removes the duplicated wrapping div the empty and full branches each had, and lets the new-group button sit at the same DOM level regardless of state. --- src/components/Properties/LayerRow.tsx | 213 +++++++++++++++ src/components/Properties/LayersPanel.tsx | 311 ++++------------------ 2 files changed, 262 insertions(+), 262 deletions(-) create mode 100644 src/components/Properties/LayerRow.tsx diff --git a/src/components/Properties/LayerRow.tsx b/src/components/Properties/LayerRow.tsx new file mode 100644 index 00000000..943c42f5 --- /dev/null +++ b/src/components/Properties/LayerRow.tsx @@ -0,0 +1,213 @@ +import { useEffect, useRef, useState } from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { + EyeIcon, + EyeSlashIcon, + LockClosedIcon, + LockOpenIcon, + ChevronRightIcon, + ChevronDownIcon, + LinkSlashIcon, +} from '@heroicons/react/16/solid'; +import { ObjectRegistry } from '../../registry'; +import type { LabelObject } from '../../registry'; +import { isGroup } from '../../types/Group'; +import { useT } from '../../lib/useT'; +import { DragHandleIcon } from '../ui/DragHandleIcon'; +import { INDENT_STEP } from './useLayerDnd'; + +export interface LayerRowProps { + obj: LabelObject; + depth: number; + containerId: string; + isSelected: boolean; + isExpanded: boolean; + /** Highlight the row body — used for "drop into this group". */ + isDropTarget: boolean; + /** Show an accent line above this row — used for sibling drops so the + * user sees the exact landing slot before releasing. */ + showInsertionLine: boolean; + /** Visual depth at which to render the insertion line. Diverges from + * the row's own depth while the user drags horizontally to climb out + * of a deeply nested container. */ + insertionLineDepth: number | null; + onSelect: () => void; + onToggle: () => void; + onToggleLock: () => void; + onToggleVisible: () => void; + onToggleExpand: () => void; + onUngroup: () => void; + /** Commit the new name; empty string clears it back to the default. */ + onRename: (name: string | undefined) => void; +} + +export function LayerRow({ + obj, + depth, + containerId, + isSelected, + isExpanded, + isDropTarget, + showInsertionLine, + insertionLineDepth, + onSelect, + onToggle, + onToggleLock, + onToggleVisible, + onToggleExpand, + onUngroup, + onRename, +}: LayerRowProps) { + const t = useT(); + const def = ObjectRegistry[obj.type]; + const groupRow = isGroup(obj); + // Inline-rename is currently only exposed for groups; leaves render + // their registry label as a plain (non-editable) span. The single + // groupRow check at the entry-point keeps the rest of the edit path + // free of repeated guards. + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + if (editing) inputRef.current?.select(); + }, [editing]); + + const beginEdit = () => { + setDraft(obj.name ?? ''); + setEditing(true); + }; + const commitEdit = () => { + const trimmed = draft.trim(); + if ((obj.name ?? '') !== trimmed) onRename(trimmed || undefined); + setEditing(false); + }; + const cancelEdit = () => setEditing(false); + const defaultLabel = groupRow ? t.types.group : (def?.label ?? obj.type); + const displayName = obj.name ?? defaultLabel; + const isLocked = !!obj.locked; + const isHidden = obj.visible === false; + const { attributes, listeners, setNodeRef, isDragging } = useSortable({ + id: obj.id, + data: { containerId }, + disabled: isLocked, + }); + const stopRowClick = (e: React.MouseEvent) => e.stopPropagation(); + // The line indent follows the *target* depth, not the row's own depth, + // so as the user drags left the line slides left in real time. + const lineDepth = insertionLineDepth ?? depth; + const linePadLeft = lineDepth > 0 ? lineDepth * INDENT_STEP + 16 : 8; + + return ( + <> +
+
0 ? depth * INDENT_STEP + 8 : undefined }} + {...attributes} + {...(isLocked ? {} : listeners)} + onClick={(e) => { + if (e.shiftKey || e.ctrlKey || e.metaKey) onToggle(); + else onSelect(); + }} + className={` + flex items-center gap-2 px-2 py-1.5 + ${isLocked ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'} + border-b border-border group transition-colors hover:bg-surface-2 + ${isSelected ? 'bg-surface-2 border-l-2 border-l-accent' : 'border-l-2 border-l-transparent'} + ${isDragging ? 'opacity-40' : ''} + ${isHidden ? 'opacity-50' : ''} + ${isDropTarget ? 'bg-accent/15 outline outline-1 outline-accent/60' : ''} + `} + > + + {groupRow ? ( + + ) : ( + + )} + + {groupRow ? '⊞' : def?.icon} + +
+ {editing ? ( + setDraft(e.target.value)} + onBlur={commitEdit} + onKeyDown={(e) => { + if (e.key === 'Enter') commitEdit(); + else if (e.key === 'Escape') cancelEdit(); + }} + onClick={stopRowClick} + onPointerDown={stopRowClick} + placeholder={defaultLabel} + className="text-xs text-text bg-surface-2 border border-border rounded px-1 py-0 -my-0.5 focus:border-accent focus:outline-none w-full" + /> + ) : ( + { e.stopPropagation(); beginEdit(); } : undefined} + title={groupRow ? t.layers.rename : undefined} + > + {displayName} + + )} + {obj.id.slice(0, 8)} +
+ {groupRow && ( + + )} + + +
+ + ); +} diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index 6fd0ae84..32efa7f7 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -1,216 +1,13 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; import { DndContext } from '@dnd-kit/core'; -import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { - EyeIcon, - EyeSlashIcon, - LockClosedIcon, - LockOpenIcon, - ChevronRightIcon, - ChevronDownIcon, - LinkSlashIcon, - FolderPlusIcon, -} from '@heroicons/react/16/solid'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { FolderPlusIcon } from '@heroicons/react/16/solid'; import { useLabelStore, useCurrentObjects } from '../../store/labelStore'; -import { ObjectRegistry } from '../../registry'; -import type { LabelObject } from '../../registry'; -import { isGroup, walkObjects } from '../../types/Group'; +import { walkObjects } from '../../types/Group'; import { useT } from '../../lib/useT'; import { buildBulkToggleUpdates, type ToggleField } from '../../lib/bulkToggle'; -import { DragHandleIcon } from '../ui/DragHandleIcon'; -import { buildFlatRows, useLayerDnd, INDENT_STEP, type FlatRow } from './useLayerDnd'; - -interface RowProps { - obj: LabelObject; - depth: number; - containerId: string; - isSelected: boolean; - isExpanded: boolean; - /** Highlight the row body — used for "drop into this group". */ - isDropTarget: boolean; - /** Show an accent line above this row — used for sibling drops so the - * user sees the exact landing slot before releasing. */ - showInsertionLine: boolean; - /** Visual depth at which to render the insertion line. Diverges from - * the row's own depth while the user drags horizontally to climb out - * of a deeply nested container. */ - insertionLineDepth: number | null; - onSelect: () => void; - onToggle: () => void; - onToggleLock: () => void; - onToggleVisible: () => void; - onToggleExpand: () => void; - onUngroup: () => void; - /** Commit the new name; empty string clears it back to the default. */ - onRename: (name: string | undefined) => void; -} - -function LayerRow({ - obj, - depth, - containerId, - isSelected, - isExpanded, - isDropTarget, - showInsertionLine, - insertionLineDepth, - onSelect, - onToggle, - onToggleLock, - onToggleVisible, - onToggleExpand, - onUngroup, - onRename, -}: RowProps) { - const t = useT(); - const def = ObjectRegistry[obj.type]; - const groupRow = isGroup(obj); - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(''); - const inputRef = useRef(null); - - useEffect(() => { - if (editing) inputRef.current?.select(); - }, [editing]); - - const beginEdit = () => { - setDraft(obj.name ?? ''); - setEditing(true); - }; - const commitEdit = () => { - const trimmed = draft.trim(); - if ((obj.name ?? '') !== trimmed) onRename(trimmed || undefined); - setEditing(false); - }; - const cancelEdit = () => setEditing(false); - const defaultLabel = groupRow ? t.types.group : (def?.label ?? obj.type); - const displayName = obj.name ?? defaultLabel; - const isLocked = !!obj.locked; - const isHidden = obj.visible === false; - const { attributes, listeners, setNodeRef, isDragging } = useSortable({ - id: obj.id, - data: { containerId }, - disabled: isLocked, - }); - const stopRowClick = (e: React.MouseEvent) => e.stopPropagation(); - // The line indent follows the *target* depth, not the row's own depth, - // so as the user drags left the line slides left in real time. - const lineDepth = insertionLineDepth ?? depth; - const linePadLeft = lineDepth > 0 ? lineDepth * INDENT_STEP + 16 : 8; - - return ( - <> -
-
0 ? depth * INDENT_STEP + 8 : undefined }} - {...attributes} - {...(isLocked ? {} : listeners)} - onClick={(e) => { - if (e.shiftKey || e.ctrlKey || e.metaKey) onToggle(); - else onSelect(); - }} - className={` - flex items-center gap-2 px-2 py-1.5 - ${isLocked ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'} - border-b border-border group transition-colors hover:bg-surface-2 - ${isSelected ? 'bg-surface-2 border-l-2 border-l-accent' : 'border-l-2 border-l-transparent'} - ${isDragging ? 'opacity-40' : ''} - ${isHidden ? 'opacity-50' : ''} - ${isDropTarget ? 'bg-accent/15 outline outline-1 outline-accent/60' : ''} - `} - > - - {groupRow ? ( - - ) : ( - - )} - - {groupRow ? '⊞' : def?.icon} - -
- {editing && groupRow ? ( - setDraft(e.target.value)} - onBlur={commitEdit} - onKeyDown={(e) => { - if (e.key === 'Enter') commitEdit(); - else if (e.key === 'Escape') cancelEdit(); - }} - onClick={stopRowClick} - onPointerDown={stopRowClick} - placeholder={defaultLabel} - className="text-xs text-text bg-surface-2 border border-border rounded px-1 py-0 -my-0.5 focus:border-accent focus:outline-none w-full" - /> - ) : ( - { e.stopPropagation(); beginEdit(); } : undefined} - title={groupRow ? t.layers.rename : undefined} - > - {displayName} - - )} - {obj.id.slice(0, 8)} -
- {groupRow && ( - - )} - - -
- - ); -} +import { buildFlatRows, useLayerDnd, type FlatRow } from './useLayerDnd'; +import { LayerRow } from './LayerRow'; export function LayersPanel() { const t = useT(); @@ -235,6 +32,7 @@ export function LayersPanel() { for (const r of rows) m.set(r.obj.id, r); return m; }, [rows]); + const allRowIds = useMemo(() => rows.map((r) => r.obj.id), [rows]); const { sensors, @@ -273,33 +71,6 @@ export function LayersPanel() { }); }; - const header = ( -
- -
- ); - - if (objects.length === 0) { - return ( -
- {header} -
- {t.layers.empty} -
-
- ); - } - - const allRowIds = rows.map((r) => r.obj.id); - return ( - {header} - -
- {rows.map(({ obj, depth, containerId }) => ( - selectObject(obj.id)} - onToggle={() => toggleSelectObject(obj.id)} - onToggleLock={() => toggleField(obj.id, 'locked')} - onToggleVisible={() => toggleField(obj.id, 'visible')} - onToggleExpand={() => toggleExpand(obj.id)} - onUngroup={() => ungroupIds([obj.id])} - onRename={(name) => updateObject(obj.id, { name })} - /> - ))} +
+ +
+ {objects.length === 0 ? ( +
+ {t.layers.empty}
- + ) : ( + +
+ {rows.map(({ obj, depth, containerId }) => ( + selectObject(obj.id)} + onToggle={() => toggleSelectObject(obj.id)} + onToggleLock={() => toggleField(obj.id, 'locked')} + onToggleVisible={() => toggleField(obj.id, 'visible')} + onToggleExpand={() => toggleExpand(obj.id)} + onUngroup={() => ungroupIds([obj.id])} + onRename={(name) => updateObject(obj.id, { name })} + /> + ))} +
+
+ )} ); } From 0b4b116b841ac660859957a4c74d43f9cf62655b Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 15 May 2026 00:23:10 +0200 Subject: [PATCH 23/37] feat(groups): indent guide lines and selected-group tint in layers panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two UX nudges for the layers tree: A. Each nested row now renders one fixed-width spacer per ancestor level, each carrying a left border. Consecutive rows at the same depth line up so the borders read as continuous vertical guides from a parent group's row down through its children — same idiom as Figma / VSCode / Notion tree views. Replaces the old paddingLeft override; total left offset stays at depth*16+8 dots. B. When a group is selected, every descendant row gets a soft accent tint (bg-accent/5). Signals 'these move with me if you drag or arrow' before the user starts the gesture, which was the missing affordance once selection became group-level. --- src/components/Properties/LayerRow.tsx | 23 +++++++++++++++++++++-- src/components/Properties/LayersPanel.tsx | 17 ++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/components/Properties/LayerRow.tsx b/src/components/Properties/LayerRow.tsx index 943c42f5..39b82138 100644 --- a/src/components/Properties/LayerRow.tsx +++ b/src/components/Properties/LayerRow.tsx @@ -21,6 +21,9 @@ export interface LayerRowProps { depth: number; containerId: string; isSelected: boolean; + /** True for any leaf or sub-group that lives under a currently-selected + * group. Drives the soft tint that signals "I move with the group". */ + isInSelectedGroup: boolean; isExpanded: boolean; /** Highlight the row body — used for "drop into this group". */ isDropTarget: boolean; @@ -46,6 +49,7 @@ export function LayerRow({ depth, containerId, isSelected, + isInSelectedGroup, isExpanded, isDropTarget, showInsertionLine, @@ -108,7 +112,7 @@ export function LayerRow({ />
0 ? depth * INDENT_STEP + 8 : undefined }} + style={{ touchAction: 'none' }} {...attributes} {...(isLocked ? {} : listeners)} onClick={(e) => { @@ -116,15 +120,30 @@ export function LayerRow({ else onSelect(); }} className={` - flex items-center gap-2 px-2 py-1.5 + flex items-center gap-2 pr-2 py-1.5 ${isLocked ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'} border-b border-border group transition-colors hover:bg-surface-2 ${isSelected ? 'bg-surface-2 border-l-2 border-l-accent' : 'border-l-2 border-l-transparent'} + ${isInSelectedGroup && !isSelected ? 'bg-accent/5' : ''} ${isDragging ? 'opacity-40' : ''} ${isHidden ? 'opacity-50' : ''} ${isDropTarget ? 'bg-accent/15 outline outline-1 outline-accent/60' : ''} `} > + {/* Indent guide lines: one fixed-width spacer per ancestor level, + each carrying a left border so consecutive rows at the same + depth visually form a continuous vertical guide from the parent + group's row down through its children. The outer pl-2 is gone + because the spacers carry that offset themselves. */} + {depth > 0 && ( +
+ + {Array.from({ length: depth }, (_, i) => ( + + ))} +
+ )} + {depth === 0 && } diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index 32efa7f7..ab32f921 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -3,7 +3,7 @@ import { DndContext } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { FolderPlusIcon } from '@heroicons/react/16/solid'; import { useLabelStore, useCurrentObjects } from '../../store/labelStore'; -import { walkObjects } from '../../types/Group'; +import { findObjectById, isGroup, walkObjects } from '../../types/Group'; import { useT } from '../../lib/useT'; import { buildBulkToggleUpdates, type ToggleField } from '../../lib/bulkToggle'; import { buildFlatRows, useLayerDnd, type FlatRow } from './useLayerDnd'; @@ -34,6 +34,20 @@ export function LayersPanel() { }, [rows]); const allRowIds = useMemo(() => rows.map((r) => r.obj.id), [rows]); + // Soft tint for every descendant of a currently-selected group, so the + // user sees which leaves would move together if they dragged the group + // (or pressed an arrow key). Excludes the group itself — its row keeps + // the stronger "is selected" accent. + const idsUnderSelectedGroup = useMemo(() => { + const out = new Set(); + for (const id of selectedIds) { + const obj = findObjectById(objects, id); + if (!obj || !isGroup(obj)) continue; + for (const desc of walkObjects(obj.children)) out.add(desc.id); + } + return out; + }, [objects, selectedIds]); + const { sensors, collisionDetection, @@ -105,6 +119,7 @@ export function LayersPanel() { depth={depth} containerId={containerId} isSelected={selectedIds.includes(obj.id)} + isInSelectedGroup={idsUnderSelectedGroup.has(obj.id)} isExpanded={expandedIds.has(obj.id)} isDropTarget={preview.dropIntoTargetId === obj.id} showInsertionLine={preview.insertionLineRowId === obj.id} From fa9d17b8ee36fd6c6519809761f3ca1d23458cbf Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 15 May 2026 00:25:37 +0200 Subject: [PATCH 24/37] feat(groups): bold group labels and child count on collapsed groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two layer-panel UX nudges: B. Group rows render their label at font-medium so the eye latches onto them as containers, not as more peer rows next to their children. Subtle weight bump (500 vs 400) reads as a header without competing with the selection accent. G. Collapsed groups append the child count to their label as 'Name · 3', so the user can judge what's inside without expanding. Suppressed while editing (the input would otherwise prefill with the count too) and while the group is empty (zero would just be noise). --- src/components/Properties/LayerRow.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/Properties/LayerRow.tsx b/src/components/Properties/LayerRow.tsx index 39b82138..c99a4ccc 100644 --- a/src/components/Properties/LayerRow.tsx +++ b/src/components/Properties/LayerRow.tsx @@ -89,6 +89,13 @@ export function LayerRow({ const cancelEdit = () => setEditing(false); const defaultLabel = groupRow ? t.types.group : (def?.label ?? obj.type); const displayName = obj.name ?? defaultLabel; + // Show the child count next to a collapsed group's name so the user can + // judge what's inside without expanding. Hidden while expanded (the + // count is visible as actual rows) and while editing (the input would + // otherwise prefill with the count too). + const childCount = groupRow ? obj.children.length : 0; + const showCount = groupRow && !isExpanded && childCount > 0 && !editing; + const labelText = showCount ? `${displayName} · ${childCount}` : displayName; const isLocked = !!obj.locked; const isHidden = obj.visible === false; const { attributes, listeners, setNodeRef, isDragging } = useSortable({ @@ -185,11 +192,11 @@ export function LayerRow({ /> ) : ( { e.stopPropagation(); beginEdit(); } : undefined} title={groupRow ? t.layers.rename : undefined} > - {displayName} + {labelText} )} {obj.id.slice(0, 8)} From 3bd477aa3166cd88ecc6d5405ec79de606a1ffb0 Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 15 May 2026 00:27:33 +0200 Subject: [PATCH 25/37] feat(groups): bottom gap on the last child of an expanded group When the next row leaves a deeper container, the current row picks up a small bottom margin (mb-1) so the visual boundary between a group's children and the next sibling at the parent level is obvious. Without it the list flowed straight through and the user had to count indent to tell which rows still belonged to the group above and which were the next top-level item. --- src/components/Properties/LayerRow.tsx | 5 +++++ src/components/Properties/LayersPanel.tsx | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/Properties/LayerRow.tsx b/src/components/Properties/LayerRow.tsx index c99a4ccc..b790a690 100644 --- a/src/components/Properties/LayerRow.tsx +++ b/src/components/Properties/LayerRow.tsx @@ -30,6 +30,9 @@ export interface LayerRowProps { /** Show an accent line above this row — used for sibling drops so the * user sees the exact landing slot before releasing. */ showInsertionLine: boolean; + /** Add a small bottom gap because the next row in display order leaves + * this row's container (depth drops). Visually closes the group. */ + isContainerEnd: boolean; /** Visual depth at which to render the insertion line. Diverges from * the row's own depth while the user drags horizontally to climb out * of a deeply nested container. */ @@ -54,6 +57,7 @@ export function LayerRow({ isDropTarget, showInsertionLine, insertionLineDepth, + isContainerEnd, onSelect, onToggle, onToggleLock, @@ -135,6 +139,7 @@ export function LayerRow({ ${isDragging ? 'opacity-40' : ''} ${isHidden ? 'opacity-50' : ''} ${isDropTarget ? 'bg-accent/15 outline outline-1 outline-accent/60' : ''} + ${isContainerEnd ? 'mb-1' : ''} `} > {/* Indent guide lines: one fixed-width spacer per ancestor level, diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index ab32f921..75f0e95d 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -112,7 +112,7 @@ export function LayersPanel() { ) : (
- {rows.map(({ obj, depth, containerId }) => ( + {rows.map(({ obj, depth, containerId }, i) => ( 0 && (rows[i + 1]?.depth ?? -1) < depth + } onSelect={() => selectObject(obj.id)} onToggle={() => toggleSelectObject(obj.id)} onToggleLock={() => toggleField(obj.id, 'locked')} From afd7d4bbd1965ab2604fe21f448d7163b065e70e Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 15 May 2026 00:32:52 +0200 Subject: [PATCH 26/37] refactor(groups): unify indent spacer + extract insertAt helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LayerRow's indent column had a depth-0 branch (single 8px span) and a depth>0 branch (flex wrapper + spacers). Both produced the same leading 8px offset. Unified to always render the wrapper; depth 0 just emits the gutter span with no extra spacers. One code path. labelStore.reparentObject was inlining 'splice an item into an array at a clamped index' twice — once for top-level inserts, once for into-group inserts. Pulled into a small insertAt() helper next to the other store-private utilities. Reads cleaner and removes the visual duplication where the two branches looked nearly identical. --- src/components/Properties/LayerRow.tsx | 26 +++++++++++----------- src/store/labelStore.ts | 30 +++++++++++++------------- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/src/components/Properties/LayerRow.tsx b/src/components/Properties/LayerRow.tsx index b790a690..d7a897e2 100644 --- a/src/components/Properties/LayerRow.tsx +++ b/src/components/Properties/LayerRow.tsx @@ -142,20 +142,18 @@ export function LayerRow({ ${isContainerEnd ? 'mb-1' : ''} `} > - {/* Indent guide lines: one fixed-width spacer per ancestor level, - each carrying a left border so consecutive rows at the same - depth visually form a continuous vertical guide from the parent - group's row down through its children. The outer pl-2 is gone - because the spacers carry that offset themselves. */} - {depth > 0 && ( -
- - {Array.from({ length: depth }, (_, i) => ( - - ))} -
- )} - {depth === 0 && } + {/* Indent column: a leading 8px gutter plus one fixed-width spacer + per ancestor level, each carrying a left border so consecutive + rows at the same depth visually form a continuous vertical + guide from the parent group's row down through its children. + Always rendered (even at depth 0) so the wrapper handles the + row's base left padding uniformly. */} +
+ + {Array.from({ length: depth }, (_, i) => ( + + ))} +
diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index 965b2264..d02616d9 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -45,6 +45,15 @@ function applyObjectChanges(obj: LabelObject, changes: ObjectChanges): LabelObje } as LabelObject; } +/** Immutable insert-at-index that clamps `idx` into the array's bounds. + * Used by reparent flows to splice a node into a children list or the + * top-level list without crashing on out-of-range indices coming from + * ephemeral drag state. */ +function insertAt(arr: readonly T[], idx: number, item: T): T[] { + const clamped = Math.max(0, Math.min(idx, arr.length)); + return [...arr.slice(0, clamped), item, ...arr.slice(clamped)]; +} + function detectLocale(): LocaleCode { const lang = navigator.language.slice(0, 2).toLowerCase(); return (lang in locales ? lang : 'en') as LocaleCode; @@ -448,22 +457,13 @@ export const useLabelStore = create()( if (!removed) return {}; const node = removed; if (target.parentId === null) { - const clamped = Math.max(0, Math.min(target.index, rest.length)); - const next = [...rest.slice(0, clamped), node, ...rest.slice(clamped)]; - return updateCurrentObjects(state, () => next); + return updateCurrentObjects(state, () => insertAt(rest, target.index, node)); } - const next = mapObjectById(rest, target.parentId, (p) => { - if (!isGroup(p)) return p; - const clamped = Math.max(0, Math.min(target.index, p.children.length)); - return { - ...p, - children: [ - ...p.children.slice(0, clamped), - node, - ...p.children.slice(clamped), - ], - }; - }); + const next = mapObjectById(rest, target.parentId, (p) => + isGroup(p) + ? { ...p, children: insertAt(p.children, target.index, node) } + : p, + ); return updateCurrentObjects(state, () => next); }), From 2d713a4b9690d87d8961adb0b1af913868281bb5 Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 15 May 2026 00:34:13 +0200 Subject: [PATCH 27/37] refactor(groups): move INDENT_STEP into its own layout module INDENT_STEP was exported from useLayerDnd.ts (the dnd hook) but consumed by both the hook (for cursor-X-to-depth math) and LayerRow (for the indent spacer column and the insertion line indent). The constant is a layout concern, not drag-protocol, so its home was wrong. Pulled into src/components/Properties/layerLayout.ts; both consumers now import from there and the hook no longer leaks a visual constant through its public surface. --- src/components/Properties/LayerRow.tsx | 2 +- src/components/Properties/layerLayout.ts | 6 ++++++ src/components/Properties/useLayerDnd.ts | 6 +----- 3 files changed, 8 insertions(+), 6 deletions(-) create mode 100644 src/components/Properties/layerLayout.ts diff --git a/src/components/Properties/LayerRow.tsx b/src/components/Properties/LayerRow.tsx index d7a897e2..9fe1a069 100644 --- a/src/components/Properties/LayerRow.tsx +++ b/src/components/Properties/LayerRow.tsx @@ -14,7 +14,7 @@ import type { LabelObject } from '../../registry'; import { isGroup } from '../../types/Group'; import { useT } from '../../lib/useT'; import { DragHandleIcon } from '../ui/DragHandleIcon'; -import { INDENT_STEP } from './useLayerDnd'; +import { INDENT_STEP } from './layerLayout'; export interface LayerRowProps { obj: LabelObject; diff --git a/src/components/Properties/layerLayout.ts b/src/components/Properties/layerLayout.ts new file mode 100644 index 00000000..ff9e9a7d --- /dev/null +++ b/src/components/Properties/layerLayout.ts @@ -0,0 +1,6 @@ +/** Horizontal pixels per nesting level in the layers panel. Used by the + * row renderer to size the indent spacers and by the drag hook to + * quantise cursor-X into a target depth — both have to agree on the + * same step so the insertion line lines up with where the drop will + * actually land. Single home for that invariant. */ +export const INDENT_STEP = 16; diff --git a/src/components/Properties/useLayerDnd.ts b/src/components/Properties/useLayerDnd.ts index b4ecbb98..6e8e6f29 100644 --- a/src/components/Properties/useLayerDnd.ts +++ b/src/components/Properties/useLayerDnd.ts @@ -4,16 +4,12 @@ import type { DragEndEvent, DragOverEvent } from '@dnd-kit/core'; import { isGroup, findObjectById, findAncestors } from '../../types/Group'; import type { GroupObject } from '../../types/Group'; import type { LabelObject } from '../../registry'; +import { INDENT_STEP } from './layerLayout'; /** Sentinel container id for the top-level objects list. Group containers * use the group's own id, so the root needs a value that can't collide. */ export const ROOT_CONTAINER = '__root__'; -/** Horizontal pixels per nesting level — matches the row's own paddingLeft - * step so the insertion line lines up visually with the target row's - * content column. Changing this means changing the row indent too. */ -export const INDENT_STEP = 16; - /** Pixel bias subtracted from the cursor X before quantising to depth so a * user has to drag a little before the target depth changes. Tuned to feel * like Figma's "you mean it" threshold. */ From 3e16aca36fa960049e5bc5f80e8283d0f1e91508 Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 15 May 2026 00:37:05 +0200 Subject: [PATCH 28/37] feat(groups): name field in PropertiesPanel for groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Double-clicking a group row in the layers panel renames it, but that gesture is hidden. Surface the same field as a regular input at the top of the PropertiesPanel body when a group is selected. Empty value falls back to the localised 'Group' default — same semantics as the layers panel inline-rename so the two stay aligned. --- src/components/Properties/PropertiesPanel.tsx | 19 +++++++++++++++++++ src/locales/ar.ts | 1 + src/locales/bg.ts | 1 + src/locales/cs.ts | 1 + src/locales/da.ts | 1 + src/locales/de.ts | 1 + src/locales/el.ts | 1 + src/locales/en.ts | 1 + src/locales/es.ts | 1 + src/locales/et.ts | 1 + src/locales/fa.ts | 1 + src/locales/fi.ts | 1 + src/locales/fr.ts | 1 + src/locales/he.ts | 1 + src/locales/hr.ts | 1 + src/locales/hu.ts | 1 + src/locales/it.ts | 1 + src/locales/ja.ts | 1 + src/locales/ko.ts | 1 + src/locales/lt.ts | 1 + src/locales/lv.ts | 1 + src/locales/nl.ts | 1 + src/locales/no.ts | 1 + src/locales/pl.ts | 1 + src/locales/pt.ts | 1 + src/locales/ro.ts | 1 + src/locales/sk.ts | 1 + src/locales/sl.ts | 1 + src/locales/sr.ts | 1 + src/locales/sv.ts | 1 + src/locales/tr.ts | 1 + src/locales/zh-hans.ts | 1 + src/locales/zh-hant.ts | 1 + 33 files changed, 51 insertions(+) diff --git a/src/components/Properties/PropertiesPanel.tsx b/src/components/Properties/PropertiesPanel.tsx index 5f6c8eb8..736b5346 100644 --- a/src/components/Properties/PropertiesPanel.tsx +++ b/src/components/Properties/PropertiesPanel.tsx @@ -106,6 +106,25 @@ export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) {
+ {/* Name — currently exposed only for groups, since leaf rows still + fall back to their registry label in the layers panel. The + field lives on LabelObjectBase so adding it for other types + later is a UI-only change. */} + {obj.type === 'group' && ( +
+ + + updateObject(obj.id, { name: e.target.value || undefined }) + } + /> +
+ )} + {/* Position */}

diff --git a/src/locales/ar.ts b/src/locales/ar.ts index df4b32ac..059a67b1 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -47,6 +47,7 @@ const ar = { properties: { positionSection: 'الموضع (مم)', + name: 'الاسم', x: 'X', y: 'Y', comment: 'تعليق', diff --git a/src/locales/bg.ts b/src/locales/bg.ts index 49bb1bc1..5eb97957 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -47,6 +47,7 @@ const bg = { properties: { positionSection: 'Позиция', + name: 'Име', x: 'X', y: 'Y', comment: 'Коментар', diff --git a/src/locales/cs.ts b/src/locales/cs.ts index a3b00842..d1c0bea9 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -47,6 +47,7 @@ const cs = { properties: { positionSection: 'Pozice', + name: 'Název', x: 'X', y: 'Y', comment: 'Komentář', diff --git a/src/locales/da.ts b/src/locales/da.ts index d118d2f0..1ade98aa 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -47,6 +47,7 @@ const da = { properties: { positionSection: 'Position', + name: 'Navn', x: 'X', y: 'Y', comment: 'Kommentar', diff --git a/src/locales/de.ts b/src/locales/de.ts index 8eec5360..5a27549f 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -47,6 +47,7 @@ const de = { properties: { positionSection: 'Position', + name: 'Name', x: 'X', y: 'Y', comment: 'Kommentar', diff --git a/src/locales/el.ts b/src/locales/el.ts index 99787c1d..237ab7d8 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -47,6 +47,7 @@ const el = { properties: { positionSection: 'Θέση', + name: 'Όνομα', x: 'X', y: 'Y', comment: 'Σχόλιο', diff --git a/src/locales/en.ts b/src/locales/en.ts index 149ae548..5f8fcfe0 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -47,6 +47,7 @@ const en = { properties: { positionSection: 'Position', + name: 'Name', x: 'X', y: 'Y', comment: 'Comment', diff --git a/src/locales/es.ts b/src/locales/es.ts index d57d3e5f..7f9abfca 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -47,6 +47,7 @@ const es = { properties: { positionSection: 'Posición', + name: 'Nombre', x: 'X', y: 'Y', comment: 'Comentario', diff --git a/src/locales/et.ts b/src/locales/et.ts index ab33acaa..cef4cd78 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -47,6 +47,7 @@ const et = { properties: { positionSection: 'Asukoht', + name: 'Nimi', x: 'X', y: 'Y', comment: 'Kommentaar', diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 522fa9cf..ae1f53e6 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -47,6 +47,7 @@ const fa = { properties: { positionSection: 'موقعیت (میلی‌متر)', + name: 'نام', x: 'X', y: 'Y', comment: 'توضیح', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index f5dd5b71..b6b6762d 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -47,6 +47,7 @@ const fi = { properties: { positionSection: 'Sijainti', + name: 'Nimi', x: 'X', y: 'Y', comment: 'Kommentti', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index bb4d3c30..8f7a98c9 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -47,6 +47,7 @@ const fr = { properties: { positionSection: 'Position', + name: 'Nom', x: 'X', y: 'Y', comment: 'Commentaire', diff --git a/src/locales/he.ts b/src/locales/he.ts index f98512f6..70c47079 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -47,6 +47,7 @@ const he = { properties: { positionSection: 'מיקום (מ"מ)', + name: 'שם', x: 'X', y: 'Y', comment: 'הערה', diff --git a/src/locales/hr.ts b/src/locales/hr.ts index 8ad38eb7..163ae87f 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -47,6 +47,7 @@ const hr = { properties: { positionSection: 'Položaj', + name: 'Naziv', x: 'X', y: 'Y', comment: 'Komentar', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index 71a8d061..7e0bbda1 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -47,6 +47,7 @@ const hu = { properties: { positionSection: 'Pozíció', + name: 'Név', x: 'X', y: 'Y', comment: 'Megjegyzés', diff --git a/src/locales/it.ts b/src/locales/it.ts index 797ddb55..71a6cacf 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -47,6 +47,7 @@ const it = { properties: { positionSection: 'Posizione', + name: 'Nome', x: 'X', y: 'Y', comment: 'Commento', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 33513cdd..3083720d 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -47,6 +47,7 @@ const ja = { properties: { positionSection: '位置', + name: '名前', x: 'X', y: 'Y', comment: 'コメント', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index d79879e7..def3ea56 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -47,6 +47,7 @@ const ko = { properties: { positionSection: '위치', + name: '이름', x: 'X', y: 'Y', comment: '설명', diff --git a/src/locales/lt.ts b/src/locales/lt.ts index 0a3b2931..7b21d26d 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -47,6 +47,7 @@ const lt = { properties: { positionSection: 'Padėtis', + name: 'Pavadinimas', x: 'X', y: 'Y', comment: 'Komentaras', diff --git a/src/locales/lv.ts b/src/locales/lv.ts index 348f154b..d1b5d73d 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -47,6 +47,7 @@ const lv = { properties: { positionSection: 'Pozīcija', + name: 'Nosaukums', x: 'X', y: 'Y', comment: 'Komentārs', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 217ee5ec..79eca485 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -47,6 +47,7 @@ const nl = { properties: { positionSection: 'Positie', + name: 'Naam', x: 'X', y: 'Y', comment: 'Opmerking', diff --git a/src/locales/no.ts b/src/locales/no.ts index 52dcf582..65d417a4 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -47,6 +47,7 @@ const no = { properties: { positionSection: 'Posisjon', + name: 'Navn', x: 'X', y: 'Y', comment: 'Kommentar', diff --git a/src/locales/pl.ts b/src/locales/pl.ts index 3fce5fdd..686cab52 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -47,6 +47,7 @@ const pl = { properties: { positionSection: 'Pozycja', + name: 'Nazwa', x: 'X', y: 'Y', comment: 'Komentarz', diff --git a/src/locales/pt.ts b/src/locales/pt.ts index 66d32695..ab02892e 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -47,6 +47,7 @@ const pt = { properties: { positionSection: 'Posição', + name: 'Nome', x: 'X', y: 'Y', comment: 'Comentário', diff --git a/src/locales/ro.ts b/src/locales/ro.ts index aa3206f7..7043e5d7 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -47,6 +47,7 @@ const ro = { properties: { positionSection: 'Poziție', + name: 'Nume', x: 'X', y: 'Y', comment: 'Comentariu', diff --git a/src/locales/sk.ts b/src/locales/sk.ts index cc119905..cbe5ed84 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -47,6 +47,7 @@ const sk = { properties: { positionSection: 'Pozícia', + name: 'Názov', x: 'X', y: 'Y', comment: 'Komentár', diff --git a/src/locales/sl.ts b/src/locales/sl.ts index f1d98b1b..e990c8b9 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -47,6 +47,7 @@ const sl = { properties: { positionSection: 'Položaj', + name: 'Ime', x: 'X', y: 'Y', comment: 'Komentar', diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 7cf2ccba..d6611a16 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -47,6 +47,7 @@ const sr = { properties: { positionSection: 'Položaj', + name: 'Naziv', x: 'X', y: 'Y', comment: 'Коментар', diff --git a/src/locales/sv.ts b/src/locales/sv.ts index d45b3ed7..c00a9061 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -47,6 +47,7 @@ const sv = { properties: { positionSection: 'Position', + name: 'Namn', x: 'X', y: 'Y', comment: 'Kommentar', diff --git a/src/locales/tr.ts b/src/locales/tr.ts index 8e5eba38..30fb94c7 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -47,6 +47,7 @@ const tr = { properties: { positionSection: 'Konum', + name: 'Ad', x: 'X', y: 'Y', comment: 'Yorum', diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 36b68079..d476c679 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -47,6 +47,7 @@ const zhHans = { properties: { positionSection: '位置 (毫米)', + name: '名称', x: 'X', y: 'Y', comment: '备注', diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index 0e29fa0e..de002483 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -47,6 +47,7 @@ const zhHant = { properties: { positionSection: '位置 (公釐)', + name: '名稱', x: 'X', y: 'Y', comment: '備註', From adb95c5bf3fccde86b03ffea1cb7cb0baf04048b Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 15 May 2026 00:39:17 +0200 Subject: [PATCH 29/37] feat(groups): group-selection button in the multi-select PropertiesPanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When more than one item is selected the PropertiesPanel showed only the count, the arrow-keys hint and the align buttons — no discoverable way to act on the selection. Added a 'Group selection' button alongside Align, gated on whether any of the selected items is top-level and unlocked (the same gate groupSelection itself applies internally) so the control doesn't appear when it would be a no-op. --- src/components/Properties/PropertiesPanel.tsx | 19 ++++++++++++++++++- src/locales/ar.ts | 1 + src/locales/bg.ts | 1 + src/locales/cs.ts | 1 + src/locales/da.ts | 1 + src/locales/de.ts | 1 + src/locales/el.ts | 1 + src/locales/en.ts | 1 + src/locales/es.ts | 1 + src/locales/et.ts | 1 + src/locales/fa.ts | 1 + src/locales/fi.ts | 1 + src/locales/fr.ts | 1 + src/locales/he.ts | 1 + src/locales/hr.ts | 1 + src/locales/hu.ts | 1 + src/locales/it.ts | 1 + src/locales/ja.ts | 1 + src/locales/ko.ts | 1 + src/locales/lt.ts | 1 + src/locales/lv.ts | 1 + src/locales/nl.ts | 1 + src/locales/no.ts | 1 + src/locales/pl.ts | 1 + src/locales/pt.ts | 1 + src/locales/ro.ts | 1 + src/locales/sk.ts | 1 + src/locales/sl.ts | 1 + src/locales/sr.ts | 1 + src/locales/sv.ts | 1 + src/locales/tr.ts | 1 + src/locales/zh-hans.ts | 1 + src/locales/zh-hant.ts | 1 + 33 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/components/Properties/PropertiesPanel.tsx b/src/components/Properties/PropertiesPanel.tsx index 736b5346..04c60a51 100644 --- a/src/components/Properties/PropertiesPanel.tsx +++ b/src/components/Properties/PropertiesPanel.tsx @@ -1,5 +1,5 @@ import type { RefObject } from "react"; -import { InformationCircleIcon } from "@heroicons/react/16/solid"; +import { InformationCircleIcon, FolderPlusIcon } from "@heroicons/react/16/solid"; import { useLabelStore, useCurrentObjects } from "../../store/labelStore"; import type { LabelCanvasHandle } from "../Canvas/LabelCanvas"; import type { AlignAxis } from "../../lib/alignment"; @@ -35,6 +35,7 @@ export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) { const { selectedIds, updateObject, + groupSelection, label, setLabelConfig, canvasSettings, @@ -47,6 +48,12 @@ export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) { canvasRef.current?.alignSelectionToLabel(axis); if (selectedIds.length > 1) { + // groupSelection only acts on top-level, unlocked items — gate the + // button on whether the current selection has any of those, so we + // don't show a control that would no-op on click. + const canGroup = selectedIds.some((id) => + objects.some((o) => o.id === id && !o.locked), + ); return (

@@ -60,6 +67,16 @@ export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) { {t.properties.x} / {t.properties.y}: {t.properties.multipleSelectedHint}

+ {canGroup && ( + + )}
); diff --git a/src/locales/ar.ts b/src/locales/ar.ts index 059a67b1..d2178429 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -57,6 +57,7 @@ const ar = { lockHint: 'يمنع التحريك وتغيير الحجم والحذف. التحديد يتم من قائمة الطبقات أو بـ Alt+النقر على اللوحة.', multipleSelectedFmt: '{n} عناصر مختارة', multipleSelectedHint: 'استخدم أسهم الاتجاه للتحريك', + groupSelection: 'تجميع التحديد', visualApproxHint: 'العرض المرئي تقريبي؛ الأبعاد تطابق طباعة ZPL', alignCenterH: 'توسيط أفقياً على الملصق', alignCenterV: 'توسيط رأسياً على الملصق', diff --git a/src/locales/bg.ts b/src/locales/bg.ts index 5eb97957..ccd3846e 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -57,6 +57,7 @@ const bg = { lockHint: 'Предотвратява преместване, преоразмеряване и изтриване. Изборът става от панела със слоеве или с Alt+клик върху платното.', multipleSelectedFmt: 'Избрани обекти: {n}', multipleSelectedHint: 'със стрелките местиш', + groupSelection: 'Групирай селекцията', visualApproxHint: 'Визуалното изобразяване е приблизително; размерите съответстват на ZPL отпечатъка', alignCenterH: 'Хоризонтално центриране върху етикета', alignCenterV: 'Вертикално центриране върху етикета', diff --git a/src/locales/cs.ts b/src/locales/cs.ts index d1c0bea9..d56d25b6 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -57,6 +57,7 @@ const cs = { lockHint: 'Brání přesouvání, změně velikosti a smazání. Výběr přes panel vrstev nebo Alt+klikem na plátně.', multipleSelectedFmt: 'Vybráno objektů: {n}', multipleSelectedHint: 'šipkami posunete', + groupSelection: 'Seskupit výběr', visualApproxHint: 'Vizuální zobrazení je přibližné; rozměry odpovídají tisku ZPL', alignCenterH: 'Vystředit vodorovně na štítku', alignCenterV: 'Vystředit svisle na štítku', diff --git a/src/locales/da.ts b/src/locales/da.ts index 1ade98aa..487af003 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -57,6 +57,7 @@ const da = { lockHint: 'Forhindrer flytning, ændring af størrelse og sletning. Markering via Lag-panelet eller Alt+klik på lærredet.', multipleSelectedFmt: '{n} objekter valgt', multipleSelectedHint: 'piletaster flytter', + groupSelection: 'Gruppér valg', visualApproxHint: 'Visuel gengivelse er omtrentlig; dimensionerne svarer til ZPL-udskriften', alignCenterH: 'Centrer vandret på label', alignCenterV: 'Centrer lodret på label', diff --git a/src/locales/de.ts b/src/locales/de.ts index 5a27549f..889943d0 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -57,6 +57,7 @@ const de = { lockHint: 'Blockiert Verschieben, Größenänderung und Löschen. Auswahl per Ebenen-Panel oder Alt+Klick im Canvas.', multipleSelectedFmt: '{n} Objekte ausgewählt', multipleSelectedHint: 'Pfeiltasten zum Verschieben', + groupSelection: 'Auswahl gruppieren', visualApproxHint: 'Visuelle Darstellung näherungsweise; Maße entsprechen dem ZPL-Druck', alignCenterH: 'Horizontal auf Label zentrieren', alignCenterV: 'Vertikal auf Label zentrieren', diff --git a/src/locales/el.ts b/src/locales/el.ts index 237ab7d8..8d6bcaa0 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -57,6 +57,7 @@ const el = { lockHint: 'Αποτρέπει μετακίνηση, αλλαγή μεγέθους και διαγραφή. Επιλογή από το πλαίσιο επιπέδων ή με Alt+κλικ στον καμβά.', multipleSelectedFmt: '{n} αντικείμενα επιλέχθηκαν', multipleSelectedHint: 'τα βέλη μετακινούν', + groupSelection: 'Ομαδοποίηση επιλογής', visualApproxHint: 'Η οπτική απόδοση είναι κατά προσέγγιση· οι διαστάσεις αντιστοιχούν στην εκτύπωση ZPL', alignCenterH: 'Οριζόντιο κεντράρισμα στην ετικέτα', alignCenterV: 'Κατακόρυφο κεντράρισμα στην ετικέτα', diff --git a/src/locales/en.ts b/src/locales/en.ts index 5f8fcfe0..7a5bd0ce 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -57,6 +57,7 @@ const en = { lockHint: 'Prevents moving, resizing and deleting. Select via the Layers panel or Alt+click on the canvas.', multipleSelectedFmt: '{n} objects selected', multipleSelectedHint: 'use arrow keys to move', + groupSelection: 'Group selection', visualApproxHint: 'Visual rendering approximate; dimensions match the ZPL print', alignCenterH: 'Center horizontally on label', alignCenterV: 'Center vertically on label', diff --git a/src/locales/es.ts b/src/locales/es.ts index 7f9abfca..8a85d90f 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -57,6 +57,7 @@ const es = { lockHint: 'Impide mover, redimensionar y eliminar. Selecciona desde el panel Capas o con Alt+clic en el lienzo.', multipleSelectedFmt: '{n} objetos seleccionados', multipleSelectedHint: 'flechas para mover', + groupSelection: 'Agrupar selección', visualApproxHint: 'Renderizado visual aproximado; las dimensiones coinciden con la impresión ZPL', alignCenterH: 'Centrar horizontalmente en la etiqueta', alignCenterV: 'Centrar verticalmente en la etiqueta', diff --git a/src/locales/et.ts b/src/locales/et.ts index cef4cd78..576dd45a 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -57,6 +57,7 @@ const et = { lockHint: 'Takistab liigutamist, suuruse muutmist ja kustutamist. Vali kihtide paanilt või Alt+klõpsuga lõuendil.', multipleSelectedFmt: '{n} objekti valitud', multipleSelectedHint: 'nooltega liigutad', + groupSelection: 'Grupeeri valik', visualApproxHint: 'Visuaalne kuva on ligikaudne; mõõtmed vastavad ZPL-väljatrükile', alignCenterH: 'Keskjoonda sildil rõhtsalt', alignCenterV: 'Keskjoonda sildil püstiselt', diff --git a/src/locales/fa.ts b/src/locales/fa.ts index ae1f53e6..eea43ac6 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -57,6 +57,7 @@ const fa = { lockHint: 'از جابه‌جایی، تغییر اندازه و حذف جلوگیری می‌کند. انتخاب از پنل لایه‌ها یا با Alt+کلیک روی بوم.', multipleSelectedFmt: '{n} مورد انتخاب شده', multipleSelectedHint: 'با کلیدهای جهت‌دار جابه‌جا کنید', + groupSelection: 'گروه‌بندی انتخاب', visualApproxHint: 'نمایش بصری تقریبی است؛ ابعاد با چاپ ZPL مطابقت دارد', alignCenterH: 'وسط‌چین افقی روی برچسب', alignCenterV: 'وسط‌چین عمودی روی برچسب', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index b6b6762d..05c07255 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -57,6 +57,7 @@ const fi = { lockHint: 'Estää siirtämisen, koon muuttamisen ja poistamisen. Valitse Tasot-paneelista tai Alt+napsautuksella kankaalla.', multipleSelectedFmt: '{n} objektia valittu', multipleSelectedHint: 'nuolinäppäimillä siirrät', + groupSelection: 'Ryhmittele valinta', visualApproxHint: 'Visuaalinen esitys on likimääräinen; mitat vastaavat ZPL-tulostetta', alignCenterH: 'Keskitä vaakasuunnassa etikettiin', alignCenterV: 'Keskitä pystysuunnassa etikettiin', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 8f7a98c9..e08f7c4d 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -57,6 +57,7 @@ const fr = { lockHint: 'Empêche le déplacement, le redimensionnement et la suppression. Sélection via le panneau Calques ou Alt+clic sur le canevas.', multipleSelectedFmt: '{n} objets sélectionnés', multipleSelectedHint: 'flèches pour déplacer', + groupSelection: 'Grouper la sélection', visualApproxHint: 'Rendu visuel approximatif ; les dimensions correspondent à l\'impression ZPL', alignCenterH: 'Centrer horizontalement sur l\'étiquette', alignCenterV: 'Centrer verticalement sur l\'étiquette', diff --git a/src/locales/he.ts b/src/locales/he.ts index 70c47079..f53db94f 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -57,6 +57,7 @@ const he = { lockHint: 'מונע הזזה, שינוי גודל ומחיקה. בחירה דרך לוח השכבות או באמצעות Alt+לחיצה על הקנבס.', multipleSelectedFmt: '{n} פריטים נבחרו', multipleSelectedHint: 'מקשי החצים מזיזים', + groupSelection: 'קבץ בחירה', visualApproxHint: 'התצוגה החזותית מקורבת; הממדים תואמים את הדפסת ה-ZPL', alignCenterH: 'מרכז אופקית במדבקה', alignCenterV: 'מרכז אנכית במדבקה', diff --git a/src/locales/hr.ts b/src/locales/hr.ts index 163ae87f..90120fc7 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -57,6 +57,7 @@ const hr = { lockHint: 'Sprječava pomicanje, promjenu veličine i brisanje. Odabir s ploče Slojevi ili Alt+klikom na platnu.', multipleSelectedFmt: 'Odabrano objekata: {n}', multipleSelectedHint: 'strelicama pomičeš', + groupSelection: 'Grupiraj odabir', visualApproxHint: 'Vizualni prikaz je približan; dimenzije odgovaraju ZPL ispisu', alignCenterH: 'Centriraj vodoravno na naljepnici', alignCenterV: 'Centriraj okomito na naljepnici', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index 7e0bbda1..b8015e5f 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -57,6 +57,7 @@ const hu = { lockHint: 'Megakadályozza a mozgatást, átméretezést és törlést. Kijelölés a Rétegek panelről vagy Alt+kattintással a vásznon.', multipleSelectedFmt: '{n} objektum kijelölve', multipleSelectedHint: 'nyilakkal mozgasd', + groupSelection: 'Kijelölés csoportosítása', visualApproxHint: 'A vizuális megjelenítés közelítő; a méretek megegyeznek a ZPL nyomtatással', alignCenterH: 'Vízszintesen középre a címkén', alignCenterV: 'Függőlegesen középre a címkén', diff --git a/src/locales/it.ts b/src/locales/it.ts index 71a6cacf..33a310ef 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -57,6 +57,7 @@ const it = { lockHint: 'Impedisce spostamento, ridimensionamento e cancellazione. Seleziona dal pannello Livelli o con Alt+clic sulla tela.', multipleSelectedFmt: '{n} oggetti selezionati', multipleSelectedHint: 'frecce per spostare', + groupSelection: 'Raggruppa selezione', visualApproxHint: 'Rendering visivo approssimato; le dimensioni corrispondono alla stampa ZPL', alignCenterH: 'Centra orizzontalmente sull\'etichetta', alignCenterV: 'Centra verticalmente sull\'etichetta', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 3083720d..2fd32164 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -57,6 +57,7 @@ const ja = { lockHint: '移動・サイズ変更・削除を防ぎます。レイヤーパネルから、またはキャンバス上で Alt+クリックして選択します。', multipleSelectedFmt: '{n} 個のオブジェクトが選択されました', multipleSelectedHint: '矢印キーで移動', + groupSelection: '選択をグループ化', visualApproxHint: '視覚的表示は概略です。寸法は ZPL 印刷と一致します', alignCenterH: 'ラベル上で水平方向に中央揃え', alignCenterV: 'ラベル上で垂直方向に中央揃え', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index def3ea56..5ff7eb31 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -57,6 +57,7 @@ const ko = { lockHint: '이동, 크기 조정, 삭제를 차단합니다. 레이어 패널 또는 캔버스에서 Alt+클릭으로 선택하세요.', multipleSelectedFmt: '{n}개 항목 선택됨', multipleSelectedHint: '화살표 키로 이동', + groupSelection: '선택 항목 그룹화', visualApproxHint: '시각적 표시는 근사치이며, 치수는 ZPL 인쇄와 일치합니다', alignCenterH: '라벨에서 가로 가운데 정렬', alignCenterV: '라벨에서 세로 가운데 정렬', diff --git a/src/locales/lt.ts b/src/locales/lt.ts index 7b21d26d..0e17b04d 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -57,6 +57,7 @@ const lt = { lockHint: 'Neleidžia perkelti, keisti dydžio ir ištrinti. Pasirinkite iš sluoksnių skydelio arba Alt+spustelėjimu drobėje.', multipleSelectedFmt: 'Pasirinkta objektų: {n}', multipleSelectedHint: 'rodyklėmis perkeli', + groupSelection: 'Grupuoti pasirinkimą', visualApproxHint: 'Vaizdavimas apytikslis; matmenys atitinka ZPL spaudinį', alignCenterH: 'Centruoti horizontaliai etiketėje', alignCenterV: 'Centruoti vertikaliai etiketėje', diff --git a/src/locales/lv.ts b/src/locales/lv.ts index d1b5d73d..760f61b5 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -57,6 +57,7 @@ const lv = { lockHint: 'Neļauj pārvietot, mainīt izmēru un dzēst. Atlasiet no slāņu paneļa vai ar Alt+klikšķi audeklā.', multipleSelectedFmt: 'Atlasīti {n} objekti', multipleSelectedHint: 'ar bultiņām pārvietot', + groupSelection: 'Grupēt atlasi', visualApproxHint: 'Vizuālais attēlojums ir aptuvens; izmēri atbilst ZPL izdrukai', alignCenterH: 'Centrēt horizontāli uz etiķetes', alignCenterV: 'Centrēt vertikāli uz etiķetes', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 79eca485..f6d3ca05 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -57,6 +57,7 @@ const nl = { lockHint: 'Voorkomt verplaatsen, vergroten/verkleinen en verwijderen. Selecteer via het Lagen-paneel of Alt+klik op het canvas.', multipleSelectedFmt: '{n} objecten geselecteerd', multipleSelectedHint: 'pijltoetsen om te verplaatsen', + groupSelection: 'Selectie groeperen', visualApproxHint: 'Visuele weergave bij benadering; afmetingen komen overeen met de ZPL-afdruk', alignCenterH: 'Horizontaal op label centreren', alignCenterV: 'Verticaal op label centreren', diff --git a/src/locales/no.ts b/src/locales/no.ts index 65d417a4..07a8f74b 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -57,6 +57,7 @@ const no = { lockHint: 'Hindrer flytting, størrelsesendring og sletting. Velg fra Lag-panelet eller med Alt+klikk på lerretet.', multipleSelectedFmt: '{n} objekter valgt', multipleSelectedHint: 'piltaster flytter', + groupSelection: 'Grupper utvalg', visualApproxHint: 'Visuell gjengivelse er omtrentlig; dimensjonene samsvarer med ZPL-utskriften', alignCenterH: 'Sentrer horisontalt på etiketten', alignCenterV: 'Sentrer vertikalt på etiketten', diff --git a/src/locales/pl.ts b/src/locales/pl.ts index 686cab52..cfcf7ade 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -57,6 +57,7 @@ const pl = { lockHint: 'Blokuje przesuwanie, zmianę rozmiaru i usuwanie. Wybór z panelu Warstwy lub Alt+kliknięciem na płótnie.', multipleSelectedFmt: 'Wybrano obiektów: {n}', multipleSelectedHint: 'strzałki przesuwają', + groupSelection: 'Grupuj zaznaczenie', visualApproxHint: 'Renderowanie wizualne jest przybliżone; wymiary odpowiadają wydrukowi ZPL', alignCenterH: 'Wyśrodkuj poziomo na etykiecie', alignCenterV: 'Wyśrodkuj pionowo na etykiecie', diff --git a/src/locales/pt.ts b/src/locales/pt.ts index ab02892e..9fa2abbb 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -57,6 +57,7 @@ const pt = { lockHint: 'Impede mover, redimensionar e excluir. Selecione no painel Camadas ou com Alt+clique no canvas.', multipleSelectedFmt: '{n} objetos selecionados', multipleSelectedHint: 'setas para mover', + groupSelection: 'Agrupar seleção', visualApproxHint: 'Renderização visual aproximada; as dimensões correspondem à impressão ZPL', alignCenterH: 'Centralizar horizontalmente no rótulo', alignCenterV: 'Centralizar verticalmente no rótulo', diff --git a/src/locales/ro.ts b/src/locales/ro.ts index 7043e5d7..e0ac1857 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -57,6 +57,7 @@ const ro = { lockHint: 'Împiedică mutarea, redimensionarea și ștergerea. Selectează din panoul Straturi sau cu Alt+clic pe pânză.', multipleSelectedFmt: '{n} obiecte selectate', multipleSelectedHint: 'săgeți pentru mutare', + groupSelection: 'Grupează selecția', visualApproxHint: 'Randarea vizuală este aproximativă; dimensiunile corespund tipăririi ZPL', alignCenterH: 'Centrează orizontal pe etichetă', alignCenterV: 'Centrează vertical pe etichetă', diff --git a/src/locales/sk.ts b/src/locales/sk.ts index cbe5ed84..f1dc804c 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -57,6 +57,7 @@ const sk = { lockHint: 'Bráni presúvaniu, zmene veľkosti a odstráneniu. Výber z panela Vrstvy alebo Alt+klikom na plátno.', multipleSelectedFmt: 'Vybraných objektov: {n}', multipleSelectedHint: 'šípkami posuniete', + groupSelection: 'Zoskupiť výber', visualApproxHint: 'Vizuálne zobrazenie je približné; rozmery zodpovedajú tlači ZPL', alignCenterH: 'Vycentrovať vodorovne na štítku', alignCenterV: 'Vycentrovať zvislo na štítku', diff --git a/src/locales/sl.ts b/src/locales/sl.ts index e990c8b9..5e8e0c1a 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -57,6 +57,7 @@ const sl = { lockHint: 'Onemogoča premikanje, spreminjanje velikosti in brisanje. Izberite s plošče Sloji ali z Alt+klikom na platnu.', multipleSelectedFmt: 'Izbranih objektov: {n}', multipleSelectedHint: 's puščicami premikaš', + groupSelection: 'Združi izbor', visualApproxHint: 'Vizualni prikaz je približen; mere se ujemajo s tiskom ZPL', alignCenterH: 'Vodoravno sredinsko poravnaj na etiketi', alignCenterV: 'Navpično sredinsko poravnaj na etiketi', diff --git a/src/locales/sr.ts b/src/locales/sr.ts index d6611a16..5f6fd929 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -57,6 +57,7 @@ const sr = { lockHint: 'Спречава померање, промену величине и брисање. Избор преко панела Слојеви или Alt+кликом на платну.', multipleSelectedFmt: 'Изабрано објеката: {n}', multipleSelectedHint: 'стрелицама померај', + groupSelection: 'Grupiši izbor', visualApproxHint: 'Визуелни приказ је приближан; димензије одговарају ZPL отиску', alignCenterH: 'Хоризонтално центрирање на етикети', alignCenterV: 'Вертикално центрирање на етикети', diff --git a/src/locales/sv.ts b/src/locales/sv.ts index c00a9061..c6a891b6 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -57,6 +57,7 @@ const sv = { lockHint: 'Hindrar flytt, storleksändring och borttagning. Markera via Lager-panelen eller Alt+klick på arbetsytan.', multipleSelectedFmt: '{n} objekt markerade', multipleSelectedHint: 'pilar för att flytta', + groupSelection: 'Gruppera markering', visualApproxHint: 'Visuell återgivning är ungefärlig; måtten matchar ZPL-utskriften', alignCenterH: 'Centrera horisontellt på etikett', alignCenterV: 'Centrera vertikalt på etikett', diff --git a/src/locales/tr.ts b/src/locales/tr.ts index 30fb94c7..48784174 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -57,6 +57,7 @@ const tr = { lockHint: 'Taşıma, yeniden boyutlandırma ve silmeyi engeller. Katmanlar panelinden veya tuvalde Alt+tıklama ile seçin.', multipleSelectedFmt: '{n} nesne seçildi', multipleSelectedHint: 'oklarla taşı', + groupSelection: 'Seçimi grupla', visualApproxHint: 'Görsel render yaklaşıktır; boyutlar ZPL çıktısıyla eşleşir', alignCenterH: 'Etikette yatay ortala', alignCenterV: 'Etikette dikey ortala', diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index d476c679..f133a0b2 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -57,6 +57,7 @@ const zhHans = { lockHint: '禁止移动、缩放与删除。可在“图层”面板中选择,或在画布上 Alt+点击。', multipleSelectedFmt: '已选择 {n} 个对象', multipleSelectedHint: '方向键移动', + groupSelection: '组合所选', visualApproxHint: '视觉渲染为近似值;尺寸与 ZPL 打印输出一致', alignCenterH: '在标签上水平居中', alignCenterV: '在标签上垂直居中', diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index de002483..46930d2e 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -57,6 +57,7 @@ const zhHant = { lockHint: '禁止移動、調整大小與刪除。可從「圖層」面板選取,或在畫布上 Alt+點擊。', multipleSelectedFmt: '已選擇 {n} 個物件', multipleSelectedHint: '方向鍵移動', + groupSelection: '群組所選', visualApproxHint: '視覺呈現為近似值;尺寸與 ZPL 列印輸出一致', alignCenterH: '於標籤水平置中', alignCenterV: '於標籤垂直置中', From 1c3567744b2adb9f0500f8052d71945e2bd0b64e Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 15 May 2026 00:40:57 +0200 Subject: [PATCH 30/37] fix(groups): drop the orphan separator under PropertiesPanel when no TypePanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two border-t separators bracketed the per-type TypePanel slot. With no registry entry for groups TypePanel rendered nothing, so the two separators landed flush against each other — two horizontal lines back to back with empty space neither above nor between. Tucked the trailing separator inside the TypePanel conditional so the line only appears when there's a type-specific section to close off. --- src/components/Properties/PropertiesPanel.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/Properties/PropertiesPanel.tsx b/src/components/Properties/PropertiesPanel.tsx index 04c60a51..459f8b83 100644 --- a/src/components/Properties/PropertiesPanel.tsx +++ b/src/components/Properties/PropertiesPanel.tsx @@ -189,14 +189,15 @@ export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) {
{TypePanel && ( - updateObject(obj.id, { props })} - /> + <> + updateObject(obj.id, { props })} + /> +
+ )} -
- {/* Comment (^FX) */}
From 8dd10246b58dad3fc2e462214465477ca73a75c9 Mon Sep 17 00:00:00 2001 From: u8array Date: Fri, 15 May 2026 00:47:55 +0200 Subject: [PATCH 31/37] refactor(groups): tree-walk lookup in PropertiesPanel and shared canGroupSelection Three follow-ups from the latest validation pass: - PropertiesPanel resolves selectedIds[0] via findObjectById instead of a top-level Array.find. When the layers panel drills into a child the selection holds a leaf id that isn't in the top-level list; the old lookup missed it and the panel silently fell back to LabelConfigPanel. Tree walk now matches. - canGroupSelection(objects, selectedIds) moves into types/Group as the single home for the 'is there anything groupRow would act on' predicate. Both the layers-panel header button and the multi-select properties button consume the same function instead of inlining the same .some/.some pair. - The group properties view hides the inputs that don't apply: x/y (a group's own coordinates are conventionally zero and don't drive children) and the ^FX comment (groups emit no ZPL). Align stays since it expands through the group at the canvas layer. Type-string equality checks switched to isGroup for consistency with the rest of the codebase. --- src/components/Properties/LayersPanel.tsx | 7 +- src/components/Properties/PropertiesPanel.tsx | 139 ++++++++++-------- src/types/Group.test.ts | 26 ++++ src/types/Group.ts | 15 ++ 4 files changed, 120 insertions(+), 67 deletions(-) diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index 75f0e95d..0e491939 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -3,7 +3,7 @@ import { DndContext } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { FolderPlusIcon } from '@heroicons/react/16/solid'; import { useLabelStore, useCurrentObjects } from '../../store/labelStore'; -import { findObjectById, isGroup, walkObjects } from '../../types/Group'; +import { canGroupSelection, findObjectById, isGroup, walkObjects } from '../../types/Group'; import { useT } from '../../lib/useT'; import { buildBulkToggleUpdates, type ToggleField } from '../../lib/bulkToggle'; import { buildFlatRows, useLayerDnd, type FlatRow } from './useLayerDnd'; @@ -68,11 +68,8 @@ export function LayersPanel() { // selection (matches the Ctrl+G shortcut), fall back to creating an // empty group at the top so the affordance is also useful before // any items exist or have been selected. - const hasTopLevelGroupable = selectedIds.some((id) => - objects.some((o) => o.id === id && !o.locked), - ); const onNewGroup = () => { - if (hasTopLevelGroupable) groupSelection(); + if (canGroupSelection(objects, selectedIds)) groupSelection(); else addGroup(); }; diff --git a/src/components/Properties/PropertiesPanel.tsx b/src/components/Properties/PropertiesPanel.tsx index 459f8b83..57d80c7a 100644 --- a/src/components/Properties/PropertiesPanel.tsx +++ b/src/components/Properties/PropertiesPanel.tsx @@ -4,6 +4,7 @@ import { useLabelStore, useCurrentObjects } from "../../store/labelStore"; import type { LabelCanvasHandle } from "../Canvas/LabelCanvas"; import type { AlignAxis } from "../../lib/alignment"; import { ObjectRegistry } from "../../registry"; +import { canGroupSelection, findObjectById, isGroup } from "../../types/Group"; import { BWIP_VISUAL_APPROX_TYPES } from "../Canvas/bwipConstants"; import { stripZplCommandChars } from "../../registry/zplHelpers"; import { dotsToMm, mmToDots } from "../../lib/coordinates"; @@ -43,17 +44,17 @@ export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) { } = useLabelStore(); const objects = useCurrentObjects(); const unit = canvasSettings.unit; - const obj = objects.find((o) => o.id === selectedIds[0]); + // Walk the tree: when the layers panel drills into a nested child, the + // selection holds a leaf id that's not at top level. A plain + // top-level .find would miss it and the panel would silently fall + // through to LabelConfigPanel. + const firstId = selectedIds[0]; + const obj = firstId !== undefined ? findObjectById(objects, firstId) : undefined; const handleAlign = (axis: AlignAxis) => canvasRef.current?.alignSelectionToLabel(axis); if (selectedIds.length > 1) { - // groupSelection only acts on top-level, unlocked items — gate the - // button on whether the current selection has any of those, so we - // don't show a control that would no-op on click. - const canGroup = selectedIds.some((id) => - objects.some((o) => o.id === id && !o.locked), - ); + const canGroup = canGroupSelection(objects, selectedIds); return (
@@ -97,9 +98,13 @@ export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) { const definition = ObjectRegistry[obj.type]; const TypePanel = definition?.PropertiesPanel; + const groupRow = isGroup(obj); // Groups intentionally have no registry entry; surface a folder-shape // glyph here so the header reads as something rather than blank. - const icon = obj.type === 'group' ? '⊞' : definition?.icon; + const icon = groupRow ? '⊞' : definition?.icon; + const typeLabel = groupRow + ? t.types.group + : (t.types as Record)[obj.type] ?? definition?.label; return (
@@ -109,7 +114,7 @@ export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) { {icon} - {(t.types as Record)[obj.type] ?? definition?.label} + {typeLabel} {BWIP_VISUAL_APPROX_TYPES.has(obj.type) && ( )} - {/* Position */} + {/* Position: groups have no meaningful x/y of their own (children + store world coordinates), so the inputs are hidden. Align + still applies — it expands to the group's leaves at the + canvas layer. */}
-

- {t.properties.positionSection} ({unitLabel(unit)}) -

-
-
- - - updateObject(obj.id, { - x: mmToDots( - unitToMm(Number(e.target.value), unit), - label.dpmm, - ), - }) - } - /> -
-
- - - updateObject(obj.id, { - y: mmToDots( - unitToMm(Number(e.target.value), unit), - label.dpmm, - ), - }) - } - /> -
-
+ {!groupRow && ( + <> +

+ {t.properties.positionSection} ({unitLabel(unit)}) +

+
+
+ + + updateObject(obj.id, { + x: mmToDots( + unitToMm(Number(e.target.value), unit), + label.dpmm, + ), + }) + } + /> +
+
+ + + updateObject(obj.id, { + y: mmToDots( + unitToMm(Number(e.target.value), unit), + label.dpmm, + ), + }) + } + /> +
+
+ + )}
@@ -198,18 +210,21 @@ export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) { )} - {/* Comment (^FX) */} -
- -