pmx-canvas 0.1.19 → 0.1.21

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 (65) hide show
  1. package/CHANGELOG.md +159 -0
  2. package/Readme.md +19 -6
  3. package/dist/canvas/global.css +123 -2
  4. package/dist/canvas/index.js +103 -68
  5. package/dist/json-render/index.js +109 -109
  6. package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
  7. package/dist/types/client/icons.d.ts +2 -0
  8. package/dist/types/client/nodes/HtmlNode.d.ts +12 -1
  9. package/dist/types/client/state/canvas-store.d.ts +2 -0
  10. package/dist/types/client/types.d.ts +3 -2
  11. package/dist/types/json-render/charts/components.d.ts +5 -1
  12. package/dist/types/json-render/renderer/index.d.ts +1 -0
  13. package/dist/types/json-render/server.d.ts +1 -0
  14. package/dist/types/mcp/canvas-access.d.ts +3 -0
  15. package/dist/types/server/canvas-operations.d.ts +4 -0
  16. package/dist/types/server/canvas-schema.d.ts +19 -3
  17. package/dist/types/server/canvas-serialization.d.ts +1 -0
  18. package/dist/types/server/canvas-state.d.ts +6 -2
  19. package/dist/types/server/html-node-summary.d.ts +2 -0
  20. package/dist/types/server/html-primitives.d.ts +42 -0
  21. package/dist/types/server/index.d.ts +26 -0
  22. package/docs/cli.md +4 -1
  23. package/docs/http-api.md +11 -1
  24. package/docs/mcp.md +10 -4
  25. package/docs/node-types.md +54 -4
  26. package/docs/screenshot.png +0 -0
  27. package/docs/sdk.md +12 -0
  28. package/package.json +1 -1
  29. package/skills/pmx-canvas/SKILL.md +17 -3
  30. package/skills/pmx-canvas/references/html-primitives.md +132 -0
  31. package/src/cli/agent.ts +159 -5
  32. package/src/cli/index.ts +1 -1
  33. package/src/client/App.tsx +21 -2
  34. package/src/client/canvas/AnnotationLayer.tsx +33 -12
  35. package/src/client/canvas/CanvasViewport.tsx +88 -7
  36. package/src/client/canvas/CommandPalette.tsx +2 -2
  37. package/src/client/canvas/ContextMenu.tsx +2 -2
  38. package/src/client/canvas/ExpandedNodeOverlay.tsx +112 -3
  39. package/src/client/canvas/auto-fit.ts +5 -1
  40. package/src/client/icons.tsx +13 -0
  41. package/src/client/nodes/HtmlNode.tsx +125 -13
  42. package/src/client/nodes/McpAppNode.tsx +12 -4
  43. package/src/client/state/canvas-store.ts +15 -5
  44. package/src/client/state/sse-bridge.ts +5 -4
  45. package/src/client/theme/global.css +123 -2
  46. package/src/client/types.ts +2 -1
  47. package/src/json-render/charts/components.tsx +41 -7
  48. package/src/json-render/charts/extra-components.tsx +13 -12
  49. package/src/json-render/renderer/index.tsx +1 -0
  50. package/src/json-render/server.ts +3 -1
  51. package/src/mcp/canvas-access.ts +54 -1
  52. package/src/mcp/server.ts +98 -28
  53. package/src/server/agent-context.ts +39 -0
  54. package/src/server/canvas-operations.ts +99 -38
  55. package/src/server/canvas-provenance.ts +8 -6
  56. package/src/server/canvas-schema.ts +94 -3
  57. package/src/server/canvas-serialization.ts +16 -4
  58. package/src/server/canvas-state.ts +9 -4
  59. package/src/server/demo-state.json +1143 -0
  60. package/src/server/demo.ts +25 -777
  61. package/src/server/html-node-summary.ts +141 -0
  62. package/src/server/html-primitives.ts +1300 -0
  63. package/src/server/index.ts +63 -3
  64. package/src/server/server.ts +154 -17
  65. package/src/server/spatial-analysis.ts +5 -3
@@ -102,6 +102,7 @@ function findAnnotationAtPoint(
102
102
  point.y < annotation.bounds.y - pad ||
103
103
  point.y > annotation.bounds.y + annotation.bounds.height + pad
104
104
  ) continue;
