@graph-artifact/core 0.1.13 → 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.
|
@@ -53,6 +53,25 @@ interface GraphCanvasProps {
|
|
|
53
53
|
* Defaults to false (clean export).
|
|
54
54
|
*/
|
|
55
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;
|
|
56
75
|
/**
|
|
57
76
|
* Whether to embed webfonts into the exported image.
|
|
58
77
|
* Defaults to false because many apps load fonts from cross-origin
|
|
@@ -273,7 +273,6 @@ 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
276
|
const brandingVisibility = useMemo(() => {
|
|
278
277
|
const v = branding?.visibility ?? {};
|
|
279
278
|
return {
|
|
@@ -281,35 +280,99 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
281
280
|
download: v.download ?? true,
|
|
282
281
|
};
|
|
283
282
|
}, [branding?.visibility]);
|
|
284
|
-
const showBrandingNow = isExporting ? brandingVisibility.download : brandingVisibility.canvas;
|
|
285
|
-
const showBackgroundNow = isExporting ? Boolean(download?.includeBackground) : Boolean(canvas.showBackground);
|
|
286
283
|
const onDownload = useCallback(async () => {
|
|
287
|
-
|
|
284
|
+
const root = exportRef.current;
|
|
285
|
+
if (!root)
|
|
288
286
|
return;
|
|
289
287
|
const fileName = (download?.fileName ?? 'diagram')
|
|
290
288
|
.trim()
|
|
291
289
|
.replace(/[^\w\- ]+/g, '')
|
|
292
290
|
.replace(/\s+/g, '-')
|
|
293
291
|
.slice(0, 80) || 'diagram';
|
|
294
|
-
|
|
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);
|
|
295
370
|
try {
|
|
296
|
-
|
|
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, {
|
|
371
|
+
const blob = await toBlob(clone, {
|
|
302
372
|
cacheBust: true,
|
|
303
|
-
pixelRatio
|
|
304
|
-
backgroundColor
|
|
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.
|
|
373
|
+
pixelRatio,
|
|
374
|
+
backgroundColor,
|
|
307
375
|
skipFonts: !(download?.embedFonts ?? false),
|
|
308
|
-
filter: (node) => {
|
|
309
|
-
if (!(node instanceof Element))
|
|
310
|
-
return true;
|
|
311
|
-
return !node.classList.contains('ga-no-export');
|
|
312
|
-
},
|
|
313
376
|
});
|
|
314
377
|
if (!blob)
|
|
315
378
|
throw new Error('Export failed: no image data produced.');
|
|
@@ -330,17 +393,23 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
330
393
|
download?.onError?.(error);
|
|
331
394
|
}
|
|
332
395
|
finally {
|
|
333
|
-
|
|
396
|
+
clone.remove();
|
|
334
397
|
}
|
|
335
398
|
}, [
|
|
399
|
+
storeApi,
|
|
400
|
+
branding,
|
|
401
|
+
brandingVisibility.download,
|
|
336
402
|
canvas.containerBackground,
|
|
337
|
-
canvas.showBackground,
|
|
338
|
-
download?.backgroundColor,
|
|
339
403
|
download?.fileName,
|
|
340
404
|
download?.pixelRatio,
|
|
405
|
+
download?.backgroundColor,
|
|
341
406
|
download?.includeBackground,
|
|
342
|
-
download?.
|
|
407
|
+
download?.cropToGraph,
|
|
408
|
+
download?.cropPadding,
|
|
409
|
+
download?.reservedTop,
|
|
410
|
+
download?.reservedBottom,
|
|
343
411
|
download?.embedFonts,
|
|
412
|
+
download?.onError,
|
|
344
413
|
]);
|
|
345
414
|
if (layoutError) {
|
|
346
415
|
return (_jsx("div", { style: {
|
|
@@ -392,7 +461,7 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
392
461
|
}
|
|
393
462
|
/* Ensure inline SVG logos scale to the requested width. */
|
|
394
463
|
.ga-logo svg { width: 100%; height: auto; display: block; }
|
|
395
|
-
` }),
|
|
464
|
+
` }), (branding?.logo?.src || branding?.logo?.node) && (_jsx("div", { "data-ga-branding": true, "data-ga-logo": true, style: {
|
|
396
465
|
position: 'absolute',
|
|
397
466
|
zIndex: 5,
|
|
398
467
|
pointerEvents: 'none',
|
|
@@ -401,18 +470,18 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
401
470
|
...(branding.logo.position === 'bottom-right' ? { bottom: 12, right: 12 } : {}),
|
|
402
471
|
...(branding.logo.position === 'top-left' || !branding.logo.position ? { top: 12, left: 12 } : {}),
|
|
403
472
|
opacity: branding.logo.opacity ?? 1,
|
|
473
|
+
display: brandingVisibility.canvas ? 'block' : 'none',
|
|
404
474
|
}, children: branding.logo.node ? (_jsx("div", { className: "ga-logo", style: {
|
|
405
475
|
width: branding.logo.width ?? 120,
|
|
406
476
|
height: branding.logo.height,
|
|
407
477
|
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' } })) })),
|
|
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: {
|
|
409
479
|
position: 'absolute',
|
|
410
480
|
left: '50%',
|
|
411
481
|
bottom: 14,
|
|
412
482
|
transform: 'translateX(-50%)',
|
|
413
483
|
zIndex: 5,
|
|
414
484
|
pointerEvents: 'none',
|
|
415
|
-
display: 'flex',
|
|
416
485
|
alignItems: 'center',
|
|
417
486
|
gap: 8,
|
|
418
487
|
fontFamily: theme.font.family,
|
|
@@ -421,7 +490,8 @@ function GraphCanvasInner({ parsed, metadata, onNodeClick, canvasOverrides, hide
|
|
|
421
490
|
opacity: branding.watermark.opacity ?? 0.9,
|
|
422
491
|
userSelect: 'none',
|
|
423
492
|
whiteSpace: 'nowrap',
|
|
424
|
-
|
|
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,
|
|
425
495
|
// Remove the "lock" (interactive) button; we want pan/zoom always available.
|
|
426
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: {
|
|
427
497
|
display: 'inline-flex',
|
package/package.json
CHANGED