From d7d91798f1ec89d486d31a650b7ff56e6b9b021e Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Fri, 10 Oct 2025 23:01:33 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20add=20crispy=20PNG/SVG=20graph=20export?= =?UTF-8?q?=20with=20tight=20cropping=20=F0=9F=94=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package-lock.json | 6 + package.json | 1 + src/App.tsx | 4 + src/components/Editor/GraphEditor.tsx | 26 +++- src/components/Menu/MenuBar.tsx | 42 ++++++- src/hooks/useGraphExport.ts | 73 +++++++++++ src/utils/graphExport.ts | 168 ++++++++++++++++++++++++++ 7 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useGraphExport.ts create mode 100644 src/utils/graphExport.ts diff --git a/package-lock.json b/package-lock.json index c68f093..b9115c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.15.10", "@mui/material": "^5.15.10", + "html-to-image": "^1.11.11", "jszip": "^3.10.1", "react": "^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", "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": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index 0da9556..50d9187 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.15.10", "@mui/material": "^5.15.10", + "html-to-image": "^1.11.11", "jszip": "^3.10.1", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/App.tsx b/src/App.tsx index 913c777..4c1349b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { useDocumentHistory } from "./hooks/useDocumentHistory"; import { useWorkspaceStore } from "./stores/workspaceStore"; import { usePanelStore } from "./stores/panelStore"; import type { Actor, Relation } from "./types"; +import type { ExportOptions } from "./utils/graphExport"; /** * App - Root application component @@ -44,6 +45,7 @@ function AppContent() { const [selectedNode, setSelectedNode] = useState(null); const [selectedEdge, setSelectedEdge] = useState(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) | null>(null); const { fitView } = useReactFlow(); @@ -133,6 +135,7 @@ function AppContent() { onOpenHelp={() => setShowKeyboardHelp(true)} onFitView={handleFitView} onSelectAll={handleSelectAll} + onExport={exportCallback || undefined} /> {/* Document Tabs */} @@ -174,6 +177,7 @@ function AppContent() { } }} onAddNodeRequest={(callback: (nodeTypeId: string, position?: { x: number; y: number }) => void) => setAddNodeCallback(() => callback)} + onExportRequest={(callback: (format: 'png' | 'svg', options?: ExportOptions) => Promise) => setExportCallback(() => callback)} /> diff --git a/src/components/Editor/GraphEditor.tsx b/src/components/Editor/GraphEditor.tsx index 943b4ea..28e03dc 100644 --- a/src/components/Editor/GraphEditor.tsx +++ b/src/components/Editor/GraphEditor.tsx @@ -33,6 +33,8 @@ import EmptyState from "../Common/EmptyState"; import { createNode } from "../../utils/nodeUtils"; import DeleteIcon from "@mui/icons-material/Delete"; import { useConfirm } from "../../hooks/useConfirm"; +import { useGraphExport } from "../../hooks/useGraphExport"; +import type { ExportOptions } from "../../utils/graphExport"; import type { Actor, Relation } from "../../types"; @@ -42,6 +44,7 @@ interface GraphEditorProps { onNodeSelect: (node: Actor | null) => void; onEdgeSelect: (edge: Relation | null) => void; onAddNodeRequest?: (callback: (nodeTypeId: string, position?: { x: number; y: number }) => void) => void; + onExportRequest?: (callback: (format: 'png' | 'svg', options?: ExportOptions) => Promise) => void; } /** @@ -57,11 +60,14 @@ interface GraphEditorProps { * * 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 const { activeDocumentId } = useActiveDocument(); const { saveViewport, getViewport, createDocument } = useWorkspaceStore(); + // Graph export functionality + const { exportPNG, exportSVG } = useGraphExport(); + const { nodes: storeNodes, edges: storeEdges, @@ -507,6 +513,24 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest }: GraphEdit } }, [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 const handleAddActorFromContextMenu = useCallback( (nodeTypeId: string) => { diff --git a/src/components/Menu/MenuBar.tsx b/src/components/Menu/MenuBar.tsx index 3a90109..6525dfc 100644 --- a/src/components/Menu/MenuBar.tsx +++ b/src/components/Menu/MenuBar.tsx @@ -7,6 +7,7 @@ import NodeTypeConfigModal from '../Config/NodeTypeConfig'; import EdgeTypeConfigModal from '../Config/EdgeTypeConfig'; import { useConfirm } from '../../hooks/useConfirm'; import { useShortcutLabels } from '../../hooks/useShortcutLabels'; +import type { ExportOptions } from '../../utils/graphExport'; /** * MenuBar Component @@ -22,9 +23,10 @@ interface MenuBarProps { onOpenHelp?: () => void; onFitView?: () => void; onSelectAll?: () => void; + onExport?: (format: 'png' | 'svg', options?: ExportOptions) => Promise; } -const MenuBar: React.FC = ({ onOpenHelp, onFitView, onSelectAll }) => { +const MenuBar: React.FC = ({ onOpenHelp, onFitView, onSelectAll, onExport }) => { const [activeMenu, setActiveMenu] = useState(null); const [showDocumentManager, setShowDocumentManager] = useState(false); const [showNodeConfig, setShowNodeConfig] = useState(false); @@ -145,6 +147,28 @@ const MenuBar: React.FC = ({ onOpenHelp, onFitView, onSelectAll }) closeMenu(); }, [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 ( <>
@@ -203,11 +227,25 @@ const MenuBar: React.FC = ({ onOpenHelp, onFitView, onSelectAll }) 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" > - Export Document + Export Document (JSON) {getShortcutLabel('save-document') && ( {getShortcutLabel('save-document')} )} + +