105
+ if (annotation.type === 'text') return annotation;
105
106
  for (let index = 1; index < annotation.points.length; index++) {
106
107
  const start = annotation.points[index - 1];
107
108
  const end = annotation.points[index];
@@ -122,19 +123,27 @@ interface LassoRect {
122
123
 
123
124
  interface AnnotationDraft {
124
125
  id: string;
125
- type: 'freehand';
126
+ type: 'freehand' | 'text';
126
127
  points: Array<{ x: number; y: number }>;
127
128
  bounds: { x: number; y: number; width: number; height: number };
128
129
  color: string;
129
130
  width: number;
131
+ text?: string;
130
132
  createdAt: string;
131
133
  }
132
134
 
135
+ interface TextAnnotationDraft {
136
+ x: number;
137
+ y: number;
138
+ value: string;
139
+ }
140
+
133
141
  const ANNOTATION_COLOR = 'currentColor';
134
142
  const ANNOTATION_WIDTH = 4;
143
+ const TEXT_ANNOTATION_WIDTH = 24;
135
144
  const ERASER_HIT_RADIUS = 14;
136
145
 
137
- type AnnotationTool = 'pen' | 'eraser' | null;
146
+ type AnnotationTool = 'pen' | 'eraser' | 'text' | null;
138
147
 
139
148
  interface CanvasViewportProps {
140
149
  onNodeContextMenu?: (e: MouseEvent, nodeId: string) => void;
@@ -233,6 +242,8 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
233
242
  const annotationPoints = useRef<Array<{ x: number; y: number }>>([]);
234
243
  const [lasso, setLasso] = useState<LassoRect | null>(null);
235
244
  const [draftAnnotation, setDraftAnnotation] = useState<AnnotationDraft | null>(null);
245
+ const [textDraft, setTextDraftState] = useState<TextAnnotationDraft | null>(null);
246
+ const textDraftRef = useRef<TextAnnotationDraft | null>(null);
236
247
  const [dropActive, setDropActive] = useState(false);
237
248
  const dropCounter = useRef(0);
238
249
  // Ref mirrors lasso state so pointer handlers always read the latest value
@@ -256,6 +267,11 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
256
267
  },
257
268
  });
258
269
 
270
+ const setTextDraft = useCallback((next: TextAnnotationDraft | null) => {
271
+ textDraftRef.current = next;
272
+ setTextDraftState(next);
273
+ }, []);
274
+
259
275
  const createWebpageNodes = useCallback(async (urls: string[], centerX: number, centerY: number) => {
260
276
  if (urls.length === 0) return;
261
277
 
@@ -305,6 +321,23 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
305
321
  return;
306
322
  }
307
323
 
324
+ if (annotationTool === 'text') {
325
+ const target = e.target instanceof Element ? e.target : null;
326
+ if (target?.closest('.hud-layer, .snapshot-panel, .context-menu, .command-palette')) return;
327
+ e.preventDefault();
328
+ e.stopPropagation();
329
+ activeNodeId.value = null;
330
+ clearSelection();
331
+ const rect = container.getBoundingClientRect();
332
+ const vp = viewport.value;
333
+ setTextDraft({
334
+ x: (e.clientX - rect.left - vp.x) / vp.scale,
335
+ y: (e.clientY - rect.top - vp.y) / vp.scale,
336
+ value: '',
337
+ });
338
+ return;
339
+ }
340
+
308
341
  if (annotationTool === 'pen') {
309
342
  const target = e.target instanceof Element ? e.target : null;
310
343
  if (target?.closest('.hud-layer, .snapshot-panel, .context-menu, .command-palette')) return;
@@ -450,11 +483,32 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
450
483
 
451
484
  useEffect(() => {
452
485
  if (annotationMode) return;
453
- if (!isAnnotating.current && !draftAnnotation) return;
486
+ if (!isAnnotating.current && !draftAnnotation && !textDraft) return;
454
487
  isAnnotating.current = false;
455
488
  annotationPoints.current = [];
456
489
  setDraftAnnotation(null);
457
- }, [annotationMode, draftAnnotation]);
490
+ setTextDraft(null);
491
+ }, [annotationMode, draftAnnotation, setTextDraft, textDraft]);
492
+
493
+ const commitTextDraft = useCallback(() => {
494
+ const draft = textDraftRef.current;
495
+ if (!draft) return;
496
+ const text = draft.value.trim();
497
+ setTextDraft(null);
498
+ if (!text) return;
499
+ const point = { x: draft.x, y: draft.y };
500
+ void createAnnotationFromClient({
501
+ type: 'text',
502
+ points: [point],
503
+ color: ANNOTATION_COLOR,
504
+ width: TEXT_ANNOTATION_WIDTH,
505
+ text,
506
+ });
507
+ }, [setTextDraft]);
508
+
509
+ useEffect(() => {
510
+ if (annotationTool !== 'text' && textDraft) setTextDraft(null);
511
+ }, [annotationTool, setTextDraft, textDraft]);
458
512
 
