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
21 changes: 21 additions & 0 deletions src/components/Canvas/LabelCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,24 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(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<Konva.Node>(`#${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,
Expand Down Expand Up @@ -673,6 +691,9 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
}
onChange={(changes) => handleObjectChange(obj.id, changes)}
snap={snap}
getOthersSnapshot={snapEnabled ? undefined : getOthersSnapshot}
labelRect={transformerSnapLabelRect}
setGuides={setGuides}
/>
))}

Expand Down
100 changes: 95 additions & 5 deletions src/components/Canvas/LineObject.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -29,6 +31,9 @@ export function LineObject({
onSelect,
onChange,
snap,
getOthersSnapshot,
labelRect,
setGuides,
}: Props) {
const p = obj.props;
const colors = useColorScheme();
Expand Down Expand Up @@ -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<SnapRect[] | null>(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(obj.id);
}
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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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 } });
}}
/>
Expand Down
11 changes: 11 additions & 0 deletions src/components/Canvas/konvaObjectProps.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -36,4 +37,14 @@ 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` 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;
}