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( + ``, + ); + parts.push( + `` + + `` + + `` + + `` + + `` + + ``, + ); + parts.push( + ``, + ); + + // 1. Screen-group background rectangles (drawn first so they sit behind screens). + for (const g of screenGroups) { + const r = groupRect(g, screens); + if (!r) continue; + const fill = g.color || "rgba(97,175,239,0.08)"; + const stroke = "rgba(97,175,239,0.4)"; + parts.push( + ``, + ); + if (g.name) { + parts.push( + `${escapeXml(g.name)}`, + ); + } + } + + // 2. Screen cards (header + image content). + for (const s of screens) { + const { w: sw, h: sh } = screenSize(s); + const imgH = s.imageHeight || DEFAULT_IMAGE_HEIGHT; + parts.push( + ``, + ); + parts.push( + `${escapeXml(s.name || "Untitled")}`, + ); + const imgY = s.y + HEADER_HEIGHT; + const href = screenContentToHref(s); + if (href) { + parts.push( + ``, + ); + } else { + parts.push(``); + } + if (Array.isArray(s.hotspots)) { + 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; + parts.push( + ``, + ); + } + } + } + + // 3. Connections (bezier curves with arrowheads and labels). + for (const conn of connections) { + const pts = computeConnectionPoints(conn, screens); + if (!pts) continue; + const { fromX, fromY, toX, toY } = pts; + const stroke = connectionColor(conn); + let marker; + if (stroke === "#98c379") marker = "url(#d-arrow-success)"; + else if (stroke === "#e06c75") marker = "url(#d-arrow-error)"; + else if (stroke === "#d19a66") marker = "url(#d-arrow-condition)"; + else marker = "url(#d-arrow)"; + const d = bezierPathD(fromX, fromY, toX, toY); + parts.push( + ``, + ); + parts.push( + ``, + ); + const label = conn.condition || conn.label; + if (label) { + const lx = (fromX + toX) / 2; + const ly = (fromY + toY) / 2 - 10; + const labelColor = conn.condition ? "#d19a66" : "#8cc5f6"; + parts.push( + `${escapeXml(label)}`, + ); + } + } + + // 4. Sticky notes (drawn last so they sit on top). + for (const n of stickyNotes) { + const nw = n.width || DEFAULT_SCREEN_WIDTH; + const colors = STICKY_COLORS[n.color] || STICKY_COLORS.yellow; + parts.push( + ``, + ); + if (n.content) { + 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) { + parts.push( + `${escapeXml(line)}`, + ); + } + }); + } + } + + 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(); + }); +});