From c921beef3212876f9fa55b5852ded096fad6e7b0 Mon Sep 17 00:00:00 2001 From: u8array Date: Mon, 4 May 2026 23:54:50 +0200 Subject: [PATCH 1/2] feat: Implement snap-to-grid and object snapping for resize Introduces snap-to-grid functionality for resizing labels, mirroring the behavior of drag-and-drop snapping. Also enhances object snapping during resize operations, ensuring visual alignment with other elements on the canvas. This includes logic for deriving active edges during resize and computing snap points based on proximity to other objects and the label boundary. --- src/components/Canvas/LabelCanvas.tsx | 15 + .../Canvas/hooks/useKonvaTransformer.ts | 149 +++++++-- src/lib/snapGuides.test.ts | 168 ++++++++++ src/lib/snapGuides.ts | 295 ++++++++++++++++-- 4 files changed, 569 insertions(+), 58 deletions(-) create mode 100644 src/lib/snapGuides.test.ts diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index ca49801a..e9f3204d 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -240,6 +240,18 @@ export function LabelCanvas({ onStageMouseDown, } = useCanvasLasso({ containerRef, stageRef, spaceDown, selectObjects }); + // Snap / guide infrastructure used by both drag and resize. The label rect + // here matches the visual (rotation-aware) bounds — snap math operates in + // stage-screen space, so it must reflect what the user sees, not the + // un-rotated layout coordinates. + const transformerSnapLabelRect = { + id: "_lbl", + x: visualLabelX, + y: visualLabelY, + width: visualLabelWidthPx, + height: visualLabelHeightPx, + }; + const { rotateEnabled, resizeEnabled, @@ -258,6 +270,9 @@ export function LabelCanvas({ labelOffsetY, snap, updateObject, + labelRect: transformerSnapLabelRect, + objectSnapEnabled: !snapEnabled, + setGuides, }); const handleObjectChange = ( diff --git a/src/components/Canvas/hooks/useKonvaTransformer.ts b/src/components/Canvas/hooks/useKonvaTransformer.ts index 1942a1d6..b60fcc25 100644 --- a/src/components/Canvas/hooks/useKonvaTransformer.ts +++ b/src/components/Canvas/hooks/useKonvaTransformer.ts @@ -14,6 +14,78 @@ import { type BoundingBox, } from "../transformerGeometry"; import { modelPositionFromRenderedTopLeft } from "../transformPosition"; +import { + computeResizeSnap, + deriveActiveEdges, + type SnapGuide, + type SnapRect, +} from "../../../lib/snapGuides"; + +/** Pack a Konva clientRect into the SnapRect shape used by snap helpers. */ +function toSnapRect(id: string, rect: { x: number; y: number; width: number; height: number }): SnapRect { + return { id, x: rect.x, y: rect.y, width: rect.width, height: rect.height }; +} + +/** + * Snapshot every object's stage-space bbox at transform start. Other objects + * can't move during a resize, so we cache once and avoid per-tick Konva queries. + */ +function captureOtherRects( + stage: Konva.Stage, + objects: LabelObject[], + excludeId: string, +): SnapRect[] { + const result: SnapRect[] = []; + for (const o of objects) { + if (o.id === excludeId) continue; + const n = stage.findOne(`#${o.id}`); + if (!n) continue; + const cr = n.getClientRect({ skipShadow: true, skipStroke: true, relativeTo: stage }); + result.push(toSnapRect(o.id, cr)); + } + return result; +} + +/** + * Phase 1 of resize: snap height to whole rowHeight increments for stacked-2D + * barcodes (or to whole dots otherwise), and pin the bottom edge if the resize + * originates from the top anchor. + */ +function applyHeightSnap( + oldBox: BoundingBox, + newBox: BoundingBox, + dotPx: number, + anchor: { nodeHeight: number; rowHeight: number } | null, +): BoundingBox { + const stepPx = + anchor && anchor.rowHeight > 0 && anchor.nodeHeight > 0 + ? anchor.nodeHeight / anchor.rowHeight + : dotPx; + const snappedH = snapBoxHeight(newBox.height, stepPx); + return isTopAnchorResize(oldBox, newBox, dotPx * 0.5) + ? pinBottomEdge(oldBox, newBox, snappedH) + : { ...newBox, height: snappedH }; +} + +/** + * Phase 2 of resize: snap moving edges to other objects' / label edges + * (object-snap, mirrors drag-time snap). Pure delegation to `computeResizeSnap` + * with input shape conversion. + */ +function applyResizeObjectSnap( + bbox: BoundingBox, + startBbox: SnapRect, + others: SnapRect[], + labelRect: SnapRect, +): { bbox: BoundingBox; guides: SnapGuide[] } { + const draggedRect = toSnapRect(startBbox.id, bbox); + const activeEdges = deriveActiveEdges(startBbox, draggedRect); + const result = computeResizeSnap(draggedRect, others, activeEdges, undefined, labelRect, labelRect); + return { + bbox: { x: result.x, y: result.y, width: result.width, height: result.height, rotation: bbox.rotation }, + guides: result.guides, + }; +} interface Options { transformerRef: React.RefObject; @@ -26,6 +98,12 @@ interface Options { labelOffsetY: number; snap: (dots: number) => number; updateObject: (id: string, changes: ObjectChanges) => void; + /** Label rect in stage-screen space, used as a snap target. */ + labelRect: SnapRect; + /** True when grid-snap is OFF — object-snap during resize mirrors drag. */ + objectSnapEnabled: boolean; + /** Pushes resize-time snap guides into the canvas's shared guide state. */ + setGuides: (guides: SnapGuide[]) => void; } export interface TransformerState { @@ -48,10 +126,20 @@ export function useKonvaTransformer({ labelOffsetY, snap, updateObject, + labelRect, + objectSnapEnabled, + setGuides, }: Options): TransformerState { // Captures node height and rowHeight at drag start so boundBoxFunc uses a // fixed step size throughout the entire drag session. const transformAnchorRef = useRef<{ nodeHeight: number; rowHeight: number } | null>(null); + // Captures the bbox at transform start so deriveActiveEdges can detect which + // edges are moving relative to the start state (oldBox in boundBoxFunc is the + // previous frame, which would always look "everything moved"). + const transformStartBboxRef = useRef(null); + // Snapshot of the other objects' bboxes at transform start. Other objects + // can't move during a resize, so we avoid re-querying Konva on every tick. + const othersSnapshotRef = useRef([]); // Stable key of selected object types — avoids re-running on every drag-move // position update (which changes objects but not the types of selected objects). @@ -91,6 +179,15 @@ export function useKonvaTransformer({ : BARCODE_1D_TYPES.has(objects.find((o) => o.id === selectedIds[0])?.type ?? "") ? ["top-center", "bottom-center"] : undefined; + const isFreeResize = enabledAnchors === undefined; + + /** Reset all transform-time state. Idempotent; safe to call from any exit path. */ + function cleanupTransformState() { + transformAnchorRef.current = null; + transformStartBboxRef.current = null; + othersSnapshotRef.current = []; + setGuides([]); + } const onTransformStart = () => { const singleId = selectedIds[0]; @@ -98,39 +195,43 @@ export function useKonvaTransformer({ const node = stageRef.current.findOne(`#${singleId}`); if (!node) return; const obj = objects.find((o) => o.id === singleId); - if (obj && STACKED_2D_TYPES.has(obj.type)) { - transformAnchorRef.current = { - nodeHeight: node.height(), - rowHeight: (obj.props as { rowHeight: number }).rowHeight, - }; - } else { - transformAnchorRef.current = null; - } + transformAnchorRef.current = obj && STACKED_2D_TYPES.has(obj.type) + ? { nodeHeight: node.height(), rowHeight: (obj.props as { rowHeight: number }).rowHeight } + : null; + const startRect = node.getClientRect({ skipShadow: true, skipStroke: true, relativeTo: stageRef.current }); + transformStartBboxRef.current = toSnapRect(singleId, startRect); + othersSnapshotRef.current = captureOtherRects(stageRef.current, objects, singleId); }; const boundBoxFunc = (oldBox: BoundingBox, newBox: BoundingBox): BoundingBox => { if (newBox.width < 10 || newBox.height < 10) return oldBox; const dotPx = scale / dpmm; - // For stacked 2D barcodes, snap to whole rowHeight increments. stepPx is - // derived from the node height captured at drag start (not oldBox.height, - // which mutates each call and would drift). - const anchor = transformAnchorRef.current; - const stepPx = - anchor && anchor.rowHeight > 0 && anchor.nodeHeight > 0 - ? anchor.nodeHeight / anchor.rowHeight - : dotPx; - const snappedH = snapBoxHeight(newBox.height, stepPx); - if (isTopAnchorResize(oldBox, newBox, dotPx * 0.5)) { - return pinBottomEdge(oldBox, newBox, snappedH); + let bbox = applyHeightSnap(oldBox, newBox, dotPx, transformAnchorRef.current); + + // Object-snap during resize (mirrors drag-time snap). Only fires when + // grid-snap is off and the user is doing a free corner-resize — the 1D / + // stacked-2D anchor restrictions have their own height math above and + // would conflict with edge-driven snapping. + const startBbox = transformStartBboxRef.current; + if (objectSnapEnabled && isFreeResize && startBbox) { + const snapped = applyResizeObjectSnap(bbox, startBbox, othersSnapshotRef.current, labelRect); + setGuides(snapped.guides); + bbox = snapped.bbox; } - return { ...newBox, height: snappedH }; + return bbox; }; const onTransformEnd = () => { - if (selectedIds.length !== 1 || !selectedIds[0] || !stageRef.current) return; + if (selectedIds.length !== 1 || !selectedIds[0] || !stageRef.current) { + cleanupTransformState(); + return; + } const singleId = selectedIds[0]; const node = stageRef.current.findOne(`#${singleId}`); - if (!node) return; + if (!node) { + cleanupTransformState(); + return; + } const sx = node.scaleX(); const sy = node.scaleY(); const nodeWidth = node.width(); @@ -139,7 +240,7 @@ export function useKonvaTransformer({ node.scaleY(1); const obj = currentObjects(useLabelStore.getState()).find((o) => o.id === singleId); if (!obj) { - transformAnchorRef.current = null; + cleanupTransformState(); return; } const isCenterAnchored = ObjectRegistry[obj.type]?.nodeOrigin === "center"; @@ -175,7 +276,7 @@ export function useKonvaTransformer({ }); updateObject(singleId, { ...pos, props: propChanges }); } - transformAnchorRef.current = null; + cleanupTransformState(); }; return { diff --git a/src/lib/snapGuides.test.ts b/src/lib/snapGuides.test.ts new file mode 100644 index 00000000..c39677b5 --- /dev/null +++ b/src/lib/snapGuides.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from "vitest"; +import { + computeSnap, + computeResizeSnap, + deriveActiveEdges, + SNAP_THRESHOLD_PX, + type SnapRect, + type ActiveEdges, +} from "./snapGuides"; + +const r = (id: string, x: number, y: number, w: number, h: number): SnapRect => ({ + id, + x, + y, + width: w, + height: h, +}); + +describe("computeSnap — drag (default activeEdges)", () => { + describe("edge / center alignment", () => { + it("snaps left-edge to another object's left-edge within threshold", () => { + const dragged = r("d", 100 + 2, 200, 50, 50); // 2 px right of target + const other = r("o", 100, 50, 60, 60); + const { x, guides } = computeSnap(dragged, [other]); + expect(x).toBe(100); + expect(guides.some((g) => g.orientation === "V" && g.type === "align" && g.pos === 100)).toBe(true); + }); + + it("snaps right-edge to another object's right-edge", () => { + // dragged.right = 150 + 50 = 200, other.right = 60 + 140 = 200 + const dragged = r("d", 152, 0, 50, 50); + const other = r("o", 60, 200, 140, 50); + const { x } = computeSnap(dragged, [other]); + expect(x).toBe(150); + }); + + it("snaps center-x to another object's center-x", () => { + // dragged center = 100 + 25 = 125, other center = 50 + 150 = 125 + const dragged = r("d", 102, 0, 50, 50); + const other = r("o", 50, 200, 150, 50); + const { x } = computeSnap(dragged, [other]); + expect(x).toBe(100); + }); + + it("does not snap when distance exceeds threshold", () => { + const dragged = r("d", 100 + SNAP_THRESHOLD_PX + 1, 0, 50, 50); + const other = r("o", 100, 200, 50, 50); + const { x } = computeSnap(dragged, [other]); + expect(x).toBe(dragged.x); + }); + }); + + describe("equal spacing", () => { + it("snaps to equal spacing between two consecutive others", () => { + // Objects at x=0..40 and x=200..240, gap of 160 between them. + // Place dragged (40 wide) centered between them so left-gap = right-gap = 60. + const a = r("a", 0, 100, 40, 40); + const b = r("b", 200, 100, 40, 40); + const dragged = r("d", 102, 100, 40, 40); // ideal center x = 100 + const { x, guides } = computeSnap(dragged, [a, b]); + expect(x).toBe(100); + expect(guides.some((g) => g.type === "space")).toBe(true); + }); + }); + + describe("label alignment", () => { + it("snaps to label center", () => { + const labelRect = r("_lbl", 0, 0, 200, 200); + // dragged center near label center = 100; place dragged at 73 (center 88) → not in threshold? Place at 76 (center 91) — within 6 of 100 + const dragged = r("d", 76, 0, 30, 30); // center 91, target 100, distance 9 — outside + const inThresh = r("d", 86, 0, 30, 30); // center 101, target 100, distance 1 + const out = computeSnap(dragged, [], undefined, labelRect, labelRect); + expect(out.x).toBe(dragged.x); // outside threshold + const inResult = computeSnap(inThresh, [], undefined, labelRect, labelRect); + expect(inResult.x).toBe(85); // center 100 + }); + }); +}); + +describe("deriveActiveEdges", () => { + it("flags only the moved edges for a BR resize", () => { + const oldBox = r("o", 100, 100, 50, 50); + const newBox = r("n", 100, 100, 70, 65); + expect(deriveActiveEdges(oldBox, newBox)).toEqual({ + left: false, + right: true, + top: false, + bottom: true, + }); + }); + + it("flags both edges of one axis when scaling around center", () => { + const oldBox = r("o", 100, 100, 50, 50); + const newBox = r("n", 90, 100, 70, 50); + const e = deriveActiveEdges(oldBox, newBox); + expect(e.left).toBe(true); + expect(e.right).toBe(true); + expect(e.top).toBe(false); + expect(e.bottom).toBe(false); + }); + + it("ignores sub-tolerance jitter", () => { + const oldBox = r("o", 100, 100, 50, 50); + const newBox = r("n", 100.2, 100.1, 50.3, 50.4); + expect(deriveActiveEdges(oldBox, newBox, 0.5)).toEqual({ + left: false, + right: false, + top: false, + bottom: false, + }); + }); +}); + +describe("computeResizeSnap", () => { + const allEdges: ActiveEdges = { left: true, right: true, top: true, bottom: true }; + const brOnly: ActiveEdges = { left: false, right: true, top: false, bottom: true }; + const tlOnly: ActiveEdges = { left: true, right: false, top: true, bottom: false }; + + describe("edge alignment", () => { + it("BR resize: snaps right edge to another object's left edge", () => { + // dragged at x=10, w=88 → right at 98. Other.left = 100, distance 2 → within threshold. + const newBox = r("n", 10, 10, 88, 50); + const other = r("o", 100, 0, 60, 200); + const result = computeResizeSnap(newBox, [other], brOnly); + expect(result.x).toBe(10); + expect(result.width).toBe(90); // right snapped to 100, x kept + expect(result.guides.some((g) => g.type === "align" && g.pos === 100)).toBe(true); + }); + + it("BR resize: snaps bottom edge to another object's top edge", () => { + const newBox = r("n", 0, 10, 50, 87); + const other = r("o", 200, 100, 80, 80); + const result = computeResizeSnap(newBox, [other], brOnly); + expect(result.y).toBe(10); + expect(result.height).toBe(90); // bottom 97 → 100 + }); + + it("TL resize: snaps left edge to another object's right edge", () => { + // dragged at x=98, w=50 → left at 98. Other.right = 100, distance 2. + const newBox = r("n", 98, 0, 50, 50); + const other = r("o", 40, 200, 60, 50); + const result = computeResizeSnap(newBox, [other], tlOnly); + expect(result.x).toBe(100); + expect(result.width).toBe(48); // right edge of newBox (148) was kept; left moved 98 → 100 + }); + + it("does not snap a static edge", () => { + // dragged at x=98, w=50, only LEFT active. Right edge sits at 148. + // other.right = 150 (delta 2 from 148, would snap right if active). + // other anchors (70, 110, 150) are all >= 12 px away from left=98, so the + // active LEFT also does not snap. Right is inactive → must stay at 148. + const newBox = r("n", 98, 0, 50, 50); + const other = r("o", 70, 200, 80, 30); // other.left=70, center=110, right=150 + const onlyLeft: ActiveEdges = { left: true, right: false, top: false, bottom: false }; + const result = computeResizeSnap(newBox, [other], onlyLeft); + expect(result.x).toBe(98); + expect(result.width).toBe(50); // right pinned, not snapped to 150 + }); + + it("returns input unchanged when nothing is in threshold", () => { + const newBox = r("n", 0, 0, 50, 50); + const other = r("o", 200, 200, 70, 70); + const result = computeResizeSnap(newBox, [other], allEdges); + expect(result).toMatchObject({ x: 0, y: 0, width: 50, height: 50 }); + expect(result.guides).toHaveLength(0); + }); + }); +}); diff --git a/src/lib/snapGuides.ts b/src/lib/snapGuides.ts index 707d5558..d3ac8da8 100644 --- a/src/lib/snapGuides.ts +++ b/src/lib/snapGuides.ts @@ -33,6 +33,73 @@ interface AxisInfo { perpSize: number; } +interface AnchorCandidate { + /** Anchor position to align against (start, center, or end of the candidate). */ + value: number; + /** Guide line emitted when this anchor wins. */ + guide: SnapGuide; +} + +/** + * The 3 anchors of `other` (start/center/end) as alignment candidates. + * Guide spans the perpendicular extent covering both `drag*` and `other`. + */ +function objectAnchorCandidates( + other: AxisInfo, + dragPerp: number, + dragPerpSize: number, + alignOrientation: 'H' | 'V', +): AnchorCandidate[] { + const oStart = other.pos; + const oCenter = other.pos + other.size / 2; + const oEnd = other.pos + other.size; + const perpFrom = Math.min(dragPerp, other.perp) - 8; + const perpTo = Math.max(dragPerp + dragPerpSize, other.perp + other.perpSize) + 8; + return [oStart, oCenter, oEnd].map(value => ({ + value, + guide: { orientation: alignOrientation, type: 'align', pos: value, from: perpFrom, to: perpTo }, + })); +} + +/** The 3 anchors of the label rect (start/center/end) as alignment candidates. */ +function labelAnchorCandidates( + labelAxis: AxisInfo, + alignOrientation: 'H' | 'V', + labelExtent: { from: number; to: number } | undefined, +): AnchorCandidate[] { + const lStart = labelAxis.pos; + const lCenter = labelAxis.pos + labelAxis.size / 2; + const lEnd = labelAxis.pos + labelAxis.size; + const perpFrom = labelExtent ? labelExtent.from : labelAxis.perp; + const perpTo = labelExtent ? labelExtent.to : labelAxis.perp + labelAxis.perpSize; + return [lStart, lCenter, lEnd].map(value => ({ + value, + guide: { orientation: alignOrientation, type: 'align', pos: value, from: perpFrom, to: perpTo }, + })); +} + +/** + * Keep only the 2 nearest objects per direction from the drag axis. Prevents + * snapping to distant objects on the other side of the label, and bounds the + * candidate count for performance. Equal-spacing math (drag-only) needs 2 (not + * 1) per side because it operates on consecutive pairs. + */ +function filterNearby(dragPos: number, dragSize: number, others: AxisInfo[]): AxisInfo[] { + const dragEnd = dragPos + dragSize; + const leftOf = others + .filter(o => o.pos + o.size <= dragPos) + .sort((a, b) => (b.pos + b.size) - (a.pos + a.size)) + .slice(0, 2); + const rightOf = others + .filter(o => o.pos >= dragEnd) + .sort((a, b) => a.pos - b.pos) + .slice(0, 2); + const overlapping = others.filter( + o => o.pos < dragEnd && o.pos + o.size > dragPos, + ); + return [...leftOf, ...overlapping, ...rightOf]; +} + /** * Computes object-snap for a dragged rect against stationary rects. * All values are stage pixels. Returns the snapped position and guide lines. @@ -82,22 +149,7 @@ function snapAxis( let snapped = drag.pos; let guides: SnapGuide[] = []; - // Keep only the 2 nearest objects per direction from the dragged object's bounding box. - // This prevents snapping to distant objects on the other side of the label. - // 2 (not 1) because equal spacing needs a consecutive pair in the same direction. - // leftOf/rightOf are mutually exclusive with overlapping, so no deduplication needed. - const leftOf = others - .filter(o => o.pos + o.size <= drag.pos) - .sort((a, b) => (b.pos + b.size) - (a.pos + a.size)) - .slice(0, 2); - const rightOf = others - .filter(o => o.pos >= drag.pos + drag.size) - .sort((a, b) => a.pos - b.pos) - .slice(0, 2); - const overlapping = others.filter( - o => o.pos < drag.pos + drag.size && o.pos + o.size > drag.pos, - ); - const nearby = [...leftOf, ...overlapping, ...rightOf]; + const nearby = filterNearby(drag.pos, drag.size, others); function trySnap(newPos: number, newGuides: SnapGuide[]) { const d = Math.abs(newPos - drag.pos); @@ -111,17 +163,12 @@ function snapAxis( } } - // Alignment: snap any of 3 drag anchors (start / center / end) to any of 3 other anchors + // Alignment: any of 3 drag anchors (start / center / end) → any of 3 other anchors. + const dragAnchors = [drag.pos, drag.pos + drag.size / 2, drag.pos + drag.size]; for (const other of nearby) { - const dragAnchors = [drag.pos, drag.pos + drag.size / 2, drag.pos + drag.size ]; - const otherAnchors = [other.pos, other.pos + other.size / 2, other.pos + other.size]; - for (const da of dragAnchors) { - for (const oa of otherAnchors) { - const newPos = drag.pos + (oa - da); - // Guide spans between the two objects only (±8px padding) - const perpFrom = Math.min(drag.perp, other.perp) - 8; - const perpTo = Math.max(drag.perp + drag.perpSize, other.perp + other.perpSize) + 8; - trySnap(newPos, [{ orientation: alignOrientation, type: 'align', pos: oa, from: perpFrom, to: perpTo }]); + for (const cand of objectAnchorCandidates(other, drag.perp, drag.perpSize, alignOrientation)) { + for (const da of dragAnchors) { + trySnap(drag.pos + (cand.value - da), [cand.guide]); } } } @@ -169,17 +216,197 @@ function snapAxis( // Label alignment: snap to label edges and center (separate from object nearest-2 filter) if (labelAxis) { - const dragAnchors = [drag.pos, drag.pos + drag.size / 2, drag.pos + drag.size]; - const lblAnchors = [labelAxis.pos, labelAxis.pos + labelAxis.size / 2, labelAxis.pos + labelAxis.size]; - const perpFrom = labelExtent ? labelExtent.from : labelAxis.perp; - const perpTo = labelExtent ? labelExtent.to : labelAxis.perp + labelAxis.perpSize; - for (const da of dragAnchors) { - for (const la of lblAnchors) { - const newPos = drag.pos + (la - da); - trySnap(newPos, [{ orientation: alignOrientation, type: 'align', pos: la, from: perpFrom, to: perpTo }]); + for (const cand of labelAnchorCandidates(labelAxis, alignOrientation, labelExtent)) { + for (const da of dragAnchors) { + trySnap(drag.pos + (cand.value - da), [cand.guide]); } } } return { snapped, guides }; } + +// ─── Resize-time snap ──────────────────────────────────────────────────────── + +export interface ActiveEdges { + left: boolean; + right: boolean; + top: boolean; + bottom: boolean; +} + +export interface ResizeSnapResult { + /** Snapped bounding-box top-left + size in stage pixels. */ + x: number; + y: number; + width: number; + height: number; + guides: SnapGuide[]; +} + +/** + * Derive which edges of `newBox` are moving relative to `oldBox`. Used to + * decide which edges should participate in resize-time snapping (the static + * edges have nothing to align). + */ +export function deriveActiveEdges( + oldBox: SnapRect, + newBox: SnapRect, + tolerance = 0.5, +): ActiveEdges { + return { + left: Math.abs(newBox.x - oldBox.x) > tolerance, + right: Math.abs((newBox.x + newBox.width) - (oldBox.x + oldBox.width)) > tolerance, + top: Math.abs(newBox.y - oldBox.y) > tolerance, + bottom: Math.abs((newBox.y + newBox.height) - (oldBox.y + oldBox.height)) > tolerance, + }; +} + +interface EdgeMatch { + /** Distance from the drag edge in px; Infinity = no match. */ + delta: number; + /** Pixel position to snap the active edge to. */ + pos: number; + /** Guides emitted when this match is selected. */ + guides: SnapGuide[]; +} + +/** + * Sentinel "no match yet" value. Returns a fresh object so callers can never + * accidentally bleed mutations (e.g. pushing into `guides`) into a shared + * reference. + */ +function noMatch(): EdgeMatch { + return { delta: Infinity, pos: 0, guides: [] }; +} + +function considerEdge(current: EdgeMatch, candidatePos: number, dragPos: number, guide: SnapGuide, threshold: number): EdgeMatch { + const d = Math.abs(candidatePos - dragPos); + if (d >= threshold) return current; + if (d < current.delta) return { delta: d, pos: candidatePos, guides: [guide] }; + if (d === current.delta && candidatePos === current.pos) { + return { ...current, guides: [...current.guides, guide] }; + } + return current; +} + +interface ResizeAxisInput { + /** Active leading edge (left for X, top for Y). */ + posActive: boolean; + /** Active trailing edge (right for X, bottom for Y). */ + endActive: boolean; + /** Current dragged value for the leading edge. */ + pos: number; + /** Current dragged size. */ + size: number; + /** Perpendicular extent of the dragged box, used for guide line spans. */ + perp: number; + perpSize: number; +} + +interface AxisSnapResult { + pos: number; + size: number; + guides: SnapGuide[]; +} + +function snapResizeAxis( + drag: ResizeAxisInput, + others: AxisInfo[], + threshold: number, + alignOrientation: 'H' | 'V', + labelAxis?: AxisInfo, + labelExtent?: { from: number; to: number }, +): AxisSnapResult { + const dragEnd = drag.pos + drag.size; + const nearby = filterNearby(drag.pos, drag.size, others); + + // Resize alignment ignores the dragged center by design — only edges align. + const candidates: AnchorCandidate[] = []; + for (const o of nearby) { + candidates.push(...objectAnchorCandidates(o, drag.perp, drag.perpSize, alignOrientation)); + } + if (labelAxis) { + candidates.push(...labelAnchorCandidates(labelAxis, alignOrientation, labelExtent)); + } + + let leadMatch = noMatch(); + let endMatch = noMatch(); + for (const c of candidates) { + if (drag.posActive) leadMatch = considerEdge(leadMatch, c.value, drag.pos, c.guide, threshold); + if (drag.endActive) endMatch = considerEdge(endMatch, c.value, dragEnd, c.guide, threshold); + } + + // Edge matches adjust pos OR size depending on which side is moving. + // The applied snap takes whichever edge has the smaller delta — both edges + // may have matches, but snapping both simultaneously would fight. + let pos = drag.pos; + let size = drag.size; + const guides: SnapGuide[] = []; + + if (leadMatch.delta < Infinity && leadMatch.delta <= endMatch.delta) { + pos = leadMatch.pos; + size = dragEnd - pos; + guides.push(...leadMatch.guides); + } else if (endMatch.delta < Infinity) { + size = endMatch.pos - drag.pos; + guides.push(...endMatch.guides); + } + + return { pos, size, guides }; +} + +/** + * Computes resize-time snap for a transformer-driven box against stationary + * rects. Only the edges in `activeEdges` participate. Returns the adjusted + * box and any guide lines to render. + * + * Distinct from `computeSnap` (drag) because the application is different: + * drag shifts the bbox; resize moves a single edge, changing the size. + */ +export function computeResizeSnap( + newBox: SnapRect, + others: SnapRect[], + activeEdges: ActiveEdges, + threshold = SNAP_THRESHOLD_PX, + /** Label bounds used for guide-line spans. */ + labelBounds?: { x: number; y: number; width: number; height: number }, + /** Full-size label rect for edge / center anchor matching. */ + labelRect?: SnapRect, +): ResizeSnapResult { + const xOthers = others.map(o => ({ pos: o.x, size: o.width, perp: o.y, perpSize: o.height })); + const yOthers = others.map(o => ({ pos: o.y, size: o.height, perp: o.x, perpSize: o.width })); + + const xLabelExtent = labelBounds ? { from: labelBounds.y, to: labelBounds.y + labelBounds.height } : undefined; + const yLabelExtent = labelBounds ? { from: labelBounds.x, to: labelBounds.x + labelBounds.width } : undefined; + + const xLblAxis = labelRect ? { pos: labelRect.x, size: labelRect.width, perp: labelRect.y, perpSize: labelRect.height } : undefined; + const yLblAxis = labelRect ? { pos: labelRect.y, size: labelRect.height, perp: labelRect.x, perpSize: labelRect.width } : undefined; + + const xResult = snapResizeAxis( + { + posActive: activeEdges.left, + endActive: activeEdges.right, + pos: newBox.x, size: newBox.width, + perp: newBox.y, perpSize: newBox.height, + }, + xOthers, threshold, 'V', xLblAxis, xLabelExtent, + ); + const yResult = snapResizeAxis( + { + posActive: activeEdges.top, + endActive: activeEdges.bottom, + pos: newBox.y, size: newBox.height, + perp: newBox.x, perpSize: newBox.width, + }, + yOthers, threshold, 'H', yLblAxis, yLabelExtent, + ); + + return { + x: xResult.pos, + y: yResult.pos, + width: xResult.size, + height: yResult.size, + guides: [...xResult.guides, ...yResult.guides], + }; +} From 7333f0f9909753b1d0003c757c600308a532a7c3 Mon Sep 17 00:00:00 2001 From: u8array Date: Tue, 5 May 2026 00:06:21 +0200 Subject: [PATCH 2/2] refactor(snap): extract GUIDE_PADDING_PX constant --- src/lib/snapGuides.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/snapGuides.ts b/src/lib/snapGuides.ts index d3ac8da8..8bf49627 100644 --- a/src/lib/snapGuides.ts +++ b/src/lib/snapGuides.ts @@ -1,4 +1,6 @@ export const SNAP_THRESHOLD_PX = 6; +/** Extra px the alignment guide extends beyond the dragged + matched objects. */ +const GUIDE_PADDING_PX = 8; export interface SnapRect { id: string; @@ -53,8 +55,8 @@ function objectAnchorCandidates( const oStart = other.pos; const oCenter = other.pos + other.size / 2; const oEnd = other.pos + other.size; - const perpFrom = Math.min(dragPerp, other.perp) - 8; - const perpTo = Math.max(dragPerp + dragPerpSize, other.perp + other.perpSize) + 8; + const perpFrom = Math.min(dragPerp, other.perp) - GUIDE_PADDING_PX; + const perpTo = Math.max(dragPerp + dragPerpSize, other.perp + other.perpSize) + GUIDE_PADDING_PX; return [oStart, oCenter, oEnd].map(value => ({ value, guide: { orientation: alignOrientation, type: 'align', pos: value, from: perpFrom, to: perpTo },