@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.
@@ -54,11 +54,24 @@ interface GraphCanvasProps {
54
54
  */
55
55
  includeBackground?: boolean;
56
56
  /**
57
- * Whether to embed webfonts into the exported image.
58
- * Defaults to false because many apps load fonts from cross-origin
59
- * stylesheets (e.g. Google Fonts), which causes DOMExceptions during export.
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
- embedFonts?: boolean;
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 { 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) {
@@ -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
- if (!exportRef.current)
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
- setIsExporting(true);
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
- // Wait for React to commit export-only visuals (branding/background toggles)
297
- // before html-to-image snapshots the DOM.
298
- await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
299
- // Prefer blob + object URL over a data URL. In some browsers, downloads
300
- // triggered after an async boundary can be blocked when using data URLs.
301
- const blob = await toBlob(exportRef.current, {
302
- cacheBust: true,
303
- pixelRatio: Math.max(1, download?.pixelRatio ?? 2),
304
- backgroundColor: download?.backgroundColor ?? canvas.containerBackground,
305
- // Avoid CORS errors when the host app uses cross-origin stylesheets for fonts.
306
- // If the host wants font embedding, they can explicitly enable it.
307
- skipFonts: !(download?.embedFonts ?? false),
308
- filter: (node) => {
309
- if (!(node instanceof Element))
310
- return true;
311
- return !node.classList.contains('ga-no-export');
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
- ` }), showBrandingNow && (branding?.logo?.src || branding?.logo?.node) && (_jsx("div", { style: {
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' } })) })), showBrandingNow && branding?.watermark?.text && (_jsxs("div", { style: {
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
- }, 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: [showBackgroundNow && (_jsx(Background, { 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,
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
- * 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,