@graph-artifact/core 0.1.11 → 0.1.13

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.
@@ -11,8 +11,22 @@ interface GraphCanvasProps {
11
11
  hideAttribution?: boolean;
12
12
  /** Optional branding overlays (included in exports). */
13
13
  branding?: {
14
+ /**
15
+ * Where branding renders:
16
+ * - `canvas`: on-screen in the ReactFlow viewer
17
+ * - `download`: only during PNG export
18
+ *
19
+ * Defaults: `{ canvas: false, download: true }`
20
+ */
21
+ visibility?: {
22
+ canvas?: boolean;
23
+ download?: boolean;
24
+ };
14
25
  logo?: {
15
- src: string;
26
+ /** Image URL to render (e.g. PNG). Prefer `node` for inline SVG via SVGR. */
27
+ src?: string;
28
+ /** Inline logo node (e.g. an SVGR component). */
29
+ node?: ReactNode;
16
30
  alt?: string;
17
31
  position?: CornerPosition;
18
32
  width?: number;
@@ -34,6 +48,19 @@ interface GraphCanvasProps {
34
48
  pixelRatio?: number;
35
49
  /** Background color used for export. Defaults to `canvas.containerBackground`. */
36
50
  backgroundColor?: string;
51
+ /**
52
+ * Whether the exported PNG should include the dotted/grid background.
53
+ * Defaults to false (clean export).
54
+ */
55
+ includeBackground?: boolean;
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.
60
+ */
61
+ embedFonts?: boolean;
62
+ /** Optional error callback for export failures. */
63
+ onError?: (error: Error) => void;
37
64
  };
38
65
  /**
39
66
  * If true, reruns dagre after React Flow measures node DOM sizes so
@@ -2,7 +2,7 @@ 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 { toPng } from 'html-to-image';
5
+ import { toBlob } from 'html-to-image';
6
6
  import { layoutNodes, buildLayoutEdges } from '../layout/index.js';
7
7
  import { getConfig, getNodeTypes } from '../config.js';
8
8
  import { useTheme } from '../ThemeContext.js';
@@ -273,6 +273,16 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
273
273
  const miniMapNodeColor = useCallback(() => theme.color.orange1, [theme.color.orange1]);
274
274
  const miniMapMaskColor = useMemo(() => `${theme.color.gray1}cc`, [theme.color.gray1]);
275
275
  const exportRef = useRef(null);
276
+ const [isExporting, setIsExporting] = useState(false);
277
+ const brandingVisibility = useMemo(() => {
278
+ const v = branding?.visibility ?? {};
279
+ return {
280
+ canvas: v.canvas ?? false,
281
+ download: v.download ?? true,
282
+ };
283
+ }, [branding?.visibility]);
284
+ const showBrandingNow = isExporting ? brandingVisibility.download : brandingVisibility.canvas;
285
+ const showBackgroundNow = isExporting ? Boolean(download?.includeBackground) : Boolean(canvas.showBackground);
276
286
  const onDownload = useCallback(async () => {
277
287
  if (!exportRef.current)
278
288
  return;
@@ -281,26 +291,57 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
281
291
  .replace(/[^\w\- ]+/g, '')
282
292
  .replace(/\s+/g, '-')
283
293
  .slice(0, 80) || 'diagram';
294
+ setIsExporting(true);
284
295
  try {
285
- const dataUrl = await toPng(exportRef.current, {
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, {
286
302
  cacheBust: true,
287
303
  pixelRatio: Math.max(1, download?.pixelRatio ?? 2),
288
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),
289
308
  filter: (node) => {
290
309
  if (!(node instanceof Element))
291
310
  return true;
292
311
  return !node.classList.contains('ga-no-export');
293
312
  },
294
313
  });
314
+ if (!blob)
315
+ throw new Error('Export failed: no image data produced.');
316
+ const url = URL.createObjectURL(blob);
295
317
  const a = document.createElement('a');
296
- a.href = dataUrl;
318
+ a.href = url;
297
319
  a.download = `${fileName}.png`;
320
+ a.rel = 'noopener';
321
+ document.body.appendChild(a);
298
322
  a.click();
323
+ a.remove();
324
+ URL.revokeObjectURL(url);
299
325
  }
300
- catch {
301
- // ignore export errors (e.g. CORS images, unmounted)
326
+ catch (err) {
327
+ const error = err instanceof Error ? err : new Error(String(err));
328
+ // eslint-disable-next-line no-console
329
+ console.error('[graph-artifact] Download export failed:', error);
330
+ download?.onError?.(error);
331
+ }
332
+ finally {
333
+ setIsExporting(false);
302
334
  }
303
- }, [canvas.containerBackground, download?.backgroundColor, download?.fileName, download?.pixelRatio]);
335
+ }, [
336
+ canvas.containerBackground,
337
+ canvas.showBackground,
338
+ download?.backgroundColor,
339
+ download?.fileName,
340
+ download?.pixelRatio,
341
+ download?.includeBackground,
342
+ download?.onError,
343
+ download?.embedFonts,
344
+ ]);
304
345
  if (layoutError) {
305
346
  return (_jsx("div", { style: {
306
347
  width: '100%',
@@ -349,7 +390,9 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
349
390
  background: ${theme.mode === 'dark' ? theme.color.darkBg2 : theme.color.white};
350
391
  border: 1px solid ${theme.color.gray2};
351
392
  }
352
- ` }), branding?.logo?.src && (_jsx("div", { style: {
393
+ /* Ensure inline SVG logos scale to the requested width. */
394
+ .ga-logo svg { width: 100%; height: auto; display: block; }
395
+ ` }), showBrandingNow && (branding?.logo?.src || branding?.logo?.node) && (_jsx("div", { style: {
353
396
  position: 'absolute',
354
397
  zIndex: 5,
355
398
  pointerEvents: 'none',
@@ -358,7 +401,11 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
358
401
  ...(branding.logo.position === 'bottom-right' ? { bottom: 12, right: 12 } : {}),
359
402
  ...(branding.logo.position === 'top-left' || !branding.logo.position ? { top: 12, left: 12 } : {}),
360
403
  opacity: branding.logo.opacity ?? 1,
361
- }, children: _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", { style: {
404
+ }, children: branding.logo.node ? (_jsx("div", { className: "ga-logo", style: {
405
+ width: branding.logo.width ?? 120,
406
+ height: branding.logo.height,
407
+ 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: {
362
409
  position: 'absolute',
363
410
  left: '50%',
364
411
  bottom: 14,
@@ -374,9 +421,9 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
374
421
  opacity: branding.watermark.opacity ?? 0.9,
375
422
  userSelect: 'none',
376
423
  whiteSpace: 'nowrap',
377
- }, 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: [canvas.showBackground && (_jsx(Background, { color: canvas.gridColor === 'auto' ? theme.color.gray2 : canvas.gridColor, gap: canvas.backgroundGap, size: canvas.backgroundSize })), canvas.showControls && (_jsx(Controls, { position: canvas.controlsPosition,
378
- // Remove the "lock" (interactive) button; we want pan/zoom always available.
379
- 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: {
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,
425
+ // Remove the "lock" (interactive) button; we want pan/zoom always available.
426
+ 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: {
380
427
  display: 'inline-flex',
381
428
  alignItems: 'center',
382
429
  justifyContent: 'center',
@@ -388,7 +435,7 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
388
435
  border: `1px solid ${theme.color.gray2}`,
389
436
  boxShadow: theme.shadow.card,
390
437
  cursor: 'pointer',
391
- }, children: download?.icon ?? defaultDownloadIcon(theme) }) })), canvas.showMiniMap && (_jsx(MiniMap, { nodeColor: miniMapNodeColor, maskColor: miniMapMaskColor }))] })] }));
438
+ }, children: download?.icon ?? defaultDownloadIcon(theme) }) })), canvas.showMiniMap && (_jsx("div", { className: "ga-no-export", children: _jsx(MiniMap, { nodeColor: miniMapNodeColor, maskColor: miniMapMaskColor }) }))] })] }));
392
439
  }
393
440
  export function GraphCanvas(props) {
394
441
  return (_jsx(ReactFlowProvider, { children: _jsx(GraphCanvasInner, { ...props }) }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graph-artifact/core",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Composable Mermaid-like parser, layout engine, and React renderer for interactive diagram artifacts.",
5
5
  "license": "MIT",
6
6
  "repository": {