Skip to content

Commit 3182ae1

Browse files
ibrahimcesarclaude
andcommitted
feat(web): Add PNG/SVG canvas export functionality
- Add html-to-image dependency for canvas export - Create image export utility with PNG and SVG support - Add Image tab to DiagramExportPanel with format selection - Export captures React Flow viewport with nodes and edges 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 681e8b5 commit 3182ae1

File tree

5 files changed

+291
-44
lines changed

5 files changed

+291
-44
lines changed

web/package-lock.json

Lines changed: 23 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
},
1717
"dependencies": {
1818
"@xyflow/react": "^12.9.3",
19+
"html-to-image": "^1.11.13",
1920
"immer": "^10.1.1",
2021
"lucide-react": "^0.555.0",
2122
"react": "^19.2.0",

web/src/components/export/DiagramExportPanel.tsx

Lines changed: 150 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import { useState, useMemo } from 'react';
2-
import { X, Download, Copy, Check, FileImage, FileText, BookOpen } from 'lucide-react';
2+
import { X, Download, Copy, Check, FileImage, FileText, BookOpen, Image, Loader2 } from 'lucide-react';
33
import { useDomainStore } from '@/stores';
4-
import { generateDiagram, downloadDiagram, generateDocs, downloadDocumentation } from '@/utils/export';
5-
import type { DiagramFormat } from '@/utils/export';
4+
import { generateDiagram, downloadDiagram, generateDocs, downloadDocumentation, exportAndDownloadCanvas } from '@/utils/export';
5+
import type { DiagramFormat, ImageFormat } from '@/utils/export';
66

77
interface DiagramExportPanelProps {
88
isOpen: boolean;
99
onClose: () => void;
1010
}
1111

12-
type ExportTab = 'diagram' | 'documentation';
12+
type ExportTab = 'image' | 'diagram' | 'documentation';
1313

1414
const diagramFormats: { id: DiagramFormat; name: string; description: string }[] = [
1515
{ id: 'mermaid-class', name: 'Mermaid Class Diagram', description: 'UML-style class diagram' },
@@ -18,10 +18,14 @@ const diagramFormats: { id: DiagramFormat; name: string; description: string }[]
1818

1919
export function DiagramExportPanel({ isOpen, onClose }: DiagramExportPanelProps) {
2020
const { contexts, activeContextId, contextMaps } = useDomainStore();
21-
const [activeTab, setActiveTab] = useState<ExportTab>('diagram');
21+
const [activeTab, setActiveTab] = useState<ExportTab>('image');
2222
const [selectedFormat, setSelectedFormat] = useState<DiagramFormat>('mermaid-class');
2323
const [copied, setCopied] = useState(false);
2424

25+
// Image export options
26+
const [imageFormat, setImageFormat] = useState<ImageFormat>('png');
27+
const [isExporting, setIsExporting] = useState(false);
28+
2529
// Documentation options
2630
const [includeTOC, setIncludeTOC] = useState(true);
2731
const [includeRelationships, setIncludeRelationships] = useState(true);
@@ -74,6 +78,21 @@ export function DiagramExportPanel({ isOpen, onClose }: DiagramExportPanelProps)
7478
}
7579
};
7680

81+
const handleImageExport = async () => {
82+
setIsExporting(true);
83+
try {
84+
const success = await exportAndDownloadCanvas({ format: imageFormat });
85+
if (!success) {
86+
alert('Failed to export canvas. Make sure there are nodes on the canvas.');
87+
}
88+
} catch (error) {
89+
console.error('Export failed:', error);
90+
alert('Failed to export canvas.');
91+
} finally {
92+
setIsExporting(false);
93+
}
94+
};
95+
7796
if (!isOpen) return null;
7897

7998
return (
@@ -104,6 +123,17 @@ export function DiagramExportPanel({ isOpen, onClose }: DiagramExportPanelProps)
104123

105124
{/* Tabs */}
106125
<div className="flex border-b border-slate-200 dark:border-slate-700">
126+
<button
127+
onClick={() => setActiveTab('image')}
128+
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
129+
activeTab === 'image'
130+
? 'border-primary text-primary'
131+
: 'border-transparent text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100'
132+
}`}
133+
>
134+
<Image className="w-4 h-4" />
135+
Image
136+
</button>
107137
<button
108138
onClick={() => setActiveTab('diagram')}
109139
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
@@ -113,7 +143,7 @@ export function DiagramExportPanel({ isOpen, onClose }: DiagramExportPanelProps)
113143
}`}
114144
>
115145
<FileImage className="w-4 h-4" />
116-
Diagrams
146+
Mermaid
117147
</button>
118148
<button
119149
onClick={() => setActiveTab('documentation')}
@@ -136,7 +166,72 @@ export function DiagramExportPanel({ isOpen, onClose }: DiagramExportPanelProps)
136166
<div className="flex-1 flex overflow-hidden">
137167
{/* Sidebar */}
138168
<div className="w-64 border-r border-slate-200 dark:border-slate-700 p-4 overflow-y-auto">
139-
{activeTab === 'diagram' ? (
169+
{activeTab === 'image' ? (
170+
<>
171+
<label className="block text-sm font-medium mb-3">Image Format</label>
172+
<div className="space-y-2">
173+
<button
174+
onClick={() => setImageFormat('png')}
175+
className={`w-full text-left p-3 rounded-lg border ${
176+
imageFormat === 'png'
177+
? 'border-primary bg-primary/10'
178+
: 'border-slate-200 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700'
179+
}`}
180+
>
181+
<div className="font-medium text-sm">PNG</div>
182+
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
183+
Raster image, best for sharing
184+
</div>
185+
</button>
186+
<button
187+
onClick={() => setImageFormat('svg')}
188+
className={`w-full text-left p-3 rounded-lg border ${
189+
imageFormat === 'svg'
190+
? 'border-primary bg-primary/10'
191+
: 'border-slate-200 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700'
192+
}`}
193+
>
194+
<div className="font-medium text-sm">SVG</div>
195+
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">
196+
Vector format, scalable
197+
</div>
198+
</button>
199+
</div>
200+
201+
<button
202+
onClick={handleImageExport}
203+
disabled={isExporting}
204+
className="w-full mt-6 flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-primary text-white hover:bg-primary-hover disabled:opacity-50"
205+
>
206+
{isExporting ? (
207+
<>
208+
<Loader2 className="w-4 h-4 animate-spin" />
209+
Exporting...
210+
</>
211+
) : (
212+
<>
213+
<Download className="w-4 h-4" />
214+
Export {imageFormat.toUpperCase()}
215+
</>
216+
)}
217+
</button>
218+
219+
<div className="mt-6 p-3 bg-slate-100 dark:bg-slate-700 rounded-lg">
220+
<div className="flex items-center gap-2 text-sm font-medium mb-2">
221+
<Image className="w-4 h-4" />
222+
Canvas Export
223+
</div>
224+
<p className="text-xs text-slate-600 dark:text-slate-400">
225+
Exports the current canvas view as an image. The exported image includes:
226+
</p>
227+
<ul className="text-xs text-slate-600 dark:text-slate-400 mt-2 space-y-1">
228+
<li>• All visible nodes</li>
229+
<li>• Connections/edges</li>
230+
<li>• Current zoom level</li>
231+
</ul>
232+
</div>
233+
</>
234+
) : activeTab === 'diagram' ? (
140235
<>
141236
<label className="block text-sm font-medium mb-3">Diagram Format</label>
142237
<div className="space-y-2">
@@ -229,41 +324,56 @@ export function DiagramExportPanel({ isOpen, onClose }: DiagramExportPanelProps)
229324

230325
{/* Preview */}
231326
<div className="flex-1 flex flex-col overflow-hidden">
232-
{/* Toolbar */}
233-
<div className="flex items-center justify-between px-4 py-2 border-b border-slate-200 dark:border-slate-700">
234-
<span className="text-sm font-medium text-slate-600 dark:text-slate-400">
235-
{currentFilename || 'No content'}
236-
</span>
237-
<div className="flex items-center gap-2">
238-
<button
239-
onClick={handleCopy}
240-
disabled={!currentContent}
241-
className="flex items-center gap-1 px-3 py-1 text-sm rounded border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700 disabled:opacity-50"
242-
>
243-
{copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
244-
{copied ? 'Copied!' : 'Copy'}
245-
</button>
246-
<button
247-
onClick={handleDownload}
248-
disabled={!currentContent}
249-
className="flex items-center gap-1 px-3 py-1 text-sm rounded bg-primary text-white hover:bg-primary-hover disabled:opacity-50"
250-
>
251-
<Download className="w-4 h-4" />
252-
Download
253-
</button>
327+
{activeTab === 'image' ? (
328+
<div className="flex-1 flex items-center justify-center bg-slate-100 dark:bg-slate-800 p-8">
329+
<div className="text-center">
330+
<Image className="w-16 h-16 mx-auto mb-4 text-slate-400" />
331+
<h3 className="text-lg font-medium mb-2">Canvas Image Export</h3>
332+
<p className="text-sm text-slate-500 dark:text-slate-400 max-w-md">
333+
Select a format and click "Export" to download an image of your current canvas.
334+
The image will capture all nodes and connections as they appear on screen.
335+
</p>
336+
</div>
254337
</div>
255-
</div>
338+
) : (
339+
<>
340+
{/* Toolbar */}
341+
<div className="flex items-center justify-between px-4 py-2 border-b border-slate-200 dark:border-slate-700">
342+
<span className="text-sm font-medium text-slate-600 dark:text-slate-400">
343+
{currentFilename || 'No content'}
344+
</span>
345+
<div className="flex items-center gap-2">
346+
<button
347+
onClick={handleCopy}
348+
disabled={!currentContent}
349+
className="flex items-center gap-1 px-3 py-1 text-sm rounded border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700 disabled:opacity-50"
350+
>
351+
{copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
352+
{copied ? 'Copied!' : 'Copy'}
353+
</button>
354+
<button
355+
onClick={handleDownload}
356+
disabled={!currentContent}
357+
className="flex items-center gap-1 px-3 py-1 text-sm rounded bg-primary text-white hover:bg-primary-hover disabled:opacity-50"
358+
>
359+
<Download className="w-4 h-4" />
360+
Download
361+
</button>
362+
</div>
363+
</div>
256364

257-
{/* Content */}
258-
<div className="flex-1 overflow-auto bg-slate-900 p-4">
259-
{currentContent ? (
260-
<pre className="text-sm text-slate-100 font-mono whitespace-pre-wrap">
261-
<code>{currentContent}</code>
262-
</pre>
263-
) : (
264-
<p className="text-slate-400">No content generated</p>
265-
)}
266-
</div>
365+
{/* Content */}
366+
<div className="flex-1 overflow-auto bg-slate-900 p-4">
367+
{currentContent ? (
368+
<pre className="text-sm text-slate-100 font-mono whitespace-pre-wrap">
369+
<code>{currentContent}</code>
370+
</pre>
371+
) : (
372+
<p className="text-slate-400">No content generated</p>
373+
)}
374+
</div>
375+
</>
376+
)}
267377
</div>
268378
</div>
269379
)}

0 commit comments

Comments
 (0)