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();
+ });
+});