feat: add crispy PNG/SVG graph export with tight cropping 🔥

Implemented image export functionality using html-to-image that actually
slaps. No more bloated screenshots with miles of blank space - this baby
wraps your graph tighter than shrink wrap on a fresh deck.

Features:
- PNG export with proper 300 DPI quality (4x pixelRatio)
- SVG vector export for infinite scaling
- Smart bounds calculation that hugs your nodes
- Configurable padding (default: 10px of breathing room)
- Accessible via File menu

Technical highlights:
- Direct transform calculation instead of getViewportForBounds bloat
- Proper pixelRatio handling (not that 16x scaling nonsense)
- Based on React Flow's official pattern but actually optimized
- Uses html-to-image@1.11.11 (newer versions are broken)

Export quality goes hard. Print-ready PNGs. Crisp. Clean. Chef's kiss.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-10-10 23:01:33 +02:00
parent e778b29b56
commit d7d91798f1
7 changed files with 317 additions and 3 deletions

6
package-lock.json generated
View file

@ -12,6 +12,7 @@
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.10", "@mui/icons-material": "^5.15.10",
"@mui/material": "^5.15.10", "@mui/material": "^5.15.10",
"html-to-image": "^1.11.11",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@ -3432,6 +3433,11 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
}, },
"node_modules/html-to-image": {
"version": "1.11.11",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.11.tgz",
"integrity": "sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA=="
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",

View file

@ -14,6 +14,7 @@
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.10", "@mui/icons-material": "^5.15.10",
"@mui/material": "^5.15.10", "@mui/material": "^5.15.10",
"html-to-image": "^1.11.11",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",

View file

@ -14,6 +14,7 @@ import { useDocumentHistory } from "./hooks/useDocumentHistory";
import { useWorkspaceStore } from "./stores/workspaceStore"; import { useWorkspaceStore } from "./stores/workspaceStore";
import { usePanelStore } from "./stores/panelStore"; import { usePanelStore } from "./stores/panelStore";
import type { Actor, Relation } from "./types"; import type { Actor, Relation } from "./types";
import type { ExportOptions } from "./utils/graphExport";
/** /**
* App - Root application component * App - Root application component
@ -44,6 +45,7 @@ function AppContent() {
const [selectedNode, setSelectedNode] = useState<Actor | null>(null); const [selectedNode, setSelectedNode] = useState<Actor | null>(null);
const [selectedEdge, setSelectedEdge] = useState<Relation | null>(null); const [selectedEdge, setSelectedEdge] = useState<Relation | null>(null);
const [addNodeCallback, setAddNodeCallback] = useState<((nodeTypeId: string, position?: { x: number; y: number }) => void) | null>(null); const [addNodeCallback, setAddNodeCallback] = useState<((nodeTypeId: string, position?: { x: number; y: number }) => void) | null>(null);
const [exportCallback, setExportCallback] = useState<((format: 'png' | 'svg', options?: ExportOptions) => Promise<void>) | null>(null);
const { fitView } = useReactFlow(); const { fitView } = useReactFlow();
@ -133,6 +135,7 @@ function AppContent() {
onOpenHelp={() => setShowKeyboardHelp(true)} onOpenHelp={() => setShowKeyboardHelp(true)}
onFitView={handleFitView} onFitView={handleFitView}
onSelectAll={handleSelectAll} onSelectAll={handleSelectAll}
onExport={exportCallback || undefined}
/> />
{/* Document Tabs */} {/* Document Tabs */}
@ -174,6 +177,7 @@ function AppContent() {
} }
}} }}
onAddNodeRequest={(callback: (nodeTypeId: string, position?: { x: number; y: number }) => void) => setAddNodeCallback(() => callback)} onAddNodeRequest={(callback: (nodeTypeId: string, position?: { x: number; y: number }) => void) => setAddNodeCallback(() => callback)}
onExportRequest={(callback: (format: 'png' | 'svg', options?: ExportOptions) => Promise<void>) => setExportCallback(() => callback)}
/> />
</div> </div>

View file

