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
146 changes: 88 additions & 58 deletions src/components/Canvas/LineObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState } from "react";
import { Circle, Group, Line as KLine } from "react-konva";
import type { LabelObject } from "../../registry";
import { dotsToPx, pxToDots } from "../../lib/coordinates";
import { constrainLine, type ConstrainMode } from "../../lib/lineConstrain";
import type { KonvaObjectProps } from "./konvaObjectProps";

type LineLabelObject = Extract<LabelObject, { type: "line" }>;
Expand Down Expand Up @@ -59,6 +60,51 @@ export function LineObject({
const dispX2 = livePt2?.x ?? x2 + dx;
const dispY2 = livePt2?.y ?? y2 + dy;

// Shift forces the user-explicit 45°-step constraint; otherwise we use
// Figma-style auto-snap (±5° tolerance to the nearest 45° step).
const resolveMode = (shift: boolean): ConstrainMode =>
shift ? "shift" : "autoSnap";

// 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
// the user is dragging the START handle, so the geometry is computed from
// new-start → fixed-end and the new start is `end - projected_delta`.
// `forStart=false` is the END-handle case: start stays fixed, the new
// end follows the projection from start.
function project(
cursorXPx: number,
cursorYPx: number,
anchorXDots: number,
anchorYDots: number,
forStart: boolean,
shift: boolean,
) {
const cursorXDots = snap(pxToDots(cursorXPx - offsetX, scale, dpmm));
const cursorYDots = snap(pxToDots(cursorYPx - offsetY, scale, dpmm));
// dx/dy is always the line direction (start → end), so for a
// start-handle drag we flip the input vector.
const inputDx = forStart
? anchorXDots - cursorXDots
: cursorXDots - anchorXDots;
const inputDy = forStart
? anchorYDots - cursorYDots
: cursorYDots - anchorYDots;
const g = constrainLine(inputDx, inputDy, resolveMode(shift));
const movingDotX = forStart ? anchorXDots - g.dx : anchorXDots + g.dx;
const movingDotY = forStart ? anchorYDots - g.dy : anchorYDots + g.dy;
return {
length: g.length,
angle: g.angle,
movingDotX,
movingDotY,
movingPx: {
x: offsetX + dotsToPx(movingDotX, scale, dpmm),
y: offsetY + dotsToPx(movingDotY, scale, dpmm),
},
};
}

return (
<Group>
{/* Visible line — tracks both whole-drag and handle-drag live */}
Expand Down Expand Up @@ -121,44 +167,37 @@ export function LineObject({
strokeWidth={1.5}
draggable
onDragMove={(e) => {
const snappedX =
offsetX +
dotsToPx(
snap(pxToDots(e.target.x() - offsetX, scale, dpmm)),
scale,
dpmm,
);
const snappedY =
offsetY +
dotsToPx(
snap(pxToDots(e.target.y() - offsetY, scale, dpmm)),
scale,
dpmm,
);
e.target.position({ x: snappedX, y: snappedY });
setLivePt1({ x: snappedX, y: snappedY });
const endDotX = pxToDots(x2 - offsetX, scale, dpmm);
const endDotY = pxToDots(y2 - offsetY, scale, dpmm);
const r = project(
e.target.x(),
e.target.y(),
endDotX,
endDotY,
true,
e.evt.shiftKey,
);
e.target.position(r.movingPx);
setLivePt1(r.movingPx);
}}
onDragEnd={(e) => {
const snapped = livePt1 ?? { x: e.target.x(), y: e.target.y() };
const cursor = livePt1 ?? { x: e.target.x(), y: e.target.y() };
e.target.position({ x: x1 + dx, y: y1 + dy });
setLivePt1(null);
const newStartDotX = pxToDots(snapped.x - offsetX, scale, dpmm);
const newStartDotY = pxToDots(snapped.y - offsetY, scale, dpmm);
const endDotX = pxToDots(x2 - offsetX, scale, dpmm);
const endDotY = pxToDots(y2 - offsetY, scale, dpmm);
const dxDots = endDotX - newStartDotX;
const dyDots = endDotY - newStartDotY;
const newLen = Math.sqrt(dxDots * dxDots + dyDots * dyDots);
const newAngle = Math.round(
(Math.atan2(dyDots, dxDots) * 180) / Math.PI,
const r = project(
cursor.x,
cursor.y,
endDotX,
endDotY,
true,
e.evt.shiftKey,
);
onChange({
x: newStartDotX,
y: newStartDotY,
props: {
length: Math.max(1, Math.round(newLen)),
angle: newAngle,
},
x: r.movingDotX,
y: r.movingDotY,
props: { length: r.length, angle: r.angle },
});
}}
/>
Expand All @@ -172,39 +211,30 @@ export function LineObject({
strokeWidth={1.5}
draggable
onDragMove={(e) => {
const snappedX =
offsetX +
dotsToPx(
snap(pxToDots(e.target.x() - offsetX, scale, dpmm)),
scale,
dpmm,
);
const snappedY =
offsetY +
dotsToPx(
snap(pxToDots(e.target.y() - offsetY, scale, dpmm)),
scale,
dpmm,
);
e.target.position({ x: snappedX, y: snappedY });
setLivePt2({ x: snappedX, y: snappedY });
const r = project(
e.target.x(),
e.target.y(),
obj.x,
obj.y,
false,
e.evt.shiftKey,
);
e.target.position(r.movingPx);
setLivePt2(r.movingPx);
}}
onDragEnd={(e) => {
const snapped = livePt2 ?? { x: e.target.x(), y: e.target.y() };
const cursor = livePt2 ?? { x: e.target.x(), y: e.target.y() };
e.target.position({ x: x2 + dx, y: y2 + dy });
setLivePt2(null);
const dxDots = pxToDots(snapped.x - offsetX, scale, dpmm) - obj.x;
const dyDots = pxToDots(snapped.y - offsetY, scale, dpmm) - obj.y;
const newLen = Math.sqrt(dxDots * dxDots + dyDots * dyDots);
const newAngle = Math.round(
(Math.atan2(dyDots, dxDots) * 180) / Math.PI,
const r = project(
cursor.x,
cursor.y,
obj.x,
obj.y,
false,
e.evt.shiftKey,
);
onChange({
props: {
length: Math.max(1, Math.round(newLen)),
angle: newAngle,
},
});
onChange({ props: { length: r.length, angle: r.angle } });
}}
/>
</>
Expand Down
49 changes: 49 additions & 0 deletions src/lib/lineConstrain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { constrainLine } from "./lineConstrain";

describe("constrainLine — free", () => {
it("returns Euclidean length and exact angle", () => {
expect(constrainLine(100, 0, "free")).toEqual({ length: 100, angle: 0, dx: 100, dy: 0 });
expect(constrainLine(0, 50, "free")).toEqual({ length: 50, angle: 90, dx: 0, dy: 50 });
expect(constrainLine(30, 40, "free")).toEqual({ length: 50, angle: 53, dx: 30, dy: 40 });
});

it("clamps length to at least 1", () => {
expect(constrainLine(0, 0, "free").length).toBe(1);
});
});

describe("constrainLine — shift (45° steps)", () => {
it("snaps near-horizontal drags to 0° with horizontal projection", () => {
expect(constrainLine(100, 5, "shift")).toMatchObject({ length: 100, angle: 0 });
});

it("snaps near-diagonal drags to 45° with axial projection", () => {
// (50, 50): raw 45° → projection = 50√2 ≈ 70.7
expect(constrainLine(50, 50, "shift")).toMatchObject({ length: 71, angle: 45 });
});

it("snaps to negative 135° for the third quadrant", () => {
expect(constrainLine(-100, -100, "shift")).toMatchObject({ length: 141, angle: -135 });
});
});

describe("constrainLine — autoSnap (within ±5° of 45° steps)", () => {
it("snaps when the raw angle is within tolerance", () => {
// raw atan2(3,100) ≈ 1.7° → within 5° of 0° → snap
expect(constrainLine(100, 3, "autoSnap")).toMatchObject({ length: 100, angle: 0 });
});

it("leaves the angle free when outside tolerance", () => {
// raw atan2(20,100) ≈ 11.3° → > 5° from 0° → free
const r = constrainLine(100, 20, "autoSnap");
expect(r.angle).toBe(11);
expect(r.length).toBe(102);
});

it("snaps near-diagonal drags to 45°", () => {
// raw atan2(48,50) ≈ 43.8° → within 5° of 45° → snap
expect(constrainLine(50, 48, "autoSnap")).toMatchObject({ angle: 45 });
});
});

85 changes: 85 additions & 0 deletions src/lib/lineConstrain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Pure geometry for constrained line editing.
*
* Three modes, all of which project the cursor's drag delta onto a
* chosen axis so the line's length matches the axial component of the
* drag (Figma / Sketch convention) instead of the diagonal Euclidean
* distance:
*
* - `free` — no constraint
* - `shift` — always snap to the nearest 45° step
* - `autoSnap` — snap to the nearest 45° step only when within ±5°,
* otherwise free (Figma's "smart guides" behaviour)
*/

export type ConstrainMode = "free" | "shift" | "autoSnap";

/** Tolerance in degrees for `autoSnap` mode — below this distance from
* a 45° step the angle is snapped, above it the raw angle is kept. */
export const AUTO_SNAP_TOLERANCE_DEG = 5;

export interface LineGeometry {
length: number;
angle: number;
/** Projected delta from origin to the constrained endpoint, rounded to
* integer dots. Convenient for callers that need to position the
* endpoint visually rather than recomputing `length * cos/sin`. */
dx: number;
dy: number;
}

function makeFree(dxDots: number, dyDots: number): LineGeometry {
const length = Math.max(
1,
Math.round(Math.sqrt(dxDots * dxDots + dyDots * dyDots)),
);
const angle = Math.round((Math.atan2(dyDots, dxDots) * 180) / Math.PI);
return { length, angle, dx: Math.round(dxDots), dy: Math.round(dyDots) };
}

/** Wrap to (-180, 180] so flipped axis angles stay in atan2's natural range. */
function normalizeAngle(deg: number): number {
let n = deg % 360;
if (n > 180) n -= 360;
if (n <= -180) n += 360;
return n;
}

/** Project (dxDots, dyDots) onto the line at `axisAngleDeg`. Picks the
* axis direction (axisAngleDeg or its 180°-flip) so the projected length
* is non-negative — the line always follows the cursor. */
function projectOntoAxis(
dxDots: number,
dyDots: number,
axisAngleDeg: number,
): LineGeometry {
const rad = (axisAngleDeg * Math.PI) / 180;
const proj = dxDots * Math.cos(rad) + dyDots * Math.sin(rad);
const angle = proj >= 0 ? axisAngleDeg : normalizeAngle(axisAngleDeg + 180);
const length = Math.max(1, Math.round(Math.abs(proj)));
const projRad = (angle * Math.PI) / 180;
return {
length,
angle,
dx: Math.round(length * Math.cos(projRad)),
dy: Math.round(length * Math.sin(projRad)),
};
}

export function constrainLine(
dxDots: number,
dyDots: number,
mode: ConstrainMode,
): LineGeometry {
if (mode === "free") return makeFree(dxDots, dyDots);

const rawAngle = (Math.atan2(dyDots, dxDots) * 180) / Math.PI;
const snappedAngle = Math.round(rawAngle / 45) * 45;
if (mode === "shift") return projectOntoAxis(dxDots, dyDots, snappedAngle);

// autoSnap: only project when the raw angle is close enough to a step.
const within = Math.abs(rawAngle - snappedAngle) <= AUTO_SNAP_TOLERANCE_DEG;
return within
? projectOntoAxis(dxDots, dyDots, snappedAngle)
: makeFree(dxDots, dyDots);
}
1 change: 1 addition & 0 deletions src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ const ar = {
colorB: 'B — أسود',
colorW: 'W — أبيض',
reverse: 'Invert',
orientation: 'الاتجاه',
},
serial: {
content: 'القيمة الابتدائية',
Expand Down
1 change: 1 addition & 0 deletions src/locales/bg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ const bg = {
colorB: 'B — Черен',
colorW: 'W — Бял',
reverse: 'Invert',
orientation: 'Ориентация',
},
serial: {
content: 'Начална стойност',
Expand Down
1 change: 1 addition & 0 deletions src/locales/cs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ const cs = {
colorB: 'B — Černá',
colorW: 'W — Bílá',
reverse: 'Invert',
orientation: 'Orientace',
},
serial: {
content: 'Počáteční hodnota',
Expand Down
1 change: 1 addition & 0 deletions src/locales/da.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ const da = {
colorB: 'B — Sort',
colorW: 'W — Hvid',
reverse: 'Invert',
orientation: 'Retning',
},
serial: {
content: 'Startværdi',
Expand Down
1 change: 1 addition & 0 deletions src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ const de = {
colorB: 'B — Schwarz',
colorW: 'W — Weiß',
reverse: 'Invertieren',
orientation: 'Ausrichtung',
},
serial: {
content: 'Startwert',
Expand Down
1 change: 1 addition & 0 deletions src/locales/el.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ const el = {
colorB: 'B — Μαύρο',
colorW: 'W — Λευκό',
reverse: 'Invert',
orientation: 'Προσανατολισμός',
},
serial: {
content: 'Αρχική τιμή',
Expand Down
1 change: 1 addition & 0 deletions src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ const en = {
colorB: 'B — Black',
colorW: 'W — White',
reverse: 'Invert',
orientation: 'Orientation',
},
serial: {
content: 'Start value',
Expand Down
1 change: 1 addition & 0 deletions src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ const es = {
colorB: 'B — Negro',
colorW: 'W — Blanco',
reverse: 'Invert',
orientation: 'Orientación',
},
serial: {
content: 'Valor inicial',
Expand Down
1 change: 1 addition & 0 deletions src/locales/et.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ const et = {
colorB: 'B — Must',
colorW: 'W — Valge',
reverse: 'Invert',
orientation: 'Suund',
},
serial: {
content: 'Algväärtus',
Expand Down
Loading