Skip to content

Commit 6b762a8

Browse files
authored
feat: export canvas as PNG or SVG image (#40)
Adds File-menu actions to export the canvas (entire flow, current scope, or rubber-band selection) as a high-resolution PNG or self-contained SVG for embedding in design docs, tickets, and chats. The renderer reuses the existing screenToImage primitive to handle all three screen content types (raw image, SVG, wireframe) and draws connection bezier curves, sticky notes, and screen-group rectangles without pulling in a DOM-capture dependency. Selection priority: multi-select > scope root > entire canvas. PNG renders at 2x pixel ratio with a 16384px safety cap. Co-authored-by: Quang Tran <16215255+trmquang93@users.noreply.github.com>
1 parent 7af7f45 commit 6b762a8

6 files changed

Lines changed: 990 additions & 3 deletions

File tree

src/Drawd.jsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,8 +355,8 @@ export default function Drawd({ initialRoomCode }) {
355355
});
356356

357357
// ── Import / export ────────────────────────────────────────────────────────────────
358-
const { importConfirm, setImportConfirm, importFileRef, onExport, onExportPrototype, onImport, onImportFileChange, onImportReplace, onImportMerge } =
359-
useImportExport({ screens, connections, documents, dataModels, stickyNotes, screenGroups, comments, pan, zoom, featureBrief, taskLink, techStack, replaceAll, mergeAll, setPan, setZoom, setStickyNotes, setScreenGroups, setComments, scopeScreenIds, connectedFileName });
358+
const { importConfirm, setImportConfirm, importFileRef, onExport, onExportPrototype, onExportPng, onExportSvg, onImport, onImportFileChange, onImportReplace, onImportMerge } =
359+
useImportExport({ screens, connections, documents, dataModels, stickyNotes, screenGroups, comments, pan, zoom, featureBrief, taskLink, techStack, replaceAll, mergeAll, setPan, setZoom, setStickyNotes, setScreenGroups, setComments, scopeScreenIds, connectedFileName, canvasSelection });
360360

361361
// ── Toast notification ─────────────────────────────────────────────────────────────
362362
const [toast, setToast] = useState(null);
@@ -506,6 +506,8 @@ export default function Drawd({ initialRoomCode }) {
506506
dataModelCount={dataModels.length}
507507
onExport={onExport}
508508
onExportPrototype={onExportPrototype}
509+
onExportPng={onExportPng}
510+
onExportSvg={onExportSvg}
509511
onImport={onImport}
510512
onGenerate={onGenerate}
511513
onDocuments={() => setShowDocuments(true)}

src/components/TopBar.jsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ function ShareIcon() {
102102
);
103103
}
104104

