@graph-artifact/core 0.1.13 → 0.1.15
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/dist/components/GraphCanvas.d.ts +17 -4
- package/dist/components/GraphCanvas.js +136 -37
- package/dist/components/edges/RoutedEdge.d.ts +3 -4
- package/dist/components/edges/RoutedEdge.js +14 -7
- package/dist/components/exportPng.d.ts +46 -0
- package/dist/components/exportPng.js +156 -0
- package/dist/components/nodes/ClassNode.js +9 -2
- package/dist/components/nodes/EntityNode.js +41 -17
- package/dist/components/nodes/FlowNode.js +22 -7
- package/dist/components/nodes/StateNode.js +13 -17
- package/dist/components/nodes/SubgraphNode.js +2 -2
- package/dist/config.d.ts +1 -1
- package/dist/config.js +7 -7
- package/dist/layout/dagre/index.js +49 -0
- package/dist/layout/dagre/nodeSizing.js +8 -8
- package/dist/layout/edges/buildEdges.js +681 -25
- package/dist/layout/edges/paths.d.ts +4 -0
- package/dist/layout/edges/paths.js +137 -9
- package/dist/layout/index.js +19 -0
- package/dist/layout/sequenceLayout.js +5 -2
- package/dist/parsers/state.js +162 -23
- package/dist/theme/dark.js +11 -11
- package/dist/theme/light.js +10 -10
- package/dist/utils/boxShadow.d.ts +9 -0
- package/dist/utils/boxShadow.js +103 -0
- package/package.json +1 -1
|
@@ -54,11 +54,24 @@ interface GraphCanvasProps {
|
|
|
54
54
|
*/
|
|
55
55
|
includeBackground?: boolean;
|
|
56
56
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
57
|
+
* Export a tightly-cropped image around the graph content (nodes + edges),
|
|
58
|
+
* instead of exporting the entire viewer viewport.
|
|
59
|
+
*
|
|
60
|
+
* Defaults to true.
|
|
61
|
+
*/
|
|
62
|
+
cropToGraph?: boolean;
|
|
63
|
+
/** Padding (CSS px) around the cropped content when `cropToGraph` is enabled. */
|
|
64
|
+
cropPadding?: number;
|
|
65
|
+
/**
|
|
66
|
+
* Extra reserved space (CSS px) above the graph content for logo/branding
|
|
67
|
+
* when exporting.
|
|
68
|
+
*/
|
|
69
|
+
reservedTop?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Extra reserved space (CSS px) below the graph content for watermark
|
|
72
|
+
* when exporting.
|
|
60
73
|
*/
|
|
61
|
-
|
|
74
|
+
reservedBottom?: number;
|
|
62
75
|
/** Optional error callback for export failures. */
|
|
63
76
|
onError?: (error: Error) => void;
|
|
64
77
|
};
|
|
@@ -2,11 +2,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useMemo, useCallback, useState, useEffect, useRef } from 'react';
|
|
3
3
|
import { ReactFlow, Background, Controls, MiniMap, ReactFlowProvider, useReactFlow, useNodesInitialized, useStore, useStoreApi, Panel, } from '@xyflow/react';
|
|
4
4
|
import '@xyflow/react/dist/style.css';
|
|
5
|
-
import {
|
|
5
|
+
import { exportToPng } from './exportPng.js';
|
|
6
6
|
import { layoutNodes, buildLayoutEdges } from '../layout/index.js';
|
|
7
7
|
import { getConfig, getNodeTypes } from '../config.js';
|
|
8
8
|
import { useTheme } from '../ThemeContext.js';
|
|
9
9
|
import { RoutedEdge } from './edges/RoutedEdge.js';
|
|
10
|
+
import { getBoxShadowMaxExtent } from '../utils/boxShadow.js';
|
|
10
11
|
// Stable reference — must be outside the component to avoid infinite re-renders
|
|
11
12
|
const edgeTypes = { routed: RoutedEdge };
|
|
12
13
|
function cornerToPanelPosition(pos) {
|
|
@@ -183,10 +184,19 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
183
184
|
metadata,
|
|
184
185
|
storeApi,
|
|
185
186
|
]);
|
|
187
|
+
// Check if any nodes have measured dimensions (more reliable than useNodesInitialized)
|
|
188
|
+
const hasMeasuredNodes = useStore((s) => {
|
|
189
|
+
for (const n of s.nodeLookup.values()) {
|
|
190
|
+
if (n.measured?.width && n.measured?.height)
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
return false;
|
|
194
|
+
});
|
|
186
195
|
// Phase 2: Recompute when React Flow measurements change so intersections
|
|
187
196
|
// always use actual rendered node dimensions.
|
|
188
197
|
useEffect(() => {
|
|
189
|
-
|
|
198
|
+
// Use hasMeasuredNodes instead of nodesInitialized (more reliable)
|
|
199
|
+
if (!hasMeasuredNodes || !contextRef.current)
|
|
190
200
|
return;
|
|
191
201
|
const nodeLookup = storeApi.getState().nodeLookup;
|
|
192
202
|
const measuredPositions = new Map();
|
|
@@ -206,7 +216,34 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
206
216
|
});
|
|
207
217
|
}
|
|
208
218
|
setEdges(buildLayoutEdges(contextRef.current, theme, measuredPositions));
|
|
209
|
-
}, [
|
|
219
|
+
}, [hasMeasuredNodes, measuredSignature, theme, storeApi, parsed.diagramType]);
|
|
220
|
+
// Sequence diagrams (and others without context) return edges directly.
|
|
221
|
+
// React Flow may not render edges until anchor nodes are registered.
|
|
222
|
+
// Re-set edges after a short delay to ensure nodes are ready.
|
|
223
|
+
const didInitialEdgeRefresh = useRef(false);
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
// Only for diagrams without context (sequence, etc.)
|
|
226
|
+
if (contextRef.current)
|
|
227
|
+
return;
|
|
228
|
+
if (didInitialEdgeRefresh.current)
|
|
229
|
+
return;
|
|
230
|
+
if (!initialLayoutState.result)
|
|
231
|
+
return;
|
|
232
|
+
const edgesToSet = initialLayoutState.result.edges;
|
|
233
|
+
if (!edgesToSet || edgesToSet.length === 0)
|
|
234
|
+
return;
|
|
235
|
+
// Use double rAF to ensure React Flow has registered all nodes
|
|
236
|
+
didInitialEdgeRefresh.current = true;
|
|
237
|
+
requestAnimationFrame(() => {
|
|
238
|
+
requestAnimationFrame(() => {
|
|
239
|
+
setEdges([...edgesToSet]);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
}, [initialLayoutState, hasMeasuredNodes]);
|
|
243
|
+
// Reset the flag when diagram changes
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
didInitialEdgeRefresh.current = false;
|
|
246
|
+
}, [parsed]);
|
|
210
247
|
// Subgraph boxes are visual annotations; compute their bounds from measured
|
|
211
248
|
// child node DOM dimensions so they never drift due to sizing changes.
|
|
212
249
|
useEffect(() => {
|
|
@@ -218,7 +255,9 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
218
255
|
const nodeLookup = storeApi.getState().nodeLookup;
|
|
219
256
|
// Include extra pad so node shadows/rounded corners don't peek outside.
|
|
220
257
|
// We intentionally over-pad here; subgraphs are visual groupings, not tight boxes.
|
|
221
|
-
const
|
|
258
|
+
const shadowPad = Math.ceil(getBoxShadowMaxExtent(theme.shadow.card));
|
|
259
|
+
const extraPad = Math.max(16, shadowPad + 6);
|
|
260
|
+
const pad = layout.subgraphPadding + extraPad;
|
|
222
261
|
const labelOffset = theme.nodeStyles.subgraph.labelOffset;
|
|
223
262
|
const boundsById = new Map();
|
|
224
263
|
for (const sg of parsed.subgraphs) {
|
|
@@ -273,7 +312,6 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
273
312
|
const miniMapNodeColor = useCallback(() => theme.color.orange1, [theme.color.orange1]);
|
|
274
313
|
const miniMapMaskColor = useMemo(() => `${theme.color.gray1}cc`, [theme.color.gray1]);
|
|
275
314
|
const exportRef = useRef(null);
|
|
276
|
-
const [isExporting, setIsExporting] = useState(false);
|
|
277
315
|
const brandingVisibility = useMemo(() => {
|
|
278
316
|
const v = branding?.visibility ?? {};
|
|
279
317
|
return {
|
|
@@ -281,38 +319,96 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
281
319
|
download: v.download ?? true,
|
|
282
320
|
};
|
|
283
321
|
}, [branding?.visibility]);
|
|
284
|
-
const showBrandingNow = isExporting ? brandingVisibility.download : brandingVisibility.canvas;
|
|
285
|
-
const showBackgroundNow = isExporting ? Boolean(download?.includeBackground) : Boolean(canvas.showBackground);
|
|
286
322
|
const onDownload = useCallback(async () => {
|
|
287
|
-
|
|
323
|
+
const root = exportRef.current;
|
|
324
|
+
if (!root)
|
|
288
325
|
return;
|
|
289
326
|
const fileName = (download?.fileName ?? 'diagram')
|
|
290
327
|
.trim()
|
|
291
328
|
.replace(/[^\w\- ]+/g, '')
|
|
292
329
|
.replace(/\s+/g, '-')
|
|
293
330
|
.slice(0, 80) || 'diagram';
|
|
294
|
-
|
|
331
|
+
const state = storeApi.getState();
|
|
332
|
+
const nodeLookup = state.nodeLookup;
|
|
333
|
+
const transform = state.transform; // [x, y, zoom]
|
|
334
|
+
const zoom = transform[2] ?? 1;
|
|
335
|
+
// Compute content bounds in graph coordinates from measured node boxes.
|
|
336
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
337
|
+
for (const n of nodeLookup.values()) {
|
|
338
|
+
if (n.hidden)
|
|
339
|
+
continue;
|
|
340
|
+
const abs = n.internals.positionAbsolute;
|
|
341
|
+
const w = n.measured?.width ?? n.width ?? 0;
|
|
342
|
+
const h = n.measured?.height ?? n.height ?? 0;
|
|
343
|
+
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0)
|
|
344
|
+
continue;
|
|
345
|
+
minX = Math.min(minX, abs.x);
|
|
346
|
+
minY = Math.min(minY, abs.y);
|
|
347
|
+
maxX = Math.max(maxX, abs.x + w);
|
|
348
|
+
maxY = Math.max(maxY, abs.y + h);
|
|
349
|
+
}
|
|
350
|
+
const canCrop = isFinite(minX) && isFinite(minY) && isFinite(maxX) && isFinite(maxY);
|
|
351
|
+
const cropToGraph = download?.cropToGraph ?? true;
|
|
352
|
+
const contentPad = Math.max(0, download?.cropPadding ?? 32);
|
|
353
|
+
const exportBranding = Boolean(brandingVisibility.download && (branding?.logo?.src || branding?.logo?.node || branding?.watermark?.text));
|
|
354
|
+
const reservedTop = Math.max(0, download?.reservedTop ?? (exportBranding && (branding?.logo?.src || branding?.logo?.node) ? 76 : 0));
|
|
355
|
+
const reservedBottom = Math.max(0, download?.reservedBottom ?? (exportBranding && branding?.watermark?.text ? 56 : 0));
|
|
356
|
+
const pixelRatio = Math.max(1, download?.pixelRatio ?? 4);
|
|
357
|
+
const backgroundColor = download?.backgroundColor ?? canvas.containerBackground;
|
|
358
|
+
const includeBackground = Boolean(download?.includeBackground);
|
|
359
|
+
// Compute export dimensions and viewport transform.
|
|
360
|
+
let exportWidth;
|
|
361
|
+
let exportHeight;
|
|
362
|
+
let vpTransform;
|
|
363
|
+
if (cropToGraph && canCrop) {
|
|
364
|
+
exportWidth = Math.ceil((maxX - minX) + contentPad * 2);
|
|
365
|
+
exportHeight = Math.ceil((maxY - minY) + contentPad * 2 + reservedTop + reservedBottom);
|
|
366
|
+
const tx = contentPad - minX;
|
|
367
|
+
const ty = reservedTop + contentPad - minY;
|
|
368
|
+
vpTransform = `translate(${tx}px, ${ty}px) scale(1)`;
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
exportWidth = root.clientWidth;
|
|
372
|
+
exportHeight = root.clientHeight;
|
|
373
|
+
vpTransform = `translate(${transform[0]}px, ${transform[1]}px) scale(${zoom})`;
|
|
374
|
+
}
|
|
295
375
|
try {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
376
|
+
const blob = await exportToPng(root, {
|
|
377
|
+
width: exportWidth,
|
|
378
|
+
height: exportHeight,
|
|
379
|
+
pixelRatio,
|
|
380
|
+
backgroundColor,
|
|
381
|
+
filter: (el) => !el.classList.contains('ga-no-export'),
|
|
382
|
+
onClone: (clone) => {
|
|
383
|
+
// Set viewport transform for cropped/framed export.
|
|
384
|
+
const vp = clone.querySelector('.react-flow__viewport');
|
|
385
|
+
if (vp)
|
|
386
|
+
vp.style.transform = vpTransform;
|
|
387
|
+
// Toggle branding visibility (download-only by default).
|
|
388
|
+
clone.querySelectorAll('[data-ga-branding]').forEach((el) => {
|
|
389
|
+
const htmlEl = el;
|
|
390
|
+
if (!exportBranding) {
|
|
391
|
+
htmlEl.style.display = 'none';
|
|
392
|
+
}
|
|
393
|
+
else if (el.hasAttribute('data-ga-watermark')) {
|
|
394
|
+
htmlEl.style.display = 'flex';
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
htmlEl.style.display = 'block';
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
// Toggle background dots (defaults to off).
|
|
401
|
+
clone.querySelectorAll('.react-flow__background').forEach((el) => {
|
|
402
|
+
el.style.display = includeBackground ? 'block' : 'none';
|
|
403
|
+
});
|
|
404
|
+
// Ensure the React Flow container fills the export frame.
|
|
405
|
+
const rf = clone.querySelector('.react-flow');
|
|
406
|
+
if (rf) {
|
|
407
|
+
rf.style.width = '100%';
|
|
408
|
+
rf.style.height = '100%';
|
|
409
|
+
}
|
|
312
410
|
},
|
|
313
411
|
});
|
|
314
|
-
if (!blob)
|
|
315
|
-
throw new Error('Export failed: no image data produced.');
|
|
316
412
|
const url = URL.createObjectURL(blob);
|
|
317
413
|
const a = document.createElement('a');
|
|
318
414
|
a.href = url;
|
|
@@ -329,18 +425,20 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
329
425
|
console.error('[graph-artifact] Download export failed:', error);
|
|
330
426
|
download?.onError?.(error);
|
|
331
427
|
}
|
|
332
|
-
finally {
|
|
333
|
-
setIsExporting(false);
|
|
334
|
-
}
|
|
335
428
|
}, [
|
|
429
|
+
storeApi,
|
|
430
|
+
branding,
|
|
431
|
+
brandingVisibility.download,
|
|
336
432
|
canvas.containerBackground,
|
|
337
|
-
canvas.showBackground,
|
|
338
|
-
download?.backgroundColor,
|
|
339
433
|
download?.fileName,
|
|
340
434
|
download?.pixelRatio,
|
|
435
|
+
download?.backgroundColor,
|
|
341
436
|
download?.includeBackground,
|
|
437
|
+
download?.cropToGraph,
|
|
438
|
+
download?.cropPadding,
|
|
439
|
+
download?.reservedTop,
|
|
440
|
+
download?.reservedBottom,
|
|
342
441
|
download?.onError,
|
|
343
|
-
download?.embedFonts,
|
|
344
442
|
]);
|
|
345
443
|
if (layoutError) {
|
|
346
444
|
return (_jsx("div", { style: {
|
|
@@ -392,7 +490,7 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
392
490
|
}
|
|
393
491
|
/* Ensure inline SVG logos scale to the requested width. */
|
|
394
492
|
.ga-logo svg { width: 100%; height: auto; display: block; }
|
|
395
|
-
` }),
|
|
493
|
+
` }), (branding?.logo?.src || branding?.logo?.node) && (_jsx("div", { "data-ga-branding": true, "data-ga-logo": true, style: {
|
|
396
494
|
position: 'absolute',
|
|
397
495
|
zIndex: 5,
|
|
398
496
|
pointerEvents: 'none',
|
|
@@ -401,18 +499,18 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
401
499
|
...(branding.logo.position === 'bottom-right' ? { bottom: 12, right: 12 } : {}),
|
|
402
500
|
...(branding.logo.position === 'top-left' || !branding.logo.position ? { top: 12, left: 12 } : {}),
|
|
403
501
|
opacity: branding.logo.opacity ?? 1,
|
|
502
|
+
display: brandingVisibility.canvas ? 'block' : 'none',
|
|
404
503
|
}, children: branding.logo.node ? (_jsx("div", { className: "ga-logo", style: {
|
|
405
504
|
width: branding.logo.width ?? 120,
|
|
406
505
|
height: branding.logo.height,
|
|
407
506
|
display: 'block',
|
|
408
|
-
}, children: branding.logo.node })) : (_jsx("img", { src: branding.logo.src, alt: branding.logo.alt ?? 'logo', width: branding.logo.width ?? 120, height: branding.logo.height, style: { display: 'block' } })) })),
|
|
507
|
+
}, children: branding.logo.node })) : (_jsx("img", { src: branding.logo.src, alt: branding.logo.alt ?? 'logo', width: branding.logo.width ?? 120, height: branding.logo.height, style: { display: 'block' } })) })), branding?.watermark?.text && (_jsxs("div", { "data-ga-branding": true, "data-ga-watermark": true, style: {
|
|
409
508
|
position: 'absolute',
|
|
410
509
|
left: '50%',
|
|
411
510
|
bottom: 14,
|
|
412
511
|
transform: 'translateX(-50%)',
|
|
413
512
|
zIndex: 5,
|
|
414
513
|
pointerEvents: 'none',
|
|
415
|
-
display: 'flex',
|
|
416
514
|
alignItems: 'center',
|
|
417
515
|
gap: 8,
|
|
418
516
|
fontFamily: theme.font.family,
|
|
@@ -421,7 +519,8 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
421
519
|
opacity: branding.watermark.opacity ?? 0.9,
|
|
422
520
|
userSelect: 'none',
|
|
423
521
|
whiteSpace: 'nowrap',
|
|
424
|
-
|
|
522
|
+
display: brandingVisibility.canvas ? 'flex' : 'none',
|
|
523
|
+
}, children: [branding.watermark.icon ?? null, _jsx("span", { children: branding.watermark.text })] })), _jsxs(ReactFlow, { nodes: nodes, edges: edges, nodeTypes: resolvedNodeTypes, edgeTypes: edgeTypes, onNodeClick: handleNodeClick, fitView: canvas.fitView, minZoom: canvas.minZoom, maxZoom: canvas.maxZoom, defaultEdgeOptions: defaultEdgeOptions, zIndexMode: "manual", colorMode: theme.mode, proOptions: hideAttribution ? { hideAttribution: true } : undefined, children: [_jsx(Background, { style: { display: canvas.showBackground ? 'block' : 'none' }, color: canvas.gridColor === 'auto' ? theme.color.gray2 : canvas.gridColor, gap: canvas.backgroundGap, size: canvas.backgroundSize }), canvas.showControls && (_jsx("div", { className: "ga-no-export", children: _jsx(Controls, { position: canvas.controlsPosition,
|
|
425
524
|
// Remove the "lock" (interactive) button; we want pan/zoom always available.
|
|
426
525
|
showInteractive: false }) })), ((download?.enabled ?? canvas.showControls) && download?.enabled !== false) && (_jsx(Panel, { position: cornerToPanelPosition(download?.position ?? 'top-right'), className: "ga-no-export", children: _jsx("button", { type: "button", onClick: onDownload, "aria-label": "Download diagram", title: "Download", style: {
|
|
427
526
|
display: 'inline-flex',
|
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
* RoutedEdge — Renders a pre-computed SVG path from dagre waypoints,
|
|
3
3
|
* with optional ER cardinality markers (crow's foot notation).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* angle computed during edge routing.
|
|
5
|
+
* Edge lines render in the default edge SVG layer (below nodes).
|
|
6
|
+
* Cardinality symbols render in EdgeLabelRenderer (above nodes),
|
|
7
|
+
* so they're never hidden behind ER card bodies or shadows.
|
|
9
8
|
*/
|
|
10
9
|
import type { EdgeProps } from '@xyflow/react';
|
|
11
10
|
export declare function RoutedEdge({ data, markerEnd, style, label, labelStyle, labelBgStyle, labelBgPadding, labelBgBorderRadius, }: EdgeProps): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* RoutedEdge — Renders a pre-computed SVG path from dagre waypoints,
|
|
4
4
|
* with optional ER cardinality markers (crow's foot notation).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* angle computed during edge routing.
|
|
6
|
+
* Edge lines render in the default edge SVG layer (below nodes).
|
|
7
|
+
* Cardinality symbols render in EdgeLabelRenderer (above nodes),
|
|
8
|
+
* so they're never hidden behind ER card bodies or shadows.
|
|
10
9
|
*/
|
|
11
|
-
import { BaseEdge } from '@xyflow/react';
|
|
10
|
+
import { BaseEdge, EdgeLabelRenderer } from '@xyflow/react';
|
|
12
11
|
import { useTheme } from '../../ThemeContext.js';
|
|
13
12
|
// ─── Cardinality Drawing ────────────────────────────────────────────────────
|
|
14
13
|
//
|
|
@@ -195,5 +194,13 @@ export function RoutedEdge({ data, markerEnd, style, label, labelStyle, labelBgS
|
|
|
195
194
|
const endX = data?.pathEndX;
|
|
196
195
|
const endY = data?.pathEndY;
|
|
197
196
|
const endAngle = data?.pathEndAngle;
|
|
198
|
-
return (_jsxs(_Fragment, { children: [_jsx(BaseEdge, { path: svgPath, markerEnd: hasCardinality ? undefined : markerEnd, style: style, label: label, labelX: labelX, labelY: labelY, labelStyle: labelStyle, labelBgStyle: labelBgStyle, labelBgPadding: labelBgPadding, labelBgBorderRadius: labelBgBorderRadius }),
|
|
197
|
+
return (_jsxs(_Fragment, { children: [_jsx(BaseEdge, { path: svgPath, markerEnd: hasCardinality ? undefined : markerEnd, style: style, label: label, labelX: labelX, labelY: labelY, labelStyle: labelStyle, labelBgStyle: labelBgStyle, labelBgPadding: labelBgPadding, labelBgBorderRadius: labelBgBorderRadius }), hasCardinality && (_jsx(EdgeLabelRenderer, { children: _jsxs("svg", { style: {
|
|
198
|
+
position: 'absolute',
|
|
199
|
+
top: 0,
|
|
200
|
+
left: 0,
|
|
201
|
+
width: 0,
|
|
202
|
+
height: 0,
|
|
203
|
+
overflow: 'visible',
|
|
204
|
+
pointerEvents: 'none',
|
|
205
|
+
}, children: [cardSource && startX != null && startY != null && startAngle != null && (_jsx(CardinalitySymbol, { card: cardSource, token: cardSourceToken, isSourceEnd: true, x: startX, y: startY, angle: startAngle, color: edgeColor, bgColor: bgColor })), cardTarget && endX != null && endY != null && endAngle != null && (_jsx(CardinalitySymbol, { card: cardTarget, token: cardTargetToken, isSourceEnd: false, x: endX, y: endY, angle: endAngle, color: edgeColor, bgColor: bgColor }))] }) }))] }));
|
|
199
206
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* exportPng.ts
|
|
3
|
+
*
|
|
4
|
+
* Zero-dependency PNG export using native browser APIs.
|
|
5
|
+
* Replaces html-to-image (which regressed in versions > 1.11.11,
|
|
6
|
+
* producing blank exports and crashing on cross-origin font stylesheets).
|
|
7
|
+
*
|
|
8
|
+
* Pipeline: clone → inline computed styles → consumer mutations →
|
|
9
|
+
* embed images → serialize XHTML → SVG foreignObject →
|
|
10
|
+
* Image → Canvas → PNG Blob
|
|
11
|
+
*/
|
|
12
|
+
interface ExportToPngOptions {
|
|
13
|
+
/** Width of the exported image in CSS pixels. */
|
|
14
|
+
width: number;
|
|
15
|
+
/** Height of the exported image in CSS pixels. */
|
|
16
|
+
height: number;
|
|
17
|
+
/** Device pixel ratio (higher = sharper). Default 2. */
|
|
18
|
+
pixelRatio?: number;
|
|
19
|
+
/** Background fill color. Default '#ffffff'. */
|
|
20
|
+
backgroundColor?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Called for each child element during filtering.
|
|
23
|
+
* Return false to exclude the element from the export.
|
|
24
|
+
*/
|
|
25
|
+
filter?: (element: Element) => boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Called after the clone is created and styles are inlined,
|
|
28
|
+
* but before serialization. Use this to mutate the clone
|
|
29
|
+
* (viewport transforms, branding visibility, etc.).
|
|
30
|
+
*/
|
|
31
|
+
onClone?: (clone: HTMLElement) => void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Export a DOM element to a PNG Blob using native browser APIs.
|
|
35
|
+
*
|
|
36
|
+
* 1. Clone the element
|
|
37
|
+
* 2. Inline all computed styles (so the clone is self-contained)
|
|
38
|
+
* 3. Filter out unwanted children
|
|
39
|
+
* 4. Embed external images as data URIs
|
|
40
|
+
* 5. Let consumer mutate the clone
|
|
41
|
+
* 6. Serialize to XHTML → wrap in SVG foreignObject
|
|
42
|
+
* 7. Load SVG as Image → draw to Canvas → export PNG Blob
|
|
43
|
+
*/
|
|
44
|
+
declare function exportToPng(element: HTMLElement, options: ExportToPngOptions): Promise<Blob>;
|
|
45
|
+
export { exportToPng };
|
|
46
|
+
export type { ExportToPngOptions };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* exportPng.ts
|
|
3
|
+
*
|
|
4
|
+
* Zero-dependency PNG export using native browser APIs.
|
|
5
|
+
* Replaces html-to-image (which regressed in versions > 1.11.11,
|
|
6
|
+
* producing blank exports and crashing on cross-origin font stylesheets).
|
|
7
|
+
*
|
|
8
|
+
* Pipeline: clone → inline computed styles → consumer mutations →
|
|
9
|
+
* embed images → serialize XHTML → SVG foreignObject →
|
|
10
|
+
* Image → Canvas → PNG Blob
|
|
11
|
+
*/
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Style inlining
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
/**
|
|
16
|
+
* Recursively copy every computed style from the live DOM onto a clone.
|
|
17
|
+
* Both trees must be structurally identical (call before any filtering).
|
|
18
|
+
*/
|
|
19
|
+
function inlineStyles(source, target) {
|
|
20
|
+
const isStyleable = (source instanceof HTMLElement || source instanceof SVGElement) &&
|
|
21
|
+
(target instanceof HTMLElement || target instanceof SVGElement);
|
|
22
|
+
if (isStyleable) {
|
|
23
|
+
const computed = window.getComputedStyle(source);
|
|
24
|
+
const len = computed.length;
|
|
25
|
+
let css = '';
|
|
26
|
+
for (let i = 0; i < len; i++) {
|
|
27
|
+
const prop = computed[i];
|
|
28
|
+
css += `${prop}:${computed.getPropertyValue(prop)};`;
|
|
29
|
+
}
|
|
30
|
+
target.style.cssText = css;
|
|
31
|
+
}
|
|
32
|
+
const srcKids = source.children;
|
|
33
|
+
const tgtKids = target.children;
|
|
34
|
+
const count = Math.min(srcKids.length, tgtKids.length);
|
|
35
|
+
for (let i = 0; i < count; i++) {
|
|
36
|
+
inlineStyles(srcKids[i], tgtKids[i]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Filtering
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
/** Recursively remove elements from a clone that fail the predicate. */
|
|
43
|
+
function applyFilter(root, filter) {
|
|
44
|
+
for (let i = root.children.length - 1; i >= 0; i--) {
|
|
45
|
+
const child = root.children[i];
|
|
46
|
+
if (!filter(child)) {
|
|
47
|
+
child.remove();
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
applyFilter(child, filter);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Image embedding
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
/**
|
|
58
|
+
* Convert <img> elements with external src to data URIs so they render
|
|
59
|
+
* inside the SVG foreignObject sandbox (which blocks external resources).
|
|
60
|
+
*/
|
|
61
|
+
async function embedImages(root) {
|
|
62
|
+
const imgs = root.querySelectorAll('img');
|
|
63
|
+
for (const img of Array.from(imgs)) {
|
|
64
|
+
const src = img.getAttribute('src') ?? '';
|
|
65
|
+
if (!src || src.startsWith('data:'))
|
|
66
|
+
continue;
|
|
67
|
+
try {
|
|
68
|
+
const resp = await fetch(src);
|
|
69
|
+
const blob = await resp.blob();
|
|
70
|
+
const dataUrl = await new Promise((resolve, reject) => {
|
|
71
|
+
const reader = new FileReader();
|
|
72
|
+
reader.onload = () => resolve(reader.result);
|
|
73
|
+
reader.onerror = reject;
|
|
74
|
+
reader.readAsDataURL(blob);
|
|
75
|
+
});
|
|
76
|
+
img.setAttribute('src', dataUrl);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Non-critical: image won't appear in export
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Public API
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
/**
|
|
87
|
+
* Export a DOM element to a PNG Blob using native browser APIs.
|
|
88
|
+
*
|
|
89
|
+
* 1. Clone the element
|
|
90
|
+
* 2. Inline all computed styles (so the clone is self-contained)
|
|
91
|
+
* 3. Filter out unwanted children
|
|
92
|
+
* 4. Embed external images as data URIs
|
|
93
|
+
* 5. Let consumer mutate the clone
|
|
94
|
+
* 6. Serialize to XHTML → wrap in SVG foreignObject
|
|
95
|
+
* 7. Load SVG as Image → draw to Canvas → export PNG Blob
|
|
96
|
+
*/
|
|
97
|
+
async function exportToPng(element, options) {
|
|
98
|
+
const { width, height, pixelRatio = 2, backgroundColor = '#ffffff', filter, onClone, } = options;
|
|
99
|
+
// 1. Clone (exact structural copy — indices must match source for inlining)
|
|
100
|
+
const clone = element.cloneNode(true);
|
|
101
|
+
// 2. Inline computed styles from the live DOM (before any structural changes)
|
|
102
|
+
inlineStyles(element, clone);
|
|
103
|
+
// 3. Filter unwanted elements (safe now — inlining already used aligned indices)
|
|
104
|
+
if (filter)
|
|
105
|
+
applyFilter(clone, filter);
|
|
106
|
+
// 4. Embed external images as data URIs
|
|
107
|
+
await embedImages(clone);
|
|
108
|
+
// 5. Let consumer mutate the styled clone (viewport transforms, branding, etc.)
|
|
109
|
+
if (onClone)
|
|
110
|
+
onClone(clone);
|
|
111
|
+
// 6. Lock clone dimensions for the export frame
|
|
112
|
+
clone.style.width = `${width}px`;
|
|
113
|
+
clone.style.height = `${height}px`;
|
|
114
|
+
clone.style.overflow = 'hidden';
|
|
115
|
+
clone.style.margin = '0';
|
|
116
|
+
// 7. Serialize to XHTML
|
|
117
|
+
const xhtml = new XMLSerializer().serializeToString(clone);
|
|
118
|
+
// 8. Wrap in SVG foreignObject
|
|
119
|
+
const svg = [
|
|
120
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">`,
|
|
121
|
+
`<foreignObject x="0" y="0" width="100%" height="100%">`,
|
|
122
|
+
xhtml,
|
|
123
|
+
`</foreignObject>`,
|
|
124
|
+
`</svg>`,
|
|
125
|
+
].join('');
|
|
126
|
+
const svgBlob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
|
|
127
|
+
const svgUrl = URL.createObjectURL(svgBlob);
|
|
128
|
+
try {
|
|
129
|
+
// 9. Load SVG as Image
|
|
130
|
+
const img = await new Promise((resolve, reject) => {
|
|
131
|
+
const image = new Image();
|
|
132
|
+
image.onload = () => resolve(image);
|
|
133
|
+
image.onerror = () => reject(new Error('SVG image failed to load — export aborted'));
|
|
134
|
+
image.src = svgUrl;
|
|
135
|
+
});
|
|
136
|
+
// 10. Draw to high-DPI canvas
|
|
137
|
+
const canvas = document.createElement('canvas');
|
|
138
|
+
canvas.width = Math.ceil(width * pixelRatio);
|
|
139
|
+
canvas.height = Math.ceil(height * pixelRatio);
|
|
140
|
+
const ctx = canvas.getContext('2d');
|
|
141
|
+
if (!ctx)
|
|
142
|
+
throw new Error('Cannot create canvas 2d context');
|
|
143
|
+
ctx.scale(pixelRatio, pixelRatio);
|
|
144
|
+
ctx.fillStyle = backgroundColor;
|
|
145
|
+
ctx.fillRect(0, 0, width, height);
|
|
146
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
147
|
+
// 11. Export as PNG blob
|
|
148
|
+
return await new Promise((resolve, reject) => {
|
|
149
|
+
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Canvas toBlob returned null'))), 'image/png', 1.0);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
URL.revokeObjectURL(svgUrl);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
export { exportToPng };
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { memo } from 'react';
|
|
3
3
|
import { Handle, Position } from '@xyflow/react';
|
|
4
4
|
import { useTheme } from '../../ThemeContext.js';
|
|
5
|
+
import { getConfig } from '../../config.js';
|
|
5
6
|
const invisibleHandle = {
|
|
6
7
|
opacity: 0,
|
|
7
8
|
width: 1,
|
|
@@ -27,6 +28,7 @@ function ClassHandles({ fwIn, fwOut }) {
|
|
|
27
28
|
}
|
|
28
29
|
export const ClassNode = memo(function ClassNode({ data }) {
|
|
29
30
|
const theme = useTheme();
|
|
31
|
+
const { layout } = getConfig();
|
|
30
32
|
const label = String(data.label ?? '');
|
|
31
33
|
const props = data.properties ?? [];
|
|
32
34
|
const methods = data.methods ?? [];
|
|
@@ -34,11 +36,16 @@ export const ClassNode = memo(function ClassNode({ data }) {
|
|
|
34
36
|
const fwIn = Math.max(1, data.fwIn ?? 1);
|
|
35
37
|
const fwOut = Math.max(1, data.fwOut ?? 1);
|
|
36
38
|
const layoutWidth = data.layoutWidth;
|
|
37
|
-
const
|
|
39
|
+
const minWidth = Number.isFinite(layoutWidth) && layoutWidth > 0
|
|
40
|
+
? layoutWidth
|
|
41
|
+
: layout.nodeSizing.common.minWidth;
|
|
38
42
|
return (_jsxs("div", { style: {
|
|
39
43
|
...theme.nodeBase.card,
|
|
40
44
|
fontFamily: theme.font.mono,
|
|
41
|
-
|
|
45
|
+
boxSizing: 'border-box',
|
|
46
|
+
width: 'fit-content',
|
|
47
|
+
minWidth,
|
|
48
|
+
maxWidth: layout.nodeSizing.common.maxWidth,
|
|
42
49
|
}, children: [_jsx("div", { style: {
|
|
43
50
|
...theme.nodeBase.header,
|
|
44
51
|
fontFamily: theme.font.family,
|