pmx-canvas 0.1.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/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/Readme.md +865 -0
- package/dist/canvas/global.css +3173 -0
- package/dist/canvas/index.js +183 -0
- package/dist/json-render/index.css +2 -0
- package/dist/json-render/index.js +389 -0
- package/dist/types/cli/agent.d.ts +13 -0
- package/dist/types/cli/index.d.ts +2 -0
- package/dist/types/cli/watch.d.ts +5 -0
- package/dist/types/client/App.d.ts +1 -0
- package/dist/types/client/canvas/AttentionHistory.d.ts +1 -0
- package/dist/types/client/canvas/AttentionToast.d.ts +1 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +8 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +8 -0
- package/dist/types/client/canvas/CommandPalette.d.ts +4 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +24 -0
- package/dist/types/client/canvas/ContextPinBar.d.ts +1 -0
- package/dist/types/client/canvas/ContextPinHud.d.ts +1 -0
- package/dist/types/client/canvas/DockedNode.d.ts +4 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
- package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -0
- package/dist/types/client/canvas/FocusFieldLayer.d.ts +1 -0
- package/dist/types/client/canvas/Minimap.d.ts +23 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +1 -0
- package/dist/types/client/canvas/ShortcutOverlay.d.ts +3 -0
- package/dist/types/client/canvas/SnapshotPanel.d.ts +7 -0
- package/dist/types/client/canvas/snap-guides.d.ts +23 -0
- package/dist/types/client/canvas/use-node-drag.d.ts +15 -0
- package/dist/types/client/canvas/use-node-resize.d.ts +15 -0
- package/dist/types/client/canvas/use-pan-zoom.d.ts +16 -0
- package/dist/types/client/ext-app/bridge.d.ts +161 -0
- package/dist/types/client/icons.d.ts +70 -0
- package/dist/types/client/index.d.ts +1 -0
- package/dist/types/client/nodes/ContextNode.d.ts +34 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +18 -0
- package/dist/types/client/nodes/FileNode.d.ts +5 -0
- package/dist/types/client/nodes/GroupNode.d.ts +6 -0
- package/dist/types/client/nodes/ImageNode.d.ts +10 -0
- package/dist/types/client/nodes/InlineFormatBar.d.ts +7 -0
- package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +14 -0
- package/dist/types/client/nodes/LedgerNode.d.ts +4 -0
- package/dist/types/client/nodes/MarkdownNode.d.ts +6 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +4 -0
- package/dist/types/client/nodes/MdFormatBar.d.ts +8 -0
- package/dist/types/client/nodes/PromptNode.d.ts +5 -0
- package/dist/types/client/nodes/ResponseNode.d.ts +5 -0
- package/dist/types/client/nodes/StatusNode.d.ts +4 -0
- package/dist/types/client/nodes/StatusSummary.d.ts +4 -0
- package/dist/types/client/nodes/TraceNode.d.ts +4 -0
- package/dist/types/client/nodes/WebpageNode.d.ts +5 -0
- package/dist/types/client/nodes/image-warnings.d.ts +6 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +11 -0
- package/dist/types/client/nodes/md-format.d.ts +25 -0
- package/dist/types/client/state/attention-bridge.d.ts +3 -0
- package/dist/types/client/state/attention-store.d.ts +25 -0
- package/dist/types/client/state/canvas-store.d.ts +74 -0
- package/dist/types/client/state/intent-bridge.d.ts +158 -0
- package/dist/types/client/state/sse-bridge.d.ts +5 -0
- package/dist/types/client/theme/tokens.d.ts +27 -0
- package/dist/types/client/types.d.ts +40 -0
- package/dist/types/client/utils/ext-app-tool-result.d.ts +1 -0
- package/dist/types/client/utils/placement.d.ts +1 -0
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/json-render/catalog.d.ts +815 -0
- package/dist/types/json-render/charts/components.d.ts +54 -0
- package/dist/types/json-render/charts/definitions.d.ts +103 -0
- package/dist/types/json-render/charts/extra-components.d.ts +58 -0
- package/dist/types/json-render/charts/extra-definitions.d.ts +181 -0
- package/dist/types/json-render/renderer/index.d.ts +16 -0
- package/dist/types/json-render/schema.d.ts +46 -0
- package/dist/types/json-render/server.d.ts +55 -0
- package/dist/types/mcp/server.d.ts +22 -0
- package/dist/types/server/agent-context.d.ts +21 -0
- package/dist/types/server/artifact-paths.d.ts +3 -0
- package/dist/types/server/canvas-operations.d.ts +154 -0
- package/dist/types/server/canvas-provenance.d.ts +13 -0
- package/dist/types/server/canvas-schema.d.ts +49 -0
- package/dist/types/server/canvas-serialization.d.ts +25 -0
- package/dist/types/server/canvas-state.d.ts +174 -0
- package/dist/types/server/canvas-validation.d.ts +33 -0
- package/dist/types/server/chart-template.d.ts +29 -0
- package/dist/types/server/code-graph.d.ts +67 -0
- package/dist/types/server/context-cards.d.ts +24 -0
- package/dist/types/server/diagram-presets.d.ts +28 -0
- package/dist/types/server/ext-app-call-registry.d.ts +16 -0
- package/dist/types/server/ext-app-tool-result.d.ts +1 -0
- package/dist/types/server/file-watcher.d.ts +16 -0
- package/dist/types/server/index.d.ts +243 -0
- package/dist/types/server/mcp-app-candidate.d.ts +25 -0
- package/dist/types/server/mcp-app-host.d.ts +65 -0
- package/dist/types/server/mcp-app-runtime.d.ts +47 -0
- package/dist/types/server/mutation-history.d.ts +105 -0
- package/dist/types/server/placement.d.ts +37 -0
- package/dist/types/server/server.d.ts +103 -0
- package/dist/types/server/spatial-analysis.d.ts +87 -0
- package/dist/types/server/trace-manager.d.ts +48 -0
- package/dist/types/server/web-artifacts.d.ts +50 -0
- package/dist/types/server/webpage-node.d.ts +25 -0
- package/dist/types/shared/auto-arrange.d.ts +29 -0
- package/dist/types/shared/ext-app-tool-result.d.ts +9 -0
- package/dist/types/shared/placement.d.ts +26 -0
- package/dist/types/shared/semantic-attention.d.ts +97 -0
- package/package.json +109 -0
- package/skills/data-analysis/SKILL.md +324 -0
- package/skills/doc-coauthoring/SKILL.md +375 -0
- package/skills/frontend-design/SKILL.md +45 -0
- package/skills/json-render-codegen/SKILL.md +112 -0
- package/skills/json-render-core/SKILL.md +265 -0
- package/skills/json-render-ink/SKILL.md +273 -0
- package/skills/json-render-mcp/SKILL.md +132 -0
- package/skills/json-render-react/SKILL.md +264 -0
- package/skills/json-render-shadcn/SKILL.md +159 -0
- package/skills/playwright-cli/SKILL.md +67 -0
- package/skills/pmx-canvas/SKILL.md +668 -0
- package/skills/pmx-canvas/evals/evals.json +186 -0
- package/skills/pmx-canvas-testing/SKILL.md +78 -0
- package/skills/published-consumer-e2e/SKILL.md +43 -0
- package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +241 -0
- package/skills/web-artifacts-builder/SKILL.md +80 -0
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +167 -0
- package/skills/web-artifacts-builder/scripts/init-artifact.sh +425 -0
- package/skills/web-artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
- package/skills/web-design-guidelines/SKILL.md +39 -0
- package/src/cli/agent.ts +2144 -0
- package/src/cli/index.ts +622 -0
- package/src/cli/watch.ts +88 -0
- package/src/client/App.tsx +507 -0
- package/src/client/canvas/AttentionHistory.tsx +81 -0
- package/src/client/canvas/AttentionToast.tsx +19 -0
- package/src/client/canvas/CanvasNode.tsx +363 -0
- package/src/client/canvas/CanvasViewport.tsx +590 -0
- package/src/client/canvas/CommandPalette.tsx +302 -0
- package/src/client/canvas/ContextMenu.tsx +601 -0
- package/src/client/canvas/ContextPinBar.tsx +25 -0
- package/src/client/canvas/ContextPinHud.tsx +22 -0
- package/src/client/canvas/DockedNode.tsx +66 -0
- package/src/client/canvas/EdgeLayer.tsx +280 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +260 -0
- package/src/client/canvas/FocusFieldLayer.tsx +107 -0
- package/src/client/canvas/Minimap.tsx +301 -0
- package/src/client/canvas/SelectionBar.tsx +69 -0
- package/src/client/canvas/ShortcutOverlay.tsx +69 -0
- package/src/client/canvas/SnapshotPanel.tsx +236 -0
- package/src/client/canvas/snap-guides.ts +170 -0
- package/src/client/canvas/use-node-drag.ts +51 -0
- package/src/client/canvas/use-node-resize.ts +59 -0
- package/src/client/canvas/use-pan-zoom.ts +191 -0
- package/src/client/ext-app/bridge.ts +542 -0
- package/src/client/icons.tsx +424 -0
- package/src/client/index.tsx +7 -0
- package/src/client/nodes/ContextNode.tsx +412 -0
- package/src/client/nodes/ExtAppFrame.tsx +509 -0
- package/src/client/nodes/FileNode.tsx +256 -0
- package/src/client/nodes/GroupNode.tsx +39 -0
- package/src/client/nodes/ImageNode.tsx +160 -0
- package/src/client/nodes/InlineFormatBar.tsx +169 -0
- package/src/client/nodes/InlineMarkdownEditor.tsx +123 -0
- package/src/client/nodes/LedgerNode.tsx +37 -0
- package/src/client/nodes/MarkdownNode.tsx +359 -0
- package/src/client/nodes/McpAppNode.tsx +85 -0
- package/src/client/nodes/MdFormatBar.tsx +109 -0
- package/src/client/nodes/PromptNode.tsx +597 -0
- package/src/client/nodes/ResponseNode.tsx +153 -0
- package/src/client/nodes/StatusNode.tsx +84 -0
- package/src/client/nodes/StatusSummary.tsx +38 -0
- package/src/client/nodes/TraceNode.tsx +120 -0
- package/src/client/nodes/WebpageNode.tsx +288 -0
- package/src/client/nodes/image-warnings.ts +95 -0
- package/src/client/nodes/inline-editor-commands.ts +37 -0
- package/src/client/nodes/md-format.ts +206 -0
- package/src/client/state/attention-bridge.ts +328 -0
- package/src/client/state/attention-store.ts +73 -0
- package/src/client/state/canvas-store.ts +631 -0
- package/src/client/state/intent-bridge.ts +315 -0
- package/src/client/state/sse-bridge.ts +965 -0
- package/src/client/theme/global.css +3173 -0
- package/src/client/theme/tokens.ts +72 -0
- package/src/client/types-shims.d.ts +5 -0
- package/src/client/types.ts +81 -0
- package/src/client/utils/ext-app-tool-result.ts +4 -0
- package/src/client/utils/placement.ts +4 -0
- package/src/client/utils/platform.ts +2 -0
- package/src/json-render/catalog.ts +256 -0
- package/src/json-render/charts/components.tsx +198 -0
- package/src/json-render/charts/definitions.ts +81 -0
- package/src/json-render/charts/extra-components.tsx +267 -0
- package/src/json-render/charts/extra-definitions.ts +145 -0
- package/src/json-render/renderer/index.css +174 -0
- package/src/json-render/renderer/index.tsx +86 -0
- package/src/json-render/schema.ts +62 -0
- package/src/json-render/server.ts +597 -0
- package/src/mcp/server.ts +1377 -0
- package/src/server/agent-context.ts +242 -0
- package/src/server/artifact-paths.ts +17 -0
- package/src/server/canvas-operations.ts +1279 -0
- package/src/server/canvas-provenance.ts +243 -0
- package/src/server/canvas-schema.ts +432 -0
- package/src/server/canvas-serialization.ts +95 -0
- package/src/server/canvas-state.ts +1134 -0
- package/src/server/canvas-validation.ts +114 -0
- package/src/server/chart-template.ts +449 -0
- package/src/server/code-graph.ts +370 -0
- package/src/server/context-cards.ts +31 -0
- package/src/server/diagram-presets.ts +71 -0
- package/src/server/ext-app-call-registry.ts +77 -0
- package/src/server/ext-app-tool-result.ts +4 -0
- package/src/server/file-watcher.ts +121 -0
- package/src/server/index.ts +647 -0
- package/src/server/mcp-app-candidate.ts +174 -0
- package/src/server/mcp-app-host.ts +814 -0
- package/src/server/mcp-app-runtime.ts +459 -0
- package/src/server/mutation-history.ts +350 -0
- package/src/server/placement.ts +125 -0
- package/src/server/server.ts +3846 -0
- package/src/server/spatial-analysis.ts +356 -0
- package/src/server/trace-manager.ts +333 -0
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +167 -0
- package/src/server/web-artifacts/scripts/init-artifact.sh +426 -0
- package/src/server/web-artifacts/scripts/shadcn-components.tar.gz +0 -0
- package/src/server/web-artifacts.ts +442 -0
- package/src/server/webpage-node.ts +328 -0
- package/src/shared/auto-arrange.ts +439 -0
- package/src/shared/ext-app-tool-result.ts +76 -0
- package/src/shared/placement.ts +81 -0
- package/src/shared/semantic-attention.ts +598 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas Mutation History — Time Travel for PMX Canvas
|
|
3
|
+
*
|
|
4
|
+
* Records every canvas mutation in an in-memory ring buffer with forward/inverse
|
|
5
|
+
* closures for undo/redo. Provides a human-readable history timeline and
|
|
6
|
+
* snapshot diff capabilities.
|
|
7
|
+
*
|
|
8
|
+
* Design decisions:
|
|
9
|
+
* - In-memory only (not persisted) — history is session-scoped
|
|
10
|
+
* - Ring buffer caps at 200 entries to bound memory
|
|
11
|
+
* - forward/inverse closures capture cloned state at record time
|
|
12
|
+
* - _replaying flag prevents undo/redo from recording new entries
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { CanvasNodeState, CanvasEdge, CanvasLayout } from './canvas-state.js';
|
|
16
|
+
|
|
17
|
+
// ── Types ────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export type MutationOp =
|
|
20
|
+
| 'addNode'
|
|
21
|
+
| 'updateNode'
|
|
22
|
+
| 'removeNode'
|
|
23
|
+
| 'addEdge'
|
|
24
|
+
| 'removeEdge'
|
|
25
|
+
| 'clear'
|
|
26
|
+
| 'arrange'
|
|
27
|
+
| 'restoreSnapshot'
|
|
28
|
+
| 'setPins'
|
|
29
|
+
| 'batch'
|
|
30
|
+
| 'viewport'
|
|
31
|
+
| 'groupNodes'
|
|
32
|
+
| 'ungroupNodes';
|
|
33
|
+
|
|
34
|
+
export interface MutationEntry {
|
|
35
|
+
id: string;
|
|
36
|
+
timestamp: string;
|
|
37
|
+
description: string;
|
|
38
|
+
operationType: MutationOp;
|
|
39
|
+
forward: () => void;
|
|
40
|
+
inverse: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MutationSummary {
|
|
44
|
+
id: string;
|
|
45
|
+
timestamp: string;
|
|
46
|
+
description: string;
|
|
47
|
+
operationType: MutationOp;
|
|
48
|
+
isCurrent: boolean;
|
|
49
|
+
isUndone: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SnapshotDiffResult {
|
|
53
|
+
snapshotName: string;
|
|
54
|
+
addedNodes: { id: string; type: string; title: string | null }[];
|
|
55
|
+
removedNodes: { id: string; type: string; title: string | null }[];
|
|
56
|
+
modifiedNodes: {
|
|
57
|
+
id: string;
|
|
58
|
+
type: string;
|
|
59
|
+
title: string | null;
|
|
60
|
+
changes: string[];
|
|
61
|
+
}[];
|
|
62
|
+
addedEdges: { id: string; from: string; to: string; type: string }[];
|
|
63
|
+
removedEdges: { id: string; from: string; to: string; type: string }[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Ring Buffer ──────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
const MAX_ENTRIES = 200;
|
|
69
|
+
|
|
70
|
+
class MutationHistory {
|
|
71
|
+
private entries: MutationEntry[] = [];
|
|
72
|
+
/** Index of the last applied mutation. -1 means nothing applied / all undone. */
|
|
73
|
+
private cursor = -1;
|
|
74
|
+
/** When true, mutations triggered by undo/redo are not recorded. */
|
|
75
|
+
private _replaying = false;
|
|
76
|
+
|
|
77
|
+
get isReplaying(): boolean {
|
|
78
|
+
return this._replaying;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Record a new mutation. Truncates any redo-able future, then appends.
|
|
83
|
+
* If called while replaying (undo/redo), the call is silently ignored.
|
|
84
|
+
*/
|
|
85
|
+
record(entry: Omit<MutationEntry, 'id' | 'timestamp'>): void {
|
|
86
|
+
if (this._replaying) return;
|
|
87
|
+
|
|
88
|
+
const full: MutationEntry = {
|
|
89
|
+
...entry,
|
|
90
|
+
id: `mut-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
|
91
|
+
timestamp: new Date().toISOString(),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Truncate redo future
|
|
95
|
+
this.entries.length = this.cursor + 1;
|
|
96
|
+
this.entries.push(full);
|
|
97
|
+
|
|
98
|
+
// Evict oldest if over capacity
|
|
99
|
+
if (this.entries.length > MAX_ENTRIES) {
|
|
100
|
+
const excess = this.entries.length - MAX_ENTRIES;
|
|
101
|
+
this.entries.splice(0, excess);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
this.cursor = this.entries.length - 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Undo the last applied mutation. Returns the entry that was undone, or null. */
|
|
108
|
+
undo(): MutationEntry | null {
|
|
109
|
+
if (this.cursor < 0) return null;
|
|
110
|
+
|
|
111
|
+
const entry = this.entries[this.cursor];
|
|
112
|
+
this._replaying = true;
|
|
113
|
+
try {
|
|
114
|
+
entry.inverse();
|
|
115
|
+
} finally {
|
|
116
|
+
this._replaying = false;
|
|
117
|
+
}
|
|
118
|
+
this.cursor--;
|
|
119
|
+
return entry;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Redo the next undone mutation. Returns the entry that was redone, or null. */
|
|
123
|
+
redo(): MutationEntry | null {
|
|
124
|
+
if (this.cursor >= this.entries.length - 1) return null;
|
|
125
|
+
|
|
126
|
+
this.cursor++;
|
|
127
|
+
const entry = this.entries[this.cursor];
|
|
128
|
+
this._replaying = true;
|
|
129
|
+
try {
|
|
130
|
+
entry.forward();
|
|
131
|
+
} finally {
|
|
132
|
+
this._replaying = false;
|
|
133
|
+
}
|
|
134
|
+
return entry;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
canUndo(): boolean {
|
|
138
|
+
return this.cursor >= 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
canRedo(): boolean {
|
|
142
|
+
return this.cursor < this.entries.length - 1;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Get all entries with current/undone status for display. */
|
|
146
|
+
getSummaries(): MutationSummary[] {
|
|
147
|
+
return this.entries.map((e, i) => ({
|
|
148
|
+
id: e.id,
|
|
149
|
+
timestamp: e.timestamp,
|
|
150
|
+
description: e.description,
|
|
151
|
+
operationType: e.operationType,
|
|
152
|
+
isCurrent: i === this.cursor,
|
|
153
|
+
isUndone: i > this.cursor,
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Human-readable timeline for the canvas://history resource. */
|
|
158
|
+
toHumanReadable(): string {
|
|
159
|
+
if (this.entries.length === 0) {
|
|
160
|
+
return 'Canvas History: empty (no mutations recorded this session)';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const lines: string[] = [
|
|
164
|
+
`Canvas History (${this.entries.length} mutations, position ${this.cursor + 1}/${this.entries.length})`,
|
|
165
|
+
'',
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
for (let i = 0; i < this.entries.length; i++) {
|
|
169
|
+
const e = this.entries[i];
|
|
170
|
+
const ts = new Date(e.timestamp);
|
|
171
|
+
const time = ts.toLocaleTimeString('en-US', { hour12: false });
|
|
172
|
+
const marker = i === this.cursor ? ' <<< current' : i > this.cursor ? ' (undone)' : '';
|
|
173
|
+
lines.push(` #${i + 1} [${time}] ${e.description}${marker}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
lines.push('');
|
|
177
|
+
lines.push(`Can undo: ${this.canUndo() ? 'yes' : 'no'} | Can redo: ${this.canRedo() ? 'yes' : 'no'}`);
|
|
178
|
+
|
|
179
|
+
return lines.join('\n');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Number of recorded entries. */
|
|
183
|
+
get length(): number {
|
|
184
|
+
return this.entries.length;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Clear all recorded mutations. Useful for isolated test runs. */
|
|
188
|
+
reset(): void {
|
|
189
|
+
this.entries = [];
|
|
190
|
+
this.cursor = -1;
|
|
191
|
+
this._replaying = false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Diff Logic ───────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Compare two canvas layouts and produce a structured diff.
|
|
199
|
+
*/
|
|
200
|
+
export function diffLayouts(
|
|
201
|
+
snapshotName: string,
|
|
202
|
+
snapshotLayout: { nodes: CanvasNodeState[]; edges: CanvasEdge[] },
|
|
203
|
+
currentLayout: { nodes: CanvasNodeState[]; edges: CanvasEdge[] },
|
|
204
|
+
): SnapshotDiffResult {
|
|
205
|
+
const snapNodes = new Map(snapshotLayout.nodes.map((n) => [n.id, n]));
|
|
206
|
+
const curNodes = new Map(currentLayout.nodes.map((n) => [n.id, n]));
|
|
207
|
+
const snapEdges = new Map(snapshotLayout.edges.map((e) => [e.id, e]));
|
|
208
|
+
const curEdges = new Map(currentLayout.edges.map((e) => [e.id, e]));
|
|
209
|
+
|
|
210
|
+
const addedNodes: SnapshotDiffResult['addedNodes'] = [];
|
|
211
|
+
const removedNodes: SnapshotDiffResult['removedNodes'] = [];
|
|
212
|
+
const modifiedNodes: SnapshotDiffResult['modifiedNodes'] = [];
|
|
213
|
+
|
|
214
|
+
// Nodes added (in current but not snapshot)
|
|
215
|
+
for (const [id, node] of curNodes) {
|
|
216
|
+
if (!snapNodes.has(id)) {
|
|
217
|
+
addedNodes.push({ id, type: node.type, title: (node.data.title as string) ?? null });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Nodes removed (in snapshot but not current)
|
|
222
|
+
for (const [id, node] of snapNodes) {
|
|
223
|
+
if (!curNodes.has(id)) {
|
|
224
|
+
removedNodes.push({ id, type: node.type, title: (node.data.title as string) ?? null });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Nodes modified (in both, check for differences)
|
|
229
|
+
for (const [id, curNode] of curNodes) {
|
|
230
|
+
const snapNode = snapNodes.get(id);
|
|
231
|
+
if (!snapNode) continue;
|
|
232
|
+
|
|
233
|
+
const changes: string[] = [];
|
|
234
|
+
|
|
235
|
+
if (snapNode.position.x !== curNode.position.x || snapNode.position.y !== curNode.position.y) {
|
|
236
|
+
changes.push(`moved (${snapNode.position.x},${snapNode.position.y}) → (${curNode.position.x},${curNode.position.y})`);
|
|
237
|
+
}
|
|
238
|
+
if (snapNode.size.width !== curNode.size.width || snapNode.size.height !== curNode.size.height) {
|
|
239
|
+
changes.push(`resized ${snapNode.size.width}x${snapNode.size.height} → ${curNode.size.width}x${curNode.size.height}`);
|
|
240
|
+
}
|
|
241
|
+
if (snapNode.collapsed !== curNode.collapsed) {
|
|
242
|
+
changes.push(curNode.collapsed ? 'collapsed' : 'expanded');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const snapTitle = (snapNode.data.title as string) ?? '';
|
|
246
|
+
const curTitle = (curNode.data.title as string) ?? '';
|
|
247
|
+
if (snapTitle !== curTitle) {
|
|
248
|
+
changes.push(`title: "${snapTitle}" → "${curTitle}"`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const snapContent = (snapNode.data.content as string) ?? '';
|
|
252
|
+
const curContent = (curNode.data.content as string) ?? '';
|
|
253
|
+
if (snapContent !== curContent) {
|
|
254
|
+
const lenDiff = curContent.length - snapContent.length;
|
|
255
|
+
changes.push(`content changed (${lenDiff >= 0 ? '+' : ''}${lenDiff} chars)`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (changes.length > 0) {
|
|
259
|
+
modifiedNodes.push({
|
|
260
|
+
id,
|
|
261
|
+
type: curNode.type,
|
|
262
|
+
title: (curNode.data.title as string) ?? null,
|
|
263
|
+
changes,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Edges
|
|
269
|
+
const addedEdges: SnapshotDiffResult['addedEdges'] = [];
|
|
270
|
+
const removedEdges: SnapshotDiffResult['removedEdges'] = [];
|
|
271
|
+
|
|
272
|
+
for (const [id, edge] of curEdges) {
|
|
273
|
+
if (!snapEdges.has(id)) {
|
|
274
|
+
addedEdges.push({ id, from: edge.from, to: edge.to, type: edge.type });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const [id, edge] of snapEdges) {
|
|
278
|
+
if (!curEdges.has(id)) {
|
|
279
|
+
removedEdges.push({ id, from: edge.from, to: edge.to, type: edge.type });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { snapshotName, addedNodes, removedNodes, modifiedNodes, addedEdges, removedEdges };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Format a diff result as human-readable text for MCP.
|
|
288
|
+
*/
|
|
289
|
+
export function formatDiff(diff: SnapshotDiffResult): string {
|
|
290
|
+
const lines: string[] = [`Diff: current canvas vs snapshot "${diff.snapshotName}"`, ''];
|
|
291
|
+
|
|
292
|
+
const total = diff.addedNodes.length + diff.removedNodes.length + diff.modifiedNodes.length
|
|
293
|
+
+ diff.addedEdges.length + diff.removedEdges.length;
|
|
294
|
+
|
|
295
|
+
if (total === 0) {
|
|
296
|
+
lines.push('No differences — canvas matches the snapshot exactly.');
|
|
297
|
+
return lines.join('\n');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (diff.addedNodes.length > 0) {
|
|
301
|
+
lines.push(`Added nodes (${diff.addedNodes.length}):`);
|
|
302
|
+
for (const n of diff.addedNodes) {
|
|
303
|
+
lines.push(` + [${n.type}] ${n.title ?? n.id}`);
|
|
304
|
+
}
|
|
305
|
+
lines.push('');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (diff.removedNodes.length > 0) {
|
|
309
|
+
lines.push(`Removed nodes (${diff.removedNodes.length}):`);
|
|
310
|
+
for (const n of diff.removedNodes) {
|
|
311
|
+
lines.push(` - [${n.type}] ${n.title ?? n.id}`);
|
|
312
|
+
}
|
|
313
|
+
lines.push('');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (diff.modifiedNodes.length > 0) {
|
|
317
|
+
lines.push(`Modified nodes (${diff.modifiedNodes.length}):`);
|
|
318
|
+
for (const n of diff.modifiedNodes) {
|
|
319
|
+
lines.push(` ~ [${n.type}] ${n.title ?? n.id}`);
|
|
320
|
+
for (const c of n.changes) {
|
|
321
|
+
lines.push(` ${c}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
lines.push('');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (diff.addedEdges.length > 0) {
|
|
328
|
+
lines.push(`Added edges (${diff.addedEdges.length}):`);
|
|
329
|
+
for (const e of diff.addedEdges) {
|
|
330
|
+
lines.push(` + ${e.type}: ${e.from} → ${e.to}`);
|
|
331
|
+
}
|
|
332
|
+
lines.push('');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (diff.removedEdges.length > 0) {
|
|
336
|
+
lines.push(`Removed edges (${diff.removedEdges.length}):`);
|
|
337
|
+
for (const e of diff.removedEdges) {
|
|
338
|
+
lines.push(` - ${e.type}: ${e.from} → ${e.to}`);
|
|
339
|
+
}
|
|
340
|
+
lines.push('');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
lines.push(`Summary: +${diff.addedNodes.length} -${diff.removedNodes.length} ~${diff.modifiedNodes.length} nodes, +${diff.addedEdges.length} -${diff.removedEdges.length} edges`);
|
|
344
|
+
|
|
345
|
+
return lines.join('\n');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── Singleton ────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
export const mutationHistory = new MutationHistory();
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
findBlocker,
|
|
3
|
+
findOpenCanvasPosition,
|
|
4
|
+
overlapsAny,
|
|
5
|
+
type CanvasPlacementRect,
|
|
6
|
+
} from '../shared/placement.js';
|
|
7
|
+
|
|
8
|
+
export { findOpenCanvasPosition, type CanvasPlacementRect } from '../shared/placement.js';
|
|
9
|
+
|
|
10
|
+
export const GROUP_PAD = 40;
|
|
11
|
+
export const GROUP_TITLEBAR_HEIGHT = 32;
|
|
12
|
+
const GROUP_LAYOUT_GAP_X = 32;
|
|
13
|
+
const GROUP_LAYOUT_GAP_Y = 32;
|
|
14
|
+
const GROUP_LAYOUT_MIN_ROW_WIDTH = 1200;
|
|
15
|
+
const GROUP_LAYOUT_MAX_ROW_WIDTH = 1800;
|
|
16
|
+
const GROUP_TO_GROUP_GAP = 48;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compute bounding box for a group that should contain the given child rects.
|
|
20
|
+
* Returns position and size with padding, or null if no valid children.
|
|
21
|
+
*/
|
|
22
|
+
export function computeGroupBounds(
|
|
23
|
+
children: CanvasPlacementRect[],
|
|
24
|
+
defaultWidth = 600,
|
|
25
|
+
defaultHeight = 400,
|
|
26
|
+
): { x: number; y: number; width: number; height: number } | null {
|
|
27
|
+
if (children.length === 0) return null;
|
|
28
|
+
|
|
29
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
30
|
+
for (const child of children) {
|
|
31
|
+
minX = Math.min(minX, child.position.x);
|
|
32
|
+
minY = Math.min(minY, child.position.y);
|
|
33
|
+
maxX = Math.max(maxX, child.position.x + child.size.width);
|
|
34
|
+
maxY = Math.max(maxY, child.position.y + child.size.height);
|
|
35
|
+
}
|
|
36
|
+
if (minX === Infinity) return null;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
x: minX - GROUP_PAD,
|
|
40
|
+
y: minY - GROUP_PAD - GROUP_TITLEBAR_HEIGHT,
|
|
41
|
+
width: maxX - minX + GROUP_PAD * 2,
|
|
42
|
+
height: maxY - minY + GROUP_PAD * 2 + GROUP_TITLEBAR_HEIGHT,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function computePackedGroupLayout<T extends CanvasPlacementRect & { id: string }>(
|
|
47
|
+
children: T[],
|
|
48
|
+
): {
|
|
49
|
+
positions: Map<string, { x: number; y: number }>;
|
|
50
|
+
bounds: { x: number; y: number; width: number; height: number } | null;
|
|
51
|
+
} {
|
|
52
|
+
const positions = new Map<string, { x: number; y: number }>();
|
|
53
|
+
if (children.length === 0) return { positions, bounds: null };
|
|
54
|
+
|
|
55
|
+
const sorted = [...children].sort(
|
|
56
|
+
(a, b) => a.position.y - b.position.y || a.position.x - b.position.x,
|
|
57
|
+
);
|
|
58
|
+
const totalArea = sorted.reduce((sum, child) => sum + child.size.width * child.size.height, 0);
|
|
59
|
+
const widestChild = sorted.reduce((max, child) => Math.max(max, child.size.width), 0);
|
|
60
|
+
const targetRowWidth = Math.max(
|
|
61
|
+
widestChild,
|
|
62
|
+
Math.min(
|
|
63
|
+
GROUP_LAYOUT_MAX_ROW_WIDTH,
|
|
64
|
+
Math.max(GROUP_LAYOUT_MIN_ROW_WIDTH, Math.ceil(Math.sqrt(totalArea))),
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const startX = Math.min(...sorted.map((child) => child.position.x));
|
|
69
|
+
const startY = Math.min(...sorted.map((child) => child.position.y));
|
|
70
|
+
let cursorX = startX;
|
|
71
|
+
let cursorY = startY;
|
|
72
|
+
let rowHeight = 0;
|
|
73
|
+
|
|
74
|
+
for (const child of sorted) {
|
|
75
|
+
if (
|
|
76
|
+
cursorX > startX &&
|
|
77
|
+
cursorX + child.size.width > startX + targetRowWidth
|
|
78
|
+
) {
|
|
79
|
+
cursorX = startX;
|
|
80
|
+
cursorY += rowHeight + GROUP_LAYOUT_GAP_Y;
|
|
81
|
+
rowHeight = 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
positions.set(child.id, { x: cursorX, y: cursorY });
|
|
85
|
+
cursorX += child.size.width + GROUP_LAYOUT_GAP_X;
|
|
86
|
+
rowHeight = Math.max(rowHeight, child.size.height);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const bounds = computeGroupBounds(
|
|
90
|
+
sorted.map((child) => ({
|
|
91
|
+
position: positions.get(child.id) ?? child.position,
|
|
92
|
+
size: child.size,
|
|
93
|
+
})),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return { positions, bounds };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function resolveGroupCollision(
|
|
100
|
+
bounds: { x: number; y: number; width: number; height: number },
|
|
101
|
+
existing: CanvasPlacementRect[],
|
|
102
|
+
): { x: number; y: number } {
|
|
103
|
+
let candidate = { x: bounds.x, y: bounds.y };
|
|
104
|
+
let guard = 0;
|
|
105
|
+
|
|
106
|
+
while (overlapsAny(candidate, bounds.width, bounds.height, existing, GROUP_TO_GROUP_GAP) && guard < 100) {
|
|
107
|
+
const blocker = findBlocker(candidate, bounds.width, bounds.height, existing, GROUP_TO_GROUP_GAP);
|
|
108
|
+
if (!blocker) break;
|
|
109
|
+
|
|
110
|
+
const blockerCenterX = blocker.position.x + blocker.size.width / 2;
|
|
111
|
+
const moveRight = candidate.x >= blockerCenterX;
|
|
112
|
+
candidate = moveRight
|
|
113
|
+
? {
|
|
114
|
+
x: blocker.position.x + blocker.size.width + GROUP_TO_GROUP_GAP,
|
|
115
|
+
y: candidate.y,
|
|
116
|
+
}
|
|
117
|
+
: {
|
|
118
|
+
x: candidate.x,
|
|
119
|
+
y: blocker.position.y + blocker.size.height + GROUP_TO_GROUP_GAP,
|
|
120
|
+
};
|
|
121
|
+
guard += 1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return candidate;
|
|
125
|
+
}
|