@ -33,6 +33,8 @@ import EmptyState from "../Common/EmptyState";
import { createNode } from "../../utils/nodeUtils"; import { createNode } from "../../utils/nodeUtils";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import { useConfirm } from "../../hooks/useConfirm"; import { useConfirm } from "../../hooks/useConfirm";
import { useGraphExport } from "../../hooks/useGraphExport";
import type { ExportOptions } from "../../utils/graphExport";
import type { Actor, Relation } from "../../types"; import type { Actor, Relation } from "../../types";
@ -42,6 +44,7 @@ interface GraphEditorProps {
onNodeSelect: (node: Actor | null) => void; onNodeSelect: (node: Actor | null) => void;
onEdgeSelect: (edge: Relation | null) => void; onEdgeSelect: (edge: Relation | null) => void;
onAddNodeRequest?: (callback: (nodeTypeId: string, position?: { x: number; y: number }) => void) => void; onAddNodeRequest?: (callback: (nodeTypeId: string, position?: { x: number; y: number }) => void) => void;
onExportRequest?: (callback: (format: 'png' | 'svg', options?: ExportOptions) => Promise<void>) => void;
} }
/** /**
@ -57,11 +60,14 @@ interface GraphEditorProps {
* *
* Usage: Core component that wraps React Flow with custom nodes and edges * Usage: Core component that wraps React Flow with custom nodes and edges
*/ */
const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest }: GraphEditorProps) => { const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportRequest }: GraphEditorProps) => {
// Sync with workspace active document // Sync with workspace active document
const { activeDocumentId } = useActiveDocument(); const { activeDocumentId } = useActiveDocument();
const { saveViewport, getViewport, createDocument } = useWorkspaceStore(); const { saveViewport, getViewport, createDocument } = useWorkspaceStore();
// Graph export functionality
const { exportPNG, exportSVG } = useGraphExport();
const { const {
nodes: storeNodes, nodes: storeNodes,
edges: storeEdges, edges: storeEdges,
@ -507,6 +513,24 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest }: GraphEdit
} }
}, [onAddNodeRequest, handleAddNode]); }, [onAddNodeRequest, handleAddNode]);
// Provide export callback to parent
const handleExport = useCallback(
async (format: 'png' | 'svg', options?: ExportOptions) => {
if (format === 'png') {
await exportPNG(options);
} else {
await exportSVG(options);
}
},
[exportPNG, exportSVG]
);
useEffect(() => {
if (onExportRequest) {
onExportRequest(handleExport);
}
}, [onExportRequest, handleExport]);
// Add new actor at context menu position // Add new actor at context menu position
const handleAddActorFromContextMenu = useCallback( const handleAddActorFromContextMenu = useCallback(
(nodeTypeId: string) => { (nodeTypeId: string) => {

View file

@ -7,6 +7,7 @@ import NodeTypeConfigModal from '../Config/NodeTypeConfig';
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig'; import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
import { useConfirm } from '../../hooks/useConfirm'; import { useConfirm } from '../../hooks/useConfirm';
import { useShortcutLabels } from '../../hooks/useShortcutLabels'; import { useShortcutLabels } from '../../hooks/useShortcutLabels';
import type { ExportOptions } from '../../utils/graphExport';
/** /**
* MenuBar Component * MenuBar Component
@ -22,9 +23,10 @@ interface MenuBarProps {
onOpenHelp?: () => void; onOpenHelp?: () => void;
onFitView?: () => void; onFitView?: () => void;
onSelectAll?: () => void; onSelectAll?: () => void;
onExport?: (format: 'png' | 'svg', options?: ExportOptions) => Promise<void>;
} }
const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onSelectAll }) => { const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onSelectAll, onExport }) => {
const [activeMenu, setActiveMenu] = useState<string | null>(null); const [activeMenu, setActiveMenu] = useState<string | null>(null);
const [showDocumentManager, setShowDocumentManager] = useState(false); const [showDocumentManager, setShowDocumentManager] = useState(false);
const [showNodeConfig, setShowNodeConfig] = useState(false); const [showNodeConfig, setShowNodeConfig] = useState(false);
@ -145,6 +147,28 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onSelectAll })
closeMenu(); closeMenu();
}, [clearGraph, closeMenu, confirm]); }, [clearGraph, closeMenu, confirm]);
const handleExportPNG = useCallback(async () => {
if (!onExport) return;
try {
await onExport('png');
closeMenu();
} catch (error) {
console.error('PNG export failed:', error);
alert('Failed to export graph as PNG');
}
}, [onExport, closeMenu]);
const handleExportSVG = useCallback(async () => {
if (!onExport) return;
try {
await onExport('svg');
closeMenu();
} catch (error) {
console.error('SVG export failed:', error);
alert('Failed to export graph as SVG');
}
}, [onExport, closeMenu]);
return ( return (
<> <>
<div ref={menuRef} className="bg-white border-b border-gray-200 shadow-sm"> <div ref={menuRef} className="bg-white border-b border-gray-200 shadow-sm">
@ -203,11 +227,25 @@ const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onSelectAll })
onClick={handleExport} onClick={handleExport}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center justify-between" className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center justify-between"
> >
<span>Export Document</span> <span>Export Document (JSON)</span>
{getShortcutLabel('save-document') && ( {getShortcutLabel('save-document') && (
<span className="text-xs text-gray-400">{getShortcutLabel('save-document')}</span> <span className="text-xs text-gray-400">{getShortcutLabel('save-document')}</span>
)} )}
</button> </button>
<button
onClick={handleExportPNG}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
disabled={!onExport || !activeDocumentId}
>
Export as PNG Image
</button>
<button
onClick={handleExportSVG}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
disabled={!onExport || !activeDocumentId}
>
Export as SVG Vector
</button>
<button <button
onClick={handleExportAll} onClick={handleExportAll}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"

View file

@ -0,0 +1,73 @@
import { useCallback } from 'react';
import { useReactFlow } from 'reactflow';
import { exportGraphAsPNG, exportGraphAsSVG } from '../utils/graphExport';
import type { ExportOptions } from '../utils/graphExport';
/**
* useGraphExport Hook
*
* Provides convenient methods for exporting the current React Flow graph
* as PNG or SVG images.
*
* Usage:
* ```tsx
* const { exportPNG, exportSVG } = useGraphExport();
*
* // Export as PNG
* await exportPNG({ fileName: 'my-graph', quality: 3 });
*
* // Export as SVG
* await exportSVG({ fileName: 'my-graph', backgroundColor: '#f0f0f0' });
* ```
*/
export function useGraphExport() {
const { getNodes } = useReactFlow();
/**
* Export the current graph as a PNG image
*
* @param options - Export options (fileName, quality, backgroundColor, padding)
* @throws Error if viewport element is not found or export fails
*/
const exportPNG = useCallback(
async (options?: ExportOptions) => {
const viewportElement = document.querySelector(
'.react-flow__viewport'
) as HTMLElement;
if (!viewportElement) {
throw new Error('React Flow viewport element not found');
}
const nodes = getNodes();
await exportGraphAsPNG(viewportElement, nodes, options);
},
[getNodes]
);
/**
* Export the current graph as an SVG image
*
* @param options - Export options (fileName, backgroundColor, padding)
* @throws Error if viewport element is not found or export fails
*/
const exportSVG = useCallback(
async (options?: ExportOptions) => {
const viewportElement = document.querySelector(
'.react-flow__viewport'
) as HTMLElement;
if (!viewportElement) {
throw new Error('React Flow viewport element not found');
}
const nodes = getNodes();
await exportGraphAsSVG(viewportElement, nodes, options);
},
[getNodes]
);
return { exportPNG, exportSVG };
}

168
src/utils/graphExport.ts Normal file
View file

@ -0,0 +1,168 @@
import { toPng, toSvg } from 'html-to-image';
import { getNodesBounds } from 'reactflow';
import type { Node } from 'reactflow';
/**
* Graph Export Utilities
*
* Based on React Flow's official download-image example:
* https://reactflow.dev/examples/misc/download-image
*
* Uses html-to-image@1.11.11 (newer versions have export issues)
*/
export interface ExportOptions {
/** Background color for the exported image */
backgroundColor?: string;
/** Padding around the graph content (in pixels) */
padding?: number;
/** Image quality/scale multiplier (1-4) */
quality?: number;
/** File name (without extension) */
fileName?: string;
}
const DEFAULT_OPTIONS: Required<Omit<ExportOptions, 'fileName'>> = {
backgroundColor: '#ffffff',
padding: 10,
quality: 4, // ~300 DPI for standard screen-to-print conversion
};
/**
* Downloads a file from a data URL
*/
function downloadImage(dataUrl: string, fileName: string, extension: string) {
const a = document.createElement('a');
a.setAttribute('download', `${fileName}.${extension}`);
a.setAttribute('href', dataUrl);
a.click();
}
/**
* Calculate the viewport bounds for capturing the entire graph
*
* This creates a tight crop around all nodes with minimal padding.
*
* @param nodes - Array of nodes in the graph
* @param padding - Padding around the content
* @returns Dimensions and transform for the export
*/
function calculateImageBounds(nodes: Node[], padding: number) {
// Get the bounding box that contains all nodes
const nodesBounds = getNodesBounds(nodes);
console.log('Node bounds:', nodesBounds);
console.log('Number of nodes:', nodes.length);
console.log('Nodes:', nodes.map(n => ({ id: n.id, position: n.position, width: n.width, height: n.height })));
// Calculate image dimensions with padding
const imageWidth = nodesBounds.width + padding * 2;
const imageHeight = nodesBounds.height + padding * 2;
// Calculate transform to position the content
// We want to translate the viewport so that the top-left of nodesBounds
// is at position (padding, padding) in the export
const transform = {
x: -nodesBounds.x + padding,
y: -nodesBounds.y + padding,
zoom: 1,
};
console.log('Calculated dimensions:', { width: imageWidth, height: imageHeight });
console.log('Transform:', transform);
return {
width: imageWidth,
height: imageHeight,
transform,
};
}
/**
* Export React Flow graph as PNG
*
* @param viewportElement - The .react-flow__viewport DOM element
* @param nodes - Array of nodes in the graph
* @param options - Export options
*/
export async function exportGraphAsPNG(
viewportElement: HTMLElement,
nodes: Node[],
options: ExportOptions = {}
): Promise<void> {
const {
backgroundColor,
padding,
quality,
fileName = 'constellation-graph',
} = { ...DEFAULT_OPTIONS, ...options };
if (nodes.length === 0) {
throw new Error('Cannot export empty graph');
}
const { width, height, transform } = calculateImageBounds(nodes, padding);
try {
const dataUrl = await toPng(viewportElement, {
backgroundColor,
width: width,
height: height,
pixelRatio: quality,
cacheBust: true,
style: {
width: `${width}px`,
height: `${height}px`,
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.zoom})`,
},
});
downloadImage(dataUrl, fileName, 'png');
} catch (error) {
console.error('PNG export failed:', error);
throw new Error('Failed to export graph as PNG');
}
}
/**
* Export React Flow graph as SVG
*
* @param viewportElement - The .react-flow__viewport DOM element
* @param nodes - Array of nodes in the graph
* @param options - Export options
*/
export async function exportGraphAsSVG(
viewportElement: HTMLElement,
nodes: Node[],
options: ExportOptions = {}
): Promise<void> {
const {
backgroundColor,
padding,
fileName = 'constellation-graph',
} = { ...DEFAULT_OPTIONS, ...options };
if (nodes.length === 0) {
throw new Error('Cannot export empty graph');
}
const { width, height, transform } = calculateImageBounds(nodes, padding);
try {
const dataUrl = await toSvg(viewportElement, {
backgroundColor,
width,
height,
style: {
width: `${width}px`,
height: `${height}px`,
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.zoom})`,
},
});
downloadImage(dataUrl, fileName, 'svg');
} catch (error) {
console.error('SVG export failed:', error);
throw new Error('Failed to export graph as SVG');
}
}