@seed-ship/mcp-ui-solid 6.8.0 → 6.8.2
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 +47 -0
- package/dist/components/GraphRenderer.cjs +4 -2
- package/dist/components/GraphRenderer.cjs.map +1 -1
- package/dist/components/GraphRenderer.d.ts +21 -0
- package/dist/components/GraphRenderer.d.ts.map +1 -1
- package/dist/components/GraphRenderer.js +4 -2
- package/dist/components/GraphRenderer.js.map +1 -1
- package/dist/services/validation.cjs +43 -9
- package/dist/services/validation.cjs.map +1 -1
- package/dist/services/validation.d.ts.map +1 -1
- package/dist/services/validation.js +43 -9
- package/dist/services/validation.js.map +1 -1
- package/docs/briefs/BRIEF-graph-component-g6.md +559 -0
- package/package.json +1 -1
- package/src/components/GraphRenderer.g6.test.tsx +77 -0
- package/src/components/GraphRenderer.tsx +224 -115
- package/src/services/validation.test.ts +298 -232
- package/src/services/validation.ts +210 -136
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -18,15 +18,20 @@
|
|
|
18
18
|
* data). All three are computed lazily on click.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { Component, createSignal, onCleanup, onMount, Show, For } from 'solid-js'
|
|
22
|
-
import type { UIComponent } from '../types'
|
|
23
|
-
import type {
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
import { Component, createSignal, onCleanup, onMount, Show, For } from 'solid-js';
|
|
22
|
+
import type { UIComponent } from '../types';
|
|
23
|
+
import type {
|
|
24
|
+
GraphComponentParams,
|
|
25
|
+
GraphLayout,
|
|
26
|
+
GraphNode,
|
|
27
|
+
GraphEdge,
|
|
28
|
+
} from '@seed-ship/mcp-ui-spec';
|
|
29
|
+
import { ExpandableWrapper, useExpanded } from './ExpandableWrapper';
|
|
30
|
+
import { PortalDropdownMenu } from './PortalDropdownMenu';
|
|
26
31
|
|
|
27
32
|
// Module-scoped lazy import promise — first call triggers the dynamic
|
|
28
33
|
// import, subsequent calls reuse the resolved module.
|
|
29
|
-
let g6ModulePromise: Promise<typeof import('@antv/g6')> | undefined
|
|
34
|
+
let g6ModulePromise: Promise<typeof import('@antv/g6')> | undefined;
|
|
30
35
|
|
|
31
36
|
/**
|
|
32
37
|
* Whether the `@antv/g6` peer dependency is installed and importable.
|
|
@@ -37,12 +42,12 @@ let g6ModulePromise: Promise<typeof import('@antv/g6')> | undefined
|
|
|
37
42
|
export async function isG6Available(): Promise<boolean> {
|
|
38
43
|
try {
|
|
39
44
|
if (!g6ModulePromise) {
|
|
40
|
-
g6ModulePromise = import('@antv/g6')
|
|
45
|
+
g6ModulePromise = import('@antv/g6');
|
|
41
46
|
}
|
|
42
|
-
await g6ModulePromise
|
|
43
|
-
return true
|
|
47
|
+
await g6ModulePromise;
|
|
48
|
+
return true;
|
|
44
49
|
} catch {
|
|
45
|
-
return false
|
|
50
|
+
return false;
|
|
46
51
|
}
|
|
47
52
|
}
|
|
48
53
|
|
|
@@ -52,16 +57,77 @@ export async function isG6Available(): Promise<boolean> {
|
|
|
52
57
|
* present (universal default) or `'circular'` otherwise.
|
|
53
58
|
*/
|
|
54
59
|
function resolveLayout(params: GraphComponentParams): { type: string; [key: string]: unknown } {
|
|
55
|
-
const layout: GraphLayout | undefined = params.layout
|
|
60
|
+
const layout: GraphLayout | undefined = params.layout;
|
|
56
61
|
if (layout === undefined) {
|
|
57
|
-
const hasEdges = (params.edges?.length ?? 0) > 0
|
|
58
|
-
return { type: hasEdges ? 'force' : 'circular' }
|
|
62
|
+
const hasEdges = (params.edges?.length ?? 0) > 0;
|
|
63
|
+
return { type: hasEdges ? 'force' : 'circular' };
|
|
59
64
|
}
|
|
60
65
|
if (typeof layout === 'string') {
|
|
61
|
-
return { type: layout }
|
|
66
|
+
return { type: layout };
|
|
62
67
|
}
|
|
63
68
|
// Object form: spread the passthrough options alongside `type`.
|
|
64
|
-
return { type: layout.type, ...(layout.options ?? {}) }
|
|
69
|
+
return { type: layout.type, ...(layout.options ?? {}) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build the G6 v5 `Graph` constructor config from the component params.
|
|
74
|
+
*
|
|
75
|
+
* Pure (no DOM/lib side effects beyond reading `container`), so it can be
|
|
76
|
+
* unit-tested directly without a jsdom render — this is the contract the
|
|
77
|
+
* 2026-05-30 audit (P0.2) wants locked.
|
|
78
|
+
*
|
|
79
|
+
* ⚠️ G6 v5's `renderer` is a factory `(layer) => IRenderer`, NOT a string
|
|
80
|
+
* (that was the v4 contract). Passing the string `'canvas'` / `'svg'` makes
|
|
81
|
+
* G6 throw `renderer is not a function` — and because the default path used
|
|
82
|
+
* to pass the string `'canvas'`, EVERY graph crashed, not just svg. So we
|
|
83
|
+
* **omit `renderer` entirely**: G6 then uses its built-in canvas renderer
|
|
84
|
+
* (documented default `() => new CanvasRenderer()`).
|
|
85
|
+
*
|
|
86
|
+
* `rendererPref: 'svg'` is reserved but NOT wired yet — a real G6 v5 SVG
|
|
87
|
+
* renderer needs the `@antv/g-svg` factory (a transitive dep of `@antv/g6`,
|
|
88
|
+
* not statically resolvable at build time). Until that's wired behind proper
|
|
89
|
+
* optional-peer resolution, svg degrades to the canvas default with a
|
|
90
|
+
* one-time warning. It must NEVER inject a string `renderer`.
|
|
91
|
+
*/
|
|
92
|
+
export function buildGraphConfig(
|
|
93
|
+
p: GraphComponentParams,
|
|
94
|
+
container?: HTMLElement
|
|
95
|
+
): Record<string, unknown> {
|
|
96
|
+
const config: Record<string, unknown> = {
|
|
97
|
+
container,
|
|
98
|
+
data: { nodes: p.nodes, edges: p.edges ?? [] },
|
|
99
|
+
layout: resolveLayout(p),
|
|
100
|
+
behaviors: resolveBehaviors(p),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
if (p.rendererPref === 'svg') {
|
|
104
|
+
console.warn(
|
|
105
|
+
'[MCP-UI] GraphRenderer: rendererPref "svg" is not yet supported; using the default canvas renderer.'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (p.fitView !== false) {
|
|
110
|
+
config.autoFit = 'view';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (p.tooltip !== false) {
|
|
114
|
+
config.plugins = [
|
|
115
|
+
{
|
|
116
|
+
type: 'tooltip',
|
|
117
|
+
getContent: (_evt: unknown, items: any[]) => {
|
|
118
|
+
const item = items?.[0];
|
|
119
|
+
if (!item) return '';
|
|
120
|
+
const label = item.label ?? item.id ?? '';
|
|
121
|
+
const data = item.data ? JSON.stringify(item.data) : '';
|
|
122
|
+
return `<div style="padding:4px 8px"><strong>${escapeHtml(String(label))}</strong>${
|
|
123
|
+
data ? `<br><span style="font-size:11px;opacity:0.7">${escapeHtml(data)}</span>` : ''
|
|
124
|
+
}</div>`;
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return config;
|
|
65
131
|
}
|
|
66
132
|
|
|
67
133
|
/**
|
|
@@ -70,13 +136,13 @@ function resolveLayout(params: GraphComponentParams): { type: string; [key: stri
|
|
|
70
136
|
* Any flag set to `false` opts out.
|
|
71
137
|
*/
|
|
72
138
|
function resolveBehaviors(params: GraphComponentParams): string[] {
|
|
73
|
-
const behaviors: string[] = []
|
|
74
|
-
if (params.enableDrag !== false) behaviors.push('drag-element')
|
|
139
|
+
const behaviors: string[] = [];
|
|
140
|
+
if (params.enableDrag !== false) behaviors.push('drag-element');
|
|
75
141
|
if (params.enableZoom !== false) {
|
|
76
|
-
behaviors.push('zoom-canvas', 'drag-canvas')
|
|
142
|
+
behaviors.push('zoom-canvas', 'drag-canvas');
|
|
77
143
|
}
|
|
78
|
-
if (params.enableSelect !== false) behaviors.push('click-select')
|
|
79
|
-
return behaviors
|
|
144
|
+
if (params.enableSelect !== false) behaviors.push('click-select');
|
|
145
|
+
return behaviors;
|
|
80
146
|
}
|
|
81
147
|
|
|
82
148
|
/**
|
|
@@ -85,7 +151,7 @@ function resolveBehaviors(params: GraphComponentParams): string[] {
|
|
|
85
151
|
* else (force, concentric, circular, grid) → LR (default mermaid).
|
|
86
152
|
*/
|
|
87
153
|
function mermaidDirection(layoutType: string): 'TD' | 'LR' {
|
|
88
|
-
return layoutType === 'dagre' || layoutType === 'tree' || layoutType === 'mindmap' ? 'TD' : 'LR'
|
|
154
|
+
return layoutType === 'dagre' || layoutType === 'tree' || layoutType === 'mindmap' ? 'TD' : 'LR';
|
|
89
155
|
}
|
|
90
156
|
|
|
91
157
|
/**
|
|
@@ -93,7 +159,10 @@ function mermaidDirection(layoutType: string): 'TD' | 'LR' {
|
|
|
93
159
|
* on raw quotes / brackets / pipes ; we strip the worst offenders.
|
|
94
160
|
*/
|
|
95
161
|
function mermaidLabel(s: string): string {
|
|
96
|
-
return s
|
|
162
|
+
return s
|
|
163
|
+
.replace(/["[\]|]/g, '')
|
|
164
|
+
.replace(/\s+/g, ' ')
|
|
165
|
+
.trim();
|
|
97
166
|
}
|
|
98
167
|
|
|
99
168
|
/**
|
|
@@ -101,82 +170,105 @@ function mermaidLabel(s: string): string {
|
|
|
101
170
|
* carries the optional `weight` prefix when present (e.g. `|3| label`).
|
|
102
171
|
*/
|
|
103
172
|
function toMermaid(params: GraphComponentParams): string {
|
|
104
|
-
const layoutType = resolveLayout(params).type
|
|
105
|
-
const dir = mermaidDirection(layoutType)
|
|
106
|
-
const lines: string[] = [`flowchart ${dir}`]
|
|
173
|
+
const layoutType = resolveLayout(params).type;
|
|
174
|
+
const dir = mermaidDirection(layoutType);
|
|
175
|
+
const lines: string[] = [`flowchart ${dir}`];
|
|
107
176
|
for (const n of params.nodes) {
|
|
108
|
-
const label = mermaidLabel(n.label ?? n.id)
|
|
109
|
-
lines.push(` ${n.id}["${label}"]`)
|
|
177
|
+
const label = mermaidLabel(n.label ?? n.id);
|
|
178
|
+
lines.push(` ${n.id}["${label}"]`);
|
|
110
179
|
}
|
|
111
180
|
for (const e of params.edges ?? []) {
|
|
112
|
-
const labelParts: string[] = []
|
|
113
|
-
if (e.weight !== undefined) labelParts.push(String(e.weight))
|
|
114
|
-
if (e.label) labelParts.push(mermaidLabel(e.label))
|
|
115
|
-
const labelText = labelParts.join(' · ')
|
|
181
|
+
const labelParts: string[] = [];
|
|
182
|
+
if (e.weight !== undefined) labelParts.push(String(e.weight));
|
|
183
|
+
if (e.label) labelParts.push(mermaidLabel(e.label));
|
|
184
|
+
const labelText = labelParts.join(' · ');
|
|
116
185
|
if (labelText) {
|
|
117
|
-
lines.push(` ${e.source} -->|${labelText}| ${e.target}`)
|
|
186
|
+
lines.push(` ${e.source} -->|${labelText}| ${e.target}`);
|
|
118
187
|
} else {
|
|
119
|
-
lines.push(` ${e.source} --> ${e.target}`)
|
|
188
|
+
lines.push(` ${e.source} --> ${e.target}`);
|
|
120
189
|
}
|
|
121
190
|
}
|
|
122
|
-
return lines.join('\n')
|
|
191
|
+
return lines.join('\n');
|
|
123
192
|
}
|
|
124
193
|
|
|
125
194
|
function toJSON(params: GraphComponentParams): string {
|
|
126
|
-
return JSON.stringify({ nodes: params.nodes, edges: params.edges ?? [] }, null, 2)
|
|
195
|
+
return JSON.stringify({ nodes: params.nodes, edges: params.edges ?? [] }, null, 2);
|
|
127
196
|
}
|
|
128
197
|
|
|
129
198
|
function downloadBlob(content: string | Blob, filename: string, mimeType?: string): void {
|
|
130
|
-
const blob =
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
a
|
|
134
|
-
a.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
199
|
+
const blob =
|
|
200
|
+
typeof content === 'string' ? new Blob([content], { type: mimeType ?? 'text/plain' }) : content;
|
|
201
|
+
const url = URL.createObjectURL(blob);
|
|
202
|
+
const a = document.createElement('a');
|
|
203
|
+
a.href = url;
|
|
204
|
+
a.download = filename;
|
|
205
|
+
document.body.appendChild(a);
|
|
206
|
+
a.click();
|
|
207
|
+
document.body.removeChild(a);
|
|
208
|
+
URL.revokeObjectURL(url);
|
|
139
209
|
}
|
|
140
210
|
|
|
141
211
|
export interface GraphRendererProps {
|
|
142
|
-
component: UIComponent
|
|
212
|
+
component: UIComponent;
|
|
143
213
|
/**
|
|
144
214
|
* Forwarded to the underlying `<ExpandableWrapper>` (v6.3.1).
|
|
145
215
|
* @see ExpandableWrapperProps.toolbarVariant
|
|
146
216
|
*/
|
|
147
|
-
toolbarVariant?: 'hover' | 'always-visible'
|
|
217
|
+
toolbarVariant?: 'hover' | 'always-visible';
|
|
148
218
|
}
|
|
149
219
|
|
|
150
220
|
export const GraphRenderer: Component<GraphRendererProps> = (props) => {
|
|
151
|
-
const params = () => props.component.params as GraphComponentParams
|
|
152
|
-
const isExpanded = useExpanded()
|
|
153
|
-
const [available, setAvailable] = createSignal<boolean | null>(null)
|
|
154
|
-
const [error, setError] = createSignal<string | undefined>()
|
|
155
|
-
const [exportMenuOpen, setExportMenuOpen] = createSignal(false)
|
|
156
|
-
let containerRef: HTMLDivElement | undefined
|
|
221
|
+
const params = () => props.component.params as GraphComponentParams;
|
|
222
|
+
const isExpanded = useExpanded();
|
|
223
|
+
const [available, setAvailable] = createSignal<boolean | null>(null);
|
|
224
|
+
const [error, setError] = createSignal<string | undefined>();
|
|
225
|
+
const [exportMenuOpen, setExportMenuOpen] = createSignal(false);
|
|
226
|
+
let containerRef: HTMLDivElement | undefined;
|
|
157
227
|
// v6.4.0 — trigger ref consumed by <PortalDropdownMenu> for positioning
|
|
158
|
-
let exportTriggerRef: HTMLButtonElement | undefined
|
|
228
|
+
let exportTriggerRef: HTMLButtonElement | undefined;
|
|
159
229
|
// Loosely typed because G6 is a peer-optional — we don't pull its
|
|
160
230
|
// types into the bundle just to type a transient local handle.
|
|
161
|
-
let graphInstance: any | undefined
|
|
231
|
+
let graphInstance: any | undefined;
|
|
162
232
|
|
|
163
233
|
onMount(async () => {
|
|
164
|
-
const g6Available = await isG6Available()
|
|
165
|
-
setAvailable(g6Available)
|
|
166
|
-
if (!g6Available || !containerRef) return
|
|
234
|
+
const g6Available = await isG6Available();
|
|
235
|
+
setAvailable(g6Available);
|
|
236
|
+
if (!g6Available || !containerRef) return;
|
|
167
237
|
|
|
168
238
|
try {
|
|
169
|
-
const { Graph } = await g6ModulePromise
|
|
170
|
-
const p = params()
|
|
239
|
+
const { Graph } = await g6ModulePromise!;
|
|
240
|
+
const p = params();
|
|
171
241
|
const config: Record<string, unknown> = {
|
|
172
242
|
container: containerRef,
|
|
173
243
|
data: { nodes: p.nodes, edges: p.edges ?? [] },
|
|
174
244
|
layout: resolveLayout(p),
|
|
175
245
|
behaviors: resolveBehaviors(p),
|
|
176
|
-
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// G6 v5's `renderer` is a factory `(layer) => IRenderer`, NOT a string
|
|
249
|
+
// (that was the v4 contract). Passing the string `'canvas'` / `'svg'`
|
|
250
|
+
// makes G6 throw `renderer is not a function` — and because the default
|
|
251
|
+
// path also passed the string `'canvas'`, EVERY graph crashed, not just
|
|
252
|
+
// the svg one.
|
|
253
|
+
//
|
|
254
|
+
// Fix: omit `renderer` entirely. G6 then uses its built-in canvas
|
|
255
|
+
// renderer (documented default `() => new CanvasRenderer()`), which is
|
|
256
|
+
// what we want for every graph.
|
|
257
|
+
//
|
|
258
|
+
// `rendererPref: 'svg'` is NOT wired yet: a real G6 v5 SVG renderer
|
|
259
|
+
// needs the `@antv/g-svg` factory, which is a *transitive* dep of
|
|
260
|
+
// `@antv/g6` (not a declared peer) and isn't statically resolvable at
|
|
261
|
+
// build time. Rather than ship a fragile/unresolvable import, we treat
|
|
262
|
+
// svg as "not yet supported" and fall back to the canvas default with a
|
|
263
|
+
// one-time warning. Tracked for a follow-up that wires the factory
|
|
264
|
+
// behind a proper optional-peer resolution. (Never pass a string here.)
|
|
265
|
+
if (p.rendererPref === 'svg') {
|
|
266
|
+
console.warn(
|
|
267
|
+
'[MCP-UI] GraphRenderer: rendererPref "svg" is not yet supported; using the default canvas renderer.'
|
|
268
|
+
);
|
|
177
269
|
}
|
|
178
270
|
if (p.fitView !== false) {
|
|
179
|
-
config.autoFit = 'view'
|
|
271
|
+
config.autoFit = 'view';
|
|
180
272
|
}
|
|
181
273
|
if (p.tooltip !== false) {
|
|
182
274
|
// Built-in tooltip plugin — shows label + a compact dump of
|
|
@@ -185,67 +277,69 @@ export const GraphRenderer: Component<GraphRendererProps> = (props) => {
|
|
|
185
277
|
{
|
|
186
278
|
type: 'tooltip',
|
|
187
279
|
getContent: (_evt: unknown, items: any[]) => {
|
|
188
|
-
const item = items?.[0]
|
|
189
|
-
if (!item) return ''
|
|
190
|
-
const label = item.label ?? item.id ?? ''
|
|
191
|
-
const data = item.data ? JSON.stringify(item.data) : ''
|
|
280
|
+
const item = items?.[0];
|
|
281
|
+
if (!item) return '';
|
|
282
|
+
const label = item.label ?? item.id ?? '';
|
|
283
|
+
const data = item.data ? JSON.stringify(item.data) : '';
|
|
192
284
|
return `<div style="padding:4px 8px"><strong>${escapeHtml(String(label))}</strong>${
|
|
193
|
-
data
|
|
194
|
-
|
|
285
|
+
data
|
|
286
|
+
? `<br><span style="font-size:11px;opacity:0.7">${escapeHtml(data)}</span>`
|
|
287
|
+
: ''
|
|
288
|
+
}</div>`;
|
|
195
289
|
},
|
|
196
290
|
},
|
|
197
|
-
]
|
|
291
|
+
];
|
|
198
292
|
}
|
|
199
|
-
graphInstance = new (Graph as any)(config)
|
|
200
|
-
await graphInstance.render()
|
|
293
|
+
graphInstance = new (Graph as any)(config);
|
|
294
|
+
await graphInstance.render();
|
|
201
295
|
} catch (err) {
|
|
202
|
-
setError(err instanceof Error ? err.message : 'Failed to render graph')
|
|
296
|
+
setError(err instanceof Error ? err.message : 'Failed to render graph');
|
|
203
297
|
}
|
|
204
|
-
})
|
|
298
|
+
});
|
|
205
299
|
|
|
206
300
|
onCleanup(() => {
|
|
207
301
|
try {
|
|
208
|
-
graphInstance?.destroy()
|
|
302
|
+
graphInstance?.destroy();
|
|
209
303
|
} catch {
|
|
210
304
|
// G6 destroy can throw on already-destroyed instances or partial
|
|
211
305
|
// init failures — silent because the component is unmounting anyway.
|
|
212
306
|
}
|
|
213
|
-
graphInstance = undefined
|
|
214
|
-
})
|
|
307
|
+
graphInstance = undefined;
|
|
308
|
+
});
|
|
215
309
|
|
|
216
310
|
// ─── Export handlers ────────────────────────────────────────────────
|
|
217
311
|
const handleExportJSON = () => {
|
|
218
|
-
downloadBlob(toJSON(params()), `${graphFilenameStem(params())}.json`, 'application/json')
|
|
219
|
-
setExportMenuOpen(false)
|
|
220
|
-
}
|
|
312
|
+
downloadBlob(toJSON(params()), `${graphFilenameStem(params())}.json`, 'application/json');
|
|
313
|
+
setExportMenuOpen(false);
|
|
314
|
+
};
|
|
221
315
|
|
|
222
316
|
const handleExportMermaid = () => {
|
|
223
|
-
downloadBlob(toMermaid(params()), `${graphFilenameStem(params())}.mmd`, 'text/plain')
|
|
224
|
-
setExportMenuOpen(false)
|
|
225
|
-
}
|
|
317
|
+
downloadBlob(toMermaid(params()), `${graphFilenameStem(params())}.mmd`, 'text/plain');
|
|
318
|
+
setExportMenuOpen(false);
|
|
319
|
+
};
|
|
226
320
|
|
|
227
321
|
const handleExportPNG = async () => {
|
|
228
|
-
if (!graphInstance) return
|
|
322
|
+
if (!graphInstance) return;
|
|
229
323
|
try {
|
|
230
324
|
// G6 v5 exposes `toDataURL()` on the graph instance.
|
|
231
|
-
const dataUrl: string = await graphInstance.toDataURL?.('image/png')
|
|
325
|
+
const dataUrl: string = await graphInstance.toDataURL?.('image/png');
|
|
232
326
|
if (!dataUrl) {
|
|
233
327
|
// Fallback: try to grab the underlying canvas directly.
|
|
234
|
-
const canvas = containerRef?.querySelector('canvas')
|
|
328
|
+
const canvas = containerRef?.querySelector('canvas');
|
|
235
329
|
if (canvas) {
|
|
236
|
-
const url = (canvas as HTMLCanvasElement).toDataURL('image/png')
|
|
237
|
-
await downloadDataUrl(url, `${graphFilenameStem(params())}.png`)
|
|
330
|
+
const url = (canvas as HTMLCanvasElement).toDataURL('image/png');
|
|
331
|
+
await downloadDataUrl(url, `${graphFilenameStem(params())}.png`);
|
|
238
332
|
} else {
|
|
239
|
-
setError('PNG export not supported in current renderer mode')
|
|
333
|
+
setError('PNG export not supported in current renderer mode');
|
|
240
334
|
}
|
|
241
335
|
} else {
|
|
242
|
-
await downloadDataUrl(dataUrl, `${graphFilenameStem(params())}.png`)
|
|
336
|
+
await downloadDataUrl(dataUrl, `${graphFilenameStem(params())}.png`);
|
|
243
337
|
}
|
|
244
338
|
} catch (err) {
|
|
245
|
-
setError(err instanceof Error ? err.message : 'PNG export failed')
|
|
339
|
+
setError(err instanceof Error ? err.message : 'PNG export failed');
|
|
246
340
|
}
|
|
247
|
-
setExportMenuOpen(false)
|
|
248
|
-
}
|
|
341
|
+
setExportMenuOpen(false);
|
|
342
|
+
};
|
|
249
343
|
|
|
250
344
|
return (
|
|
251
345
|
<Show
|
|
@@ -266,7 +360,8 @@ export const GraphRenderer: Component<GraphRendererProps> = (props) => {
|
|
|
266
360
|
Graph rendering unavailable
|
|
267
361
|
</p>
|
|
268
362
|
<p class="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
|
|
269
|
-
Install <code>@antv/g6</code> peer dependency to render <code>type: "graph"</code>
|
|
363
|
+
Install <code>@antv/g6</code> peer dependency to render <code>type: "graph"</code>{' '}
|
|
364
|
+
components.
|
|
270
365
|
</p>
|
|
271
366
|
</div>
|
|
272
367
|
</Show>
|
|
@@ -278,9 +373,11 @@ export const GraphRenderer: Component<GraphRendererProps> = (props) => {
|
|
|
278
373
|
copyLabel="Copy graph (JSON)"
|
|
279
374
|
toolbarVariant={props.toolbarVariant}
|
|
280
375
|
>
|
|
281
|
-
<div
|
|
282
|
-
|
|
283
|
-
|
|
376
|
+
<div
|
|
377
|
+
class={`relative w-full ${params().className ?? ''} ${
|
|
378
|
+
isExpanded() ? 'flex-1 min-h-0 flex flex-col' : ''
|
|
379
|
+
}`}
|
|
380
|
+
>
|
|
284
381
|
{/* Export menu — top-right, mirrors TableRenderer's pattern */}
|
|
285
382
|
<div class="absolute right-2 top-2 z-10">
|
|
286
383
|
<button
|
|
@@ -302,11 +399,17 @@ export const GraphRenderer: Component<GraphRendererProps> = (props) => {
|
|
|
302
399
|
width={176}
|
|
303
400
|
class="text-xs"
|
|
304
401
|
>
|
|
305
|
-
<For
|
|
306
|
-
{
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
402
|
+
<For
|
|
403
|
+
each={[
|
|
404
|
+
{ label: 'Download PNG', onClick: handleExportPNG, hint: 'visual snapshot' },
|
|
405
|
+
{
|
|
406
|
+
label: 'Download Mermaid',
|
|
407
|
+
onClick: handleExportMermaid,
|
|
408
|
+
hint: 'markdown / GitHub',
|
|
409
|
+
},
|
|
410
|
+
{ label: 'Download JSON', onClick: handleExportJSON, hint: 'raw data' },
|
|
411
|
+
]}
|
|
412
|
+
>
|
|
310
413
|
{(item) => (
|
|
311
414
|
<button
|
|
312
415
|
type="button"
|
|
@@ -338,34 +441,40 @@ export const GraphRenderer: Component<GraphRendererProps> = (props) => {
|
|
|
338
441
|
</div>
|
|
339
442
|
</ExpandableWrapper>
|
|
340
443
|
</Show>
|
|
341
|
-
)
|
|
342
|
-
}
|
|
444
|
+
);
|
|
445
|
+
};
|
|
343
446
|
|
|
344
447
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
345
448
|
|
|
346
449
|
function graphFilenameStem(params: GraphComponentParams): string {
|
|
347
|
-
const base = (params.title ?? 'graph').replace(/[^a-z0-9-_]+/gi, '-').replace(/^-+|-+$/g, '')
|
|
348
|
-
return base || 'graph'
|
|
450
|
+
const base = (params.title ?? 'graph').replace(/[^a-z0-9-_]+/gi, '-').replace(/^-+|-+$/g, '');
|
|
451
|
+
return base || 'graph';
|
|
349
452
|
}
|
|
350
453
|
|
|
351
454
|
async function downloadDataUrl(dataUrl: string, filename: string): Promise<void> {
|
|
352
|
-
const res = await fetch(dataUrl)
|
|
353
|
-
const blob = await res.blob()
|
|
354
|
-
downloadBlob(blob, filename)
|
|
455
|
+
const res = await fetch(dataUrl);
|
|
456
|
+
const blob = await res.blob();
|
|
457
|
+
downloadBlob(blob, filename);
|
|
355
458
|
}
|
|
356
459
|
|
|
357
460
|
function escapeHtml(s: string): string {
|
|
358
461
|
return s.replace(/[&<>"']/g, (c) => {
|
|
359
462
|
switch (c) {
|
|
360
|
-
case '&':
|
|
361
|
-
|
|
362
|
-
case '
|
|
363
|
-
|
|
364
|
-
case
|
|
365
|
-
|
|
463
|
+
case '&':
|
|
464
|
+
return '&';
|
|
465
|
+
case '<':
|
|
466
|
+
return '<';
|
|
467
|
+
case '>':
|
|
468
|
+
return '>';
|
|
469
|
+
case '"':
|
|
470
|
+
return '"';
|
|
471
|
+
case "'":
|
|
472
|
+
return ''';
|
|
473
|
+
default:
|
|
474
|
+
return c;
|
|
366
475
|
}
|
|
367
|
-
})
|
|
476
|
+
});
|
|
368
477
|
}
|
|
369
478
|
|
|
370
479
|
// Re-export for tests + consumers that want to compose their own export menu
|
|
371
|
-
export { toMermaid as graphToMermaid, toJSON as graphToJSON }
|
|
480
|
+
export { toMermaid as graphToMermaid, toJSON as graphToJSON };
|