pmx-canvas 0.1.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/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/Readme.md +865 -0
- package/dist/canvas/global.css +3173 -0
- package/dist/canvas/index.js +183 -0
- package/dist/json-render/index.css +2 -0
- package/dist/json-render/index.js +389 -0
- package/dist/types/cli/agent.d.ts +13 -0
- package/dist/types/cli/index.d.ts +2 -0
- package/dist/types/cli/watch.d.ts +5 -0
- package/dist/types/client/App.d.ts +1 -0
- package/dist/types/client/canvas/AttentionHistory.d.ts +1 -0
- package/dist/types/client/canvas/AttentionToast.d.ts +1 -0
- package/dist/types/client/canvas/CanvasNode.d.ts +8 -0
- package/dist/types/client/canvas/CanvasViewport.d.ts +8 -0
- package/dist/types/client/canvas/CommandPalette.d.ts +4 -0
- package/dist/types/client/canvas/ContextMenu.d.ts +24 -0
- package/dist/types/client/canvas/ContextPinBar.d.ts +1 -0
- package/dist/types/client/canvas/ContextPinHud.d.ts +1 -0
- package/dist/types/client/canvas/DockedNode.d.ts +4 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
- package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -0
- package/dist/types/client/canvas/FocusFieldLayer.d.ts +1 -0
- package/dist/types/client/canvas/Minimap.d.ts +23 -0
- package/dist/types/client/canvas/SelectionBar.d.ts +1 -0
- package/dist/types/client/canvas/ShortcutOverlay.d.ts +3 -0
- package/dist/types/client/canvas/SnapshotPanel.d.ts +7 -0
- package/dist/types/client/canvas/snap-guides.d.ts +23 -0
- package/dist/types/client/canvas/use-node-drag.d.ts +15 -0
- package/dist/types/client/canvas/use-node-resize.d.ts +15 -0
- package/dist/types/client/canvas/use-pan-zoom.d.ts +16 -0
- package/dist/types/client/ext-app/bridge.d.ts +161 -0
- package/dist/types/client/icons.d.ts +70 -0
- package/dist/types/client/index.d.ts +1 -0
- package/dist/types/client/nodes/ContextNode.d.ts +34 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +18 -0
- package/dist/types/client/nodes/FileNode.d.ts +5 -0
- package/dist/types/client/nodes/GroupNode.d.ts +6 -0
- package/dist/types/client/nodes/ImageNode.d.ts +10 -0
- package/dist/types/client/nodes/InlineFormatBar.d.ts +7 -0
- package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +14 -0
- package/dist/types/client/nodes/LedgerNode.d.ts +4 -0
- package/dist/types/client/nodes/MarkdownNode.d.ts +6 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +4 -0
- package/dist/types/client/nodes/MdFormatBar.d.ts +8 -0
- package/dist/types/client/nodes/PromptNode.d.ts +5 -0
- package/dist/types/client/nodes/ResponseNode.d.ts +5 -0
- package/dist/types/client/nodes/StatusNode.d.ts +4 -0
- package/dist/types/client/nodes/StatusSummary.d.ts +4 -0
- package/dist/types/client/nodes/TraceNode.d.ts +4 -0
- package/dist/types/client/nodes/WebpageNode.d.ts +5 -0
- package/dist/types/client/nodes/image-warnings.d.ts +6 -0
- package/dist/types/client/nodes/inline-editor-commands.d.ts +11 -0
- package/dist/types/client/nodes/md-format.d.ts +25 -0
- package/dist/types/client/state/attention-bridge.d.ts +3 -0
- package/dist/types/client/state/attention-store.d.ts +25 -0
- package/dist/types/client/state/canvas-store.d.ts +74 -0
- package/dist/types/client/state/intent-bridge.d.ts +158 -0
- package/dist/types/client/state/sse-bridge.d.ts +5 -0
- package/dist/types/client/theme/tokens.d.ts +27 -0
- package/dist/types/client/types.d.ts +40 -0
- package/dist/types/client/utils/ext-app-tool-result.d.ts +1 -0
- package/dist/types/client/utils/placement.d.ts +1 -0
- package/dist/types/client/utils/platform.d.ts +2 -0
- package/dist/types/json-render/catalog.d.ts +815 -0
- package/dist/types/json-render/charts/components.d.ts +54 -0
- package/dist/types/json-render/charts/definitions.d.ts +103 -0
- package/dist/types/json-render/charts/extra-components.d.ts +58 -0
- package/dist/types/json-render/charts/extra-definitions.d.ts +181 -0
- package/dist/types/json-render/renderer/index.d.ts +16 -0
- package/dist/types/json-render/schema.d.ts +46 -0
- package/dist/types/json-render/server.d.ts +55 -0
- package/dist/types/mcp/server.d.ts +22 -0
- package/dist/types/server/agent-context.d.ts +21 -0
- package/dist/types/server/artifact-paths.d.ts +3 -0
- package/dist/types/server/canvas-operations.d.ts +154 -0
- package/dist/types/server/canvas-provenance.d.ts +13 -0
- package/dist/types/server/canvas-schema.d.ts +49 -0
- package/dist/types/server/canvas-serialization.d.ts +25 -0
- package/dist/types/server/canvas-state.d.ts +174 -0
- package/dist/types/server/canvas-validation.d.ts +33 -0
- package/dist/types/server/chart-template.d.ts +29 -0
- package/dist/types/server/code-graph.d.ts +67 -0
- package/dist/types/server/context-cards.d.ts +24 -0
- package/dist/types/server/diagram-presets.d.ts +28 -0
- package/dist/types/server/ext-app-call-registry.d.ts +16 -0
- package/dist/types/server/ext-app-tool-result.d.ts +1 -0
- package/dist/types/server/file-watcher.d.ts +16 -0
- package/dist/types/server/index.d.ts +243 -0
- package/dist/types/server/mcp-app-candidate.d.ts +25 -0
- package/dist/types/server/mcp-app-host.d.ts +65 -0
- package/dist/types/server/mcp-app-runtime.d.ts +47 -0
- package/dist/types/server/mutation-history.d.ts +105 -0
- package/dist/types/server/placement.d.ts +37 -0
- package/dist/types/server/server.d.ts +103 -0
- package/dist/types/server/spatial-analysis.d.ts +87 -0
- package/dist/types/server/trace-manager.d.ts +48 -0
- package/dist/types/server/web-artifacts.d.ts +50 -0
- package/dist/types/server/webpage-node.d.ts +25 -0
- package/dist/types/shared/auto-arrange.d.ts +29 -0
- package/dist/types/shared/ext-app-tool-result.d.ts +9 -0
- package/dist/types/shared/placement.d.ts +26 -0
- package/dist/types/shared/semantic-attention.d.ts +97 -0
- package/package.json +109 -0
- package/skills/data-analysis/SKILL.md +324 -0
- package/skills/doc-coauthoring/SKILL.md +375 -0
- package/skills/frontend-design/SKILL.md +45 -0
- package/skills/json-render-codegen/SKILL.md +112 -0
- package/skills/json-render-core/SKILL.md +265 -0
- package/skills/json-render-ink/SKILL.md +273 -0
- package/skills/json-render-mcp/SKILL.md +132 -0
- package/skills/json-render-react/SKILL.md +264 -0
- package/skills/json-render-shadcn/SKILL.md +159 -0
- package/skills/playwright-cli/SKILL.md +67 -0
- package/skills/pmx-canvas/SKILL.md +668 -0
- package/skills/pmx-canvas/evals/evals.json +186 -0
- package/skills/pmx-canvas-testing/SKILL.md +78 -0
- package/skills/published-consumer-e2e/SKILL.md +43 -0
- package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +241 -0
- package/skills/web-artifacts-builder/SKILL.md +80 -0
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +167 -0
- package/skills/web-artifacts-builder/scripts/init-artifact.sh +425 -0
- package/skills/web-artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
- package/skills/web-design-guidelines/SKILL.md +39 -0
- package/src/cli/agent.ts +2144 -0
- package/src/cli/index.ts +622 -0
- package/src/cli/watch.ts +88 -0
- package/src/client/App.tsx +507 -0
- package/src/client/canvas/AttentionHistory.tsx +81 -0
- package/src/client/canvas/AttentionToast.tsx +19 -0
- package/src/client/canvas/CanvasNode.tsx +363 -0
- package/src/client/canvas/CanvasViewport.tsx +590 -0
- package/src/client/canvas/CommandPalette.tsx +302 -0
- package/src/client/canvas/ContextMenu.tsx +601 -0
- package/src/client/canvas/ContextPinBar.tsx +25 -0
- package/src/client/canvas/ContextPinHud.tsx +22 -0
- package/src/client/canvas/DockedNode.tsx +66 -0
- package/src/client/canvas/EdgeLayer.tsx +280 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +260 -0
- package/src/client/canvas/FocusFieldLayer.tsx +107 -0
- package/src/client/canvas/Minimap.tsx +301 -0
- package/src/client/canvas/SelectionBar.tsx +69 -0
- package/src/client/canvas/ShortcutOverlay.tsx +69 -0
- package/src/client/canvas/SnapshotPanel.tsx +236 -0
- package/src/client/canvas/snap-guides.ts +170 -0
- package/src/client/canvas/use-node-drag.ts +51 -0
- package/src/client/canvas/use-node-resize.ts +59 -0
- package/src/client/canvas/use-pan-zoom.ts +191 -0
- package/src/client/ext-app/bridge.ts +542 -0
- package/src/client/icons.tsx +424 -0
- package/src/client/index.tsx +7 -0
- package/src/client/nodes/ContextNode.tsx +412 -0
- package/src/client/nodes/ExtAppFrame.tsx +509 -0
- package/src/client/nodes/FileNode.tsx +256 -0
- package/src/client/nodes/GroupNode.tsx +39 -0
- package/src/client/nodes/ImageNode.tsx +160 -0
- package/src/client/nodes/InlineFormatBar.tsx +169 -0
- package/src/client/nodes/InlineMarkdownEditor.tsx +123 -0
- package/src/client/nodes/LedgerNode.tsx +37 -0
- package/src/client/nodes/MarkdownNode.tsx +359 -0
- package/src/client/nodes/McpAppNode.tsx +85 -0
- package/src/client/nodes/MdFormatBar.tsx +109 -0
- package/src/client/nodes/PromptNode.tsx +597 -0
- package/src/client/nodes/ResponseNode.tsx +153 -0
- package/src/client/nodes/StatusNode.tsx +84 -0
- package/src/client/nodes/StatusSummary.tsx +38 -0
- package/src/client/nodes/TraceNode.tsx +120 -0
- package/src/client/nodes/WebpageNode.tsx +288 -0
- package/src/client/nodes/image-warnings.ts +95 -0
- package/src/client/nodes/inline-editor-commands.ts +37 -0
- package/src/client/nodes/md-format.ts +206 -0
- package/src/client/state/attention-bridge.ts +328 -0
- package/src/client/state/attention-store.ts +73 -0
- package/src/client/state/canvas-store.ts +631 -0
- package/src/client/state/intent-bridge.ts +315 -0
- package/src/client/state/sse-bridge.ts +965 -0
- package/src/client/theme/global.css +3173 -0
- package/src/client/theme/tokens.ts +72 -0
- package/src/client/types-shims.d.ts +5 -0
- package/src/client/types.ts +81 -0
- package/src/client/utils/ext-app-tool-result.ts +4 -0
- package/src/client/utils/placement.ts +4 -0
- package/src/client/utils/platform.ts +2 -0
- package/src/json-render/catalog.ts +256 -0
- package/src/json-render/charts/components.tsx +198 -0
- package/src/json-render/charts/definitions.ts +81 -0
- package/src/json-render/charts/extra-components.tsx +267 -0
- package/src/json-render/charts/extra-definitions.ts +145 -0
- package/src/json-render/renderer/index.css +174 -0
- package/src/json-render/renderer/index.tsx +86 -0
- package/src/json-render/schema.ts +62 -0
- package/src/json-render/server.ts +597 -0
- package/src/mcp/server.ts +1377 -0
- package/src/server/agent-context.ts +242 -0
- package/src/server/artifact-paths.ts +17 -0
- package/src/server/canvas-operations.ts +1279 -0
- package/src/server/canvas-provenance.ts +243 -0
- package/src/server/canvas-schema.ts +432 -0
- package/src/server/canvas-serialization.ts +95 -0
- package/src/server/canvas-state.ts +1134 -0
- package/src/server/canvas-validation.ts +114 -0
- package/src/server/chart-template.ts +449 -0
- package/src/server/code-graph.ts +370 -0
- package/src/server/context-cards.ts +31 -0
- package/src/server/diagram-presets.ts +71 -0
- package/src/server/ext-app-call-registry.ts +77 -0
- package/src/server/ext-app-tool-result.ts +4 -0
- package/src/server/file-watcher.ts +121 -0
- package/src/server/index.ts +647 -0
- package/src/server/mcp-app-candidate.ts +174 -0
- package/src/server/mcp-app-host.ts +814 -0
- package/src/server/mcp-app-runtime.ts +459 -0
- package/src/server/mutation-history.ts +350 -0
- package/src/server/placement.ts +125 -0
- package/src/server/server.ts +3846 -0
- package/src/server/spatial-analysis.ts +356 -0
- package/src/server/trace-manager.ts +333 -0
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +167 -0
- package/src/server/web-artifacts/scripts/init-artifact.sh +426 -0
- package/src/server/web-artifacts/scripts/shadcn-components.tar.gz +0 -0
- package/src/server/web-artifacts.ts +442 -0
- package/src/server/webpage-node.ts +328 -0
- package/src/shared/auto-arrange.ts +439 -0
- package/src/shared/ext-app-tool-result.ts +76 -0
- package/src/shared/placement.ts +81 -0
- package/src/shared/semantic-attention.ts +598 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { gfm } from '@joplin/turndown-plugin-gfm';
|
|
2
|
+
import TurndownService from 'turndown';
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'preact/hooks';
|
|
4
|
+
import { InlineFormatBar } from './InlineFormatBar';
|
|
5
|
+
import { promptAndInsertLink } from './inline-editor-commands';
|
|
6
|
+
|
|
7
|
+
let _turndown: TurndownService | null = null;
|
|
8
|
+
function getTurndown(): TurndownService {
|
|
9
|
+
if (_turndown) return _turndown;
|
|
10
|
+
const td = new TurndownService({
|
|
11
|
+
headingStyle: 'atx',
|
|
12
|
+
codeBlockStyle: 'fenced',
|
|
13
|
+
emDelimiter: '*',
|
|
14
|
+
bulletListMarker: '-',
|
|
15
|
+
});
|
|
16
|
+
td.use(gfm);
|
|
17
|
+
_turndown = td;
|
|
18
|
+
return td;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Fully inline WYSIWYG editor. The rendered HTML is the editor.
|
|
22
|
+
*
|
|
23
|
+
* Two persistence signals:
|
|
24
|
+
* - `onChange(md)` — fires on every input. Caller typically debounces.
|
|
25
|
+
* - `onSave(md)` — fires on ⌘S and blur. Caller should persist immediately
|
|
26
|
+
* and cancel any pending debounced save, since `md` is the authoritative
|
|
27
|
+
* latest content. Both receive freshly-serialized markdown so the caller
|
|
28
|
+
* never reads a stale state snapshot. */
|
|
29
|
+
export function InlineMarkdownEditor({
|
|
30
|
+
initialHtml,
|
|
31
|
+
className,
|
|
32
|
+
onChange,
|
|
33
|
+
onSave,
|
|
34
|
+
}: {
|
|
35
|
+
initialHtml: string;
|
|
36
|
+
className?: string;
|
|
37
|
+
onChange: (markdown: string) => void;
|
|
38
|
+
onSave?: (markdown: string) => void;
|
|
39
|
+
}) {
|
|
40
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
41
|
+
// Capture mount-time HTML — decouples us from prop identity churn under
|
|
42
|
+
// strict-mode double-invoke. Re-mount via `key` to swap documents.
|
|
43
|
+
const initialHtmlRef = useRef(initialHtml);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const el = rootRef.current;
|
|
47
|
+
if (!el) return;
|
|
48
|
+
el.innerHTML = initialHtmlRef.current;
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
const serialize = useCallback((): string => {
|
|
52
|
+
const el = rootRef.current;
|
|
53
|
+
if (!el) return '';
|
|
54
|
+
return getTurndown().turndown(el.innerHTML);
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const handleInput = useCallback(() => {
|
|
58
|
+
onChange(serialize());
|
|
59
|
+
}, [onChange, serialize]);
|
|
60
|
+
|
|
61
|
+
const handleSave = useCallback(() => {
|
|
62
|
+
onSave?.(serialize());
|
|
63
|
+
}, [onSave, serialize]);
|
|
64
|
+
|
|
65
|
+
const handleKeyDown = useCallback(
|
|
66
|
+
(e: KeyboardEvent) => {
|
|
67
|
+
if (!(e.metaKey || e.ctrlKey)) return;
|
|
68
|
+
if (e.key === 's') {
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
handleSave();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (e.key === 'b') {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
document.execCommand('bold');
|
|
76
|
+
handleInput();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (e.key === 'i') {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
document.execCommand('italic');
|
|
82
|
+
handleInput();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (e.key === 'k') {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
promptAndInsertLink();
|
|
88
|
+
handleInput();
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
[handleInput, handleSave],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Arbitrary rich HTML (Word, the web) would force turndown to sanitize a
|
|
95
|
+
// far wider surface and tends to lose fidelity — plain text is safer.
|
|
96
|
+
const handlePaste = useCallback(
|
|
97
|
+
(e: ClipboardEvent) => {
|
|
98
|
+
const text = e.clipboardData?.getData('text/plain');
|
|
99
|
+
if (text == null) return;
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
document.execCommand('insertText', false, text);
|
|
102
|
+
handleInput();
|
|
103
|
+
},
|
|
104
|
+
[handleInput],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<>
|
|
109
|
+
<div
|
|
110
|
+
ref={rootRef}
|
|
111
|
+
class={className}
|
|
112
|
+
contentEditable
|
|
113
|
+
spellcheck={false}
|
|
114
|
+
onInput={handleInput}
|
|
115
|
+
onKeyDown={handleKeyDown}
|
|
116
|
+
onPaste={handlePaste}
|
|
117
|
+
onBlur={handleSave}
|
|
118
|
+
/>
|
|
119
|
+
<InlineFormatBar hostRef={rootRef} onChange={handleInput} />
|
|
120
|
+
</>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CanvasNodeState } from '../types';
|
|
2
|
+
|
|
3
|
+
export function LedgerNode({ node }: { node: CanvasNodeState }) {
|
|
4
|
+
const data = node.data as Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
// Render key-value pairs from ledger summary
|
|
7
|
+
const entries = Object.entries(data).filter(([key]) => key !== 'title' && key !== '__type');
|
|
8
|
+
|
|
9
|
+
if (entries.length === 0) {
|
|
10
|
+
return (
|
|
11
|
+
<div style={{ color: 'var(--c-dim)', fontSize: '12px', fontStyle: 'italic' }}>No ledger data</div>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', fontSize: '12px' }}>
|
|
17
|
+
{entries.map(([key, value]) => (
|
|
18
|
+
<div
|
|
19
|
+
key={key}
|
|
20
|
+
style={{
|
|
21
|
+
display: 'flex',
|
|
22
|
+
justifyContent: 'space-between',
|
|
23
|
+
padding: '3px 0',
|
|
24
|
+
borderBottom: '1px solid rgba(45,55,90,0.3)',
|
|
25
|
+
}}
|
|
26
|
+
>
|
|
27
|
+
<span style={{ color: 'var(--c-muted)', fontSize: '11px' }}>
|
|
28
|
+
{key.replace(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase())}
|
|
29
|
+
</span>
|
|
30
|
+
<span style={{ color: 'var(--c-text)', fontFamily: 'var(--mono)', fontSize: '11px' }}>
|
|
31
|
+
{typeof value === 'object' ? JSON.stringify(value) : String(value ?? '—')}
|
|
32
|
+
</span>
|
|
33
|
+
</div>
|
|
34
|
+
))}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import type { JSX } from 'preact';
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
3
|
+
import { expandNode, updateNodeData } from '../state/canvas-store';
|
|
4
|
+
import { fetchFile, renderMarkdown, saveFile, updateNodeFromClient } from '../state/intent-bridge';
|
|
5
|
+
import type { CanvasNodeState } from '../types';
|
|
6
|
+
import { MdFormatBar } from './MdFormatBar';
|
|
7
|
+
import { handleFormatShortcut, handleTab } from './md-format';
|
|
8
|
+
import { InlineMarkdownEditor } from './InlineMarkdownEditor';
|
|
9
|
+
|
|
10
|
+
function RenderedMarkdown({
|
|
11
|
+
html,
|
|
12
|
+
className,
|
|
13
|
+
style,
|
|
14
|
+
}: {
|
|
15
|
+
html: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
style?: string | JSX.CSSProperties;
|
|
18
|
+
}) {
|
|
19
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const container = containerRef.current;
|
|
23
|
+
if (!container) return;
|
|
24
|
+
container.replaceChildren();
|
|
25
|
+
if (!html) return;
|
|
26
|
+
const template = document.createElement('template');
|
|
27
|
+
template.innerHTML = html;
|
|
28
|
+
container.append(template.content.cloneNode(true));
|
|
29
|
+
}, [html]);
|
|
30
|
+
|
|
31
|
+
return <div ref={containerRef} class={className} style={style} />;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function MarkdownNode({
|
|
35
|
+
node,
|
|
36
|
+
expanded = false,
|
|
37
|
+
}: { node: CanvasNodeState; expanded?: boolean }) {
|
|
38
|
+
const path = node.data.path as string;
|
|
39
|
+
const [content, setContent] = useState('');
|
|
40
|
+
const [rendered, setRendered] = useState('');
|
|
41
|
+
const [loaded, setLoaded] = useState(false);
|
|
42
|
+
const [sourceMode, setSourceMode] = useState(false);
|
|
43
|
+
const [saving, setSaving] = useState(false);
|
|
44
|
+
const [dirty, setDirty] = useState(false);
|
|
45
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
46
|
+
const persistTimerRef = useRef<number | null>(null);
|
|
47
|
+
// Always-current md + saver, so the unmount cleanup can flush a pending
|
|
48
|
+
// debounced save without capturing stale closures.
|
|
49
|
+
const latestMdRef = useRef<string>('');
|
|
50
|
+
const persistFnRef = useRef<((md: string) => Promise<void>) | null>(null);
|
|
51
|
+
const reviewActive = node.data.reviewActive as boolean | undefined;
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
let cancelled = false;
|
|
55
|
+
(async () => {
|
|
56
|
+
let raw: string;
|
|
57
|
+
if (path) {
|
|
58
|
+
const result = await fetchFile(path);
|
|
59
|
+
if (cancelled) return;
|
|
60
|
+
raw = result.content;
|
|
61
|
+
} else if (node.data.content) {
|
|
62
|
+
raw = node.data.content as string;
|
|
63
|
+
} else {
|
|
64
|
+
setLoaded(true);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
setContent(raw);
|
|
68
|
+
const html = await renderMarkdown(raw);
|
|
69
|
+
if (cancelled) return;
|
|
70
|
+
setRendered(html);
|
|
71
|
+
setLoaded(true);
|
|
72
|
+
updateNodeData(node.id, { content: raw, rendered: html });
|
|
73
|
+
})();
|
|
74
|
+
return () => {
|
|
75
|
+
cancelled = true;
|
|
76
|
+
};
|
|
77
|
+
}, [path, node.id, node.data.content]);
|
|
78
|
+
|
|
79
|
+
const handleInput = useCallback(async (e: Event) => {
|
|
80
|
+
const value = (e.target as HTMLTextAreaElement).value;
|
|
81
|
+
setContent(value);
|
|
82
|
+
setDirty(true);
|
|
83
|
+
const html = await renderMarkdown(value);
|
|
84
|
+
setRendered(html);
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
const persistContent = useCallback(
|
|
88
|
+
async (newContent: string) => {
|
|
89
|
+
if (!path) {
|
|
90
|
+
const html = await renderMarkdown(newContent);
|
|
91
|
+
setRendered(html);
|
|
92
|
+
setDirty(false);
|
|
93
|
+
updateNodeData(node.id, { content: newContent, rendered: html });
|
|
94
|
+
await updateNodeFromClient(node.id, { content: newContent, data: { rendered: html } });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
setSaving(true);
|
|
98
|
+
const result = await saveFile(path, newContent);
|
|
99
|
+
setSaving(false);
|
|
100
|
+
if (result.ok) {
|
|
101
|
+
const html = await renderMarkdown(newContent);
|
|
102
|
+
setRendered(html);
|
|
103
|
+
setDirty(false);
|
|
104
|
+
updateNodeData(node.id, { content: newContent, rendered: html, savedAt: result.updatedAt });
|
|
105
|
+
await updateNodeFromClient(node.id, {
|
|
106
|
+
content: newContent,
|
|
107
|
+
data: { rendered: html, savedAt: result.updatedAt },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
[path, node.id],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const handleSave = useCallback(async () => {
|
|
115
|
+
if (!dirty) return;
|
|
116
|
+
await persistContent(content);
|
|
117
|
+
}, [dirty, content, persistContent]);
|
|
118
|
+
|
|
119
|
+
const handleKeyDown = useCallback(
|
|
120
|
+
(e: KeyboardEvent) => {
|
|
121
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
handleSave();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const ta = textareaRef.current;
|
|
127
|
+
if (ta && handleFormatShortcut(e, ta)) return;
|
|
128
|
+
if (ta && e.key === 'Tab') {
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
handleTab(ta, e.shiftKey);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
[handleSave],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Keep refs in sync so the unmount-cleanup effect below can flush with
|
|
137
|
+
// the freshest values instead of closure captures.
|
|
138
|
+
persistFnRef.current = persistContent;
|
|
139
|
+
|
|
140
|
+
// Editor fires onChange on every keystroke; debounce the server save so we
|
|
141
|
+
// don't hit the backend on every letter.
|
|
142
|
+
const handleInlineChange = useCallback(
|
|
143
|
+
(md: string) => {
|
|
144
|
+
setContent(md);
|
|
145
|
+
setDirty(true);
|
|
146
|
+
latestMdRef.current = md;
|
|
147
|
+
if (persistTimerRef.current !== null) window.clearTimeout(persistTimerRef.current);
|
|
148
|
+
persistTimerRef.current = window.setTimeout(() => {
|
|
149
|
+
persistTimerRef.current = null;
|
|
150
|
+
void persistContent(md);
|
|
151
|
+
}, 800);
|
|
152
|
+
},
|
|
153
|
+
[persistContent],
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Fires on ⌘S and blur — persist immediately with whatever markdown the
|
|
157
|
+
// editor just serialized. Cancels any pending debounced save so we don't
|
|
158
|
+
// write twice with slightly different content.
|
|
159
|
+
const handleInlineSave = useCallback(
|
|
160
|
+
(md: string) => {
|
|
161
|
+
setContent(md);
|
|
162
|
+
latestMdRef.current = md;
|
|
163
|
+
if (persistTimerRef.current !== null) {
|
|
164
|
+
window.clearTimeout(persistTimerRef.current);
|
|
165
|
+
persistTimerRef.current = null;
|
|
166
|
+
}
|
|
167
|
+
void persistContent(md);
|
|
168
|
+
},
|
|
169
|
+
[persistContent],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// On unmount / node switch, flush any pending debounced save so trailing
|
|
173
|
+
// keystrokes aren't dropped when the user switches to another document.
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
return () => {
|
|
176
|
+
if (persistTimerRef.current === null) return;
|
|
177
|
+
window.clearTimeout(persistTimerRef.current);
|
|
178
|
+
persistTimerRef.current = null;
|
|
179
|
+
const fn = persistFnRef.current;
|
|
180
|
+
if (fn) void fn(latestMdRef.current);
|
|
181
|
+
};
|
|
182
|
+
}, [node.id]);
|
|
183
|
+
|
|
184
|
+
const reviewBanner = reviewActive ? (
|
|
185
|
+
<div
|
|
186
|
+
style={{
|
|
187
|
+
padding: '4px 8px',
|
|
188
|
+
fontSize: '10px',
|
|
189
|
+
background: 'var(--c-ok-10)',
|
|
190
|
+
color: 'var(--c-ok)',
|
|
191
|
+
borderBottom: '1px solid var(--c-ok-20)',
|
|
192
|
+
textTransform: 'uppercase',
|
|
193
|
+
letterSpacing: '0.05em',
|
|
194
|
+
fontWeight: 600,
|
|
195
|
+
}}
|
|
196
|
+
>
|
|
197
|
+
Review active
|
|
198
|
+
</div>
|
|
199
|
+
) : null;
|
|
200
|
+
|
|
201
|
+
// ── Raw source editor (escape hatch) ──────────────────────────
|
|
202
|
+
|
|
203
|
+
if (sourceMode && expanded) {
|
|
204
|
+
return (
|
|
205
|
+
<div class="md-editor-expanded" onKeyDown={handleKeyDown}>
|
|
206
|
+
<div class="md-editor-toolbar">
|
|
207
|
+
<button type="button" class="md-toolbar-btn" onClick={() => setSourceMode(false)}>
|
|
208
|
+
← Back to document
|
|
209
|
+
</button>
|
|
210
|
+
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
|
211
|
+
{path && <span class="md-toolbar-path">{path.split('/').pop()}</span>}
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
class={`md-toolbar-btn${dirty ? ' md-toolbar-btn-primary' : ''}`}
|
|
215
|
+
onClick={handleSave}
|
|
216
|
+
disabled={!dirty || saving}
|
|
217
|
+
>
|
|
218
|
+
{saving ? 'Saving…' : dirty ? 'Save' : 'Saved'}
|
|
219
|
+
</button>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
<div class="md-editor-split">
|
|
223
|
+
<div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
|
224
|
+
<textarea ref={textareaRef} value={content} onInput={handleInput} spellcheck={false} />
|
|
225
|
+
<MdFormatBar textareaRef={textareaRef} />
|
|
226
|
+
</div>
|
|
227
|
+
{/* D5/H4: Trust boundary — rendered HTML comes from server-side marked()
|
|
228
|
+
on the user's own markdown files, served only on 127.0.0.1. No DOMPurify
|
|
229
|
+
needed for this localhost-only rendering of user-owned content. */}
|
|
230
|
+
<RenderedMarkdown html={rendered} className="md-preview" />
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (sourceMode) {
|
|
237
|
+
return (
|
|
238
|
+
<div class="md-editor-split" style={{ height: '100%' }} onKeyDown={handleKeyDown}>
|
|
239
|
+
<div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
|
240
|
+
<textarea ref={textareaRef} value={content} onInput={handleInput} spellcheck={false} />
|
|
241
|
+
<MdFormatBar textareaRef={textareaRef} />
|
|
242
|
+
</div>
|
|
243
|
+
<RenderedMarkdown html={rendered} className="md-preview" />
|
|
244
|
+
<div
|
|
245
|
+
style={{
|
|
246
|
+
position: 'absolute',
|
|
247
|
+
bottom: '8px',
|
|
248
|
+
right: '8px',
|
|
249
|
+
display: 'flex',
|
|
250
|
+
gap: '6px',
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
<button
|
|
254
|
+
type="button"
|
|
255
|
+
onClick={() => setSourceMode(false)}
|
|
256
|
+
style={{
|
|
257
|
+
padding: '4px 10px',
|
|
258
|
+
fontSize: '11px',
|
|
259
|
+
background: 'var(--c-input-bg)',
|
|
260
|
+
border: '1px solid var(--c-line)',
|
|
261
|
+
borderRadius: '6px',
|
|
262
|
+
color: 'var(--c-text-soft)',
|
|
263
|
+
cursor: 'pointer',
|
|
264
|
+
}}
|
|
265
|
+
>
|
|
266
|
+
Document
|
|
267
|
+
</button>
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
onClick={handleSave}
|
|
271
|
+
disabled={!dirty || saving}
|
|
272
|
+
style={{
|
|
273
|
+
padding: '4px 10px',
|
|
274
|
+
fontSize: '11px',
|
|
275
|
+
background: dirty ? 'var(--c-accent-25)' : 'var(--c-input-bg)',
|
|
276
|
+
border: `1px solid ${dirty ? 'var(--c-accent)' : 'var(--c-line)'}`,
|
|
277
|
+
borderRadius: '6px',
|
|
278
|
+
color: dirty ? 'var(--c-text)' : 'var(--c-dim)',
|
|
279
|
+
cursor: dirty ? 'pointer' : 'default',
|
|
280
|
+
}}
|
|
281
|
+
>
|
|
282
|
+
{saving ? 'Saving…' : dirty ? 'Save' : 'Saved'}
|
|
283
|
+
</button>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Expanded document mode (inline WYSIWYG) ───────────────────
|
|
290
|
+
|
|
291
|
+
if (expanded) {
|
|
292
|
+
return (
|
|
293
|
+
<div style={{ height: '100%', position: 'relative' }}>
|
|
294
|
+
{reviewBanner}
|
|
295
|
+
<div class="md-reader">
|
|
296
|
+
{loaded ? (
|
|
297
|
+
<InlineMarkdownEditor
|
|
298
|
+
key={node.id}
|
|
299
|
+
initialHtml={rendered || '<p><br></p>'}
|
|
300
|
+
className="md-reader-content md-reader-editable"
|
|
301
|
+
onChange={handleInlineChange}
|
|
302
|
+
onSave={handleInlineSave}
|
|
303
|
+
/>
|
|
304
|
+
) : (
|
|
305
|
+
<div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '24px' }}>
|
|
306
|
+
Loading…
|
|
307
|
+
</div>
|
|
308
|
+
)}
|
|
309
|
+
</div>
|
|
310
|
+
<button type="button" class="md-edit-fab" onClick={() => setSourceMode(true)}>
|
|
311
|
+
{'</> Source'}
|
|
312
|
+
</button>
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── Card preview ──────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<div style={{ height: '100%', position: 'relative' }}>
|
|
321
|
+
{reviewBanner}
|
|
322
|
+
<RenderedMarkdown
|
|
323
|
+
html={rendered}
|
|
324
|
+
style={{ padding: rendered ? '0' : '12px', color: rendered ? undefined : 'var(--c-dim)' }}
|
|
325
|
+
/>
|
|
326
|
+
{!loaded && (
|
|
327
|
+
<div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>Loading…</div>
|
|
328
|
+
)}
|
|
329
|
+
{loaded && !rendered && (
|
|
330
|
+
<div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>Empty node</div>
|
|
331
|
+
)}
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
onClick={() => expandNode(node.id)}
|
|
335
|
+
style={{
|
|
336
|
+
position: 'absolute',
|
|
337
|
+
top: '4px',
|
|
338
|
+
right: '4px',
|
|
339
|
+
padding: '3px 8px',
|
|
340
|
+
fontSize: '10px',
|
|
341
|
+
background: 'var(--c-panel-overlay)',
|
|
342
|
+
border: '1px solid var(--c-line)',
|
|
343
|
+
borderRadius: '4px',
|
|
344
|
+
color: 'var(--c-text-soft)',
|
|
345
|
+
cursor: 'pointer',
|
|
346
|
+
opacity: 0.7,
|
|
347
|
+
}}
|
|
348
|
+
onMouseEnter={(e) => {
|
|
349
|
+
(e.target as HTMLElement).style.opacity = '1';
|
|
350
|
+
}}
|
|
351
|
+
onMouseLeave={(e) => {
|
|
352
|
+
(e.target as HTMLElement).style.opacity = '0.7';
|
|
353
|
+
}}
|
|
354
|
+
>
|
|
355
|
+
Edit
|
|
356
|
+
</button>
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { CanvasNodeState } from '../types';
|
|
2
|
+
import { canvasTheme } from '../state/canvas-store';
|
|
3
|
+
import { ExtAppFrame } from './ExtAppFrame';
|
|
4
|
+
|
|
5
|
+
function withTheme(url: string): string {
|
|
6
|
+
if (!url) return url;
|
|
7
|
+
try {
|
|
8
|
+
const resolved = new URL(url, window.location.origin);
|
|
9
|
+
resolved.searchParams.set('theme', canvasTheme.value === 'light' ? 'light' : 'dark');
|
|
10
|
+
return resolved.toString();
|
|
11
|
+
} catch {
|
|
12
|
+
return url;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function McpAppNode({ node }: { node: CanvasNodeState }) {
|
|
17
|
+
if (node.data.mode === 'ext-app') {
|
|
18
|
+
return <ExtAppFrame node={node} />;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const url = withTheme((node.data.url as string) || '');
|
|
22
|
+
const sourceServer = (node.data.sourceServer as string) || '';
|
|
23
|
+
const hostMode = (node.data.hostMode as string) || 'hosted';
|
|
24
|
+
const fallbackReason = node.data.fallbackReason as string | undefined;
|
|
25
|
+
const trustedDomain = node.data.trustedDomain as boolean | undefined;
|
|
26
|
+
|
|
27
|
+
if (hostMode === 'fallback') {
|
|
28
|
+
return (
|
|
29
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', fontSize: '12px' }}>
|
|
30
|
+
<div style={{ color: 'var(--c-warn)', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
31
|
+
<span>⚠</span>
|
|
32
|
+
<span>Cannot embed — opened externally</span>
|
|
33
|
+
</div>
|
|
34
|
+
{fallbackReason && (
|
|
35
|
+
<div style={{ color: 'var(--c-muted)', fontSize: '11px' }}>Reason: {fallbackReason}</div>
|
|
36
|
+
)}
|
|
37
|
+
<a
|
|
38
|
+
href={url}
|
|
39
|
+
target="_blank"
|
|
40
|
+
rel="noopener noreferrer"
|
|
41
|
+
style={{
|
|
42
|
+
color: 'var(--c-accent)',
|
|
43
|
+
fontSize: '12px',
|
|
44
|
+
wordBreak: 'break-all',
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{url}
|
|
48
|
+
</a>
|
|
49
|
+
{sourceServer && (
|
|
50
|
+
<div style={{ color: 'var(--c-dim)', fontSize: '10px' }}>Source: {sourceServer}</div>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
58
|
+
{!trustedDomain && (
|
|
59
|
+
<div
|
|
60
|
+
style={{
|
|
61
|
+
padding: '4px 8px',
|
|
62
|
+
fontSize: '10px',
|
|
63
|
+
background: 'var(--c-warn-10)',
|
|
64
|
+
color: 'var(--c-warn)',
|
|
65
|
+
borderBottom: '1px solid var(--c-warn-15)',
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
Unverified domain
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
{/* Plain iframe-backed viewers stay on an opaque origin. Hosted ext-apps use
|
|
72
|
+
the explicit postMessage bridge instead, which is the only path that needs
|
|
73
|
+
app/host RPC and broader capabilities. */}
|
|
74
|
+
<iframe
|
|
75
|
+
src={url}
|
|
76
|
+
class="mcp-app-frame"
|
|
77
|
+
sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
|
|
78
|
+
allow="clipboard-read; clipboard-write"
|
|
79
|
+
loading="lazy"
|
|
80
|
+
style={{ flex: 1 }}
|
|
81
|
+
title={`MCP App: ${sourceServer}`}
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|