459
513
  // ── Drag-to-connect: track cursor in world space, hit-test on drop ──
460
514
  useEffect(() => {
@@ -517,8 +571,8 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
517
571
  const wx = (e.clientX - rect.left - v.x) / v.scale;
518
572
  const wy = (e.clientY - rect.top - v.y) / v.scale;
519
573
  // Offset so node centers on click point
520
- const nodeW = 360;
521
- const nodeH = 200;
574
+ const nodeW = 520;
575
+ const nodeH = 360;
522
576
  createNodeFromClient({
523
577
  type: 'markdown',
524
578
  title: 'New note',
@@ -697,6 +751,8 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
697
751
  overflow: 'hidden',
698
752
  cursor: annotationTool === 'eraser'
699
753
  ? 'cell'
754
+ : annotationTool === 'text'
755
+ ? 'text'
700
756
  : annotationMode || draggingEdge.value || isLassoing.current
701
757
  ? 'crosshair'
702
758
  : 'grab',
@@ -737,7 +793,32 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
737
793
  </svg>
738
794
  )}
739
795
  </div>
740
- {annotationMode && <div class={`annotation-capture-layer${annotationTool === 'eraser' ? ' erasing' : ''}`} aria-hidden="true" />}
796
+ {annotationMode && <div class={`annotation-capture-layer${annotationTool === 'eraser' ? ' erasing' : ''}${annotationTool === 'text' ? ' text' : ''}`} aria-hidden="true" />}
797
+ {textDraft && (
798
+ <input
799
+ class="annotation-text-input"
800
+ value={textDraft.value}
801
+ autoFocus
802
+ style={{
803
+ left: `${textDraft.x * v.scale + v.x}px`,
804
+ top: `${textDraft.y * v.scale + v.y}px`,
805
+ fontSize: `${TEXT_ANNOTATION_WIDTH * v.scale}px`,
806
+ }}
807
+ onInput={(e) => setTextDraft({ ...textDraft, value: (e.target as HTMLInputElement).value })}
808
+ onBlur={commitTextDraft}
809
+ onPointerDown={(e) => e.stopPropagation()}
810
+ onKeyDown={(e) => {
811
+ if (e.key === 'Enter') {
812
+ e.preventDefault();
813
+ commitTextDraft();
814
+ }
815
+ if (e.key === 'Escape') {
816
+ e.preventDefault();
817
+ setTextDraft(null);
818
+ }
819
+ }}
820
+ />
821
+ )}
741
822
  {lassoStyle && <div class="lasso-rect" style={lassoStyle} />}
742
823
  {dropActive && (
743
824
  <div class="drop-zone-overlay">
@@ -135,7 +135,7 @@ export function CommandPalette({
135
135
 
136
136
  // Action items
137
137
  const actions: Array<{ label: string; badge: string; action: () => void }> = [
138
- { label: 'New note (markdown node)', badge: 'CREATE', action: () => { createNodeFromClient({ type: 'markdown', title: 'New note' }); onClose(); } },
138
+ { label: 'New note (markdown node)', badge: 'CREATE', action: () => { createNodeFromClient({ type: 'markdown', title: 'New note', width: 520, height: 360 }); onClose(); } },
139
139
  { label: 'Fit all nodes', badge: 'VIEW', action: () => { fitAll(window.innerWidth, window.innerHeight); onClose(); } },
140
140
  { label: 'Auto-arrange (grid)', badge: 'LAYOUT', action: () => { autoArrange(); onClose(); } },
141
141
  { label: 'Auto-arrange (graph-aware)', badge: 'LAYOUT', action: () => { forceDirectedArrange(); onClose(); } },
@@ -145,9 +145,9 @@ export function CommandPalette({
145
145
  badge: 'THEME',
146
146
  action: () => {
147
147
  const next = canvasTheme.value === 'dark' ? 'light' : 'dark';
148
- canvasTheme.value = next;
149
148
  document.documentElement.setAttribute('data-theme', next);
150
149
  invalidateTokenCache();
150
+ canvasTheme.value = next;
151
151
  onClose();
152
152
  },
153
153
  },
@@ -319,8 +319,8 @@ function buildCanvasMenuItems(canvasX: number, canvasY: number): MenuItem[] {
319
319
  {
320
320
  label: 'New note',
321
321
  action: () => {
322
- const width = 360;
323
- const height = 200;
322
+ const width = 520;
323
+ const height = 360;
324
324
  const position = centeredPosition(canvasX, canvasY, width, height);
325
325
  void createNodeFromClient({
326
326
  type: 'markdown',
@@ -1,4 +1,4 @@
1
- import { useCallback, useState } from 'preact/hooks';
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
2
2
  import { ContextNode } from '../nodes/ContextNode';
3
3
  import { FileNode } from '../nodes/FileNode';
4
4
  import { LedgerNode } from '../nodes/LedgerNode';
@@ -7,7 +7,7 @@ import { McpAppNode } from '../nodes/McpAppNode';
7
7
  import { StatusNode } from '../nodes/StatusNode';
8
8
  import { ImageNode } from '../nodes/ImageNode';
9
9
  import { WebpageNode } from '../nodes/WebpageNode';
10
- import { HtmlNode } from '../nodes/HtmlNode';
10
+ import { HtmlNode, shouldShowPresentationControls } from '../nodes/HtmlNode';
11
11
  import { PromptNode } from '../nodes/PromptNode';
12
12
  import { ResponseNode } from '../nodes/ResponseNode';
13
13
  import { TraceNode } from '../nodes/TraceNode';
@@ -81,15 +81,62 @@ function wordCount(text: string): number {
81
81
  return text.split(/\s+/).filter(Boolean).length;
82
82
  }
83
83
 
84
+ function isPresentationExitMessage(value: unknown, token: string): boolean {
85
+ return value !== null &&
86
+ typeof value === 'object' &&
87
+ (value as { source?: unknown }).source === 'pmx-canvas-html-node' &&
88
+ (value as { type?: unknown }).type === 'presentation-exit' &&
89
+ (value as { token?: unknown }).token === token;
90
+ }
91
+
92
+ function isPresentationNavigationKey(key: string): boolean {
93
+ return key === 'ArrowRight' || key === 'PageDown' || key === ' ' || key === 'ArrowLeft' || key === 'PageUp' || key === 'Home' || key === 'End';
94
+ }
95
+
84
96
  export function ExpandedNodeOverlay() {
85
97
  const nodeId = expandedNodeId.value;
86
98
  const node = nodeId ? nodes.value.get(nodeId) : undefined;
87
99
  const [copied, setCopied] = useState(false);
100
+ const [presenting, setPresenting] = useState(false);
101
+ const [presentationExitToken, setPresentationExitToken] = useState('');
102
+ const presentationOverlayRef = useRef<HTMLDivElement>(null);
88
103
 
89
104
  const handleClose = useCallback(() => {
105
+ setPresenting(false);
90
106
  collapseExpandedNode();
91
107
  }, []);
92
108
 
109
+ const handlePresent = useCallback(() => {
110
+ setPresentationExitToken(`presentation-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`);
111
+ setPresenting(true);
112
+ }, []);
113
+
114
+ const postPresentationMessage = useCallback((message: Record<string, unknown>) => {
115
+ const frame = document.querySelector<HTMLIFrameElement>('.html-presentation-overlay iframe.html-node-frame-presentation');
116
+ frame?.contentWindow?.postMessage({
117
+ source: 'pmx-canvas-html-node',
118
+ token: presentationExitToken,
119
+ ...message,
120
+ }, '*');
121
+ }, [presentationExitToken]);
122
+
123
+ const handleExitPresentation = useCallback(() => {
124
+ setPresenting(false);
125
+ }, []);
126
+
127
+ const handlePresentationKeyDown = useCallback((event: KeyboardEvent) => {
128
+ if (event.key === 'Escape') {
129
+ event.preventDefault();
130
+ event.stopPropagation();
131
+ setPresenting(false);
132
+ return;
133
+ }
134
+ if (!isPresentationNavigationKey(event.key)) return;
135
+ event.preventDefault();
136
+ event.stopPropagation();
137
+ postPresentationMessage({ type: 'presentation-key', key: event.key });
138
+ }, [postPresentationMessage]);
139
+
93
140
  const handleBackdropPointerDown = useCallback((e: PointerEvent) => {
94
141
  if ((e.target as HTMLElement).classList.contains('expanded-overlay-backdrop')) {
95
142
  collapseExpandedNode();
@@ -111,6 +158,29 @@ export function ExpandedNodeOverlay() {
111
158
  toggleContextPin(nodeId);
112
159
  }, [nodeId]);
113
160
 
161
+ useEffect(() => {
162
+ setPresenting(false);
163
+ }, [nodeId]);
164
+
165
+ useLayoutEffect(() => {
166
+ if (!presenting) return;
167
+ const focusPresentationOverlay = () => {
168
+ presentationOverlayRef.current?.focus();
169
+ };
170
+ const focusTimers = [0, 50, 150].map((delay) => window.setTimeout(focusPresentationOverlay, delay));
171
+ const handleMessage = (event: MessageEvent) => {
172
+ if (!isPresentationExitMessage(event.data, presentationExitToken)) return;
173
+ setPresenting(false);
174
+ };
175
+ document.addEventListener('keydown', handlePresentationKeyDown, true);
176
+ window.addEventListener('message', handleMessage);
177
+ return () => {
178
+ focusTimers.forEach((timer) => window.clearTimeout(timer));
179
+ document.removeEventListener('keydown', handlePresentationKeyDown, true);
180
+ window.removeEventListener('message', handleMessage);
181
+ };
182
+ }, [handlePresentationKeyDown, presentationExitToken, presenting]);
183
+
114
184
  if (!node) return null;
115
185
 
116
186
  const title =
@@ -122,6 +192,8 @@ export function ExpandedNodeOverlay() {
122
192
  const isCtxPinned = nodeId ? contextPinnedNodeIds.value.has(nodeId) : false;
123
193
  const hasText = textContent.length > 0;
124
194
  const pendingClose = pendingExpandedNodeCloseId.value === nodeId;
195
+ const isEmbeddedViewer = node.type === 'mcp-app' || node.type === 'webpage' || node.type === 'json-render' || node.type === 'graph';
196
+ const canPresent = shouldShowPresentationControls(node);
125
197
 
126
198
  return (
127
199
  <div
@@ -217,6 +289,17 @@ export function ExpandedNodeOverlay() {
217
289
  </button>
218
290
  )}
219
291
 
292
+ {canPresent && (
293
+ <button
294
+ type="button"
295
+ class="expanded-action-btn expanded-action-primary"
296
+ onClick={handlePresent}
297
+ title="Present this HTML node fullscreen"
298
+ >
299
+ Present
300
+ </button>
301
+ )}
302
+
220
303
  {/* Word count */}
221
304
  {words > 0 && (
222
305
  <span class="expanded-meta">
@@ -260,10 +343,36 @@ export function ExpandedNodeOverlay() {
260
343
  overflow: 'auto',
261
344
  padding: '16px',
262
345
  minHeight: 0,
346
+ ...(isEmbeddedViewer ? { display: 'flex', flexDirection: 'column' } : {}),
263
347
  }}
264
348
  >
265
- {renderContent(node, true)}
349
+ {isEmbeddedViewer ? (
350
+ <div style={{ flex: 1, minHeight: 0, display: 'flex' }}>
351
+ {renderContent(node, true)}
352
+ </div>
353
+ ) : renderContent(node, true)}
266
354
  </div>
355
+ {canPresent && presenting && (
356
+ <div ref={presentationOverlayRef} class="html-presentation-overlay" role="dialog" aria-modal="true" aria-label={`Present ${title}`} tabIndex={-1} onKeyDownCapture={handlePresentationKeyDown}>
357
+ <div class="html-presentation-toolbar">
358
+ <div>
359
+ <div class="html-presentation-kicker">HTML presentation</div>
360
+ <div class="html-presentation-title">{title}</div>
361
+ </div>
362
+ <button
363
+ type="button"
364
+ class="html-presentation-exit"
365
+ onClick={handleExitPresentation}
366
+ title="Exit presentation (Esc)"
367
+ >
368
+ Exit presentation
369
+ </button>
370
+ </div>
371
+ <div class="html-presentation-stage">
372
+ <HtmlNode node={node} expanded presentation presentationExitToken={presentationExitToken} />
373
+ </div>
374
+ </div>
375
+ )}
267
376
  </div>
268
377
  </div>
269
378
  );
@@ -11,8 +11,12 @@ function hasExplicitStructuredFrame(node: CanvasNodeState): boolean {
11
11
  return node.type === 'graph' || node.type === 'json-render';
12
12
  }
13
13
 
14
+ function isPresentationHtmlNode(node: CanvasNodeState): boolean {
15
+ return node.type === 'html' && node.data.presentation === true;
16
+ }
17
+
14
18
  export function shouldAutoFitNode(node: CanvasNodeState): boolean {
15
- return !node.collapsed && !node.dockPosition && node.data.strictSize !== true && node.type !== 'group' && !isExtAppNode(node) && !hasExplicitStructuredFrame(node);
19
+ return !node.collapsed && !node.dockPosition && node.data.strictSize !== true && node.type !== 'group' && !isExtAppNode(node) && !hasExplicitStructuredFrame(node) && !isPresentationHtmlNode(node);
16
20
  }
17
21
 
18
22
  export function computeAutoFitHeight(node: CanvasNodeState, contentHeight: number): number | null {
@@ -139,6 +139,19 @@ export function IconEraser(p: IconProps): JSX.Element {
139
139
  );
140
140
  }
141
141
 
142
+ /** Text cursor — canvas text annotation mode */
143
+ export function IconTextAnnotation(p: IconProps): JSX.Element {
144
+ return (
145
+ <Icon {...p}>
146
+ <path d="M3 4h10" />
147
+ <path d="M8 4v8" />
148
+ <path d="M6 12h4" />
149
+ <path d="M4.5 4 4 6" />
150
+ <path d="M11.5 4 12 6" />
151
+ </Icon>
152
+ );
153
+ }
154
+
142
155
  /** Camera — snapshots */
143
156
  export function IconSnapshot(p: IconProps): JSX.Element {
144
157
  return (
@@ -1,4 +1,5 @@
1
- import { useMemo } from 'preact/hooks';
1
+ import { useEffect, useMemo, useRef } from 'preact/hooks';
2
+ import { canvasTheme } from '../state/canvas-store';
2
3
  import { getCanvasTokens } from '../theme/tokens';
3
4
  import type { CanvasNodeState } from '../types';
4
5
 
@@ -95,28 +96,135 @@ function buildThemeStyleBlock(): string {
95
96
  * a `<head>`, inject at the top of head; otherwise wrap the content in a full
96
97
  * document. Returns a complete HTML string suitable for `srcdoc`.
97
98
  */
98
- function buildSrcDoc(userHtml: string): string {
99
- const styleBlock = `<style data-pmx-canvas-theme>${buildThemeStyleBlock()}</style>`;
99
+ function buildPresentationEscapeBridge(exitToken?: string): string {
100
+ const token = JSON.stringify(exitToken ?? '');
101
+ return `<script data-pmx-canvas-presentation-bridge>
102
+ const PMX_CANVAS_PRESENTATION_EXIT_TOKEN = ${token};
103
+ document.addEventListener('keydown', (event) => {
104
+ if (event.key === 'Escape') {
105
+ window.parent.postMessage({ source: 'pmx-canvas-html-node', type: 'presentation-exit', token: PMX_CANVAS_PRESENTATION_EXIT_TOKEN }, '*');
106
+ }
107
+ }, true);
108
+ window.addEventListener('message', (event) => {
109
+ const message = event.data;
110
+ if (!message || message.source !== 'pmx-canvas-html-node' || message.type !== 'presentation-key' || message.token !== PMX_CANVAS_PRESENTATION_EXIT_TOKEN) return;
111
+ if (typeof message.key !== 'string') return;
112
+ if (typeof window.PMX_CANVAS_PRESENTATION_HANDLE_KEY === 'function') {
113
+ window.PMX_CANVAS_PRESENTATION_HANDLE_KEY(message.key);
114
+ return;
115
+ }
116
+ document.dispatchEvent(new CustomEvent('pmx-presentation-key', { detail: { key: message.key }, bubbles: true, cancelable: true }));
117
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: message.key, bubbles: true, cancelable: true }));
118
+ });
119
+ </script>`;
120
+ }
121
+
122
+ function buildThemeBridge(themeToken: string): string {
123
+ const token = JSON.stringify(themeToken);
124
+ return `<script data-pmx-canvas-theme-bridge>
125
+ const PMX_CANVAS_THEME_TOKEN = ${token};
126
+ window.addEventListener('message', (event) => {
127
+ const message = event.data;
128
+ if (!message || message.source !== 'pmx-canvas-html-node' || message.type !== 'theme-update' || message.token !== PMX_CANVAS_THEME_TOKEN) return;
129
+ if (typeof message.css !== 'string' || typeof message.theme !== 'string') return;
130
+ let style = document.querySelector('style[data-pmx-canvas-theme]');
131
+ if (!style) {
132
+ style = document.createElement('style');
133
+ style.setAttribute('data-pmx-canvas-theme', '');
134
+ document.head.prepend(style);
135
+ }
136
+ style.textContent = message.css;
137
+ document.documentElement.setAttribute('data-pmx-canvas-theme', message.theme);
138
+ document.documentElement.setAttribute('data-theme', message.theme);
139
+ });
140
+ </script>`;
141
+ }
142
+
143
+ function injectIntoHead(html: string, content: string): string {
144
+ if (/<head[\s>]/i.test(html)) {
145
+ return html.replace(/<head([^>]*)>/i, `<head$1>${content}`);
146
+ }
147
+ if (/<html[\s>]/i.test(html)) {
148
+ return html.replace(/<html([^>]*)>/i, `<html$1><head>${content}</head>`);
149
+ }
150
+ return html;
151
+ }
152
+
153
+ function buildSrcDoc(userHtml: string, options: { presentation?: boolean; presentationExitToken?: string; themeToken: string; themeCss: string; theme: string }): string {
154
+ const styleBlock = `<style data-pmx-canvas-theme>${options.themeCss}</style>`;
155
+ const themeBridge = buildThemeBridge(options.themeToken);
156
+ const presentationBridge = options.presentation ? buildPresentationEscapeBridge(options.presentationExitToken) : '';
157
+ const injectedHeadContent = `${styleBlock}${themeBridge}${presentationBridge}`;
158
+ const presentationAttr = options.presentation ? ' data-pmx-presentation-mode="present"' : '';
100
159
  const trimmed = userHtml.trim();
101
160
  const isFullDoc = /<html[\s>]/i.test(trimmed);
102
161
  if (isFullDoc) {
103
- if (/<head[\s>]/i.test(trimmed)) {
104
- return trimmed.replace(/<head([^>]*)>/i, `<head$1>${styleBlock}`);
105
- }
106
- // Has <html> but no <head> — inject one.
107
- return trimmed.replace(/<html([^>]*)>/i, `<html$1><head>${styleBlock}</head>`);
162
+ const withTheme = trimmed.replace(/<html([^>]*)>/i, `<html$1 data-pmx-canvas-theme="${options.theme}" data-theme="${options.theme}"${presentationAttr}>`);
163
+ return injectIntoHead(withTheme, injectedHeadContent);
108
164
  }
109
165
  // Fragment — wrap in full document.
110
- return `<!doctype html><html><head><meta charset="utf-8">${styleBlock}</head><body>${userHtml}</body></html>`;
166
+ return `<!doctype html><html data-pmx-canvas-theme="${options.theme}" data-theme="${options.theme}"${presentationAttr}><head><meta charset="utf-8">${injectedHeadContent}</head><body>${userHtml}</body></html>`;
167
+ }
168
+
169
+ export function createHtmlNodeSrcDocForTest(userHtml: string, options: { theme: string; themeCss: string; themeToken?: string; presentation?: boolean; presentationExitToken?: string }): string {
170
+ return buildSrcDoc(userHtml, {
171
+ themeToken: options.themeToken ?? 'test-theme-token',
172
+ theme: options.theme,
173
+ themeCss: options.themeCss,
174
+ presentation: options.presentation,
175
+ presentationExitToken: options.presentationExitToken,
176
+ });
177
+ }
178
+
179
+ export function shouldShowPresentationControls(node: CanvasNodeState): boolean {
180
+ return node.type === 'html' && node.data.presentation === true;
111
181
  }
112
182
 
113
- export function HtmlNode({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
183
+ export function HtmlNode({
184
+ node,
185
+ expanded = false,
186
+ presentation = false,
187
+ presentationExitToken,
188
+ autoFocus = false,
189
+ }: { node: CanvasNodeState; expanded?: boolean; presentation?: boolean; presentationExitToken?: string; autoFocus?: boolean }) {
190
+ const iframeRef = useRef<HTMLIFrameElement>(null);
191
+ const theme = canvasTheme.value;
192
+ const themeToken = useMemo(() => `theme-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, []);
193
+ const themeCss = useMemo(() => buildThemeStyleBlock(), [theme]);
114
194
  const html = typeof node.data.html === 'string'
115
195
  ? node.data.html
116
196
  : typeof node.data.content === 'string'
117
197
  ? node.data.content
118
198
  : '';
119
- const srcDoc = useMemo(() => (html ? buildSrcDoc(html) : ''), [html]);
199
+ const srcDoc = useMemo(() => (html ? buildSrcDoc(html, { presentation, presentationExitToken, themeToken, themeCss, theme }) : ''), [html, presentation, presentationExitToken, themeToken]);
200
+
201
+ useEffect(() => {
202
+ iframeRef.current?.contentWindow?.postMessage({
203
+ source: 'pmx-canvas-html-node',
204
+ type: 'theme-update',
205
+ token: themeToken,
206
+ theme,
207
+ css: themeCss,
208
+ }, '*');
209
+ if (autoFocus) iframeRef.current?.focus();
210
+ }, [theme, themeCss, themeToken]);
211
+
212
+ useEffect(() => {
213
+ if (!autoFocus) return;
214
+ const id = window.setTimeout(() => iframeRef.current?.focus(), 0);
215
+ return () => window.clearTimeout(id);
216
+ }, [autoFocus, srcDoc]);
217
+
218
+ const handleFrameLoad = () => {
219
+ iframeRef.current?.contentWindow?.postMessage({
220
+ source: 'pmx-canvas-html-node',
221
+ type: 'theme-update',
222
+ token: themeToken,
223
+ theme,
224
+ css: themeCss,
225
+ }, '*');
226
+ if (autoFocus) iframeRef.current?.focus();
227
+ };
120
228
 
121
229
  if (!html) {
122
230
  return (
@@ -134,16 +242,20 @@ export function HtmlNode({ node, expanded = false }: { node: CanvasNodeState; ex
134
242
  // arbitrary author code runs inside this exact sandbox.
135
243
  return (
136
244
  <iframe
245
+ ref={iframeRef}
246
+ class={presentation ? 'html-node-frame html-node-frame-presentation' : 'html-node-frame'}
137
247
  title={typeof node.data.title === 'string' ? node.data.title : 'HTML node'}
138
248
  sandbox="allow-scripts"
139
249
  srcdoc={srcDoc}
250
+ tabIndex={autoFocus ? 0 : undefined}
251
+ onLoad={handleFrameLoad}
140
252
  style={{
141
253
  width: '100%',
142
254
  height: '100%',
143
- minHeight: expanded ? '70vh' : '300px',
255
+ minHeight: presentation ? 0 : expanded ? '70vh' : '300px',
144
256
  border: 'none',
145
257
  background: 'var(--c-bg)',
146
- borderRadius: '6px',
258
+ borderRadius: presentation ? '18px' : '6px',
147
259
  display: 'block',
148
260
  }}
149
261
  />
@@ -2,11 +2,12 @@ import type { CanvasNodeState } from '../types';
2
2
  import { canvasTheme } from '../state/canvas-store';
3
3
  import { ExtAppFrame } from './ExtAppFrame';
4
4
 
5
- function withTheme(url: string): string {
5
+ function withViewerParams(url: string, expanded: boolean): string {
6
6
  if (!url) return url;
7
7
  try {
8
8
  const resolved = new URL(url, window.location.origin);
9
9
  resolved.searchParams.set('theme', canvasTheme.value === 'light' ? 'light' : 'dark');
10
+ if (expanded) resolved.searchParams.set('display', 'expanded');
10
11
  return resolved.toString();
11
12
  } catch {
12
13
  return url;
@@ -18,7 +19,7 @@ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState;
18
19
  return <ExtAppFrame node={node} expanded={expanded} />;
19
20
  }
20
21
 
21
- const url = withTheme((node.data.url as string) || '');
22
+ const url = withViewerParams((node.data.url as string) || '', expanded);
22
23
  const sourceServer = (node.data.sourceServer as string) || '';
23
24
  const hostMode = (node.data.hostMode as string) || 'hosted';
24
25
  const fallbackReason = node.data.fallbackReason as string | undefined;
@@ -54,7 +55,14 @@ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState;
54
55
  }
55
56
 
56
57
  return (
57
- <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
58
+ <div
59
+ style={{
60
+ height: '100%',
61
+ display: 'flex',
62
+ flexDirection: 'column',
63
+ ...(expanded ? { flex: 1, minHeight: 0, width: '100%' } : {}),
64
+ }}
65
+ >
58
66
  {!trustedDomain && (
59
67
  <div
60
68
  style={{
@@ -77,7 +85,7 @@ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState;
77
85
  sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
78
86
  allow="clipboard-read; clipboard-write"
79
87
  loading="lazy"
80
- style={{ flex: 1 }}
88
+ style={{ flex: 1, minHeight: 0, width: '100%' }}
81
89
  title={`MCP App: ${sourceServer}`}
82
90
  />
83
91
  </div>