From 19c51841566ccf0e6e982e3ad7ce23d6968cab98 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 23:06:10 +0200 Subject: [PATCH 1/4] feat(canvas): selection wraps barcode bars only, not the firmware text-zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Barcodes used to pad their Konva Group's bbox out to the full ZPL footprint via an invisible Rect at (w, h). That made the Transformer's selection border, the smart-align centring, and drag-time object-snap all latch onto the padded edges. For EAN/UPC the gap was a firmware- reserved text zone (rendered as empty space), so selection appeared disconnected from the visible bars even with HRI off. Two changes that route every consumer through the *same* bbox via plain getClientRect: - Drop the three padding Rects from BarcodeObject (default, default- no-HRI, EAN/UPC rotated branches). The HRI Text already has getSelfRect=0; without the padding the Group's bbox naturally collapses to the bar image. - EAN/UPC HRI digits are rendered as multiple nodes — these *do* contribute to the bbox by default. Wrap them in a Group whose getClientRect is overridden to {0,0,0,0}, applying the same 'bars- only' rule to the multi-text variant without threading refs through 10+ individual Text components. Smart-align, drag-snap and the Transformer selection border all now target the bar rect without any external helper or per-shape special case in LabelCanvas. --- src/components/Canvas/BarcodeObject.tsx | 50 +++++++++---------------- 1 file changed, 18 insertions(+), 32 deletions(-) diff --git a/src/components/Canvas/BarcodeObject.tsx b/src/components/Canvas/BarcodeObject.tsx index 8d55e818..07307e3e 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 && {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 +715,6 @@ export function BarcodeObject({ onDragMove={handleDragMove} onDragEnd={handleDragEnd} > - Date: Sun, 10 May 2026 23:06:21 +0200 Subject: [PATCH 2/4] feat(snap): edge-only point snap for line endpoint resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Line endpoint snap previously routed through computeSnap with a zero-size SnapRect, which still emitted the *centre* of every neighbour as a candidate (sensible for whole-object alignment, surprising for endpoints — dragging a line endpoint toward another shape could latch onto the midpoint of that shape and produce a guide at '50 %' rather than at an actual edge). New computePointSnap helper considers only edges of others + the label rect. LineObject's snapEndpoint now calls it instead. Adds 5 unit tests pinning the no-centre rule, label-edge support, threshold honour and tie-break between competing edges. --- src/components/Canvas/LineObject.tsx | 7 ++-- src/lib/snapGuides.test.ts | 50 +++++++++++++++++++++++ src/lib/snapGuides.ts | 60 ++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 4 deletions(-) diff --git a/src/components/Canvas/LineObject.tsx b/src/components/Canvas/LineObject.tsx index 50f3b9f2..040b98da 100644 --- a/src/components/Canvas/LineObject.tsx +++ b/src/components/Canvas/LineObject.tsx @@ -5,7 +5,7 @@ import type { LabelObject } from "../../registry"; import { dotsToPx, pxToDots } from "../../lib/coordinates"; import { constrainLine, type ConstrainMode } from "../../lib/lineConstrain"; import { useColorScheme } from "../../lib/useColorScheme"; -import { computeSnap, type SnapRect } from "../../lib/snapGuides"; +import { computePointSnap, type SnapRect } from "../../lib/snapGuides"; import { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps"; /** Endpoint-handle visuals — small white square with a thin selection @@ -162,12 +162,11 @@ export function LineObject({ } const transform = parent.getAbsoluteTransform(); const stagePx = transform.point(localPx); - const result = computeSnap( - { id: obj.id, x: stagePx.x, y: stagePx.y, width: 0, height: 0 }, + const result = computePointSnap( + stagePx, othersSnapshotRef.current, undefined, labelRect, - labelRect, ); setGuides(result.guides); const back = transform.copy().invert().point({ x: result.x, y: result.y }); diff --git a/src/lib/snapGuides.test.ts b/src/lib/snapGuides.test.ts index c39677b5..288c24ac 100644 --- a/src/lib/snapGuides.test.ts +++ b/src/lib/snapGuides.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { computeSnap, + computePointSnap, computeResizeSnap, deriveActiveEdges, SNAP_THRESHOLD_PX, @@ -166,3 +167,52 @@ describe("computeResizeSnap", () => { }); }); }); + +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); + const result = computePointSnap({ x: 998, y: 300 }, [], 6, lbl); + expect(result.x).toBe(1000); // right edge of label + expect(result.guides).toHaveLength(1); + }); + + 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..d4f21aa0 100644 --- a/src/lib/snapGuides.ts +++ b/src/lib/snapGuides.ts @@ -2,6 +2,66 @@ 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 bestGuide: SnapGuide | null = null; + const consider = (target: number, perpFrom: number, perpTo: number) => { + const d = Math.abs(target - drag); + if (d > threshold || d >= bestDelta) return; + bestDelta = d; + bestValue = target; + const guideOrientation: 'H' | 'V' = axis === 'x' ? 'V' : 'H'; + bestGuide = { + orientation: guideOrientation, + type: 'align', + pos: target, + from: Math.min(dragPerp, perpFrom) - GUIDE_PADDING_PX, + to: Math.max(dragPerp, perpTo) + GUIDE_PADDING_PX, + }; + }; + 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) { + const startEdge = axis === 'x' ? labelRect.x : labelRect.y; + const endEdge = startEdge + (axis === 'x' ? labelRect.width : labelRect.height); + const perpStart = axis === 'x' ? labelRect.y : labelRect.x; + const perpEnd = perpStart + (axis === 'x' ? labelRect.height : labelRect.width); + consider(startEdge, perpStart, perpEnd); + consider(endEdge, perpStart, perpEnd); + } + return { value: bestValue, guides: bestGuide ? [bestGuide] : [] }; + }; + + 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; From 8b002fbdd4d1db26e9e67b4e283e5dbc383d618c Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 23:12:24 +0200 Subject: [PATCH 3/4] refactor: address gemini review on PR #52 - computePointSnap now accumulates all guides at the same best distance, mirroring computeSnap's behaviour: when two neighbour objects share an edge with the dragged endpoint, both guide lines render instead of only the first-encountered one. - BarcodeObject's text-group wrapper guards on .length > 0 instead of truthy. textNodes / textElements are arrays initialised to [] / undefined respectively; an empty array is truthy and would otherwise spawn an empty + ref callback every render. --- src/components/Canvas/BarcodeObject.tsx | 6 ++++-- src/lib/snapGuides.ts | 20 ++++++++++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/components/Canvas/BarcodeObject.tsx b/src/components/Canvas/BarcodeObject.tsx index 07307e3e..2ded9ea6 100644 --- a/src/components/Canvas/BarcodeObject.tsx +++ b/src/components/Canvas/BarcodeObject.tsx @@ -446,7 +446,7 @@ export function BarcodeObject({ strokeWidth={isSelected ? 2 : 0} strokeScaleEnabled={false} /> - {textNodes && {textNodes}} + {textNodes.length > 0 && {textNodes}} ); } @@ -695,7 +695,9 @@ export function BarcodeObject({ strokeWidth={isSelected ? 2 : 0} strokeScaleEnabled={false} /> - {textElements && {textElements}} + {Array.isArray(textElements) && textElements.length > 0 && ( + {textElements} + )} ); } diff --git a/src/lib/snapGuides.ts b/src/lib/snapGuides.ts index d4f21aa0..0a630b54 100644 --- a/src/lib/snapGuides.ts +++ b/src/lib/snapGuides.ts @@ -23,20 +23,28 @@ export function computePointSnap( ): { value: number; guides: SnapGuide[] } => { let bestDelta = Infinity; let bestValue = drag; - let bestGuide: SnapGuide | null = null; + let bestGuides: SnapGuide[] = []; const consider = (target: number, perpFrom: number, perpTo: number) => { const d = Math.abs(target - drag); - if (d > threshold || d >= bestDelta) return; - bestDelta = d; - bestValue = target; + if (d > threshold || d > bestDelta) return; const guideOrientation: 'H' | 'V' = axis === 'x' ? 'V' : 'H'; - bestGuide = { + 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; @@ -54,7 +62,7 @@ export function computePointSnap( consider(startEdge, perpStart, perpEnd); consider(endEdge, perpStart, perpEnd); } - return { value: bestValue, guides: bestGuide ? [bestGuide] : [] }; + return { value: bestValue, guides: bestGuides }; }; const xRes = snapAxisPt(point.x, point.y, 'x'); From 0826d9d925218d8d53aeadcf63971573d22223de Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 23:20:27 +0200 Subject: [PATCH 4/4] refactor: address gemini review round 2 on PR #52 - BarcodeObject: revert textElements guard from Array.isArray to truthy check. Non-EAN rotated barcodes assign a single ReactNode (not an array), which the stricter check incorrectly filtered out, killing HRI text rendering. - snapGuides.computePointSnap: allow snapping to the label rect's centre in addition to its edges. Neighbour objects stay edge-only so the original 50%-snap regression remains fixed. --- src/components/Canvas/BarcodeObject.tsx | 2 +- src/lib/snapGuides.test.ts | 14 +++++++++++++- src/lib/snapGuides.ts | 9 ++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/components/Canvas/BarcodeObject.tsx b/src/components/Canvas/BarcodeObject.tsx index 2ded9ea6..ebde5b77 100644 --- a/src/components/Canvas/BarcodeObject.tsx +++ b/src/components/Canvas/BarcodeObject.tsx @@ -695,7 +695,7 @@ export function BarcodeObject({ strokeWidth={isSelected ? 2 : 0} strokeScaleEnabled={false} /> - {Array.isArray(textElements) && textElements.length > 0 && ( + {textElements && ( {textElements} )} diff --git a/src/lib/snapGuides.test.ts b/src/lib/snapGuides.test.ts index 288c24ac..6263be43 100644 --- a/src/lib/snapGuides.test.ts +++ b/src/lib/snapGuides.test.ts @@ -192,9 +192,21 @@ describe("computePointSnap", () => { 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.guides).toHaveLength(1); + 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", () => { diff --git a/src/lib/snapGuides.ts b/src/lib/snapGuides.ts index 0a630b54..0c028229 100644 --- a/src/lib/snapGuides.ts +++ b/src/lib/snapGuides.ts @@ -55,11 +55,18 @@ export function computePointSnap( 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 endEdge = startEdge + (axis === 'x' ? labelRect.width : labelRect.height); + 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 };