Skip to content

Commit 1c822db

Browse files
Merge pull request #865 from gridaco/feat/svg-editor-shift-edge-aspect-resize
feat(svg-editor): Shift + side-edge resize aspect-locks about the opposite-edge center
2 parents d389f0c + 8276932 commit 1c822db

11 files changed

Lines changed: 926 additions & 29 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// `applyResize` — aspect-lock under `{ aspect: true }` (Shift). A corner takes
2+
// the max-magnitude axis factor; a side edge drives the perpendicular axis by
3+
// the same factor about the perpendicular center. This variant drives the
4+
// dashed resize *preview* so it matches an aspect-locking host (the emitted
5+
// intent stays opposite-anchored / free — see state.ts). Composes with
6+
// `fromCenter` (Alt) for uniform-about-center.
7+
8+
import { describe, it, expect } from "vitest";
9+
import { applyResize } from "../event/gesture";
10+
import type { SelectionShape } from "../event";
11+
12+
function rect(x: number, y: number, w: number, h: number): SelectionShape {
13+
return { kind: "rect", rect: { x, y, width: w, height: h } };
14+
}
15+
function bbox(s: SelectionShape) {
16+
if (s.kind !== "rect") throw new Error("expected rect");
17+
return s.rect;
18+
}
19+
function center(r: { x: number; y: number; width: number; height: number }) {
20+
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
21+
}
22+
23+
describe("applyResize — aspect off is unchanged (regression guard)", () => {
24+
it("e edge stays free (perpendicular unchanged) without aspect", () => {
25+
expect(bbox(applyResize(rect(0, 0, 100, 50), "e", 20, 0))).toEqual({
26+
x: 0,
27+
y: 0,
28+
width: 120,
29+
height: 50,
30+
});
31+
});
32+
});
33+
34+
describe("applyResize — aspect (Shift) on a side edge follows the perpendicular", () => {
35+
it("e: left edge pinned, height scales with width about the vertical center", () => {
36+
const r = bbox(
37+
applyResize(rect(0, 0, 100, 50), "e", 20, 0, { aspect: true })
38+
);
39+
expect(r).toEqual({ x: 0, y: -5, width: 120, height: 60 }); // s = 1.2
40+
expect(r.y + r.height / 2).toBe(25); // vertical center preserved
41+
expect(r.x).toBe(0); // left edge pinned
42+
});
43+
44+
it("n: bottom edge pinned, width scales with height about the horizontal center", () => {
45+
const r = bbox(
46+
applyResize(rect(0, 0, 100, 50), "n", 0, -10, { aspect: true })
47+
);
48+
expect(r).toEqual({ x: -10, y: -10, width: 120, height: 60 }); // s = 1.2
49+
expect(r.x + r.width / 2).toBe(50); // horizontal center preserved
50+
expect(r.y + r.height).toBe(50); // bottom edge pinned
51+
});
52+
53+
it("e: crossing the opposite edge clamps (no flip), matching core", () => {
54+
// Drag the E edge far left, past the left edge → free width would be -50.
55+
// Core clamps sx/sy to 0.001 (a thin box pinned at the left edge); the
56+
// preview must match, not mirror.
57+
const r = bbox(
58+
applyResize(rect(0, 0, 100, 50), "e", -150, 0, { aspect: true })
59+
);
60+
expect(r.width).toBeCloseTo(0.1, 6); // 100 * 0.001, never negative
61+
expect(r.height).toBeCloseTo(0.05, 6); // 50 * 0.001, followed
62+
expect(r.x).toBe(0); // left edge still pinned
63+
});
64+
});
65+
66+
describe("applyResize — aspect (Shift) on a corner is max-magnitude uniform", () => {
67+
it("se collapses to the larger axis factor, origin pinned", () => {
68+
// sx = 1.2, sy = 1.1 → mag 1.2.
69+
const r = bbox(
70+
applyResize(rect(0, 0, 100, 50), "se", 20, 5, { aspect: true })
71+
);
72+
expect(r).toEqual({ x: 0, y: 0, width: 120, height: 60 });
73+
});
74+
});
75+
76+
describe("applyResize — Shift+Alt is uniform about the bbox center", () => {
77+
it("e edge: both opposite edges move, center stays put", () => {
78+
const r = bbox(
79+
applyResize(rect(0, 0, 100, 50), "e", 20, 0, {
80+
fromCenter: true,
81+
aspect: true,
82+
})
83+
);
84+
expect(r).toEqual({ x: -20, y: -10, width: 140, height: 70 }); // s = 1.4
85+
expect(center(r)).toEqual({ x: 50, y: 25 }); // initial center
86+
});
87+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Aspect-ratio guide — the diagonal dashed line drawn across the resize
2+
// preview box while Shift aspect-locks the gesture. Mirrors the main editor's
3+
// `AspectRatioGuide`; geometry comes from `cmath.ui.diagonalForDirection`
4+
// (the corner opposite the dragged handle). Lives in `decoration.lines`,
5+
// alongside the dashed preview rect/polyline.
6+
7+
import { describe, it, expect } from "vitest";
8+
import cmath from "@grida/cmath";
9+
import { SurfaceState } from "../event/state";
10+
import { buildChrome } from "../surface/chrome";
11+
import { DEFAULT_STYLE } from "../surface/style";
12+
import type { NodeId, Rect } from "../event/gesture";
13+
import type { SelectionShape, ResizeDirection } from "../event";
14+
15+
const IDENTITY_CAMERA: cmath.Transform = [
16+
[1, 0, 0],
17+
[0, 1, 0],
18+
];
19+
20+
function resizeChrome(
21+
shape: SelectionShape,
22+
direction: ResizeDirection,
23+
opts: { shift: boolean }
24+
) {
25+
const state = new SurfaceState();
26+
state.setSelection(["a"]);
27+
state.setTransform(IDENTITY_CAMERA);
28+
state.modifiers.shift = opts.shift;
29+
state.gesture = {
30+
kind: "resize",
31+
ids: ["a"],
32+
direction,
33+
initial_shape: shape,
34+
anchor_doc: [0, 0],
35+
last_doc: [0, 0],
36+
current_shape: shape,
37+
preview_shape: shape,
38+
};
39+
const shapeOf = (_: NodeId): SelectionShape | null => null;
40+
const { decoration } = buildChrome({
41+
state,
42+
shapeOf,
43+
style: DEFAULT_STYLE,
44+
width: 1000,
45+
height: 1000,
46+
});
47+
return decoration;
48+
}
49+
50+
const RECT: Rect = { x: 0, y: 0, width: 100, height: 50 };
51+
52+
describe("aspect-ratio guide — Shift draws the diagonal of the preview box", () => {
53+
it("is absent without Shift", () => {
54+
const decoration = resizeChrome({ kind: "rect", rect: RECT }, "se", {
55+
shift: false,
56+
});
57+
expect(decoration.lines ?? []).toHaveLength(0);
58+
});
59+
60+
it("'se' draws TL→BR (the corner opposite the dragged handle)", () => {
61+
const decoration = resizeChrome({ kind: "rect", rect: RECT }, "se", {
62+
shift: true,
63+
});
64+
const lines = decoration.lines ?? [];
65+
expect(lines).toHaveLength(1);
66+
const l = lines[0];
67+
expect(l.dashed).toBe(true);
68+
// toCorners = [TL, TR, BR, BL]; "se" → TL→BR.
69+
expect([l.x1, l.y1]).toEqual([0, 0]);
70+
expect([l.x2, l.y2]).toEqual([100, 50]);
71+
});
72+
73+
it("'e' edge uses the same SE diagonal (TL→BR)", () => {
74+
const decoration = resizeChrome({ kind: "rect", rect: RECT }, "e", {
75+
shift: true,
76+
});
77+
const l = (decoration.lines ?? [])[0];
78+
expect([l.x1, l.y1, l.x2, l.y2]).toEqual([0, 0, 100, 50]);
79+
});
80+
81+
it("'ne' draws BL→TR", () => {
82+
const decoration = resizeChrome({ kind: "rect", rect: RECT }, "ne", {
83+
shift: true,
84+
});
85+
const l = (decoration.lines ?? [])[0];
86+
// "ne" → BL(0,50) → TR(100,0).
87+
expect([l.x1, l.y1, l.x2, l.y2]).toEqual([0, 50, 100, 0]);
88+
});
89+
90+
it("transformed shape: the diagonal is projected through the matrix", () => {
91+
const local: Rect = { x: 0, y: 0, width: 100, height: 100 };
92+
const matrix = cmath.transform.rotate(
93+
cmath.transform.identity,
94+
30,
95+
[50, 50]
96+
);
97+
const decoration = resizeChrome(
98+
{ kind: "transformed", local, matrix },
99+
"se",
100+
{ shift: true }
101+
);
102+
const l = (decoration.lines ?? [])[0];
103+
expect(l.dashed).toBe(true);
104+
// "se" → local TL(0,0) → BR(100,100), each projected by the matrix.
105+
const [ex1, ey1] = cmath.vector2.transform([0, 0], matrix);
106+
const [ex2, ey2] = cmath.vector2.transform([100, 100], matrix);
107+
expect(l.x1).toBeCloseTo(ex1, 9);
108+
expect(l.y1).toBeCloseTo(ey1, 9);
109+
expect(l.x2).toBeCloseTo(ex2, 9);
110+
expect(l.y2).toBeCloseTo(ey2, 9);
111+
});
112+
113+
it("coexists with the dashed preview rect (both are emitted)", () => {
114+
const decoration = resizeChrome({ kind: "rect", rect: RECT }, "se", {
115+
shift: true,
116+
});
117+
expect((decoration.rects ?? []).filter((r) => r.dashed)).toHaveLength(1);
118+
expect(decoration.lines ?? []).toHaveLength(1);
119+
});
120+
});

packages/grida-canvas-hud/event/gesture.ts

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -410,13 +410,21 @@ export function applyResize(
410410
direction: ResizeDirection,
411411
dx: number,
412412
dy: number,
413-
opts?: { fromCenter?: boolean }
413+
opts?: { fromCenter?: boolean; aspect?: boolean }
414414
): SelectionShape {
415415
const fromCenter = opts?.fromCenter ?? false;
416+
const aspect = opts?.aspect ?? false;
416417
if (initial.kind === "rect") {
417418
return {
418419
kind: "rect",
419-
rect: applyResizeRect(initial.rect, direction, dx, dy, fromCenter),
420+
rect: applyResizeRect(
421+
initial.rect,
422+
direction,
423+
dx,
424+
dy,
425+
fromCenter,
426+
aspect
427+
),
420428
};
421429
}
422430
if (initial.kind === "transformed") {
@@ -435,7 +443,14 @@ export function applyResize(
435443
// for rotated / sheared selections.
436444
return {
437445
kind: "transformed",
438-
local: applyResizeRect(initial.local, direction, ldx, ldy, fromCenter),
446+
local: applyResizeRect(
447+
initial.local,
448+
direction,
449+
ldx,
450+
ldy,
451+
fromCenter,
452+
aspect
453+
),
439454
matrix: m,
440455
};
441456
}
@@ -450,13 +465,19 @@ export function applyResize(
450465
* `fromCenter` (Alt), the resize is symmetric about the rect's center:
451466
* the dragged edge moves by the delta and the opposite edge mirrors it,
452467
* so the size delta doubles and the center stays put.
468+
*
469+
* With `aspect` (Shift), the resize is uniform: a corner takes the
470+
* max-magnitude axis factor; a side edge drives the perpendicular axis by
471+
* the same factor about the perpendicular center. This mirrors the host's
472+
* core `compute_factors`, so the dashed preview tracks what gets written.
453473
*/
454474
function applyResizeRect(
455475
initial: Rect,
456476
direction: ResizeDirection,
457477
dx: number,
458478
dy: number,
459-
fromCenter = false
479+
fromCenter = false,
480+
aspect = false
460481
): Rect {
461482
let { x, y, width, height } = initial;
462483

@@ -486,5 +507,49 @@ function applyResizeRect(
486507
height -= k * dy;
487508
}
488509

489-
return { x, y, width, height };
510+
if (!aspect) return { x, y, width, height };
511+
512+
// Aspect-lock (Shift). Take the signed per-axis factors the free math
513+
// produced, lock them to one magnitude, then rebuild the box about the
514+
// anchor the free math pins — the opposite edge/corner, or the bbox
515+
// center under `fromCenter`. A side edge has only one driven axis, so the
516+
// perpendicular follows it about the perpendicular center.
517+
const affectsX = hasE || hasW;
518+
const affectsY = hasN || hasS;
519+
// Per-axis anchor: bbox center under Alt or on the free (perpendicular)
520+
// axis; else the pinned far edge — `pinLo` (moving the high edge) pins the
521+
// low coordinate, otherwise the high one.
522+
const anchorAxis = (
523+
affected: boolean,
524+
lo: number,
525+
size: number,
526+
pinLo: boolean
527+
) => (fromCenter || !affected ? lo + size / 2 : pinLo ? lo : lo + size);
528+
const ax = anchorAxis(affectsX, initial.x, initial.width, hasE);
529+
const ay = anchorAxis(affectsY, initial.y, initial.height, hasS);
530+
531+
const sxRaw = initial.width !== 0 ? width / initial.width : 1;
532+
const syRaw = initial.height !== 0 ? height / initial.height : 1;
533+
let sx: number;
534+
let sy: number;
535+
if (affectsX && affectsY) {
536+
const mag = Math.max(Math.abs(sxRaw), Math.abs(syRaw));
537+
sx = sxRaw >= 0 ? mag : -mag;
538+
sy = syRaw >= 0 ? mag : -mag;
539+
} else if (affectsX) {
540+
sx = sxRaw;
541+
sy = sxRaw;
542+
} else {
543+
sx = syRaw;
544+
sy = syRaw;
545+
}
546+
547+
// Clamp to the same positive floor as core `compute_factors`. When the drag
548+
// crosses the opposite edge the core commits a degenerate-thin box pinned at
549+
// the anchor (not a mirror), so the aspect preview must too — otherwise the
550+
// dashed box flips while the committed geometry collapses.
551+
sx = Math.max(0.001, sx);
552+
sy = Math.max(0.001, sy);
553+
554+
return cmath.rect.scale(initial, [ax, ay], [sx, sy]);
490555
}

packages/grida-canvas-hud/event/state.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -483,10 +483,12 @@ export class SurfaceState {
483483
return this.onPointerUp(event.x, event.y, event.button, deps);
484484
case "modifiers": {
485485
this.modifiers = event.mods;
486-
// A mid-drag Alt toggle (no pointer move) switches the resize anchor
487-
// opposite<->center, so the dashed preview must refresh now —
488-
// otherwise it stays stale until the next move. `current_shape` (the
489-
// intent dims) is anchor-independent, so only `preview_shape` changes.
486+
// A mid-drag Alt toggle (anchor opposite<->center) or Shift toggle
487+
// (aspect-lock on/off) — both with no pointer move — change the
488+
// dashed preview, so it must refresh now or it stays stale until the
489+
// next move. `current_shape` (the intent dims) is modifier-independent
490+
// (the host applies anchor/aspect itself), so only `preview_shape`
491+
// changes.
490492
if (this.gesture.kind === "resize") {
491493
const g = this.gesture;
492494
const dx = g.last_doc[0] - g.anchor_doc[0];
@@ -509,19 +511,23 @@ export class SurfaceState {
509511
}
510512
}
511513

512-
/** The dashed-preview shape for a resize: center-symmetric under Alt,
513-
* else the opposite-anchored `opposite` shape (which the intent carries).
514-
* One rule, shared by the pointer-move handler and the modifier-toggle
515-
* refresh so the preview can't go stale on a mid-drag Alt flip. */
514+
/** The dashed-preview shape for a resize: aspect-locked under Shift
515+
* and/or center-symmetric under Alt (the two compose), else the
516+
* opposite-anchored `opposite` shape (which the intent carries). One
517+
* rule, shared by the pointer-move handler and the modifier-toggle
518+
* refresh so the preview can't go stale on a mid-drag Shift/Alt flip. */
516519
private resizePreviewShape(
517520
g: Extract<SurfaceGesture, { kind: "resize" }>,
518521
dx: number,
519522
dy: number,
520523
opposite: SelectionShape
521524
): SelectionShape {
522-
return this.modifiers.alt
523-
? applyResize(g.initial_shape, g.direction, dx, dy, { fromCenter: true })
524-
: opposite;
525+
const { alt, shift } = this.modifiers;
526+
if (!alt && !shift) return opposite;
527+
return applyResize(g.initial_shape, g.direction, dx, dy, {
528+
fromCenter: alt,
529+
aspect: shift,
530+
});
525531
}
526532

527533
// ── Pointer move ─────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)