Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 20 additions & 32 deletions src/components/Canvas/BarcodeObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -435,7 +446,7 @@ export function BarcodeObject({
strokeWidth={isSelected ? 2 : 0}
strokeScaleEnabled={false}
/>
{textNodes}
{textNodes.length > 0 && <Group ref={excludeGroupFromBbox}>{textNodes}</Group>}
</Group>
);
}
Expand Down Expand Up @@ -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. */}
<Rect
x={0}
y={0}
width={Math.max(w, 1)}
height={Math.max(h, 1)}
fill="transparent"
listening={false}
/>
{/* 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. */}
<KImage
x={btX}
y={btY}
Expand Down Expand Up @@ -685,23 +688,16 @@ export function BarcodeObject({
onDragMove={(e) => 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. */}
<Rect
x={0} y={0}
width={Math.max(w, 1)} height={Math.max(h, 1)}
fill="transparent" listening={false}
/>
<KImage x={btX} y={btY} image={barcodeCanvas} crop={bitmapCrop}
width={bw} height={bh}
imageSmoothingEnabled={false}
stroke={isSelected ? colors.selection : undefined}
strokeWidth={isSelected ? 2 : 0}
strokeScaleEnabled={false}
/>
{textElements}
{textElements && (
<Group ref={excludeGroupFromBbox}>{textElements}</Group>
)}
</Group>
);
}
Expand All @@ -721,14 +717,6 @@ export function BarcodeObject({
onDragMove={handleDragMove}
onDragEnd={handleDragEnd}
>
<Rect
x={0}
y={0}
width={Math.max(w, 1)}
height={Math.max(h, 1)}
fill="transparent"
listening={false}
/>
<KImage
x={btX}
y={btY}
Expand Down
7 changes: 3 additions & 4 deletions src/components/Canvas/LineObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 });
Expand Down
62 changes: 62 additions & 0 deletions src/lib/snapGuides.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import {
computeSnap,
computePointSnap,
computeResizeSnap,
deriveActiveEdges,
SNAP_THRESHOLD_PX,
Expand Down Expand Up @@ -166,3 +167,64 @@ 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);
// 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
});
});
75 changes: 75 additions & 0 deletions src/lib/snapGuides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};
Comment thread
u8array marked this conversation as resolved.
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);
}
Comment thread
u8array marked this conversation as resolved.
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;
Expand Down