pmx-canvas 0.6.5 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/pmx-canvas/extension.mjs +139 -14
- package/CHANGELOG.md +70 -0
- package/Readme.md +32 -6
- package/dist/canvas/global.css +510 -31
- package/dist/canvas/index.js +92 -77
- package/dist/types/cli/agent.d.ts +3 -0
- package/dist/types/cli/commands/backup.d.ts +1 -0
- package/dist/types/cli/commands/boards.d.ts +1 -0
- package/dist/types/cli/commands/export.d.ts +1 -0
- package/dist/types/cli/daemon.d.ts +5 -5
- package/dist/types/cli/shared.d.ts +3 -1
- package/dist/types/client/canvas/BoardCopyDialog.d.ts +5 -0
- package/dist/types/client/canvas/BoardFolderDialog.d.ts +7 -0
- package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -0
- package/dist/types/client/canvas/BoardSwitcher.d.ts +8 -0
- package/dist/types/client/canvas/ExportDialog.d.ts +7 -0
- package/dist/types/client/canvas/HomeView.d.ts +9 -0
- package/dist/types/client/nodes/AttachmentNode.d.ts +6 -0
- package/dist/types/client/nodes/BoardNode.d.ts +5 -0
- package/dist/types/client/state/boards-store.d.ts +79 -0
- package/dist/types/client/state/canvas-store.d.ts +2 -0
- package/dist/types/client/state/intent-bridge.d.ts +1 -0
- package/dist/types/client/state/session-store.d.ts +7 -0
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/json-render/catalog.d.ts +1 -1
- package/dist/types/json-render/charts/definitions.d.ts +1 -1
- package/dist/types/json-render/server.d.ts +6 -0
- package/dist/types/mcp/canvas-access.d.ts +10 -3
- package/dist/types/mcp/context-uri-template.d.ts +6 -0
- package/dist/types/server/agent-presence.d.ts +1 -1
- package/dist/types/server/attribution.d.ts +11 -0
- package/dist/types/server/ax-context.d.ts +3 -1
- package/dist/types/server/ax-interaction.d.ts +2 -2
- package/dist/types/server/ax-state.d.ts +13 -3
- package/dist/types/server/backup.d.ts +36 -0
- package/dist/types/server/board-export.d.ts +45 -0
- package/dist/types/server/canvas-db.d.ts +63 -9
- package/dist/types/server/canvas-operations.d.ts +23 -0
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-state.d.ts +140 -3
- package/dist/types/server/context-brief.d.ts +111 -0
- package/dist/types/server/context-reads.d.ts +55 -0
- package/dist/types/server/document-import.d.ts +66 -0
- package/dist/types/server/index.d.ts +103 -4
- package/dist/types/server/mutation-history.d.ts +1 -0
- package/dist/types/server/operations/invoker.d.ts +2 -0
- package/dist/types/server/operations/mcp.d.ts +7 -1
- package/dist/types/server/operations/ops/ax-shared.d.ts +2 -2
- package/dist/types/server/operations/ops/backup.d.ts +2 -0
- package/dist/types/server/operations/ops/boards.d.ts +10 -0
- package/dist/types/server/operations/ops/export.d.ts +2 -0
- package/dist/types/server/operations/ops/imports.d.ts +2 -0
- package/dist/types/server/operations/ops/nodes.d.ts +1 -1
- package/dist/types/server/operations/ops/query.d.ts +2 -0
- package/dist/types/server/operations/registry.d.ts +9 -1
- package/dist/types/server/operations/types.d.ts +1 -1
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/trace-manager.d.ts +7 -0
- package/dist/types/server/webpage-node.d.ts +22 -1
- package/dist/types/server/workbench-auth.d.ts +3 -0
- package/dist/types/shared/boards.d.ts +6 -0
- package/docs/RELEASE.md +5 -0
- package/docs/cli.md +73 -0
- package/docs/http-api.md +198 -2
- package/docs/mcp.md +71 -6
- package/docs/node-types.md +26 -0
- package/docs/plans/plan-010-attributed-ask-answers.md +39 -0
- package/docs/plans/plan-011-context-read-instrumentation.md +54 -0
- package/docs/plans/plan-012-many-boards-stage-1.md +98 -0
- package/docs/plans/plan-013-static-board-export.md +47 -0
- package/docs/plans/plan-014-connected-board-memory.md +140 -0
- package/docs/product-vision-2026-09.md +118 -18
- package/docs/sdk.md +50 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +68 -11
- package/skills/pmx-canvas/evals/evals.json +130 -0
- package/skills/pmx-canvas/references/full-reference.md +43 -7
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +3 -0
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
- package/skills/pmx-canvas/references/tours-and-recording.md +69 -0
- package/skills/pmx-canvas-orchestration/SKILL.md +5 -0
- package/skills/pmx-canvas-testing/SKILL.md +11 -2
- package/src/cli/agent.ts +4 -0
- package/src/cli/commands/apps.ts +1 -1
- package/src/cli/commands/ax.ts +13 -0
- package/src/cli/commands/backup.ts +66 -0
- package/src/cli/commands/boards.ts +71 -0
- package/src/cli/commands/export.ts +37 -0
- package/src/cli/commands/nodes.ts +4 -1
- package/src/cli/commands/pins.ts +6 -2
- package/src/cli/commands/pump.ts +3 -0
- package/src/cli/commands/query.ts +33 -7
- package/src/cli/commands/view.ts +7 -5
- package/src/cli/daemon.ts +37 -7
- package/src/cli/index.ts +19 -9
- package/src/cli/shared.ts +11 -2
- package/src/client/App.tsx +21 -4
- package/src/client/canvas/AttentionToast.tsx +5 -3
- package/src/client/canvas/BoardCopyDialog.tsx +153 -0
- package/src/client/canvas/BoardFolderDialog.tsx +120 -0
- package/src/client/canvas/BoardLinkDialog.tsx +93 -0
- package/src/client/canvas/BoardSwitcher.tsx +149 -0
- package/src/client/canvas/CanvasNode.tsx +0 -1
- package/src/client/canvas/CanvasViewport.tsx +3 -0
- package/src/client/canvas/CommandPalette.tsx +107 -4
- package/src/client/canvas/ContextMenu.tsx +4 -1
- package/src/client/canvas/ExpandedNodeOverlay.tsx +3 -0
- package/src/client/canvas/ExportDialog.tsx +199 -0
- package/src/client/canvas/HomeView.tsx +277 -0
- package/src/client/canvas/Presentation.tsx +39 -9
- package/src/client/canvas/SelectionBar.tsx +0 -2
- package/src/client/canvas/SessionPanel.tsx +28 -1
- package/src/client/canvas/ToolRail.tsx +38 -3
- package/src/client/canvas/TopBar.tsx +34 -18
- package/src/client/canvas/import-files.ts +19 -1
- package/src/client/canvas/kind-colors.ts +1 -0
- package/src/client/icons.tsx +2 -0
- package/src/client/nodes/AttachmentNode.tsx +145 -0
- package/src/client/nodes/BoardNode.tsx +35 -0
- package/src/client/nodes/FileNode.tsx +6 -0
- package/src/client/nodes/MarkdownNode.tsx +47 -19
- package/src/client/nodes/ax-node-actions.ts +2 -5
- package/src/client/nodes/use-ax-surface-bridge.ts +1 -1
- package/src/client/state/attention-bridge.ts +3 -1
- package/src/client/state/boards-store.ts +167 -0
- package/src/client/state/canvas-store.ts +22 -0
- package/src/client/state/intent-bridge.ts +30 -2
- package/src/client/state/node-factory.ts +1 -0
- package/src/client/state/session-store.ts +5 -0
- package/src/client/state/sse-bridge.ts +52 -6
- package/src/client/theme/global.css +510 -31
- package/src/client/types.ts +2 -0
- package/src/json-render/server.ts +16 -9
- package/src/mcp/canvas-access.ts +59 -22
- package/src/mcp/context-uri-template.ts +28 -0
- package/src/mcp/server.ts +122 -15
- package/src/server/agent-context.ts +116 -29
- package/src/server/attribution.ts +29 -0
- package/src/server/ax-context.ts +4 -2
- package/src/server/ax-interaction.ts +1 -0
- package/src/server/ax-state-manager.ts +72 -3
- package/src/server/ax-state.ts +41 -0
- package/src/server/backup.ts +178 -0
- package/src/server/board-export.ts +686 -0
- package/src/server/canvas-db.ts +599 -107
- package/src/server/canvas-operations.ts +68 -9
- package/src/server/canvas-provenance.ts +1 -0
- package/src/server/canvas-schema.ts +12 -0
- package/src/server/canvas-state.ts +774 -45
- package/src/server/context-brief.ts +372 -0
- package/src/server/context-reads.ts +238 -0
- package/src/server/demo-state.json +659 -2
- package/src/server/document-import.ts +223 -0
- package/src/server/index.ts +231 -5
- package/src/server/mutation-history.ts +10 -13
- package/src/server/operations/composites.ts +26 -4
- package/src/server/operations/http.ts +9 -3
- package/src/server/operations/index.ts +8 -0
- package/src/server/operations/invoker.ts +14 -3
- package/src/server/operations/mcp.ts +50 -11
- package/src/server/operations/ops/app.ts +5 -0
- package/src/server/operations/ops/ax-read.ts +10 -6
- package/src/server/operations/ops/ax-timeline.ts +75 -0
- package/src/server/operations/ops/backup.ts +134 -0
- package/src/server/operations/ops/batch.ts +8 -1
- package/src/server/operations/ops/boards.ts +351 -0
- package/src/server/operations/ops/export.ts +74 -0
- package/src/server/operations/ops/ext-app.ts +4 -0
- package/src/server/operations/ops/imports.ts +236 -0
- package/src/server/operations/ops/nodes.ts +57 -5
- package/src/server/operations/ops/query.ts +206 -9
- package/src/server/operations/registry.ts +123 -31
- package/src/server/operations/types.ts +1 -1
- package/src/server/server.ts +146 -2
- package/src/server/spatial-analysis.ts +9 -17
- package/src/server/trace-manager.ts +19 -0
- package/src/server/web-artifacts.ts +2 -1
- package/src/server/webpage-node.ts +173 -13
- package/src/server/workbench-auth.ts +11 -0
- package/src/shared/boards.ts +18 -0
- package/src/shared/node-sizes.ts +1 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static board export (plan 013, vision move 11): one self-contained HTML file
|
|
3
|
+
* of one board — read-only, pan / zoom / expand, no server. The first step to
|
|
4
|
+
* sharing, so the owner sees what leaves the machine (`previewBoardExport`)
|
|
5
|
+
* before the file is written.
|
|
6
|
+
*
|
|
7
|
+
* Card bodies are rendered here as escaped HTML. Anything that runs code (html
|
|
8
|
+
* cards, charts, diagrams, built apps) goes into a sandboxed iframe with no
|
|
9
|
+
* same-origin, exactly as on the canvas. The chart and diagram viewer bundles
|
|
10
|
+
* are stored once in the file and spliced into each iframe at open time.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
14
|
+
import { Marked, type RendererThis, type Tokens } from 'marked';
|
|
15
|
+
import { canvasThemeScheme } from '../shared/themes.js';
|
|
16
|
+
import { buildJsonRenderViewerHtml, escapeInlineScriptSource, readJsonRenderBundle } from '../json-render/server.js';
|
|
17
|
+
import type { JsonRenderSpec } from '../json-render/server.js';
|
|
18
|
+
import { getCanvasNodeTitle } from './canvas-serialization.js';
|
|
19
|
+
import { canvasState, PMX_CANVAS_DIR, type CanvasEdge, type CanvasNodeState } from './canvas-state.js';
|
|
20
|
+
import { buildHtmlSurfaceDocument, buildMermaidSurfaceHtml } from './html-surface.js';
|
|
21
|
+
import { validateLocalImageFile } from './image-source.js';
|
|
22
|
+
|
|
23
|
+
const JR_JS = '__PMX_EXPORT_JSONRENDER_JS__';
|
|
24
|
+
const JR_CSS = '__PMX_EXPORT_JSONRENDER_CSS__';
|
|
25
|
+
const MERMAID_SRC = '__PMX_EXPORT_MERMAID_SRC__';
|
|
26
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
export interface ExportManifest {
|
|
29
|
+
boardId: string;
|
|
30
|
+
boardName: string;
|
|
31
|
+
cards: number;
|
|
32
|
+
connections: number;
|
|
33
|
+
/** Cards shown in sandboxed frames: html, charts, diagrams, built apps. */
|
|
34
|
+
frames: number;
|
|
35
|
+
placeholders: Array<{ nodeId: string; title: string; reason: string }>;
|
|
36
|
+
files: Array<{ nodeId: string; path: string; included: boolean }>;
|
|
37
|
+
attachments: Array<{ nodeId: string; name: string }>;
|
|
38
|
+
sourceDerivedCards: Array<{ nodeId: string; included: boolean }>;
|
|
39
|
+
embeddedImages: number;
|
|
40
|
+
/** Web images the file loads when opened. */
|
|
41
|
+
remoteImages: string[];
|
|
42
|
+
/** Navigable links present in markdown and webpage cards. */
|
|
43
|
+
links: string[];
|
|
44
|
+
/** Statically visible network destinations in sandboxed HTML frames. */
|
|
45
|
+
frameNetworkDestinations: string[];
|
|
46
|
+
/** Scripts in embedded frames can make requests that static inspection cannot enumerate. */
|
|
47
|
+
embeddedCodeCanAccessNetwork: boolean;
|
|
48
|
+
includeFiles: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ExportCard {
|
|
52
|
+
id: string;
|
|
53
|
+
kind: string;
|
|
54
|
+
title: string;
|
|
55
|
+
x: number;
|
|
56
|
+
y: number;
|
|
57
|
+
w: number;
|
|
58
|
+
h: number;
|
|
59
|
+
group?: true;
|
|
60
|
+
html?: string;
|
|
61
|
+
frame?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Collected {
|
|
65
|
+
manifest: ExportManifest;
|
|
66
|
+
cards: ExportCard[];
|
|
67
|
+
edges: CanvasEdge[];
|
|
68
|
+
scheme: 'dark' | 'light';
|
|
69
|
+
needsJsonRender: boolean;
|
|
70
|
+
needsMermaid: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function escapeHtml(value: string): string {
|
|
74
|
+
return value
|
|
75
|
+
.replaceAll('&', '&')
|
|
76
|
+
.replaceAll('<', '<')
|
|
77
|
+
.replaceAll('>', '>')
|
|
78
|
+
.replaceAll('"', '"')
|
|
79
|
+
.replaceAll("'", ''');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const text = (value: unknown): string => (typeof value === 'string' ? value : '');
|
|
83
|
+
const isSafeLink = (href: string): boolean => /^(https?:|mailto:|#)/i.test(href.trim());
|
|
84
|
+
|
|
85
|
+
function createMarkdown(remoteImages: Set<string>, links: Set<string>): Marked {
|
|
86
|
+
return new Marked({
|
|
87
|
+
renderer: {
|
|
88
|
+
// Raw HTML in a card is shown as text: the export's own page must run no author script.
|
|
89
|
+
html({ text: raw }: Tokens.HTML | Tokens.Tag) {
|
|
90
|
+
return escapeHtml(raw);
|
|
91
|
+
},
|
|
92
|
+
link(this: RendererThis, { href, tokens }: Tokens.Link) {
|
|
93
|
+
const inner = this.parser.parseInline(tokens);
|
|
94
|
+
if (/^https?:/i.test(href)) links.add(href);
|
|
95
|
+
return isSafeLink(href)
|
|
96
|
+
? `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${inner}</a>`
|
|
97
|
+
: inner;
|
|
98
|
+
},
|
|
99
|
+
image({ href, text: alt }: Tokens.Image) {
|
|
100
|
+
if (/^https?:/i.test(href)) {
|
|
101
|
+
remoteImages.add(href);
|
|
102
|
+
return `<img src="${escapeHtml(href)}" alt="${escapeHtml(alt)}" referrerpolicy="no-referrer">`;
|
|
103
|
+
}
|
|
104
|
+
if (/^data:image\//i.test(href)) return `<img src="${escapeHtml(href)}" alt="${escapeHtml(alt)}">`;
|
|
105
|
+
return escapeHtml(alt);
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function collectFrameNetworkDestinations(html: string, destinations: Set<string>): void {
|
|
112
|
+
const patterns = [
|
|
113
|
+
/\b(?:src|href|action|poster)\s*=\s*["'](https?:\/\/[^"']+)["']/gi,
|
|
114
|
+
/\burl\(\s*["']?(https?:\/\/[^)'"\s]+)["']?\s*\)/gi,
|
|
115
|
+
];
|
|
116
|
+
for (const pattern of patterns) {
|
|
117
|
+
for (const match of html.matchAll(pattern)) destinations.add(match[1]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function ownedArtifactHtml(path: string): string | null {
|
|
122
|
+
try {
|
|
123
|
+
const artifactRoot = realpathSync(resolve(canvasState.workspaceRoot, PMX_CANVAS_DIR, 'artifacts'));
|
|
124
|
+
const candidate = realpathSync(resolve(path));
|
|
125
|
+
const rel = relative(artifactRoot, candidate);
|
|
126
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
|
|
127
|
+
const stat = statSync(candidate);
|
|
128
|
+
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return null;
|
|
129
|
+
return readFileSync(candidate, 'utf-8');
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function pre(value: string, className = ''): string {
|
|
136
|
+
return `<pre class="${className}">${escapeHtml(value)}</pre>`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function rows(entries: Array<[string, unknown]>): string {
|
|
140
|
+
const cells = entries
|
|
141
|
+
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
|
142
|
+
.map(
|
|
143
|
+
([key, value]) =>
|
|
144
|
+
`<tr><th>${escapeHtml(key)}</th><td>${escapeHtml(typeof value === 'string' ? value : JSON.stringify(value))}</td></tr>`,
|
|
145
|
+
);
|
|
146
|
+
return cells.length ? `<table class="kv">${cells.join('')}</table>` : '';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function canvasBundleDir(): string {
|
|
150
|
+
const candidates = [join(import.meta.dir, '..', '..', 'dist', 'canvas'), join(process.cwd(), 'dist', 'canvas')];
|
|
151
|
+
return candidates.find((dir) => existsSync(join(dir, 'surface-theme.css'))) ?? candidates[0];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readAsset(name: string): string {
|
|
155
|
+
const path = join(canvasBundleDir(), name);
|
|
156
|
+
return existsSync(path) ? readFileSync(path, 'utf-8') : '';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function imageDataUri(path: string): string | null {
|
|
160
|
+
try {
|
|
161
|
+
const { mimeType } = validateLocalImageFile(path);
|
|
162
|
+
return `data:${mimeType};base64,${readFileSync(path).toString('base64')}`;
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function fileText(node: CanvasNodeState, path: string): string | null {
|
|
169
|
+
if (node.data.binary === true) return null;
|
|
170
|
+
if (typeof node.data.fileContent === 'string') return node.data.fileContent;
|
|
171
|
+
try {
|
|
172
|
+
if (!statSync(path).isFile() || statSync(path).size > MAX_FILE_BYTES) return null;
|
|
173
|
+
return readFileSync(path, 'utf-8');
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function collect(
|
|
180
|
+
boardId: string,
|
|
181
|
+
includeFiles: boolean,
|
|
182
|
+
withFrames: boolean,
|
|
183
|
+
includeDerivedText = false,
|
|
184
|
+
): Promise<Collected | null> {
|
|
185
|
+
const read = canvasState.readBoard(boardId);
|
|
186
|
+
if (!read) return null;
|
|
187
|
+
const { board, state } = read;
|
|
188
|
+
const theme = canvasThemeScheme(state.theme ?? canvasState.theme);
|
|
189
|
+
const remoteImages = new Set<string>();
|
|
190
|
+
const links = new Set<string>();
|
|
191
|
+
const frameNetworkDestinations = new Set<string>();
|
|
192
|
+
const markdown = createMarkdown(remoteImages, links);
|
|
193
|
+
const manifest: ExportManifest = {
|
|
194
|
+
boardId,
|
|
195
|
+
boardName: board.name,
|
|
196
|
+
cards: 0,
|
|
197
|
+
connections: state.edges.length,
|
|
198
|
+
frames: 0,
|
|
199
|
+
placeholders: [],
|
|
200
|
+
files: [],
|
|
201
|
+
attachments: [],
|
|
202
|
+
sourceDerivedCards: [],
|
|
203
|
+
embeddedImages: 0,
|
|
204
|
+
remoteImages: [],
|
|
205
|
+
links: [],
|
|
206
|
+
frameNetworkDestinations: [],
|
|
207
|
+
embeddedCodeCanAccessNetwork: false,
|
|
208
|
+
includeFiles,
|
|
209
|
+
};
|
|
210
|
+
const cards: ExportCard[] = [];
|
|
211
|
+
let needsJsonRender = false;
|
|
212
|
+
let needsMermaid = false;
|
|
213
|
+
const themeCss = withFrames ? readAsset('surface-theme.css') : '';
|
|
214
|
+
|
|
215
|
+
for (const node of state.nodes) {
|
|
216
|
+
const title = node.type === 'board' ? 'Linked board' : (getCanvasNodeTitle(node) ?? node.type);
|
|
217
|
+
const card: ExportCard = {
|
|
218
|
+
id: node.id,
|
|
219
|
+
kind: node.type,
|
|
220
|
+
title,
|
|
221
|
+
x: node.position.x,
|
|
222
|
+
y: node.position.y,
|
|
223
|
+
w: node.size.width,
|
|
224
|
+
h: node.size.height,
|
|
225
|
+
};
|
|
226
|
+
const data = node.data;
|
|
227
|
+
const placeholder = (reason: string) => {
|
|
228
|
+
manifest.placeholders.push({ nodeId: node.id, title: card.title, reason });
|
|
229
|
+
card.html = `<div class="placeholder">${escapeHtml(reason)}</div>`;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
if (data.source && typeof data.source === 'object' && 'attachmentId' in data.source) {
|
|
233
|
+
manifest.sourceDerivedCards.push({ nodeId: node.id, included: includeDerivedText });
|
|
234
|
+
if (!includeDerivedText) {
|
|
235
|
+
card.title = 'Imported document content';
|
|
236
|
+
placeholder('Imported document text excluded.');
|
|
237
|
+
manifest.cards += 1;
|
|
238
|
+
cards.push(card);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (typeof data.attachmentId === 'string') {
|
|
243
|
+
manifest.attachments.push({ nodeId: node.id, name: title });
|
|
244
|
+
placeholder('Original attachment is not included in this export.');
|
|
245
|
+
manifest.cards += 1;
|
|
246
|
+
cards.push(card);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
switch (node.type) {
|
|
250
|
+
case 'board':
|
|
251
|
+
placeholder('Linked board is not included in this export.');
|
|
252
|
+
break;
|
|
253
|
+
case 'group':
|
|
254
|
+
card.group = true;
|
|
255
|
+
break;
|
|
256
|
+
case 'markdown':
|
|
257
|
+
card.html = `<div class="md">${await markdown.parse(text(data.content))}</div>`;
|
|
258
|
+
break;
|
|
259
|
+
case 'status':
|
|
260
|
+
card.html = rows([
|
|
261
|
+
['Phase', data.phase],
|
|
262
|
+
['Message', data.message],
|
|
263
|
+
['Detail', data.detail],
|
|
264
|
+
]);
|
|
265
|
+
break;
|
|
266
|
+
case 'context': {
|
|
267
|
+
const items = Array.isArray(data.cards) ? (data.cards as Array<Record<string, unknown>>) : [];
|
|
268
|
+
card.html = `<ul class="list">${items
|
|
269
|
+
.map((item) => `<li><strong>${escapeHtml(text(item.title))}</strong> ${escapeHtml(text(item.summary))}</li>`)
|
|
270
|
+
.join('')}</ul>`;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
case 'ledger':
|
|
274
|
+
card.html = `${text(data.content) ? pre(text(data.content)) : ''}${rows(
|
|
275
|
+
Object.entries(data).filter(
|
|
276
|
+
([key]) => !['title', 'content', '__type', 'strictSize', 'arrangeLocked'].includes(key),
|
|
277
|
+
),
|
|
278
|
+
)}`;
|
|
279
|
+
break;
|
|
280
|
+
case 'trace':
|
|
281
|
+
card.html = rows([
|
|
282
|
+
['Tool', data.toolName],
|
|
283
|
+
['Status', data.status],
|
|
284
|
+
['Duration', data.duration],
|
|
285
|
+
['Result', data.resultSummary],
|
|
286
|
+
]);
|
|
287
|
+
break;
|
|
288
|
+
case 'diff':
|
|
289
|
+
card.html = `<pre class="diff">${text(data.content)
|
|
290
|
+
.split('\n')
|
|
291
|
+
.map((line) => {
|
|
292
|
+
const cls = line.startsWith('+')
|
|
293
|
+
? 'add'
|
|
294
|
+
: line.startsWith('-')
|
|
295
|
+
? 'del'
|
|
296
|
+
: line.startsWith('@@')
|
|
297
|
+
? 'hunk'
|
|
298
|
+
: '';
|
|
299
|
+
return `<span class="${cls}">${escapeHtml(line)}</span>`;
|
|
300
|
+
})
|
|
301
|
+
.join('\n')}</pre>`;
|
|
302
|
+
break;
|
|
303
|
+
case 'prompt':
|
|
304
|
+
card.html = pre(text(data.text));
|
|
305
|
+
break;
|
|
306
|
+
case 'response':
|
|
307
|
+
card.html = `<div class="md">${await markdown.parse(text(data.content))}</div>`;
|
|
308
|
+
break;
|
|
309
|
+
case 'webpage': {
|
|
310
|
+
const url = text(data.url);
|
|
311
|
+
if (/^https?:/i.test(url)) links.add(url);
|
|
312
|
+
card.html = `${url && isSafeLink(url) ? `<p><a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a></p>` : ''}${
|
|
313
|
+
text(data.description) ? `<p>${escapeHtml(text(data.description))}</p>` : ''
|
|
314
|
+
}${text(data.excerpt) ? `<p class="dim">${escapeHtml(text(data.excerpt))}</p>` : ''}`;
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
case 'image': {
|
|
318
|
+
const src = text(data.src);
|
|
319
|
+
const alt = text(data.alt) || title;
|
|
320
|
+
let img = '';
|
|
321
|
+
if (/^data:image\//i.test(src)) img = src;
|
|
322
|
+
else if (/^https?:/i.test(src)) {
|
|
323
|
+
remoteImages.add(src);
|
|
324
|
+
img = src;
|
|
325
|
+
} else if (src) {
|
|
326
|
+
img = imageDataUri(src) ?? '';
|
|
327
|
+
if (img) manifest.embeddedImages += 1;
|
|
328
|
+
}
|
|
329
|
+
card.html = img
|
|
330
|
+
? `<img class="image" src="${escapeHtml(img)}" alt="${escapeHtml(alt)}" referrerpolicy="no-referrer">${
|
|
331
|
+
text(data.caption) ? `<p class="dim">${escapeHtml(text(data.caption))}</p>` : ''
|
|
332
|
+
}`
|
|
333
|
+
: `<div class="placeholder">Image not available: ${escapeHtml(src)}</div>`;
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
case 'file': {
|
|
337
|
+
const path = text(data.path);
|
|
338
|
+
if (!path) {
|
|
339
|
+
// Inline content the board itself holds, not a file on disk.
|
|
340
|
+
card.html = pre(text(data.fileContent) || text(data.content), 'code');
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
const body = includeFiles ? fileText(node, path) : null;
|
|
344
|
+
manifest.files.push({ nodeId: node.id, path, included: body !== null });
|
|
345
|
+
card.html = `<p class="dim">${escapeHtml(path)}</p>${
|
|
346
|
+
body !== null
|
|
347
|
+
? pre(body, 'code')
|
|
348
|
+
: '<div class="placeholder">File contents not included in this export.</div>'
|
|
349
|
+
}`;
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
case 'html': {
|
|
353
|
+
const html = text(data.html) || text(data.content);
|
|
354
|
+
collectFrameNetworkDestinations(html, frameNetworkDestinations);
|
|
355
|
+
manifest.frames += 1;
|
|
356
|
+
if (withFrames) card.frame = buildHtmlSurfaceDocument(html, { theme, title, inlineThemeCss: themeCss });
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
case 'mermaid':
|
|
360
|
+
manifest.frames += 1;
|
|
361
|
+
needsMermaid = true;
|
|
362
|
+
if (withFrames) {
|
|
363
|
+
card.frame = buildHtmlSurfaceDocument(
|
|
364
|
+
buildMermaidSurfaceHtml(text(data.content), null, data.fit === 'none' ? 'none' : 'contain').replace(
|
|
365
|
+
'<script src="/canvas/mermaid-entry.js"></script>',
|
|
366
|
+
`<script src="${MERMAID_SRC}"></script>`,
|
|
367
|
+
),
|
|
368
|
+
{ theme, title, inlineThemeCss: themeCss },
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
break;
|
|
372
|
+
case 'json-render':
|
|
373
|
+
case 'graph':
|
|
374
|
+
if (!data.spec || typeof data.spec !== 'object') {
|
|
375
|
+
placeholder('This chart has no spec to export.');
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
manifest.frames += 1;
|
|
379
|
+
needsJsonRender = true;
|
|
380
|
+
if (withFrames) {
|
|
381
|
+
card.frame = await buildJsonRenderViewerHtml({
|
|
382
|
+
title,
|
|
383
|
+
spec: data.spec as JsonRenderSpec,
|
|
384
|
+
theme,
|
|
385
|
+
display: 'site',
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
break;
|
|
389
|
+
case 'mcp-app': {
|
|
390
|
+
const artifactPath = data.viewerType === 'web-artifact' ? text(data.path) : '';
|
|
391
|
+
const artifactHtml = artifactPath ? ownedArtifactHtml(artifactPath) : null;
|
|
392
|
+
if (artifactHtml !== null) {
|
|
393
|
+
collectFrameNetworkDestinations(artifactHtml, frameNetworkDestinations);
|
|
394
|
+
manifest.frames += 1;
|
|
395
|
+
if (withFrames) card.frame = artifactHtml;
|
|
396
|
+
} else {
|
|
397
|
+
placeholder(
|
|
398
|
+
artifactPath
|
|
399
|
+
? 'Built app is unavailable or is not an owned PMX Canvas artifact.'
|
|
400
|
+
: 'Live app — open this board in PMX Canvas to use it.',
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
manifest.cards += card.group ? 0 : 1;
|
|
407
|
+
cards.push(card);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
manifest.remoteImages = [...remoteImages];
|
|
411
|
+
manifest.links = [...links];
|
|
412
|
+
manifest.frameNetworkDestinations = [...frameNetworkDestinations];
|
|
413
|
+
manifest.embeddedCodeCanAccessNetwork = manifest.frames > 0;
|
|
414
|
+
return { manifest, cards, edges: state.edges, scheme: theme, needsJsonRender, needsMermaid };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** What an export of `boardId` would put in the file, without building it. */
|
|
418
|
+
export async function previewBoardExport(
|
|
419
|
+
boardId: string,
|
|
420
|
+
includeFiles: boolean,
|
|
421
|
+
includeDerivedText = false,
|
|
422
|
+
): Promise<ExportManifest | null> {
|
|
423
|
+
return (await collect(boardId, includeFiles, false, includeDerivedText))?.manifest ?? null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function scriptJson(value: unknown): string {
|
|
427
|
+
return JSON.stringify(value).replace(/</g, '\\u003c');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function buildBoardExport(
|
|
431
|
+
boardId: string,
|
|
432
|
+
includeFiles: boolean,
|
|
433
|
+
includeDerivedText = false,
|
|
434
|
+
): Promise<{ html: string; manifest: ExportManifest } | null> {
|
|
435
|
+
const collected = await collect(boardId, includeFiles, true, includeDerivedText);
|
|
436
|
+
if (!collected) return null;
|
|
437
|
+
const { manifest, cards, edges: snapshotEdges, scheme, needsJsonRender, needsMermaid } = collected;
|
|
438
|
+
|
|
439
|
+
// Store each viewer bundle once; frames reference it by placeholder.
|
|
440
|
+
const assets: Record<string, string> = {};
|
|
441
|
+
if (needsJsonRender) {
|
|
442
|
+
const bundle = await readJsonRenderBundle();
|
|
443
|
+
const js = escapeInlineScriptSource(bundle.js);
|
|
444
|
+
for (const card of cards) {
|
|
445
|
+
if (!card.frame || (card.kind !== 'json-render' && card.kind !== 'graph')) continue;
|
|
446
|
+
card.frame = card.frame.replace(js, JR_JS).replace(bundle.css, JR_CSS);
|
|
447
|
+
}
|
|
448
|
+
assets[JR_JS] = js;
|
|
449
|
+
assets[JR_CSS] = bundle.css;
|
|
450
|
+
}
|
|
451
|
+
if (needsMermaid) {
|
|
452
|
+
const entry = readAsset('mermaid-entry.js');
|
|
453
|
+
assets[MERMAID_SRC] = `data:text/javascript;charset=utf-8;base64,${Buffer.from(entry, 'utf-8').toString('base64')}`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const edges = snapshotEdges.map((edge: CanvasEdge) => ({
|
|
457
|
+
from: edge.from,
|
|
458
|
+
to: edge.to,
|
|
459
|
+
label: edge.label ?? '',
|
|
460
|
+
style: edge.style ?? 'solid',
|
|
461
|
+
}));
|
|
462
|
+
const exportedAt = new Date().toISOString();
|
|
463
|
+
const html = exportPage({
|
|
464
|
+
title: manifest.boardName,
|
|
465
|
+
scheme,
|
|
466
|
+
exportedAt,
|
|
467
|
+
board: { cards, edges },
|
|
468
|
+
assets,
|
|
469
|
+
});
|
|
470
|
+
return { html, manifest };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Writes the export beside the database (`exports/`) and returns its path. */
|
|
474
|
+
export function writeBoardExport(html: string, boardName: string): string {
|
|
475
|
+
const dbPath = canvasState.databasePath;
|
|
476
|
+
const folder = resolve(
|
|
477
|
+
dbPath ? join(dirname(dbPath), 'exports') : join(canvasState.workspaceRoot, PMX_CANVAS_DIR, 'exports'),
|
|
478
|
+
);
|
|
479
|
+
mkdirSync(folder, { recursive: true });
|
|
480
|
+
const slug =
|
|
481
|
+
boardName
|
|
482
|
+
.toLowerCase()
|
|
483
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
484
|
+
.replace(/^-|-$/g, '')
|
|
485
|
+
.slice(0, 60) || 'board';
|
|
486
|
+
const stem = `${slug}-${new Date().toISOString().replace(/[-:.]/g, '')}`;
|
|
487
|
+
for (let attempt = 0; ; attempt++) {
|
|
488
|
+
const path = join(folder, `${stem}${attempt === 0 ? '' : `-${attempt}`}.html`);
|
|
489
|
+
try {
|
|
490
|
+
writeFileSync(path, html, { flag: 'wx' });
|
|
491
|
+
return path;
|
|
492
|
+
} catch (error) {
|
|
493
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function exportsFolder(): string | null {
|
|
499
|
+
const dbPath = canvasState.databasePath;
|
|
500
|
+
return dbPath ? resolve(dirname(dbPath), 'exports') : null;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function exportPage(input: {
|
|
504
|
+
title: string;
|
|
505
|
+
scheme: 'dark' | 'light';
|
|
506
|
+
exportedAt: string;
|
|
507
|
+
board: { cards: ExportCard[]; edges: Array<{ from: string; to: string; label: string; style: string }> };
|
|
508
|
+
assets: Record<string, string>;
|
|
509
|
+
}): string {
|
|
510
|
+
const date = new Date(input.exportedAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' });
|
|
511
|
+
return `<!doctype html>
|
|
512
|
+
<html lang="en" data-scheme="${input.scheme}">
|
|
513
|
+
<head>
|
|
514
|
+
<meta charset="utf-8">
|
|
515
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
516
|
+
<meta name="generator" content="PMX Canvas">
|
|
517
|
+
<title>${escapeHtml(input.title)}</title>
|
|
518
|
+
<style>${EXPORT_CSS}</style>
|
|
519
|
+
</head>
|
|
520
|
+
<body>
|
|
521
|
+
<header class="bar">
|
|
522
|
+
<div class="name"><strong>${escapeHtml(input.title)}</strong><span>Exported ${escapeHtml(date)} from PMX Canvas · read-only</span></div>
|
|
523
|
+
<div class="tools">
|
|
524
|
+
<button type="button" data-zoom="out" aria-label="Zoom out">−</button>
|
|
525
|
+
<button type="button" data-zoom="fit" aria-label="Fit board">Fit</button>
|
|
526
|
+
<button type="button" data-zoom="in" aria-label="Zoom in">+</button>
|
|
527
|
+
</div>
|
|
528
|
+
</header>
|
|
529
|
+
<main id="stage" aria-label="Board"><div id="world"><svg id="edges" aria-hidden="true"></svg></div></main>
|
|
530
|
+
<div id="overlay" hidden><div class="sheet" role="dialog" aria-modal="true"><header><strong id="overlay-title"></strong><button type="button" id="overlay-close" aria-label="Close">×</button></header><div id="overlay-body"></div></div></div>
|
|
531
|
+
<script type="application/json" id="pmx-board">${scriptJson(input.board)}</script>
|
|
532
|
+
<script type="application/json" id="pmx-assets">${scriptJson(input.assets)}</script>
|
|
533
|
+
<script>${EXPORT_VIEWER_JS}</script>
|
|
534
|
+
</body>
|
|
535
|
+
</html>
|
|
536
|
+
`;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const EXPORT_CSS = `
|
|
540
|
+
:root{--bg:#0b1016;--panel:#121a23;--line:#263241;--text:#e8edf2;--soft:#9aa7b4;--accent:#4bbcff;--add:#2ea66f;--del:#e25d6e}
|
|
541
|
+
[data-scheme=light]{--bg:#f4f6f8;--panel:#ffffff;--line:#d5dbe1;--text:#17202a;--soft:#5b6875;--accent:#0a6fc2;--add:#1c7a4f;--del:#b3263a}
|
|
542
|
+
*{box-sizing:border-box}
|
|
543
|
+
html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font:14px/1.45 system-ui,-apple-system,Segoe UI,sans-serif}
|
|
544
|
+
.bar{position:fixed;top:0;left:0;right:0;height:48px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:0 16px;background:var(--panel);border-bottom:1px solid var(--line);z-index:5}
|
|
545
|
+
.bar .name{display:flex;flex-direction:column;min-width:0}
|
|
546
|
+
.bar .name strong,.bar .name span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
547
|
+
.bar .name span{font-size:11px;color:var(--soft)}
|
|
548
|
+
.tools{display:flex;gap:6px;flex-shrink:0}
|
|
549
|
+
button{font:inherit;color:var(--text);background:none;border:1px solid var(--line);border-radius:7px;padding:4px 10px;cursor:pointer}
|
|
550
|
+
button:hover{border-color:var(--accent)}
|
|
551
|
+
#stage{position:fixed;top:48px;left:0;right:0;bottom:0;overflow:hidden;cursor:grab;touch-action:none}
|
|
552
|
+
#stage.panning{cursor:grabbing}
|
|
553
|
+
#world{position:absolute;left:0;top:0;transform-origin:0 0}
|
|
554
|
+
#edges{position:absolute;left:0;top:0;overflow:visible;pointer-events:none}
|
|
555
|
+
#edges line{stroke:var(--soft);stroke-width:1.5}
|
|
556
|
+
#edges text{fill:var(--soft);font-size:12px;text-anchor:middle}
|
|
557
|
+
.card{position:absolute;display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden;box-shadow:0 6px 24px rgba(0,0,0,.18)}
|
|
558
|
+
.card.group{background:color-mix(in srgb,var(--accent) 5%,transparent);border-style:dashed;box-shadow:none}
|
|
559
|
+
.card-title{flex-shrink:0;padding:7px 10px;font-weight:600;font-size:13px;border-bottom:1px solid var(--line);cursor:zoom-in;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
560
|
+
.card.group .card-title{border-bottom:none;cursor:default;color:var(--soft)}
|
|
561
|
+
.card-body{flex:1;min-height:0;overflow:auto;padding:8px 10px}
|
|
562
|
+
.card iframe,#overlay-body iframe{flex:1;width:100%;height:100%;border:0;background:var(--bg)}
|
|
563
|
+
.md h1,.md h2,.md h3{margin:.4em 0 .3em;line-height:1.2}.md p{margin:.4em 0}.md img{max-width:100%}
|
|
564
|
+
.md code,pre{font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
565
|
+
pre{margin:0;white-space:pre-wrap;word-break:break-word}
|
|
566
|
+
pre.diff .add{color:var(--add)}pre.diff .del{color:var(--del)}pre.diff .hunk{color:var(--accent)}
|
|
567
|
+
a{color:var(--accent)}
|
|
568
|
+
.dim{color:var(--soft)}
|
|
569
|
+
.kv{border-collapse:collapse;font-size:12px}.kv th{text-align:left;color:var(--soft);padding:2px 10px 2px 0;vertical-align:top}.kv td{padding:2px 0}
|
|
570
|
+
.list{margin:0;padding-left:18px}
|
|
571
|
+
.image{max-width:100%;max-height:100%;object-fit:contain;display:block;margin:auto}
|
|
572
|
+
.placeholder{padding:12px;border:1px dashed var(--line);border-radius:8px;color:var(--soft);font-size:12px}
|
|
573
|
+
#overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;padding:24px;z-index:10}
|
|
574
|
+
#overlay[hidden]{display:none}
|
|
575
|
+
#overlay .sheet{width:min(1100px,100%);height:min(90vh,100%);display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}
|
|
576
|
+
#overlay .sheet>header{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;border-bottom:1px solid var(--line)}
|
|
577
|
+
#overlay-body{flex:1;min-height:0;overflow:auto;padding:14px;display:flex;flex-direction:column}
|
|
578
|
+
`;
|
|
579
|
+
|
|
580
|
+
const EXPORT_VIEWER_JS = `(() => {
|
|
581
|
+
const board = JSON.parse(document.getElementById('pmx-board').textContent);
|
|
582
|
+
const assets = JSON.parse(document.getElementById('pmx-assets').textContent);
|
|
583
|
+
const stage = document.getElementById('stage');
|
|
584
|
+
const world = document.getElementById('world');
|
|
585
|
+
const svg = document.getElementById('edges');
|
|
586
|
+
const overlay = document.getElementById('overlay');
|
|
587
|
+
const view = { x: 0, y: 0, scale: 1 };
|
|
588
|
+
const byId = new Map(board.cards.map((c) => [c.id, c]));
|
|
589
|
+
const withAssets = (doc) => Object.keys(assets).reduce((out, key) => out.split(key).join(assets[key]), doc);
|
|
590
|
+
const frame = (card) => {
|
|
591
|
+
const f = document.createElement('iframe');
|
|
592
|
+
f.setAttribute('sandbox', 'allow-scripts');
|
|
593
|
+
f.setAttribute('title', card.title);
|
|
594
|
+
f.srcdoc = withAssets(card.frame);
|
|
595
|
+
return f;
|
|
596
|
+
};
|
|
597
|
+
const body = (card) => {
|
|
598
|
+
if (card.frame) return frame(card);
|
|
599
|
+
const d = document.createElement('div');
|
|
600
|
+
d.className = 'card-body';
|
|
601
|
+
d.innerHTML = card.html || '';
|
|
602
|
+
return d;
|
|
603
|
+
};
|
|
604
|
+
const ordered = [...board.cards].sort((a, b) => (b.group ? 1 : 0) - (a.group ? 1 : 0));
|
|
605
|
+
for (const card of ordered) {
|
|
606
|
+
const el = document.createElement('section');
|
|
607
|
+
el.className = 'card' + (card.group ? ' group' : '');
|
|
608
|
+
el.dataset.nodeId = card.id;
|
|
609
|
+
Object.assign(el.style, { left: card.x + 'px', top: card.y + 'px', width: card.w + 'px', height: card.h + 'px' });
|
|
610
|
+
const title = document.createElement('div');
|
|
611
|
+
title.className = 'card-title';
|
|
612
|
+
title.textContent = card.title;
|
|
613
|
+
el.appendChild(title);
|
|
614
|
+
if (!card.group) {
|
|
615
|
+
el.appendChild(body(card));
|
|
616
|
+
title.addEventListener('click', () => expand(card));
|
|
617
|
+
}
|
|
618
|
+
world.appendChild(el);
|
|
619
|
+
}
|
|
620
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
621
|
+
for (const edge of board.edges) {
|
|
622
|
+
const a = byId.get(edge.from), b = byId.get(edge.to);
|
|
623
|
+
if (!a || !b) continue;
|
|
624
|
+
const x1 = a.x + a.w / 2, y1 = a.y + a.h / 2, x2 = b.x + b.w / 2, y2 = b.y + b.h / 2;
|
|
625
|
+
const line = document.createElementNS(NS, 'line');
|
|
626
|
+
line.setAttribute('x1', x1); line.setAttribute('y1', y1); line.setAttribute('x2', x2); line.setAttribute('y2', y2);
|
|
627
|
+
if (edge.style === 'dashed') line.setAttribute('stroke-dasharray', '6 4');
|
|
628
|
+
if (edge.style === 'dotted') line.setAttribute('stroke-dasharray', '2 4');
|
|
629
|
+
svg.appendChild(line);
|
|
630
|
+
if (edge.label) {
|
|
631
|
+
const t = document.createElementNS(NS, 'text');
|
|
632
|
+
t.setAttribute('x', (x1 + x2) / 2); t.setAttribute('y', (y1 + y2) / 2 - 4);
|
|
633
|
+
t.textContent = edge.label;
|
|
634
|
+
svg.appendChild(t);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
const apply = () => { world.style.transform = 'matrix(' + view.scale + ',0,0,' + view.scale + ',' + view.x + ',' + view.y + ')'; };
|
|
638
|
+
const fit = () => {
|
|
639
|
+
if (!board.cards.length) return apply();
|
|
640
|
+
const minX = Math.min(...board.cards.map((c) => c.x)), minY = Math.min(...board.cards.map((c) => c.y));
|
|
641
|
+
const maxX = Math.max(...board.cards.map((c) => c.x + c.w)), maxY = Math.max(...board.cards.map((c) => c.y + c.h));
|
|
642
|
+
const pad = 32, w = stage.clientWidth, h = stage.clientHeight;
|
|
643
|
+
view.scale = Math.min(1, (w - pad * 2) / (maxX - minX), (h - pad * 2) / (maxY - minY));
|
|
644
|
+
view.x = (w - (maxX - minX) * view.scale) / 2 - minX * view.scale;
|
|
645
|
+
view.y = pad - minY * view.scale;
|
|
646
|
+
apply();
|
|
647
|
+
};
|
|
648
|
+
const zoomAt = (factor, cx, cy) => {
|
|
649
|
+
const next = Math.min(4, Math.max(0.1, view.scale * factor));
|
|
650
|
+
view.x = cx - (cx - view.x) * (next / view.scale);
|
|
651
|
+
view.y = cy - (cy - view.y) * (next / view.scale);
|
|
652
|
+
view.scale = next;
|
|
653
|
+
apply();
|
|
654
|
+
};
|
|
655
|
+
stage.addEventListener('wheel', (e) => {
|
|
656
|
+
e.preventDefault();
|
|
657
|
+
const r = stage.getBoundingClientRect();
|
|
658
|
+
zoomAt(Math.exp(-e.deltaY * 0.0015), e.clientX - r.left, e.clientY - r.top);
|
|
659
|
+
}, { passive: false });
|
|
660
|
+
let drag = null;
|
|
661
|
+
stage.addEventListener('pointerdown', (e) => {
|
|
662
|
+
if (e.target.closest('.card-body, .card-title')) return;
|
|
663
|
+
drag = { x: e.clientX - view.x, y: e.clientY - view.y };
|
|
664
|
+
stage.classList.add('panning');
|
|
665
|
+
stage.setPointerCapture(e.pointerId);
|
|
666
|
+
});
|
|
667
|
+
stage.addEventListener('pointermove', (e) => {
|
|
668
|
+
if (!drag) return;
|
|
669
|
+
view.x = e.clientX - drag.x; view.y = e.clientY - drag.y;
|
|
670
|
+
apply();
|
|
671
|
+
});
|
|
672
|
+
stage.addEventListener('pointerup', () => { drag = null; stage.classList.remove('panning'); });
|
|
673
|
+
document.querySelector('[data-zoom=in]').addEventListener('click', () => zoomAt(1.25, stage.clientWidth / 2, stage.clientHeight / 2));
|
|
674
|
+
document.querySelector('[data-zoom=out]').addEventListener('click', () => zoomAt(0.8, stage.clientWidth / 2, stage.clientHeight / 2));
|
|
675
|
+
document.querySelector('[data-zoom=fit]').addEventListener('click', fit);
|
|
676
|
+
const close = () => { overlay.hidden = true; document.getElementById('overlay-body').replaceChildren(); };
|
|
677
|
+
function expand(card) {
|
|
678
|
+
document.getElementById('overlay-title').textContent = card.title;
|
|
679
|
+
document.getElementById('overlay-body').replaceChildren(body(card));
|
|
680
|
+
overlay.hidden = false;
|
|
681
|
+
}
|
|
682
|
+
document.getElementById('overlay-close').addEventListener('click', close);
|
|
683
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
|
684
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
|
|
685
|
+
fit();
|
|
686
|
+
})();`;
|