@1agh/maude 0.40.0 → 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/apps/studio/ai-banner.tsx +2 -2
- package/apps/studio/annotations-layer.tsx +53 -2
- package/apps/studio/api.ts +504 -3
- 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/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/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 +744 -11
- package/apps/studio/canvas-lib.tsx +219 -2
- package/apps/studio/canvas-shell.tsx +487 -20
- package/apps/studio/client/app.jsx +1451 -22
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/github.js +8 -0
- package/apps/studio/client/styles/3-shell-maude.css +48 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/context-menu.tsx +15 -5
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +20 -20
- package/apps/studio/dist/comment-mount.js +59 -1
- 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/history.ts +47 -1
- package/apps/studio/http.ts +223 -0
- package/apps/studio/input-router.tsx +12 -0
- 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/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/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-edit.test.ts +50 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +18 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -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-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/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/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- 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/whats-new.json +28 -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
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// spacing-handles.ts — Stage J (feature-element-editing-robustness). Pure
|
|
2
|
+
// geometry for the on-canvas padding + gap drag overlay: padding-edge handle
|
|
3
|
+
// placement + drag math, and flex gap-midpoint placement + drag math.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
computeGapDrag,
|
|
9
|
+
computeGapMidpoints,
|
|
10
|
+
computePaddingDrag,
|
|
11
|
+
computePaddingLines,
|
|
12
|
+
flexMainAxis,
|
|
13
|
+
paddingSideSet,
|
|
14
|
+
} from '../spacing-handles.ts';
|
|
15
|
+
|
|
16
|
+
const RECT = { x: 100, y: 200, w: 300, h: 150 };
|
|
17
|
+
|
|
18
|
+
describe('spacing-handles / computePaddingLines', () => {
|
|
19
|
+
test('places all 4 edges at the padding/content boundary, zoom 1', () => {
|
|
20
|
+
const lines = computePaddingLines(RECT, { top: 10, right: 20, bottom: 30, left: 40 }, 1);
|
|
21
|
+
const bySide = Object.fromEntries(lines.map((l) => [l.side, l]));
|
|
22
|
+
expect(bySide.top).toEqual({ side: 'top', axis: 'y', x: 250, y: 210, length: 240 }); // w-40-20
|
|
23
|
+
expect(bySide.bottom).toEqual({ side: 'bottom', axis: 'y', x: 250, y: 320, length: 240 });
|
|
24
|
+
expect(bySide.left).toEqual({ side: 'left', axis: 'x', x: 140, y: 275, length: 110 }); // h-10-30
|
|
25
|
+
expect(bySide.right).toEqual({ side: 'right', axis: 'x', x: 380, y: 275, length: 110 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('scales the inset by zoom', () => {
|
|
29
|
+
const lines = computePaddingLines(RECT, { top: 10, right: 0, bottom: 0, left: 0 }, 2);
|
|
30
|
+
const top = lines.find((l) => l.side === 'top');
|
|
31
|
+
expect(top?.y).toBe(220); // rect.y + 10*2
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('a non-positive zoom falls back to 1x', () => {
|
|
35
|
+
const lines = computePaddingLines(RECT, { top: 10, right: 0, bottom: 0, left: 0 }, 0);
|
|
36
|
+
expect(lines.find((l) => l.side === 'top')?.y).toBe(210);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('spacing-handles / computePaddingDrag', () => {
|
|
41
|
+
test('top grows when dragged DOWN (positive dy)', () => {
|
|
42
|
+
expect(computePaddingDrag('top', 10, 0, 6, 1)).toBe(16);
|
|
43
|
+
});
|
|
44
|
+
test('top shrinks when dragged UP (negative dy)', () => {
|
|
45
|
+
expect(computePaddingDrag('top', 10, 0, -4, 1)).toBe(6);
|
|
46
|
+
});
|
|
47
|
+
test('left grows when dragged RIGHT (positive dx)', () => {
|
|
48
|
+
expect(computePaddingDrag('left', 8, 5, 0, 1)).toBe(13);
|
|
49
|
+
});
|
|
50
|
+
test('bottom grows when dragged UP (sign flips vs top)', () => {
|
|
51
|
+
expect(computePaddingDrag('bottom', 10, 0, -6, 1)).toBe(16);
|
|
52
|
+
});
|
|
53
|
+
test('right grows when dragged LEFT (sign flips vs left)', () => {
|
|
54
|
+
expect(computePaddingDrag('right', 8, -5, 0, 1)).toBe(13);
|
|
55
|
+
});
|
|
56
|
+
test('clamps at 0 — never goes negative', () => {
|
|
57
|
+
expect(computePaddingDrag('top', 4, 0, -100, 1)).toBe(0);
|
|
58
|
+
});
|
|
59
|
+
test('divides the screen delta by zoom (world units)', () => {
|
|
60
|
+
expect(computePaddingDrag('top', 10, 0, 20, 2)).toBe(20); // 20/2 = +10
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('spacing-handles / flexMainAxis', () => {
|
|
65
|
+
test('row / row-reverse → x', () => {
|
|
66
|
+
expect(flexMainAxis('row')).toBe('x');
|
|
67
|
+
expect(flexMainAxis('row-reverse')).toBe('x');
|
|
68
|
+
});
|
|
69
|
+
test('column / column-reverse → y', () => {
|
|
70
|
+
expect(flexMainAxis('column')).toBe('y');
|
|
71
|
+
expect(flexMainAxis('column-reverse')).toBe('y');
|
|
72
|
+
});
|
|
73
|
+
test('missing/empty defaults to row → x', () => {
|
|
74
|
+
expect(flexMainAxis(null)).toBe('x');
|
|
75
|
+
expect(flexMainAxis('')).toBe('x');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('spacing-handles / computeGapMidpoints', () => {
|
|
80
|
+
test('no gaps for < 2 children', () => {
|
|
81
|
+
expect(computeGapMidpoints([], 'x')).toEqual([]);
|
|
82
|
+
expect(computeGapMidpoints([RECT], 'x')).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('one midpoint between 2 children on the X axis, sorted by position', () => {
|
|
86
|
+
const a = { x: 0, y: 0, w: 100, h: 50 };
|
|
87
|
+
const b = { x: 120, y: 0, w: 100, h: 50 };
|
|
88
|
+
// Pass out of order — must sort by x before pairing.
|
|
89
|
+
expect(computeGapMidpoints([b, a], 'x')).toEqual([{ x: 110, y: 25 }]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('N-1 midpoints for N children, Y axis (column)', () => {
|
|
93
|
+
const a = { x: 0, y: 0, w: 80, h: 40 };
|
|
94
|
+
const b = { x: 0, y: 60, w: 80, h: 40 };
|
|
95
|
+
const c = { x: 0, y: 120, w: 80, h: 40 };
|
|
96
|
+
expect(computeGapMidpoints([a, b, c], 'y')).toEqual([
|
|
97
|
+
{ x: 40, y: 50 },
|
|
98
|
+
{ x: 40, y: 110 },
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('spacing-handles / paddingSideSet', () => {
|
|
104
|
+
test('plain drag touches only the dragged side', () => {
|
|
105
|
+
expect(paddingSideSet('top', false, false)).toEqual(['top']);
|
|
106
|
+
expect(paddingSideSet('left', false, false)).toEqual(['left']);
|
|
107
|
+
});
|
|
108
|
+
test('Alt on top/bottom locks the vertical pair', () => {
|
|
109
|
+
expect(paddingSideSet('top', true, false)).toEqual(['top', 'bottom']);
|
|
110
|
+
expect(paddingSideSet('bottom', true, false)).toEqual(['top', 'bottom']);
|
|
111
|
+
});
|
|
112
|
+
test('Alt on left/right locks the horizontal pair', () => {
|
|
113
|
+
expect(paddingSideSet('left', true, false)).toEqual(['left', 'right']);
|
|
114
|
+
expect(paddingSideSet('right', true, false)).toEqual(['left', 'right']);
|
|
115
|
+
});
|
|
116
|
+
test('Alt+Shift locks all 4 sides regardless of which was grabbed', () => {
|
|
117
|
+
expect(paddingSideSet('top', true, true)).toEqual(['top', 'right', 'bottom', 'left']);
|
|
118
|
+
expect(paddingSideSet('right', true, true)).toEqual(['top', 'right', 'bottom', 'left']);
|
|
119
|
+
});
|
|
120
|
+
test('Shift alone (no Alt) is plain — matches the panel (shift is a step modifier there)', () => {
|
|
121
|
+
expect(paddingSideSet('top', false, true)).toEqual(['top']);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('spacing-handles / computeGapDrag', () => {
|
|
126
|
+
test('1:1 with cursor delta on the X axis (no doubling)', () => {
|
|
127
|
+
expect(computeGapDrag('x', 12, 8, 0, 1)).toBe(20);
|
|
128
|
+
});
|
|
129
|
+
test('1:1 with cursor delta on the Y axis', () => {
|
|
130
|
+
expect(computeGapDrag('y', 12, 0, -8, 1)).toBe(4);
|
|
131
|
+
});
|
|
132
|
+
test('clamps at 0', () => {
|
|
133
|
+
expect(computeGapDrag('x', 4, -100, 0, 1)).toBe(0);
|
|
134
|
+
});
|
|
135
|
+
test('divides by zoom', () => {
|
|
136
|
+
expect(computeGapDrag('x', 10, 20, 0, 2)).toBe(20); // 20/2 = +10
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// specimen-select — feature-element-editing-robustness Stage E (Task E1 + G2).
|
|
2
|
+
// A DS preview specimen (`system/<ds>/preview/*.tsx`) has no CanvasShell — no
|
|
3
|
+
// `.dc-canvas` host, no artboard-scoped selector. `isBareSpecimen`/
|
|
4
|
+
// `pickSpecimenSelectEl` (canvas-comment-mount.tsx) are the generalized select
|
|
5
|
+
// resolver that lets a specimen element still resolve to a `Selection` the
|
|
6
|
+
// Inspector can edit, with a NULL artboard scope (the selector degrades to a
|
|
7
|
+
// bare `[data-cd-id="…"]`, per dom-selection.ts `scopedCdSelector`).
|
|
8
|
+
//
|
|
9
|
+
// Needs a live DOM (elementFromPoint / closest / querySelector) — register
|
|
10
|
+
// happy-dom for this file only, same convention as canvas-hmr-runtime.test.tsx.
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
13
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
GlobalRegistrator.register();
|
|
17
|
+
});
|
|
18
|
+
afterAll(() => {
|
|
19
|
+
GlobalRegistrator.unregister();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
import { isBareSpecimen, pickSpecimenSelectEl } from '../canvas-comment-mount.tsx';
|
|
23
|
+
import { hoverTargetToSelection } from '../dom-selection.ts';
|
|
24
|
+
import type { HoverTarget } from '../input-router.tsx';
|
|
25
|
+
|
|
26
|
+
describe('isBareSpecimen', () => {
|
|
27
|
+
test('true when no .dc-canvas host is mounted (a bare specimen)', () => {
|
|
28
|
+
document.body.innerHTML = '<article data-cd-id="aaaaaaaa"><span>hi</span></article>';
|
|
29
|
+
expect(isBareSpecimen()).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('false when a .dc-canvas host is present (a UI canvas owns select instead)', () => {
|
|
33
|
+
document.body.innerHTML =
|
|
34
|
+
'<div class="dc-canvas"><article data-cd-id="aaaaaaaa">hi</article></div>';
|
|
35
|
+
expect(isBareSpecimen()).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('pickSpecimenSelectEl', () => {
|
|
40
|
+
test('climbs from the raw hit to its closest stamped [data-cd-id] ancestor', () => {
|
|
41
|
+
document.body.innerHTML = '<article data-cd-id="aaaaaaaa"><span id="inner">hi</span></article>';
|
|
42
|
+
const inner = document.getElementById('inner') as HTMLElement;
|
|
43
|
+
const article = document.querySelector('[data-cd-id]') as HTMLElement;
|
|
44
|
+
document.elementFromPoint = () => inner;
|
|
45
|
+
|
|
46
|
+
const el = pickSpecimenSelectEl(10, 10);
|
|
47
|
+
expect(el).toBe(article);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('falls back to the bare hit when nothing is stamped (defensive, no crash)', () => {
|
|
51
|
+
document.body.innerHTML = '<div id="plain">no cd-id here</div>';
|
|
52
|
+
const plain = document.getElementById('plain') as HTMLElement;
|
|
53
|
+
document.elementFromPoint = () => plain;
|
|
54
|
+
|
|
55
|
+
expect(pickSpecimenSelectEl(5, 5)).toBe(plain);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('returns null over comment chrome / the resize-handle overlay', () => {
|
|
59
|
+
document.body.innerHTML =
|
|
60
|
+
'<article data-cd-id="aaaaaaaa"><div class="cm-composer"><span id="chrome">x</span></div></article>';
|
|
61
|
+
const chromeEl = document.getElementById('chrome') as HTMLElement;
|
|
62
|
+
document.elementFromPoint = () => chromeEl;
|
|
63
|
+
|
|
64
|
+
expect(pickSpecimenSelectEl(1, 1)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('returns null when the hit is HTML/BODY (no content under the cursor)', () => {
|
|
68
|
+
document.elementFromPoint = () => document.body;
|
|
69
|
+
expect(pickSpecimenSelectEl(0, 0)).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('specimen select → Selection has a NULL artboard scope', () => {
|
|
74
|
+
test('hoverTargetToSelection({artboardId: null}) degrades to a bare [data-cd-id] selector', () => {
|
|
75
|
+
document.body.innerHTML = '<article data-cd-id="aaaaaaaa">hi</article>';
|
|
76
|
+
const el = document.querySelector('[data-cd-id]') as HTMLElement;
|
|
77
|
+
const target: HoverTarget = { el, cdId: 'aaaaaaaa', artboardId: null };
|
|
78
|
+
|
|
79
|
+
const sel = hoverTargetToSelection(target);
|
|
80
|
+
|
|
81
|
+
expect(sel.artboardId).toBeNull();
|
|
82
|
+
expect(sel.id).toBe('aaaaaaaa');
|
|
83
|
+
// No `[data-dc-screen="…"]` artboard scope prefix — a specimen has no
|
|
84
|
+
// artboard, so the selector must stay bare (dom-selection.ts scopedCdSelector).
|
|
85
|
+
expect(sel.selector).not.toContain('data-dc-screen');
|
|
86
|
+
expect(sel.selector).toContain('data-cd-id="aaaaaaaa"');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// tool-palette.tsx — Stage I3 tail (feature-element-editing-robustness). The
|
|
2
|
+
// "+ Element" affordance's anchor resolver: appends relative to the ACTIVE
|
|
3
|
+
// artboard's last existing child (mirrors the context-menu's element-relative
|
|
4
|
+
// insert, since an artboard's own `<article data-dc-screen>` carries no
|
|
5
|
+
// `data-cd-id` of its own — DCArtboard doesn't forward it to the DOM).
|
|
6
|
+
//
|
|
7
|
+
// Needs a live DOM (querySelector/:scope) — register happy-dom for this file
|
|
8
|
+
// only, like canvas-hmr-runtime.test.tsx.
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
11
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
12
|
+
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
GlobalRegistrator.register();
|
|
15
|
+
});
|
|
16
|
+
afterAll(() => {
|
|
17
|
+
GlobalRegistrator.unregister();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
import { resolveInsertAnchor } from '../tool-palette.tsx';
|
|
21
|
+
|
|
22
|
+
describe('tool-palette / resolveInsertAnchor', () => {
|
|
23
|
+
test('anchors on the last data-cd-id child of the CURRENT (aria-current) artboard', () => {
|
|
24
|
+
document.body.innerHTML = `
|
|
25
|
+
<article data-dc-screen="a1">
|
|
26
|
+
<div class="dc-artboard-body">
|
|
27
|
+
<div data-cd-id="11111111">one</div>
|
|
28
|
+
<div data-cd-id="22222222">two</div>
|
|
29
|
+
</div>
|
|
30
|
+
</article>
|
|
31
|
+
<article data-dc-screen="a2" aria-current="true">
|
|
32
|
+
<div class="dc-artboard-body">
|
|
33
|
+
<div data-cd-id="33333333">three</div>
|
|
34
|
+
<div data-cd-id="44444444">four</div>
|
|
35
|
+
</div>
|
|
36
|
+
</article>
|
|
37
|
+
`;
|
|
38
|
+
expect(resolveInsertAnchor(document)).toEqual({ refId: '44444444', position: 'after' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('falls back to the FIRST artboard when none is aria-current', () => {
|
|
42
|
+
document.body.innerHTML = `
|
|
43
|
+
<article data-dc-screen="a1">
|
|
44
|
+
<div class="dc-artboard-body"><div data-cd-id="aaaaaaaa">x</div></div>
|
|
45
|
+
</article>
|
|
46
|
+
<article data-dc-screen="a2">
|
|
47
|
+
<div class="dc-artboard-body"><div data-cd-id="bbbbbbbb">y</div></div>
|
|
48
|
+
</article>
|
|
49
|
+
`;
|
|
50
|
+
expect(resolveInsertAnchor(document)).toEqual({ refId: 'aaaaaaaa', position: 'after' });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('skips a chrome direct-child with no data-cd-id, picking the last STAMPED one', () => {
|
|
54
|
+
document.body.innerHTML = `
|
|
55
|
+
<article data-dc-screen="a1" aria-current="true">
|
|
56
|
+
<div class="dc-artboard-body">
|
|
57
|
+
<div data-cd-id="11111111">one</div>
|
|
58
|
+
<div class="some-chrome-node">no cd-id</div>
|
|
59
|
+
</div>
|
|
60
|
+
</article>
|
|
61
|
+
`;
|
|
62
|
+
expect(resolveInsertAnchor(document)).toEqual({ refId: '11111111', position: 'after' });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('returns null for an EMPTY artboard (no data-cd-id child to anchor on)', () => {
|
|
66
|
+
document.body.innerHTML = `
|
|
67
|
+
<article data-dc-screen="a1" aria-current="true">
|
|
68
|
+
<div class="dc-artboard-body"></div>
|
|
69
|
+
</article>
|
|
70
|
+
`;
|
|
71
|
+
expect(resolveInsertAnchor(document)).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('returns null when there is no artboard at all', () => {
|
|
75
|
+
document.body.innerHTML = '<div>no artboards here</div>';
|
|
76
|
+
expect(resolveInsertAnchor(document)).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('returns null when the artboard has no .dc-artboard-body wrapper', () => {
|
|
80
|
+
document.body.innerHTML =
|
|
81
|
+
'<article data-dc-screen="a1"><div data-cd-id="11111111">x</div></article>';
|
|
82
|
+
expect(resolveInsertAnchor(document)).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Stage K (Task K1, feature-element-editing-robustness) — the scripted
|
|
2
|
+
// mixed-op undo sequence the plan's INV-1 asks for: resize → delete → insert
|
|
3
|
+
// → move → spacing-drag → Cmd+Z ×5 → canvas byte-identical to the start.
|
|
4
|
+
//
|
|
5
|
+
// Drives the SAME HTTP routes the client's undo mechanisms call:
|
|
6
|
+
// - CSS-shaped ops (resize/move/spacing) undo by re-POSTing `/_api/edit-css`
|
|
7
|
+
// with the pre-edit value — exactly what `recordSourceEdit` +
|
|
8
|
+
// `commands/edit-source-command.ts`'s `undo()` do (do/undo are both a
|
|
9
|
+
// single `applyFn` call with a different `value`, so there is no
|
|
10
|
+
// separate "revert" endpoint to test — the HTTP-level round-trip IS the
|
|
11
|
+
// undo mechanism).
|
|
12
|
+
// - Structural ops (delete/insert) undo via `/_api/reorder-revert`, the
|
|
13
|
+
// whole-file seq-log swap Stage I reuses from Phase 12.1's reorder undo
|
|
14
|
+
// (`commands/reorder-command.ts`) rather than a bespoke command per op.
|
|
15
|
+
//
|
|
16
|
+
// `/_api/reorder-revert` 409s if the file drifted since the logged seq (its
|
|
17
|
+
// own doc comment: "refuses 409 when the canvas changed since") — so the 5
|
|
18
|
+
// undos below MUST run in strict reverse (LIFO) order, exactly like a real
|
|
19
|
+
// Cmd+Z stack, or the structural reverts would fail closed. That ordering
|
|
20
|
+
// constraint is itself part of what this test proves.
|
|
21
|
+
|
|
22
|
+
import { describe, expect, test } from 'bun:test';
|
|
23
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
27
|
+
|
|
28
|
+
// Style values use DOUBLE quotes throughout — matching this codebase's own
|
|
29
|
+
// authored-JSX convention (canvas-lib.tsx, scaffold-design.ts): editCss's
|
|
30
|
+
// underlying write is a JSON.stringify literal, which is always
|
|
31
|
+
// double-quoted regardless of the original source's quote style. A
|
|
32
|
+
// single-quoted fixture would make a value round-trip only VALUE-identical,
|
|
33
|
+
// not byte-identical (a cosmetic quote-style delta, not a real undo gap) —
|
|
34
|
+
// double-quoting the fixture up front keeps this test's byte-compare
|
|
35
|
+
// meaningful instead of tripping over that unrelated cosmetic mismatch.
|
|
36
|
+
const CANVAS_SRC = `export default function Seq() {
|
|
37
|
+
return (
|
|
38
|
+
<DesignCanvas>
|
|
39
|
+
<DCArtboard id="home" label="Home" width={1440} height={1024}>
|
|
40
|
+
<figure style={{ position: "absolute", left: "20px", top: "20px", width: "100px", height: "60px" }}>Box</figure>
|
|
41
|
+
<div>A</div>
|
|
42
|
+
<div>B</div>
|
|
43
|
+
<div>C</div>
|
|
44
|
+
<section style={{ display: "flex", paddingLeft: "8px" }}>
|
|
45
|
+
<span>x</span>
|
|
46
|
+
<span>y</span>
|
|
47
|
+
</section>
|
|
48
|
+
</DCArtboard>
|
|
49
|
+
</DesignCanvas>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
interface LocatorEntry {
|
|
55
|
+
line: number;
|
|
56
|
+
jsxPath: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function locatorMap(main: string, designRoot: string): Promise<Record<string, LocatorEntry>> {
|
|
60
|
+
const r = await fetch(`${main}/.design/ui/Seq.tsx`, { signal: AbortSignal.timeout(2000) });
|
|
61
|
+
expect(r.status).toBe(200);
|
|
62
|
+
const locator = JSON.parse(readFileSync(join(designRoot, '_locator.json'), 'utf8'));
|
|
63
|
+
return locator['ui/Seq'] as Record<string, LocatorEntry>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** First (only, in this fixture) data-cd-id whose JSX tag is `tag`. */
|
|
67
|
+
function idOf(map: Record<string, LocatorEntry>, tag: string): string {
|
|
68
|
+
const hit = Object.entries(map).find(([, e]) => e.jsxPath[e.jsxPath.length - 1] === tag);
|
|
69
|
+
expect(hit).toBeDefined();
|
|
70
|
+
return hit?.[0] as string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function idsByTag(map: Record<string, LocatorEntry>, tag: string): string[] {
|
|
74
|
+
return Object.entries(map)
|
|
75
|
+
.filter(([, e]) => e.jsxPath[e.jsxPath.length - 1] === tag)
|
|
76
|
+
.sort((a, b) => a[1].line - b[1].line)
|
|
77
|
+
.map(([id]) => id);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function editCss(
|
|
81
|
+
main: string,
|
|
82
|
+
body: { canvas: string; id: string; property: string; value: string }
|
|
83
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
84
|
+
const res = await fetch(`${main}/_api/edit-css`, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
body: JSON.stringify(body),
|
|
87
|
+
signal: AbortSignal.timeout(2000),
|
|
88
|
+
});
|
|
89
|
+
return (await res.json()) as { ok: boolean; error?: string };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('Stage K1 — mixed resize/delete/insert/move/spacing undo sequence', () => {
|
|
93
|
+
test('5 undos (LIFO) return the canvas byte-identical to its start', async () => {
|
|
94
|
+
const { root, designRoot } = makeSandbox();
|
|
95
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
96
|
+
writeFileSync(join(designRoot, 'ui', 'Seq.tsx'), CANVAS_SRC);
|
|
97
|
+
const port = nextPort();
|
|
98
|
+
const proc = await bootServer(root, port);
|
|
99
|
+
const main = `http://localhost:${port}`;
|
|
100
|
+
const canvasPath = join(designRoot, 'ui', 'Seq.tsx');
|
|
101
|
+
const original = readFileSync(canvasPath, 'utf8');
|
|
102
|
+
expect(original).toBe(CANVAS_SRC);
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
// ── 1. resize (CSS-shaped) ──────────────────────────────────────────
|
|
106
|
+
let map = await locatorMap(main, designRoot);
|
|
107
|
+
const boxId = idOf(map, 'figure');
|
|
108
|
+
const resize = await editCss(main, {
|
|
109
|
+
canvas: 'ui/Seq',
|
|
110
|
+
id: boxId,
|
|
111
|
+
property: 'width',
|
|
112
|
+
value: '140px',
|
|
113
|
+
});
|
|
114
|
+
expect(resize.ok).toBe(true);
|
|
115
|
+
expect(readFileSync(canvasPath, 'utf8')).toContain('width: "140px"');
|
|
116
|
+
|
|
117
|
+
// ── 2. delete (structural) ──────────────────────────────────────────
|
|
118
|
+
map = await locatorMap(main, designRoot);
|
|
119
|
+
const [aId, bId] = idsByTag(map, 'div');
|
|
120
|
+
const del = await fetch(`${main}/_api/delete-element`, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
body: JSON.stringify({ canvas: 'ui/Seq', id: bId }),
|
|
123
|
+
signal: AbortSignal.timeout(2000),
|
|
124
|
+
});
|
|
125
|
+
expect(del.status).toBe(200);
|
|
126
|
+
const delJson = (await del.json()) as { ok: boolean; seq: number };
|
|
127
|
+
expect(delJson.ok).toBe(true);
|
|
128
|
+
expect(
|
|
129
|
+
[...readFileSync(canvasPath, 'utf8').matchAll(/<div>([ABC])</g)].map((m) => m[1])
|
|
130
|
+
).toEqual(['A', 'C']);
|
|
131
|
+
|
|
132
|
+
// ── 3. insert (structural) ──────────────────────────────────────────
|
|
133
|
+
const ins = await fetch(`${main}/_api/insert-element`, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
body: JSON.stringify({ canvas: 'ui/Seq', refId: aId, position: 'after', kind: 'div' }),
|
|
136
|
+
signal: AbortSignal.timeout(2000),
|
|
137
|
+
});
|
|
138
|
+
expect(ins.status).toBe(200);
|
|
139
|
+
const insJson = (await ins.json()) as { ok: boolean; seq: number; newId: string | null };
|
|
140
|
+
expect(insJson.ok).toBe(true);
|
|
141
|
+
|
|
142
|
+
// ── 4. move (CSS-shaped, same box element as step 1) ────────────────
|
|
143
|
+
map = await locatorMap(main, designRoot);
|
|
144
|
+
const boxIdAfterStructural = idOf(map, 'figure');
|
|
145
|
+
const move = await editCss(main, {
|
|
146
|
+
canvas: 'ui/Seq',
|
|
147
|
+
id: boxIdAfterStructural,
|
|
148
|
+
property: 'left',
|
|
149
|
+
value: '60px',
|
|
150
|
+
});
|
|
151
|
+
expect(move.ok).toBe(true);
|
|
152
|
+
expect(readFileSync(canvasPath, 'utf8')).toContain('left: "60px"');
|
|
153
|
+
|
|
154
|
+
// ── 5. spacing-drag (CSS-shaped, padding-left) ──────────────────────
|
|
155
|
+
map = await locatorMap(main, designRoot);
|
|
156
|
+
const sectionId = idOf(map, 'section');
|
|
157
|
+
const spacing = await editCss(main, {
|
|
158
|
+
canvas: 'ui/Seq',
|
|
159
|
+
id: sectionId,
|
|
160
|
+
property: 'padding-left',
|
|
161
|
+
value: '24px',
|
|
162
|
+
});
|
|
163
|
+
expect(spacing.ok).toBe(true);
|
|
164
|
+
expect(readFileSync(canvasPath, 'utf8')).toContain('paddingLeft: "24px"');
|
|
165
|
+
|
|
166
|
+
// ── Cmd+Z ×5 — strict LIFO ───────────────────────────────────────────
|
|
167
|
+
const undo5 = await editCss(main, {
|
|
168
|
+
canvas: 'ui/Seq',
|
|
169
|
+
id: sectionId,
|
|
170
|
+
property: 'padding-left',
|
|
171
|
+
value: '8px',
|
|
172
|
+
});
|
|
173
|
+
expect(undo5.ok).toBe(true);
|
|
174
|
+
|
|
175
|
+
const undo4 = await editCss(main, {
|
|
176
|
+
canvas: 'ui/Seq',
|
|
177
|
+
id: boxIdAfterStructural,
|
|
178
|
+
property: 'left',
|
|
179
|
+
value: '20px',
|
|
180
|
+
});
|
|
181
|
+
expect(undo4.ok).toBe(true);
|
|
182
|
+
|
|
183
|
+
const undo3 = await fetch(`${main}/_api/reorder-revert`, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
body: JSON.stringify({ canvas: 'ui/Seq', seq: insJson.seq, dir: 'undo' }),
|
|
186
|
+
signal: AbortSignal.timeout(2000),
|
|
187
|
+
});
|
|
188
|
+
expect(undo3.status).toBe(200);
|
|
189
|
+
|
|
190
|
+
const undo2 = await fetch(`${main}/_api/reorder-revert`, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
body: JSON.stringify({ canvas: 'ui/Seq', seq: delJson.seq, dir: 'undo' }),
|
|
193
|
+
signal: AbortSignal.timeout(2000),
|
|
194
|
+
});
|
|
195
|
+
expect(undo2.status).toBe(200);
|
|
196
|
+
|
|
197
|
+
const undo1 = await editCss(main, {
|
|
198
|
+
canvas: 'ui/Seq',
|
|
199
|
+
id: boxId,
|
|
200
|
+
property: 'width',
|
|
201
|
+
value: '100px',
|
|
202
|
+
});
|
|
203
|
+
expect(undo1.ok).toBe(true);
|
|
204
|
+
|
|
205
|
+
// ── Assert: byte-identical to the pre-sequence source ───────────────
|
|
206
|
+
expect(readFileSync(canvasPath, 'utf8')).toBe(original);
|
|
207
|
+
} finally {
|
|
208
|
+
await killProc(proc);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
});
|