105-
export function TopBar({ screenCount, connectionCount, onExport, onExportPrototype, onImport, onGenerate, canUndo, canRedo, onUndo, onRedo, connectedFileName, saveStatus, isFileSystemSupported, onNew, onOpen, onSaveAs, onDocuments, documentCount = 0, onDataModels, dataModelCount = 0, collabState, onShare, collabBadge, collabPresence, onToggleParticipants, showParticipants, onTemplates, onCompareFlows, onToggleComments, showComments, unresolvedCommentCount = 0, canComment }) {
105+
export function TopBar({ screenCount, connectionCount, onExport, onExportPrototype, onExportPng, onExportSvg, onImport, onGenerate, canUndo, canRedo, onUndo, onRedo, connectedFileName, saveStatus, isFileSystemSupported, onNew, onOpen, onSaveAs, onDocuments, documentCount = 0, onDataModels, dataModelCount = 0, collabState, onShare, collabBadge, collabPresence, onToggleParticipants, showParticipants, onTemplates, onCompareFlows, onToggleComments, showComments, unresolvedCommentCount = 0, canComment }) {
106106
const [fileMenuOpen, setFileMenuOpen] = useState(false);
107107
const fileMenuRef = useRef(null);
108108

@@ -440,6 +440,26 @@ export function TopBar({ screenCount, connectionCount, onExport, onExportPrototy
440440
<span>Export Prototype</span>
441441
</button>
442442

443+
<button
444+
className="ff-menu-item"
445+
onClick={() => { if (screenCount > 0) { setFileMenuOpen(false); onExportPng?.(); } }}
446+
disabled={screenCount === 0}
447+
style={menuItemStyle(screenCount === 0)}
448+
title="Export the canvas (or selected items) as a high-resolution PNG image"
449+
>
450+
<span>Export as PNG</span>
451+
</button>
452+
453+
<button
454+
className="ff-menu-item"
455+
onClick={() => { if (screenCount > 0) { setFileMenuOpen(false); onExportSvg?.(); } }}
456+
disabled={screenCount === 0}
457+
style={menuItemStyle(screenCount === 0)}
458+
title="Export the canvas (or selected items) as a scalable SVG image"
459+
>
460+
<span>Export as SVG</span>
461+
</button>
462+
443463
{isFileSystemSupported && (
444464
<>
445465
<div style={{ height: 1, background: COLORS.border, margin: "6px 0" }} />

src/hooks/useImportExport.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { exportFlow } from "../utils/exportFlow";
33
import { importFlow } from "../utils/importFlow";
44
import { mergeFlow } from "../utils/mergeFlow";
55
import { generatePrototype, downloadPrototype } from "../utils/generatePrototype";
6+
import { exportCanvasAsPng, exportCanvasAsSvg } from "../utils/exportCanvasImage";
67

78
export function useImportExport({
89
screens,
@@ -26,6 +27,7 @@ export function useImportExport({
2627
setComments,
2728
scopeScreenIds,
2829
connectedFileName,
30+
canvasSelection,
2931
}) {
3032
const [importConfirm, setImportConfirm] = useState(null);
3133
const importFileRef = useRef(null);
@@ -90,11 +92,41 @@ export function useImportExport({
9092
downloadPrototype(html);
9193
}, [screens, connections, scopeScreenIds, connectedFileName]);
9294

95+
const buildImageExportOpts = useCallback(() => ({
96+
screens,
97+
connections,
98+
stickyNotes: stickyNotes || [],
99+
screenGroups: screenGroups || [],
100+
selection: canvasSelection || [],
101+
scopeScreenIds,
102+
filename: connectedFileName ? connectedFileName.replace(/\.drawd(\.json)?$/i, "") : undefined,
103+
}), [screens, connections, stickyNotes, screenGroups, canvasSelection, scopeScreenIds, connectedFileName]);
104+
105+
const onExportPng = useCallback(async () => {
106+
if (screens.length === 0 && (stickyNotes?.length || 0) === 0) return;
107+
try {
108+
await exportCanvasAsPng(buildImageExportOpts());
109+
} catch (err) {
110+
alert("PNG export failed: " + err.message);
111+
}
112+
}, [screens.length, stickyNotes?.length, buildImageExportOpts]);
113+
114+
const onExportSvg = useCallback(async () => {
115+
if (screens.length === 0 && (stickyNotes?.length || 0) === 0) return;
116+
try {
117+
await exportCanvasAsSvg(buildImageExportOpts());
118+
} catch (err) {
119+
alert("SVG export failed: " + err.message);
120+
}
121+
}, [screens.length, stickyNotes?.length, buildImageExportOpts]);
122+
93123
return {
94124
importConfirm, setImportConfirm,
95125
importFileRef,
96126
onExport,
97127
onExportPrototype,
128+
onExportPng,
129+
onExportSvg,
98130
onImport,
99131
onImportFileChange,
100132
onImportReplace,

src/pages/docs/userGuide.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,45 @@ If a scope root is active (you are viewing a sub-flow), only the screens in that
505505
> [!TIP]
506506
> The exported file is entirely self-contained — share it via email, Slack, or any file host. Recipients just open it in a browser to tap through the flow.
507507
508+
## Exporting Canvas Images (PNG / SVG)
509+
510+
Export the visual canvas as a flat image to embed in design docs, Notion pages, Slack threads, JIRA tickets, or PR descriptions.
511+
512+
### How to export
513+
514+
- Open the **File** menu in the top bar and click **Export as PNG** or **Export as SVG**
515+
- A timestamped image file downloads immediately — no extra dialog
516+
517+
### What gets included
518+
519+
- All screen cards (header bar with name + image content) at their canvas positions
520+
- Connection bezier curves with arrowheads, color-coded by path (default / api-success / api-error / conditional)
521+
- Connection labels and conditional branch labels
522+
- Sticky notes with their content and color
523+
- Screen-group rectangles (dashed outline + label)
524+
- Hotspots are drawn as subtle dashed overlays so reviewers can see tap targets
525+
526+
### What gets excluded (by design)
527+
528+
- Editor chrome: top bar, side panels, toolbar, selection handles, hover effects, comment pins, remote cursors
529+
- Canvas grid dots — the export uses a clean dark background
530+
531+
### Choosing what to export
532+
533+
The exporter picks one of three scopes, in priority order:
534+
535+
1. **Multi-selected items** — if you have screens or sticky notes selected (rubber-band or `Shift+click`), only those are exported. Connections between selected screens are included; connections to non-selected screens are dropped.
536+
2. **Scope root** — if a scope root is active (you are viewing a sub-flow), only the in-scope screens and their connections are exported.
537+
3. **Everything** — if nothing is selected and no scope is active, the entire canvas is exported.
538+
539+
### PNG vs SVG
540+
541+
- **PNG** — Raster image at 2x pixel ratio (Retina-quality). Best for chat apps, screenshots, and tickets where you want a fixed image. Very large flows are auto-capped at the browser's canvas-size limit (~16384px) so they render reliably.
542+
- **SVG** — Scalable vector with screens embedded as data URLs. Best for design tools (Figma, Illustrator), zooming without quality loss, and editing labels after export.
543+
544+
> [!NOTE]
545+
> SVG files are self-contained — screen images are embedded as data URLs, so the SVG renders correctly on its own with no external dependencies.
546+
508547
## Keyboard Shortcuts
509548

510549
Press `?` anywhere on the canvas to open the full keyboard shortcuts panel. The shortcuts below are organized by category.

0 commit comments

Comments
 (0)