mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 15:53:42 +00:00
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:
parent
e778b29b56
commit
d7d91798f1
7 changed files with 317 additions and 3 deletions
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
73
src/hooks/useGraphExport.ts
Normal file
73
src/hooks/useGraphExport.ts
Normal 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
168
src/utils/graphExport.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue