Skip to content
Merged
Show file tree
Hide file tree
Changes from 31 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
92 changes: 75 additions & 17 deletions src/components/Canvas/LabelCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
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 Down Expand Up @@ -122,6 +123,42 @@
} = 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: LabelObject[] = [];
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 LabelObject) : 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 @@
// 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 @@
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 @@
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 @@
} = useKonvaTransformer({
transformerRef,
stageRef,
selectedIds,
objects,
selectedIds: attachableIds,
objects: allLeaves,
scale,
dpmm: label.dpmm,
objectsOffsetX,
Expand All @@ -394,7 +439,7 @@
const singleSelected = selectedIds.length === 1
? objects.find((o) => o.id === selectedIds[0]) ?? null
: null;
const stepRotation = singleSelected ? getStepRotation(singleSelected) : null;

Check failure on line 442 in src/components/Canvas/LabelCanvas.tsx

View workflow job for this annotation

GitHub Actions / check

Argument of type 'LabelObject' is not assignable to parameter of type '{ props?: object | undefined; }'.
const [rotationBtnPos, setRotationBtnPos] = useState<{ x: number; y: number } | null>(null);
const rotationBtnRef = useRef<Konva.Group>(null);
useLayoutEffect(() => {
Expand Down Expand Up @@ -447,15 +492,18 @@
// 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 @@
...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 @@
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 @@ -771,19 +819,29 @@
/>
)}

{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
18 changes: 16 additions & 2 deletions src/components/Canvas/hooks/useCanvasLasso.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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];
});
Comment thread
u8array marked this conversation as resolved.
Outdated
const hits = getIdsIntersectingRect(stageRef.current, leafIds, rect);
const promoted = new Set(hits.map((id) => selectionTargetId(objects, id)));
selectObjects([...promoted]);
};

const onStageMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
Expand Down
3 changes: 2 additions & 1 deletion src/components/Canvas/hooks/useKonvaTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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,
Expand Down Expand Up @@ -144,7 +145,7 @@
const selectedSignature = selectedIds
.map((id) => {
const o = objects.find((obj) => obj.id === id);
return o ? `${id}:${JSON.stringify(o.props)}` : id;

Check failure on line 148 in src/components/Canvas/hooks/useKonvaTransformer.ts

View workflow job for this annotation

GitHub Actions / check

Property 'props' does not exist on type 'LabelObject'.
})
.join("|");

Expand Down Expand Up @@ -224,7 +225,7 @@
if (!node) return;
const obj = objects.find((o) => o.id === singleId);
transformAnchorRef.current = obj && STACKED_2D_TYPES.has(obj.type)
? { nodeHeight: node.height(), rowHeight: (obj.props as { rowHeight: number }).rowHeight }

Check failure on line 228 in src/components/Canvas/hooks/useKonvaTransformer.ts

View workflow job for this annotation

GitHub Actions / check

Property 'props' does not exist on type 'LabelObject'.
: null;
// startBbox is captured lazily on the first boundBoxFunc call — Konva
// passes those bboxes in the transformer's frame, which on rotated
Expand Down Expand Up @@ -303,7 +304,7 @@
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;
Expand Down
Loading
Loading