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..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; @@ -33,6 +35,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) - 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 }, + })); +} + +/** 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 +151,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 +165,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 +218,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], + }; +}