- Export: use useReactFlow().getNodesBounds() (measured dimensions) instead of the standalone helper, which treated every node's size as zero and clipped the last row/column of the exported image - Export: give edge paths an explicit inline stroke before capture - html-to-image clones <svg> natively and skips computed-style inlining for it, so edges came out with no stroke at all - Enable the edge flow animation (was hardcoded off) - Switch the graph from left-to-right to top-to-bottom, including flipping every node's handle positions, and recolor edges from the barely-visible border tone to the primary accent
103 lines
4.2 KiB
TypeScript
103 lines
4.2 KiB
TypeScript
import { getViewportForBounds, type Rect } from '@xyflow/react';
|
|
import { toPng, toSvg } from 'html-to-image';
|
|
|
|
const MAX_SIDE = 4096;
|
|
|
|
/**
|
|
* Saves the graph as an image.
|
|
*
|
|
* Custom nodes are HTML, so a native SVG serialisation is impossible — the SVG
|
|
* variant wraps the markup in `<foreignObject>`, which browsers render fine but
|
|
* vector editors only partially. PNG is the default for that reason.
|
|
*
|
|
* The capture always covers the whole graph rather than the current viewport,
|
|
* which is what people expect from an export button.
|
|
*
|
|
* `bounds` must come from the `useReactFlow().getNodesBounds()` hook, not the
|
|
* standalone `getNodesBounds` helper - the standalone version has no access to
|
|
* each node's measured width/height and silently treats them as zero, which
|
|
* undersizes the canvas and clips the last row/column of the graph.
|
|
*/
|
|
export async function exportGraph(
|
|
bounds: Rect,
|
|
hasNodes: boolean,
|
|
format: 'png' | 'svg',
|
|
filename: string,
|
|
): Promise<void> {
|
|
const viewport = document.querySelector<HTMLElement>('.react-flow__viewport');
|
|
if (!viewport || !hasNodes) return;
|
|
|
|
// Edges live inside a nested <svg> (`.react-flow__edges`). html-to-image
|
|
// clones any <svg> with the browser's native `cloneNode(true)` instead of
|
|
// its own computed-style-inlining walk (see cloneChildren in
|
|
// html-to-image/lib/clone-node.js, which returns early for svg elements),
|
|
// so nothing that comes from an external stylesheet - including React
|
|
// Flow's own `.react-flow__edge-path { stroke: var(--xy-edge-stroke) }` -
|
|
// survives into the clone. The path has no inline style of its own, so the
|
|
// exported edge ends up with no stroke at all. Give every edge element an
|
|
// explicit inline stroke (its current resolved color) right before
|
|
// capture, then revert once the capture is done.
|
|
const edgeLayer = viewport.querySelector<SVGElement>('.react-flow__edges');
|
|
const restoreEdgeStyles: Array<() => void> = [];
|
|
if (edgeLayer) {
|
|
const styledEls = edgeLayer.querySelectorAll<SVGElement>('path, circle, polygon, rect');
|
|
styledEls.forEach((el) => {
|
|
const computed = getComputedStyle(el);
|
|
const prevStroke = el.style.stroke;
|
|
const prevFill = el.style.fill;
|
|
const stroke = computed.stroke;
|
|
const fill = computed.fill;
|
|
if (stroke && stroke !== 'none') el.style.stroke = stroke;
|
|
if (fill && fill !== 'none') el.style.fill = fill;
|
|
restoreEdgeStyles.push(() => {
|
|
el.style.stroke = prevStroke;
|
|
el.style.fill = prevFill;
|
|
});
|
|
});
|
|
}
|
|
|
|
const padding = 40;
|
|
// Size the canvas to the graph itself, not an arbitrary minimum - forcing a
|
|
// larger canvas than the content needs a zoom above the 2x cap to fill it,
|
|
// so small graphs end up tiny in a mostly-empty image.
|
|
const width = Math.min(MAX_SIDE, Math.max(1, Math.round(bounds.width + padding * 2)));
|
|
const height = Math.min(MAX_SIDE, Math.max(1, Math.round(bounds.height + padding * 2)));
|
|
// getViewportForBounds's padding is a fraction of width/height unless given
|
|
// as a "px" string - a bare number here (e.g. 40) is read as 4000% padding,
|
|
// which collapses the zoom to the floor and leaves the graph tiny.
|
|
const transform = getViewportForBounds(bounds, width, height, 0.2, 2, `${padding}px`);
|
|
|
|
const background =
|
|
getComputedStyle(document.documentElement).getPropertyValue('--background').trim() || '#0a0a0a';
|
|
|
|
const options = {
|
|
backgroundColor: background,
|
|
width,
|
|
height,
|
|
style: {
|
|
width: `${width}px`,
|
|
height: `${height}px`,
|
|
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.zoom})`,
|
|
},
|
|
// Controls and the minimap are UI chrome, not part of the diagram.
|
|
filter: (element: HTMLElement) =>
|
|
!element?.classList?.contains?.('react-flow__minimap') &&
|
|
!element?.classList?.contains?.('react-flow__controls') &&
|
|
!element?.dataset?.exportIgnore,
|
|
cacheBust: true,
|
|
pixelRatio: format === 'png' ? 2 : 1,
|
|
};
|
|
|
|
let dataUrl: string;
|
|
try {
|
|
dataUrl = format === 'png' ? await toPng(viewport, options) : await toSvg(viewport, options);
|
|
} finally {
|
|
restoreEdgeStyles.forEach((restore) => restore());
|
|
}
|
|
|
|
const link = document.createElement('a');
|
|
link.download = `${filename}.${format}`;
|
|
link.href = dataUrl;
|
|
link.click();
|
|
}
|