Skip to content

Commit 61749e3

Browse files
authored
Merge pull request #5 from u8array/feature/resize-smart-align
feat: Implement snap-to-grid and object snapping for resize
2 parents a7111f9 + 7333f0f commit 61749e3

4 files changed

Lines changed: 571 additions & 58 deletions

File tree

‎src/components/Canvas/LabelCanvas.tsx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,18 @@ export function LabelCanvas({
240240
onStageMouseDown,
241241
} = useCanvasLasso({ containerRef, stageRef, spaceDown, selectObjects });
242242

243+
// Snap / guide infrastructure used by both drag and resize. The label rect
244+
// here matches the visual (rotation-aware) bounds — snap math operates in
245+
// stage-screen space, so it must reflect what the user sees, not the
246+
// un-rotated layout coordinates.
247+
const transformerSnapLabelRect = {
248+
id: "_lbl",
249+
x: visualLabelX,
250+
y: visualLabelY,
251+
width: visualLabelWidthPx,
252+
height: visualLabelHeightPx,
253+
};
254+
243255
const {
244256
rotateEnabled,
245257
resizeEnabled,
@@ -258,6 +270,9 @@ export function LabelCanvas({
258270
labelOffsetY,
259271
snap,
260272
updateObject,
273+
labelRect: transformerSnapLabelRect,
274+
objectSnapEnabled: !snapEnabled,
275+
setGuides,
261276
});
262277

263278
const handleObjectChange = (

‎src/components/Canvas/hooks/useKonvaTransformer.ts‎

Lines changed: 125 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,78 @@ import {
1414
type BoundingBox,
1515
} from "../transformerGeometry";
1616
import { modelPositionFromRenderedTopLeft } from "../transformPosition";
17+
import {
18+
computeResizeSnap,
19+
deriveActiveEdges,
20+
type SnapGuide,
21+
type SnapRect,
22+
} from "../../../lib/snapGuides";
23+
24+
/** Pack a Konva clientRect into the SnapRect shape used by snap helpers. */
25+
function toSnapRect(id: string, rect: { x: number; y: number; width: number; height: number }): SnapRect {
26+
return { id, x: rect.x, y: rect.y, width: rect.width, height: rect.height };
27+
}
28+
29+
/**
30+
* Snapshot every object's stage-space bbox at transform start. Other objects
31+
* can't move during a resize, so we cache once and avoid per-tick Konva queries.
32+
*/
33+
function captureOtherRects(
34+
stage: Konva.Stage,
35+
objects: LabelObject[],
36+
excludeId: string,
37+
): SnapRect[] {
38+
const result: SnapRect[] = [];
39+
for (const o of objects) {
40+
if (o.id === excludeId) continue;
41+
const n = stage.findOne<Konva.Node>(`#${o.id}`);
42+
if (!n) continue;
43+
const cr = n.getClientRect({ skipShadow: true, skipStroke: true, relativeTo: stage });
44+
result.push(toSnapRect(o.id, cr));
45+
}
46+
return result;
47+
}
48+
49+
/**
50+
* Phase 1 of resize: snap height to whole rowHeight increments for stacked-2D
51+
* barcodes (or to whole dots otherwise), and pin the bottom edge if the resize
52+
* originates from the top anchor.
53+
*/
54+
function applyHeightSnap(
55+
oldBox: BoundingBox,
56+
newBox: BoundingBox,
57+
dotPx: number,
58+
anchor: { nodeHeight: number; rowHeight: number } | null,
59+
): BoundingBox {
60+
const stepPx =
61+
anchor && anchor.rowHeight > 0 && anchor.nodeHeight > 0
62+
? anchor.nodeHeight / anchor.rowHeight
63+
: dotPx;
64+
const snappedH = snapBoxHeight(newBox.height, stepPx);
65+
return isTopAnchorResize(oldBox, newBox, dotPx * 0.5)
66+
? pinBottomEdge(oldBox, newBox, snappedH)
67+
: { ...newBox, height: snappedH };
68+
}
69+
70+
/**
71+
* Phase 2 of resize: snap moving edges to other objects' / label edges
72+
* (object-snap, mirrors drag-time snap). Pure delegation to `computeResizeSnap`
73+
* with input shape conversion.
74+
*/
75+
function applyResizeObjectSnap(
76+
bbox: BoundingBox,
77+
startBbox: SnapRect,
78+
others: SnapRect[],
79+
labelRect: SnapRect,
80+
): { bbox: BoundingBox; guides: SnapGuide[] } {
81+
const draggedRect = toSnapRect(startBbox.id, bbox);
82+
const activeEdges = deriveActiveEdges(startBbox, draggedRect);
83+
const result = computeResizeSnap(draggedRect, others, activeEdges, undefined, labelRect, labelRect);
84+
return {
85+
bbox: { x: result.x, y: result.y, width: result.width, height: result.height, rotation: bbox.rotation },
86+
guides: result.guides,
87+
};
88+
}
1789

1890
interface Options {
1991
transformerRef: React.RefObject<Konva.Transformer | null>;
@@ -26,6 +98,12 @@ interface Options {
2698
labelOffsetY: number;
2799
snap: (dots: number) => number;
28100
updateObject: (id: string, changes: ObjectChanges) => void;
101+
/** Label rect in stage-screen space, used as a snap target. */
102+
labelRect: SnapRect;
103+
/** True when grid-snap is OFF — object-snap during resize mirrors drag. */
104+
objectSnapEnabled: boolean;
105+
/** Pushes resize-time snap guides into the canvas's shared guide state. */
106+
setGuides: (guides: SnapGuide[]) => void;
29107
}
30108

31109
export interface TransformerState {
@@ -48,10 +126,20 @@ export function useKonvaTransformer({
48126
labelOffsetY,
49127
snap,
50128
updateObject,
129+
labelRect,
130+
objectSnapEnabled,
131+
setGuides,
51132
}: Options): TransformerState {
52133
// Captures node height and rowHeight at drag start so boundBoxFunc uses a
53134
// fixed step size throughout the entire drag session.
54135
const transformAnchorRef = useRef<{ nodeHeight: number; rowHeight: number } | null>(null);
136+
// Captures the bbox at transform start so deriveActiveEdges can detect which
137+
// edges are moving relative to the start state (oldBox in boundBoxFunc is the
138+
// previous frame, which would always look "everything moved").
139+
const transformStartBboxRef = useRef<SnapRect | null>(null);
140+
// Snapshot of the other objects' bboxes at transform start. Other objects
141+
// can't move during a resize, so we avoid re-querying Konva on every tick.
142+
const othersSnapshotRef = useRef<SnapRect[]>([]);
55143

56144
// Stable key of selected object types — avoids re-running on every drag-move
57145
// position update (which changes objects but not the types of selected objects).
@@ -91,46 +179,59 @@ export function useKonvaTransformer({
91179
: BARCODE_1D_TYPES.has(objects.find((o) => o.id === selectedIds[0])?.type ?? "")
92180
? ["top-center", "bottom-center"]
93181
: undefined;
182+
const isFreeResize = enabledAnchors === undefined;
183+
184+
/** Reset all transform-time state. Idempotent; safe to call from any exit path. */
185+
function cleanupTransformState() {
186+
transformAnchorRef.current = null;
187+
transformStartBboxRef.current = null;
188+
othersSnapshotRef.current = [];
189+
setGuides([]);
190+
}
94191

95192
const onTransformStart = () => {
96193
const singleId = selectedIds[0];
97194
if (!singleId || !stageRef.current) return;
98195
const node = stageRef.current.findOne<Konva.Node>(`#${singleId}`);
99196
if (!node) return;
100197
const obj = objects.find((o) => o.id === singleId);
101-
if (obj && STACKED_2D_TYPES.has(obj.type)) {
102-
transformAnchorRef.current = {
103-
nodeHeight: node.height(),
104-
rowHeight: (obj.props as { rowHeight: number }).rowHeight,
105-
};
106-
} else {
107-
transformAnchorRef.current = null;
108-
}
198+
transformAnchorRef.current = obj && STACKED_2D_TYPES.has(obj.type)
199+
? { nodeHeight: node.height(), rowHeight: (obj.props as { rowHeight: number }).rowHeight }
200+
: null;
201+
const startRect = node.getClientRect({ skipShadow: true, skipStroke: true, relativeTo: stageRef.current });
202+
transformStartBboxRef.current = toSnapRect(singleId, startRect);
203+
othersSnapshotRef.current = captureOtherRects(stageRef.current, objects, singleId);
109204
};
110205

111206
const boundBoxFunc = (oldBox: BoundingBox, newBox: BoundingBox): BoundingBox => {
112207
if (newBox.width < 10 || newBox.height < 10) return oldBox;
113208
const dotPx = scale / dpmm;
114-
// For stacked 2D barcodes, snap to whole rowHeight increments. stepPx is
115-
// derived from the node height captured at drag start (not oldBox.height,
116-
// which mutates each call and would drift).
117-
const anchor = transformAnchorRef.current;
118-
const stepPx =
119-
anchor && anchor.rowHeight > 0 && anchor.nodeHeight > 0
120-
? anchor.nodeHeight / anchor.rowHeight
121-
: dotPx;
122-
const snappedH = snapBoxHeight(newBox.height, stepPx);
123-
if (isTopAnchorResize(oldBox, newBox, dotPx * 0.5)) {
124-
return pinBottomEdge(oldBox, newBox, snappedH);
209+
let bbox = applyHeightSnap(oldBox, newBox, dotPx, transformAnchorRef.current);
210+
211+
// Object-snap during resize (mirrors drag-time snap). Only fires when
212+
// grid-snap is off and the user is doing a free corner-resize — the 1D /
213+
// stacked-2D anchor restrictions have their own height math above and
214+
// would conflict with edge-driven snapping.
215+
const startBbox = transformStartBboxRef.current;
216+
if (objectSnapEnabled && isFreeResize && startBbox) {
217+
const snapped = applyResizeObjectSnap(bbox, startBbox, othersSnapshotRef.current, labelRect);
218+
setGuides(snapped.guides);
219+
bbox = snapped.bbox;
125220
}
126-
return { ...newBox, height: snappedH };
221+
return bbox;
127222
};
128223

129224
const onTransformEnd = () => {
130-
if (selectedIds.length !== 1 || !selectedIds[0] || !stageRef.current) return;
225+
if (selectedIds.length !== 1 || !selectedIds[0] || !stageRef.current) {
226+
cleanupTransformState();
227+
return;
228+
}
131229
const singleId = selectedIds[0];
132230
const node = stageRef.current.findOne<Konva.Node>(`#${singleId}`);
133-
if (!node) return;
231+
if (!node) {
232+
cleanupTransformState();
233+
return;
234+
}
134235
const sx = node.scaleX();
135236
const sy = node.scaleY();
136237
const nodeWidth = node.width();
@@ -139,7 +240,7 @@ export function useKonvaTransformer({
139240
node.scaleY(1);
140241
const obj = currentObjects(useLabelStore.getState()).find((o) => o.id === singleId);
141242
if (!obj) {
142-
transformAnchorRef.current = null;
243+
cleanupTransformState();
143244
return;
144245
}
145246
const isCenterAnchored = ObjectRegistry[obj.type]?.nodeOrigin === "center";
@@ -175,7 +276,7 @@ export function useKonvaTransformer({
175276
});
176277
updateObject(singleId, { ...pos, props: propChanges });
177278
}
178-
transformAnchorRef.current = null;
279+
cleanupTransformState();
179280
};
180281

181282
return {

0 commit comments

Comments
 (0)