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.
- package/CHANGELOG.md +159 -0
- package/Readme.md +19 -6
- package/dist/canvas/global.css +123 -2
- package/dist/canvas/index.js +103 -68
- package/dist/json-render/index.js +109 -109
- package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
- package/dist/types/client/icons.d.ts +2 -0
- package/dist/types/client/nodes/HtmlNode.d.ts +12 -1
- package/dist/types/client/state/canvas-store.d.ts +2 -0
- package/dist/types/client/types.d.ts +3 -2
- package/dist/types/json-render/charts/components.d.ts +5 -1
- package/dist/types/json-render/renderer/index.d.ts +1 -0
- package/dist/types/json-render/server.d.ts +1 -0
- package/dist/types/mcp/canvas-access.d.ts +3 -0
- package/dist/types/server/canvas-operations.d.ts +4 -0
- package/dist/types/server/canvas-schema.d.ts +19 -3
- package/dist/types/server/canvas-serialization.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +6 -2
- package/dist/types/server/html-node-summary.d.ts +2 -0
- package/dist/types/server/html-primitives.d.ts +42 -0
- package/dist/types/server/index.d.ts +26 -0
- package/docs/cli.md +4 -1
- package/docs/http-api.md +11 -1
- package/docs/mcp.md +10 -4
- package/docs/node-types.md +54 -4
- package/docs/screenshot.png +0 -0
- package/docs/sdk.md +12 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +17 -3
- package/skills/pmx-canvas/references/html-primitives.md +132 -0
- package/src/cli/agent.ts +159 -5
- package/src/cli/index.ts +1 -1
- package/src/client/App.tsx +21 -2
- package/src/client/canvas/AnnotationLayer.tsx +33 -12
- package/src/client/canvas/CanvasViewport.tsx +88 -7
- package/src/client/canvas/CommandPalette.tsx +2 -2
- package/src/client/canvas/ContextMenu.tsx +2 -2
- package/src/client/canvas/ExpandedNodeOverlay.tsx +112 -3
- package/src/client/canvas/auto-fit.ts +5 -1
- package/src/client/icons.tsx +13 -0
- package/src/client/nodes/HtmlNode.tsx +125 -13
- package/src/client/nodes/McpAppNode.tsx +12 -4
- package/src/client/state/canvas-store.ts +15 -5
- package/src/client/state/sse-bridge.ts +5 -4
- package/src/client/theme/global.css +123 -2
- package/src/client/types.ts +2 -1
- package/src/json-render/charts/components.tsx +41 -7
- package/src/json-render/charts/extra-components.tsx +13 -12
- package/src/json-render/renderer/index.tsx +1 -0
- package/src/json-render/server.ts +3 -1
- package/src/mcp/canvas-access.ts +54 -1
- package/src/mcp/server.ts +98 -28
- package/src/server/agent-context.ts +39 -0
- package/src/server/canvas-operations.ts +99 -38
- package/src/server/canvas-provenance.ts +8 -6
- package/src/server/canvas-schema.ts +94 -3
- package/src/server/canvas-serialization.ts +16 -4
- package/src/server/canvas-state.ts +9 -4
- package/src/server/demo-state.json +1143 -0
- package/src/server/demo.ts +25 -777
- package/src/server/html-node-summary.ts +141 -0
- package/src/server/html-primitives.ts +1300 -0
- package/src/server/index.ts +63 -3
- package/src/server/server.ts +154 -17
- 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
|
-
|
|
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 =
|
|
521
|
-
const nodeH =
|
|
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 =
|
|
323
|
-
const height =
|
|
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
|
-
{
|
|
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 {
|
package/src/client/icons.tsx
CHANGED
|
@@ -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
|
|
99
|
-
const
|
|
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
|
-
|
|
104
|
-
|
|
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">${
|
|
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({
|
|
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
|
|
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 =
|
|
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
|
|
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>
|