pmx-canvas 0.10.0 → 0.11.0
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/.github/extensions/pmx-canvas/runtime.json +1 -1
- package/.github/extensions/pmx-canvas/tools.json +246 -108
- package/CHANGELOG.md +32 -0
- package/dist/canvas/chunks/abnfDiagram-N423BO3Z-p8dtp6va.js +1 -0
- package/dist/canvas/chunks/architecture-TIHT7OUA-9tffvpsv.js +1 -0
- package/dist/canvas/chunks/architectureDiagram-T3A2C74G-5jhmxrzf.js +36 -0
- package/dist/canvas/chunks/blockDiagram-VBNYF7ZC-pw1fa9yb.js +132 -0
- package/dist/canvas/chunks/c4Diagram-5PPSVZJV-x07nq0tt.js +10 -0
- package/dist/canvas/chunks/classDiagram-JCYQIIEL-nc6g5x1r.js +1 -0
- package/dist/canvas/chunks/classDiagram-v2-OCEON4UE-0qnbpwvh.js +1 -0
- package/dist/canvas/chunks/cose-bilkent-JH36ORCC-nm39npq0.js +1 -0
- package/dist/canvas/chunks/cynefin-VYW2F7L2-ys8p46ts.js +1 -0
- package/dist/canvas/chunks/cynefinDiagram-MW4NZA55-vdxrwahz.js +62 -0
- package/dist/canvas/chunks/dagre-VZM6K2ZE-bqdm7wpd.js +4 -0
- package/dist/canvas/chunks/diagram-7IWD3JNH-gs5gpr93.js +30 -0
- package/dist/canvas/chunks/diagram-B4RE2ZJO-cgwjg47s.js +3 -0
- package/dist/canvas/chunks/diagram-LBJQPF4R-w3vahpav.js +24 -0
- package/dist/canvas/chunks/diagram-Q27KOJAE-42837z6h.js +24 -0
- package/dist/canvas/chunks/diagram-UB23O5K3-4fz83q59.js +41 -0
- package/dist/canvas/chunks/ebnfDiagram-BXEA7PRR-zphtgegj.js +1 -0
- package/dist/canvas/chunks/erDiagram-JOGREHBK-zvbbdxp4.js +85 -0
- package/dist/canvas/chunks/eventmodeling-45OFAUF4-v9q6dn4c.js +1 -0
- package/dist/canvas/chunks/flowDiagram-UKHOOZJN-yrr5z8tc.js +1 -0
- package/dist/canvas/chunks/ganttDiagram-PKOTCBZU-282jzycm.js +292 -0
- package/dist/canvas/chunks/gitGraph-TEB2WS4Q-kdwb8m0a.js +1 -0
- package/dist/canvas/chunks/gitGraphDiagram-DS77QQ5N-vqqy6p1b.js +106 -0
- package/dist/canvas/chunks/index-0rk4gp1g.js +2 -0
- package/dist/canvas/chunks/index-194m3hc3.js +2 -0
- package/dist/canvas/chunks/index-1r72hdzb.js +301 -0
- package/dist/canvas/chunks/index-353m7b0p.js +2 -0
- package/dist/canvas/chunks/index-3cy1bzwh.js +2 -0
- package/dist/canvas/chunks/index-5tv8v1hk.js +1 -0
- package/dist/canvas/chunks/index-6ceya556.js +332 -0
- package/dist/canvas/chunks/index-6qmsnxwy.js +4 -0
- package/dist/canvas/chunks/index-d3b3gqe9.js +2 -0
- package/dist/canvas/chunks/index-fjtdggjn.js +2 -0
- package/dist/canvas/chunks/index-g3pwnqeb.js +2 -0
- package/dist/canvas/chunks/index-gf4e0et4.js +2 -0
- package/dist/canvas/chunks/index-hj99nz1f.js +1 -0
- package/dist/canvas/chunks/index-nprm96xp.js +16 -0
- package/dist/canvas/chunks/index-qj8fxwsf.js +2 -0
- package/dist/canvas/chunks/index-r2fyc9w1.js +89 -0
- package/dist/canvas/chunks/index-rft2hcz5.js +2 -0
- package/dist/canvas/chunks/index-rh7nv68n.js +2 -0
- package/dist/canvas/chunks/index-s4j678gj.js +2 -0
- package/dist/canvas/chunks/index-tb1wf525.js +179 -0
- package/dist/canvas/chunks/index-vv56h019.js +2 -0
- package/dist/canvas/chunks/index-w2mra17t.js +207 -0
- package/dist/canvas/chunks/index-wgv7en21.js +232 -0
- package/dist/canvas/chunks/index-yw8h9vha.js +157 -0
- package/dist/canvas/chunks/info-DKCQHKI2-wpzs6sz3.js +1 -0
- package/dist/canvas/chunks/infoDiagram-6WML65LV-v1yvggqj.js +2 -0
- package/dist/canvas/chunks/ishikawaDiagram-WSZJBQD7-5hnq1a9p.js +70 -0
- package/dist/canvas/chunks/journeyDiagram-NVQOT4AX-zjcffveh.js +139 -0
- package/dist/canvas/chunks/kanban-definition-27J2QSJJ-dakqtfp1.js +89 -0
- package/dist/canvas/chunks/katex-dmg8k4zm.js +257 -0
- package/dist/canvas/chunks/mermaid.core-j2geswrd.js +12 -0
- package/dist/canvas/chunks/mindmap-definition-FAOFIHXS-142xhr3k.js +96 -0
- package/dist/canvas/chunks/packet-7NZHBO7P-xn439f0s.js +1 -0
- package/dist/canvas/chunks/pegDiagram-VL7TDLO6-5wvk7vsd.js +1 -0
- package/dist/canvas/chunks/pie-RZYD4A2V-0kf16rwh.js +1 -0
- package/dist/canvas/chunks/pieDiagram-7S7Q4E2Y-ztdhwycc.js +39 -0
- package/dist/canvas/chunks/quadrantDiagram-CIZ2JOQS-c5p9gmg6.js +7 -0
- package/dist/canvas/chunks/radar-I7S5WNFK-0g2pz36t.js +1 -0
- package/dist/canvas/chunks/railroad-3IZDKUUU-fcaran6j.js +1 -0
- package/dist/canvas/chunks/railroad-abnf-AHOZXSZD-wgdya41q.js +1 -0
- package/dist/canvas/chunks/railroad-ebnf-EBAXGLYW-gy07a4ka.js +1 -0
- package/dist/canvas/chunks/railroad-peg-LSFZ7HO6-ewk6wtkh.js +1 -0
- package/dist/canvas/chunks/railroadDiagram-AXF67PYL-xkmqkjwf.js +1 -0
- package/dist/canvas/chunks/requirementDiagram-LRYGKXZP-9wm2dyaw.js +84 -0
- package/dist/canvas/chunks/sankeyDiagram-W5VNT64P-2w19etgb.js +40 -0
- package/dist/canvas/chunks/sequenceDiagram-SI44F4Z6-0jn0vwmz.js +162 -0
- package/dist/canvas/chunks/sizeCapture-X5ZJPWSS-x75epwdq.js +1 -0
- package/dist/canvas/chunks/stateDiagram-OKZ733FA-schjpy6d.js +1 -0
- package/dist/canvas/chunks/stateDiagram-v2-UEYNNEHI-k8sfp2e7.js +1 -0
- package/dist/canvas/chunks/swimlanes-SLNWSIFB-gkw4xce3.js +1 -0
- package/dist/canvas/chunks/swimlanesDiagram-ULZ7WXOC-nqysw67j.js +8 -0
- package/dist/canvas/chunks/timeline-definition-Z64GVDOM-x7nkcy01.js +120 -0
- package/dist/canvas/chunks/treeView-QDETBFTQ-cesw6gp0.js +1 -0
- package/dist/canvas/chunks/treemap-6X3UGDF4-zdv4knp9.js +1 -0
- package/dist/canvas/chunks/vennDiagram-T6HMQDX7-x8dqn9yf.js +34 -0
- package/dist/canvas/chunks/wardley-OPB4EBWU-44pdddez.js +1 -0
- package/dist/canvas/chunks/wardleyDiagram-T6FBY63Y-t1j52t9r.js +78 -0
- package/dist/canvas/chunks/xychartDiagram-ELKLHX3M-38t16pme.js +7 -0
- package/dist/canvas/global.css +389 -229
- package/dist/canvas/index.js +141 -211
- package/dist/canvas/mcp-app.html +4205 -554
- package/dist/canvas/mermaid-entry.js +338 -338
- package/dist/canvas/surface-theme.css +6 -6
- package/dist/json-render/index.css +1 -1
- package/dist/json-render/index.js +49 -49
- package/dist/types/client/canvas/BarHint.d.ts +5 -3
- package/dist/types/client/canvas/CanvasNode.d.ts +0 -11
- package/dist/types/client/canvas/EdgeLayer.d.ts +1 -2
- package/dist/types/client/canvas/auto-fit.d.ts +4 -5
- package/dist/types/client/mermaid-entry.d.ts +1 -2
- package/dist/types/client/mermaid-render.d.ts +1 -0
- package/dist/types/client/nodes/BoardNode.d.ts +1 -1
- package/dist/types/client/nodes/ExtAppFrame.d.ts +7 -90
- package/dist/types/client/nodes/McpAppNode.d.ts +1 -25
- package/dist/types/client/nodes/MermaidNode.d.ts +1 -8
- package/dist/types/client/nodes/ViewerFrame.d.ts +29 -0
- package/dist/types/client/nodes/ViewerHost.d.ts +6 -0
- package/dist/types/client/nodes/iframe-document-url.d.ts +3 -9
- package/dist/types/client/nodes/primitive-copy.d.ts +5 -0
- package/dist/types/client/nodes/use-iframe-content-height.d.ts +1 -1
- package/dist/types/client/nodes/use-surface-frame.d.ts +4 -6
- package/dist/types/client/nodes/viewer-runtime-state.d.ts +2 -1
- package/dist/types/client/state/canvas-store.d.ts +3 -1
- package/dist/types/client/state/intent-bridge.d.ts +1 -0
- package/dist/types/client/state/presentation.d.ts +17 -6
- package/dist/types/json-render/server.d.ts +2 -1
- package/dist/types/server/canvas-state.d.ts +2 -0
- package/dist/types/server/index.d.ts +10 -5
- package/dist/types/server/operations/ops/boards.d.ts +0 -1
- package/dist/types/server/tour-control.d.ts +13 -3
- package/dist/types/shared/ax-surface-protocol.d.ts +0 -9
- package/dist/types/shared/tour.d.ts +8 -0
- package/docs/design/Chrome.dc.html +32 -6
- package/docs/design/Types1.dc.html +3 -2
- package/docs/design/Types2.dc.html +1 -1
- package/docs/design/Types3.dc.html +1 -1
- package/docs/design/Zoom.dc.html +37 -60
- package/docs/design/canvas.json +1 -1
- package/docs/design.md +169 -4
- package/docs/http-api.md +51 -1
- package/docs/mcp.md +8 -0
- package/docs/node-types.md +38 -3
- package/docs/plans/plan-009-tech-debt-backlog.md +2 -2
- package/docs/plans/plan-019-post-0.9-roadmap.md +283 -11
- package/docs/product-vision-2026-09.md +9 -0
- package/package.json +2 -2
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/plugin.json +1 -1
- package/plugins/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/tools.json +246 -108
- package/skills/pmx-canvas/SKILL.md +11 -18
- package/skills/pmx-canvas/references/full-reference.md +9 -4
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
- package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +3 -5
- package/src/client/canvas/BarHint.tsx +6 -3
- package/src/client/canvas/CanvasNode.tsx +34 -47
- package/src/client/canvas/CanvasViewport.tsx +6 -7
- package/src/client/canvas/ContextMenu.tsx +63 -3
- package/src/client/canvas/EdgeLayer.tsx +365 -169
- package/src/client/canvas/ExpandedNodeOverlay.tsx +1 -2
- package/src/client/canvas/Presentation.tsx +53 -3
- package/src/client/canvas/TopBar.tsx +24 -9
- package/src/client/canvas/auto-fit.ts +9 -11
- package/src/client/canvas/canvas-area.ts +1 -1
- package/src/client/canvas/use-focus-trap.ts +6 -1
- package/src/client/mermaid-entry.ts +6 -20
- package/src/client/mermaid-render.ts +39 -0
- package/src/client/nodes/AxStepControls.tsx +45 -69
- package/src/client/nodes/BoardNode.tsx +65 -9
- package/src/client/nodes/ContextNode.tsx +88 -396
- package/src/client/nodes/ExtAppFrame.tsx +283 -1462
- package/src/client/nodes/FileNode.tsx +92 -81
- package/src/client/nodes/HtmlNode.tsx +8 -36
- package/src/client/nodes/LedgerNode.tsx +31 -17
- package/src/client/nodes/MarkdownNode.tsx +8 -17
- package/src/client/nodes/McpAppNode.tsx +48 -154
- package/src/client/nodes/MermaidNode.tsx +80 -69
- package/src/client/nodes/PromptNode.tsx +12 -14
- package/src/client/nodes/ResponseNode.tsx +8 -6
- package/src/client/nodes/StatusNode.tsx +22 -7
- package/src/client/nodes/StatusSummary.tsx +5 -3
- package/src/client/nodes/TraceNode.tsx +8 -8
- package/src/client/nodes/ViewerFrame.tsx +262 -0
- package/src/client/nodes/ViewerHost.tsx +41 -0
- package/src/client/nodes/WebpageNode.tsx +22 -24
- package/src/client/nodes/iframe-document-url.ts +20 -27
- package/src/client/nodes/primitive-copy.ts +97 -0
- package/src/client/nodes/use-iframe-content-height.ts +34 -10
- package/src/client/nodes/use-surface-frame.ts +22 -11
- package/src/client/nodes/viewer-runtime-state.ts +41 -35
- package/src/client/state/canvas-store.ts +18 -12
- package/src/client/state/intent-bridge.ts +1 -0
- package/src/client/state/presentation.ts +135 -15
- package/src/client/state/sse-bridge.ts +18 -19
- package/src/client/theme/global.css +389 -229
- package/src/client/theme/surface-theme.css +6 -6
- package/src/json-render/charts/tufte-components.tsx +44 -2
- package/src/json-render/renderer/index.css +62 -2
- package/src/json-render/renderer/index.tsx +31 -7
- package/src/json-render/server.ts +3 -2
- package/src/server/board-export.ts +35 -24
- package/src/server/canvas-geometry.ts +1 -0
- package/src/server/canvas-state.ts +5 -0
- package/src/server/demo-state.json +106 -100
- package/src/server/html-primitives.ts +3 -9
- package/src/server/html-surface.ts +8 -0
- package/src/server/index.ts +19 -9
- package/src/server/operations/mcp-contracts.ts +17 -2
- package/src/server/operations/ops/boards.ts +1 -3
- package/src/server/operations/ops/viewport.ts +11 -9
- package/src/server/server.ts +7 -59
- package/src/server/tour-control.ts +66 -6
- package/src/shared/ax-surface-protocol.ts +0 -11
- package/src/shared/content-height-reporter.ts +4 -3
- package/src/shared/tour.ts +8 -0
|
@@ -17,7 +17,7 @@ const STATUS_ICONS: Record<string, string> = {
|
|
|
17
17
|
};
|
|
18
18
|
|
|
19
19
|
const STATUS_COLORS: Record<string, string> = {
|
|
20
|
-
running: 'var(--c-
|
|
20
|
+
running: 'var(--c-agent)',
|
|
21
21
|
success: 'var(--c-ok)',
|
|
22
22
|
failed: 'var(--c-danger)',
|
|
23
23
|
};
|
|
@@ -71,9 +71,9 @@ export function TraceNode({ node }: { node: CanvasNodeState }) {
|
|
|
71
71
|
<div
|
|
72
72
|
style={{
|
|
73
73
|
fontFamily: 'var(--font-code)',
|
|
74
|
-
fontSize: '
|
|
75
|
-
fontWeight:
|
|
76
|
-
color: catColor,
|
|
74
|
+
fontSize: 'var(--fs-ui)',
|
|
75
|
+
fontWeight: 500,
|
|
76
|
+
color: category === 'subagent' ? catColor : 'var(--c-text)',
|
|
77
77
|
whiteSpace: 'nowrap',
|
|
78
78
|
overflow: 'hidden',
|
|
79
79
|
textOverflow: 'ellipsis',
|
|
@@ -84,7 +84,7 @@ export function TraceNode({ node }: { node: CanvasNodeState }) {
|
|
|
84
84
|
{summary && (
|
|
85
85
|
<div
|
|
86
86
|
style={{
|
|
87
|
-
fontSize: '
|
|
87
|
+
fontSize: 'var(--fs-meta)',
|
|
88
88
|
color: error ? 'var(--c-danger)' : 'var(--c-muted)',
|
|
89
89
|
whiteSpace: 'nowrap',
|
|
90
90
|
overflow: 'hidden',
|
|
@@ -101,10 +101,10 @@ export function TraceNode({ node }: { node: CanvasNodeState }) {
|
|
|
101
101
|
{duration && (
|
|
102
102
|
<span
|
|
103
103
|
style={{
|
|
104
|
-
fontSize: '
|
|
104
|
+
fontSize: 'var(--fs-meta)',
|
|
105
105
|
padding: '1px 5px',
|
|
106
|
-
borderRadius: '
|
|
107
|
-
background: '
|
|
106
|
+
borderRadius: 'var(--r-control)',
|
|
107
|
+
background: 'var(--c-surface-hover)',
|
|
108
108
|
color: 'var(--c-muted)',
|
|
109
109
|
flexShrink: 0,
|
|
110
110
|
whiteSpace: 'nowrap',
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import type { JSX, RefObject } from 'preact';
|
|
2
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
3
|
+
import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
|
|
4
|
+
|
|
5
|
+
export const VIEWER_LOAD_DEADLINE_MS = 10_000;
|
|
6
|
+
export type ViewerSource = { src?: string; srcdoc?: string; error?: boolean; retry?: () => void };
|
|
7
|
+
type LoadDocument = (
|
|
8
|
+
frame: HTMLIFrameElement,
|
|
9
|
+
signal: AbortSignal,
|
|
10
|
+
) => Promise<boolean | 'cancelled' | undefined> | undefined;
|
|
11
|
+
type Entry = { key: string; source: ViewerSource; attempt: number; load?: LoadDocument };
|
|
12
|
+
type MountFrame = (frame: HTMLIFrameElement, fail: () => void) => () => void;
|
|
13
|
+
|
|
14
|
+
/** The document acknowledges mounting before its frame-gated paint signal. */
|
|
15
|
+
export function waitForViewerPaint(frame: HTMLIFrameElement, token: string, signal: AbortSignal): Promise<boolean> {
|
|
16
|
+
if (signal.aborted) return Promise.resolve(false);
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const finish = (ready: boolean) => {
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
window.removeEventListener('message', receive);
|
|
21
|
+
signal.removeEventListener('abort', cancel);
|
|
22
|
+
resolve(ready);
|
|
23
|
+
};
|
|
24
|
+
const cancel = () => finish(false);
|
|
25
|
+
const receive = (event: MessageEvent) => {
|
|
26
|
+
if (
|
|
27
|
+
event.source !== frame.contentWindow ||
|
|
28
|
+
event.data?.source !== HTML_SURFACE_PUSH_SOURCE ||
|
|
29
|
+
event.data?.token !== token
|
|
30
|
+
)
|
|
31
|
+
return;
|
|
32
|
+
if (event.data.type === 'mounted') {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
}
|
|
35
|
+
if (event.data.type === 'ready') finish(true);
|
|
36
|
+
};
|
|
37
|
+
const timer = setTimeout(cancel, VIEWER_LOAD_DEADLINE_MS);
|
|
38
|
+
window.addEventListener('message', receive);
|
|
39
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
40
|
+
frame.contentWindow?.postMessage({ source: HTML_SURFACE_PUSH_SOURCE, type: 'check-ready', token }, '*');
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Bind protocol adapters before document scripts run, once per document. */
|
|
45
|
+
function ViewerDocument({
|
|
46
|
+
mount,
|
|
47
|
+
iframeRef,
|
|
48
|
+
...attributes
|
|
49
|
+
}: JSX.IntrinsicElements['iframe'] & {
|
|
50
|
+
mount?: (frame: HTMLIFrameElement) => () => void;
|
|
51
|
+
iframeRef?: RefObject<HTMLIFrameElement>;
|
|
52
|
+
}) {
|
|
53
|
+
const frame = useRef<HTMLIFrameElement>(null);
|
|
54
|
+
useLayoutEffect(() => mount?.(frame.current!), []);
|
|
55
|
+
useLayoutEffect(() => {
|
|
56
|
+
if (iframeRef) iframeRef.current = frame.current;
|
|
57
|
+
}, [iframeRef]);
|
|
58
|
+
return <iframe {...attributes} ref={frame} />;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Preserve the painted document until its replacement restores and paints. */
|
|
62
|
+
export function RefreshingViewerFrame({
|
|
63
|
+
source,
|
|
64
|
+
identity = '',
|
|
65
|
+
iframeRef,
|
|
66
|
+
onLoad = () => {},
|
|
67
|
+
onMount,
|
|
68
|
+
onOpen,
|
|
69
|
+
onDocumentLoad,
|
|
70
|
+
title,
|
|
71
|
+
className = 'mcp-app-frame',
|
|
72
|
+
sandbox = 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox',
|
|
73
|
+
allow = '',
|
|
74
|
+
tabIndex,
|
|
75
|
+
referrerPolicy,
|
|
76
|
+
}: {
|
|
77
|
+
source: ViewerSource;
|
|
78
|
+
identity?: string;
|
|
79
|
+
iframeRef: RefObject<HTMLIFrameElement>;
|
|
80
|
+
onLoad?: () => void;
|
|
81
|
+
onMount?: MountFrame;
|
|
82
|
+
onOpen?: () => void;
|
|
83
|
+
onDocumentLoad?: LoadDocument;
|
|
84
|
+
title: string;
|
|
85
|
+
className?: string;
|
|
86
|
+
sandbox?: string;
|
|
87
|
+
allow?: string;
|
|
88
|
+
tabIndex?: number;
|
|
89
|
+
referrerPolicy?: JSX.IntrinsicElements['iframe']['referrerPolicy'];
|
|
90
|
+
}) {
|
|
91
|
+
const documentSource = source.src ?? source.srcdoc ?? '';
|
|
92
|
+
const sourceKey = documentSource ? `${identity}\0${documentSource}` : '';
|
|
93
|
+
const [painted, setPainted] = useState<Entry>({ key: sourceKey, source, attempt: 0, load: onDocumentLoad });
|
|
94
|
+
const [pending, setPending] = useState<Entry | null>(null);
|
|
95
|
+
const [failure, setFailure] = useState<Entry | null>(null);
|
|
96
|
+
const [ready, setReady] = useState(false);
|
|
97
|
+
const promotion = useRef(0);
|
|
98
|
+
const readiness = useRef(new Map<AbortController, Entry>());
|
|
99
|
+
const currentEntries = useRef({ painted, pending });
|
|
100
|
+
currentEntries.current = { painted, pending };
|
|
101
|
+
const networkTimer = useRef<{ entry: Entry; timer: ReturnType<typeof setTimeout> }>();
|
|
102
|
+
const loadedEntries = useRef(new WeakSet<Entry>());
|
|
103
|
+
|
|
104
|
+
const fail = (entry: Entry) => {
|
|
105
|
+
const current = currentEntries.current;
|
|
106
|
+
if (entry === (current.pending ?? current.painted)) setFailure(entry);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
useLayoutEffect(() => {
|
|
110
|
+
promotion.current += 1;
|
|
111
|
+
setFailure(null);
|
|
112
|
+
if (!sourceKey || sourceKey === painted.key) {
|
|
113
|
+
setPending(null);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!painted.key) {
|
|
117
|
+
setPainted({ key: sourceKey, source, attempt: 0, load: onDocumentLoad });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
setPending({ key: sourceKey, source, attempt: 0, load: onDocumentLoad });
|
|
121
|
+
}, [sourceKey, painted.key]);
|
|
122
|
+
|
|
123
|
+
useLayoutEffect(() => {
|
|
124
|
+
// Retry retires an entry without changing its source key. Keep the retained
|
|
125
|
+
// foreground's first-paint wait, but cancel every removed document's work.
|
|
126
|
+
for (const [controller, entry] of readiness.current) {
|
|
127
|
+
if (entry === painted || entry === pending) continue;
|
|
128
|
+
controller.abort();
|
|
129
|
+
readiness.current.delete(controller);
|
|
130
|
+
}
|
|
131
|
+
}, [painted, pending]);
|
|
132
|
+
|
|
133
|
+
useLayoutEffect(() => {
|
|
134
|
+
const entry = pending ?? painted;
|
|
135
|
+
if (!entry.key || loadedEntries.current.has(entry)) return;
|
|
136
|
+
const timer = setTimeout(() => fail(entry), VIEWER_LOAD_DEADLINE_MS);
|
|
137
|
+
networkTimer.current = { entry, timer };
|
|
138
|
+
return () => clearTimeout(timer);
|
|
139
|
+
}, [pending, painted]);
|
|
140
|
+
|
|
141
|
+
useEffect(
|
|
142
|
+
() => () => {
|
|
143
|
+
promotion.current += 1;
|
|
144
|
+
for (const controller of readiness.current.keys()) controller.abort();
|
|
145
|
+
readiness.current.clear();
|
|
146
|
+
},
|
|
147
|
+
[],
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const frame = (entry: Entry, isPending: boolean) =>
|
|
151
|
+
entry.key && (
|
|
152
|
+
<ViewerDocument
|
|
153
|
+
key={`${entry.key}:${entry.attempt}`}
|
|
154
|
+
iframeRef={isPending ? undefined : iframeRef}
|
|
155
|
+
mount={onMount ? (frame) => onMount(frame, () => fail(entry)) : undefined}
|
|
156
|
+
src={entry.source.src}
|
|
157
|
+
srcdoc={entry.source.srcdoc}
|
|
158
|
+
class={className}
|
|
159
|
+
sandbox={sandbox}
|
|
160
|
+
allow={allow}
|
|
161
|
+
tabIndex={tabIndex}
|
|
162
|
+
referrerPolicy={referrerPolicy}
|
|
163
|
+
inert={isPending}
|
|
164
|
+
aria-hidden={isPending ? true : undefined}
|
|
165
|
+
onError={() => fail(entry)}
|
|
166
|
+
onLoad={async (event) => {
|
|
167
|
+
loadedEntries.current.add(entry);
|
|
168
|
+
if (networkTimer.current?.entry === entry) clearTimeout(networkTimer.current.timer);
|
|
169
|
+
const token = isPending ? ++promotion.current : promotion.current;
|
|
170
|
+
const loadedFrame = event.currentTarget;
|
|
171
|
+
const controller = new AbortController();
|
|
172
|
+
readiness.current.set(controller, entry);
|
|
173
|
+
const result = entry.load?.(loadedFrame, controller.signal);
|
|
174
|
+
const loaded = result ? await result : undefined;
|
|
175
|
+
readiness.current.delete(controller);
|
|
176
|
+
const current = currentEntries.current;
|
|
177
|
+
if (
|
|
178
|
+
(isPending && promotion.current !== token) ||
|
|
179
|
+
(entry !== current.painted && entry !== current.pending) ||
|
|
180
|
+
loaded === 'cancelled' ||
|
|
181
|
+
controller.signal.aborted
|
|
182
|
+
)
|
|
183
|
+
return;
|
|
184
|
+
if (loaded === false) {
|
|
185
|
+
fail(entry);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
setFailure((failed) => (failed === entry ? null : failed));
|
|
189
|
+
if (!isPending) {
|
|
190
|
+
setReady(true);
|
|
191
|
+
onLoad();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
requestAnimationFrame(() =>
|
|
195
|
+
requestAnimationFrame(() => {
|
|
196
|
+
if (promotion.current !== token) return;
|
|
197
|
+
iframeRef.current = loadedFrame;
|
|
198
|
+
setPainted(entry);
|
|
199
|
+
setPending(null);
|
|
200
|
+
setReady(true);
|
|
201
|
+
onLoad();
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
}}
|
|
205
|
+
style={{
|
|
206
|
+
position: 'absolute',
|
|
207
|
+
inset: 0,
|
|
208
|
+
width: '100%',
|
|
209
|
+
height: '100%',
|
|
210
|
+
border: 0,
|
|
211
|
+
visibility: 'visible',
|
|
212
|
+
zIndex: isPending ? 0 : 1,
|
|
213
|
+
background: 'var(--c-panel)',
|
|
214
|
+
}}
|
|
215
|
+
title={title}
|
|
216
|
+
/>
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<div class="mcp-app-frame-stack">
|
|
221
|
+
{frame(painted, false)}
|
|
222
|
+
{pending && frame(pending, true)}
|
|
223
|
+
{!ready && !failure && !source.error && (
|
|
224
|
+
<div class="viewer-fallback" role="status">
|
|
225
|
+
Loading viewer…
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
{(failure || source.error) && (
|
|
229
|
+
<div class={`viewer-fallback${ready ? ' viewer-fallback-retained' : ''}`} role="status">
|
|
230
|
+
<span>Viewer could not load.{ready ? ' Keeping the last view.' : ''}</span>
|
|
231
|
+
<button
|
|
232
|
+
type="button"
|
|
233
|
+
onClick={() => {
|
|
234
|
+
if (source.error) {
|
|
235
|
+
source.retry?.();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (!failure) return;
|
|
239
|
+
const retry = { ...failure, attempt: failure.attempt + 1 };
|
|
240
|
+
setFailure(null);
|
|
241
|
+
if (pending) setPending(retry);
|
|
242
|
+
else setPainted(retry);
|
|
243
|
+
}}
|
|
244
|
+
>
|
|
245
|
+
Retry
|
|
246
|
+
</button>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
{onOpen && !failure && !source.error && (
|
|
250
|
+
<button
|
|
251
|
+
type="button"
|
|
252
|
+
class="ext-app-preview-catcher"
|
|
253
|
+
aria-label="Open full view to edit"
|
|
254
|
+
onClick={(event) => {
|
|
255
|
+
event.stopPropagation();
|
|
256
|
+
onOpen();
|
|
257
|
+
}}
|
|
258
|
+
/>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ComponentChildren } from 'preact';
|
|
2
|
+
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
3
|
+
import { expandNode } from '../state/canvas-store';
|
|
4
|
+
import type { CanvasNodeState } from '../types';
|
|
5
|
+
|
|
6
|
+
// Mount on first approach at any zoom, then retain the document's arbitrary
|
|
7
|
+
// live state. A lifetime cap starves later regions of a populated board.
|
|
8
|
+
export function ViewerHost({ node, children }: { node: CanvasNodeState; children: ComponentChildren }) {
|
|
9
|
+
const anchor = useRef<HTMLDivElement>(null);
|
|
10
|
+
const [mounted, setMounted] = useState(false);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!anchor.current) return;
|
|
14
|
+
const observer = new IntersectionObserver(
|
|
15
|
+
([entry]) => {
|
|
16
|
+
if (entry!.isIntersecting) {
|
|
17
|
+
setMounted(true);
|
|
18
|
+
observer.disconnect();
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
{ rootMargin: '200px' },
|
|
22
|
+
);
|
|
23
|
+
observer.observe(anchor.current);
|
|
24
|
+
return () => observer.disconnect();
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div ref={anchor} class="viewer-host" data-viewer-mounted={mounted}>
|
|
29
|
+
{mounted ? (
|
|
30
|
+
children
|
|
31
|
+
) : (
|
|
32
|
+
<div class="viewer-placeholder">
|
|
33
|
+
<span>{String(node.data.title || node.type)}</span>
|
|
34
|
+
<button type="button" onClick={() => expandNode(node.id)}>
|
|
35
|
+
Open viewer
|
|
36
|
+
</button>
|
|
37
|
+
</div>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useCallback, useEffect, useState } from 'preact/hooks';
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import { refreshWebpageNodeFromClient } from '../state/intent-bridge';
|
|
3
3
|
import type { CanvasNodeState } from '../types';
|
|
4
|
+
import { RefreshingViewerFrame } from './ViewerFrame';
|
|
4
5
|
|
|
5
6
|
function formatHost(url: string): string {
|
|
6
7
|
try {
|
|
@@ -35,6 +36,7 @@ export function WebpageNode({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
35
36
|
const frameBlockedReason = typeof node.data.frameBlockedReason === 'string' ? node.data.frameBlockedReason : '';
|
|
36
37
|
const [refreshing, setRefreshing] = useState(false);
|
|
37
38
|
const [showEmbed, setShowEmbed] = useState(expanded);
|
|
39
|
+
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
38
40
|
|
|
39
41
|
useEffect(() => {
|
|
40
42
|
if (expanded) {
|
|
@@ -63,9 +65,9 @@ export function WebpageNode({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
63
65
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '12px' }}>
|
|
64
66
|
<div style={{ minWidth: 0, flex: 1 }}>
|
|
65
67
|
<div style={{ fontSize: '12px', color: 'var(--c-muted)' }}>{formatHost(url)}</div>
|
|
66
|
-
|
|
67
|
-
{
|
|
68
|
-
|
|
68
|
+
{pageTitle && pageTitle !== node.data.title && (
|
|
69
|
+
<div style={{ fontSize: 'var(--fs-title)', fontWeight: 600, color: 'var(--c-text)' }}>{pageTitle}</div>
|
|
70
|
+
)}
|
|
69
71
|
<a
|
|
70
72
|
href={url}
|
|
71
73
|
target="_blank"
|
|
@@ -82,7 +84,7 @@ export function WebpageNode({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
82
84
|
gap: '6px',
|
|
83
85
|
padding: '4px 8px',
|
|
84
86
|
borderRadius: '999px',
|
|
85
|
-
background: '
|
|
87
|
+
background: 'var(--c-panel-soft)',
|
|
86
88
|
color: statusTone,
|
|
87
89
|
fontSize: '11px',
|
|
88
90
|
textTransform: 'uppercase',
|
|
@@ -194,7 +196,7 @@ export function WebpageNode({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
194
196
|
fontSize: '11px',
|
|
195
197
|
color: 'var(--c-muted)',
|
|
196
198
|
borderBottom: '1px solid var(--c-line)',
|
|
197
|
-
background: '
|
|
199
|
+
background: 'var(--c-panel)',
|
|
198
200
|
}}
|
|
199
201
|
>
|
|
200
202
|
Live preview unavailable. This site refuses embedding, so PMX Canvas cannot show it inline.
|
|
@@ -216,9 +218,13 @@ export function WebpageNode({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
216
218
|
<div
|
|
217
219
|
style={{
|
|
218
220
|
border: '1px solid var(--c-line)',
|
|
219
|
-
borderRadius: '
|
|
221
|
+
borderRadius: 'var(--r-control)',
|
|
220
222
|
overflow: 'hidden',
|
|
221
|
-
background: 'var(--c-panel
|
|
223
|
+
background: 'var(--c-panel)',
|
|
224
|
+
height: '320px',
|
|
225
|
+
flexShrink: 0,
|
|
226
|
+
display: 'flex',
|
|
227
|
+
flexDirection: 'column',
|
|
222
228
|
}}
|
|
223
229
|
>
|
|
224
230
|
<div
|
|
@@ -227,26 +233,20 @@ export function WebpageNode({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
227
233
|
fontSize: '11px',
|
|
228
234
|
color: 'var(--c-muted)',
|
|
229
235
|
borderBottom: '1px solid var(--c-line)',
|
|
230
|
-
background: '
|
|
236
|
+
background: 'var(--c-panel)',
|
|
231
237
|
}}
|
|
232
238
|
>
|
|
233
239
|
Live preview (best effort). If this stays blank, the site likely blocks framing. The cached text snapshot
|
|
234
240
|
below still works.
|
|
235
241
|
</div>
|
|
236
|
-
<
|
|
237
|
-
|
|
242
|
+
<RefreshingViewerFrame
|
|
243
|
+
iframeRef={iframeRef}
|
|
244
|
+
onLoad={() => {}}
|
|
245
|
+
className="webpage-node-iframe"
|
|
238
246
|
title={pageTitle || (node.data.title as string) || url}
|
|
239
|
-
|
|
240
|
-
loading="lazy"
|
|
247
|
+
source={{ src: url }}
|
|
241
248
|
referrerPolicy="no-referrer"
|
|
242
249
|
sandbox="allow-downloads allow-forms allow-modals allow-popups allow-popups-to-escape-sandbox allow-presentation allow-scripts"
|
|
243
|
-
style={{
|
|
244
|
-
display: 'block',
|
|
245
|
-
width: '100%',
|
|
246
|
-
height: expanded ? '320px' : '180px',
|
|
247
|
-
border: 'none',
|
|
248
|
-
background: '#fff',
|
|
249
|
-
}}
|
|
250
250
|
/>
|
|
251
251
|
</div>
|
|
252
252
|
)}
|
|
@@ -266,10 +266,8 @@ export function WebpageNode({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
266
266
|
flex: 1,
|
|
267
267
|
minHeight: 0,
|
|
268
268
|
overflow: 'auto',
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
background: 'var(--c-panel-soft)',
|
|
272
|
-
padding: expanded ? '14px' : '10px',
|
|
269
|
+
borderTop: '1px solid var(--c-line)',
|
|
270
|
+
paddingTop: '12px',
|
|
273
271
|
}}
|
|
274
272
|
>
|
|
275
273
|
{excerpt ? (
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { useEffect,
|
|
1
|
+
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import { workbenchConnectionEpoch } from '../state/canvas-store';
|
|
3
3
|
import { iframeMode } from '../state/iframe-mode';
|
|
4
|
-
import { surfaceContentHash } from './surface-url';
|
|
5
4
|
import { workbenchFetch } from '../state/workbench-transport';
|
|
5
|
+
import { VIEWER_LOAD_DEADLINE_MS, type ViewerSource } from './ViewerFrame';
|
|
6
6
|
|
|
7
7
|
interface FrameDocumentCreateResponse {
|
|
8
8
|
ok: boolean;
|
|
@@ -21,11 +21,12 @@ function isFrameDocumentCreateResponse(value: unknown): value is FrameDocumentCr
|
|
|
21
21
|
);
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export async function createIframeDocumentUrl(html: string, sandbox: string): Promise<string> {
|
|
24
|
+
export async function createIframeDocumentUrl(html: string, sandbox: string, signal?: AbortSignal): Promise<string> {
|
|
25
25
|
const response = await workbenchFetch('/api/canvas/frame-documents', {
|
|
26
26
|
method: 'POST',
|
|
27
27
|
headers: { 'Content-Type': 'application/json' },
|
|
28
28
|
body: JSON.stringify({ html, sandbox }),
|
|
29
|
+
signal,
|
|
29
30
|
});
|
|
30
31
|
const json = (await response.json()) as unknown;
|
|
31
32
|
if (!response.ok || !isFrameDocumentCreateResponse(json) || !json.ok || typeof json.url !== 'string') {
|
|
@@ -38,32 +39,37 @@ export async function createIframeDocumentUrl(html: string, sandbox: string): Pr
|
|
|
38
39
|
return json.url;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
export function useIframeDocument(
|
|
42
|
-
html: string,
|
|
43
|
-
sandbox: string,
|
|
44
|
-
): { attributes: { src?: string; srcdoc?: string }; ready: boolean; key: string } {
|
|
42
|
+
export function useIframeDocument(html: string, sandbox: string): ViewerSource {
|
|
45
43
|
// Boot-wide embed probe (state/iframe-mode.ts): srcdoc mode means src-URL
|
|
46
44
|
// iframes are blocked here (nested-iframe hosts like Amp orb portals), so the
|
|
47
45
|
// frame-documents round-trip would produce a URL the browser refuses to load.
|
|
48
46
|
const mode = iframeMode.value;
|
|
49
47
|
const [src, setSrc] = useState<string | null>(null);
|
|
48
|
+
const [error, setError] = useState(false);
|
|
50
49
|
const [remintNonce, setRemintNonce] = useState(0);
|
|
51
50
|
const mintEpochRef = useRef(0);
|
|
52
51
|
|
|
53
52
|
useEffect(() => {
|
|
54
|
-
|
|
53
|
+
setError(false);
|
|
55
54
|
if (!html || mode !== 'src') return;
|
|
56
55
|
let cancelled = false;
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
const deadline = setTimeout(() => controller.abort(), VIEWER_LOAD_DEADLINE_MS);
|
|
57
58
|
mintEpochRef.current = workbenchConnectionEpoch.value;
|
|
58
|
-
void createIframeDocumentUrl(html, sandbox)
|
|
59
|
+
void createIframeDocumentUrl(html, sandbox, controller.signal)
|
|
59
60
|
.then((url) => {
|
|
60
61
|
if (!cancelled) setSrc(url);
|
|
61
62
|
})
|
|
62
63
|
.catch((error) => {
|
|
64
|
+
if (cancelled) return;
|
|
63
65
|
console.error('[iframe-document] failed to create frame document:', error);
|
|
64
|
-
|
|
66
|
+
setError(true);
|
|
67
|
+
})
|
|
68
|
+
.finally(() => clearTimeout(deadline));
|
|
65
69
|
return () => {
|
|
66
70
|
cancelled = true;
|
|
71
|
+
clearTimeout(deadline);
|
|
72
|
+
controller.abort();
|
|
67
73
|
};
|
|
68
74
|
}, [html, sandbox, mode, remintNonce]);
|
|
69
75
|
|
|
@@ -101,21 +107,8 @@ export function useIframeDocument(
|
|
|
101
107
|
};
|
|
102
108
|
}, [epoch, src, mode]);
|
|
103
109
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// sandboxing the frame-document CSP header would have.
|
|
109
|
-
return {
|
|
110
|
-
attributes: { srcdoc: html },
|
|
111
|
-
ready: true,
|
|
112
|
-
key: `srcdoc-${surfaceContentHash(html)}`,
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
return {
|
|
116
|
-
attributes: src ? { src } : {},
|
|
117
|
-
ready: Boolean(src),
|
|
118
|
-
key: src ?? '',
|
|
119
|
-
};
|
|
120
|
-
}, [src, mode, html]);
|
|
110
|
+
if (!html || mode === null) return {};
|
|
111
|
+
// The host always supplies the sandbox, including on inline documents.
|
|
112
|
+
if (mode === 'srcdoc') return { srcdoc: html };
|
|
113
|
+
return { src: src ?? undefined, error, retry: () => setRemintNonce((value) => value + 1) };
|
|
121
114
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { RefObject } from 'preact';
|
|
2
|
+
import { useLayoutEffect } from 'preact/hooks';
|
|
3
|
+
import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
|
|
4
|
+
import { showToast } from '../state/attention-bridge';
|
|
5
|
+
import type { CanvasNodeState } from '../types';
|
|
6
|
+
|
|
7
|
+
type Owner = { nodeId: string; token: string; frame: RefObject<HTMLIFrameElement>; expanded: boolean };
|
|
8
|
+
const owners = new Set<Owner>();
|
|
9
|
+
|
|
10
|
+
export function canCopyPrimitiveJson(node: CanvasNodeState): boolean {
|
|
11
|
+
return (
|
|
12
|
+
node.type === 'html' && typeof node.data.htmlPrimitive === 'string' && node.data.htmlPrimitive !== 'presentation'
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function usePrimitiveJson(
|
|
17
|
+
node: CanvasNodeState,
|
|
18
|
+
token: string,
|
|
19
|
+
frame: RefObject<HTMLIFrameElement>,
|
|
20
|
+
expanded: boolean,
|
|
21
|
+
): void {
|
|
22
|
+
const enabled = canCopyPrimitiveJson(node);
|
|
23
|
+
useLayoutEffect(() => {
|
|
24
|
+
if (!enabled) return;
|
|
25
|
+
const owner = { nodeId: node.id, token, frame, expanded };
|
|
26
|
+
owners.add(owner);
|
|
27
|
+
return () => {
|
|
28
|
+
owners.delete(owner);
|
|
29
|
+
};
|
|
30
|
+
}, [enabled, node.id, token, expanded]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readCurrentJson(nodeId: string): Promise<string> {
|
|
34
|
+
// The expanded document, when present, owns the latest interactive state.
|
|
35
|
+
const connected = [...owners].filter((owner) => owner.nodeId === nodeId && owner.frame.current?.isConnected);
|
|
36
|
+
const owner = connected.find((owner) => owner.expanded) ?? connected[0];
|
|
37
|
+
const frame = owner?.frame.current;
|
|
38
|
+
if (!owner || !frame?.contentWindow) return Promise.reject(new Error('Open the viewer before copying JSON.'));
|
|
39
|
+
const requestId = crypto.randomUUID();
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const finish = (json?: string) => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
window.removeEventListener('message', receive);
|
|
44
|
+
frame.removeEventListener('load', changed);
|
|
45
|
+
if (json !== undefined) resolve(json);
|
|
46
|
+
else reject(new Error('The viewer is unavailable or changed. Wait for it to load, then try again.'));
|
|
47
|
+
};
|
|
48
|
+
const changed = () => finish();
|
|
49
|
+
const receive = (event: MessageEvent) => {
|
|
50
|
+
const message = event.data;
|
|
51
|
+
if (
|
|
52
|
+
event.source !== frame.contentWindow ||
|
|
53
|
+
event.origin !== 'null' ||
|
|
54
|
+
message?.source !== HTML_SURFACE_PUSH_SOURCE ||
|
|
55
|
+
message.type !== 'copy-json-result' ||
|
|
56
|
+
message.token !== owner.token ||
|
|
57
|
+
message.requestId !== requestId
|
|
58
|
+
)
|
|
59
|
+
return;
|
|
60
|
+
finish(
|
|
61
|
+
owners.has(owner) && owner.frame.current === frame && frame.isConnected && typeof message.json === 'string'
|
|
62
|
+
? message.json
|
|
63
|
+
: undefined,
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
const timer = setTimeout(changed, 3000);
|
|
67
|
+
window.addEventListener('message', receive);
|
|
68
|
+
frame.addEventListener('load', changed, { once: true });
|
|
69
|
+
// Opaque-origin sandbox: exact source + per-document nonce bind the reply.
|
|
70
|
+
frame.contentWindow!.postMessage(
|
|
71
|
+
{ source: HTML_SURFACE_PUSH_SOURCE, type: 'copy-json', token: owner.token, requestId },
|
|
72
|
+
'*',
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function copyPrimitiveJson(nodeId: string): Promise<void> {
|
|
78
|
+
try {
|
|
79
|
+
if (!navigator.clipboard) throw new Error('Clipboard access is unavailable in this browser.');
|
|
80
|
+
const json = readCurrentJson(nodeId);
|
|
81
|
+
// Keep clipboard.write inside the user's activation, including WebKit. The
|
|
82
|
+
// iframe supplies bytes, never clipboard permission or a stale server export.
|
|
83
|
+
void json.catch(() => {});
|
|
84
|
+
if (typeof ClipboardItem !== 'undefined' && navigator.clipboard.write) {
|
|
85
|
+
await navigator.clipboard.write([
|
|
86
|
+
new ClipboardItem({ 'text/plain': json.then((text) => new Blob([text], { type: 'text/plain' })) }),
|
|
87
|
+
]);
|
|
88
|
+
} else {
|
|
89
|
+
await navigator.clipboard.writeText(await json);
|
|
90
|
+
}
|
|
91
|
+
showToast('context', 'JSON copied', '', [nodeId]);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
showToast('remove', 'Could not copy JSON', error instanceof Error ? error.message : 'Clipboard access failed.', [
|
|
94
|
+
nodeId,
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
}
|