Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
e6e6b41
feat(groups): add GroupObject type and tree-walk helpers
u8array May 14, 2026
60971df
feat(groups): add groupSelection and ungroup store actions
u8array May 14, 2026
cff375c
feat(groups): make ZPL export and canvas render group-aware
u8array May 14, 2026
3e52d4b
feat(groups): bind Ctrl+G / Ctrl+Shift+G to group and ungroup
u8array May 14, 2026
8fe9f25
fix(groups): guard getStepRotation against missing props
u8array May 14, 2026
1975928
feat(groups): auto-select-parent on click, group drag via attachable …
u8array May 14, 2026
cfbf207
feat(groups): cascade lock to children, expand selection for arrow ke…
u8array May 14, 2026
508cd81
feat(groups): localised label and folder glyph for the group row
u8array May 14, 2026
b1a47ba
feat(groups): hierarchical layers panel with expand and ungroup
u8array May 14, 2026
ad650f8
feat(groups): drag layers in and out of groups
u8array May 14, 2026
60e355c
feat(groups): drop-position preview in the layers panel
u8array May 14, 2026
65c78f1
fix(groups): empty groups accept drops, drop position matches inserti…
u8array May 14, 2026
6860ab7
fix(groups): bottom drop zone so the last row can be extracted
u8array May 14, 2026
555a41c
feat(groups): horizontal-indent drag, drop the bottom sentinel
u8array May 14, 2026
ec7bf6c
fix(groups): pointer-direct cursor tracking for indent drag
u8array May 14, 2026
4777d7f
fix(groups): indent drag drops onto its own row when depth changes
u8array May 14, 2026
cbf275f
refactor(groups): inline useT() in LayerRow instead of threading 8 t-…
u8array May 14, 2026
165e328
refactor(groups): extract useLayerDnd hook from LayersPanel
u8array May 14, 2026
6f9dcef
refactor(groups): extract shouldDropInto predicate
u8array May 14, 2026
3e1911c
feat(groups): new-group button in the layers panel header
u8array May 14, 2026
ccfa3f2
feat(groups): inline-rename groups via double-click in the layers panel
u8array May 14, 2026
7108c58
refactor(groups): split LayerRow into its own file, unify panel rende…
u8array May 14, 2026
0b4b116
feat(groups): indent guide lines and selected-group tint in layers panel
u8array May 14, 2026
fa9d17b
feat(groups): bold group labels and child count on collapsed groups
u8array May 14, 2026
3bd477a
feat(groups): bottom gap on the last child of an expanded group
u8array May 14, 2026
afd7d4b
refactor(groups): unify indent spacer + extract insertAt helper
u8array May 14, 2026
2d713a4
refactor(groups): move INDENT_STEP into its own layout module
u8array May 14, 2026
3e16aca
feat(groups): name field in PropertiesPanel for groups
u8array May 14, 2026
adb95c5
feat(groups): group-selection button in the multi-select PropertiesPanel
u8array May 14, 2026
1c35677
fix(groups): drop the orphan separator under PropertiesPanel when no …
u8array May 14, 2026
8dd1024
refactor(groups): tree-walk lookup in PropertiesPanel and shared canG…
u8array May 14, 2026
751cf09
fix(groups): make leaf-only consumers reject GroupObject at the type …
u8array May 14, 2026
4886183
perf(groups): address gemini review on hot-path traversals
u8array May 14, 2026
27233fb
fix(groups): address gemini round 2 — indent-gated drop-into, reorder…
u8array May 14, 2026
58d759a
refactor(groups): collapse drop-resolution into a single DropMode helper
u8array May 14, 2026
bfeea95
perf(groups): identity-preserving mapObjectById and updateObjects walk
u8array May 14, 2026
1ab5189
fix(groups): guard rename commit against escape-driven blur
u8array May 14, 2026
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
98 changes: 78 additions & 20 deletions src/components/Canvas/LabelCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -93,7 +94,7 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const rotateView = () => onViewRotationChange(nextRotation(viewRotation));
const [guides, setGuides] = useState<SnapGuide[]>([]);
const [ghost, setGhost] = useState<LabelObject | null>(null);
const [ghost, setGhost] = useState<LeafObject | null>(null);

// Raw pointer position tracked independently of @dnd-kit's scroll-adjusted delta.
// activatorEvent.client + event.delta includes scroll momentum from the palette
Expand Down Expand Up @@ -122,6 +123,42 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(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]);
Comment thread
u8array marked this conversation as resolved.

// 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;
Expand Down Expand Up @@ -180,9 +217,13 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(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 } }];
}),
Expand Down Expand Up @@ -320,8 +361,12 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(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<Konva.Node>(`#${id}`);
if (!node) return [];
const r = node.getClientRect({ relativeTo: stage });
Expand All @@ -348,8 +393,8 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(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 } },
Expand All @@ -371,8 +416,8 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
} = useKonvaTransformer({
transformerRef,
stageRef,
selectedIds,
objects,
selectedIds: attachableIds,
objects: allLeaves,
scale,
dpmm: label.dpmm,
objectsOffsetX,
Expand Down Expand Up @@ -447,15 +492,18 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(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;
Expand All @@ -464,7 +512,7 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(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 } }]
: [];
Expand All @@ -484,15 +532,15 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(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;
const dr = node.getClientRect({ relativeTo: stage });
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<Konva.Node>(`#${o.id}`);
if (!n) continue;
Expand Down Expand Up @@ -608,7 +656,7 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(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);
Expand Down Expand Up @@ -771,19 +819,29 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
/>
)}

{objects.map((obj) => obj.visible === false ? null : (
{visibleLeaves.map((obj) => (
<KonvaObject
key={obj.id}
obj={obj}
scale={scale}
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}
Expand Down
3 changes: 2 additions & 1 deletion src/components/Canvas/bwipHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
8 changes: 4 additions & 4 deletions src/components/Canvas/bwipHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -290,7 +290,7 @@ export function parseZplCode128Escapes(text: string): string | null {
}

export function buildBwipOptions(
obj: LabelObject,
obj: LeafObject,
renderScale?: number,
renderDpmm?: number,
): Record<string, unknown> | null {
Expand Down Expand Up @@ -552,7 +552,7 @@ const TEXT_ZONE_DOTS_BY_TYPE: Partial<Record<LabelObject["type"], number>> = {
};

export function getDisplaySize(
obj: LabelObject,
obj: LeafObject,
canvas: HTMLCanvasElement,
scale: number,
dpmm: number,
Expand Down Expand Up @@ -637,7 +637,7 @@ export function getDisplaySize(
}

function getUprightDisplaySize(
obj: LabelObject,
obj: LeafObject,
cw: number,
ch: number,
scale: number,
Expand Down
35 changes: 33 additions & 2 deletions src/components/Canvas/hooks/useCanvasLasso.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<string, string>();
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<MouseEvent>) => {
Expand Down
13 changes: 7 additions & 6 deletions src/components/Canvas/hooks/useKonvaTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -75,7 +76,7 @@ interface Options {
transformerRef: React.RefObject<Konva.Transformer | null>;
stageRef: React.RefObject<Konva.Stage | null>;
selectedIds: string[];
objects: LabelObject[];
objects: LeafObject[];
scale: number;
dpmm: number;
objectsOffsetX: number;
Expand Down Expand Up @@ -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<Konva.Node>(`#${o.id}`))
.filter((n): n is Konva.Node => n != null);
transformerRef.current.nodes(nodes);
Expand Down Expand Up @@ -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;
}
Expand Down
13 changes: 6 additions & 7 deletions src/components/Canvas/konvaObjectProps.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
Expand Down
Loading