pmx-canvas 0.4.5 → 0.4.7
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 +118 -0
- package/Readme.md +76 -36
- package/dist/canvas/global.css +169 -3
- package/dist/canvas/index.js +112 -87
- package/dist/canvas/mermaid-entry.js +3563 -0
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/smoke.d.ts +1 -0
- package/dist/types/cli/shared.d.ts +13 -1
- package/dist/types/client/canvas/CanvasNode.d.ts +11 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
- package/dist/types/client/mermaid-entry.d.ts +11 -0
- package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
- package/dist/types/client/nodes/DiffNode.d.ts +20 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +0 -2
- package/dist/types/client/nodes/FileNode.d.ts +14 -0
- package/dist/types/client/nodes/MermaidNode.d.ts +12 -0
- package/dist/types/client/state/canvas-store.d.ts +10 -0
- package/dist/types/client/state/intent-bridge.d.ts +9 -0
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/server/ax-flow-loop.d.ts +5 -0
- package/dist/types/server/ax-interaction.d.ts +21 -2
- package/dist/types/server/canvas-operations.d.ts +5 -0
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-state.d.ts +18 -1
- package/dist/types/server/canvas-validation.d.ts +28 -1
- package/dist/types/server/file-content.d.ts +46 -0
- package/dist/types/server/html-primitives.d.ts +13 -1
- package/dist/types/server/html-surface.d.ts +8 -0
- package/dist/types/server/index.d.ts +5 -0
- package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
- package/dist/types/server/operations/ops/nodes.d.ts +1 -1
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/web-artifacts.d.ts +14 -0
- package/dist/types/server/workboard.d.ts +46 -0
- package/dist/types/shared/ax-flow.d.ts +51 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
- package/dist/types/shared/node-sizes.d.ts +33 -0
- package/docs/cli.md +31 -2
- package/docs/environment.md +1 -0
- package/docs/http-api.md +29 -0
- package/docs/mcp.md +13 -1
- package/docs/node-types.md +47 -1
- package/docs/screenshot.png +0 -0
- package/package.json +5 -4
- package/skills/pmx-canvas/SKILL.md +123 -25
- package/skills/pmx-canvas/evals/evals.json +54 -3
- package/skills/pmx-canvas/references/full-reference.md +11 -6
- package/skills/pmx-canvas-testing/SKILL.md +3 -0
- package/src/cli/agent.ts +2 -0
- package/src/cli/commands/ax.ts +28 -3
- package/src/cli/commands/history.ts +1 -1
- package/src/cli/commands/nodes.ts +2 -2
- package/src/cli/commands/query.ts +1 -1
- package/src/cli/commands/smoke.ts +212 -0
- package/src/cli/commands/view.ts +1 -1
- package/src/cli/index.ts +7 -0
- package/src/cli/shared.ts +35 -5
- package/src/client/App.tsx +28 -15
- package/src/client/canvas/CanvasNode.tsx +54 -5
- package/src/client/canvas/CanvasViewport.tsx +6 -0
- package/src/client/canvas/EdgeLayer.tsx +44 -24
- package/src/client/canvas/ExpandedNodeOverlay.tsx +21 -0
- package/src/client/canvas/Minimap.tsx +2 -0
- package/src/client/canvas/auto-fit.ts +12 -1
- package/src/client/canvas/use-pan-zoom.ts +16 -1
- package/src/client/icons.tsx +4 -0
- package/src/client/mermaid-entry.ts +76 -0
- package/src/client/nodes/AxStepControls.tsx +226 -0
- package/src/client/nodes/DiffNode.tsx +114 -0
- package/src/client/nodes/ExtAppFrame.tsx +259 -84
- package/src/client/nodes/FileNode.tsx +249 -51
- package/src/client/nodes/MermaidNode.tsx +81 -0
- package/src/client/state/canvas-store.ts +19 -0
- package/src/client/state/intent-bridge.ts +32 -0
- package/src/client/state/node-factory.ts +2 -0
- package/src/client/state/sse-bridge.ts +32 -1
- package/src/client/theme/global.css +169 -3
- package/src/client/types.ts +6 -0
- package/src/mcp/server.ts +19 -1
- package/src/server/ax-flow-loop.ts +114 -0
- package/src/server/ax-interaction.ts +305 -4
- package/src/server/canvas-operations.ts +83 -26
- package/src/server/canvas-provenance.ts +2 -0
- package/src/server/canvas-schema.ts +33 -0
- package/src/server/canvas-state.ts +46 -10
- package/src/server/canvas-validation.ts +88 -0
- package/src/server/demo-state.json +4605 -523
- package/src/server/demo.ts +24 -0
- package/src/server/file-content.ts +152 -0
- package/src/server/file-watcher.ts +7 -10
- package/src/server/html-primitives.ts +636 -0
- package/src/server/html-surface.ts +11 -0
- package/src/server/index.ts +25 -6
- package/src/server/operations/composites.ts +3 -2
- package/src/server/operations/ops/ax-work.ts +5 -0
- package/src/server/operations/ops/batch.ts +8 -0
- package/src/server/operations/ops/json-render.ts +44 -0
- package/src/server/operations/ops/nodes.ts +24 -2
- package/src/server/operations/ops/viewport.ts +15 -2
- package/src/server/server.ts +154 -6
- package/src/server/web-artifacts.ts +109 -0
- package/src/server/workboard.ts +159 -0
- package/src/shared/ax-flow.ts +102 -0
- package/src/shared/ax-surface-protocol.ts +8 -0
- package/src/shared/node-sizes.ts +52 -0
- package/src/shared/surface.ts +2 -0
- package/dist/types/server/chart-template.d.ts +0 -29
- package/src/server/chart-template.ts +0 -448
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from 'preact/hooks';
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
|
|
2
2
|
import { updateNodeData } from '../state/canvas-store';
|
|
3
|
+
import { iframeMode } from '../state/iframe-mode';
|
|
3
4
|
import { fetchFile, updateNodeFromClient } from '../state/intent-bridge';
|
|
4
5
|
import type { CanvasNodeState } from '../types';
|
|
5
6
|
import { runNodeAxInteraction } from './ax-node-actions';
|
|
@@ -39,20 +40,125 @@ function langFromPath(path: string): string {
|
|
|
39
40
|
return map[ext] ?? (ext.toUpperCase() || 'Text');
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/** Rendered-row ceiling for delimited files — a 200k-row CSV must not lock the browser. */
|
|
44
|
+
const MAX_TABLE_ROWS = 500;
|
|
45
|
+
|
|
46
|
+
export interface DelimitedTable {
|
|
47
|
+
header: string[];
|
|
48
|
+
/** Rectangular: every row padded (short) or truncated (long) to the header width. */
|
|
49
|
+
rows: string[][];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Column delimiter for a delimited-data path, or null when the file is not one. */
|
|
53
|
+
function delimiterForPath(path: string): string | null {
|
|
54
|
+
const lower = path.toLowerCase();
|
|
55
|
+
if (lower.endsWith('.csv')) return ',';
|
|
56
|
+
if (lower.endsWith('.tsv')) return '\t';
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* RFC4180-ish delimited-text parser. Quoted fields may contain the delimiter,
|
|
62
|
+
* newlines, and `""` escapes; CRLF, LF and lone CR all end a record; a trailing
|
|
63
|
+
* newline produces no phantom empty row. Ragged records are squared off against
|
|
64
|
+
* the header width instead of throwing.
|
|
65
|
+
*/
|
|
66
|
+
export function parseDelimitedText(text: string, delimiter: string): DelimitedTable {
|
|
67
|
+
const records: string[][] = [];
|
|
68
|
+
let row: string[] = [];
|
|
69
|
+
let field = '';
|
|
70
|
+
let quoted = false;
|
|
71
|
+
// Tracks whether the current record consumed anything — distinguishes a real
|
|
72
|
+
// empty-quoted record from the dangling state after a terminating newline.
|
|
73
|
+
let started = false;
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < text.length; i++) {
|
|
76
|
+
const char = text[i];
|
|
77
|
+
started = true;
|
|
78
|
+
if (quoted) {
|
|
79
|
+
if (char === '"') {
|
|
80
|
+
if (text[i + 1] === '"') {
|
|
81
|
+
field += '"';
|
|
82
|
+
i++;
|
|
83
|
+
} else {
|
|
84
|
+
quoted = false;
|
|
85
|
+
}
|
|
86
|
+
} else if (char === '\r' && text[i + 1] === '\n') {
|
|
87
|
+
field += '\n';
|
|
88
|
+
i++;
|
|
89
|
+
} else {
|
|
90
|
+
field += char;
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (char === '"' && field === '') {
|
|
95
|
+
quoted = true;
|
|
96
|
+
} else if (char === delimiter) {
|
|
97
|
+
row.push(field);
|
|
98
|
+
field = '';
|
|
99
|
+
} else if (char === '\n' || char === '\r') {
|
|
100
|
+
if (char === '\r' && text[i + 1] === '\n') i++;
|
|
101
|
+
row.push(field);
|
|
102
|
+
field = '';
|
|
103
|
+
records.push(row);
|
|
104
|
+
row = [];
|
|
105
|
+
started = false;
|
|
106
|
+
} else {
|
|
107
|
+
field += char;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (started) {
|
|
111
|
+
row.push(field);
|
|
112
|
+
records.push(row);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const header = records.shift() ?? [];
|
|
116
|
+
return {
|
|
117
|
+
header,
|
|
118
|
+
rows: records.map((record) => Array.from({ length: header.length }, (_, i) => record[i] ?? '')),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Human-readable byte size, e.g. `4.2 MB`. */
|
|
123
|
+
export function formatBytes(bytes: number): string {
|
|
124
|
+
if (!Number.isFinite(bytes) || bytes < 0) return '';
|
|
125
|
+
if (bytes < 1024) return `${Math.round(bytes)} B`;
|
|
126
|
+
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
127
|
+
let value = bytes / 1024;
|
|
128
|
+
let unit = 0;
|
|
129
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
130
|
+
value /= 1024;
|
|
131
|
+
unit++;
|
|
132
|
+
}
|
|
133
|
+
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
42
136
|
export function FileNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
|
|
43
137
|
const filePath = (node.data.path as string) || (node.data.content as string) || '';
|
|
44
138
|
const title = (node.data.title as string) || filePath.split('/').pop() || 'File';
|
|
45
139
|
const cachedContent = node.data.fileContent as string | undefined;
|
|
46
140
|
const updatedAt = node.data.updatedAt as string | undefined;
|
|
47
141
|
const lineCount = node.data.lineCount as number | undefined;
|
|
142
|
+
const byteSize = node.data.byteSize as number | undefined;
|
|
143
|
+
const isBinary = node.data.binary === true;
|
|
144
|
+
const truncated = node.data.truncated === true;
|
|
145
|
+
const mimeType = node.data.mimeType as string | undefined;
|
|
146
|
+
const isPdf = mimeType === 'application/pdf' || filePath.toLowerCase().endsWith('.pdf');
|
|
147
|
+
// Binary bytes and PDFs are served raw — never fetched or decoded as text.
|
|
148
|
+
const servesBytes = isBinary || isPdf;
|
|
48
149
|
|
|
49
150
|
const [content, setContent] = useState<string>(cachedContent ?? '');
|
|
50
|
-
const [loading, setLoading] = useState(!cachedContent && !!filePath);
|
|
151
|
+
const [loading, setLoading] = useState(!cachedContent && !!filePath && !servesBytes);
|
|
51
152
|
const [error, setError] = useState<string | null>(null);
|
|
153
|
+
const [bytesVersion, setBytesVersion] = useState(0);
|
|
52
154
|
|
|
53
155
|
// Load file content on mount or when path changes
|
|
54
156
|
useEffect(() => {
|
|
55
157
|
if (!filePath) return;
|
|
158
|
+
if (servesBytes) {
|
|
159
|
+
setLoading(false);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
56
162
|
// If we already have cached content from SSE, use it
|
|
57
163
|
if (cachedContent !== undefined) {
|
|
58
164
|
setContent(cachedContent);
|
|
@@ -86,7 +192,7 @@ export function FileNode({ node, expanded = false }: { node: CanvasNodeState; ex
|
|
|
86
192
|
return () => {
|
|
87
193
|
cancelled = true;
|
|
88
194
|
};
|
|
89
|
-
}, [filePath, cachedContent]);
|
|
195
|
+
}, [filePath, cachedContent, servesBytes]);
|
|
90
196
|
|
|
91
197
|
// Sync content when server pushes updates via SSE
|
|
92
198
|
useEffect(() => {
|
|
@@ -97,6 +203,11 @@ export function FileNode({ node, expanded = false }: { node: CanvasNodeState; ex
|
|
|
97
203
|
|
|
98
204
|
const handleReload = useCallback(() => {
|
|
99
205
|
if (!filePath) return;
|
|
206
|
+
if (servesBytes) {
|
|
207
|
+
// Byte-served files have no cached text — bust the frame URL instead.
|
|
208
|
+
setBytesVersion((v) => v + 1);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
100
211
|
setLoading(true);
|
|
101
212
|
setError(null);
|
|
102
213
|
// Clear cached content to force a fresh fetch
|
|
@@ -125,11 +236,31 @@ export function FileNode({ node, expanded = false }: { node: CanvasNodeState; ex
|
|
|
125
236
|
setError('Failed to reload');
|
|
126
237
|
setLoading(false);
|
|
127
238
|
});
|
|
128
|
-
}, [filePath, node.id]);
|
|
239
|
+
}, [filePath, node.id, servesBytes]);
|
|
129
240
|
|
|
130
241
|
const lang = langFromPath(filePath);
|
|
131
242
|
const lines = content.split('\n');
|
|
132
243
|
const gutterWidth = `${String(lines.length).length + 1}ch`;
|
|
244
|
+
const delimiter = delimiterForPath(filePath);
|
|
245
|
+
// A .csv that is not actually delimited (single column) stays plain text.
|
|
246
|
+
const table = useMemo(() => {
|
|
247
|
+
if (!delimiter || servesBytes || !content) return null;
|
|
248
|
+
const parsed = parseDelimitedText(content, delimiter);
|
|
249
|
+
return parsed.header.length >= 2 ? parsed : null;
|
|
250
|
+
}, [content, delimiter, servesBytes]);
|
|
251
|
+
const bytesUrl = `/api/canvas/file-bytes?nodeId=${encodeURIComponent(node.id)}${
|
|
252
|
+
bytesVersion ? `&v=${bytesVersion}` : ''
|
|
253
|
+
}`;
|
|
254
|
+
// Amp orb portals block src-URL child iframes and force srcdoc, which cannot
|
|
255
|
+
// inline a PDF — offer the raw URL instead of a dead frame.
|
|
256
|
+
const pdfFrameBlocked = iframeMode.value === 'srcdoc';
|
|
257
|
+
const fillsBody = servesBytes || table !== null;
|
|
258
|
+
|
|
259
|
+
const truncatedNotice = truncated ? (
|
|
260
|
+
<div class="file-node-notice">
|
|
261
|
+
Showing the first {formatBytes(content.length)} of {formatBytes(byteSize ?? content.length)}
|
|
262
|
+
</div>
|
|
263
|
+
) : null;
|
|
133
264
|
|
|
134
265
|
if (!filePath) {
|
|
135
266
|
return <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>No file path set</div>;
|
|
@@ -236,57 +367,124 @@ export function FileNode({ node, expanded = false }: { node: CanvasNodeState; ex
|
|
|
236
367
|
<div
|
|
237
368
|
style={{
|
|
238
369
|
flex: 1,
|
|
239
|
-
|
|
240
|
-
|
|
370
|
+
minHeight: 0,
|
|
371
|
+
overflow: fillsBody ? 'hidden' : 'auto',
|
|
372
|
+
padding: fillsBody ? 0 : '8px 0',
|
|
241
373
|
background: expanded ? 'var(--c-panel-soft)' : undefined,
|
|
242
374
|
borderRadius: expanded ? '0 0 8px 8px' : undefined,
|
|
243
375
|
}}
|
|
244
376
|
>
|
|
245
|
-
{
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
377
|
+
{isPdf &&
|
|
378
|
+
(pdfFrameBlocked ? (
|
|
379
|
+
<div class="file-node-placeholder">
|
|
380
|
+
<div>PDF preview is unavailable in this embedded host.</div>
|
|
381
|
+
<a class="file-node-link" href={bytesUrl} target="_blank" rel="noreferrer">
|
|
382
|
+
Open PDF
|
|
383
|
+
</a>
|
|
384
|
+
</div>
|
|
385
|
+
) : (
|
|
386
|
+
<iframe
|
|
387
|
+
src={bytesUrl}
|
|
388
|
+
title={title}
|
|
389
|
+
// SECURITY: same-origin URL, so it must be sandboxed like every
|
|
390
|
+
// other iframe in the client. Without allow-same-origin the frame
|
|
391
|
+
// gets an opaque origin and cannot reach the canvas API even if
|
|
392
|
+
// the bytes turn out to be markup. The built-in PDF viewer needs
|
|
393
|
+
// no script permissions from us.
|
|
394
|
+
sandbox=""
|
|
395
|
+
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
|
|
396
|
+
/>
|
|
397
|
+
))}
|
|
398
|
+
{!isPdf && isBinary && (
|
|
399
|
+
<div class="file-node-placeholder">
|
|
400
|
+
<div class="file-node-placeholder-name">{title}</div>
|
|
401
|
+
<div>Binary file</div>
|
|
402
|
+
{byteSize !== undefined && <div>{formatBytes(byteSize)}</div>}
|
|
403
|
+
</div>
|
|
404
|
+
)}
|
|
405
|
+
{!servesBytes && (
|
|
406
|
+
<>
|
|
407
|
+
{loading && <div style={{ color: 'var(--c-dim)', padding: '12px', fontStyle: 'italic' }}>Loading…</div>}
|
|
408
|
+
{error && <div style={{ color: 'var(--c-danger)', padding: '12px' }}>{error}</div>}
|
|
409
|
+
{!loading &&
|
|
410
|
+
!error &&
|
|
411
|
+
(table ? (
|
|
412
|
+
<div class="file-table-shell">
|
|
413
|
+
{truncatedNotice}
|
|
414
|
+
{table.rows.length > MAX_TABLE_ROWS && (
|
|
415
|
+
<div class="file-node-notice">
|
|
416
|
+
Showing {MAX_TABLE_ROWS} of {table.rows.length} rows
|
|
417
|
+
</div>
|
|
418
|
+
)}
|
|
419
|
+
<div class="file-table-wrap">
|
|
420
|
+
<table class="file-table">
|
|
421
|
+
<thead>
|
|
422
|
+
<tr>
|
|
423
|
+
{table.header.map((cell, i) => (
|
|
424
|
+
<th key={i}>{cell}</th>
|
|
425
|
+
))}
|
|
426
|
+
</tr>
|
|
427
|
+
</thead>
|
|
428
|
+
<tbody>
|
|
429
|
+
{table.rows.slice(0, MAX_TABLE_ROWS).map((row, rowIndex) => (
|
|
430
|
+
<tr key={rowIndex}>
|
|
431
|
+
{row.map((cell, cellIndex) => (
|
|
432
|
+
<td key={cellIndex}>{cell}</td>
|
|
433
|
+
))}
|
|
434
|
+
</tr>
|
|
435
|
+
))}
|
|
436
|
+
</tbody>
|
|
437
|
+
</table>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
) : (
|
|
441
|
+
<>
|
|
442
|
+
{truncatedNotice}
|
|
443
|
+
<pre
|
|
444
|
+
style={{
|
|
445
|
+
margin: 0,
|
|
446
|
+
lineHeight: '1.55',
|
|
447
|
+
tabSize: 2,
|
|
448
|
+
}}
|
|
449
|
+
>
|
|
450
|
+
{lines.map((line, i) => (
|
|
451
|
+
<div
|
|
452
|
+
key={i}
|
|
453
|
+
style={{
|
|
454
|
+
display: 'flex',
|
|
455
|
+
minHeight: '1.55em',
|
|
456
|
+
}}
|
|
457
|
+
>
|
|
458
|
+
<span
|
|
459
|
+
style={{
|
|
460
|
+
width: gutterWidth,
|
|
461
|
+
minWidth: gutterWidth,
|
|
462
|
+
textAlign: 'right',
|
|
463
|
+
color: 'var(--c-dim)',
|
|
464
|
+
paddingRight: '12px',
|
|
465
|
+
paddingLeft: '10px',
|
|
466
|
+
userSelect: 'none',
|
|
467
|
+
flexShrink: 0,
|
|
468
|
+
opacity: 0.6,
|
|
469
|
+
}}
|
|
470
|
+
>
|
|
471
|
+
{i + 1}
|
|
472
|
+
</span>
|
|
473
|
+
<code
|
|
474
|
+
style={{
|
|
475
|
+
color: 'var(--c-text)',
|
|
476
|
+
whiteSpace: 'pre',
|
|
477
|
+
paddingRight: '10px',
|
|
478
|
+
}}
|
|
479
|
+
>
|
|
480
|
+
{line || '\n'}
|
|
481
|
+
</code>
|
|
482
|
+
</div>
|
|
483
|
+
))}
|
|
484
|
+
</pre>
|
|
485
|
+
</>
|
|
486
|
+
))}
|
|
487
|
+
</>
|
|
290
488
|
)}
|
|
291
489
|
</div>
|
|
292
490
|
</div>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef } from 'preact/hooks';
|
|
2
|
+
import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
|
|
3
|
+
import { canvasTheme } from '../state/canvas-store';
|
|
4
|
+
import type { CanvasNodeState } from '../types';
|
|
5
|
+
import { nodeSurfaceUrl, surfaceContentHash } from './surface-url';
|
|
6
|
+
import { useIframeContentHeight } from './use-iframe-content-height';
|
|
7
|
+
import { useSurfaceFrame } from './use-surface-frame';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Mermaid diagram node — a display-only iframe surface. The server renders the
|
|
11
|
+
* surface document (escaped diagram source + /canvas/mermaid-entry.js) at
|
|
12
|
+
* /api/canvas/surface/:id; the iframe and "Open as site" share that one render
|
|
13
|
+
* path, exactly like html nodes. Minimal subset of HtmlNode: theme push and
|
|
14
|
+
* content-height growth, no AX bridge, no presentation mode.
|
|
15
|
+
*/
|
|
16
|
+
export function MermaidNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
|
|
17
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
18
|
+
const theme = canvasTheme.value;
|
|
19
|
+
// Stable per-mount nonce that authorizes parent → iframe theme-update messages.
|
|
20
|
+
const themeToken = useMemo(() => `theme-${crypto.randomUUID()}`, []);
|
|
21
|
+
// Per-mount nonce for the content-height reporter (node grows to fit content).
|
|
22
|
+
const frameToken = useMemo(() => `frame-${crypto.randomUUID()}`, []);
|
|
23
|
+
const source = typeof node.data.content === 'string' ? node.data.content : '';
|
|
24
|
+
const v = useMemo(() => surfaceContentHash(source), [source]);
|
|
25
|
+
|
|
26
|
+
// `theme` is intentionally excluded from the deps: live theme changes are
|
|
27
|
+
// pushed via postMessage below (no reload), while `v` reloads the frame when
|
|
28
|
+
// the diagram source changes.
|
|
29
|
+
const surfaceSrc = useMemo(
|
|
30
|
+
() => (source ? nodeSurfaceUrl(node.id, { theme, themeToken, v, frameToken }) : ''),
|
|
31
|
+
[source, themeToken, v, node.id, frameToken],
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// src vs fetch()+srcdoc, decided by the boot-wide embed probe (Amp portals).
|
|
35
|
+
const surfaceFrame = useSurfaceFrame(surfaceSrc);
|
|
36
|
+
|
|
37
|
+
// Grow the node to fit the surface's reported content height (grow-only, gated).
|
|
38
|
+
// Never in the expanded overlay — there the surface fills the large overlay frame.
|
|
39
|
+
useIframeContentHeight(node, iframeRef, expanded ? '' : frameToken);
|
|
40
|
+
|
|
41
|
+
const pushTheme = () => {
|
|
42
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
43
|
+
{
|
|
44
|
+
source: HTML_SURFACE_PUSH_SOURCE,
|
|
45
|
+
type: 'theme-update',
|
|
46
|
+
token: themeToken,
|
|
47
|
+
theme,
|
|
48
|
+
},
|
|
49
|
+
'*',
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
useEffect(pushTheme, [theme, themeToken]);
|
|
54
|
+
|
|
55
|
+
if (!source) {
|
|
56
|
+
return <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>No diagram source set</div>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// SECURITY: sandbox stays `allow-scripts` ONLY — same tier as html surfaces
|
|
60
|
+
// (see HtmlNode for the full rationale); the surface route sends a matching
|
|
61
|
+
// CSP sandbox header.
|
|
62
|
+
return (
|
|
63
|
+
<iframe
|
|
64
|
+
ref={iframeRef}
|
|
65
|
+
class="html-node-frame"
|
|
66
|
+
title={typeof node.data.title === 'string' ? node.data.title : 'Mermaid diagram'}
|
|
67
|
+
sandbox="allow-scripts"
|
|
68
|
+
{...surfaceFrame}
|
|
69
|
+
onLoad={pushTheme}
|
|
70
|
+
style={{
|
|
71
|
+
width: '100%',
|
|
72
|
+
height: '100%',
|
|
73
|
+
minHeight: expanded ? '70vh' : '300px',
|
|
74
|
+
border: 'none',
|
|
75
|
+
background: 'var(--c-bg)',
|
|
76
|
+
borderRadius: '6px',
|
|
77
|
+
display: 'block',
|
|
78
|
+
}}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -466,6 +466,25 @@ function easeOutCubic(t: number): number {
|
|
|
466
466
|
* Cancels any in-flight animation. Direct manipulation (pan/zoom gestures)
|
|
467
467
|
* should use setViewport() instead for instant response.
|
|
468
468
|
*/
|
|
469
|
+
/**
|
|
470
|
+
* Zoom by a factor about the CENTRE of the viewport.
|
|
471
|
+
*
|
|
472
|
+
* The toolbar's +/- used to change `scale` alone and keep `x`/`y`, which anchors
|
|
473
|
+
* the zoom at the world origin — so zooming in visibly slid the board up-left and
|
|
474
|
+
* zooming out pushed it down-right, instead of magnifying what you were looking
|
|
475
|
+
* at. Same correction the pointer-anchored wheel zoom applies, with the viewport
|
|
476
|
+
* centre as the anchor.
|
|
477
|
+
*/
|
|
478
|
+
export function zoomByFactor(factor: number, duration = 150): void {
|
|
479
|
+
const v = viewport.value;
|
|
480
|
+
const scale = Math.min(4, Math.max(0.1, v.scale * factor));
|
|
481
|
+
if (scale === v.scale) return;
|
|
482
|
+
const ratio = scale / v.scale;
|
|
483
|
+
const cx = window.innerWidth / 2;
|
|
484
|
+
const cy = window.innerHeight / 2;
|
|
485
|
+
animateViewport({ x: cx - ratio * (cx - v.x), y: cy - ratio * (cy - v.y), scale }, duration);
|
|
486
|
+
}
|
|
487
|
+
|
|
469
488
|
export function animateViewport(
|
|
470
489
|
target: ViewportState,
|
|
471
490
|
duration = 300,
|
|
@@ -376,12 +376,44 @@ export async function updateViewportFromClient(
|
|
|
376
376
|
headers: { 'Content-Type': 'application/json' },
|
|
377
377
|
body: JSON.stringify({
|
|
378
378
|
...viewport,
|
|
379
|
+
...clientWindowSize(),
|
|
379
380
|
...(options.recordHistory === false ? { recordHistory: false } : {}),
|
|
380
381
|
}),
|
|
381
382
|
},
|
|
382
383
|
);
|
|
383
384
|
}
|
|
384
385
|
|
|
386
|
+
/**
|
|
387
|
+
* This window's size, sent alongside every viewport write so a later agent
|
|
388
|
+
* `fit` computes against the human's REAL window instead of a hardcoded
|
|
389
|
+
* 1440x900 (0.4.6 orb feedback #2: fit clipped nodes on both sides).
|
|
390
|
+
*/
|
|
391
|
+
function clientWindowSize(): { clientWidth: number; clientHeight: number } | Record<string, never> {
|
|
392
|
+
if (typeof window === 'undefined') return {};
|
|
393
|
+
const clientWidth = window.innerWidth;
|
|
394
|
+
const clientHeight = window.innerHeight;
|
|
395
|
+
if (!clientWidth || !clientHeight) return {};
|
|
396
|
+
return { clientWidth, clientHeight };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Report this window's size WITHOUT touching the stored viewport. The
|
|
401
|
+
* viewport op defaults every omitted coordinate to the server's CURRENT
|
|
402
|
+
* viewport, so a body carrying only the size records the size and leaves the
|
|
403
|
+
* viewport exactly as it was — never echoing a stale client viewport back over
|
|
404
|
+
* the server's. Called on connect and on resize so an agent `fit` has the real
|
|
405
|
+
* window size before the human ever pans or zooms.
|
|
406
|
+
*/
|
|
407
|
+
export async function reportClientViewportSize(): Promise<void> {
|
|
408
|
+
const size = clientWindowSize();
|
|
409
|
+
if (!('clientWidth' in size)) return;
|
|
410
|
+
await requestBestEffort('reportClientViewportSize', '/api/canvas/viewport', {
|
|
411
|
+
method: 'POST',
|
|
412
|
+
headers: { 'Content-Type': 'application/json' },
|
|
413
|
+
body: JSON.stringify({ ...size, recordHistory: false }),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
385
417
|
// ── Group API ─────────────────────────────────────────────────
|
|
386
418
|
|
|
387
419
|
/** Create a group containing the given child node IDs. */
|
|
@@ -34,6 +34,8 @@ export const DEFAULT_POSITIONS: Record<CanvasNodeState['type'], { x: number; y:
|
|
|
34
34
|
ledger: { x: 1130, y: 520, w: 320, h: 280 },
|
|
35
35
|
trace: { x: 40, y: 900, w: 200, h: 56 },
|
|
36
36
|
file: { x: 380, y: 80, w: 720, h: 600 },
|
|
37
|
+
diff: { x: 380, y: 80, w: 640, h: 420 },
|
|
38
|
+
mermaid: { x: 380, y: 80, w: 640, h: 460 },
|
|
37
39
|
image: { x: 380, y: 80, w: 720, h: 520 },
|
|
38
40
|
html: { x: 380, y: 80, w: 720, h: 640 },
|
|
39
41
|
group: { x: 220, y: 60, w: 840, h: 560 },
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
updateNodeData,
|
|
26
26
|
workbenchConnectionEpoch,
|
|
27
27
|
} from './canvas-store';
|
|
28
|
-
import { fetchAxSurfaceState } from './intent-bridge';
|
|
28
|
+
import { fetchAxSurfaceState, reportClientViewportSize } from './intent-bridge';
|
|
29
29
|
import { initSessionThemeOverride, themeOverrideActive } from './theme-override';
|
|
30
30
|
import { DEFAULT_POSITIONS, makeNodeState } from './node-factory';
|
|
31
31
|
import { invalidateTokenCache } from '../theme/tokens';
|
|
@@ -307,6 +307,8 @@ function isCanvasNodeType(value: unknown): value is CanvasNodeState['type'] {
|
|
|
307
307
|
value === 'ledger' ||
|
|
308
308
|
value === 'trace' ||
|
|
309
309
|
value === 'file' ||
|
|
310
|
+
value === 'diff' ||
|
|
311
|
+
value === 'mermaid' ||
|
|
310
312
|
value === 'image' ||
|
|
311
313
|
value === 'html' ||
|
|
312
314
|
value === 'group'
|
|
@@ -402,11 +404,40 @@ function parseCanvasAnnotation(raw: Record<string, unknown>): CanvasAnnotation |
|
|
|
402
404
|
}
|
|
403
405
|
|
|
404
406
|
// ── SSE event handlers ───────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Stale-SPA guard (0.4.5 report Finding W): a long-lived host panel keeps its
|
|
410
|
+
* in-memory bundle across a daemon upgrade — SSE reconnects silently and the
|
|
411
|
+
* old client renders none of the new release's behavior. The boot HTML stamps
|
|
412
|
+
* the server version at page-serve time; when a `connected` frame reports a
|
|
413
|
+
* DIFFERENT version, the server was upgraded under this page — reload once to
|
|
414
|
+
* pick up the new bundle. Guarded per version so a broken stamp can't loop.
|
|
415
|
+
*/
|
|
416
|
+
const VERSION_RELOAD_KEY = 'pmx-canvas-version-reload';
|
|
417
|
+
function reloadIfServerUpgraded(serverVersion: unknown): void {
|
|
418
|
+
if (typeof serverVersion !== 'string' || !serverVersion || serverVersion === 'unknown') return;
|
|
419
|
+
if (typeof window === 'undefined') return;
|
|
420
|
+
const bootVersion = (window as Window & { __PMX_BOOT_SERVER_VERSION?: unknown }).__PMX_BOOT_SERVER_VERSION;
|
|
421
|
+
if (typeof bootVersion !== 'string' || !bootVersion || bootVersion === 'unknown') return;
|
|
422
|
+
if (serverVersion === bootVersion) return;
|
|
423
|
+
try {
|
|
424
|
+
if (window.sessionStorage.getItem(VERSION_RELOAD_KEY) === serverVersion) return;
|
|
425
|
+
window.sessionStorage.setItem(VERSION_RELOAD_KEY, serverVersion);
|
|
426
|
+
} catch {
|
|
427
|
+
return; // No sessionStorage loop guard available — never risk a reload loop.
|
|
428
|
+
}
|
|
429
|
+
window.location.reload();
|
|
430
|
+
}
|
|
431
|
+
|
|
405
432
|
function handleConnected(data: Record<string, unknown>): void {
|
|
406
433
|
sessionId.value = (data.sessionId as string) || '';
|
|
407
434
|
connectionStatus.value = 'connected';
|
|
408
435
|
// Reconnect marker for holders of server-minted URLs (Finding S).
|
|
409
436
|
workbenchConnectionEpoch.value += 1;
|
|
437
|
+
reloadIfServerUpgraded(data.version);
|
|
438
|
+
// Tell the server how big this window actually is, so an agent `fit` sizes
|
|
439
|
+
// the board to the human's window (0.4.6 orb feedback #2).
|
|
440
|
+
void reportClientViewportSize();
|
|
410
441
|
// A ?theme= session override (host-default theming) wins over the
|
|
411
442
|
// server-global theme for THIS client only.
|
|
412
443
|
if (typeof data.theme === 'string' && !themeOverrideActive()) {
|