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;