From 6767bcd1f6c2d8bfb462e1ee79e22d29f9e3ffd7 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 21:45:19 +0200 Subject: [PATCH 1/2] feat(canvas): smart-align guides during line endpoint resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Line endpoint resize previously bypassed Konva's Transformer (it uses custom handles in LineObject), so the Stage-level object-snap that fires for normal drag never kicked in — endpoints could only free-form-snap to the grid. Plumb getOthersSnapshot / labelRect / setGuides through KonvaObjectProps so LineObject can run its own snap during endpoint drag: - snapEndpoint(): zero-size rect snap via computeSnap against neighbour edges + label rect, in stage coords. Local↔stage conversion via the parent group's absoluteTransform keeps the snap correct under rotated view (viewRotation 90°/270°). Snap is skipped when Shift is held (axis constraint wins) and when grid-snap is on (mutual exclusion with the existing drag-time snap, mirroring the rest of the app). - snapshot is cached at drag-start to avoid re-querying every other node's clientRect per frame; cleared on drag-end. - selectionHandlers helper applied to BarcodeObject's remaining unfixed Group node (caught while threading new KonvaObjectProps imports). Open trade-off: snap picks the nearest neighbour edge by raw distance. For aligning a line endpoint to the *matching* edge of another line (bottom-to-bottom etc.) the user may need to drag past the near edge. A side-aware heuristic (which edge to prefer when dragging in a given direction) is a follow-up — orthogonal to this branch's scope. --- src/components/Canvas/LabelCanvas.tsx | 19 ++++ src/components/Canvas/LineObject.tsx | 100 ++++++++++++++++++++-- src/components/Canvas/konvaObjectProps.ts | 9 ++ 3 files changed, 123 insertions(+), 5 deletions(-) diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 392a8446..9db8feb6 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -673,6 +673,25 @@ export const LabelCanvas = forwardRef(function LabelCa } onChange={(changes) => handleObjectChange(obj.id, changes)} snap={snap} + getOthersSnapshot={ + snapEnabled + ? undefined + : () => { + const stage = stageRef.current; + if (!stage) return []; + const rects = []; + for (const o of getCurrentObjects()) { + if (o.id === obj.id) continue; + const n = stage.findOne(`#${o.id}`); + if (!n) continue; + const r = n.getClientRect({ relativeTo: stage }); + rects.push({ id: o.id, x: r.x, y: r.y, width: r.width, height: r.height }); + } + return rects; + } + } + labelRect={transformerSnapLabelRect} + setGuides={setGuides} /> ))} diff --git a/src/components/Canvas/LineObject.tsx b/src/components/Canvas/LineObject.tsx index f20e5d47..ab478c36 100644 --- a/src/components/Canvas/LineObject.tsx +++ b/src/components/Canvas/LineObject.tsx @@ -1,9 +1,11 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { Group, Line as KLine, Rect } from "react-konva"; +import type Konva from "konva"; 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 { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps"; /** Endpoint-handle visuals — small white square with a thin selection @@ -29,6 +31,9 @@ export function LineObject({ onSelect, onChange, snap, + getOthersSnapshot, + labelRect, + setGuides, }: Props) { const p = obj.props; const colors = useColorScheme(); @@ -77,6 +82,85 @@ export function LineObject({ const resolveMode = (shift: boolean): ConstrainMode => shift ? "shift" : "autoSnap"; + // Cache the other-objects snapshot for the duration of a single endpoint + // drag — captured lazily on the first onDragMove and cleared on + // onDragEnd. Avoids re-querying every Konva node's clientRect per frame. + const othersSnapshotRef = useRef(null); + + /** + * Run the projected endpoint position through object-snap (other shapes' + * edges + label edges). Skips when shift is held — the user-explicit + * 45°-step constraint would otherwise fight the snap-nudge. + * + * The snap pipeline (othersSnapshot, labelRect, returned guides) is in + * stage-screen coords. The line's own drag math is in label-group local + * coords (which coincide with stage at viewRotation=0 but diverge under + * rotation). The `parent` Konva node is used to convert local↔stage so + * the snap stays correct in rotated views. + */ + function snapEndpoint( + localPx: { x: number; y: number }, + shift: boolean, + parent: Konva.Node | null, + ): { x: number; y: number } { + if (shift || !getOthersSnapshot || !labelRect || !setGuides || !parent) { + setGuides?.([]); + return localPx; + } + if (othersSnapshotRef.current === null) { + othersSnapshotRef.current = getOthersSnapshot(); + } + 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 }, + othersSnapshotRef.current, + undefined, + labelRect, + labelRect, + ); + setGuides(result.guides); + const back = transform.copy().invert().point({ x: result.x, y: result.y }); + return { x: back.x, y: back.y }; + } + + function clearSnap() { + othersSnapshotRef.current = null; + setGuides?.([]); + } + + /** + * Full endpoint-drag pipeline: axis constraint → object snap → final + * geometry derivation. Returns the same shape as `project` so call + * sites stay symmetric; the snapped endpoint may sit slightly off the + * axis the constraint chose, which is the standard Figma compromise + * (snap nudges trump the auto-snap step, but shift still locks). + */ + function endpointDrag( + cursorXPx: number, + cursorYPx: number, + anchorXDots: number, + anchorYDots: number, + forStart: boolean, + shift: boolean, + parent: Konva.Node | null, + ) { + const projected = project(cursorXPx, cursorYPx, anchorXDots, anchorYDots, forStart, shift); + const snappedPx = snapEndpoint(projected.movingPx, shift, parent); + const snappedDotX = pxToDots(snappedPx.x - offsetX, scale, dpmm); + const snappedDotY = pxToDots(snappedPx.y - offsetY, scale, dpmm); + const dxDots = forStart ? anchorXDots - snappedDotX : snappedDotX - anchorXDots; + const dyDots = forStart ? anchorYDots - snappedDotY : snappedDotY - anchorYDots; + const g = constrainLine(dxDots, dyDots, "free"); + return { + length: g.length, + angle: g.angle, + movingDotX: snappedDotX, + movingDotY: snappedDotY, + movingPx: snappedPx, + }; + } + // Project the cursor (`cursorPx`) toward the line endpoint that should // stay fixed (`anchorDots`), returning both the constrained line geometry // and the new "moving" endpoint in display pixels. `forStart=true` means @@ -191,13 +275,14 @@ export function LineObject({ onDragMove={(e) => { const endDotX = pxToDots(x2 - offsetX, scale, dpmm); const endDotY = pxToDots(y2 - offsetY, scale, dpmm); - const r = project( + const r = endpointDrag( e.target.x() + HANDLE_HIT_SIZE / 2, e.target.y() + HANDLE_HIT_SIZE / 2, endDotX, endDotY, true, e.evt.shiftKey, + e.target.getParent(), ); e.target.position({ x: r.movingPx.x - HANDLE_HIT_SIZE / 2, @@ -217,14 +302,16 @@ export function LineObject({ setLivePt1(null); const endDotX = pxToDots(x2 - offsetX, scale, dpmm); const endDotY = pxToDots(y2 - offsetY, scale, dpmm); - const r = project( + const r = endpointDrag( cursor.x, cursor.y, endDotX, endDotY, true, e.evt.shiftKey, + e.target.getParent(), ); + clearSnap(); onChange({ x: r.movingDotX, y: r.movingDotY, @@ -251,13 +338,14 @@ export function LineObject({ fill="transparent" draggable onDragMove={(e) => { - const r = project( + const r = endpointDrag( e.target.x() + HANDLE_HIT_SIZE / 2, e.target.y() + HANDLE_HIT_SIZE / 2, obj.x, obj.y, false, e.evt.shiftKey, + e.target.getParent(), ); e.target.position({ x: r.movingPx.x - HANDLE_HIT_SIZE / 2, @@ -275,14 +363,16 @@ export function LineObject({ y: y2 + dy - HANDLE_HIT_SIZE / 2, }); setLivePt2(null); - const r = project( + const r = endpointDrag( cursor.x, cursor.y, obj.x, obj.y, false, e.evt.shiftKey, + e.target.getParent(), ); + clearSnap(); onChange({ props: { length: r.length, angle: r.angle } }); }} /> diff --git a/src/components/Canvas/konvaObjectProps.ts b/src/components/Canvas/konvaObjectProps.ts index 3e0dd50d..aee0321d 100644 --- a/src/components/Canvas/konvaObjectProps.ts +++ b/src/components/Canvas/konvaObjectProps.ts @@ -1,6 +1,7 @@ import type Konva from "konva"; import type { LabelObject } from "../../registry"; import type { ObjectChanges } from "../../store/labelStore"; +import type { SnapGuide, SnapRect } from "../../lib/snapGuides"; /** * Click / tap handlers shared across every per-type renderer. Click reads @@ -36,4 +37,12 @@ export interface KonvaObjectProps { onSelect: (addToSelection: boolean) => void; onChange: (changes: ObjectChanges) => void; snap: (dots: number) => number; + /** Snap-guide hooks used by line endpoint resize. Other shapes route + * through Konva's Transformer (useKonvaTransformer's boundBoxFunc) + * which has its own snap pipeline; lines manage their own endpoint + * drag and use these instead. Optional so per-type renderers without + * custom resize handles can ignore them. */ + getOthersSnapshot?: () => SnapRect[]; + labelRect?: SnapRect; + setGuides?: (guides: SnapGuide[]) => void; } From 48deec27c4d194878b8ab41e5afc1b820d878b38 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 21:50:01 +0200 Subject: [PATCH 2/2] perf(canvas): hoist getOthersSnapshot out of the KonvaObject map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer-spotted (gemini-code-assist on PR #50): defining the snapshot closure inside objects.map allocated a fresh function for every object on every LabelCanvas render — and LabelCanvas re-renders on every setGuides call during a drag. Lift to a single useCallback-stable function that takes excludeId. The KonvaObjectProps signature changes from () => SnapRect[] to (excludeId) => SnapRect[]; LineObject's lazy snapshot call now passes obj.id. --- src/components/Canvas/LabelCanvas.tsx | 36 ++++++++++++----------- src/components/Canvas/LineObject.tsx | 2 +- src/components/Canvas/konvaObjectProps.ts | 6 ++-- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 9db8feb6..2ab74a6a 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -250,6 +250,24 @@ export const LabelCanvas = forwardRef(function LabelCa const snap = (dots: number) => snapEnabled ? Math.round(dots / snapUnit) * snapUnit : dots; + // Stable across renders — every consumer that needs a snap snapshot + // calls this with its own id. Defined once (vs. per-object in the + // KonvaObject map) so a 60Hz dragmove that re-renders LabelCanvas + // doesn't churn N closures per frame. + const getOthersSnapshot = useCallback((excludeId: string) => { + const stage = stageRef.current; + if (!stage) return []; + const rects = []; + for (const o of getCurrentObjects()) { + if (o.id === excludeId) continue; + const n = stage.findOne(`#${o.id}`); + if (!n) continue; + const r = n.getClientRect({ relativeTo: stage }); + rects.push({ id: o.id, x: r.x, y: r.y, width: r.width, height: r.height }); + } + return rects; + }, []); + const { lasso: lassoRect, consumeDidLasso, @@ -673,23 +691,7 @@ export const LabelCanvas = forwardRef(function LabelCa } onChange={(changes) => handleObjectChange(obj.id, changes)} snap={snap} - getOthersSnapshot={ - snapEnabled - ? undefined - : () => { - const stage = stageRef.current; - if (!stage) return []; - const rects = []; - for (const o of getCurrentObjects()) { - if (o.id === obj.id) continue; - const n = stage.findOne(`#${o.id}`); - if (!n) continue; - const r = n.getClientRect({ relativeTo: stage }); - rects.push({ id: o.id, x: r.x, y: r.y, width: r.width, height: r.height }); - } - return rects; - } - } + getOthersSnapshot={snapEnabled ? undefined : getOthersSnapshot} labelRect={transformerSnapLabelRect} setGuides={setGuides} /> diff --git a/src/components/Canvas/LineObject.tsx b/src/components/Canvas/LineObject.tsx index ab478c36..e39bb987 100644 --- a/src/components/Canvas/LineObject.tsx +++ b/src/components/Canvas/LineObject.tsx @@ -108,7 +108,7 @@ export function LineObject({ return localPx; } if (othersSnapshotRef.current === null) { - othersSnapshotRef.current = getOthersSnapshot(); + othersSnapshotRef.current = getOthersSnapshot(obj.id); } const transform = parent.getAbsoluteTransform(); const stagePx = transform.point(localPx); diff --git a/src/components/Canvas/konvaObjectProps.ts b/src/components/Canvas/konvaObjectProps.ts index aee0321d..9a126d6c 100644 --- a/src/components/Canvas/konvaObjectProps.ts +++ b/src/components/Canvas/konvaObjectProps.ts @@ -41,8 +41,10 @@ export interface KonvaObjectProps { * through Konva's Transformer (useKonvaTransformer's boundBoxFunc) * which has its own snap pipeline; lines manage their own endpoint * drag and use these instead. Optional so per-type renderers without - * custom resize handles can ignore them. */ - getOthersSnapshot?: () => SnapRect[]; + * custom resize handles can ignore them. `getOthersSnapshot` takes + * the consumer's id so a single stable function can serve every + * renderer without per-object closure allocations. */ + getOthersSnapshot?: (excludeId: string) => SnapRect[]; labelRect?: SnapRect; setGuides?: (guides: SnapGuide[]) => void; }