Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/Drawd.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -355,8 +355,8 @@ export default function Drawd({ initialRoomCode }) {
});

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

// ── Toast notification ─────────────────────────────────────────────────────────────
const [toast, setToast] = useState(null);
Expand Down Expand Up @@ -506,6 +506,8 @@ export default function Drawd({ initialRoomCode }) {
dataModelCount={dataModels.length}
onExport={onExport}
onExportPrototype={onExportPrototype}
onExportPng={onExportPng}
onExportSvg={onExportSvg}
onImport={onImport}
onGenerate={onGenerate}
onDocuments={() => setShowDocuments(true)}
Expand Down
22 changes: 21 additions & 1 deletion src/components/TopBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ function ShareIcon() {
);
}

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 }) {
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 }) {
const [fileMenuOpen, setFileMenuOpen] = useState(false);
const fileMenuRef = useRef(null);

Expand Down Expand Up @@ -440,6 +440,26 @@ export function TopBar({ screenCount, connectionCount, onExport, onExportPrototy
<span>Export Prototype</span>
</button>

<button
className="ff-menu-item"
onClick={() => { if (screenCount > 0) { setFileMenuOpen(false); onExportPng?.(); } }}
disabled={screenCount === 0}
style={menuItemStyle(screenCount === 0)}
title="Export the canvas (or selected items) as a high-resolution PNG image"
>
<span>Export as PNG</span>
</button>

<button
className="ff-menu-item"
onClick={() => { if (screenCount > 0) { setFileMenuOpen(false); onExportSvg?.(); } }}
disabled={screenCount === 0}
style={menuItemStyle(screenCount === 0)}
title="Export the canvas (or selected items) as a scalable SVG image"
>
<span>Export as SVG</span>
</button>

{isFileSystemSupported && (
<>
<div style={{ height: 1, background: COLORS.border, margin: "6px 0" }} />
Expand Down
32 changes: 32 additions & 0 deletions src/hooks/useImportExport.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { exportFlow } from "../utils/exportFlow";
import { importFlow } from "../utils/importFlow";
import { mergeFlow } from "../utils/mergeFlow";
import { generatePrototype, downloadPrototype } from "../utils/generatePrototype";
import { exportCanvasAsPng, exportCanvasAsSvg } from "../utils/exportCanvasImage";

export function useImportExport({
screens,
Expand All @@ -26,6 +27,7 @@ export function useImportExport({
setComments,
scopeScreenIds,
connectedFileName,
canvasSelection,
}) {
const [importConfirm, setImportConfirm] = useState(null);
const importFileRef = useRef(null);
Expand Down Expand Up @@ -90,11 +92,41 @@ export function useImportExport({
downloadPrototype(html);
}, [screens, connections, scopeScreenIds, connectedFileName]);

const buildImageExportOpts = useCallback(() => ({
screens,
connections,
stickyNotes: stickyNotes || [],
screenGroups: screenGroups || [],
selection: canvasSelection || [],
scopeScreenIds,
filename: connectedFileName ? connectedFileName.replace(/\.drawd(\.json)?$/i, "") : undefined,
}), [screens, connections, stickyNotes, screenGroups, canvasSelection, scopeScreenIds, connectedFileName]);

const onExportPng = useCallback(async () => {
if (screens.length === 0 && (stickyNotes?.length || 0) === 0) return;
try {
await exportCanvasAsPng(buildImageExportOpts());
} catch (err) {
alert("PNG export failed: " + err.message);
}
}, [screens.length, stickyNotes?.length, buildImageExportOpts]);

const onExportSvg = useCallback(async () => {
if (screens.length === 0 && (stickyNotes?.length || 0) === 0) return;
try {
await exportCanvasAsSvg(buildImageExportOpts());
} catch (err) {
alert("SVG export failed: " + err.message);
}
}, [screens.length, stickyNotes?.length, buildImageExportOpts]);

return {
importConfirm, setImportConfirm,
importFileRef,
onExport,
onExportPrototype,
onExportPng,
onExportSvg,
onImport,
onImportFileChange,
onImportReplace,
Expand Down
39 changes: 39 additions & 0 deletions src/pages/docs/userGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,45 @@ If a scope root is active (you are viewing a sub-flow), only the screens in that
> [!TIP]
> 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.

## Exporting Canvas Images (PNG / SVG)

Export the visual canvas as a flat image to embed in design docs, Notion pages, Slack threads, JIRA tickets, or PR descriptions.

### How to export

- Open the **File** menu in the top bar and click **Export as PNG** or **Export as SVG**
- A timestamped image file downloads immediately — no extra dialog

### What gets included

- All screen cards (header bar with name + image content) at their canvas positions
- Connection bezier curves with arrowheads, color-coded by path (default / api-success / api-error / conditional)
- Connection labels and conditional branch labels
- Sticky notes with their content and color
- Screen-group rectangles (dashed outline + label)
- Hotspots are drawn as subtle dashed overlays so reviewers can see tap targets

### What gets excluded (by design)

- Editor chrome: top bar, side panels, toolbar, selection handles, hover effects, comment pins, remote cursors
- Canvas grid dots — the export uses a clean dark background

### Choosing what to export

The exporter picks one of three scopes, in priority order:

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.
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.
3. **Everything** — if nothing is selected and no scope is active, the entire canvas is exported.

### PNG vs SVG

- **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.
- **SVG** — Scalable vector with screens embedded as data URLs. Best for design tools (Figma, Illustrator), zooming without quality loss, and editing labels after export.

> [!NOTE]
> SVG files are self-contained — screen images are embedded as data URLs, so the SVG renders correctly on its own with no external dependencies.

## Keyboard Shortcuts

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