Skip to content

Commit 5d7a96b

Browse files
authored
Merge pull request #52 from u8array/feat/smart-snap-tweaks
Feat/smart snap tweaks
2 parents 2f19211 + 0826d9d commit 5d7a96b

4 files changed

Lines changed: 160 additions & 36 deletions

File tree

‎src/components/Canvas/BarcodeObject.tsx‎

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ export function BarcodeObject({
5050
}
5151
}, []);
5252

53+
// Multi-text HRI variants (EAN/UPC digits) are wrapped in a Group whose
54+
// getClientRect is overridden to zero, so the parent barcode Group's
55+
// bbox collapses to the bar image only. Same intent as setTextRef but
56+
// applied at the group level: avoids threading the ref through 10+
57+
// individual Text components.
58+
const excludeGroupFromBbox = useCallback((node: Konva.Group | null) => {
59+
if (node) {
60+
node.getClientRect = () => ({ x: 0, y: 0, width: 0, height: 0 });
61+
}
62+
}, []);
63+
5364
const opts = buildBwipOptions(obj, scale, dpmm);
5465
let barcodeCanvas: HTMLCanvasElement | null = null;
5566
let errorMsg: string | null = null;
@@ -435,7 +446,7 @@ export function BarcodeObject({
435446
strokeWidth={isSelected ? 2 : 0}
436447
strokeScaleEnabled={false}
437448
/>
438-
{textNodes}
449+
{textNodes.length > 0 && <Group ref={excludeGroupFromBbox}>{textNodes}</Group>}
439450
</Group>
440451
);
441452
}
@@ -515,19 +526,11 @@ export function BarcodeObject({
515526
onTransform={handleTransform}
516527
onTransformEnd={handleTransformEnd}
517528
>
518-
{/* Invisible rect spanning the full ZPL footprint so the Group's
519-
getClientRect picks up the text-zone reservation. The HRI Text
520-
node has getSelfRect=0 (excluded from bbox to keep resize
521-
anchored at the bars), so without this rect the bbox would
522-
shrink to barH only. */}
523-
<Rect
524-
x={0}
525-
y={0}
526-
width={Math.max(w, 1)}
527-
height={Math.max(h, 1)}
528-
fill="transparent"
529-
listening={false}
530-
/>
529+
{/* No invisible footprint rect: bbox shrinks to the bars (HRI
530+
Text node has getSelfRect=0 already). The firmware text-zone
531+
reservation stays implicit — it only matters for print
532+
output, not for canvas selection / smart-align, where the
533+
user expects the visual focus to sit on the bars. */}
531534
<KImage
532535
x={btX}
533536
y={btY}
@@ -685,23 +688,16 @@ export function BarcodeObject({
685688
onDragMove={(e) => e.target.position(snapPos(e.target.x(), e.target.y()))}
686689
onDragEnd={handleDragEnd}
687690
>
688-
{/* Full-bbox invisible rect — same role as in the upright/showText
689-
branch: the rotated HRI Text overlay sits outside the bars and
690-
its position varies per rotation, so the Group's auto-bbox
691-
would not necessarily span the full ZPL footprint. */}
692-
<Rect
693-
x={0} y={0}
694-
width={Math.max(w, 1)} height={Math.max(h, 1)}
695-
fill="transparent" listening={false}
696-
/>
697691
<KImage x={btX} y={btY} image={barcodeCanvas} crop={bitmapCrop}
698692
width={bw} height={bh}
699693
imageSmoothingEnabled={false}
700694
stroke={isSelected ? colors.selection : undefined}
701695
strokeWidth={isSelected ? 2 : 0}
702696
strokeScaleEnabled={false}
703697
/>
704-
{textElements}
698+
{textElements && (
699+
<Group ref={excludeGroupFromBbox}>{textElements}</Group>
700+
)}
705701
</Group>
706702
);
707703
}
@@ -721,14 +717,6 @@ export function BarcodeObject({
721717
onDragMove={handleDragMove}
722718
onDragEnd={handleDragEnd}
723719
>
724-
<Rect
725-
x={0}
726-
y={0}
727-
width={Math.max(w, 1)}
728-
height={Math.max(h, 1)}
729-
fill="transparent"
730-
listening={false}
731-
/>
732720
<KImage
733721
x={btX}
734722
y={btY}

‎src/components/Canvas/LineObject.tsx‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { LabelObject } from "../../registry";
55
import { dotsToPx, pxToDots } from "../../lib/coordinates";
66
import { constrainLine, type ConstrainMode } from "../../lib/lineConstrain";
77
import { useColorScheme } from "../../lib/useColorScheme";
8-
import { computeSnap, type SnapRect } from "../../lib/snapGuides";
8+
import { computePointSnap, type SnapRect } from "../../lib/snapGuides";
99
import { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps";
1010

1111
/** Endpoint-handle visuals — small white square with a thin selection
@@ -162,12 +162,11 @@ export function LineObject({
162162
}
163163
const transform = parent.getAbsoluteTransform();
164164
const stagePx = transform.point(localPx);
165-
const result = computeSnap(
166-
{ id: obj.id, x: stagePx.x, y: stagePx.y, width: 0, height: 0 },
165+
const result = computePointSnap(
166+
stagePx,
167167
othersSnapshotRef.current,
168168
undefined,
169169
labelRect,
170-
labelRect,
171170
);
172171
setGuides(result.guides);
173172
const back = transform.copy().invert().point({ x: result.x, y: result.y });

‎src/lib/snapGuides.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect } from "vitest";
22
import {
33
computeSnap,
4+
computePointSnap,
45
computeResizeSnap,
56
deriveActiveEdges,
67
SNAP_THRESHOLD_PX,
@@ -166,3 +167,64 @@ describe("computeResizeSnap", () => {
166167
});
167168
});
168169
});
170+
171+
describe("computePointSnap", () => {
172+
it("snaps to an other's nearest edge in x and y independently", () => {
173+
// other rect at (100..200, 50..150). point near its left edge x=100
174+
// and bottom edge y=150.
175+
const other = r("o", 100, 50, 100, 100);
176+
const result = computePointSnap({ x: 102, y: 148 }, [other], 6);
177+
expect(result.x).toBe(100);
178+
expect(result.y).toBe(150);
179+
expect(result.guides).toHaveLength(2);
180+
});
181+
182+
it("does NOT consider the other's centre as a snap target (regression: 50%-snap bug)", () => {
183+
// other rect at (100..200, 50..150). centre y = 100. point at y=98
184+
// is 2 px from the centre but >= 6 px from any edge — must not snap.
185+
const other = r("o", 100, 50, 100, 100);
186+
const result = computePointSnap({ x: 500, y: 98 }, [other], 6);
187+
expect(result.y).toBe(98);
188+
// x is far from the other and the labelRect is omitted, so no snap.
189+
expect(result.x).toBe(500);
190+
expect(result.guides).toHaveLength(0);
191+
});
192+
193+
it("uses the label rect for edge alignment too", () => {
194+
const lbl = r("_lbl", 0, 0, 1000, 600);
195+
// y=300 sits exactly on the label's vertical centre — a valid
196+
// label-centre snap, so two guides fire (right edge + centre).
197+
const result = computePointSnap({ x: 998, y: 300 }, [], 6, lbl);
198+
expect(result.x).toBe(1000); // right edge of label
199+
expect(result.y).toBe(300); // label vertical centre
200+
expect(result.guides).toHaveLength(2);
201+
});
202+
203+
it("snaps to label centre (allowed for label only, not for neighbour objects)", () => {
204+
const lbl = r("_lbl", 0, 0, 1000, 600);
205+
// Point near horizontal centre of label (500, 300).
206+
const result = computePointSnap({ x: 502, y: 302 }, [], 6, lbl);
207+
expect(result.x).toBe(500);
208+
expect(result.y).toBe(300);
209+
expect(result.guides).toHaveLength(2);
210+
});
211+
212+
it("respects the threshold — far targets are ignored", () => {
213+
const other = r("o", 100, 50, 50, 50);
214+
const result = computePointSnap({ x: 300, y: 300 }, [other], 6);
215+
expect(result.x).toBe(300);
216+
expect(result.y).toBe(300);
217+
expect(result.guides).toHaveLength(0);
218+
});
219+
220+
it("picks the closest of two competing edges", () => {
221+
// Two other rects with nearby edges at y=98 and y=102.
222+
// point at y=100 → both at distance 2. The implementation prefers
223+
// the first-encountered tied target; pin this to the closer one
224+
// when it's strictly closer.
225+
const a = r("a", 0, 0, 50, 96); // y_end = 96
226+
const b = r("b", 0, 102, 50, 50); // y_start = 102
227+
const result = computePointSnap({ x: 500, y: 100.5 }, [a, b], 6);
228+
expect(result.y).toBe(102); // 102 - 100.5 = 1.5, closer than 100.5 - 96 = 4.5
229+
});
230+
});

‎src/lib/snapGuides.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,81 @@ export const SNAP_THRESHOLD_PX = 6;
22
/** Extra px the alignment guide extends beyond the dragged + matched objects. */
33
const GUIDE_PADDING_PX = 8;
44

5+
/**
6+
* Edge-only point snap used for line endpoint resize. Unlike computeSnap,
7+
* which treats the drag as a sized rect and considers other shapes'
8+
* centres as snap targets (sensible for whole-object alignment, weird
9+
* for endpoint alignment — picking the middle of a neighbour line as a
10+
* snap target produces the "snaps to 50%" artefact users reported).
11+
* Considers only edges of others + the label rect.
12+
*/
13+
export function computePointSnap(
14+
point: { x: number; y: number },
15+
others: SnapRect[],
16+
threshold = SNAP_THRESHOLD_PX,
17+
labelRect?: SnapRect,
18+
): { x: number; y: number; guides: SnapGuide[] } {
19+
const snapAxisPt = (
20+
drag: number,
21+
dragPerp: number,
22+
axis: 'x' | 'y',
23+
): { value: number; guides: SnapGuide[] } => {
24+
let bestDelta = Infinity;
25+
let bestValue = drag;
26+
let bestGuides: SnapGuide[] = [];
27+
const consider = (target: number, perpFrom: number, perpTo: number) => {
28+
const d = Math.abs(target - drag);
29+
if (d > threshold || d > bestDelta) return;
30+
const guideOrientation: 'H' | 'V' = axis === 'x' ? 'V' : 'H';
31+
const guide: SnapGuide = {
32+
orientation: guideOrientation,
33+
type: 'align',
34+
pos: target,
35+
from: Math.min(dragPerp, perpFrom) - GUIDE_PADDING_PX,
36+
to: Math.max(dragPerp, perpTo) + GUIDE_PADDING_PX,
37+
};
38+
if (d < bestDelta) {
39+
// Strictly closer — replace the best candidate.
40+
bestDelta = d;
41+
bestValue = target;
42+
bestGuides = [guide];
43+
} else if (target === bestValue) {
44+
// Same edge value at the same distance — accumulate so each
45+
// contributing object draws its own guide line.
46+
bestGuides.push(guide);
47+
}
48+
};
49+
for (const o of others) {
50+
const startEdge = axis === 'x' ? o.x : o.y;
51+
const endEdge = startEdge + (axis === 'x' ? o.width : o.height);
52+
const perpStart = axis === 'x' ? o.y : o.x;
53+
const perpEnd = perpStart + (axis === 'x' ? o.height : o.width);
54+
consider(startEdge, perpStart, perpEnd);
55+
consider(endEdge, perpStart, perpEnd);
56+
}
57+
if (labelRect) {
58+
// Label edges *and* center are valid endpoint snap targets. Centre
59+
// is intentionally only allowed for the label (not for other
60+
// objects) — endpoint alignment to a neighbour's midpoint produced
61+
// the "50 %" artefact this helper was created to avoid.
62+
const startEdge = axis === 'x' ? labelRect.x : labelRect.y;
63+
const size = axis === 'x' ? labelRect.width : labelRect.height;
64+
const endEdge = startEdge + size;
65+
const center = startEdge + size / 2;
66+
const perpStart = axis === 'x' ? labelRect.y : labelRect.x;
67+
const perpEnd = perpStart + (axis === 'x' ? labelRect.height : labelRect.width);
68+
consider(startEdge, perpStart, perpEnd);
69+
consider(center, perpStart, perpEnd);
70+
consider(endEdge, perpStart, perpEnd);
71+
}
72+
return { value: bestValue, guides: bestGuides };
73+
};
74+
75+
const xRes = snapAxisPt(point.x, point.y, 'x');
76+
const yRes = snapAxisPt(point.y, point.x, 'y');
77+
return { x: xRes.value, y: yRes.value, guides: [...xRes.guides, ...yRes.guides] };
78+
}
79+
580
export interface SnapRect {
681
id: string;
782
x: number;

0 commit comments

Comments
 (0)