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.
Files changed (156) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/canvas/index.js +2 -2
  3. package/dist/types/cli/daemon.d.ts +74 -0
  4. package/dist/types/cli/watch.d.ts +2 -2
  5. package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
  6. package/dist/types/client/canvas/CommandPalette.d.ts +1 -1
  7. package/dist/types/client/canvas/Minimap.d.ts +1 -1
  8. package/dist/types/client/nodes/FileNode.d.ts +1 -1
  9. package/dist/types/client/nodes/ImageNode.d.ts +1 -1
  10. package/dist/types/client/nodes/InlineFormatBar.d.ts +1 -1
  11. package/dist/types/client/nodes/MarkdownNode.d.ts +1 -1
  12. package/dist/types/client/nodes/PromptNode.d.ts +1 -1
  13. package/dist/types/client/nodes/ResponseNode.d.ts +1 -1
  14. package/dist/types/server/canvas-schema.d.ts +1 -1
  15. package/dist/types/server/html-primitives.d.ts +1 -1
  16. package/dist/types/server/index.d.ts +4 -4
  17. package/dist/types/server/operations/index.d.ts +1 -1
  18. package/dist/types/server/operations/ops/ax-read.d.ts +2 -0
  19. package/dist/types/server/operations/ops/canvas-wire.d.ts +2 -0
  20. package/dist/types/server/operations/ops/ext-app.d.ts +2 -0
  21. package/docs/http-api.md +28 -0
  22. package/docs/plans/plan-009-tech-debt-backlog.md +5 -5
  23. package/docs/tech-debt-assessment-2026-07.md +2 -2
  24. package/package.json +5 -2
  25. package/skills/pmx-canvas/SKILL.md +3 -1
  26. package/skills/pmx-canvas/references/full-reference.md +10 -3
  27. package/src/cli/agent.ts +1861 -1548
  28. package/src/cli/daemon.ts +460 -0
  29. package/src/cli/index.ts +63 -326
  30. package/src/cli/watch.ts +2 -10
  31. package/src/client/App.tsx +48 -46
  32. package/src/client/canvas/AttentionHistory.tsx +11 -1
  33. package/src/client/canvas/CanvasNode.tsx +41 -29
  34. package/src/client/canvas/CanvasViewport.tsx +101 -66
  35. package/src/client/canvas/CommandPalette.tsx +61 -27
  36. package/src/client/canvas/ContextMenu.tsx +13 -20
  37. package/src/client/canvas/ContextPinBar.tsx +1 -5
  38. package/src/client/canvas/ContextPinHud.tsx +1 -6
  39. package/src/client/canvas/DockedNode.tsx +4 -4
  40. package/src/client/canvas/EdgeLayer.tsx +37 -36
  41. package/src/client/canvas/ExpandedNodeOverlay.tsx +69 -45
  42. package/src/client/canvas/FocusFieldLayer.tsx +20 -22
  43. package/src/client/canvas/IntentLayer.tsx +31 -14
  44. package/src/client/canvas/Minimap.tsx +11 -16
  45. package/src/client/canvas/SelectionBar.tsx +4 -11
  46. package/src/client/canvas/ShortcutOverlay.tsx +3 -1
  47. package/src/client/canvas/SnapshotPanel.tsx +77 -95
  48. package/src/client/canvas/auto-fit.ts +15 -14
  49. package/src/client/canvas/snap-guides.ts +12 -12
  50. package/src/client/canvas/use-node-resize.ts +1 -5
  51. package/src/client/canvas/use-pan-zoom.ts +25 -26
  52. package/src/client/ext-app/bridge.ts +3 -12
  53. package/src/client/icons.tsx +63 -20
  54. package/src/client/nodes/ContextNode.tsx +14 -25
  55. package/src/client/nodes/ExtAppFrame.tsx +60 -39
  56. package/src/client/nodes/FileNode.tsx +74 -62
  57. package/src/client/nodes/GroupNode.tsx +4 -6
  58. package/src/client/nodes/HtmlNode.tsx +76 -46
  59. package/src/client/nodes/ImageNode.tsx +18 -27
  60. package/src/client/nodes/InlineFormatBar.tsx +4 -21
  61. package/src/client/nodes/InlineMarkdownEditor.tsx +0 -1
  62. package/src/client/nodes/LedgerNode.tsx +10 -4
  63. package/src/client/nodes/MarkdownNode.tsx +3 -10
  64. package/src/client/nodes/McpAppNode.tsx +26 -22
  65. package/src/client/nodes/MdFormatBar.tsx +10 -7
  66. package/src/client/nodes/PromptNode.tsx +23 -51
  67. package/src/client/nodes/ResponseNode.tsx +3 -13
  68. package/src/client/nodes/StatusNode.tsx +5 -9
  69. package/src/client/nodes/StatusSummary.tsx +2 -8
  70. package/src/client/nodes/WebpageNode.tsx +20 -14
  71. package/src/client/nodes/iframe-document-url.ts +25 -16
  72. package/src/client/nodes/image-warnings.ts +1 -7
  73. package/src/client/nodes/md-format.ts +20 -5
  74. package/src/client/state/attention-bridge.ts +4 -9
  75. package/src/client/state/attention-store.ts +1 -7
  76. package/src/client/state/canvas-store.ts +52 -36
  77. package/src/client/state/intent-bridge.ts +176 -112
  78. package/src/client/state/intent-store.ts +4 -1
  79. package/src/client/state/sse-bridge.ts +53 -70
  80. package/src/json-render/catalog.ts +12 -16
  81. package/src/json-render/charts/components.tsx +16 -20
  82. package/src/json-render/charts/extra-components.tsx +8 -16
  83. package/src/json-render/charts/extra-definitions.ts +1 -2
  84. package/src/json-render/charts/tufte-components.tsx +37 -20
  85. package/src/json-render/charts/tufte-definitions.ts +8 -2
  86. package/src/json-render/renderer/index.tsx +42 -22
  87. package/src/json-render/schema.ts +6 -3
  88. package/src/json-render/server.ts +33 -39
  89. package/src/mcp/canvas-access.ts +35 -21
  90. package/src/mcp/server.ts +132 -70
  91. package/src/server/agent-context.ts +63 -36
  92. package/src/server/ax-context.ts +7 -5
  93. package/src/server/ax-interaction.ts +176 -43
  94. package/src/server/ax-state-manager.ts +182 -39
  95. package/src/server/ax-state.ts +142 -47
  96. package/src/server/canvas-db.ts +213 -95
  97. package/src/server/canvas-operations.ts +177 -120
  98. package/src/server/canvas-provenance.ts +1 -4
  99. package/src/server/canvas-schema.ts +454 -73
  100. package/src/server/canvas-serialization.ts +27 -35
  101. package/src/server/canvas-state.ts +150 -58
  102. package/src/server/chart-template.ts +4 -5
  103. package/src/server/code-graph.ts +19 -6
  104. package/src/server/diagram-presets.ts +28 -29
  105. package/src/server/ext-app-lookup.ts +3 -12
  106. package/src/server/html-node-summary.ts +19 -10
  107. package/src/server/html-primitives.ts +326 -97
  108. package/src/server/html-surface.ts +6 -9
  109. package/src/server/image-source.ts +6 -4
  110. package/src/server/index.ts +320 -217
  111. package/src/server/intent-registry.ts +2 -5
  112. package/src/server/mcp-app-candidate.ts +5 -10
  113. package/src/server/mcp-app-host.ts +14 -38
  114. package/src/server/mcp-app-runtime.ts +12 -20
  115. package/src/server/mutation-history.ts +15 -5
  116. package/src/server/operations/composites.ts +1 -3
  117. package/src/server/operations/http.ts +2 -3
  118. package/src/server/operations/index.ts +7 -1
  119. package/src/server/operations/invoker.ts +4 -3
  120. package/src/server/operations/mcp.ts +22 -30
  121. package/src/server/operations/ops/annotation.ts +122 -10
  122. package/src/server/operations/ops/app.ts +98 -73
  123. package/src/server/operations/ops/ax-await.ts +17 -10
  124. package/src/server/operations/ops/ax-read.ts +347 -0
  125. package/src/server/operations/ops/ax-shared.ts +2 -7
  126. package/src/server/operations/ops/ax-state.ts +32 -14
  127. package/src/server/operations/ops/ax-timeline.ts +32 -19
  128. package/src/server/operations/ops/ax-work.ts +54 -37
  129. package/src/server/operations/ops/batch.ts +39 -14
  130. package/src/server/operations/ops/canvas-wire.ts +91 -0
  131. package/src/server/operations/ops/edges.ts +37 -25
  132. package/src/server/operations/ops/ext-app.ts +346 -0
  133. package/src/server/operations/ops/groups.ts +49 -20
  134. package/src/server/operations/ops/intent.ts +18 -12
  135. package/src/server/operations/ops/json-render.ts +239 -98
  136. package/src/server/operations/ops/nodes.ts +298 -109
  137. package/src/server/operations/ops/query.ts +46 -28
  138. package/src/server/operations/ops/snapshots.ts +35 -26
  139. package/src/server/operations/ops/validate.ts +2 -1
  140. package/src/server/operations/ops/viewport.ts +60 -16
  141. package/src/server/operations/ops/webview.ts +44 -18
  142. package/src/server/operations/registry.ts +2 -3
  143. package/src/server/operations/types.ts +7 -5
  144. package/src/server/operations/webview-runner.ts +1 -3
  145. package/src/server/placement.ts +8 -18
  146. package/src/server/server.ts +108 -1027
  147. package/src/server/spatial-analysis.ts +39 -25
  148. package/src/server/trace-manager.ts +3 -8
  149. package/src/server/web-artifacts.ts +23 -27
  150. package/src/server/webpage-node.ts +5 -13
  151. package/src/shared/auto-arrange.ts +12 -5
  152. package/src/shared/content-height-reporter.ts +8 -6
  153. package/src/shared/ext-app-tool-result.ts +2 -6
  154. package/src/shared/placement.ts +1 -4
  155. package/src/shared/semantic-attention.ts +39 -37
  156. 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(() => { webkitRepaintSlot = 0; }, 3000);
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
- csp?: Record<string, unknown>;
251
- permissions?: Record<string, unknown>;
252
- } | undefined;
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; token?: string; nodeId?: string; correlationId?: 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
- source: 'pmx-canvas-ax-ack',
294
- token: axToken,
295
- ...(data.correlationId ? { correlationId: data.correlationId } : {}),
296
- interaction: { type: interactionType },
297
- result: res,
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
- if (webkitRepaintTimerRef.current !== null) window.clearTimeout(webkitRepaintTimerRef.current);
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 = (displayMode: DisplayMode = expandedNodeId.value === nodeId ? 'fullscreen' : 'inline') => ({
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
- toolInfo: {
438
- id: toolCallId,
439
- tool: toolDefinition,
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) { firstFire = false; return; }
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 style={{ flex: 1, width: '100%', height: '100%', minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
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', tsx: 'TSX', js: 'JavaScript', jsx: 'JSX',
12
- py: 'Python', rs: 'Rust', go: 'Go', rb: 'Ruby',
13
- java: 'Java', kt: 'Kotlin', swift: 'Swift', c: 'C', cpp: 'C++', h: 'C/C++',
14
- css: 'CSS', html: 'HTML', json: 'JSON', yaml: 'YAML', yml: 'YAML',
15
- md: 'Markdown', toml: 'TOML', sql: 'SQL', sh: 'Shell', bash: 'Shell',
16
- xml: 'XML', graphql: 'GraphQL', proto: 'Protobuf',
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).then(({ content: fileText }) => {
48
- if (cancelled) return;
49
- if (!fileText && fileText !== '') {
50
- setError('File not found');
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
- return;
53
- }
54
- setContent(fileText);
55
- setLoading(false);
56
- // Cache content in node data so it survives re-renders
57
- const lines = fileText.split('\n').length;
58
- updateNodeData(node.id, { fileContent: fileText, lineCount: lines });
59
- void updateNodeFromClient(node.id, { data: { fileContent: fileText, lineCount: lines } });
60
- }).catch(() => {
61
- if (!cancelled) {
62
- setError('Failed to load file');
63
- setLoading(false);
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).then(({ content: fileText }) => {
84
- setContent(fileText);
85
- setLoading(false);
86
- const lines = fileText.split('\n').length;
87
- const updatedAt = new Date().toISOString();
88
- updateNodeData(node.id, {
89
- fileContent: fileText,
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
- <div style={{ color: 'var(--c-dim)', padding: '12px', fontStyle: 'italic' }}>
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">{childCount} node{childCount !== 1 ? 's' : ''}</span>
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
- }: { node: CanvasNodeState; expanded?: boolean; presentation?: boolean; presentationExitToken?: string; autoFocus?: boolean }) {
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 = typeof node.data.html === 'string'
29
- ? node.data.html
30
- : typeof node.data.content === 'string'
31
- ? node.data.content
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
- () => (html
42
- ? nodeSurfaceUrl(node.id, { theme, themeToken, present: presentation, presentToken: presentationExitToken, v, axToken, frameToken })
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; token?: string; nodeId?: string; correlationId?: 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
- source: 'pmx-canvas-ax-ack',
80
- token: axToken,
81
- ...(data.correlationId ? { correlationId: data.correlationId } : {}),
82
- interaction: { type: interactionType },
83
- result: res,
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
- source: 'pmx-canvas-html-node',
94
- type: 'theme-update',
95
- token: themeToken,
96
- theme,
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
- source: 'pmx-canvas-html-node',
112
- type: 'ax-update',
113
- token: axToken,
114
- state: axStateValue,
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
- source: 'pmx-canvas-html-node',
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: 'ax-update',
135
- token: axToken,
136
- state: axSurfaceState.value,
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 = src.startsWith('data:') || src.startsWith('http://') || src.startsWith('https://')
21
- ? src
22
- : `/api/canvas/image/${node.id}`;
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((e: PointerEvent) => {
58
- if (zoom <= 1) return;
59
- e.stopPropagation();
60
- dragging.current = true;
61
- lastPos.current = { x: e.clientX, y: e.clientY };
62
- (e.target as HTMLElement).setPointerCapture(e.pointerId);
63
- }, [zoom]);
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
  )}