@1agh/maude 0.39.1 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/apps/studio/ai-banner.tsx +2 -2
- package/apps/studio/annotations-context-toolbar.tsx +44 -1
- package/apps/studio/annotations-layer.tsx +306 -5
- package/apps/studio/annotations-model.ts +107 -6
- package/apps/studio/api.ts +1316 -67
- package/apps/studio/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
- package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
- package/apps/studio/bin/_fetch-asset.mjs +556 -0
- package/apps/studio/bin/_html-playwright.mjs +9 -1
- package/apps/studio/bin/_pdf-playwright.mjs +8 -1
- package/apps/studio/bin/_png-playwright.mjs +8 -1
- package/apps/studio/bin/_pw-launch.mjs +54 -0
- package/apps/studio/bin/_svg-playwright.mjs +8 -1
- package/apps/studio/bin/_video-playwright.mjs +452 -0
- package/apps/studio/bin/annotate.mjs +576 -34
- package/apps/studio/bin/canvas-rects.sh +152 -0
- package/apps/studio/bin/fetch-asset.sh +34 -0
- package/apps/studio/bin/prep.sh +8 -1
- package/apps/studio/bin/read-annotations.mjs +138 -7
- package/apps/studio/bin/smoke.sh +42 -6
- package/apps/studio/build.ts +21 -0
- package/apps/studio/canvas-comment-mount.tsx +138 -4
- package/apps/studio/canvas-edit.ts +2625 -111
- package/apps/studio/canvas-lib.tsx +238 -2
- package/apps/studio/canvas-list-watch.ts +6 -2
- package/apps/studio/canvas-shell.tsx +514 -20
- package/apps/studio/client/app.jsx +2480 -52
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/github.js +15 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
- package/apps/studio/client/panels/timeline-parse.js +229 -0
- package/apps/studio/client/panels/timeline-snap.js +55 -0
- package/apps/studio/client/styles/3-shell-maude.css +155 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/config.schema.json +14 -0
- package/apps/studio/context-menu.tsx +16 -6
- package/apps/studio/context.ts +113 -1
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +89 -17
- package/apps/studio/dist/comment-mount.js +59 -1
- package/apps/studio/dist/runtime/.min-sizes.json +11 -1
- package/apps/studio/dist/runtime/@remotion_media.js +491 -0
- package/apps/studio/dist/runtime/@remotion_player.js +56 -0
- package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
- package/apps/studio/dist/runtime/remotion.js +42 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +127 -1
- package/apps/studio/drag-state.ts +24 -0
- package/apps/studio/equal-spacing-detector.ts +205 -0
- package/apps/studio/export-dialog.tsx +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +117 -0
- package/apps/studio/exporters/_runtime.ts +69 -0
- package/apps/studio/exporters/html.ts +4 -3
- package/apps/studio/exporters/index.ts +28 -2
- package/apps/studio/exporters/pdf.ts +4 -3
- package/apps/studio/exporters/png.ts +5 -3
- package/apps/studio/exporters/pptx.ts +6 -4
- package/apps/studio/exporters/svg.ts +9 -5
- package/apps/studio/exporters/video-encode-lib.ts +200 -0
- package/apps/studio/exporters/video-render-lib.ts +108 -0
- package/apps/studio/exporters/video.ts +184 -0
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +758 -27
- package/apps/studio/input-router.tsx +19 -1
- package/apps/studio/marquee-overlay.tsx +1 -1
- package/apps/studio/measure-overlay.tsx +241 -0
- package/apps/studio/participants-chrome.tsx +3 -3
- package/apps/studio/runtime-bundle.ts +30 -0
- package/apps/studio/server.ts +34 -9
- package/apps/studio/sizing-mode.ts +117 -0
- package/apps/studio/spacing-handles.ts +166 -0
- package/apps/studio/test/annotate-write.test.ts +890 -0
- package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
- package/apps/studio/test/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-create-api.test.ts +76 -0
- package/apps/studio/test/canvas-edit.test.ts +140 -0
- package/apps/studio/test/canvas-list-watch.test.ts +49 -0
- package/apps/studio/test/canvas-media-drop.test.ts +30 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +54 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/clip-addressing.test.ts +732 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -0
- package/apps/studio/test/config-reload.test.ts +230 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
- package/apps/studio/test/dom-selection.test.ts +130 -0
- package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
- package/apps/studio/test/edit-persistence.test.ts +91 -0
- package/apps/studio/test/edit-scope-api.test.ts +115 -0
- package/apps/studio/test/element-resize.test.ts +136 -0
- package/apps/studio/test/element-structural-api.test.ts +360 -0
- package/apps/studio/test/element-structural-edit.test.ts +233 -0
- package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
- package/apps/studio/test/exporters/runtime.test.ts +59 -0
- package/apps/studio/test/file-lock.test.ts +84 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
- package/apps/studio/test/history-rollback.test.ts +26 -0
- package/apps/studio/test/knob-props-authored.test.ts +87 -0
- package/apps/studio/test/read-annotations.test.ts +154 -0
- package/apps/studio/test/sizing-mode.test.ts +102 -0
- package/apps/studio/test/spacing-handles.test.ts +138 -0
- package/apps/studio/test/specimen-select.test.ts +88 -0
- package/apps/studio/test/timeline-parse.test.ts +127 -0
- package/apps/studio/test/timeline-snap.test.ts +85 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/test/video-asset.test.ts +163 -0
- package/apps/studio/test/video-comp-fixture.test.ts +50 -0
- package/apps/studio/test/video-comp.test.ts +168 -0
- package/apps/studio/test/video-render-bridge.test.ts +149 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- package/apps/studio/use-annotation-resize.tsx +6 -3
- package/apps/studio/use-canvas-media-drop.tsx +66 -4
- package/apps/studio/use-element-resize.tsx +732 -0
- package/apps/studio/use-keyboard-discipline.tsx +205 -15
- package/apps/studio/use-selection-set.tsx +14 -0
- package/apps/studio/use-spacing-handles.tsx +388 -0
- package/apps/studio/video-comp.tsx +444 -0
- package/apps/studio/whats-new.json +55 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/commands/design.mjs +6 -1
- package/cli/commands/design.test.mjs +49 -1
- package/cli/lib/fetch-asset.test.mjs +213 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
- package/plugins/design/templates/_shell.html +25 -2
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// edit-persistence — DDR-148 dogfood. A CSS edit on a video-comp element must
|
|
2
|
+
// survive into the RENDERED module, not just the optimistic DOM overlay — i.e.
|
|
3
|
+
// source edit → build → the new value is in the compiled canvas the Player runs.
|
|
4
|
+
// This end-to-end guard would have caught the original "my color reverts when
|
|
5
|
+
// the video replays" bug (a trailing-comma insert produced `opacity: o, , color`
|
|
6
|
+
// → the build FAILED → the Player kept rendering the OLD source).
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { buildCanvasModule } from '../canvas-build.ts';
|
|
13
|
+
import { applyEdit } from '../canvas-edit.ts';
|
|
14
|
+
import { transpileCanvasSource } from '../canvas-pipeline.ts';
|
|
15
|
+
|
|
16
|
+
const CANVAS = '/tmp/edit-persist.tsx';
|
|
17
|
+
|
|
18
|
+
/** buildCanvasModule needs the entrypoint on disk — write the source, then build. */
|
|
19
|
+
async function buildAt(source: string) {
|
|
20
|
+
const dir = mkdtempSync(join(tmpdir(), 'edit-persist-'));
|
|
21
|
+
const abs = join(dir, 'Comp.tsx');
|
|
22
|
+
writeFileSync(abs, source);
|
|
23
|
+
return buildCanvasModule(abs, source);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A frame-driven letter span (the exact shape from the intro demo): computed
|
|
27
|
+
// transform/opacity + a trailing comma on the last property.
|
|
28
|
+
const COMP = `import { AbsoluteFill, interpolate, useCurrentFrame } from 'remotion';
|
|
29
|
+
export default function Comp() {
|
|
30
|
+
const frame = useCurrentFrame();
|
|
31
|
+
const y = interpolate(frame, [0, 20], [40, 0]);
|
|
32
|
+
const o = interpolate(frame, [0, 20], [0, 1]);
|
|
33
|
+
return (
|
|
34
|
+
<AbsoluteFill>
|
|
35
|
+
<span
|
|
36
|
+
style={{
|
|
37
|
+
fontSize: 132,
|
|
38
|
+
fontWeight: 800,
|
|
39
|
+
transform: \`translateY(\${y}px)\`,
|
|
40
|
+
opacity: o,
|
|
41
|
+
color: '#f0ece5',
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
maude
|
|
45
|
+
</span>
|
|
46
|
+
</AbsoluteFill>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
function spanId(source: string): string {
|
|
52
|
+
const { withIds } = transpileCanvasSource(CANVAS, source);
|
|
53
|
+
const m = withIds.match(/<span[^>]*?data-cd-id="([0-9a-f]{8})"/);
|
|
54
|
+
if (!m) throw new Error('no span id');
|
|
55
|
+
return m[1] as string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('edit-persistence — a CSS edit survives into the built module', () => {
|
|
59
|
+
test('SWAP an existing color → the new color is in the compiled canvas', async () => {
|
|
60
|
+
const id = spanId(COMP);
|
|
61
|
+
const edited = applyEdit(CANVAS, COMP, id, 'style.color', "'#ff0000'").source;
|
|
62
|
+
expect(edited).toContain("color: '#ff0000'");
|
|
63
|
+
// The value the Player actually renders is the BUILT module — assert it there.
|
|
64
|
+
const built = await buildAt(edited);
|
|
65
|
+
expect(built.js).toContain('#ff0000');
|
|
66
|
+
expect(built.js).not.toContain('#f0ece5'); // old value fully replaced
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('INSERT a new prop next to a TRAILING comma → still builds + renders (the bug)', async () => {
|
|
70
|
+
const id = spanId(COMP);
|
|
71
|
+
// Insert a brand-new property where the last one (color) ends with a comma.
|
|
72
|
+
const edited = applyEdit(CANVAS, COMP, id, 'style.letterSpacing', "'-0.04em'").source;
|
|
73
|
+
expect(edited).not.toMatch(/,\s*,/); // NO double comma (the crash that reverted the render)
|
|
74
|
+
const built = await buildAt(edited); // must not throw
|
|
75
|
+
expect(built.js).toContain('-0.04em');
|
|
76
|
+
expect(built.js).toContain('#f0ece5'); // untouched props survive
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a SEQUENCE of edits all persist (no edit clobbers a prior one)', async () => {
|
|
80
|
+
let src = COMP;
|
|
81
|
+
const id = spanId(src);
|
|
82
|
+
src = applyEdit(CANVAS, src, id, 'style.color', "'#00ff00'").source;
|
|
83
|
+
src = applyEdit(CANVAS, src, id, 'style.fontWeight', '900').source;
|
|
84
|
+
src = applyEdit(CANVAS, src, id, 'style.fontSize', '148').source;
|
|
85
|
+
const built = await buildAt(src);
|
|
86
|
+
expect(built.js).toContain('#00ff00');
|
|
87
|
+
expect(built.js).toContain('148');
|
|
88
|
+
// the frame-driven values must still be present (edits never touched them)
|
|
89
|
+
expect(built.js).toContain('translateY');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// GET /_api/edit-scope — the INV-3 local-vs-shared predictability verdict
|
|
2
|
+
// (feature-element-editing-robustness Stage H). Boots a real server, serves a
|
|
3
|
+
// canvas with a `Card` component reused 3×, then asserts:
|
|
4
|
+
// • an element INSIDE the reused component → scope 'shared', affects 3
|
|
5
|
+
// • a top-level artboard element → scope 'local'
|
|
6
|
+
// • the `.map()` caveat (DDR-139) — a local element rendered N× (client-
|
|
7
|
+
// supplied `rendered`) → scope 'shared', reason 'mapped'
|
|
8
|
+
// • the route is main-origin only (proven separately in canvas-origin-gate).
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
15
|
+
|
|
16
|
+
const CANVAS_SRC = `const Card = ({ title }) => (
|
|
17
|
+
<div className="card">
|
|
18
|
+
<h3>{title}</h3>
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
export default function Gallery() {
|
|
23
|
+
return (
|
|
24
|
+
<DesignCanvas>
|
|
25
|
+
<DCArtboard id="home" label="Home" width={1440} height={1024}>
|
|
26
|
+
<section className="grid">
|
|
27
|
+
<Card title="A" />
|
|
28
|
+
<Card title="B" />
|
|
29
|
+
<Card title="C" />
|
|
30
|
+
</section>
|
|
31
|
+
</DCArtboard>
|
|
32
|
+
</DesignCanvas>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
interface LocEntry {
|
|
38
|
+
line: number;
|
|
39
|
+
jsxPath: string[];
|
|
40
|
+
componentName: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function boot() {
|
|
44
|
+
const { root, designRoot } = makeSandbox();
|
|
45
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
46
|
+
writeFileSync(join(designRoot, 'ui', 'Gallery.tsx'), CANVAS_SRC);
|
|
47
|
+
const port = nextPort();
|
|
48
|
+
const proc = await bootServer(root, port);
|
|
49
|
+
return { designRoot, main: `http://localhost:${port}`, proc };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Warm the pipeline (stamps cd-ids + writes _locator.json), return the map. */
|
|
53
|
+
async function locatorMap(main: string, designRoot: string): Promise<Record<string, LocEntry>> {
|
|
54
|
+
const r = await fetch(`${main}/.design/ui/Gallery.tsx`, { signal: AbortSignal.timeout(2000) });
|
|
55
|
+
expect(r.status).toBe(200);
|
|
56
|
+
const locator = JSON.parse(readFileSync(join(designRoot, '_locator.json'), 'utf8'));
|
|
57
|
+
return locator['ui/Gallery'] as Record<string, LocEntry>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function findId(map: Record<string, LocEntry>, pred: (e: LocEntry) => boolean): string {
|
|
61
|
+
const hit = Object.entries(map).find(([, e]) => pred(e));
|
|
62
|
+
if (!hit) throw new Error('no matching locator entry');
|
|
63
|
+
return hit[0];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function scope(main: string, id: string, rendered?: number) {
|
|
67
|
+
const q = new URLSearchParams({ canvas: 'ui/Gallery', id });
|
|
68
|
+
if (rendered != null) q.set('rendered', String(rendered));
|
|
69
|
+
const res = await fetch(`${main}/_api/edit-scope?${q}`, { signal: AbortSignal.timeout(2000) });
|
|
70
|
+
return { status: res.status, body: (await res.json()) as Record<string, unknown> };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('GET /_api/edit-scope', () => {
|
|
74
|
+
test('reports shared for a reused-component inner element, local for a top-level one', async () => {
|
|
75
|
+
const { designRoot, main, proc } = await boot();
|
|
76
|
+
try {
|
|
77
|
+
const map = await locatorMap(main, designRoot);
|
|
78
|
+
|
|
79
|
+
// An element inside the reused `Card` component (componentName === 'Card').
|
|
80
|
+
const innerId = findId(map, (e) => e.componentName === 'Card');
|
|
81
|
+
const shared = await scope(main, innerId);
|
|
82
|
+
expect(shared.status).toBe(200);
|
|
83
|
+
expect(shared.body.scope).toBe('shared');
|
|
84
|
+
expect(shared.body.componentName).toBe('Card');
|
|
85
|
+
expect(shared.body.affects).toBe(3);
|
|
86
|
+
expect(shared.body.reason).toBe('component');
|
|
87
|
+
|
|
88
|
+
// A top-level artboard element (the <section>) — not inside a reused comp.
|
|
89
|
+
const topId = findId(
|
|
90
|
+
map,
|
|
91
|
+
(e) => e.componentName !== 'Card' && e.jsxPath[e.jsxPath.length - 1] === 'section'
|
|
92
|
+
);
|
|
93
|
+
const local = await scope(main, topId);
|
|
94
|
+
expect(local.status).toBe(200);
|
|
95
|
+
expect(local.body.scope).toBe('local');
|
|
96
|
+
expect(local.body.affects).toBe(1);
|
|
97
|
+
expect(local.body.reason).toBe('single');
|
|
98
|
+
|
|
99
|
+
// The .map() caveat (DDR-139): a local element the client says renders N×
|
|
100
|
+
// is 'shared' ('mapped') even though its source usage count is 1.
|
|
101
|
+
const mapped = await scope(main, topId, 4);
|
|
102
|
+
expect(mapped.body.scope).toBe('shared');
|
|
103
|
+
expect(mapped.body.reason).toBe('mapped');
|
|
104
|
+
expect(mapped.body.affects).toBe(4);
|
|
105
|
+
|
|
106
|
+
// A bogus id is a 400 (invalid data-cd-id shape).
|
|
107
|
+
const bad = await fetch(`${main}/_api/edit-scope?canvas=ui/Gallery&id=zzz`, {
|
|
108
|
+
signal: AbortSignal.timeout(2000),
|
|
109
|
+
});
|
|
110
|
+
expect(bad.status).toBe(400);
|
|
111
|
+
} finally {
|
|
112
|
+
await killProc(proc as never);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// element-resize — feature-element-editing-robustness Stage D (Task D1 + G2).
|
|
2
|
+
// Pure resize math: `computeElementResize(corner, start, dxW, dyW, mods, flags)`.
|
|
3
|
+
// World-unit deltas → target box, deriving left/top only when the moved edge
|
|
4
|
+
// permits it (out-of-flow element with an inline value), holding the opposite
|
|
5
|
+
// edge fixed (Figma grammar).
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import {
|
|
9
|
+
computeElementResize,
|
|
10
|
+
type ElResizeStart,
|
|
11
|
+
pointerAngleRad,
|
|
12
|
+
rotateDeltaDeg,
|
|
13
|
+
rotatePointDeg,
|
|
14
|
+
rotationDegFromMatrix,
|
|
15
|
+
scaleFromMatrix,
|
|
16
|
+
} from '../use-element-resize.tsx';
|
|
17
|
+
|
|
18
|
+
const NO_MODS = { aspect: false, center: false };
|
|
19
|
+
const NO_MOVE = { canMoveLeft: false, canMoveTop: false };
|
|
20
|
+
const CAN_MOVE = { canMoveLeft: true, canMoveTop: true };
|
|
21
|
+
|
|
22
|
+
// A 200×100 box at left:50 top:30 (out-of-flow when CAN_MOVE).
|
|
23
|
+
const START: ElResizeStart = { w: 200, h: 100, left: 50, top: 30 };
|
|
24
|
+
|
|
25
|
+
describe('computeElementResize — plain drags', () => {
|
|
26
|
+
test('SE corner grows width + height, never moves origin', () => {
|
|
27
|
+
const r = computeElementResize('se', START, 40, 20, NO_MODS, CAN_MOVE);
|
|
28
|
+
expect(r.width).toBe(240);
|
|
29
|
+
expect(r.height).toBe(120);
|
|
30
|
+
expect(r.left).toBeUndefined();
|
|
31
|
+
expect(r.top).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('E edge changes width only', () => {
|
|
35
|
+
const r = computeElementResize('e', START, 30, 999, NO_MODS, CAN_MOVE);
|
|
36
|
+
expect(r.width).toBe(230);
|
|
37
|
+
expect(r.height).toBe(100);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('S edge changes height only', () => {
|
|
41
|
+
const r = computeElementResize('s', START, 999, -25, NO_MODS, CAN_MOVE);
|
|
42
|
+
expect(r.width).toBe(200);
|
|
43
|
+
expect(r.height).toBe(75);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('NW corner (out-of-flow) moves left+top, SE corner stays fixed', () => {
|
|
47
|
+
// Drag NW by (+10, +10): width 190, height 90; left 60, top 40.
|
|
48
|
+
const r = computeElementResize('nw', START, 10, 10, NO_MODS, CAN_MOVE);
|
|
49
|
+
expect(r.width).toBe(190);
|
|
50
|
+
expect(r.height).toBe(90);
|
|
51
|
+
expect(r.left).toBe(60); // start.left + start.w - width = 50 + 200 - 190
|
|
52
|
+
expect(r.top).toBe(40); // start.top + start.h - height = 30 + 100 - 90
|
|
53
|
+
// Opposite (SE) corner is invariant: left+width and top+height unchanged.
|
|
54
|
+
expect((r.left as number) + r.width).toBe(START.left + START.w);
|
|
55
|
+
expect((r.top as number) + r.height).toBe(START.top + START.h);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('NW corner IN-FLOW resizes width/height but never writes left/top', () => {
|
|
59
|
+
const r = computeElementResize('nw', START, 10, 10, NO_MODS, NO_MOVE);
|
|
60
|
+
expect(r.width).toBe(190);
|
|
61
|
+
expect(r.height).toBe(90);
|
|
62
|
+
expect(r.left).toBeUndefined();
|
|
63
|
+
expect(r.top).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('W edge (out-of-flow) moves left, keeps the east edge fixed', () => {
|
|
67
|
+
const r = computeElementResize('w', START, -20, 0, NO_MODS, CAN_MOVE);
|
|
68
|
+
expect(r.width).toBe(220); // start.w - dxW = 200 - (-20)
|
|
69
|
+
expect(r.left).toBe(30); // 50 + 200 - 220
|
|
70
|
+
expect(r.top).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('computeElementResize — modifiers', () => {
|
|
75
|
+
test('Shift on SE corner locks the start aspect ratio (2:1)', () => {
|
|
76
|
+
// Bigger horizontal change → height driven by width to keep 2:1.
|
|
77
|
+
const r = computeElementResize('se', START, 100, 5, { aspect: true, center: false }, CAN_MOVE);
|
|
78
|
+
expect(r.width).toBe(300);
|
|
79
|
+
expect(r.height).toBe(150); // 300 / (200/100)
|
|
80
|
+
expect(r.width / r.height).toBe(START.w / START.h);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('Alt on E edge (out-of-flow) resizes from center: 2× delta, left shifts', () => {
|
|
84
|
+
const r = computeElementResize('e', START, 20, 0, { aspect: false, center: true }, CAN_MOVE);
|
|
85
|
+
expect(r.width).toBe(240); // 200 + 2*20
|
|
86
|
+
expect(r.left).toBe(30); // center-preserving: 50 + (200-240)/2
|
|
87
|
+
// Center is invariant: left + width/2 unchanged.
|
|
88
|
+
expect((r.left as number) + r.width / 2).toBe(START.left + START.w / 2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('shrinking past zero clamps to the 1px minimum', () => {
|
|
92
|
+
const r = computeElementResize('se', START, -500, -500, NO_MODS, CAN_MOVE);
|
|
93
|
+
expect(r.width).toBe(1);
|
|
94
|
+
expect(r.height).toBe(1);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('rotate math (Task L8)', () => {
|
|
99
|
+
test('rotationDegFromMatrix reads the angle', () => {
|
|
100
|
+
expect(rotationDegFromMatrix('none')).toBe(0);
|
|
101
|
+
expect(rotationDegFromMatrix(null)).toBe(0);
|
|
102
|
+
expect(rotationDegFromMatrix('matrix(1, 0, 0, 1, 0, 0)')).toBe(0);
|
|
103
|
+
expect(rotationDegFromMatrix('matrix(0, 1, -1, 0, 0, 0)')).toBe(90); // CW 90°
|
|
104
|
+
expect(Math.round(rotationDegFromMatrix('matrix(0.7071, 0.7071, -0.7071, 0.7071, 0, 0)'))).toBe(
|
|
105
|
+
45
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('scaleFromMatrix reads the .dc-world zoom', () => {
|
|
110
|
+
expect(scaleFromMatrix('none')).toBe(1);
|
|
111
|
+
expect(scaleFromMatrix('matrix(2, 0, 0, 2, 100, 50)')).toBe(2);
|
|
112
|
+
// Scale survives a combined rotate+scale (magnitude of the first column).
|
|
113
|
+
expect(scaleFromMatrix('matrix(0, 1.5, -1.5, 0, 0, 0)')).toBe(1.5);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('rotatePointDeg rotates a local offset (screen y-down, CW positive)', () => {
|
|
117
|
+
const [x0, y0] = rotatePointDeg(10, 0, 0);
|
|
118
|
+
expect(x0).toBeCloseTo(10);
|
|
119
|
+
expect(y0).toBeCloseTo(0);
|
|
120
|
+
const [x90, y90] = rotatePointDeg(10, 0, 90);
|
|
121
|
+
expect(x90).toBeCloseTo(0);
|
|
122
|
+
expect(y90).toBeCloseTo(10);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('rotateDeltaDeg turns by the change in center→pointer angle; Shift snaps 15°', () => {
|
|
126
|
+
const start = pointerAngleRad(100, 100, 200, 100); // pointer right → 0 rad
|
|
127
|
+
const down = pointerAngleRad(100, 100, 100, 200); // pointer down → +π/2
|
|
128
|
+
// From 0°, dragging the pointer from right to down = +90°.
|
|
129
|
+
expect(rotateDeltaDeg(0, start, down, false)).toBe(90);
|
|
130
|
+
// No pointer change → angle unchanged (relative model).
|
|
131
|
+
expect(rotateDeltaDeg(30, start, start, false)).toBe(30);
|
|
132
|
+
// Shift snaps to a 15° multiple.
|
|
133
|
+
expect(rotateDeltaDeg(0, start, down, true) % 15).toBe(0);
|
|
134
|
+
expect(rotateDeltaDeg(0, start, down, true)).toBe(90);
|
|
135
|
+
});
|
|
136
|
+
});
|