@graph-artifact/core 0.1.14 → 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 +0 -6
- package/dist/components/GraphCanvas.js +88 -59
- 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
|
@@ -72,12 +72,6 @@ interface GraphCanvasProps {
|
|
|
72
72
|
* when exporting.
|
|
73
73
|
*/
|
|
74
74
|
reservedBottom?: number;
|
|
75
|
-
/**
|
|
76
|
-
* Whether to embed webfonts into the exported image.
|
|
77
|
-
* Defaults to false because many apps load fonts from cross-origin
|
|
78
|
-
* stylesheets (e.g. Google Fonts), which causes DOMExceptions during export.
|
|
79
|
-
*/
|
|
80
|
-
embedFonts?: boolean;
|
|
81
75
|
/** Optional error callback for export failures. */
|
|
82
76
|
onError?: (error: Error) => void;
|
|
83
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) {
|
|
@@ -308,74 +347,68 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
308
347
|
maxX = Math.max(maxX, abs.x + w);
|
|
309
348
|
maxY = Math.max(maxY, abs.y + h);
|
|
310
349
|
}
|
|
311
|
-
// If we can't compute bounds, fall back to exporting the full viewport.
|
|
312
350
|
const canCrop = isFinite(minX) && isFinite(minY) && isFinite(maxX) && isFinite(maxY);
|
|
313
351
|
const cropToGraph = download?.cropToGraph ?? true;
|
|
314
352
|
const contentPad = Math.max(0, download?.cropPadding ?? 32);
|
|
315
|
-
// Reserve header/footer areas for export-only branding so they never overlap content.
|
|
316
353
|
const exportBranding = Boolean(brandingVisibility.download && (branding?.logo?.src || branding?.logo?.node || branding?.watermark?.text));
|
|
317
354
|
const reservedTop = Math.max(0, download?.reservedTop ?? (exportBranding && (branding?.logo?.src || branding?.logo?.node) ? 76 : 0));
|
|
318
355
|
const reservedBottom = Math.max(0, download?.reservedBottom ?? (exportBranding && branding?.watermark?.text ? 56 : 0));
|
|
319
356
|
const pixelRatio = Math.max(1, download?.pixelRatio ?? 4);
|
|
320
357
|
const backgroundColor = download?.backgroundColor ?? canvas.containerBackground;
|
|
321
358
|
const includeBackground = Boolean(download?.includeBackground);
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
clone.style.top = '0';
|
|
327
|
-
clone.style.opacity = '0';
|
|
328
|
-
clone.style.pointerEvents = 'none';
|
|
329
|
-
clone.style.backgroundColor = backgroundColor;
|
|
330
|
-
// Remove non-export UI.
|
|
331
|
-
clone.querySelectorAll('.ga-no-export').forEach((el) => el.remove());
|
|
332
|
-
// Toggle branding visibility inside clone (download-only by default).
|
|
333
|
-
clone.querySelectorAll('[data-ga-branding]').forEach((el) => {
|
|
334
|
-
el.style.display = exportBranding ? '' : 'none';
|
|
335
|
-
});
|
|
336
|
-
// Toggle background dots in clone (defaults to off).
|
|
337
|
-
clone.querySelectorAll('.react-flow__background').forEach((el) => {
|
|
338
|
-
el.style.display = includeBackground ? '' : 'none';
|
|
339
|
-
});
|
|
359
|
+
// Compute export dimensions and viewport transform.
|
|
360
|
+
let exportWidth;
|
|
361
|
+
let exportHeight;
|
|
362
|
+
let vpTransform;
|
|
340
363
|
if (cropToGraph && canCrop) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const viewport = clone.querySelector('.react-flow__viewport');
|
|
347
|
-
if (viewport) {
|
|
348
|
-
const tx = contentPad - minX;
|
|
349
|
-
const ty = reservedTop + contentPad - minY;
|
|
350
|
-
viewport.style.transform = `translate(${tx}px, ${ty}px) scale(1)`;
|
|
351
|
-
}
|
|
352
|
-
// Ensure the React Flow container expands to the clone size.
|
|
353
|
-
const rf = clone.querySelector('.react-flow');
|
|
354
|
-
if (rf) {
|
|
355
|
-
rf.style.width = '100%';
|
|
356
|
-
rf.style.height = '100%';
|
|
357
|
-
}
|
|
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)`;
|
|
358
369
|
}
|
|
359
370
|
else {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
// Keep current zoom so export roughly matches what the user sees.
|
|
364
|
-
const viewport = clone.querySelector('.react-flow__viewport');
|
|
365
|
-
if (viewport) {
|
|
366
|
-
viewport.style.transform = `translate(${transform[0]}px, ${transform[1]}px) scale(${zoom})`;
|
|
367
|
-
}
|
|
371
|
+
exportWidth = root.clientWidth;
|
|
372
|
+
exportHeight = root.clientHeight;
|
|
373
|
+
vpTransform = `translate(${transform[0]}px, ${transform[1]}px) scale(${zoom})`;
|
|
368
374
|
}
|
|
369
|
-
document.body.appendChild(clone);
|
|
370
375
|
try {
|
|
371
|
-
const blob = await
|
|
372
|
-
|
|
376
|
+
const blob = await exportToPng(root, {
|
|
377
|
+
width: exportWidth,
|
|
378
|
+
height: exportHeight,
|
|
373
379
|
pixelRatio,
|
|
374
380
|
backgroundColor,
|
|
375
|
-
|
|
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
|
+
}
|
|
410
|
+
},
|
|
376
411
|
});
|
|
377
|
-
if (!blob)
|
|
378
|
-
throw new Error('Export failed: no image data produced.');
|
|
379
412
|
const url = URL.createObjectURL(blob);
|
|
380
413
|
const a = document.createElement('a');
|
|
381
414
|
a.href = url;
|
|
@@ -392,9 +425,6 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
392
425
|
console.error('[graph-artifact] Download export failed:', error);
|
|
393
426
|
download?.onError?.(error);
|
|
394
427
|
}
|
|
395
|
-
finally {
|
|
396
|
-
clone.remove();
|
|
397
|
-
}
|
|
398
428
|
}, [
|
|
399
429
|
storeApi,
|
|
400
430
|
branding,
|
|
@@ -408,7 +438,6 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
408
438
|
download?.cropPadding,
|
|
409
439
|
download?.reservedTop,
|
|
410
440
|
download?.reservedBottom,
|
|
411
|
-
download?.embedFonts,
|
|
412
441
|
download?.onError,
|
|
413
442
|
]);
|
|
414
443
|
if (layoutError) {
|
|
@@ -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,
|
|
@@ -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,31 +28,54 @@ function EntityHandles({ fwIn, fwOut }) {
|
|
|
27
28
|
}
|
|
28
29
|
export const EntityNode = memo(function EntityNode({ data }) {
|
|
29
30
|
const theme = useTheme();
|
|
31
|
+
const { layout } = getConfig();
|
|
30
32
|
const label = String(data.label ?? '');
|
|
31
33
|
const attrs = data.attributes ?? [];
|
|
32
34
|
const ns = theme.nodeStyles.entity;
|
|
33
35
|
const fwIn = Math.max(1, data.fwIn ?? 1);
|
|
34
36
|
const fwOut = Math.max(1, data.fwOut ?? 1);
|
|
35
37
|
const layoutWidth = data.layoutWidth;
|
|
36
|
-
const
|
|
38
|
+
const minWidth = Number.isFinite(layoutWidth) && layoutWidth > 0
|
|
39
|
+
? layoutWidth
|
|
40
|
+
: layout.nodeSizing.common.minWidth;
|
|
37
41
|
return (_jsxs("div", { style: {
|
|
38
42
|
...theme.nodeBase.card,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const badge = parts[2];
|
|
45
|
-
const badgeVariant = badge === 'PK' ? 'primary' : badge === 'FK' ? 'secondary' : 'muted';
|
|
46
|
-
return (_jsxs("div", { style: {
|
|
43
|
+
boxSizing: 'border-box',
|
|
44
|
+
width: 'fit-content',
|
|
45
|
+
minWidth,
|
|
46
|
+
maxWidth: layout.nodeSizing.common.maxWidth,
|
|
47
|
+
}, children: [_jsxs("div", { style: { padding: `${theme.space[2]} ${theme.space[3]}` }, children: [_jsx("div", { style: {
|
|
47
48
|
display: 'flex', alignItems: 'center', gap: theme.space[2],
|
|
48
|
-
padding: `${theme.space[1]} 0`,
|
|
49
|
-
borderBottom: i < attrs.length - 1 ? `${theme.borderWidth.sm} solid ${theme.color.gray2}` : 'none',
|
|
50
49
|
fontSize: theme.font.size.md,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
backgroundColor: theme.color.orange1,
|
|
51
|
+
margin: `-${theme.space[2]} -${theme.space[3]}`,
|
|
52
|
+
marginBottom: attrs.length > 0 ? theme.space[1] : `-${theme.space[2]}`,
|
|
53
|
+
padding: `${theme.space[2]} ${theme.space[3]}`,
|
|
54
|
+
borderRadius: `${theme.radius.lg} ${theme.radius.lg} 0 0`,
|
|
55
|
+
}, children: _jsx("span", { style: {
|
|
56
|
+
color: theme.color.white,
|
|
57
|
+
fontWeight: theme.font.weight.semibold,
|
|
58
|
+
flex: 1,
|
|
59
|
+
minWidth: 0,
|
|
60
|
+
whiteSpace: 'nowrap',
|
|
61
|
+
overflow: 'hidden',
|
|
62
|
+
textOverflow: 'ellipsis',
|
|
63
|
+
textAlign: 'center',
|
|
64
|
+
}, title: label, children: label }) }), attrs.map((attr, i) => {
|
|
65
|
+
const parts = attr.split(' ');
|
|
66
|
+
const type = parts[0] ?? '';
|
|
67
|
+
const name = parts[1] ?? '';
|
|
68
|
+
const badge = parts[2];
|
|
69
|
+
const badgeVariant = badge === 'PK' ? 'primary' : badge === 'FK' ? 'secondary' : 'muted';
|
|
70
|
+
return (_jsxs("div", { style: {
|
|
71
|
+
display: 'flex', alignItems: 'center', gap: theme.space[2],
|
|
72
|
+
padding: `${theme.space[1]} 0`,
|
|
73
|
+
borderBottom: i < attrs.length - 1 ? `${theme.borderWidth.sm} solid ${theme.color.gray2}` : 'none',
|
|
74
|
+
fontSize: theme.font.size.md,
|
|
75
|
+
}, children: [_jsx("span", { style: {
|
|
76
|
+
color: theme.color.gray3, minWidth: ns.typeColumnWidth,
|
|
77
|
+
fontFamily: theme.font.mono, fontSize: theme.font.size.sm,
|
|
78
|
+
flexShrink: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
|
79
|
+
}, children: _jsx("span", { title: type, children: type }) }), _jsx("span", { style: { color: theme.color.gray4, flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }, title: name, children: name }), badge && _jsx("span", { style: theme.nodeBase.badge(badgeVariant), children: badge })] }, i));
|
|
80
|
+
})] }), _jsx(EntityHandles, { fwIn: fwIn, fwOut: fwOut })] }));
|
|
57
81
|
});
|