@graph-artifact/core 0.1.12 → 0.1.14
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,6 +11,17 @@ 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
26
|
/** Image URL to render (e.g. PNG). Prefer `node` for inline SVG via SVGR. */
|
|
16
27
|
src?: string;
|
|
@@ -37,6 +48,38 @@ interface GraphCanvasProps {
|
|
|
37
48
|
pixelRatio?: number;
|
|
38
49
|
/** Background color used for export. Defaults to `canvas.containerBackground`. */
|
|
39
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
|
+
* 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.
|
|
73
|
+
*/
|
|
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
|
+
/** Optional error callback for export failures. */
|
|
82
|
+
onError?: (error: Error) => void;
|
|
40
83
|
};
|
|
41
84
|
/**
|
|
42
85
|
* 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 {
|
|
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,34 +273,144 @@ 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 brandingVisibility = useMemo(() => {
|
|
277
|
+
const v = branding?.visibility ?? {};
|
|
278
|
+
return {
|
|
279
|
+
canvas: v.canvas ?? false,
|
|
280
|
+
download: v.download ?? true,
|
|
281
|
+
};
|
|
282
|
+
}, [branding?.visibility]);
|
|
276
283
|
const onDownload = useCallback(async () => {
|
|
277
|
-
|
|
284
|
+
const root = exportRef.current;
|
|
285
|
+
if (!root)
|
|
278
286
|
return;
|
|
279
287
|
const fileName = (download?.fileName ?? 'diagram')
|
|
280
288
|
.trim()
|
|
281
289
|
.replace(/[^\w\- ]+/g, '')
|
|
282
290
|
.replace(/\s+/g, '-')
|
|
283
291
|
.slice(0, 80) || 'diagram';
|
|
292
|
+
const state = storeApi.getState();
|
|
293
|
+
const nodeLookup = state.nodeLookup;
|
|
294
|
+
const transform = state.transform; // [x, y, zoom]
|
|
295
|
+
const zoom = transform[2] ?? 1;
|
|
296
|
+
// Compute content bounds in graph coordinates from measured node boxes.
|
|
297
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
298
|
+
for (const n of nodeLookup.values()) {
|
|
299
|
+
if (n.hidden)
|
|
300
|
+
continue;
|
|
301
|
+
const abs = n.internals.positionAbsolute;
|
|
302
|
+
const w = n.measured?.width ?? n.width ?? 0;
|
|
303
|
+
const h = n.measured?.height ?? n.height ?? 0;
|
|
304
|
+
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0)
|
|
305
|
+
continue;
|
|
306
|
+
minX = Math.min(minX, abs.x);
|
|
307
|
+
minY = Math.min(minY, abs.y);
|
|
308
|
+
maxX = Math.max(maxX, abs.x + w);
|
|
309
|
+
maxY = Math.max(maxY, abs.y + h);
|
|
310
|
+
}
|
|
311
|
+
// If we can't compute bounds, fall back to exporting the full viewport.
|
|
312
|
+
const canCrop = isFinite(minX) && isFinite(minY) && isFinite(maxX) && isFinite(maxY);
|
|
313
|
+
const cropToGraph = download?.cropToGraph ?? true;
|
|
314
|
+
const contentPad = Math.max(0, download?.cropPadding ?? 32);
|
|
315
|
+
// Reserve header/footer areas for export-only branding so they never overlap content.
|
|
316
|
+
const exportBranding = Boolean(brandingVisibility.download && (branding?.logo?.src || branding?.logo?.node || branding?.watermark?.text));
|
|
317
|
+
const reservedTop = Math.max(0, download?.reservedTop ?? (exportBranding && (branding?.logo?.src || branding?.logo?.node) ? 76 : 0));
|
|
318
|
+
const reservedBottom = Math.max(0, download?.reservedBottom ?? (exportBranding && branding?.watermark?.text ? 56 : 0));
|
|
319
|
+
const pixelRatio = Math.max(1, download?.pixelRatio ?? 4);
|
|
320
|
+
const backgroundColor = download?.backgroundColor ?? canvas.containerBackground;
|
|
321
|
+
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
|
+
});
|
|
340
|
+
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
|
+
}
|
|
358
|
+
}
|
|
359
|
+
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
|
+
}
|
|
368
|
+
}
|
|
369
|
+
document.body.appendChild(clone);
|
|
284
370
|
try {
|
|
285
|
-
const
|
|
371
|
+
const blob = await toBlob(clone, {
|
|
286
372
|
cacheBust: true,
|
|
287
|
-
pixelRatio
|
|
288
|
-
backgroundColor
|
|
289
|
-
|
|
290
|
-
if (!(node instanceof Element))
|
|
291
|
-
return true;
|
|
292
|
-
return !node.classList.contains('ga-no-export');
|
|
293
|
-
},
|
|
373
|
+
pixelRatio,
|
|
374
|
+
backgroundColor,
|
|
375
|
+
skipFonts: !(download?.embedFonts ?? false),
|
|
294
376
|
});
|
|
377
|
+
if (!blob)
|
|
378
|
+
throw new Error('Export failed: no image data produced.');
|
|
379
|
+
const url = URL.createObjectURL(blob);
|
|
295
380
|
const a = document.createElement('a');
|
|
296
|
-
a.href =
|
|
381
|
+
a.href = url;
|
|
297
382
|
a.download = `${fileName}.png`;
|
|
383
|
+
a.rel = 'noopener';
|
|
384
|
+
document.body.appendChild(a);
|
|
298
385
|
a.click();
|
|
386
|
+
a.remove();
|
|
387
|
+
URL.revokeObjectURL(url);
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
391
|
+
// eslint-disable-next-line no-console
|
|
392
|
+
console.error('[graph-artifact] Download export failed:', error);
|
|
393
|
+
download?.onError?.(error);
|
|
299
394
|
}
|
|
300
|
-
|
|
301
|
-
|
|
395
|
+
finally {
|
|
396
|
+
clone.remove();
|
|
302
397
|
}
|
|
303
|
-
}, [
|
|
398
|
+
}, [
|
|
399
|
+
storeApi,
|
|
400
|
+
branding,
|
|
401
|
+
brandingVisibility.download,
|
|
402
|
+
canvas.containerBackground,
|
|
403
|
+
download?.fileName,
|
|
404
|
+
download?.pixelRatio,
|
|
405
|
+
download?.backgroundColor,
|
|
406
|
+
download?.includeBackground,
|
|
407
|
+
download?.cropToGraph,
|
|
408
|
+
download?.cropPadding,
|
|
409
|
+
download?.reservedTop,
|
|
410
|
+
download?.reservedBottom,
|
|
411
|
+
download?.embedFonts,
|
|
412
|
+
download?.onError,
|
|
413
|
+
]);
|
|
304
414
|
if (layoutError) {
|
|
305
415
|
return (_jsx("div", { style: {
|
|
306
416
|
width: '100%',
|
|
@@ -351,7 +461,7 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
351
461
|
}
|
|
352
462
|
/* Ensure inline SVG logos scale to the requested width. */
|
|
353
463
|
.ga-logo svg { width: 100%; height: auto; display: block; }
|
|
354
|
-
` }), (branding?.logo?.src || branding?.logo?.node) && (_jsx("div", { style: {
|
|
464
|
+
` }), (branding?.logo?.src || branding?.logo?.node) && (_jsx("div", { "data-ga-branding": true, "data-ga-logo": true, style: {
|
|
355
465
|
position: 'absolute',
|
|
356
466
|
zIndex: 5,
|
|
357
467
|
pointerEvents: 'none',
|
|
@@ -360,18 +470,18 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
360
470
|
...(branding.logo.position === 'bottom-right' ? { bottom: 12, right: 12 } : {}),
|
|
361
471
|
...(branding.logo.position === 'top-left' || !branding.logo.position ? { top: 12, left: 12 } : {}),
|
|
362
472
|
opacity: branding.logo.opacity ?? 1,
|
|
473
|
+
display: brandingVisibility.canvas ? 'block' : 'none',
|
|
363
474
|
}, children: branding.logo.node ? (_jsx("div", { className: "ga-logo", style: {
|
|
364
475
|
width: branding.logo.width ?? 120,
|
|
365
476
|
height: branding.logo.height,
|
|
366
477
|
display: 'block',
|
|
367
|
-
}, 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", { style: {
|
|
478
|
+
}, 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: {
|
|
368
479
|
position: 'absolute',
|
|
369
480
|
left: '50%',
|
|
370
481
|
bottom: 14,
|
|
371
482
|
transform: 'translateX(-50%)',
|
|
372
483
|
zIndex: 5,
|
|
373
484
|
pointerEvents: 'none',
|
|
374
|
-
display: 'flex',
|
|
375
485
|
alignItems: 'center',
|
|
376
486
|
gap: 8,
|
|
377
487
|
fontFamily: theme.font.family,
|
|
@@ -380,9 +490,10 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
380
490
|
opacity: branding.watermark.opacity ?? 0.9,
|
|
381
491
|
userSelect: 'none',
|
|
382
492
|
whiteSpace: 'nowrap',
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
493
|
+
display: brandingVisibility.canvas ? 'flex' : 'none',
|
|
494
|
+
}, 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,
|
|
495
|
+
// Remove the "lock" (interactive) button; we want pan/zoom always available.
|
|
496
|
+
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: {
|
|
386
497
|
display: 'inline-flex',
|
|
387
498
|
alignItems: 'center',
|
|
388
499
|
justifyContent: 'center',
|
|
@@ -394,7 +505,7 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
394
505
|
border: `1px solid ${theme.color.gray2}`,
|
|
395
506
|
boxShadow: theme.shadow.card,
|
|
396
507
|
cursor: 'pointer',
|
|
397
|
-
}, children: download?.icon ?? defaultDownloadIcon(theme) }) })), canvas.showMiniMap && (_jsx(MiniMap, { nodeColor: miniMapNodeColor, maskColor: miniMapMaskColor }))] })] }));
|
|
508
|
+
}, children: download?.icon ?? defaultDownloadIcon(theme) }) })), canvas.showMiniMap && (_jsx("div", { className: "ga-no-export", children: _jsx(MiniMap, { nodeColor: miniMapNodeColor, maskColor: miniMapMaskColor }) }))] })] }));
|
|
398
509
|
}
|
|
399
510
|
export function GraphCanvas(props) {
|
|
400
511
|
return (_jsx(ReactFlowProvider, { children: _jsx(GraphCanvasInner, { ...props }) }));
|
package/package.json
CHANGED