pmx-canvas 0.3.0 → 0.3.1
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/dist/canvas/index.js +2 -2
- package/dist/types/cli/daemon.d.ts +74 -0
- package/dist/types/cli/watch.d.ts +2 -2
- package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
- package/dist/types/client/canvas/CommandPalette.d.ts +1 -1
- package/dist/types/client/canvas/Minimap.d.ts +1 -1
- package/dist/types/client/nodes/FileNode.d.ts +1 -1
- package/dist/types/client/nodes/ImageNode.d.ts +1 -1
- package/dist/types/client/nodes/InlineFormatBar.d.ts +1 -1
- package/dist/types/client/nodes/MarkdownNode.d.ts +1 -1
- package/dist/types/client/nodes/PromptNode.d.ts +1 -1
- package/dist/types/client/nodes/ResponseNode.d.ts +1 -1
- package/dist/types/server/canvas-schema.d.ts +1 -1
- package/dist/types/server/html-primitives.d.ts +1 -1
- package/dist/types/server/index.d.ts +4 -4
- package/dist/types/server/operations/index.d.ts +1 -1
- package/dist/types/server/operations/ops/ax-read.d.ts +2 -0
- package/dist/types/server/operations/ops/canvas-wire.d.ts +2 -0
- package/dist/types/server/operations/ops/ext-app.d.ts +2 -0
- package/docs/http-api.md +28 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +5 -5
- package/docs/tech-debt-assessment-2026-07.md +2 -2
- package/package.json +5 -2
- package/skills/pmx-canvas/SKILL.md +3 -1
- package/skills/pmx-canvas/references/full-reference.md +10 -3
- package/src/cli/agent.ts +1861 -1548
- package/src/cli/daemon.ts +460 -0
- package/src/cli/index.ts +63 -326
- package/src/cli/watch.ts +2 -10
- package/src/client/App.tsx +48 -46
- package/src/client/canvas/AttentionHistory.tsx +11 -1
- package/src/client/canvas/CanvasNode.tsx +41 -29
- package/src/client/canvas/CanvasViewport.tsx +101 -66
- package/src/client/canvas/CommandPalette.tsx +61 -27
- package/src/client/canvas/ContextMenu.tsx +13 -20
- package/src/client/canvas/ContextPinBar.tsx +1 -5
- package/src/client/canvas/ContextPinHud.tsx +1 -6
- package/src/client/canvas/DockedNode.tsx +4 -4
- package/src/client/canvas/EdgeLayer.tsx +37 -36
- package/src/client/canvas/ExpandedNodeOverlay.tsx +69 -45
- package/src/client/canvas/FocusFieldLayer.tsx +20 -22
- package/src/client/canvas/IntentLayer.tsx +31 -14
- package/src/client/canvas/Minimap.tsx +11 -16
- package/src/client/canvas/SelectionBar.tsx +4 -11
- package/src/client/canvas/ShortcutOverlay.tsx +3 -1
- package/src/client/canvas/SnapshotPanel.tsx +77 -95
- package/src/client/canvas/auto-fit.ts +15 -14
- package/src/client/canvas/snap-guides.ts +12 -12
- package/src/client/canvas/use-node-resize.ts +1 -5
- package/src/client/canvas/use-pan-zoom.ts +25 -26
- package/src/client/ext-app/bridge.ts +3 -12
- package/src/client/icons.tsx +63 -20
- package/src/client/nodes/ContextNode.tsx +14 -25
- package/src/client/nodes/ExtAppFrame.tsx +60 -39
- package/src/client/nodes/FileNode.tsx +74 -62
- package/src/client/nodes/GroupNode.tsx +4 -6
- package/src/client/nodes/HtmlNode.tsx +76 -46
- package/src/client/nodes/ImageNode.tsx +18 -27
- package/src/client/nodes/InlineFormatBar.tsx +4 -21
- package/src/client/nodes/InlineMarkdownEditor.tsx +0 -1
- package/src/client/nodes/LedgerNode.tsx +10 -4
- package/src/client/nodes/MarkdownNode.tsx +3 -10
- package/src/client/nodes/McpAppNode.tsx +26 -22
- package/src/client/nodes/MdFormatBar.tsx +10 -7
- package/src/client/nodes/PromptNode.tsx +23 -51
- package/src/client/nodes/ResponseNode.tsx +3 -13
- package/src/client/nodes/StatusNode.tsx +5 -9
- package/src/client/nodes/StatusSummary.tsx +2 -8
- package/src/client/nodes/WebpageNode.tsx +20 -14
- package/src/client/nodes/iframe-document-url.ts +25 -16
- package/src/client/nodes/image-warnings.ts +1 -7
- package/src/client/nodes/md-format.ts +20 -5
- package/src/client/state/attention-bridge.ts +4 -9
- package/src/client/state/attention-store.ts +1 -7
- package/src/client/state/canvas-store.ts +52 -36
- package/src/client/state/intent-bridge.ts +176 -112
- package/src/client/state/intent-store.ts +4 -1
- package/src/client/state/sse-bridge.ts +53 -70
- package/src/json-render/catalog.ts +12 -16
- package/src/json-render/charts/components.tsx +16 -20
- package/src/json-render/charts/extra-components.tsx +8 -16
- package/src/json-render/charts/extra-definitions.ts +1 -2
- package/src/json-render/charts/tufte-components.tsx +37 -20
- package/src/json-render/charts/tufte-definitions.ts +8 -2
- package/src/json-render/renderer/index.tsx +42 -22
- package/src/json-render/schema.ts +6 -3
- package/src/json-render/server.ts +33 -39
- package/src/mcp/canvas-access.ts +35 -21
- package/src/mcp/server.ts +132 -70
- package/src/server/agent-context.ts +63 -36
- package/src/server/ax-context.ts +7 -5
- package/src/server/ax-interaction.ts +176 -43
- package/src/server/ax-state-manager.ts +182 -39
- package/src/server/ax-state.ts +142 -47
- package/src/server/canvas-db.ts +213 -95
- package/src/server/canvas-operations.ts +177 -120
- package/src/server/canvas-provenance.ts +1 -4
- package/src/server/canvas-schema.ts +454 -73
- package/src/server/canvas-serialization.ts +27 -35
- package/src/server/canvas-state.ts +150 -58
- package/src/server/chart-template.ts +4 -5
- package/src/server/code-graph.ts +19 -6
- package/src/server/diagram-presets.ts +28 -29
- package/src/server/ext-app-lookup.ts +3 -12
- package/src/server/html-node-summary.ts +19 -10
- package/src/server/html-primitives.ts +326 -97
- package/src/server/html-surface.ts +6 -9
- package/src/server/image-source.ts +6 -4
- package/src/server/index.ts +320 -217
- package/src/server/intent-registry.ts +2 -5
- package/src/server/mcp-app-candidate.ts +5 -10
- package/src/server/mcp-app-host.ts +14 -38
- package/src/server/mcp-app-runtime.ts +12 -20
- package/src/server/mutation-history.ts +15 -5
- package/src/server/operations/composites.ts +1 -3
- package/src/server/operations/http.ts +2 -3
- package/src/server/operations/index.ts +7 -1
- package/src/server/operations/invoker.ts +4 -3
- package/src/server/operations/mcp.ts +22 -30
- package/src/server/operations/ops/annotation.ts +122 -10
- package/src/server/operations/ops/app.ts +98 -73
- package/src/server/operations/ops/ax-await.ts +17 -10
- package/src/server/operations/ops/ax-read.ts +347 -0
- package/src/server/operations/ops/ax-shared.ts +2 -7
- package/src/server/operations/ops/ax-state.ts +32 -14
- package/src/server/operations/ops/ax-timeline.ts +32 -19
- package/src/server/operations/ops/ax-work.ts +54 -37
- package/src/server/operations/ops/batch.ts +39 -14
- package/src/server/operations/ops/canvas-wire.ts +91 -0
- package/src/server/operations/ops/edges.ts +37 -25
- package/src/server/operations/ops/ext-app.ts +346 -0
- package/src/server/operations/ops/groups.ts +49 -20
- package/src/server/operations/ops/intent.ts +18 -12
- package/src/server/operations/ops/json-render.ts +239 -98
- package/src/server/operations/ops/nodes.ts +298 -109
- package/src/server/operations/ops/query.ts +46 -28
- package/src/server/operations/ops/snapshots.ts +35 -26
- package/src/server/operations/ops/validate.ts +2 -1
- package/src/server/operations/ops/viewport.ts +60 -16
- package/src/server/operations/ops/webview.ts +44 -18
- package/src/server/operations/registry.ts +2 -3
- package/src/server/operations/types.ts +7 -5
- package/src/server/operations/webview-runner.ts +1 -3
- package/src/server/placement.ts +8 -18
- package/src/server/server.ts +108 -1027
- package/src/server/spatial-analysis.ts +39 -25
- package/src/server/trace-manager.ts +3 -8
- package/src/server/web-artifacts.ts +23 -27
- package/src/server/webpage-node.ts +5 -13
- package/src/shared/auto-arrange.ts +12 -5
- package/src/shared/content-height-reporter.ts +8 -6
- package/src/shared/ext-app-tool-result.ts +2 -6
- package/src/shared/placement.ts +1 -4
- package/src/shared/semantic-attention.ts +39 -37
- package/src/shared/surface.ts +8 -4
|
@@ -37,7 +37,7 @@ async function postJson<T>(url: string, body: Record<string, unknown>): Promise<
|
|
|
37
37
|
headers: { 'Content-Type': 'application/json' },
|
|
38
38
|
body: JSON.stringify(body),
|
|
39
39
|
});
|
|
40
|
-
const json = await response.json() as {
|
|
40
|
+
const json = (await response.json()) as {
|
|
41
41
|
ok: boolean;
|
|
42
42
|
result?: T;
|
|
43
43
|
error?: string;
|
|
@@ -72,7 +72,9 @@ export function nextWebkitRepaintSlot(): number {
|
|
|
72
72
|
const slot = webkitRepaintSlot++;
|
|
73
73
|
if (typeof window !== 'undefined') {
|
|
74
74
|
if (webkitRepaintSlotResetTimer) clearTimeout(webkitRepaintSlotResetTimer);
|
|
75
|
-
webkitRepaintSlotResetTimer = setTimeout(() => {
|
|
75
|
+
webkitRepaintSlotResetTimer = setTimeout(() => {
|
|
76
|
+
webkitRepaintSlot = 0;
|
|
77
|
+
}, 3000);
|
|
76
78
|
}
|
|
77
79
|
return slot;
|
|
78
80
|
}
|
|
@@ -128,9 +130,7 @@ export async function sendExtAppBootstrapState(
|
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
export function resolveExtAppSandbox(value: unknown): string {
|
|
131
|
-
return typeof value === 'string' && value.trim().length > 0
|
|
132
|
-
? value.trim()
|
|
133
|
-
: DEFAULT_EXT_APP_SANDBOX;
|
|
133
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : DEFAULT_EXT_APP_SANDBOX;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
export function buildExtAppAxBridgeScript(axToken: string, nodeId: string): string {
|
|
@@ -246,10 +246,12 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
246
246
|
const rawToolCallId = node.data.toolCallId;
|
|
247
247
|
const toolCallId: RequestId | undefined =
|
|
248
248
|
typeof rawToolCallId === 'string' || typeof rawToolCallId === 'number' ? rawToolCallId : undefined;
|
|
249
|
-
const resourceMeta = node.data.resourceMeta as
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
249
|
+
const resourceMeta = node.data.resourceMeta as
|
|
250
|
+
| {
|
|
251
|
+
csp?: Record<string, unknown>;
|
|
252
|
+
permissions?: Record<string, unknown>;
|
|
253
|
+
}
|
|
254
|
+
| undefined;
|
|
253
255
|
const sessionStatus = node.data.sessionStatus as string | undefined;
|
|
254
256
|
const sessionError = node.data.sessionError as string | undefined;
|
|
255
257
|
const maxHeight = node.size.height;
|
|
@@ -262,9 +264,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
262
264
|
const axCaps = node.data.axCapabilities as { enabled?: boolean } | undefined;
|
|
263
265
|
const axEnabled = axCaps?.enabled === true && typeof html === 'string' && html.length > 0;
|
|
264
266
|
const axToken = useMemo(() => `ax-${crypto.randomUUID()}`, []);
|
|
265
|
-
const axBridgeScript = axEnabled
|
|
266
|
-
? buildExtAppAxBridgeScript(axToken, nodeId)
|
|
267
|
-
: '';
|
|
267
|
+
const axBridgeScript = axEnabled ? buildExtAppAxBridgeScript(axToken, nodeId) : '';
|
|
268
268
|
const iframeDocument = useIframeDocument(injectExtAppAxBridgeScript(html ?? '', axBridgeScript), iframeSandbox);
|
|
269
269
|
|
|
270
270
|
useEffect(() => {
|
|
@@ -272,7 +272,10 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
272
272
|
function onAxMessage(event: MessageEvent) {
|
|
273
273
|
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
274
274
|
const data = event.data as {
|
|
275
|
-
source?: string;
|
|
275
|
+
source?: string;
|
|
276
|
+
token?: string;
|
|
277
|
+
nodeId?: string;
|
|
278
|
+
correlationId?: string;
|
|
276
279
|
interaction?: { type?: unknown; payload?: unknown };
|
|
277
280
|
} | null;
|
|
278
281
|
if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== nodeId) return;
|
|
@@ -289,13 +292,16 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
289
292
|
}).then((res) => {
|
|
290
293
|
if (res.ok) showToast('context', 'AX interaction', interactionType, [nodeId]);
|
|
291
294
|
else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [nodeId]);
|
|
292
|
-
iframeRef.current?.contentWindow?.postMessage(
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
295
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
296
|
+
{
|
|
297
|
+
source: 'pmx-canvas-ax-ack',
|
|
298
|
+
token: axToken,
|
|
299
|
+
...(data.correlationId ? { correlationId: data.correlationId } : {}),
|
|
300
|
+
interaction: { type: interactionType },
|
|
301
|
+
result: res,
|
|
302
|
+
},
|
|
303
|
+
'*',
|
|
304
|
+
);
|
|
299
305
|
});
|
|
300
306
|
}
|
|
301
307
|
window.addEventListener('message', onAxMessage);
|
|
@@ -336,9 +342,12 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
336
342
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
337
343
|
}, [status, hasReplayToolResult]);
|
|
338
344
|
|
|
339
|
-
useEffect(
|
|
340
|
-
|
|
341
|
-
|
|
345
|
+
useEffect(
|
|
346
|
+
() => () => {
|
|
347
|
+
if (webkitRepaintTimerRef.current !== null) window.clearTimeout(webkitRepaintTimerRef.current);
|
|
348
|
+
},
|
|
349
|
+
[],
|
|
350
|
+
);
|
|
342
351
|
|
|
343
352
|
const toMcpTheme = (theme: string): McpUiTheme => (theme === 'light' ? 'light' : 'dark');
|
|
344
353
|
const isExpanded = expanded || expandedNodeId.value === nodeId;
|
|
@@ -368,10 +377,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
368
377
|
if (lastSentToolResultRef.current === pendingToolResult) {
|
|
369
378
|
return null;
|
|
370
379
|
}
|
|
371
|
-
if (
|
|
372
|
-
lastSentToolResultRef.current &&
|
|
373
|
-
extAppToolResultsMatch(lastSentToolResultRef.current, pendingToolResult)
|
|
374
|
-
) {
|
|
380
|
+
if (lastSentToolResultRef.current && extAppToolResultsMatch(lastSentToolResultRef.current, pendingToolResult)) {
|
|
375
381
|
lastSentToolResultRef.current = pendingToolResult;
|
|
376
382
|
return null;
|
|
377
383
|
}
|
|
@@ -423,7 +429,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
423
429
|
throw new Error('Ext-app iframe window is unavailable');
|
|
424
430
|
}
|
|
425
431
|
|
|
426
|
-
const buildHostContext = (
|
|
432
|
+
const buildHostContext = (
|
|
433
|
+
displayMode: DisplayMode = expandedNodeId.value === nodeId ? 'fullscreen' : 'inline',
|
|
434
|
+
) => ({
|
|
427
435
|
theme: toMcpTheme(canvasTheme.value),
|
|
428
436
|
platform: 'web' as const,
|
|
429
437
|
containerDimensions: resolveExtAppContainerDimensions(iframe, {
|
|
@@ -433,12 +441,14 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
433
441
|
displayMode,
|
|
434
442
|
locale: navigator.language,
|
|
435
443
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
436
|
-
...(toolDefinition
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
444
|
+
...(toolDefinition
|
|
445
|
+
? {
|
|
446
|
+
toolInfo: {
|
|
447
|
+
id: toolCallId,
|
|
448
|
+
tool: toolDefinition,
|
|
449
|
+
},
|
|
450
|
+
}
|
|
451
|
+
: {}),
|
|
442
452
|
});
|
|
443
453
|
|
|
444
454
|
const scheduleHostContextUpdate = () => {
|
|
@@ -655,7 +665,10 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
655
665
|
// at fire time so the widget keeps its fullscreen/inline context accurate.
|
|
656
666
|
let firstFire = true;
|
|
657
667
|
themeUnsubRef.current = canvasTheme.subscribe((newTheme) => {
|
|
658
|
-
if (firstFire) {
|
|
668
|
+
if (firstFire) {
|
|
669
|
+
firstFire = false;
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
659
672
|
if (disposed) return;
|
|
660
673
|
bridge.setHostContext?.({
|
|
661
674
|
...buildHostContext(),
|
|
@@ -787,7 +800,17 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
787
800
|
}
|
|
788
801
|
|
|
789
802
|
return (
|
|
790
|
-
<div
|
|
803
|
+
<div
|
|
804
|
+
style={{
|
|
805
|
+
flex: 1,
|
|
806
|
+
width: '100%',
|
|
807
|
+
height: '100%',
|
|
808
|
+
minWidth: 0,
|
|
809
|
+
minHeight: 0,
|
|
810
|
+
display: 'flex',
|
|
811
|
+
flexDirection: 'column',
|
|
812
|
+
}}
|
|
813
|
+
>
|
|
791
814
|
{sessionStatus && sessionStatus !== 'ready' && (
|
|
792
815
|
<div
|
|
793
816
|
style={{
|
|
@@ -852,9 +875,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
852
875
|
</div>
|
|
853
876
|
)}
|
|
854
877
|
{status === 'loading' && (
|
|
855
|
-
<div style={{ padding: '8px', fontSize: '11px', color: 'var(--c-muted)' }}>
|
|
856
|
-
Connecting to ext-app viewer...
|
|
857
|
-
</div>
|
|
878
|
+
<div style={{ padding: '8px', fontSize: '11px', color: 'var(--c-muted)' }}>Connecting to ext-app viewer...</div>
|
|
858
879
|
)}
|
|
859
880
|
{/* Iframe stack: the widget renders a preview; when not expanded, a
|
|
860
881
|
transparent click-catcher sits on top so the first click always
|
|
@@ -8,20 +8,38 @@ import { runNodeAxInteraction } from './ax-node-actions';
|
|
|
8
8
|
function langFromPath(path: string): string {
|
|
9
9
|
const ext = path.split('.').pop()?.toLowerCase() ?? '';
|
|
10
10
|
const map: Record<string, string> = {
|
|
11
|
-
ts: 'TypeScript',
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
ts: 'TypeScript',
|
|
12
|
+
tsx: 'TSX',
|
|
13
|
+
js: 'JavaScript',
|
|
14
|
+
jsx: 'JSX',
|
|
15
|
+
py: 'Python',
|
|
16
|
+
rs: 'Rust',
|
|
17
|
+
go: 'Go',
|
|
18
|
+
rb: 'Ruby',
|
|
19
|
+
java: 'Java',
|
|
20
|
+
kt: 'Kotlin',
|
|
21
|
+
swift: 'Swift',
|
|
22
|
+
c: 'C',
|
|
23
|
+
cpp: 'C++',
|
|
24
|
+
h: 'C/C++',
|
|
25
|
+
css: 'CSS',
|
|
26
|
+
html: 'HTML',
|
|
27
|
+
json: 'JSON',
|
|
28
|
+
yaml: 'YAML',
|
|
29
|
+
yml: 'YAML',
|
|
30
|
+
md: 'Markdown',
|
|
31
|
+
toml: 'TOML',
|
|
32
|
+
sql: 'SQL',
|
|
33
|
+
sh: 'Shell',
|
|
34
|
+
bash: 'Shell',
|
|
35
|
+
xml: 'XML',
|
|
36
|
+
graphql: 'GraphQL',
|
|
37
|
+
proto: 'Protobuf',
|
|
17
38
|
};
|
|
18
39
|
return map[ext] ?? (ext.toUpperCase() || 'Text');
|
|
19
40
|
}
|
|
20
41
|
|
|
21
|
-
export function FileNode({
|
|
22
|
-
node,
|
|
23
|
-
expanded = false,
|
|
24
|
-
}: { node: CanvasNodeState; expanded?: boolean }) {
|
|
42
|
+
export function FileNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
|
|
25
43
|
const filePath = (node.data.path as string) || (node.data.content as string) || '';
|
|
26
44
|
const title = (node.data.title as string) || filePath.split('/').pop() || 'File';
|
|
27
45
|
const cachedContent = node.data.fileContent as string | undefined;
|
|
@@ -44,26 +62,30 @@ export function FileNode({
|
|
|
44
62
|
let cancelled = false;
|
|
45
63
|
setLoading(true);
|
|
46
64
|
setError(null);
|
|
47
|
-
fetchFile(filePath)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
65
|
+
fetchFile(filePath)
|
|
66
|
+
.then(({ content: fileText }) => {
|
|
67
|
+
if (cancelled) return;
|
|
68
|
+
if (!fileText && fileText !== '') {
|
|
69
|
+
setError('File not found');
|
|
70
|
+
setLoading(false);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
setContent(fileText);
|
|
51
74
|
setLoading(false);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
return () => { cancelled = true; };
|
|
75
|
+
// Cache content in node data so it survives re-renders
|
|
76
|
+
const lines = fileText.split('\n').length;
|
|
77
|
+
updateNodeData(node.id, { fileContent: fileText, lineCount: lines });
|
|
78
|
+
void updateNodeFromClient(node.id, { data: { fileContent: fileText, lineCount: lines } });
|
|
79
|
+
})
|
|
80
|
+
.catch(() => {
|
|
81
|
+
if (!cancelled) {
|
|
82
|
+
setError('Failed to load file');
|
|
83
|
+
setLoading(false);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
return () => {
|
|
87
|
+
cancelled = true;
|
|
88
|
+
};
|
|
67
89
|
}, [filePath, cachedContent]);
|
|
68
90
|
|
|
69
91
|
// Sync content when server pushes updates via SSE
|
|
@@ -80,27 +102,29 @@ export function FileNode({
|
|
|
80
102
|
// Clear cached content to force a fresh fetch
|
|
81
103
|
updateNodeData(node.id, { fileContent: undefined });
|
|
82
104
|
void updateNodeFromClient(node.id, { data: { fileContent: undefined } });
|
|
83
|
-
fetchFile(filePath)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
lineCount: lines,
|
|
91
|
-
updatedAt,
|
|
92
|
-
});
|
|
93
|
-
void updateNodeFromClient(node.id, {
|
|
94
|
-
data: {
|
|
105
|
+
fetchFile(filePath)
|
|
106
|
+
.then(({ content: fileText }) => {
|
|
107
|
+
setContent(fileText);
|
|
108
|
+
setLoading(false);
|
|
109
|
+
const lines = fileText.split('\n').length;
|
|
110
|
+
const updatedAt = new Date().toISOString();
|
|
111
|
+
updateNodeData(node.id, {
|
|
95
112
|
fileContent: fileText,
|
|
96
113
|
lineCount: lines,
|
|
97
114
|
updatedAt,
|
|
98
|
-
}
|
|
115
|
+
});
|
|
116
|
+
void updateNodeFromClient(node.id, {
|
|
117
|
+
data: {
|
|
118
|
+
fileContent: fileText,
|
|
119
|
+
lineCount: lines,
|
|
120
|
+
updatedAt,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
})
|
|
124
|
+
.catch(() => {
|
|
125
|
+
setError('Failed to reload');
|
|
126
|
+
setLoading(false);
|
|
99
127
|
});
|
|
100
|
-
}).catch(() => {
|
|
101
|
-
setError('Failed to reload');
|
|
102
|
-
setLoading(false);
|
|
103
|
-
});
|
|
104
128
|
}, [filePath, node.id]);
|
|
105
129
|
|
|
106
130
|
const lang = langFromPath(filePath);
|
|
@@ -108,11 +132,7 @@ export function FileNode({
|
|
|
108
132
|
const gutterWidth = `${String(lines.length).length + 1}ch`;
|
|
109
133
|
|
|
110
134
|
if (!filePath) {
|
|
111
|
-
return (
|
|
112
|
-
<div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>
|
|
113
|
-
No file path set
|
|
114
|
-
</div>
|
|
115
|
-
);
|
|
135
|
+
return <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>No file path set</div>;
|
|
116
136
|
}
|
|
117
137
|
|
|
118
138
|
return (
|
|
@@ -162,9 +182,7 @@ export function FileNode({
|
|
|
162
182
|
{filePath}
|
|
163
183
|
</span>
|
|
164
184
|
{lineCount !== undefined && (
|
|
165
|
-
<span style={{ color: 'var(--c-dim)', fontSize: '10px', flexShrink: 0 }}>
|
|
166
|
-
{lineCount} lines
|
|
167
|
-
</span>
|
|
185
|
+
<span style={{ color: 'var(--c-dim)', fontSize: '10px', flexShrink: 0 }}>{lineCount} lines</span>
|
|
168
186
|
)}
|
|
169
187
|
{updatedAt && (
|
|
170
188
|
<span style={{ color: 'var(--c-dim)', fontSize: '10px', flexShrink: 0 }}>
|
|
@@ -224,14 +242,8 @@ export function FileNode({
|
|
|
224
242
|
borderRadius: expanded ? '0 0 8px 8px' : undefined,
|
|
225
243
|
}}
|
|
226
244
|
>
|
|
227
|
-
{loading && (
|
|
228
|
-
|
|
229
|
-
Loading…
|
|
230
|
-
</div>
|
|
231
|
-
)}
|
|
232
|
-
{error && (
|
|
233
|
-
<div style={{ color: 'var(--c-danger)', padding: '12px' }}>{error}</div>
|
|
234
|
-
)}
|
|
245
|
+
{loading && <div style={{ color: 'var(--c-dim)', padding: '12px', fontStyle: 'italic' }}>Loading…</div>}
|
|
246
|
+
{error && <div style={{ color: 'var(--c-danger)', padding: '12px' }}>{error}</div>}
|
|
235
247
|
{!loading && !error && (
|
|
236
248
|
<pre
|
|
237
249
|
style={{
|
|
@@ -26,14 +26,12 @@ export function GroupNode({ node }: GroupNodeProps) {
|
|
|
26
26
|
return (
|
|
27
27
|
<div class="group-node-body">
|
|
28
28
|
<div class="group-summary">
|
|
29
|
-
<span class="group-child-count">
|
|
29
|
+
<span class="group-child-count">
|
|
30
|
+
{childCount} node{childCount !== 1 ? 's' : ''}
|
|
31
|
+
</span>
|
|
30
32
|
{typeSummary && <span class="group-type-summary">{typeSummary}</span>}
|
|
31
33
|
</div>
|
|
32
|
-
{childCount === 0 &&
|
|
33
|
-
<div class="group-empty-hint">
|
|
34
|
-
Drag nodes here or use the selection bar to group nodes
|
|
35
|
-
</div>
|
|
36
|
-
)}
|
|
34
|
+
{childCount === 0 && <div class="group-empty-hint">Drag nodes here or use the selection bar to group nodes</div>}
|
|
37
35
|
</div>
|
|
38
36
|
);
|
|
39
37
|
}
|
|
@@ -16,7 +16,13 @@ export function HtmlNode({
|
|
|
16
16
|
presentation = false,
|
|
17
17
|
presentationExitToken,
|
|
18
18
|
autoFocus = false,
|
|
19
|
-
}: {
|
|
19
|
+
}: {
|
|
20
|
+
node: CanvasNodeState;
|
|
21
|
+
expanded?: boolean;
|
|
22
|
+
presentation?: boolean;
|
|
23
|
+
presentationExitToken?: string;
|
|
24
|
+
autoFocus?: boolean;
|
|
25
|
+
}) {
|
|
20
26
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
21
27
|
const theme = canvasTheme.value;
|
|
22
28
|
// Stable per-mount nonce that authorizes parent → iframe theme-update messages.
|
|
@@ -25,11 +31,12 @@ export function HtmlNode({
|
|
|
25
31
|
const axToken = useMemo(() => `ax-${crypto.randomUUID()}`, []);
|
|
26
32
|
// Per-mount nonce for the content-height reporter (node grows to fit content).
|
|
27
33
|
const frameToken = useMemo(() => `frame-${crypto.randomUUID()}`, []);
|
|
28
|
-
const html =
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
const html =
|
|
35
|
+
typeof node.data.html === 'string'
|
|
36
|
+
? node.data.html
|
|
37
|
+
: typeof node.data.content === 'string'
|
|
38
|
+
? node.data.content
|
|
39
|
+
: '';
|
|
33
40
|
const v = useMemo(() => surfaceContentHash(html), [html]);
|
|
34
41
|
|
|
35
42
|
// The in-canvas iframe and the "Open as site" tab load the SAME server-rendered
|
|
@@ -38,9 +45,18 @@ export function HtmlNode({
|
|
|
38
45
|
// via postMessage below (no reload), while `v` reloads the frame when the HTML
|
|
39
46
|
// itself changes.
|
|
40
47
|
const surfaceSrc = useMemo(
|
|
41
|
-
() =>
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
() =>
|
|
49
|
+
html
|
|
50
|
+
? nodeSurfaceUrl(node.id, {
|
|
51
|
+
theme,
|
|
52
|
+
themeToken,
|
|
53
|
+
present: presentation,
|
|
54
|
+
presentToken: presentationExitToken,
|
|
55
|
+
v,
|
|
56
|
+
axToken,
|
|
57
|
+
frameToken,
|
|
58
|
+
})
|
|
59
|
+
: '',
|
|
44
60
|
[html, presentation, presentationExitToken, themeToken, v, node.id, axToken, frameToken],
|
|
45
61
|
);
|
|
46
62
|
|
|
@@ -57,7 +73,10 @@ export function HtmlNode({
|
|
|
57
73
|
// nodeId are a second gate, not the only one.
|
|
58
74
|
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
59
75
|
const data = event.data as {
|
|
60
|
-
source?: string;
|
|
76
|
+
source?: string;
|
|
77
|
+
token?: string;
|
|
78
|
+
nodeId?: string;
|
|
79
|
+
correlationId?: string;
|
|
61
80
|
interaction?: { type?: unknown; payload?: unknown };
|
|
62
81
|
} | null;
|
|
63
82
|
if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== node.id) return;
|
|
@@ -75,13 +94,16 @@ export function HtmlNode({
|
|
|
75
94
|
if (res.ok) showToast('context', 'AX interaction', interactionType, [node.id]);
|
|
76
95
|
else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [node.id]);
|
|
77
96
|
// Report #55: ack back to the surface so it can self-confirm (e.g. "queued ✓").
|
|
78
|
-
iframeRef.current?.contentWindow?.postMessage(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
97
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
98
|
+
{
|
|
99
|
+
source: 'pmx-canvas-ax-ack',
|
|
100
|
+
token: axToken,
|
|
101
|
+
...(data.correlationId ? { correlationId: data.correlationId } : {}),
|
|
102
|
+
interaction: { type: interactionType },
|
|
103
|
+
result: res,
|
|
104
|
+
},
|
|
105
|
+
'*',
|
|
106
|
+
);
|
|
85
107
|
});
|
|
86
108
|
}
|
|
87
109
|
window.addEventListener('message', onAxMessage);
|
|
@@ -89,12 +111,15 @@ export function HtmlNode({
|
|
|
89
111
|
}, [axToken, node.id]);
|
|
90
112
|
|
|
91
113
|
useEffect(() => {
|
|
92
|
-
iframeRef.current?.contentWindow?.postMessage(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
114
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
115
|
+
{
|
|
116
|
+
source: 'pmx-canvas-html-node',
|
|
117
|
+
type: 'theme-update',
|
|
118
|
+
token: themeToken,
|
|
119
|
+
theme,
|
|
120
|
+
},
|
|
121
|
+
'*',
|
|
122
|
+
);
|
|
98
123
|
if (autoFocus) iframeRef.current?.focus();
|
|
99
124
|
}, [theme, themeToken]);
|
|
100
125
|
|
|
@@ -107,12 +132,15 @@ export function HtmlNode({
|
|
|
107
132
|
const axStateValue = axSurfaceState.value;
|
|
108
133
|
useEffect(() => {
|
|
109
134
|
if (!axEnabled || axStateValue == null) return;
|
|
110
|
-
iframeRef.current?.contentWindow?.postMessage(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
135
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
136
|
+
{
|
|
137
|
+
source: 'pmx-canvas-html-node',
|
|
138
|
+
type: 'ax-update',
|
|
139
|
+
token: axToken,
|
|
140
|
+
state: axStateValue,
|
|
141
|
+
},
|
|
142
|
+
'*',
|
|
143
|
+
);
|
|
116
144
|
}, [axEnabled, axStateValue, axToken]);
|
|
117
145
|
|
|
118
146
|
useEffect(() => {
|
|
@@ -122,29 +150,31 @@ export function HtmlNode({
|
|
|
122
150
|
}, [autoFocus, surfaceSrc]);
|
|
123
151
|
|
|
124
152
|
const handleFrameLoad = () => {
|
|
125
|
-
iframeRef.current?.contentWindow?.postMessage(
|
|
126
|
-
|
|
127
|
-
type: 'theme-update',
|
|
128
|
-
token: themeToken,
|
|
129
|
-
theme,
|
|
130
|
-
}, '*');
|
|
131
|
-
if (axEnabled && axSurfaceState.value != null) {
|
|
132
|
-
iframeRef.current?.contentWindow?.postMessage({
|
|
153
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
154
|
+
{
|
|
133
155
|
source: 'pmx-canvas-html-node',
|
|
134
|
-
type: '
|
|
135
|
-
token:
|
|
136
|
-
|
|
137
|
-
},
|
|
156
|
+
type: 'theme-update',
|
|
157
|
+
token: themeToken,
|
|
158
|
+
theme,
|
|
159
|
+
},
|
|
160
|
+
'*',
|
|
161
|
+
);
|
|
162
|
+
if (axEnabled && axSurfaceState.value != null) {
|
|
163
|
+
iframeRef.current?.contentWindow?.postMessage(
|
|
164
|
+
{
|
|
165
|
+
source: 'pmx-canvas-html-node',
|
|
166
|
+
type: 'ax-update',
|
|
167
|
+
token: axToken,
|
|
168
|
+
state: axSurfaceState.value,
|
|
169
|
+
},
|
|
170
|
+
'*',
|
|
171
|
+
);
|
|
138
172
|
}
|
|
139
173
|
if (autoFocus) iframeRef.current?.focus();
|
|
140
174
|
};
|
|
141
175
|
|
|
142
176
|
if (!html) {
|
|
143
|
-
return (
|
|
144
|
-
<div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>
|
|
145
|
-
No HTML content set
|
|
146
|
-
</div>
|
|
147
|
-
);
|
|
177
|
+
return <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>No HTML content set</div>;
|
|
148
178
|
}
|
|
149
179
|
|
|
150
180
|
// SECURITY: sandbox is intentionally `allow-scripts` ONLY. Do NOT add
|
|
@@ -7,19 +7,17 @@ import { getImageNodeWarnings } from './image-warnings';
|
|
|
7
7
|
* Supports: file paths (served via /api/canvas/image/:nodeId), data URIs, and URLs.
|
|
8
8
|
* Features: fit-to-container, zoom in/out within node, pan when zoomed.
|
|
9
9
|
*/
|
|
10
|
-
export function ImageNode({
|
|
11
|
-
node,
|
|
12
|
-
expanded = false,
|
|
13
|
-
}: { node: CanvasNodeState; expanded?: boolean }) {
|
|
10
|
+
export function ImageNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
|
|
14
11
|
const src = (node.data.src as string) || '';
|
|
15
12
|
const alt = (node.data.alt as string) || (node.data.title as string) || 'Image';
|
|
16
13
|
const caption = (node.data.caption as string) || '';
|
|
17
14
|
const warnings = getImageNodeWarnings(node);
|
|
18
15
|
|
|
19
16
|
// Determine the image source URL
|
|
20
|
-
const imageSrc =
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
const imageSrc =
|
|
18
|
+
src.startsWith('data:') || src.startsWith('http://') || src.startsWith('https://')
|
|
19
|
+
? src
|
|
20
|
+
: `/api/canvas/image/${node.id}`;
|
|
23
21
|
|
|
24
22
|
const [loaded, setLoaded] = useState(false);
|
|
25
23
|
const [error, setError] = useState(false);
|
|
@@ -54,13 +52,16 @@ export function ImageNode({
|
|
|
54
52
|
setZoom((z) => Math.max(0.25, Math.min(10, z * delta)));
|
|
55
53
|
}, []);
|
|
56
54
|
|
|
57
|
-
const handlePointerDown = useCallback(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
55
|
+
const handlePointerDown = useCallback(
|
|
56
|
+
(e: PointerEvent) => {
|
|
57
|
+
if (zoom <= 1) return;
|
|
58
|
+
e.stopPropagation();
|
|
59
|
+
dragging.current = true;
|
|
60
|
+
lastPos.current = { x: e.clientX, y: e.clientY };
|
|
61
|
+
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
62
|
+
},
|
|
63
|
+
[zoom],
|
|
64
|
+
);
|
|
64
65
|
|
|
65
66
|
const handlePointerMove = useCallback((e: PointerEvent) => {
|
|
66
67
|
if (!dragging.current) return;
|
|
@@ -92,10 +93,7 @@ export function ImageNode({
|
|
|
92
93
|
const zoomPct = Math.round(zoom * 100);
|
|
93
94
|
|
|
94
95
|
return (
|
|
95
|
-
<div
|
|
96
|
-
class={`image-node ${expanded ? 'image-node-expanded' : ''}`}
|
|
97
|
-
ref={containerRef}
|
|
98
|
-
>
|
|
96
|
+
<div class={`image-node ${expanded ? 'image-node-expanded' : ''}`} ref={containerRef}>
|
|
99
97
|
{warnings.length > 0 && (
|
|
100
98
|
<div class="image-node-warning-stack">
|
|
101
99
|
{warnings.map((warning) => (
|
|
@@ -114,9 +112,7 @@ export function ImageNode({
|
|
|
114
112
|
onPointerUp={handlePointerUp}
|
|
115
113
|
style={{ cursor: zoom > 1 ? 'grab' : 'default' }}
|
|
116
114
|
>
|
|
117
|
-
{!loaded && !error &&
|
|
118
|
-
<div class="image-node-loading">Loading…</div>
|
|
119
|
-
)}
|
|
115
|
+
{!loaded && !error && <div class="image-node-loading">Loading…</div>}
|
|
120
116
|
{error && (
|
|
121
117
|
<div class="image-node-error">
|
|
122
118
|
<div class="image-node-error-icon">⚠</div>
|
|
@@ -143,12 +139,7 @@ export function ImageNode({
|
|
|
143
139
|
<span class="image-node-meta">
|
|
144
140
|
{sizeLabel && <span>{sizeLabel}</span>}
|
|
145
141
|
{zoom !== 1 && (
|
|
146
|
-
<button
|
|
147
|
-
type="button"
|
|
148
|
-
class="image-node-zoom-reset"
|
|
149
|
-
onClick={resetView}
|
|
150
|
-
title="Reset zoom"
|
|
151
|
-
>
|
|
142
|
+
<button type="button" class="image-node-zoom-reset" onClick={resetView} title="Reset zoom">
|
|
152
143
|
{zoomPct}% ↺
|
|
153
144
|
</button>
|
|
154
145
|
)}
|