From 04399f4add4fabfa4b76239d50ac8cfe025203d7 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 15:51:20 +0200 Subject: [PATCH 1/2] feat(line): smart axis snap + quick-orientation picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endpoint editing: - onDragMove and onDragEnd both project the cursor onto a constrained axis so the line visually stays on a rail during the drag instead of only snapping at release. Length matches the axial component of the drag (Figma / Sketch convention) rather than the diagonal Euclidean distance. - Default behaviour: Figma-style auto-snap to the nearest 45° step when the raw angle is within ±5°, otherwise free. - Shift always forces an explicit 45°-step constraint. Quick-orientation picker in the Properties panel: - Four buttons (—, |, /, \) rendered as inline 12×12 SVGs for crisp, font-independent icons. - Buttons pick the candidate angle closest to the line's current angle so the line keeps its rough direction. Repeated click on the active orientation flips the line 180° (e.g. 0° ↔ 180°). - View-rotation aware: candidates are expressed as on-screen angles, then converted to label-space by subtracting the current view rotation. Clicking '—' always yields a visually horizontal line. - Length is preserved (no projection collapse when rotating). --- src/components/Canvas/LineObject.tsx | 146 ++++++++++++++++----------- src/lib/lineConstrain.test.ts | 49 +++++++++ src/lib/lineConstrain.ts | 85 ++++++++++++++++ src/locales/ar.ts | 1 + src/locales/bg.ts | 1 + src/locales/cs.ts | 1 + src/locales/da.ts | 1 + src/locales/de.ts | 1 + src/locales/el.ts | 1 + src/locales/en.ts | 1 + src/locales/es.ts | 1 + src/locales/et.ts | 1 + src/locales/fa.ts | 1 + src/locales/fi.ts | 1 + src/locales/fr.ts | 1 + src/locales/he.ts | 1 + src/locales/hr.ts | 1 + src/locales/hu.ts | 1 + src/locales/it.ts | 1 + src/locales/ja.ts | 1 + src/locales/ko.ts | 1 + src/locales/lt.ts | 1 + src/locales/lv.ts | 1 + src/locales/nl.ts | 1 + src/locales/no.ts | 1 + src/locales/pl.ts | 1 + src/locales/pt.ts | 1 + src/locales/ro.ts | 1 + src/locales/sk.ts | 1 + src/locales/sl.ts | 1 + src/locales/sr.ts | 1 + src/locales/sv.ts | 1 + src/locales/tr.ts | 1 + src/locales/zh-hans.ts | 1 + src/locales/zh-hant.ts | 1 + src/registry/line.test.ts | 75 ++++++++++++++ src/registry/line.tsx | 82 +++++++++++++++ 37 files changed, 411 insertions(+), 58 deletions(-) create mode 100644 src/lib/lineConstrain.test.ts create mode 100644 src/lib/lineConstrain.ts create mode 100644 src/registry/line.test.ts diff --git a/src/components/Canvas/LineObject.tsx b/src/components/Canvas/LineObject.tsx index 86a59322..f5a2a160 100644 --- a/src/components/Canvas/LineObject.tsx +++ b/src/components/Canvas/LineObject.tsx @@ -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; @@ -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 ( {/* Visible line — tracks both whole-drag and handle-drag live */} @@ -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 }, }); }} /> @@ -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 } }); }} /> diff --git a/src/lib/lineConstrain.test.ts b/src/lib/lineConstrain.test.ts new file mode 100644 index 00000000..337d020c --- /dev/null +++ b/src/lib/lineConstrain.test.ts @@ -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 }); + }); +}); + diff --git a/src/lib/lineConstrain.ts b/src/lib/lineConstrain.ts new file mode 100644 index 00000000..c8c0c542 --- /dev/null +++ b/src/lib/lineConstrain.ts @@ -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); +} diff --git a/src/locales/ar.ts b/src/locales/ar.ts index 37173095..abc8122e 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -240,6 +240,7 @@ const ar = { colorB: 'B — أسود', colorW: 'W — أبيض', reverse: 'Invert', + orientation: 'الاتجاه', }, serial: { content: 'القيمة الابتدائية', diff --git a/src/locales/bg.ts b/src/locales/bg.ts index e1ebdf56..71d28080 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -240,6 +240,7 @@ const bg = { colorB: 'B — Черен', colorW: 'W — Бял', reverse: 'Invert', + orientation: 'Ориентация', }, serial: { content: 'Начална стойност', diff --git a/src/locales/cs.ts b/src/locales/cs.ts index d93effe9..57f74877 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -240,6 +240,7 @@ const cs = { colorB: 'B — Černá', colorW: 'W — Bílá', reverse: 'Invert', + orientation: 'Orientace', }, serial: { content: 'Počáteční hodnota', diff --git a/src/locales/da.ts b/src/locales/da.ts index 2308af2d..c9263b70 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -240,6 +240,7 @@ const da = { colorB: 'B — Sort', colorW: 'W — Hvid', reverse: 'Invert', + orientation: 'Retning', }, serial: { content: 'Startværdi', diff --git a/src/locales/de.ts b/src/locales/de.ts index 8a3ddf05..f8acc907 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -261,6 +261,7 @@ const de = { colorB: 'B — Schwarz', colorW: 'W — Weiß', reverse: 'Invertieren', + orientation: 'Ausrichtung', }, serial: { content: 'Startwert', diff --git a/src/locales/el.ts b/src/locales/el.ts index b030d52c..8bdb6336 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -240,6 +240,7 @@ const el = { colorB: 'B — Μαύρο', colorW: 'W — Λευκό', reverse: 'Invert', + orientation: 'Προσανατολισμός', }, serial: { content: 'Αρχική τιμή', diff --git a/src/locales/en.ts b/src/locales/en.ts index 2caa32c2..c6bb37c1 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -261,6 +261,7 @@ const en = { colorB: 'B — Black', colorW: 'W — White', reverse: 'Invert', + orientation: 'Orientation', }, serial: { content: 'Start value', diff --git a/src/locales/es.ts b/src/locales/es.ts index 89a51ecc..b5702b54 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -240,6 +240,7 @@ const es = { colorB: 'B — Negro', colorW: 'W — Blanco', reverse: 'Invert', + orientation: 'Orientación', }, serial: { content: 'Valor inicial', diff --git a/src/locales/et.ts b/src/locales/et.ts index 6d4915fc..28474c4c 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -240,6 +240,7 @@ const et = { colorB: 'B — Must', colorW: 'W — Valge', reverse: 'Invert', + orientation: 'Suund', }, serial: { content: 'Algväärtus', diff --git a/src/locales/fa.ts b/src/locales/fa.ts index ea3f6843..be83e465 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -240,6 +240,7 @@ const fa = { colorB: 'B — مشکی', colorW: 'W — سفید', reverse: 'Invert', + orientation: 'جهت', }, serial: { content: 'مقدار شروع', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index ac5acb81..c1bb7772 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -240,6 +240,7 @@ const fi = { colorB: 'B — Musta', colorW: 'W — Valkoinen', reverse: 'Invert', + orientation: 'Suunta', }, serial: { content: 'Aloitusarvo', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index c31dcd95..f2a814ed 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -240,6 +240,7 @@ const fr = { colorB: 'B — Noir', colorW: 'W — Blanc', reverse: 'Invert', + orientation: 'Orientation', }, serial: { content: 'Valeur de départ', diff --git a/src/locales/he.ts b/src/locales/he.ts index 7b2df6b8..616a7ce1 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -240,6 +240,7 @@ const he = { colorB: 'B — שחור', colorW: 'W — לבן', reverse: 'Invert', + orientation: 'כיוון', }, serial: { content: 'ערך התחלתי', diff --git a/src/locales/hr.ts b/src/locales/hr.ts index ee5ba20d..61b0bff5 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -240,6 +240,7 @@ const hr = { colorB: 'B — Crna', colorW: 'W — Bijela', reverse: 'Invert', + orientation: 'Orijentacija', }, serial: { content: 'Početna vrijednost', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index 8022762c..47417735 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -240,6 +240,7 @@ const hu = { colorB: 'B — Fekete', colorW: 'W — Fehér', reverse: 'Invert', + orientation: 'Tájolás', }, serial: { content: 'Kezdőérték', diff --git a/src/locales/it.ts b/src/locales/it.ts index c9a127eb..eab1ee38 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -240,6 +240,7 @@ const it = { colorB: 'B — Nero', colorW: 'W — Bianco', reverse: 'Invert', + orientation: 'Orientamento', }, serial: { content: 'Valore iniziale', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index af4e9f50..3edbdcee 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -240,6 +240,7 @@ const ja = { colorB: 'B — 黒', colorW: 'W — 白', reverse: 'Invert', + orientation: '方向', }, serial: { content: '開始値', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 5ed4edf0..c4b6b217 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -240,6 +240,7 @@ const ko = { colorB: 'B — 검정', colorW: 'W — 흰색', reverse: 'Invert', + orientation: '방향', }, serial: { content: '시작 값', diff --git a/src/locales/lt.ts b/src/locales/lt.ts index b6d3d3b2..52772748 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -240,6 +240,7 @@ const lt = { colorB: 'B — Juoda', colorW: 'W — Balta', reverse: 'Invert', + orientation: 'Kryptis', }, serial: { content: 'Pradinė reikšmė', diff --git a/src/locales/lv.ts b/src/locales/lv.ts index c18c7d0e..c8b468a3 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -240,6 +240,7 @@ const lv = { colorB: 'B — Melna', colorW: 'W — Balta', reverse: 'Invert', + orientation: 'Orientācija', }, serial: { content: 'Sākumvērtība', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index e89847a0..83229649 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -240,6 +240,7 @@ const nl = { colorB: 'B — Zwart', colorW: 'W — Wit', reverse: 'Invert', + orientation: 'Oriëntatie', }, serial: { content: 'Startwaarde', diff --git a/src/locales/no.ts b/src/locales/no.ts index c754541c..70c4b411 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -240,6 +240,7 @@ const no = { colorB: 'B — Svart', colorW: 'W — Hvit', reverse: 'Invert', + orientation: 'Retning', }, serial: { content: 'Startverdi', diff --git a/src/locales/pl.ts b/src/locales/pl.ts index cad41431..7b9e9c77 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -240,6 +240,7 @@ const pl = { colorB: 'B — Czarny', colorW: 'W — Biały', reverse: 'Invert', + orientation: 'Orientacja', }, serial: { content: 'Wartość początkowa', diff --git a/src/locales/pt.ts b/src/locales/pt.ts index d272e54a..23ac784b 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -240,6 +240,7 @@ const pt = { colorB: 'B — Preto', colorW: 'W — Branco', reverse: 'Invert', + orientation: 'Orientação', }, serial: { content: 'Valor inicial', diff --git a/src/locales/ro.ts b/src/locales/ro.ts index 4404a6e1..0f09c0fc 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -240,6 +240,7 @@ const ro = { colorB: 'B — Negru', colorW: 'W — Alb', reverse: 'Invert', + orientation: 'Orientare', }, serial: { content: 'Valoare inițială', diff --git a/src/locales/sk.ts b/src/locales/sk.ts index f39a2dbb..9ad34f32 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -240,6 +240,7 @@ const sk = { colorB: 'B — Čierna', colorW: 'W — Biela', reverse: 'Invert', + orientation: 'Orientácia', }, serial: { content: 'Počiatočná hodnota', diff --git a/src/locales/sl.ts b/src/locales/sl.ts index fee60b09..7e23ea91 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -240,6 +240,7 @@ const sl = { colorB: 'B — Črna', colorW: 'W — Bela', reverse: 'Invert', + orientation: 'Usmerjenost', }, serial: { content: 'Začetna vrednost', diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 88078a15..0f2e4cca 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -240,6 +240,7 @@ const sr = { colorB: 'B — Crna', colorW: 'W — Bela', reverse: 'Invert', + orientation: 'Orijentacija', }, serial: { content: 'Početna vrednost', diff --git a/src/locales/sv.ts b/src/locales/sv.ts index f88859ab..f68e4c45 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -240,6 +240,7 @@ const sv = { colorB: 'B — Svart', colorW: 'W — Vit', reverse: 'Invert', + orientation: 'Riktning', }, serial: { content: 'Startvärde', diff --git a/src/locales/tr.ts b/src/locales/tr.ts index b375eb63..4b178944 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -240,6 +240,7 @@ const tr = { colorB: 'B — Siyah', colorW: 'W — Beyaz', reverse: 'Invert', + orientation: 'Yön', }, serial: { content: 'Başlangıç değeri', diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 8ed5a2be..897565bd 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -240,6 +240,7 @@ const zhHans = { colorB: 'B — 黑色', colorW: 'W — 白色', reverse: 'Invert', + orientation: '方向', }, serial: { content: '起始值', diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index ea7acfc3..365ef9ab 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -240,6 +240,7 @@ const zhHant = { colorB: 'B — 黑色', colorW: 'W — 白色', reverse: 'Invert', + orientation: '方向', }, serial: { content: '起始值', diff --git a/src/registry/line.test.ts b/src/registry/line.test.ts new file mode 100644 index 00000000..866daf82 --- /dev/null +++ b/src/registry/line.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { pickAngle } from "./line"; + +describe("pickAngle", () => { + describe("nearest-candidate behaviour at viewRotation=0", () => { + it("picks 0° for an already-horizontal line clicking horizontal", () => { + expect(pickAngle(0, [0, 180], 0)).toBe(180); // exact match → flip + }); + + it("picks 0° for a 45° line clicking horizontal (closer than 180°)", () => { + expect(pickAngle(45, [0, 180], 0)).toBe(0); + }); + + it("picks 180° for a 170° line clicking horizontal", () => { + expect(pickAngle(170, [0, 180], 0)).toBe(180); + }); + + it("picks 90° for a 45° line clicking vertical", () => { + expect(pickAngle(45, [90, -90], 0)).toBe(90); + }); + }); + + describe("flip-on-second-click", () => { + it("flips horizontal 0° ↔ 180° on repeated clicks", () => { + expect(pickAngle(0, [0, 180], 0)).toBe(180); + expect(pickAngle(180, [0, 180], 0)).toBe(0); + }); + + it("flips vertical 90° ↔ -90°", () => { + expect(pickAngle(90, [90, -90], 0)).toBe(-90); + expect(pickAngle(-90, [90, -90], 0)).toBe(90); + }); + + it("flips diagonal -45° ↔ 135°", () => { + expect(pickAngle(-45, [-45, 135], 0)).toBe(135); + expect(pickAngle(135, [-45, 135], 0)).toBe(-45); + }); + }); + + describe("view-rotation awareness (regression for the mirrored-picker bug)", () => { + // viewRotation = 90 means the canvas is rotated 90° CW on screen. + // A label-space angle of 0 (logical horizontal) appears vertical there, + // so the horizontal-button [0,180] (screen-angles) must yield -90 / 90 + // in label-space to actually look horizontal on the rotated canvas. + + it("clicking horizontal on a 0°-stored line in 90° view yields -90 (looks horizontal)", () => { + // candidates in label-space: 0-90=-90, 180-90=90. + // currentAngle=0, dist(0,-90)=90, dist(0,90)=90 → tie → first candidate + expect(pickAngle(0, [0, 180], 90)).toBe(-90); + }); + + it("clicking vertical in 90° view yields 0 (label-vertical = screen-horizontal at 0° view)", () => { + // wait — clicking vertical in a 90° view should yield a line that LOOKS vertical on screen. + // Screen-vertical = label-angle = 90 - 90 = 0 (or -90 - 90 = -180). + // dist(0, 0) = 0 → exact match → flip → -180 + expect(pickAngle(0, [90, -90], 90)).toBe(-180); + // dist(45, 0) = 45, dist(45, -180) = 135 → 0 wins + expect(pickAngle(45, [90, -90], 90)).toBe(0); + }); + + it("clicking horizontal in 180° view yields -180 / 0", () => { + // candidates: 0-180=-180, 180-180=0 + expect(pickAngle(45, [0, 180], 180)).toBe(0); + expect(pickAngle(-170, [0, 180], 180)).toBe(-180); + }); + + it("clicking diagonal `/` in 90° view yields the screen-up-right diagonal", () => { + // /'s screen-angles = [-45, 135], with viewRotation=90: + // candidates label-space: -135, 45 + // For a horizontal-ish line (angle=0): + // dist(0, -135) = 135, dist(0, 45) = 45 → 45 wins + expect(pickAngle(0, [-45, 135], 90)).toBe(45); + }); + }); +}); diff --git a/src/registry/line.tsx b/src/registry/line.tsx index 15bb5826..3ec1733b 100644 --- a/src/registry/line.tsx +++ b/src/registry/line.tsx @@ -1,5 +1,6 @@ import type { ObjectTypeDefinition } from '../types/ObjectType'; import { useT } from '../lib/useT'; +import { useLabelStore } from '../store/labelStore'; import { inputCls, labelCls } from '../components/Properties/styles'; import { NumberInput } from '../components/Properties/NumberInput'; @@ -12,6 +13,57 @@ export interface LineProps { reverse?: boolean; } +/** + * Quick-orientation picker. + * + * - `screenAngles`: the two valid angles **as the user sees them on + * screen** for that orientation. Stored angles are then derived by + * subtracting the current `viewRotation`, so clicking `—` always + * yields a line that *looks* horizontal regardless of how the canvas + * is rotated. + * - `path`: SVG endpoints (12×12 viewbox) used as the visible + * button icon. Avoids font-rendering quirks of ASCII glyphs and + * keeps the four buttons visually consistent. + */ +const ORIENTATION_PICKER: readonly { + id: string; + screenAngles: readonly [number, number]; + path: { x1: number; y1: number; x2: number; y2: number }; +}[] = [ + { id: 'h', screenAngles: [0, 180], path: { x1: 1, y1: 6, x2: 11, y2: 6 } }, + { id: 'v', screenAngles: [90, -90], path: { x1: 6, y1: 1, x2: 6, y2: 11 } }, + { id: '/', screenAngles: [-45, 135], path: { x1: 1, y1: 11, x2: 11, y2: 1 } }, + { id: '\\', screenAngles: [45, -135], path: { x1: 1, y1: 1, x2: 11, y2: 11 } }, +]; + +/** Smallest angular distance between two angles in degrees, accounting + * for the ±180° wrap. Returns a value in [0, 180]. */ +function angleDistance(a: number, b: number): number { + return Math.abs(((a - b + 540) % 360) - 180); +} + +/** + * Pick a target stored-angle for the clicked orientation. + * + * The two `screenAngles` are converted to label-space by subtracting the + * current view rotation. If the line's current angle already matches one + * of those candidates exactly, we flip to the other (lets the user click + * the same orientation button twice to reverse the line's direction). + * Otherwise, the candidate closer to the current angle wins so the line + * keeps its rough direction. + */ +export function pickAngle( + currentAngle: number, + screenAngles: readonly [number, number], + viewRotation: number, +): number { + const a = screenAngles[0] - viewRotation; + const b = screenAngles[1] - viewRotation; + if (currentAngle === a) return b; + if (currentAngle === b) return a; + return angleDistance(currentAngle, a) <= angleDistance(currentAngle, b) ? a : b; +} + export const line: ObjectTypeDefinition = { label: 'Line', icon: '—', @@ -61,6 +113,7 @@ export const line: ObjectTypeDefinition = { PropertiesPanel: ({ obj, onChange }) => { const t = useT(); const p = obj.props; + const viewRotation = useLabelStore((s) => s.canvasSettings.viewRotation); return (
@@ -98,6 +151,35 @@ export const line: ObjectTypeDefinition = {
+
+ +
+ {ORIENTATION_PICKER.map(({ id, screenAngles, path }) => ( + + ))} +
+
+