diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 06c32af0..9459a055 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, 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"; @@ -25,7 +26,7 @@ import { Grid } from "./Grid"; import { GuideLines } from "./GuideLines"; import { Ruler, RULER_SIZE } from "./Ruler"; import { ObjectRegistry } from "../../registry"; -import type { LabelObject } from "../../registry"; +import type { LabelObject, LeafObject } from "../../registry"; import { useColorScheme } from "../../lib/useColorScheme"; import { objectIdsAtPoint } from "./hitTesting"; import { useT } from "../../lib/useT"; @@ -93,7 +94,7 @@ export const LabelCanvas = forwardRef(function LabelCa const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); const rotateView = () => onViewRotationChange(nextRotation(viewRotation)); const [guides, setGuides] = useState([]); - const [ghost, setGhost] = useState(null); + const [ghost, setGhost] = useState(null); // Raw pointer position tracked independently of @dnd-kit's scroll-adjusted delta. // activatorEvent.client + event.delta includes scroll momentum from the palette @@ -122,6 +123,42 @@ 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. 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: LeafObject[] = []; + const walk = (nodes: LabelObject[], inheritedLocked: boolean, inheritedHidden: boolean) => { + for (const n of nodes) { + 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 LeafObject) : n); + } + } + }; + walk(objects, false, false); + 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; @@ -180,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 } }]; }), @@ -320,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 }); @@ -348,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 } }, @@ -371,8 +416,8 @@ export const LabelCanvas = forwardRef(function LabelCa } = useKonvaTransformer({ transformerRef, stageRef, - selectedIds, - objects, + selectedIds: attachableIds, + objects: allLeaves, scale, dpmm: label.dpmm, objectsOffsetX, @@ -447,15 +492,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; @@ -464,7 +512,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 } }] : []; @@ -484,7 +532,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; @@ -492,7 +540,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; @@ -608,7 +656,7 @@ export const LabelCanvas = forwardRef(function LabelCa if (!type) return; const def = ObjectRegistry[type]; if (!def) return; - setGhost({ id: "__ghost__", type, ...pos, rotation: 0, props: def.defaultProps } as LabelObject); + setGhost({ id: "__ghost__", type, ...pos, rotation: 0, props: def.defaultProps } as LeafObject); }, onDragEnd(event) { setGhost(null); @@ -771,7 +819,7 @@ export const LabelCanvas = forwardRef(function LabelCa /> )} - {objects.map((obj) => obj.visible === false ? null : ( + {visibleLeaves.map((obj) => ( (function LabelCa dpmm={label.dpmm} offsetX={objectsOffsetX} offsetY={labelOffsetY} - isSelected={selectedIds.includes(obj.id)} + isSelected={attachableIds.includes(obj.id)} onSelect={(add) => { - if (obj.locked) handleLockedClick(add); - else if (add) toggleSelectObject(obj.id); - else selectObject(obj.id); + // 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); + // 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); }} onChange={(changes) => handleObjectChange(obj.id, changes)} snap={snap} diff --git a/src/components/Canvas/bwipHelpers.test.ts b/src/components/Canvas/bwipHelpers.test.ts index 4f28a702..99f507d3 100644 --- a/src/components/Canvas/bwipHelpers.test.ts +++ b/src/components/Canvas/bwipHelpers.test.ts @@ -3,7 +3,8 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { buildBwipOptions, getDisplaySize, getEanUpcLayout, parseZplCode128Escapes } from "./bwipHelpers"; -import type { LabelObject } from "../../registry"; +import type { LeafObject } from "../../registry"; +type LabelObject = LeafObject; describe("getEanUpcLayout", () => { // bwip-js native canvas widths (no quiet zones, scale=2): diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index daeafe83..f38d705b 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -12,7 +12,7 @@ * bwipHelpers.test.ts ensures every BCID-registered type has a case. */ -import type { LabelObject } from "../../registry"; +import type { LabelObject, LeafObject } from "../../registry"; import type { Gs1DatabarProps } from "../../registry/gs1databar"; import { objectRotation } from "../../registry/rotation"; import { dotsToPx } from "../../lib/coordinates"; @@ -290,7 +290,7 @@ export function parseZplCode128Escapes(text: string): string | null { } export function buildBwipOptions( - obj: LabelObject, + obj: LeafObject, renderScale?: number, renderDpmm?: number, ): Record | null { @@ -552,7 +552,7 @@ const TEXT_ZONE_DOTS_BY_TYPE: Partial> = { }; export function getDisplaySize( - obj: LabelObject, + obj: LeafObject, canvas: HTMLCanvasElement, scale: number, dpmm: number, @@ -637,7 +637,7 @@ export function getDisplaySize( } function getUprightDisplaySize( - obj: LabelObject, + obj: LeafObject, cw: number, ch: number, scale: number, diff --git a/src/components/Canvas/hooks/useCanvasLasso.ts b/src/components/Canvas/hooks/useCanvasLasso.ts index ea25461e..062849c1 100644 --- a/src/components/Canvas/hooks/useCanvasLasso.ts +++ b/src/components/Canvas/hooks/useCanvasLasso.ts @@ -1,6 +1,8 @@ import { useState, useRef } from "react"; import type Konva from "konva"; import { getCurrentObjects } from "../../../store/labelStore"; +import type { LabelObject } from "../../../registry"; +import { isGroup } from "../../../types/Group"; import { getIdsIntersectingRect, type LassoRect } from "../lassoGeometry"; interface Options { @@ -62,8 +64,37 @@ 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. + // + // Single-pass walk: collect unlocked leaf ids and the topmost group + // each leaf belongs to (or the leaf itself if at top level) so the + // hit→selection promote below is a Map lookup instead of an O(tree) + // ancestor walk per hit. Lock cascades from the top-level container. + const objects = getCurrentObjects(); + const leafIds: string[] = []; + const selectionTarget = new Map(); + const walk = ( + nodes: LabelObject[], + inheritedLocked: boolean, + topAncestorId: string | null, + ) => { + for (const n of nodes) { + const locked = inheritedLocked || !!n.locked; + const ancestor = topAncestorId ?? n.id; + if (isGroup(n)) { + walk(n.children, locked, ancestor); + } else if (!locked) { + leafIds.push(n.id); + selectionTarget.set(n.id, ancestor); + } + } + }; + walk(objects, false, null); + const hits = getIdsIntersectingRect(stageRef.current, leafIds, rect); + const promoted = new Set(hits.map((id) => selectionTarget.get(id) ?? 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..e0c7f7fb 100644 --- a/src/components/Canvas/hooks/useKonvaTransformer.ts +++ b/src/components/Canvas/hooks/useKonvaTransformer.ts @@ -3,8 +3,9 @@ import type Konva from "konva"; import { pxToDots } from "../../../lib/coordinates"; import { getCurrentObjects } from "../../../store/labelStore"; import { BARCODE_1D_TYPES, STACKED_2D_TYPES, ObjectRegistry } from "../../../registry"; -import type { LabelObject } from "../../../registry"; +import type { LeafObject } from "../../../registry"; import type { ObjectChanges } from "../../../store/labelStore"; +import { findObjectById, isGroup } from "../../../types/Group"; import { applyHeightSnap, pinInactiveEdges, @@ -37,7 +38,7 @@ function toSnapRect(id: string, rect: { x: number; y: number; width: number; hei */ function captureOtherRects( stage: Konva.Stage, - objects: LabelObject[], + objects: LeafObject[], excludeId: string, ): SnapRect[] { const result: SnapRect[] = []; @@ -75,7 +76,7 @@ interface Options { transformerRef: React.RefObject; stageRef: React.RefObject; selectedIds: string[]; - objects: LabelObject[]; + objects: LeafObject[]; scale: number; dpmm: number; objectsOffsetX: number; @@ -165,7 +166,7 @@ export function useKonvaTransformer({ } else { const nodes = selectedIds .map((id) => objects.find((o) => o.id === id)) - .filter((o): o is LabelObject => !!o && o.type !== "line" && !o.locked) + .filter((o): o is LeafObject => !!o && o.type !== "line" && !o.locked) .map((o) => stageRef.current?.findOne(`#${o.id}`)) .filter((n): n is Konva.Node => n != null); transformerRef.current.nodes(nodes); @@ -303,8 +304,8 @@ export function useKonvaTransformer({ const nodeHeight = node.height(); node.scaleX(1); node.scaleY(1); - const obj = getCurrentObjects().find((o) => o.id === singleId); - if (!obj) { + const obj = findObjectById(getCurrentObjects(), singleId); + if (!obj || isGroup(obj)) { cleanupTransformState(); return; } diff --git a/src/components/Canvas/konvaObjectProps.ts b/src/components/Canvas/konvaObjectProps.ts index 9a126d6c..b3b43a52 100644 --- a/src/components/Canvas/konvaObjectProps.ts +++ b/src/components/Canvas/konvaObjectProps.ts @@ -1,5 +1,5 @@ import type Konva from "konva"; -import type { LabelObject } from "../../registry"; +import type { LeafObject } from "../../registry"; import type { ObjectChanges } from "../../store/labelStore"; import type { SnapGuide, SnapRect } from "../../lib/snapGuides"; @@ -22,13 +22,12 @@ export function selectionHandlers(onSelect: (add: boolean) => void): { } /** Shared props for the per-type renderers under KonvaObject (LineObject, - * ImageObject, BarcodeObject, KonvaObjectInner). LineObject and - * ImageObject re-narrow `obj` at the type level via `Omit & { obj: ... }` - * and the dispatcher passes the narrowed value explicitly; BarcodeObject - * and KonvaObjectInner currently take the wide LabelObject and narrow - * internally. */ + * ImageObject, BarcodeObject, KonvaObjectInner). Per-type renderers + * always receive a leaf — groups have no Konva counterpart and the + * dispatcher in LabelCanvas filters them out before mapping. Typed as + * LeafObject here so the renderers can reach .props without narrowing. */ export interface KonvaObjectProps { - obj: LabelObject; + obj: LeafObject; scale: number; dpmm: number; offsetX: number; diff --git a/src/components/Properties/LayerRow.tsx b/src/components/Properties/LayerRow.tsx new file mode 100644 index 00000000..b6b33494 --- /dev/null +++ b/src/components/Properties/LayerRow.tsx @@ -0,0 +1,251 @@ +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 './layerLayout'; + +export interface LayerRowProps { + obj: LabelObject; + 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; + /** 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. */ + 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, + isInSelectedGroup, + isExpanded, + isDropTarget, + showInsertionLine, + insertionLineDepth, + isContainerEnd, + 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); + // Cancellation flag so the blur that fires when the input unmounts + // on Escape doesn't sneak through commitEdit and persist the draft + // the user wanted to discard. + const cancellingRef = useRef(false); + + useEffect(() => { + if (editing) inputRef.current?.select(); + }, [editing]); + + const beginEdit = () => { + cancellingRef.current = false; + setDraft(obj.name ?? ''); + setEditing(true); + }; + const commitEdit = () => { + if (cancellingRef.current) return; + const trimmed = draft.trim(); + if ((obj.name ?? '') !== trimmed) onRename(trimmed || undefined); + setEditing(false); + }; + const cancelEdit = () => { + cancellingRef.current = true; + 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({ + 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 ( + <> +
+
{ + if (e.shiftKey || e.ctrlKey || e.metaKey) onToggle(); + else onSelect(); + }} + className={` + 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' : ''} + ${isContainerEnd ? 'mb-1' : ''} + `} + > + {/* 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) => ( + + ))} +
+ + {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} + > + {labelText} + + )} + {obj.id.slice(0, 8)} +
+ {groupRow && ( + + )} + + +
+ + ); +} diff --git a/src/components/Properties/LayersPanel.tsx b/src/components/Properties/LayersPanel.tsx index c30d3e08..0e491939 100644 --- a/src/components/Properties/LayersPanel.tsx +++ b/src/components/Properties/LayersPanel.tsx @@ -1,184 +1,147 @@ -import { useState } from 'react'; -import { - DndContext, - PointerSensor, - closestCenter, - useSensor, - useSensors, -} from '@dnd-kit/core'; -import type { DragEndEvent, DragOverEvent } from '@dnd-kit/core'; -import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { EyeIcon, EyeSlashIcon, LockClosedIcon, LockOpenIcon } from '@heroicons/react/16/solid'; +import { useMemo, useState } from 'react'; +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 { ObjectRegistry } from '../../registry'; -import type { LabelObject } from '../../registry'; +import { canGroupSelection, findObjectById, isGroup, walkObjects } from '../../types/Group'; import { useT } from '../../lib/useT'; import { buildBulkToggleUpdates, type ToggleField } from '../../lib/bulkToggle'; -import { DragHandleIcon } from '../ui/DragHandleIcon'; - -interface RowProps { - obj: LabelObject; - isSelected: boolean; - isOver: boolean; - onSelect: () => void; - onToggle: () => void; - onToggleLock: () => void; - onToggleVisible: () => void; - tLock: string; - tUnlock: string; - tShow: string; - tHide: string; -} - -function SortableLayerRow({ - obj, - isSelected, - isOver, - onSelect, - onToggle, - onToggleLock, - onToggleVisible, - tLock, - tUnlock, - tShow, - tHide, -}: RowProps) { - const def = ObjectRegistry[obj.type]; - const isLocked = !!obj.locked; - const isHidden = obj.visible === false; - // Locked rows opt out of @dnd-kit's sortable listeners so the drag handle - // can't reorder them; the row stays clickable for selection / toggles. - const { attributes, listeners, setNodeRef, isDragging } = useSortable({ - id: obj.id, - disabled: isLocked, - }); - - const stopRowClick = (e: React.MouseEvent) => e.stopPropagation(); - - return ( - <> -
-
{ - 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' : ''} - `} - > - - - {def?.icon} - -
- {def?.label ?? obj.type} - {obj.id.slice(0, 8)} -
- - -
- - ); -} +import { buildFlatRows, useLayerDnd, type FlatRow } from './useLayerDnd'; +import { LayerRow } from './LayerRow'; export function LayersPanel() { const t = useT(); - const { selectedIds, selectObject, toggleSelectObject, reorderObject, updateObjects } = useLabelStore(); + const { + selectedIds, + selectObject, + toggleSelectObject, + updateObject, + updateObjects, + groupSelection, + addGroup, + ungroupIds, + reparentObject, + } = useLabelStore(); const objects = useCurrentObjects(); - const [overId, setOverId] = useState(null); - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), - ); + const [expandedIds, setExpandedIds] = useState>(new Set()); - const toggleField = (clickedId: string, field: ToggleField) => { - const updates = buildBulkToggleUpdates(objects, selectedIds, clickedId, field); - if (updates.length > 0) updateObjects(updates); - }; + 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 allRowIds = useMemo(() => rows.map((r) => r.obj.id), [rows]); - if (objects.length === 0) { - return ( -
- {t.layers.empty} -
- ); - } + // 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]); - // Reverse so topmost layer (last in array = front) appears first - const reversed = [...objects].reverse(); - const n = objects.length; + const { + sensors, + collisionDetection, + panelRef, + onDragStart, + onDragOver, + onDragEnd, + onDragCancel, + preview, + } = useLayerDnd({ objects, rowsById, expandedIds, reparentObject }); - const handleDragOver = ({ over }: DragOverEvent) => - setOverId((over?.id as string) ?? null); + const toggleField = (clickedId: string, field: ToggleField) => { + const updates = buildBulkToggleUpdates(allNodes, selectedIds, clickedId, field); + if (updates.length > 0) updateObjects(updates); + }; - const handleDragEnd = ({ active, over }: DragEndEvent) => { - setOverId(null); - if (!over || active.id === over.id) return; - const toVisualIndex = reversed.findIndex((o) => o.id === over.id); - reorderObject(active.id as string, n - 1 - toVisualIndex); + // 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 onNewGroup = () => { + if (canGroupSelection(objects, selectedIds)) groupSelection(); + else addGroup(); }; - const handleDragCancel = () => setOverId(null); + const toggleExpand = (id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; 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} - /> - ))} +
+ +
+ {objects.length === 0 ? ( +
+ {t.layers.empty}
- + ) : ( + +
+ {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')} + onToggleVisible={() => toggleField(obj.id, 'visible')} + onToggleExpand={() => toggleExpand(obj.id)} + onUngroup={() => ungroupIds([obj.id])} + onRename={(name) => updateObject(obj.id, { name })} + /> + ))} +
+
+ )} ); } diff --git a/src/components/Properties/PropertiesPanel.tsx b/src/components/Properties/PropertiesPanel.tsx index 81f83d03..993e08d0 100644 --- a/src/components/Properties/PropertiesPanel.tsx +++ b/src/components/Properties/PropertiesPanel.tsx @@ -1,9 +1,10 @@ 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"; 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"; @@ -35,6 +36,7 @@ export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) { const { selectedIds, updateObject, + groupSelection, label, setLabelConfig, canvasSettings, @@ -42,11 +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) { + const canGroup = canGroupSelection(objects, selectedIds); return (
@@ -60,6 +68,16 @@ export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) { {t.properties.x} / {t.properties.y}: {t.properties.multipleSelectedHint}

+ {canGroup && ( + + )}
); @@ -80,16 +98,23 @@ 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 = groupRow ? '⊞' : definition?.icon; + const typeLabel = groupRow + ? t.types.group + : (t.types as Record)[obj.type] ?? definition?.label; return (
{/* Type header */}
- {definition?.icon} + {icon} - {(t.types as Record)[obj.type] ?? definition?.label} + {typeLabel} {BWIP_VISUAL_APPROX_TYPES.has(obj.type) && (
- {/* Position */} -
-

- {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, - ), - }) - } - /> -
+ {/* 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. */} + {groupRow && ( +
+ + + updateObject(obj.id, { name: e.target.value || undefined }) + } + />
+ )} + + {/* 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. */} +
+ {!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, + ), + }) + } + /> +
+
+ + )}
- {TypePanel && ( - updateObject(obj.id, { props })} - /> + {/* Per-type panel: only leaves have a registry entry, so TypePanel + is never present for groups. The isGroup guard narrows obj for + TypeScript at the call site since registry panels expect the + leaf shape (props field present). */} + {TypePanel && !groupRow && ( + <> + updateObject(obj.id, { props })} + /> +
+ )} -
- - {/* Comment (^FX) */} -
- -