@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.
@@ -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 { toBlob } from 'html-to-image';
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
- if (!nodesInitialized || !contextRef.current)
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
- }, [nodesInitialized, measuredSignature, theme, storeApi]);
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 pad = layout.subgraphPadding + 16;
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
- // Create an offscreen clone so export-only UI never flashes in the live viewer.
323
- const clone = root.cloneNode(true);
324
- clone.style.position = 'fixed';
325
- clone.style.left = '-100000px';
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
- const w = Math.ceil((maxX - minX) + contentPad * 2);
342
- const h = Math.ceil((maxY - minY) + contentPad * 2 + reservedTop + reservedBottom);
343
- clone.style.width = `${w}px`;
344
- clone.style.height = `${h}px`;
345
- // Reposition the viewport so content is tightly framed with padding.
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
- // Keep the clone at the current viewport size.
361
- clone.style.width = `${root.clientWidth}px`;
362
- clone.style.height = `${root.clientHeight}px`;
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 toBlob(clone, {
372
- cacheBust: true,
376
+ const blob = await exportToPng(root, {
377
+ width: exportWidth,
378
+ height: exportHeight,
373
379
  pixelRatio,
374
380
  backgroundColor,
375
- skipFonts: !(download?.embedFonts ?? false),
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
- * Instead of using SVG <marker> definitions (which have unreliable
6
- * orient/refX behavior across browsers), we draw cardinality symbols
7
- * as explicit SVG elements at the path endpoints using the tangent
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, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
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
- * Instead of using SVG <marker> definitions (which have unreliable
7
- * orient/refX behavior across browsers), we draw cardinality symbols
8
- * as explicit SVG elements at the path endpoints using the tangent
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 }), 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 }))] }));
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 layoutHeight = data.layoutHeight;
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
- width: layoutWidth,
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 layoutHeight = data.layoutHeight;
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
- width: layoutWidth,
40
- }, children: [_jsx("div", { style: { ...theme.nodeBase.header, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }, title: label, children: label }), attrs.length > 0 && (_jsx("div", { style: { padding: `${theme.space[2]} ${theme.space[3]}` }, children: attrs.map((attr, i) => {
41
- const parts = attr.split(' ');
42
- const type = parts[0] ?? '';
43
- const name = parts[1] ?? '';
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
- }, children: [_jsx("span", { style: {
52
- color: theme.color.gray3, minWidth: ns.typeColumnWidth,
53
- fontFamily: theme.font.mono, fontSize: theme.font.size.sm,
54
- flexShrink: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
55
- }, 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));
56
- }) })), _jsx(EntityHandles, { fwIn: fwIn, fwOut: fwOut })] }));
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
  });