Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/core/src/types/Group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand Down
34 changes: 9 additions & 25 deletions src/components/Canvas/LabelCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -343,23 +343,7 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(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(
Expand Down Expand Up @@ -585,7 +569,7 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(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<Konva.Node>(`#${o.id}`);
if (!n) continue;
Expand Down Expand Up @@ -637,10 +621,10 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(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));
Expand Down Expand Up @@ -930,7 +914,7 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(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,
Expand Down Expand Up @@ -972,10 +956,10 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(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,
Expand Down Expand Up @@ -1602,7 +1586,7 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
</>
)
) : (
visibleLeaves.map((obj) => (
renderedLeaves.map((obj) => (
<KonvaObject
key={obj.id}
obj={obj}
Expand Down
25 changes: 24 additions & 1 deletion src/components/Canvas/dragGeometry.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, it, expect } from "vitest";
import { snapToGrid, gridSnapDelta, smartSnapDelta, labelSnapRectDots } from "./dragGeometry";
import { dragSnapTargets, snapToGrid, gridSnapDelta, smartSnapDelta, labelSnapRectDots } from "./dragGeometry";
import { ObjectRegistry, type LeafObject } from "@zplab/core/registry";
import type { LabelObject } from "@zplab/core/types/Group";
import type { BoundingBoxDots } from "@zplab/core/lib/objectBounds";
import type { SnapRect } from "../../lib/snapGuides";
import type { PageLabel } from "@zplab/core/types/LabelConfig";
Expand Down Expand Up @@ -78,3 +80,24 @@ describe("smartSnapDelta", () => {
expect(dx).toBe(-3);
});
});

describe("dragSnapTargets", () => {
const label = { widthMm: 100, heightMm: 50, dpmm: 8 } as PageLabel;
const box = (id: string, x: number, extra: Partial<LeafObject> = {}): 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"]);
});
});
14 changes: 13 additions & 1 deletion src/components/Canvas/dragGeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<string>,
ctx: ObjectBoundsCtx,
): SnapRect[] {
return visibleLeaves(objects)
.filter((leaf) => !dragged.has(leaf.id))
.map((leaf) => ({ id: leaf.id, ...barBoxDots(leaf, ctx) }));
}
14 changes: 5 additions & 9 deletions src/components/Canvas/hooks/useKonvaDragController.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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<string, { x: number; y: number }>();
const startModel = new Map<string, { x: number; y: number }>();
const ids: string[] = [];
Expand All @@ -148,7 +149,7 @@ export function useKonvaDragController(args: DragControllerArgs): DragHandlers {
const node = stage.findOne<Konva.Node>(`#${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);
Expand All @@ -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 = {
Expand Down
18 changes: 18 additions & 0 deletions src/types/Group.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
canDeleteSelection,
isSelectionLocked,
mapObjectById,
visibleLeaves,
type GroupObject,
type LabelObject,
} from '@zplab/core/types/Group';
Expand Down Expand Up @@ -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');
Expand Down
Loading