diff --git a/src/components/Canvas/BarcodeObject.tsx b/src/components/Canvas/BarcodeObject.tsx
index 8d55e818..ebde5b77 100644
--- a/src/components/Canvas/BarcodeObject.tsx
+++ b/src/components/Canvas/BarcodeObject.tsx
@@ -50,6 +50,17 @@ export function BarcodeObject({
}
}, []);
+ // Multi-text HRI variants (EAN/UPC digits) are wrapped in a Group whose
+ // getClientRect is overridden to zero, so the parent barcode Group's
+ // bbox collapses to the bar image only. Same intent as setTextRef but
+ // applied at the group level: avoids threading the ref through 10+
+ // individual Text components.
+ const excludeGroupFromBbox = useCallback((node: Konva.Group | null) => {
+ if (node) {
+ node.getClientRect = () => ({ x: 0, y: 0, width: 0, height: 0 });
+ }
+ }, []);
+
const opts = buildBwipOptions(obj, scale, dpmm);
let barcodeCanvas: HTMLCanvasElement | null = null;
let errorMsg: string | null = null;
@@ -435,7 +446,7 @@ export function BarcodeObject({
strokeWidth={isSelected ? 2 : 0}
strokeScaleEnabled={false}
/>
- {textNodes}
+ {textNodes.length > 0 && {textNodes}}
);
}
@@ -515,19 +526,11 @@ export function BarcodeObject({
onTransform={handleTransform}
onTransformEnd={handleTransformEnd}
>
- {/* Invisible rect spanning the full ZPL footprint so the Group's
- getClientRect picks up the text-zone reservation. The HRI Text
- node has getSelfRect=0 (excluded from bbox to keep resize
- anchored at the bars), so without this rect the bbox would
- shrink to barH only. */}
-
+ {/* No invisible footprint rect: bbox shrinks to the bars (HRI
+ Text node has getSelfRect=0 already). The firmware text-zone
+ reservation stays implicit — it only matters for print
+ output, not for canvas selection / smart-align, where the
+ user expects the visual focus to sit on the bars. */}
e.target.position(snapPos(e.target.x(), e.target.y()))}
onDragEnd={handleDragEnd}
>
- {/* Full-bbox invisible rect — same role as in the upright/showText
- branch: the rotated HRI Text overlay sits outside the bars and
- its position varies per rotation, so the Group's auto-bbox
- would not necessarily span the full ZPL footprint. */}
-
- {textElements}
+ {textElements && (
+ {textElements}
+ )}
);
}
@@ -721,14 +717,6 @@ export function BarcodeObject({
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
>
-
{
});
});
});
+
+describe("computePointSnap", () => {
+ it("snaps to an other's nearest edge in x and y independently", () => {
+ // other rect at (100..200, 50..150). point near its left edge x=100
+ // and bottom edge y=150.
+ const other = r("o", 100, 50, 100, 100);
+ const result = computePointSnap({ x: 102, y: 148 }, [other], 6);
+ expect(result.x).toBe(100);
+ expect(result.y).toBe(150);
+ expect(result.guides).toHaveLength(2);
+ });
+
+ it("does NOT consider the other's centre as a snap target (regression: 50%-snap bug)", () => {
+ // other rect at (100..200, 50..150). centre y = 100. point at y=98
+ // is 2 px from the centre but >= 6 px from any edge — must not snap.
+ const other = r("o", 100, 50, 100, 100);
+ const result = computePointSnap({ x: 500, y: 98 }, [other], 6);
+ expect(result.y).toBe(98);
+ // x is far from the other and the labelRect is omitted, so no snap.
+ expect(result.x).toBe(500);
+ expect(result.guides).toHaveLength(0);
+ });
+
+ it("uses the label rect for edge alignment too", () => {
+ const lbl = r("_lbl", 0, 0, 1000, 600);
+ // y=300 sits exactly on the label's vertical centre — a valid
+ // label-centre snap, so two guides fire (right edge + centre).
+ const result = computePointSnap({ x: 998, y: 300 }, [], 6, lbl);
+ expect(result.x).toBe(1000); // right edge of label
+ expect(result.y).toBe(300); // label vertical centre
+ expect(result.guides).toHaveLength(2);
+ });
+
+ it("snaps to label centre (allowed for label only, not for neighbour objects)", () => {
+ const lbl = r("_lbl", 0, 0, 1000, 600);
+ // Point near horizontal centre of label (500, 300).
+ const result = computePointSnap({ x: 502, y: 302 }, [], 6, lbl);
+ expect(result.x).toBe(500);
+ expect(result.y).toBe(300);
+ expect(result.guides).toHaveLength(2);
+ });
+
+ it("respects the threshold — far targets are ignored", () => {
+ const other = r("o", 100, 50, 50, 50);
+ const result = computePointSnap({ x: 300, y: 300 }, [other], 6);
+ expect(result.x).toBe(300);
+ expect(result.y).toBe(300);
+ expect(result.guides).toHaveLength(0);
+ });
+
+ it("picks the closest of two competing edges", () => {
+ // Two other rects with nearby edges at y=98 and y=102.
+ // point at y=100 → both at distance 2. The implementation prefers
+ // the first-encountered tied target; pin this to the closer one
+ // when it's strictly closer.
+ const a = r("a", 0, 0, 50, 96); // y_end = 96
+ const b = r("b", 0, 102, 50, 50); // y_start = 102
+ const result = computePointSnap({ x: 500, y: 100.5 }, [a, b], 6);
+ expect(result.y).toBe(102); // 102 - 100.5 = 1.5, closer than 100.5 - 96 = 4.5
+ });
+});
diff --git a/src/lib/snapGuides.ts b/src/lib/snapGuides.ts
index 23a7b931..0c028229 100644
--- a/src/lib/snapGuides.ts
+++ b/src/lib/snapGuides.ts
@@ -2,6 +2,81 @@ export const SNAP_THRESHOLD_PX = 6;
/** Extra px the alignment guide extends beyond the dragged + matched objects. */
const GUIDE_PADDING_PX = 8;
+/**
+ * Edge-only point snap used for line endpoint resize. Unlike computeSnap,
+ * which treats the drag as a sized rect and considers other shapes'
+ * centres as snap targets (sensible for whole-object alignment, weird
+ * for endpoint alignment — picking the middle of a neighbour line as a
+ * snap target produces the "snaps to 50%" artefact users reported).
+ * Considers only edges of others + the label rect.
+ */
+export function computePointSnap(
+ point: { x: number; y: number },
+ others: SnapRect[],
+ threshold = SNAP_THRESHOLD_PX,
+ labelRect?: SnapRect,
+): { x: number; y: number; guides: SnapGuide[] } {
+ const snapAxisPt = (
+ drag: number,
+ dragPerp: number,
+ axis: 'x' | 'y',
+ ): { value: number; guides: SnapGuide[] } => {
+ let bestDelta = Infinity;
+ let bestValue = drag;
+ let bestGuides: SnapGuide[] = [];
+ const consider = (target: number, perpFrom: number, perpTo: number) => {
+ const d = Math.abs(target - drag);
+ if (d > threshold || d > bestDelta) return;
+ const guideOrientation: 'H' | 'V' = axis === 'x' ? 'V' : 'H';
+ const guide: SnapGuide = {
+ orientation: guideOrientation,
+ type: 'align',
+ pos: target,
+ from: Math.min(dragPerp, perpFrom) - GUIDE_PADDING_PX,
+ to: Math.max(dragPerp, perpTo) + GUIDE_PADDING_PX,
+ };
+ if (d < bestDelta) {
+ // Strictly closer — replace the best candidate.
+ bestDelta = d;
+ bestValue = target;
+ bestGuides = [guide];
+ } else if (target === bestValue) {
+ // Same edge value at the same distance — accumulate so each
+ // contributing object draws its own guide line.
+ bestGuides.push(guide);
+ }
+ };
+ for (const o of others) {
+ const startEdge = axis === 'x' ? o.x : o.y;
+ const endEdge = startEdge + (axis === 'x' ? o.width : o.height);
+ const perpStart = axis === 'x' ? o.y : o.x;
+ const perpEnd = perpStart + (axis === 'x' ? o.height : o.width);
+ consider(startEdge, perpStart, perpEnd);
+ consider(endEdge, perpStart, perpEnd);
+ }
+ if (labelRect) {
+ // Label edges *and* center are valid endpoint snap targets. Centre
+ // is intentionally only allowed for the label (not for other
+ // objects) — endpoint alignment to a neighbour's midpoint produced
+ // the "50 %" artefact this helper was created to avoid.
+ const startEdge = axis === 'x' ? labelRect.x : labelRect.y;
+ const size = axis === 'x' ? labelRect.width : labelRect.height;
+ const endEdge = startEdge + size;
+ const center = startEdge + size / 2;
+ const perpStart = axis === 'x' ? labelRect.y : labelRect.x;
+ const perpEnd = perpStart + (axis === 'x' ? labelRect.height : labelRect.width);
+ consider(startEdge, perpStart, perpEnd);
+ consider(center, perpStart, perpEnd);
+ consider(endEdge, perpStart, perpEnd);
+ }
+ return { value: bestValue, guides: bestGuides };
+ };
+
+ const xRes = snapAxisPt(point.x, point.y, 'x');
+ const yRes = snapAxisPt(point.y, point.x, 'y');
+ return { x: xRes.value, y: yRes.value, guides: [...xRes.guides, ...yRes.guides] };
+}
+
export interface SnapRect {
id: string;
x: number;