@1agh/maude 0.29.0 → 0.31.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 +6 -6
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +30 -11
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +58 -12
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// annotations-bindings — FigJam v3 connector binding (pure, DOM-free).
|
|
2
|
+
// Semantics: Excalidraw proximity bind + FigJam side/center magnets; bound
|
|
3
|
+
// endpoints derive from the host; deleting the host unbinds + freezes.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
anchorPoint,
|
|
9
|
+
BIND_THRESHOLD_PX,
|
|
10
|
+
bindCandidate,
|
|
11
|
+
facingAnchor,
|
|
12
|
+
isBindable,
|
|
13
|
+
recomputeBoundArrows,
|
|
14
|
+
} from '../annotations-bindings.ts';
|
|
15
|
+
import type { ArrowStroke, RectStroke, Stroke, TextStroke } from '../annotations-model.ts';
|
|
16
|
+
|
|
17
|
+
function rect(id: string, x = 0, y = 0, w = 100, h = 80): RectStroke {
|
|
18
|
+
return { id, tool: 'rect', color: '#1f1f1f', width: 3, x, y, w, h };
|
|
19
|
+
}
|
|
20
|
+
function arrow(id: string, extra: Partial<ArrowStroke> = {}): ArrowStroke {
|
|
21
|
+
return { id, tool: 'arrow', color: '#1f1f1f', width: 3, x1: 0, y1: 0, x2: 50, y2: 0, ...extra };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('bindCandidate', () => {
|
|
25
|
+
test('binds within the threshold and snaps to the nearest magnet', () => {
|
|
26
|
+
const strokes: Stroke[] = [rect('host', 100, 100, 100, 100)];
|
|
27
|
+
// Just right of the host's right edge, vertically centered → (1, 0.5).
|
|
28
|
+
const bind = bindCandidate(205, 150, strokes, BIND_THRESHOLD_PX);
|
|
29
|
+
expect(bind).toEqual({ hostId: 'host', nx: 1, ny: 0.5 });
|
|
30
|
+
// Top-left corner region → (0, 0).
|
|
31
|
+
expect(bindCandidate(98, 102, strokes, BIND_THRESHOLD_PX)).toEqual({
|
|
32
|
+
hostId: 'host',
|
|
33
|
+
nx: 0,
|
|
34
|
+
ny: 0,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('misses outside the threshold; topmost host wins; exclusions respected', () => {
|
|
39
|
+
const below = rect('below', 0, 0, 100, 100);
|
|
40
|
+
const above = rect('above', 50, 50, 100, 100);
|
|
41
|
+
const strokes: Stroke[] = [below, above];
|
|
42
|
+
expect(bindCandidate(500, 500, strokes, BIND_THRESHOLD_PX)).toBeNull();
|
|
43
|
+
expect(bindCandidate(75, 75, strokes, BIND_THRESHOLD_PX)?.hostId).toBe('above');
|
|
44
|
+
expect(bindCandidate(75, 75, strokes, BIND_THRESHOLD_PX, new Set(['above']))?.hostId).toBe(
|
|
45
|
+
'below'
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('only bindable tools host (no text/pen/arrow/link)', () => {
|
|
50
|
+
const label: TextStroke = {
|
|
51
|
+
id: 't',
|
|
52
|
+
tool: 'text',
|
|
53
|
+
color: '#1a1a1a',
|
|
54
|
+
fontSize: 14,
|
|
55
|
+
text: 'x',
|
|
56
|
+
x: 0,
|
|
57
|
+
y: 0,
|
|
58
|
+
};
|
|
59
|
+
expect(isBindable(label)).toBe(false);
|
|
60
|
+
expect(bindCandidate(0, 0, [label], BIND_THRESHOLD_PX)).toBeNull();
|
|
61
|
+
expect(isBindable(rect('r'))).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('anchorPoint / recomputeBoundArrows', () => {
|
|
66
|
+
test('bound endpoints follow the host on move (translate → recompute)', () => {
|
|
67
|
+
const host = rect('host', 0, 0, 100, 100);
|
|
68
|
+
const ar = arrow('ar', {
|
|
69
|
+
x1: 100,
|
|
70
|
+
y1: 50,
|
|
71
|
+
x2: 300,
|
|
72
|
+
y2: 50,
|
|
73
|
+
startBind: { hostId: 'host', nx: 1, ny: 0.5 },
|
|
74
|
+
});
|
|
75
|
+
const moved: Stroke[] = [{ ...host, x: 50, y: 20 }, ar];
|
|
76
|
+
const out = recomputeBoundArrows(moved);
|
|
77
|
+
const a = out.find((s) => s.id === 'ar') as ArrowStroke;
|
|
78
|
+
expect([a.x1, a.y1]).toEqual([150, 70]);
|
|
79
|
+
expect([a.x2, a.y2]).toEqual([300, 50]); // free end untouched
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('anchor stays proportional through a host resize', () => {
|
|
83
|
+
const host = rect('host', 0, 0, 200, 100);
|
|
84
|
+
expect(anchorPoint(host, 0.5, 1)).toEqual([100, 100]);
|
|
85
|
+
expect(anchorPoint({ ...host, w: 400 }, 0.5, 1)).toEqual([200, 100]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('missing host → bind stripped, endpoint frozen (arrow survives)', () => {
|
|
89
|
+
const ar = arrow('ar', {
|
|
90
|
+
x1: 100,
|
|
91
|
+
y1: 50,
|
|
92
|
+
x2: 300,
|
|
93
|
+
y2: 50,
|
|
94
|
+
startBind: { hostId: 'gone', nx: 1, ny: 0.5 },
|
|
95
|
+
});
|
|
96
|
+
const out = recomputeBoundArrows([ar]);
|
|
97
|
+
const a = out.find((s) => s.id === 'ar') as ArrowStroke;
|
|
98
|
+
expect(a.startBind).toBeUndefined();
|
|
99
|
+
expect([a.x1, a.y1]).toEqual([100, 50]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('referential no-op when every endpoint already sits at its anchor', () => {
|
|
103
|
+
const host = rect('host', 0, 0, 100, 100);
|
|
104
|
+
const ar = arrow('ar', {
|
|
105
|
+
x1: 100,
|
|
106
|
+
y1: 50,
|
|
107
|
+
x2: 300,
|
|
108
|
+
y2: 50,
|
|
109
|
+
startBind: { hostId: 'host', nx: 1, ny: 0.5 },
|
|
110
|
+
});
|
|
111
|
+
const strokes: Stroke[] = [host, ar];
|
|
112
|
+
expect(recomputeBoundArrows(strokes)).toBe(strokes);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('facingAnchor (AI annotate / quick-create side pick)', () => {
|
|
117
|
+
test('picks the side magnet facing the target', () => {
|
|
118
|
+
const host = rect('h', 0, 0, 100, 100);
|
|
119
|
+
expect(facingAnchor(host, 500, 50)).toEqual({ hostId: 'h', nx: 1, ny: 0.5 });
|
|
120
|
+
expect(facingAnchor(host, -400, 50)).toEqual({ hostId: 'h', nx: 0, ny: 0.5 });
|
|
121
|
+
expect(facingAnchor(host, 50, 900)).toEqual({ hostId: 'h', nx: 0.5, ny: 1 });
|
|
122
|
+
expect(facingAnchor(host, 50, -900)).toEqual({ hostId: 'h', nx: 0.5, ny: 0 });
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// annotations-groups — FigJam v3 grouping / duplicate / z-order (pure, DOM-free).
|
|
2
|
+
// Group model: flat `groupIds` tag-array, deepest→shallowest, outermost = last.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
duplicateStrokes,
|
|
8
|
+
expandIdsToGroups,
|
|
9
|
+
groupStrokes,
|
|
10
|
+
normalizeGroups,
|
|
11
|
+
outermostGroupOf,
|
|
12
|
+
reorderStrokes,
|
|
13
|
+
ungroupStrokes,
|
|
14
|
+
} from '../annotations-groups.ts';
|
|
15
|
+
import type {
|
|
16
|
+
ArrowStroke,
|
|
17
|
+
PenStroke,
|
|
18
|
+
RectStroke,
|
|
19
|
+
Stroke,
|
|
20
|
+
TextStroke,
|
|
21
|
+
} from '../annotations-model.ts';
|
|
22
|
+
|
|
23
|
+
function rect(id: string, x = 0, extra: Partial<RectStroke> = {}): RectStroke {
|
|
24
|
+
return { id, tool: 'rect', color: '#1f1f1f', width: 3, x, y: 0, w: 100, h: 80, ...extra };
|
|
25
|
+
}
|
|
26
|
+
function pen(id: string, extra: Partial<PenStroke> = {}): PenStroke {
|
|
27
|
+
return {
|
|
28
|
+
id,
|
|
29
|
+
tool: 'pen',
|
|
30
|
+
color: '#e5484d',
|
|
31
|
+
width: 3,
|
|
32
|
+
points: [
|
|
33
|
+
[0, 0],
|
|
34
|
+
[10, 10],
|
|
35
|
+
],
|
|
36
|
+
...extra,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function arrow(id: string, extra: Partial<ArrowStroke> = {}): ArrowStroke {
|
|
40
|
+
return { id, tool: 'arrow', color: '#1f1f1f', width: 3, x1: 0, y1: 0, x2: 50, y2: 0, ...extra };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('outermostGroupOf / expandIdsToGroups', () => {
|
|
44
|
+
test('outermost group is the LAST groupIds element (deepest→shallowest)', () => {
|
|
45
|
+
expect(outermostGroupOf(rect('a', 0, { groupIds: ['inner', 'outer'] }))).toBe('outer');
|
|
46
|
+
expect(outermostGroupOf(rect('a'))).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('expanding one member id selects the whole outermost group, in z order', () => {
|
|
50
|
+
const strokes: Stroke[] = [
|
|
51
|
+
rect('a', 0, { groupIds: ['g1'] }),
|
|
52
|
+
pen('loose'),
|
|
53
|
+
rect('b', 200, { groupIds: ['g1'] }),
|
|
54
|
+
];
|
|
55
|
+
expect(expandIdsToGroups(['b'], strokes)).toEqual(['a', 'b']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('ungrouped ids pass through; mixed selection keeps both', () => {
|
|
59
|
+
const strokes: Stroke[] = [
|
|
60
|
+
rect('a', 0, { groupIds: ['g1'] }),
|
|
61
|
+
pen('loose'),
|
|
62
|
+
rect('b', 200, { groupIds: ['g1'] }),
|
|
63
|
+
];
|
|
64
|
+
expect(expandIdsToGroups(['loose', 'a'], strokes)).toEqual(['a', 'loose', 'b']);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('groupStrokes', () => {
|
|
69
|
+
test('groups ≥2 strokes under a fresh outermost id, contiguous at the topmost member', () => {
|
|
70
|
+
const strokes: Stroke[] = [rect('a'), pen('mid'), rect('b', 200)];
|
|
71
|
+
const res = groupStrokes(strokes, ['a', 'b'], 'gNew');
|
|
72
|
+
expect(res).not.toBeNull();
|
|
73
|
+
if (!res) return;
|
|
74
|
+
// Block lands where the topmost member ('b') sat — after 'mid'.
|
|
75
|
+
expect(res.strokes.map((s) => s.id)).toEqual(['mid', 'a', 'b']);
|
|
76
|
+
expect(
|
|
77
|
+
res.strokes.filter((s) => s.id !== 'mid').every((s) => outermostGroupOf(s) === 'gNew')
|
|
78
|
+
).toBe(true);
|
|
79
|
+
expect(res.memberIds).toEqual(['a', 'b']);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('grouping a group with a loose stroke NESTS (old id stays deeper)', () => {
|
|
83
|
+
const strokes: Stroke[] = [
|
|
84
|
+
rect('a', 0, { groupIds: ['g1'] }),
|
|
85
|
+
rect('b', 100, { groupIds: ['g1'] }),
|
|
86
|
+
pen('c'),
|
|
87
|
+
];
|
|
88
|
+
const res = groupStrokes(strokes, ['c', 'a'], 'g2');
|
|
89
|
+
expect(res).not.toBeNull();
|
|
90
|
+
if (!res) return;
|
|
91
|
+
const a = res.strokes.find((s) => s.id === 'a');
|
|
92
|
+
expect(a?.groupIds).toEqual(['g1', 'g2']);
|
|
93
|
+
const c = res.strokes.find((s) => s.id === 'c');
|
|
94
|
+
expect(c?.groupIds).toEqual(['g2']);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('fewer than two resolved members → null', () => {
|
|
98
|
+
expect(groupStrokes([rect('a')], ['a'])).toBeNull();
|
|
99
|
+
expect(groupStrokes([rect('a')], ['a', 'ghost'])).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('ungroupStrokes / normalizeGroups', () => {
|
|
104
|
+
test('ungroup strips ONE level (the outermost) and drops the key when empty', () => {
|
|
105
|
+
const strokes: Stroke[] = [
|
|
106
|
+
rect('a', 0, { groupIds: ['g1', 'g2'] }),
|
|
107
|
+
rect('b', 100, { groupIds: ['g1', 'g2'] }),
|
|
108
|
+
];
|
|
109
|
+
const out = ungroupStrokes(strokes, ['a']);
|
|
110
|
+
expect(out.map((s) => s.groupIds)).toEqual([['g1'], ['g1']]);
|
|
111
|
+
const out2 = ungroupStrokes(out, ['b']);
|
|
112
|
+
for (const s of out2) expect('groupIds' in s).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('normalizeGroups dissolves singleton groups after a delete', () => {
|
|
116
|
+
const strokes: Stroke[] = [rect('a', 0, { groupIds: ['g1'] }), pen('loose')];
|
|
117
|
+
const out = normalizeGroups(strokes);
|
|
118
|
+
expect('groupIds' in (out[0] as Stroke)).toBe(false);
|
|
119
|
+
// Referential no-op when nothing changes.
|
|
120
|
+
const stable: Stroke[] = [rect('x', 0, { groupIds: ['g'] }), rect('y', 1, { groupIds: ['g'] })];
|
|
121
|
+
expect(normalizeGroups(stable)).toBe(stable);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('duplicateStrokes', () => {
|
|
126
|
+
test('clones with fresh ids, remapped group ids, offset applied', () => {
|
|
127
|
+
let n = 0;
|
|
128
|
+
const makeId = () => `dup${++n}`;
|
|
129
|
+
const strokes: Stroke[] = [
|
|
130
|
+
rect('a', 0, { groupIds: ['g1'] }),
|
|
131
|
+
rect('b', 100, { groupIds: ['g1'] }),
|
|
132
|
+
];
|
|
133
|
+
const res = duplicateStrokes(strokes, ['a'], 16, 16, makeId, () => 'gDup');
|
|
134
|
+
expect(res.strokes).toHaveLength(4);
|
|
135
|
+
expect(res.newIds).toEqual(['dup1', 'dup2']);
|
|
136
|
+
const dup1 = res.strokes.find((s) => s.id === 'dup1') as RectStroke;
|
|
137
|
+
expect(dup1.x).toBe(16);
|
|
138
|
+
expect(dup1.groupIds).toEqual(['gDup']);
|
|
139
|
+
// Originals untouched.
|
|
140
|
+
expect((res.strokes.find((s) => s.id === 'a') as RectStroke).x).toBe(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('anchored text travels with its duplicated host and is NOT selectable', () => {
|
|
144
|
+
const host = rect('host');
|
|
145
|
+
const label: TextStroke = {
|
|
146
|
+
id: 'label',
|
|
147
|
+
tool: 'text',
|
|
148
|
+
color: '#1a1a1a',
|
|
149
|
+
fontSize: 14,
|
|
150
|
+
text: 'hi',
|
|
151
|
+
anchorId: 'host',
|
|
152
|
+
};
|
|
153
|
+
let n = 0;
|
|
154
|
+
const res = duplicateStrokes([host, label], ['host'], 10, 0, () => `d${++n}`);
|
|
155
|
+
expect(res.strokes).toHaveLength(4);
|
|
156
|
+
const dupLabel = res.strokes.find((s) => s.tool === 'text' && s.id !== 'label') as TextStroke;
|
|
157
|
+
expect(dupLabel.anchorId).toBe('d1'); // remapped to the cloned host
|
|
158
|
+
expect(res.newIds).toEqual(['d1']); // anchored-text clone excluded
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('arrow binds remap to co-duplicated hosts; binds to outside hosts are kept', () => {
|
|
162
|
+
const a = rect('a');
|
|
163
|
+
const b = rect('b', 300);
|
|
164
|
+
const ar = arrow('ar', {
|
|
165
|
+
startBind: { hostId: 'a', nx: 1, ny: 0.5 },
|
|
166
|
+
endBind: { hostId: 'b', nx: 0, ny: 0.5 },
|
|
167
|
+
});
|
|
168
|
+
let n = 0;
|
|
169
|
+
const res = duplicateStrokes([a, b, ar], ['a', 'ar'], 0, 0, () => `d${++n}`);
|
|
170
|
+
const dupArrow = res.strokes.find((s) => s.tool === 'arrow' && s.id !== 'ar') as ArrowStroke;
|
|
171
|
+
expect(dupArrow.startBind?.hostId).toBe('d1'); // co-duplicated → remapped
|
|
172
|
+
expect(dupArrow.endBind?.hostId).toBe('b'); // outside host → kept
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('reorderStrokes (z-order, group-contiguous units)', () => {
|
|
177
|
+
const base = (): Stroke[] => [
|
|
178
|
+
rect('a'),
|
|
179
|
+
rect('g1a', 100, { groupIds: ['g1'] }),
|
|
180
|
+
rect('g1b', 200, { groupIds: ['g1'] }),
|
|
181
|
+
pen('top'),
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
test('bring to front / send to back move the unit as one block', () => {
|
|
185
|
+
expect(reorderStrokes(base(), ['a'], 'front').map((s) => s.id)).toEqual([
|
|
186
|
+
'g1a',
|
|
187
|
+
'g1b',
|
|
188
|
+
'top',
|
|
189
|
+
'a',
|
|
190
|
+
]);
|
|
191
|
+
expect(reorderStrokes(base(), ['top'], 'back').map((s) => s.id)).toEqual([
|
|
192
|
+
'top',
|
|
193
|
+
'a',
|
|
194
|
+
'g1a',
|
|
195
|
+
'g1b',
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('forward/backward hop over a WHOLE neighbouring group', () => {
|
|
200
|
+
expect(reorderStrokes(base(), ['a'], 'forward').map((s) => s.id)).toEqual([
|
|
201
|
+
'g1a',
|
|
202
|
+
'g1b',
|
|
203
|
+
'a',
|
|
204
|
+
'top',
|
|
205
|
+
]);
|
|
206
|
+
expect(reorderStrokes(base(), ['top'], 'backward').map((s) => s.id)).toEqual([
|
|
207
|
+
'a',
|
|
208
|
+
'top',
|
|
209
|
+
'g1a',
|
|
210
|
+
'g1b',
|
|
211
|
+
]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('selecting one group member moves the whole group', () => {
|
|
215
|
+
expect(reorderStrokes(base(), ['g1b'], 'front').map((s) => s.id)).toEqual([
|
|
216
|
+
'a',
|
|
217
|
+
'top',
|
|
218
|
+
'g1a',
|
|
219
|
+
'g1b',
|
|
220
|
+
]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('already at the boundary is a no-op', () => {
|
|
224
|
+
expect(reorderStrokes(base(), ['top'], 'forward').map((s) => s.id)).toEqual([
|
|
225
|
+
'a',
|
|
226
|
+
'g1a',
|
|
227
|
+
'g1b',
|
|
228
|
+
'top',
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// annotations-snap — FigJam v3 drag snapping + smart guides (pure, DOM-free).
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from 'bun:test';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
computeSnap,
|
|
7
|
+
GRID_PITCH_PX,
|
|
8
|
+
SNAP_THRESHOLD_PX,
|
|
9
|
+
type SnapBox,
|
|
10
|
+
} from '../annotations-snap.ts';
|
|
11
|
+
|
|
12
|
+
const box = (x: number, y: number, w = 100, h = 100): SnapBox => ({ x, y, w, h });
|
|
13
|
+
|
|
14
|
+
describe('computeSnap', () => {
|
|
15
|
+
test('snaps left edge to a candidate left edge within the threshold', () => {
|
|
16
|
+
const res = computeSnap(box(104, 300), [box(100, 0)], SNAP_THRESHOLD_PX);
|
|
17
|
+
expect(res.dx).toBe(-4);
|
|
18
|
+
expect(res.dy).toBe(0);
|
|
19
|
+
expect(res.guides).toHaveLength(1);
|
|
20
|
+
const guide = res.guides[0];
|
|
21
|
+
expect(guide?.axis).toBe('x');
|
|
22
|
+
expect(guide?.at).toBe(100);
|
|
23
|
+
// The guide spans from the candidate to the (snapped) moving box.
|
|
24
|
+
expect(guide?.from).toBe(0);
|
|
25
|
+
expect(guide?.to).toBe(400);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('centers participate — center-to-center snap', () => {
|
|
29
|
+
// moving center x = 153, candidate center x = 150 → dx -3.
|
|
30
|
+
const res = computeSnap(box(103, 0), [box(100, 300)], SNAP_THRESHOLD_PX);
|
|
31
|
+
expect(res.dx).toBe(-3);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('axes resolve independently against different candidates', () => {
|
|
35
|
+
const res = computeSnap(box(104, 203), [box(100, 500), box(500, 200)], SNAP_THRESHOLD_PX);
|
|
36
|
+
expect(res.dx).toBe(-4); // left edge → first candidate
|
|
37
|
+
expect(res.dy).toBe(-3); // top edge → second candidate
|
|
38
|
+
expect(res.guides).toHaveLength(2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('nearest line wins; outside the threshold = no snap', () => {
|
|
42
|
+
const res = computeSnap(box(104, 0), [box(100, 0), box(105, 0)], SNAP_THRESHOLD_PX);
|
|
43
|
+
expect(res.dx).toBe(1); // 105 is 1 away vs 100 being 4 away
|
|
44
|
+
const none = computeSnap(box(120, 0), [box(100, 300)], SNAP_THRESHOLD_PX);
|
|
45
|
+
expect(none.dx).toBe(0);
|
|
46
|
+
expect(none.guides).toHaveLength(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('empty candidates / zero threshold → no snap', () => {
|
|
50
|
+
expect(computeSnap(box(0, 0), [], SNAP_THRESHOLD_PX).guides).toHaveLength(0);
|
|
51
|
+
expect(computeSnap(box(101, 0), [box(100, 0)], 0).dx).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('grid fallback — leading edge snaps to the 24px lattice with no candidates', () => {
|
|
55
|
+
// x=98 → nearest grid line 96 (4·24), 2 away; y=50 → nearest 48, 2 away.
|
|
56
|
+
const res = computeSnap(box(98, 50), [], SNAP_THRESHOLD_PX, { grid: GRID_PITCH_PX });
|
|
57
|
+
expect(res.dx).toBe(-2);
|
|
58
|
+
expect(res.dy).toBe(-2);
|
|
59
|
+
// Grid snap is silent — the dots themselves are the visual, no guide lines.
|
|
60
|
+
expect(res.guides).toHaveLength(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('grid fallback is per-axis — a smart-guide axis keeps its candidate snap', () => {
|
|
64
|
+
// x snaps to the candidate edge (dx -4 → x=100, NOT the 96 grid line);
|
|
65
|
+
// y has no candidate within reach → grid line 48 wins (dy -2).
|
|
66
|
+
const res = computeSnap(box(104, 50), [box(100, 500)], SNAP_THRESHOLD_PX, {
|
|
67
|
+
grid: GRID_PITCH_PX,
|
|
68
|
+
});
|
|
69
|
+
expect(res.dx).toBe(-4);
|
|
70
|
+
expect(res.dy).toBe(-2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('grid outside the threshold → no correction', () => {
|
|
74
|
+
// x=84 sits 12 from both 72 and 96 — beyond the 6px threshold.
|
|
75
|
+
const res = computeSnap(box(84, 84), [], SNAP_THRESHOLD_PX, { grid: GRID_PITCH_PX });
|
|
76
|
+
expect(res.dx).toBe(0);
|
|
77
|
+
expect(res.dy).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -122,6 +122,71 @@ describe('/_api/canvas — POST round-trip', () => {
|
|
|
122
122
|
}
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
test('create + delete emit canvas-list-update over the inspector WS (Phase 30)', async () => {
|
|
126
|
+
const { root } = makeSandbox();
|
|
127
|
+
const port = nextPort();
|
|
128
|
+
const proc = await bootServer(root, port);
|
|
129
|
+
let socket: WebSocket | null = null;
|
|
130
|
+
try {
|
|
131
|
+
const messages: Array<{ type: string; payload?: { action?: string; slug?: string } }> = [];
|
|
132
|
+
const sock = new WebSocket(`ws://localhost:${port}/_ws`);
|
|
133
|
+
socket = sock;
|
|
134
|
+
await new Promise<void>((res, rej) => {
|
|
135
|
+
sock.addEventListener('open', () => res());
|
|
136
|
+
sock.addEventListener('error', () => rej(new Error('ws error')));
|
|
137
|
+
setTimeout(() => rej(new Error('ws open timeout')), 2000);
|
|
138
|
+
});
|
|
139
|
+
sock.addEventListener('message', (ev) => {
|
|
140
|
+
try {
|
|
141
|
+
messages.push(JSON.parse(String(ev.data)));
|
|
142
|
+
} catch {
|
|
143
|
+
/* ignore non-JSON */
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const waitFor = async (pred: (m: (typeof messages)[number]) => boolean) => {
|
|
148
|
+
const deadline = Date.now() + 2000;
|
|
149
|
+
while (Date.now() < deadline) {
|
|
150
|
+
const hit = messages.find(pred);
|
|
151
|
+
if (hit) return hit;
|
|
152
|
+
await Bun.sleep(25);
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const created = await fetch(`http://localhost:${port}/_api/canvas`, {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: { 'Content-Type': 'application/json' },
|
|
160
|
+
body: JSON.stringify({ name: 'Live Add', kind: 'brief-board' }),
|
|
161
|
+
});
|
|
162
|
+
expect(created.status).toBe(201);
|
|
163
|
+
const cj = (await created.json()) as { rel: string };
|
|
164
|
+
|
|
165
|
+
const added = await waitFor(
|
|
166
|
+
(m) => m.type === 'canvas-list-update' && m.payload?.action === 'added'
|
|
167
|
+
);
|
|
168
|
+
expect(added?.payload?.slug).toBe('ui-live_add');
|
|
169
|
+
|
|
170
|
+
const del = await fetch(
|
|
171
|
+
`http://localhost:${port}/_api/canvas?file=${encodeURIComponent(cj.rel)}`,
|
|
172
|
+
{ method: 'DELETE' }
|
|
173
|
+
);
|
|
174
|
+
expect(del.status).toBe(200);
|
|
175
|
+
|
|
176
|
+
const removed = await waitFor(
|
|
177
|
+
(m) => m.type === 'canvas-list-update' && m.payload?.action === 'removed'
|
|
178
|
+
);
|
|
179
|
+
expect(removed?.payload?.slug).toBe('ui-live_add');
|
|
180
|
+
} finally {
|
|
181
|
+
try {
|
|
182
|
+
socket?.close();
|
|
183
|
+
} catch {
|
|
184
|
+
/* ignore */
|
|
185
|
+
}
|
|
186
|
+
await killProc(proc);
|
|
187
|
+
}
|
|
188
|
+
}, 10_000);
|
|
189
|
+
|
|
125
190
|
test('defaults kind to brief-board when omitted', async () => {
|
|
126
191
|
const { root } = makeSandbox();
|
|
127
192
|
const port = nextPort();
|
|
@@ -284,6 +349,10 @@ describe('/_api/canvas — DELETE (soft-delete)', () => {
|
|
|
284
349
|
join(designRoot, `${created.slug}.annotations.svg`),
|
|
285
350
|
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"></svg>'
|
|
286
351
|
);
|
|
352
|
+
// Seed the per-machine camera view file (DDR-115) to prove it's swept too.
|
|
353
|
+
mkdirSync(join(designRoot, '_canvas-state'), { recursive: true });
|
|
354
|
+
const viewAbs = join(designRoot, '_canvas-state', `${created.slug}.view.json`);
|
|
355
|
+
writeFileSync(viewAbs, JSON.stringify({ viewport: { x: 1, y: 2, zoom: 1 } }));
|
|
287
356
|
const tsx = join(designRoot, 'ui', 'Trash Me.tsx');
|
|
288
357
|
expect(existsSync(tsx)).toBe(true);
|
|
289
358
|
|
|
@@ -293,14 +362,17 @@ describe('/_api/canvas — DELETE (soft-delete)', () => {
|
|
|
293
362
|
expect(j.ok).toBe(true);
|
|
294
363
|
expect(j.trashed.some((t) => t.endsWith('Trash Me.tsx'))).toBe(true);
|
|
295
364
|
expect(j.trashed.some((t) => t.endsWith('.annotations.svg'))).toBe(true);
|
|
365
|
+
expect(j.trashed.some((t) => t.endsWith('.view.json'))).toBe(true);
|
|
296
366
|
|
|
297
367
|
// Gone from the group; present (recoverable) under _trash/ with a manifest.
|
|
298
368
|
expect(existsSync(tsx)).toBe(false);
|
|
299
369
|
expect(existsSync(join(designRoot, `${created.slug}.annotations.svg`))).toBe(false);
|
|
370
|
+
expect(existsSync(viewAbs)).toBe(false);
|
|
300
371
|
const trashDirs = readdirSync(join(designRoot, '_trash'));
|
|
301
372
|
expect(trashDirs.length).toBe(1);
|
|
302
373
|
const td = join(designRoot, '_trash', trashDirs[0] as string);
|
|
303
374
|
expect(existsSync(join(td, 'Trash Me.tsx'))).toBe(true);
|
|
375
|
+
expect(existsSync(join(td, `_canvas-state__${created.slug}.view.json`))).toBe(true);
|
|
304
376
|
expect(existsSync(join(td, '_trash-manifest.json'))).toBe(true);
|
|
305
377
|
} finally {
|
|
306
378
|
await killProc(proc);
|