From a29c8f1d4278500e2dffae5d1c4039e1489910d0 Mon Sep 17 00:00:00 2001
From: Quang Tran <16215255+trmquang93@users.noreply.github.com>
Date: Mon, 27 Apr 2026 06:13:27 +0700
Subject: [PATCH] feat: export canvas as PNG or SVG image
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.
---
src/Drawd.jsx | 6 +-
src/components/TopBar.jsx | 22 +-
src/hooks/useImportExport.js | 32 ++
src/pages/docs/userGuide.md | 39 ++
src/utils/exportCanvasImage.js | 616 ++++++++++++++++++++++++++++
src/utils/exportCanvasImage.test.js | 278 +++++++++++++
6 files changed, 990 insertions(+), 3 deletions(-)
create mode 100644 src/utils/exportCanvasImage.js
create mode 100644 src/utils/exportCanvasImage.test.js
diff --git a/src/Drawd.jsx b/src/Drawd.jsx
index 1aa12d2..aa3eeed 100644
--- a/src/Drawd.jsx
+++ b/src/Drawd.jsx
@@ -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);
@@ -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)}
diff --git a/src/components/TopBar.jsx b/src/components/TopBar.jsx
index b1236a8..e6fe690 100644
--- a/src/components/TopBar.jsx
+++ b/src/components/TopBar.jsx
@@ -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);
@@ -440,6 +440,26 @@ export function TopBar({ screenCount, connectionCount, onExport, onExportPrototy
Export Prototype
+
+
+
+
{isFileSystemSupported && (
<>
diff --git a/src/hooks/useImportExport.js b/src/hooks/useImportExport.js
index 7936152..ae856e5 100644
--- a/src/hooks/useImportExport.js
+++ b/src/hooks/useImportExport.js
@@ -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,
@@ -26,6 +27,7 @@ export function useImportExport({
setComments,
scopeScreenIds,
connectedFileName,
+ canvasSelection,
}) {
const [importConfirm, setImportConfirm] = useState(null);
const importFileRef = useRef(null);
@@ -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,
diff --git a/src/pages/docs/userGuide.md b/src/pages/docs/userGuide.md
index ef05ee1..471525a 100644
--- a/src/pages/docs/userGuide.md
+++ b/src/pages/docs/userGuide.md
@@ -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.
diff --git a/src/utils/exportCanvasImage.js b/src/utils/exportCanvasImage.js
new file mode 100644
index 0000000..8b81b65
--- /dev/null
+++ b/src/utils/exportCanvasImage.js
@@ -0,0 +1,616 @@
+import { wireframeToSvg } from "./wireframeToSvg";
+import {
+ DEFAULT_SCREEN_WIDTH,
+ DEFAULT_IMAGE_HEIGHT,
+ HEADER_HEIGHT,
+ BORDER_WIDTH,
+ BEZIER_FACTOR,
+ BEZIER_MIN_CP,
+ DEFAULT_EXPORT_FILENAME,
+} from "../constants";
+
+// Match ScreenGroup.jsx visual padding so the exported group rect contains its members.
+const GROUP_PADDING = 30;
+const GROUP_LABEL_HEIGHT = 20;
+
+// Sticky note bounds height used by stickyBounds() — kept in sync to avoid a circular import.
+const STICKY_NOTE_HEIGHT = 120;
+
+// Mirrors NOTE_COLORS in StickyNote.jsx so exports match the editor visuals.
+const STICKY_COLORS = {
+ yellow: { bg: "#2d2a00", border: "#f0c040", text: "#f5e17a" },
+ blue: { bg: "#001a2d", border: "#4da6ff", text: "#a8d4ff" },
+ red: { bg: "#2d0000", border: "#ff6b6b", text: "#ffb3b3" },
+ green: { bg: "#002d0a", border: "#00d27d", text: "#7fffb8" },
+};
+
+const CANVAS_BG = "#21252b";
+
+// ── Filtering ────────────────────────────────────────────────────────────────
+/**
+ * Decides which screens, connections, sticky notes and groups to include.
+ * Priority: explicit `selection` ▸ `scopeScreenIds` ▸ everything.
+ *
+ * `selection` is the canvas multi-selection array: [{type: "screen"|"sticky", id}, …]
+ * `scopeScreenIds` is a Set produced by the scope-root traversal in Drawd.jsx.
+ */
+export function selectExportItems({
+ screens = [],
+ connections = [],
+ stickyNotes = [],
+ screenGroups = [],
+ selection = null,
+ scopeScreenIds = null,
+}) {
+ const hasSelection = Array.isArray(selection) && selection.length > 0;
+
+ let screenIdSet;
+ let stickyIdSet = null;
+
+ if (hasSelection) {
+ screenIdSet = new Set(selection.filter((s) => s.type === "screen").map((s) => s.id));
+ stickyIdSet = new Set(selection.filter((s) => s.type === "sticky").map((s) => s.id));
+ } else if (scopeScreenIds) {
+ screenIdSet = new Set(scopeScreenIds);
+ } else {
+ screenIdSet = new Set(screens.map((s) => s.id));
+ }
+
+ const includedScreens = screens.filter((s) => screenIdSet.has(s.id));
+ const includedConnections = connections.filter(
+ (c) => screenIdSet.has(c.fromScreenId) && screenIdSet.has(c.toScreenId),
+ );
+ const includedSticky = stickyIdSet
+ ? stickyNotes.filter((n) => stickyIdSet.has(n.id))
+ : (hasSelection ? [] : stickyNotes);
+ // A group is rendered only when all of its member screens made it into the export.
+ const includedGroups = (screenGroups || []).filter(
+ (g) => g.screenIds.length > 0 && g.screenIds.every((id) => screenIdSet.has(id)),
+ );
+
+ return {
+ screens: includedScreens,
+ connections: includedConnections,
+ stickyNotes: includedSticky,
+ screenGroups: includedGroups,
+ };
+}
+
+// ── Geometry helpers ─────────────────────────────────────────────────────────
+function screenSize(s) {
+ return {
+ w: s.width || DEFAULT_SCREEN_WIDTH,
+ h: (s.imageHeight || DEFAULT_IMAGE_HEIGHT) + HEADER_HEIGHT,
+ };
+}
+
+function groupRect(group, screens) {
+ const members = screens.filter((s) => group.screenIds.includes(s.id));
+ if (members.length === 0) return null;
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (const s of members) {
+ const { w, h } = screenSize(s);
+ minX = Math.min(minX, s.x);
+ minY = Math.min(minY, s.y);
+ maxX = Math.max(maxX, s.x + w);
+ maxY = Math.max(maxY, s.y + h);
+ }
+ return {
+ x: minX - GROUP_PADDING,
+ y: minY - GROUP_PADDING - GROUP_LABEL_HEIGHT,
+ width: (maxX - minX) + GROUP_PADDING * 2,
+ height: (maxY - minY) + GROUP_PADDING * 2 + GROUP_LABEL_HEIGHT,
+ };
+}
+
+/**
+ * Connection endpoint geometry. Mirrors computePoints() in ConnectionLines.jsx
+ * but inlined to keep this module dependency-free of React/JSX (so tests stay
+ * fast and the bundler doesn't pull a UI component into the export path).
+ */
+function computeConnectionPoints(conn, screens) {
+ const from = screens.find((s) => s.id === conn.fromScreenId);
+ const to = screens.find((s) => s.id === conn.toScreenId);
+ if (!from || !to) return null;
+
+ const fromW = from.width || DEFAULT_SCREEN_WIDTH;
+ const fromImgH = from.imageHeight || DEFAULT_IMAGE_HEIGHT;
+ const toImgH = to.imageHeight || DEFAULT_IMAGE_HEIGHT;
+ const hs = conn.hotspotId && from.hotspots
+ ? from.hotspots.find((h) => h.id === conn.hotspotId)
+ : null;
+
+ let fromX, fromY;
+ if (hs && from.imageHeight) {
+ fromX = from.x + BORDER_WIDTH + (hs.x + hs.w / 2) / 100 * fromW;
+ fromY = from.y + BORDER_WIDTH + HEADER_HEIGHT + (hs.y + hs.h / 2) / 100 * fromImgH;
+ } else {
+ fromX = from.x + fromW;
+ fromY = from.y + (HEADER_HEIGHT + fromImgH) / 2;
+ }
+ const toX = to.x;
+ const toY = to.y + (HEADER_HEIGHT + toImgH) / 2;
+ return { fromX, fromY, toX, toY };
+}
+
+function bezierControlPoints(fromX, fromY, toX, toY) {
+ const dx = toX - fromX;
+ const cp = Math.max(BEZIER_MIN_CP, Math.abs(dx) * BEZIER_FACTOR);
+ return { cp1x: fromX + cp, cp1y: fromY, cp2x: toX - cp, cp2y: toY };
+}
+
+function bezierPathD(fromX, fromY, toX, toY) {
+ const { cp1x, cp1y, cp2x, cp2y } = bezierControlPoints(fromX, fromY, toX, toY);
+ return `M ${fromX} ${fromY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${toX} ${toY}`;
+}
+
+function connectionColor(conn) {
+ if (conn.connectionPath === "api-success") return "#98c379";
+ if (conn.connectionPath === "api-error") return "#e06c75";
+ if (conn.connectionPath?.startsWith?.("condition-")) return "#d19a66";
+ return "#61afef";
+}
+
+// ── Bounding box ─────────────────────────────────────────────────────────────
+/**
+ * Computes the union bounding box of all visible items, expanded by `padding` on each side.
+ * Returns null when there is nothing to export.
+ */
+export function computeExportBounds(items, padding = 40) {
+ const { screens, stickyNotes, screenGroups } = items;
+ if (screens.length === 0 && stickyNotes.length === 0 && screenGroups.length === 0) {
+ return null;
+ }
+
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+
+ for (const s of screens) {
+ const { w, h } = screenSize(s);
+ minX = Math.min(minX, s.x);
+ minY = Math.min(minY, s.y);
+ maxX = Math.max(maxX, s.x + w);
+ maxY = Math.max(maxY, s.y + h);
+ }
+
+ for (const n of stickyNotes) {
+ const w = n.width || DEFAULT_SCREEN_WIDTH;
+ minX = Math.min(minX, n.x);
+ minY = Math.min(minY, n.y);
+ maxX = Math.max(maxX, n.x + w);
+ maxY = Math.max(maxY, n.y + STICKY_NOTE_HEIGHT);
+ }
+
+ for (const g of screenGroups) {
+ const r = groupRect(g, screens);
+ if (!r) continue;
+ minX = Math.min(minX, r.x);
+ minY = Math.min(minY, r.y);
+ maxX = Math.max(maxX, r.x + r.width);
+ maxY = Math.max(maxY, r.y + r.height);
+ }
+
+ return {
+ minX: minX - padding,
+ minY: minY - padding,
+ width: (maxX - minX) + padding * 2,
+ height: (maxY - minY) + padding * 2,
+ };
+}
+
+// ── Screen content ──────────────────────────────────────────────────────────
+function escapeXml(str) {
+ if (str == null) return "";
+ return String(str)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+/**
+ * Returns a data: URL representing the screen's visual content, or null if
+ * the screen has no content (blank screen). Wireframe screens are rasterised
+ * into SVG via the existing wireframeToSvg helper.
+ */
+export function screenContentToHref(screen) {
+ if (screen.imageData) return screen.imageData;
+ if (screen.svgContent) {
+ const encoded = btoa(unescape(encodeURIComponent(screen.svgContent)));
+ return `data:image/svg+xml;base64,${encoded}`;
+ }
+ if (screen.wireframe) {
+ const svg = wireframeToSvg(screen.wireframe);
+ if (!svg) return null;
+ const encoded = btoa(unescape(encodeURIComponent(svg)));
+ return `data:image/svg+xml;base64,${encoded}`;
+ }
+ return null;
+}
+
+// ── SVG renderer ─────────────────────────────────────────────────────────────
+/**
+ * Builds a self-contained SVG string for the export. Screens are embedded as
+ * elements via data URLs so the SVG renders standalone (no asset deps).
+ */
+export function buildCanvasSvg({
+ screens,
+ connections,
+ stickyNotes,
+ screenGroups,
+ bounds,
+ backgroundColor = CANVAS_BG,
+}) {
+ if (!bounds) return null;
+ const { minX, minY, width, height } = bounds;
+ const w = Math.round(width);
+ const h = Math.round(height);
+
+ const parts = [];
+ parts.push(
+ ``);
+ return parts.join("\n");
+}
+
+// ── PNG renderer ─────────────────────────────────────────────────────────────
+function loadImage(src) {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => resolve(img);
+ img.onerror = reject;
+ img.src = src;
+ });
+}
+
+async function loadScreenImage(screen) {
+ const href = screenContentToHref(screen);
+ if (!href) return null;
+ try { return await loadImage(href); } catch { return null; }
+}
+
+function drawRoundedRect(ctx, x, y, w, h, r) {
+ ctx.beginPath();
+ ctx.moveTo(x + r, y);
+ ctx.arcTo(x + w, y, x + w, y + h, r);
+ ctx.arcTo(x + w, y + h, x, y + h, r);
+ ctx.arcTo(x, y + h, x, y, r);
+ ctx.arcTo(x, y, x + w, y, r);
+ ctx.closePath();
+}
+
+function drawArrowhead(ctx, fromX, fromY, toX, toY, color) {
+ const angle = Math.atan2(toY - fromY, toX - fromX);
+ const size = 9;
+ ctx.fillStyle = color;
+ ctx.globalAlpha = 0.9;
+ ctx.beginPath();
+ ctx.moveTo(toX, toY);
+ ctx.lineTo(toX - size * Math.cos(angle - Math.PI / 7), toY - size * Math.sin(angle - Math.PI / 7));
+ ctx.lineTo(toX - size * Math.cos(angle + Math.PI / 7), toY - size * Math.sin(angle + Math.PI / 7));
+ ctx.closePath();
+ ctx.fill();
+ ctx.globalAlpha = 1;
+}
+
+/**
+ * Paints the export onto a 2D canvas context. Caller is responsible for sizing
+ * the canvas and applying any pixel-ratio scaling. Coordinates are translated
+ * so the top-left of the bounding box sits at (0, 0).
+ */
+export async function paintExportToContext(ctx, items, bounds, backgroundColor = CANVAS_BG) {
+ const { screens, connections, stickyNotes, screenGroups } = items;
+ const { minX, minY, width, height } = bounds;
+
+ ctx.save();
+ ctx.fillStyle = backgroundColor;
+ ctx.fillRect(0, 0, width, height);
+ ctx.translate(-minX, -minY);
+
+ // 1. Screen groups.
+ for (const g of screenGroups) {
+ const r = groupRect(g, screens);
+ if (!r) continue;
+ ctx.fillStyle = g.color || "rgba(97,175,239,0.08)";
+ ctx.strokeStyle = "rgba(97,175,239,0.4)";
+ ctx.lineWidth = 1.5;
+ ctx.setLineDash([6, 4]);
+ drawRoundedRect(ctx, r.x, r.y, r.width, r.height, 14);
+ ctx.fill();
+ ctx.stroke();
+ ctx.setLineDash([]);
+ if (g.name) {
+ ctx.fillStyle = "#abb2bf";
+ ctx.font = "600 11px Menlo, monospace";
+ ctx.fillText(g.name, r.x + 12, r.y + 14);
+ }
+ }
+
+ // 2. Screens — load all images in parallel for speed.
+ const imagePairs = await Promise.all(screens.map(async (s) => ({ s, img: await loadScreenImage(s) })));
+ for (const { s, img } of imagePairs) {
+ const { w: sw, h: sh } = screenSize(s);
+ const imgH = s.imageHeight || DEFAULT_IMAGE_HEIGHT;
+ ctx.fillStyle = "#2c313a";
+ ctx.strokeStyle = "#3e4451";
+ ctx.lineWidth = 1.5;
+ drawRoundedRect(ctx, s.x, s.y, sw, sh, 6);
+ ctx.fill();
+ ctx.stroke();
+
+ ctx.fillStyle = "#abb2bf";
+ ctx.font = "600 12px Menlo, monospace";
+ ctx.fillText(s.name || "Untitled", s.x + 10, s.y + 23);
+
+ const imgY = s.y + HEADER_HEIGHT;
+ if (img) {
+ ctx.drawImage(img, s.x, imgY, sw, imgH);
+ } else {
+ ctx.fillStyle = "#0d0d15";
+ ctx.fillRect(s.x, imgY, sw, imgH);
+ }
+
+ if (Array.isArray(s.hotspots)) {
+ ctx.fillStyle = "rgba(97,175,239,0.18)";
+ ctx.strokeStyle = "#61afef";
+ ctx.lineWidth = 1;
+ ctx.setLineDash([3, 2]);
+ for (const hs of s.hotspots) {
+ const hx = s.x + (hs.x / 100) * sw;
+ const hy = imgY + (hs.y / 100) * imgH;
+ const hw = (hs.w / 100) * sw;
+ const hh = (hs.h / 100) * imgH;
+ ctx.fillRect(hx, hy, hw, hh);
+ ctx.strokeRect(hx, hy, hw, hh);
+ }
+ ctx.setLineDash([]);
+ }
+ }
+
+ // 3. Connections.
+ for (const conn of connections) {
+ const pts = computeConnectionPoints(conn, screens);
+ if (!pts) continue;
+ const { fromX, fromY, toX, toY } = pts;
+ const { cp1x, cp1y, cp2x, cp2y } = bezierControlPoints(fromX, fromY, toX, toY);
+ const stroke = connectionColor(conn);
+
+ ctx.fillStyle = stroke;
+ ctx.globalAlpha = 0.9;
+ ctx.beginPath();
+ ctx.arc(fromX, fromY, 5, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.globalAlpha = 1;
+
+ ctx.strokeStyle = stroke;
+ ctx.lineWidth = 2.5;
+ ctx.setLineDash([8, 4]);
+ ctx.globalAlpha = 0.85;
+ ctx.beginPath();
+ ctx.moveTo(fromX, fromY);
+ ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, toX, toY);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ ctx.globalAlpha = 1;
+
+ drawArrowhead(ctx, cp2x, cp2y, toX, toY, stroke);
+
+ const label = conn.condition || conn.label;
+ if (label) {
+ ctx.fillStyle = conn.condition ? "#d19a66" : "#8cc5f6";
+ ctx.font = "10px Menlo, monospace";
+ ctx.textAlign = "center";
+ ctx.fillText(label, (fromX + toX) / 2, (fromY + toY) / 2 - 10);
+ ctx.textAlign = "left";
+ }
+ }
+
+ // 4. Sticky notes.
+ for (const n of stickyNotes) {
+ const nw = n.width || DEFAULT_SCREEN_WIDTH;
+ const colors = STICKY_COLORS[n.color] || STICKY_COLORS.yellow;
+ ctx.fillStyle = colors.bg;
+ ctx.strokeStyle = colors.border;
+ ctx.lineWidth = 1.5;
+ drawRoundedRect(ctx, n.x, n.y, nw, STICKY_NOTE_HEIGHT, 10);
+ ctx.fill();
+ ctx.stroke();
+ if (n.content) {
+ ctx.fillStyle = colors.text;
+ ctx.font = "11px Menlo, monospace";
+ const lines = String(n.content).split(/\n/);
+ lines.forEach((line, i) => {
+ const ty = n.y + 32 + i * 14;
+ if (ty < n.y + STICKY_NOTE_HEIGHT - 6) ctx.fillText(line, n.x + 12, ty);
+ });
+ }
+ }
+
+ ctx.restore();
+}
+
+// ── Public entry points ──────────────────────────────────────────────────────
+function downloadBlob(blob, filename) {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
+
+function timestamped(filename, ext) {
+ const base = filename || DEFAULT_EXPORT_FILENAME;
+ return `${base}-${Date.now()}.${ext}`;
+}
+
+/**
+ * Exports the visible canvas as a high-resolution PNG and triggers a download.
+ * Returns true on success, false when there is nothing to export.
+ *
+ * Options:
+ * screens, connections, stickyNotes, screenGroups — flow data
+ * selection — canvas multi-selection [{type, id}], optional
+ * scopeScreenIds — Set of in-scope screen ids, optional
+ * filename — base filename (no extension), defaults to "flow-export"
+ * padding — empty space around content in canvas units, default 40
+ * pixelRatio — pixel density multiplier, default 2 (Retina)
+ */
+export async function exportCanvasAsPng(opts) {
+ const items = selectExportItems(opts);
+ const bounds = computeExportBounds(items, opts.padding ?? 40);
+ if (!bounds) return false;
+
+ const requested = opts.pixelRatio ?? 2;
+ // Hard cap so we don't allocate beyond browser canvas limits (~16384px on most engines).
+ const MAX_DIM = 16384;
+ let scale = requested;
+ const maxAtScale = Math.max(bounds.width, bounds.height) * scale;
+ if (maxAtScale > MAX_DIM) {
+ scale = MAX_DIM / Math.max(bounds.width, bounds.height);
+ }
+
+ const canvas = document.createElement("canvas");
+ canvas.width = Math.round(bounds.width * scale);
+ canvas.height = Math.round(bounds.height * scale);
+ const ctx = canvas.getContext("2d");
+ ctx.scale(scale, scale);
+
+ await paintExportToContext(ctx, items, bounds);
+
+ await new Promise((resolve) => {
+ canvas.toBlob((blob) => {
+ if (blob) downloadBlob(blob, timestamped(opts.filename, "png"));
+ resolve();
+ }, "image/png");
+ });
+ return true;
+}
+
+/**
+ * Exports the visible canvas as an SVG document and triggers a download.
+ * Returns true on success, false when there is nothing to export.
+ *
+ * Same options as exportCanvasAsPng (pixelRatio is ignored — SVG is vector).
+ */
+export async function exportCanvasAsSvg(opts) {
+ const items = selectExportItems(opts);
+ const bounds = computeExportBounds(items, opts.padding ?? 40);
+ if (!bounds) return false;
+ const svg = buildCanvasSvg({ ...items, bounds });
+ if (!svg) return false;
+ const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
+ downloadBlob(blob, timestamped(opts.filename, "svg"));
+ return true;
+}
diff --git a/src/utils/exportCanvasImage.test.js b/src/utils/exportCanvasImage.test.js
new file mode 100644
index 0000000..35801b7
--- /dev/null
+++ b/src/utils/exportCanvasImage.test.js
@@ -0,0 +1,278 @@
+import { describe, it, expect } from "vitest";
+import {
+ selectExportItems,
+ computeExportBounds,
+ buildCanvasSvg,
+ screenContentToHref,
+} from "./exportCanvasImage";
+
+function makeScreen(overrides = {}) {
+ return {
+ id: "s1",
+ name: "Screen 1",
+ x: 0,
+ y: 0,
+ width: 220,
+ imageHeight: 120,
+ imageData: null,
+ svgContent: null,
+ wireframe: null,
+ hotspots: [],
+ ...overrides,
+ };
+}
+
+function makeConnection(overrides = {}) {
+ return {
+ id: "c1",
+ fromScreenId: "s1",
+ toScreenId: "s2",
+ label: "",
+ condition: "",
+ connectionPath: null,
+ hotspotId: null,
+ ...overrides,
+ };
+}
+
+function makeSticky(overrides = {}) {
+ return { id: "n1", x: 500, y: 100, width: 220, color: "yellow", content: "", ...overrides };
+}
+
+function makeGroup(overrides = {}) {
+ return { id: "g1", name: "Auth", screenIds: ["s1", "s2"], color: "rgba(97,175,239,0.08)", ...overrides };
+}
+
+describe("selectExportItems", () => {
+ const screens = [makeScreen({ id: "s1" }), makeScreen({ id: "s2", x: 400 }), makeScreen({ id: "s3", x: 800 })];
+ const connections = [
+ makeConnection({ id: "c-a", fromScreenId: "s1", toScreenId: "s2" }),
+ makeConnection({ id: "c-b", fromScreenId: "s2", toScreenId: "s3" }),
+ makeConnection({ id: "c-c", fromScreenId: "s1", toScreenId: "s3" }),
+ ];
+ const stickyNotes = [makeSticky({ id: "n1" }), makeSticky({ id: "n2" })];
+ const screenGroups = [
+ makeGroup({ id: "g-12", screenIds: ["s1", "s2"] }),
+ makeGroup({ id: "g-23", screenIds: ["s2", "s3"] }),
+ ];
+
+ it("returns everything when no selection or scope is provided", () => {
+ const out = selectExportItems({ screens, connections, stickyNotes, screenGroups });
+ expect(out.screens).toHaveLength(3);
+ expect(out.connections).toHaveLength(3);
+ expect(out.stickyNotes).toHaveLength(2);
+ expect(out.screenGroups).toHaveLength(2);
+ });
+
+ it("filters by scopeScreenIds and drops connections that leave the scope", () => {
+ const out = selectExportItems({
+ screens,
+ connections,
+ stickyNotes,
+ screenGroups,
+ scopeScreenIds: new Set(["s1", "s2"]),
+ });
+ expect(out.screens.map((s) => s.id)).toEqual(["s1", "s2"]);
+ expect(out.connections.map((c) => c.id)).toEqual(["c-a"]);
+ // sticky notes are not scope-bound
+ expect(out.stickyNotes).toHaveLength(2);
+ // only the group whose members are all in scope
+ expect(out.screenGroups.map((g) => g.id)).toEqual(["g-12"]);
+ });
+
+ it("honours an explicit selection over scope and includes only selected sticky notes", () => {
+ const out = selectExportItems({
+ screens,
+ connections,
+ stickyNotes,
+ screenGroups,
+ selection: [
+ { type: "screen", id: "s2" },
+ { type: "screen", id: "s3" },
+ { type: "sticky", id: "n2" },
+ ],
+ // Provide a scope that should be ignored once selection is present.
+ scopeScreenIds: new Set(["s1"]),
+ });
+ expect(out.screens.map((s) => s.id)).toEqual(["s2", "s3"]);
+ expect(out.connections.map((c) => c.id)).toEqual(["c-b"]);
+ expect(out.stickyNotes.map((n) => n.id)).toEqual(["n2"]);
+ expect(out.screenGroups.map((g) => g.id)).toEqual(["g-23"]);
+ });
+
+ it("returns empty sticky list when selection contains no sticky entries", () => {
+ const out = selectExportItems({
+ screens,
+ connections,
+ stickyNotes,
+ screenGroups,
+ selection: [{ type: "screen", id: "s1" }],
+ });
+ expect(out.stickyNotes).toEqual([]);
+ });
+});
+
+describe("computeExportBounds", () => {
+ it("returns null when there is nothing to export", () => {
+ expect(
+ computeExportBounds({ screens: [], connections: [], stickyNotes: [], screenGroups: [] }),
+ ).toBeNull();
+ });
+
+ it("expands the union of screen bounds by the requested padding on each side", () => {
+ const items = {
+ screens: [makeScreen({ id: "s1", x: 0, y: 0 }), makeScreen({ id: "s2", x: 400, y: 200 })],
+ connections: [],
+ stickyNotes: [],
+ screenGroups: [],
+ };
+ const b = computeExportBounds(items, 40);
+ // Screen s1 spans (0,0)-(220, 157), s2 spans (400,200)-(620, 357). Padding = 40.
+ expect(b.minX).toBe(-40);
+ expect(b.minY).toBe(-40);
+ expect(b.width).toBe(620 + 80);
+ expect(b.height).toBe(357 + 80);
+ });
+
+ it("includes sticky notes in the bounds", () => {
+ const items = {
+ screens: [],
+ connections: [],
+ stickyNotes: [makeSticky({ id: "n1", x: 100, y: 100 })],
+ screenGroups: [],
+ };
+ const b = computeExportBounds(items, 0);
+ expect(b.minX).toBe(100);
+ expect(b.minY).toBe(100);
+ expect(b.width).toBe(220);
+ expect(b.height).toBe(120);
+ });
+
+ it("expands bounds to fit a screen group's padded rectangle", () => {
+ const screens = [makeScreen({ id: "s1", x: 100, y: 100 })];
+ const items = {
+ screens,
+ connections: [],
+ stickyNotes: [],
+ screenGroups: [makeGroup({ id: "g1", screenIds: ["s1"] })],
+ };
+ const b = computeExportBounds(items, 0);
+ // Group adds 30px padding on left/right/bottom and 30+20px on top.
+ expect(b.minX).toBe(70); // 100 - 30
+ expect(b.minY).toBe(50); // 100 - 30 - 20
+ });
+});
+
+describe("buildCanvasSvg", () => {
+ it("returns null when bounds is missing", () => {
+ expect(
+ buildCanvasSvg({
+ screens: [],
+ connections: [],
+ stickyNotes: [],
+ screenGroups: [],
+ bounds: null,
+ }),
+ ).toBeNull();
+ });
+
+ it("emits a self-contained SVG with viewBox covering the bounds", () => {
+ const items = {
+ screens: [makeScreen({ id: "s1", x: 0, y: 0 })],
+ connections: [],
+ stickyNotes: [],
+ screenGroups: [],
+ };
+ const bounds = computeExportBounds(items, 40);
+ const svg = buildCanvasSvg({ ...items, bounds });
+ expect(svg).toContain('xmlns="http://www.w3.org/2000/svg"');
+ expect(svg).toContain(`viewBox="${bounds.minX} ${bounds.minY} ${bounds.width} ${bounds.height}"`);
+ // Background rect is present
+ expect(svg).toContain(`fill="#21252b"`);
+ // Screen card + name appear
+ expect(svg).toContain(">Screen 1<");
+ });
+
+ it("renders connections as bezier paths with arrowheads when both endpoints exist", () => {
+ const screens = [makeScreen({ id: "s1", x: 0, y: 0 }), makeScreen({ id: "s2", x: 400, y: 0 })];
+ const connections = [makeConnection({ id: "c1", fromScreenId: "s1", toScreenId: "s2", label: "Tap" })];
+ const items = { screens, connections, stickyNotes: [], screenGroups: [] };
+ const bounds = computeExportBounds(items, 40);
+ const svg = buildCanvasSvg({ ...items, bounds });
+ expect(svg).toContain('marker-end="url(#d-arrow)"');
+ expect(svg).toContain('Tap<");
+ });
+
+ it("uses the api-success color when the connection path is api-success", () => {
+ const screens = [makeScreen({ id: "s1" }), makeScreen({ id: "s2", x: 400 })];
+ const connections = [
+ makeConnection({ id: "c1", fromScreenId: "s1", toScreenId: "s2", connectionPath: "api-success" }),
+ ];
+ const items = { screens, connections, stickyNotes: [], screenGroups: [] };
+ const svg = buildCanvasSvg({ ...items, bounds: computeExportBounds(items, 40) });
+ expect(svg).toContain('marker-end="url(#d-arrow-success)"');
+ expect(svg).toContain('stroke="#98c379"');
+ });
+
+ it("renders sticky notes with the correct color palette", () => {
+ const items = {
+ screens: [],
+ connections: [],
+ stickyNotes: [makeSticky({ id: "n1", color: "blue", content: "Hello\nWorld" })],
+ screenGroups: [],
+ };
+ const svg = buildCanvasSvg({ ...items, bounds: computeExportBounds(items, 0) });
+ expect(svg).toContain('fill="#001a2d"'); // blue background
+ expect(svg).toContain(">Hello<");
+ expect(svg).toContain(">World<");
+ });
+
+ it("escapes XML-special characters in screen names and labels", () => {
+ const screens = [makeScreen({ id: "s1", name: 'A & B ' }), makeScreen({ id: "s2", x: 400 })];
+ const connections = [makeConnection({ id: "c1", fromScreenId: "s1", toScreenId: "s2", label: '' })];
+ const items = { screens, connections, stickyNotes: [], screenGroups: [] };
+ const svg = buildCanvasSvg({ ...items, bounds: computeExportBounds(items, 40) });
+ expect(svg).toContain("A & B <C>");
+ expect(svg).toContain("<click>");
+ expect(svg).not.toMatch(/A & B/);
+ });
+
+ it("emits a screen group rectangle with its name label", () => {
+ const screens = [makeScreen({ id: "s1" }), makeScreen({ id: "s2", x: 400 })];
+ const items = {
+ screens,
+ connections: [],
+ stickyNotes: [],
+ screenGroups: [makeGroup({ id: "g1", name: "Auth Flow", screenIds: ["s1", "s2"] })],
+ };
+ const svg = buildCanvasSvg({ ...items, bounds: computeExportBounds(items, 40) });
+ expect(svg).toContain("Auth Flow");
+ expect(svg).toContain('stroke-dasharray="6 4"');
+ });
+});
+
+describe("screenContentToHref", () => {
+ it("returns the imageData URL directly when present", () => {
+ const screen = makeScreen({ imageData: "data:image/png;base64,AAA" });
+ expect(screenContentToHref(screen)).toBe("data:image/png;base64,AAA");
+ });
+
+ it("encodes svgContent into a base64 data URL", () => {
+ const screen = makeScreen({ svgContent: "" });
+ const href = screenContentToHref(screen);
+ expect(href).toMatch(/^data:image\/svg\+xml;base64,/);
+ });
+
+ it("converts a wireframe into a base64 SVG data URL", () => {
+ const screen = makeScreen({
+ wireframe: { components: [], viewport: { width: 100, height: 200 } },
+ });
+ const href = screenContentToHref(screen);
+ expect(href).toMatch(/^data:image\/svg\+xml;base64,/);
+ });
+
+ it("returns null for a blank screen", () => {
+ expect(screenContentToHref(makeScreen())).toBeNull();
+ });
+});