diff --git a/packages/core/src/types/Group.ts b/packages/core/src/types/Group.ts index 882b993d..13d3a4a2 100644 --- a/packages/core/src/types/Group.ts +++ b/packages/core/src/types/Group.ts @@ -136,6 +136,22 @@ export function hasLockedAncestor(objects: LabelObject[], id: string): boolean { return findAncestors(objects, id).some((g) => !!g.locked); } +/** Carries the cascaded group lock, so one list serves snap, select and + * transform and none of them can target what is not drawn. */ +export function visibleLeaves(objects: readonly LabelObject[]): LeafObject[] { + const out: LeafObject[] = []; + const walk = (nodes: readonly LabelObject[], inheritedLocked: boolean) => { + for (const n of nodes) { + if (n.visible === false) continue; + const locked = inheritedLocked || !!n.locked; + if (isGroup(n)) walk(n.children, locked); + else out.push(locked && !n.locked ? { ...n, locked: true } : n); + } + }; + walk(objects, false); + return out; +} + /** Identity-preserving: unaffected subtrees keep their original references. */ export function mapObjectById( objects: LabelObject[], diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 30f55189..dd97dc38 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -15,7 +15,7 @@ import { paletteGhostHandlers } from "./paletteGhostMonitor"; import { Stage, Layer, Group, Image as KImage, Rect, Transformer } from "react-konva"; import type Konva from "konva"; import { useLabelStore, currentObjects, currentPageLabel, getCurrentObjects, selectEditorFrozen, selectPreviewLocksEditor, selectRenderPageLabel, selectRenderObjects, selectRenderColumnMapping, selectRenderDataset } from "../../store/labelStore"; -import { isGroup, getAllLeaves, exportableLeaves, expandSelection, selectionTargetId, findObjectById, canDeleteSelection, canGroupSelection, canUngroupSelection, hasLockedAncestor, isSelectionLocked, type LabelObject } from "@zplab/core/types/Group"; +import { isGroup, getAllLeaves, exportableLeaves, expandSelection, selectionTargetId, findObjectById, canDeleteSelection, canGroupSelection, canUngroupSelection, hasLockedAncestor, isSelectionLocked, visibleLeaves, type LabelObject } from "@zplab/core/types/Group"; import { pxToDots, dotsToPx, mmToDots, SCREEN_PX_PER_MM } from "@zplab/core/lib/coordinates"; import { effectiveDpmm } from "@zplab/core/types/LabelConfig"; import { loadImage } from "@zplab/core/lib/loadImage"; @@ -343,23 +343,7 @@ export const LabelCanvas = forwardRef(function LabelCa }; }, []); - // Leaves only; group lock/visible cascades down so per-leaf checks see one value. - const visibleLeaves = useMemo(() => { - const out: LeafObject[] = []; - const walk = (nodes: LabelObject[], inheritedLocked: boolean) => { - for (const n of nodes) { - if (n.visible === false) continue; - const locked = inheritedLocked || !!n.locked; - if (isGroup(n)) { - walk(n.children, locked); - } else { - out.push(locked && !n.locked ? ({ ...n, locked: true } as LeafObject) : n); - } - } - }; - walk(objects, false); - return out; - }, [objects]); + const renderedLeaves = useMemo(() => visibleLeaves(objects), [objects]); // Expand selection so group-click feels like Figma multi-drag. const attachableIds = useMemo( @@ -585,7 +569,7 @@ export const LabelCanvas = forwardRef(function LabelCa const stage = stageRef.current; if (!stage) return []; const rects = []; - for (const o of getCurrentObjects()) { + for (const o of visibleLeaves(getCurrentObjects())) { if (o.id === excludeId) continue; const n = stage.findOne(`#${o.id}`); if (!n) continue; @@ -637,10 +621,10 @@ export const LabelCanvas = forwardRef(function LabelCa const frameCtx = { label, measured: measuredSnapshot, variables: renderVariables }; // Hidden leaves never render, so the old client-rect path ignored them; keep // them out of the model-based bounds too. - // visibleLeaves carries the cascaded (effective) lock; read it, not the raw + // renderedLeaves carries the cascaded (effective) lock; read it, not the raw // leaf in `objects`, so a locked group's children count as static here just // like the controller skips them. - const visibleLeafById = new Map(visibleLeaves.map((l) => [l.id, l])); + const visibleLeafById = new Map(renderedLeaves.map((l) => [l.id, l])); const visibleSelIds = attachableIds.filter((id) => visibleLeafById.has(id)); const movableSelIds = visibleSelIds.filter((id) => !visibleLeafById.get(id)?.locked); const staticSelIds = visibleSelIds.filter((id) => !movableSelIds.includes(id)); @@ -930,7 +914,7 @@ export const LabelCanvas = forwardRef(function LabelCa selectedIds: attachableIds, // Cascade-aware leaves so a child of a locked group reads as locked and the // transformer detaches (raw leaves would show dead resize handles). - objects: visibleLeaves, + objects: renderedLeaves, scale, dpmm: effDpmm, objectsOffsetX, @@ -972,10 +956,10 @@ export const LabelCanvas = forwardRef(function LabelCa // outline since the transformer skips locked nodes. const lockedLeafIds = useMemo(() => { const locked = new Set( - visibleLeaves.flatMap((l) => (l.locked ? [l.id] : [])), + renderedLeaves.flatMap((l) => (l.locked ? [l.id] : [])), ); return attachableIds.filter((id) => locked.has(id)); - }, [visibleLeaves, attachableIds]); + }, [renderedLeaves, attachableIds]); useSelectionActionBar({ stageRef, @@ -1602,7 +1586,7 @@ export const LabelCanvas = forwardRef(function LabelCa ) ) : ( - visibleLeaves.map((obj) => ( + renderedLeaves.map((obj) => ( { expect(dx).toBe(-3); }); }); + +describe("dragSnapTargets", () => { + const label = { widthMm: 100, heightMm: 50, dpmm: 8 } as PageLabel; + const box = (id: string, x: number, extra: Partial = {}): LabelObject => + ({ + id, type: "box", x, y: 0, rotation: 0, + props: { ...ObjectRegistry.box.defaultProps, width: 10, height: 10 }, + ...extra, + }) as LabelObject; + + it("offers only what is drawn: no hidden leaf, nothing inside a hidden group", () => { + const objects: LabelObject[] = [ + box("dragged", 0), + box("shown", 20), + box("hidden", 40, { visible: false }), + { id: "g", type: "group", x: 0, y: 0, rotation: 0, visible: false, children: [box("inHidden", 60)] }, + ]; + const ids = dragSnapTargets(objects, new Set(["dragged"]), { label }).map((r) => r.id); + expect(ids).toEqual(["shown"]); + }); +}); diff --git a/src/components/Canvas/dragGeometry.ts b/src/components/Canvas/dragGeometry.ts index aa1777c6..12c2a3cf 100644 --- a/src/components/Canvas/dragGeometry.ts +++ b/src/components/Canvas/dragGeometry.ts @@ -3,8 +3,9 @@ // stage and the controller stays a thin imperative layer. import { computeSnap, type SnapGuide, type SnapRect } from "../../lib/snapGuides"; -import { printableRectDots, type BoundingBoxDots } from "@zplab/core/lib/objectBounds"; +import { barBoxDots, printableRectDots, type BoundingBoxDots, type ObjectBoundsCtx } from "@zplab/core/lib/objectBounds"; import type { PageLabel } from "@zplab/core/types/LabelConfig"; +import { visibleLeaves, type LabelObject } from "@zplab/core/types/Group"; /** Printable-area snap rect (dots), the {@link printableRectDots} rect tagged * with the snap id so it shares one source with the out-of-bounds check. */ @@ -41,3 +42,14 @@ export function smartSnapDelta( const r = computeSnap(box, others, threshold, labelRect, labelRect); return { dx: r.x - box.x, dy: r.y - box.y, guides: r.guides }; } + +/** Bar boxes, so a barcode's text cannot pull the symbol. */ +export function dragSnapTargets( + objects: readonly LabelObject[], + dragged: ReadonlySet, + ctx: ObjectBoundsCtx, +): SnapRect[] { + return visibleLeaves(objects) + .filter((leaf) => !dragged.has(leaf.id)) + .map((leaf) => ({ id: leaf.id, ...barBoxDots(leaf, ctx) })); +} diff --git a/src/components/Canvas/hooks/useKonvaDragController.ts b/src/components/Canvas/hooks/useKonvaDragController.ts index 48350d07..ccfa3b67 100644 --- a/src/components/Canvas/hooks/useKonvaDragController.ts +++ b/src/components/Canvas/hooks/useKonvaDragController.ts @@ -1,7 +1,7 @@ import { useRef } from "react"; import type Konva from "konva"; import { pxToDots, dotsToPx } from "@zplab/core/lib/coordinates"; -import { gridSnapDelta, smartSnapDelta, labelSnapRectDots } from "../dragGeometry"; +import { dragSnapTargets, gridSnapDelta, smartSnapDelta, labelSnapRectDots } from "../dragGeometry"; import { SNAP_THRESHOLD_PX } from "../../../lib/snapGuides"; import type { SnapGuide, SnapRect } from "../../../lib/snapGuides"; import { @@ -10,7 +10,7 @@ import { type BoundingBoxDots, } from "@zplab/core/lib/objectBounds"; import { measuredBoundsMap } from "../../../lib/measuredBoundsCache"; -import { expandSelection, findObjectById, getAllLeaves } from "@zplab/core/types/Group"; +import { expandSelection, findObjectById, visibleLeaves } from "@zplab/core/types/Group"; import { useLabelStore, currentObjects, currentPageLabel, type ObjectChanges } from "../../../store/labelStore"; /** Everything the controller needs from LabelCanvas; mirrors the param style of @@ -140,6 +140,7 @@ export function useKonvaDragController(args: DragControllerArgs): DragHandlers { // selection; otherwise just the grabbed object. const selection = expandSelection(objs, state.selectedIds); const set = selection.includes(primaryId) && selection.length > 1 ? selection : [primaryId]; + const rendered = new Set(visibleLeaves(objs).map((l) => l.id)); const startLocal = new Map(); const startModel = new Map(); const ids: string[] = []; @@ -148,7 +149,7 @@ export function useKonvaDragController(args: DragControllerArgs): DragHandlers { const node = stage.findOne(`#${id}`); // node.draggable() carries the cascaded lock (renderers set it), so a // locked group's children are skipped even though their raw leaf isn't. - if (!obj || obj.visible === false || !node || !node.draggable()) continue; + if (!obj || !rendered.has(id) || !node || !node.draggable()) continue; startLocal.set(id, { x: node.x(), y: node.y() }); startModel.set(id, { x: obj.x, y: obj.y }); ids.push(id); @@ -158,12 +159,7 @@ export function useKonvaDragController(args: DragControllerArgs): DragHandlers { const startUnionDots = selectionUnionDots(objs, ids, ctx, barBoxDots); if (!startUnionDots) return; - const dragged = new Set(ids); - const others: SnapRect[] = []; - for (const leaf of getAllLeaves(objs)) { - if (dragged.has(leaf.id)) continue; - others.push({ id: leaf.id, ...barBoxDots(leaf, ctx) }); - } + const others = dragSnapTargets(objs, new Set(ids), ctx); const labelDots = labelSnapRectDots(currentPageLabel(state)); dragRef.current = { diff --git a/src/types/Group.test.ts b/src/types/Group.test.ts index f6356c4a..23ec946b 100644 --- a/src/types/Group.test.ts +++ b/src/types/Group.test.ts @@ -18,6 +18,7 @@ import { canDeleteSelection, isSelectionLocked, mapObjectById, + visibleLeaves, type GroupObject, type LabelObject, } from '@zplab/core/types/Group'; @@ -78,6 +79,23 @@ describe('Group helpers', () => { }); }); + describe('visibleLeaves', () => { + it('drops a hidden leaf and the subtree of a hidden group', () => { + const tree = [ + leaf('a'), + { ...leaf('hidden'), visible: false }, + { ...group('g', [leaf('b')]), visible: false }, + ]; + expect(visibleLeaves(tree).map((l) => l.id)).toEqual(['a']); + }); + + it('cascades a group lock onto its leaves', () => { + const lockedGroup = { ...group('g', [leaf('a'), { ...leaf('b'), locked: true }]), locked: true }; + expect(visibleLeaves([lockedGroup, leaf('c')]).map((l) => [l.id, l.locked])) + .toEqual([['a', true], ['b', true], ['c', undefined]]); + }); + }); + describe('findObjectById', () => { it('finds top-level leaves', () => { expect(findObjectById([leaf('a')], 'a')?.id).toBe('a');