web based infinite canvas
at main 308 lines 10 kB view raw
1import { shapeBounds } from "./geom"; 2import type { Box2 } from "./math"; 3import { Box2 as Box2Ops } from "./math"; 4import type { ArrowShape, EllipseShape, LineShape, MarkdownShape, RectShape, ShapeRecord, TextShape } from "./model"; 5import type { EditorState } from "./reactivity"; 6import { getSelectedShapes, getShapesOnCurrentPage } from "./reactivity"; 7 8export type ExportOptions = { 9 /** 10 * Export only selected shapes (default: false - export all) 11 */ 12 selectedOnly?: boolean; 13 14 /** 15 * Include camera transform in the SVG (default: false - export in world coordinates) 16 * 17 * When false, shapes are exported in their natural world coordinates. 18 * When true, the camera transform is baked into the SVG viewBox. 19 */ 20 includeCamera?: boolean; 21}; 22 23/** 24 * Export the current viewport as a PNG blob. 25 * 26 * This captures whatever is currently visible on the canvas. 27 * 28 * @param canvas - The canvas element to export 29 * @returns Promise resolving to PNG blob 30 */ 31export async function exportViewportToPNG(canvas: HTMLCanvasElement): Promise<Blob> { 32 return new Promise((resolve, reject) => { 33 canvas.toBlob((blob) => { 34 if (blob) { 35 resolve(blob); 36 } else { 37 reject(new Error("Failed to export canvas to PNG")); 38 } 39 }, "image/png"); 40 }); 41} 42 43/** 44 * Export selected shapes as a PNG blob. 45 * 46 * This creates a temporary canvas, renders only the selected shapes 47 * with their bounds, and exports it as PNG. 48 * 49 * @param state - Editor state containing shapes 50 * @param renderFn - Function to render shapes to a canvas context 51 * @returns Promise resolving to PNG blob, or null if no selection 52 */ 53export async function exportSelectionToPNG( 54 state: EditorState, 55 renderFunction: (context: CanvasRenderingContext2D, shapes: ShapeRecord[], bounds: Box2) => void, 56): Promise<Blob | null> { 57 const shapes = getSelectedShapes(state); 58 if (shapes.length === 0) { 59 return null; 60 } 61 62 const bounds = combineBounds(shapes.map((s) => shapeBounds(s))); 63 if (!bounds) { 64 return null; 65 } 66 67 const padding = 20; 68 const width = Box2Ops.width(bounds) + padding * 2; 69 const height = Box2Ops.height(bounds) + padding * 2; 70 71 const canvas = document.createElement("canvas"); 72 canvas.width = width; 73 canvas.height = height; 74 75 const context = canvas.getContext("2d"); 76 if (!context) { 77 throw new Error("Failed to get 2D context"); 78 } 79 80 context.fillStyle = "white"; 81 context.fillRect(0, 0, width, height); 82 83 context.save(); 84 context.translate(-bounds.min.x + padding, -bounds.min.y + padding); 85 86 renderFunction(context, shapes, bounds); 87 88 context.restore(); 89 90 return new Promise((resolve, reject) => { 91 canvas.toBlob((blob) => { 92 if (blob) { 93 resolve(blob); 94 } else { 95 reject(new Error("Failed to export selection to PNG")); 96 } 97 }, "image/png"); 98 }); 99} 100 101/** 102 * Export shapes to SVG format. 103 * 104 * By default, shapes are exported in world coordinates (camera transform is NOT applied). 105 * Set `includeCamera: true` to bake the camera transform into the SVG viewBox. 106 * 107 * @param state - Editor state containing shapes and camera 108 * @param options - Export options 109 * @returns SVG string 110 */ 111export function exportToSVG(state: EditorState, options: ExportOptions = {}): string { 112 const shapes = options.selectedOnly ? getSelectedShapes(state) : getShapesOnCurrentPage(state); 113 114 if (shapes.length === 0) { 115 return "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"></svg>"; 116 } 117 118 const bounds = combineBounds(shapes.map((s) => shapeBounds(s))); 119 if (!bounds) { 120 return "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"></svg>"; 121 } 122 123 const padding = 20; 124 const width = Box2Ops.width(bounds) + padding * 2; 125 const height = Box2Ops.height(bounds) + padding * 2; 126 const offsetX = bounds.min.x - padding; 127 const offsetY = bounds.min.y - padding; 128 129 const elements: string[] = [`<rect x="${offsetX}" y="${offsetY}" width="${width}" height="${height}" fill="white"/>`]; 130 131 for (const shape of shapes) { 132 const svg = shapeToSVG(shape, state); 133 if (svg) { 134 elements.push(svg); 135 } 136 } 137 138 const viewBox = `${offsetX} ${offsetY} ${width} ${height}`; 139 140 return [ 141 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="${width}" height="${height}">`, 142 ...elements, 143 `</svg>`, 144 ].join("\n"); 145} 146 147/** 148 * Convert a single shape to SVG markup. 149 */ 150function shapeToSVG(shape: ShapeRecord, state: EditorState): string | null { 151 const transform = `translate(${shape.x},${shape.y})${ 152 shape.rot === 0 ? "" : ` rotate(${(shape.rot * 180) / Math.PI})` 153 }`; 154 155 switch (shape.type) { 156 case "rect": { 157 return rectToSVG(shape, transform); 158 } 159 case "ellipse": { 160 return ellipseToSVG(shape, transform); 161 } 162 case "line": { 163 return lineToSVG(shape, transform); 164 } 165 case "arrow": { 166 return arrowToSVG(shape, transform, state); 167 } 168 case "text": { 169 return textToSVG(shape, transform); 170 } 171 case "markdown": { 172 return markdownToSVG(shape, transform); 173 } 174 default: { 175 return null; 176 } 177 } 178} 179 180function rectToSVG(shape: RectShape, transform: string): string { 181 const { w, h, fill, stroke, radius } = shape.props; 182 const fillAttribute = fill ? `fill="${escapeXML(fill)}"` : "fill=\"none\""; 183 const strokeAttribute = stroke ? `stroke="${escapeXML(stroke)}" stroke-width="2"` : ""; 184 const radiusAttribute = radius > 0 ? `rx="${radius}" ry="${radius}"` : ""; 185 186 return `<rect transform="${transform}" width="${w}" height="${h}" ${fillAttribute} ${strokeAttribute} ${radiusAttribute}/>`; 187} 188 189function ellipseToSVG(shape: EllipseShape, transform: string): string { 190 const { w, h, fill, stroke } = shape.props; 191 const cx = w / 2; 192 const cy = h / 2; 193 const rx = w / 2; 194 const ry = h / 2; 195 const fillAttribute = fill ? `fill="${escapeXML(fill)}"` : "fill=\"none\""; 196 const strokeAttribute = stroke ? `stroke="${escapeXML(stroke)}" stroke-width="2"` : ""; 197 198 return `<ellipse transform="${transform}" cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" ${fillAttribute} ${strokeAttribute}/>`; 199} 200 201function lineToSVG(shape: LineShape, transform: string): string { 202 const { a, b, stroke, width } = shape.props; 203 204 return `<line transform="${transform}" x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="${ 205 escapeXML(stroke) 206 }" stroke-width="${width}"/>`; 207} 208 209function arrowToSVG(shape: ArrowShape, transform: string, _state: EditorState): string { 210 const points = shape.props.points; 211 if (!points || points.length < 2) { 212 return `<g transform="${transform}"></g>`; 213 } 214 215 const startPoint = points[0]; 216 const endPoint = points[points.length - 1]; 217 const strokeColor = shape.props.style.stroke; 218 const strokeWidth = shape.props.style.width; 219 220 const angle = Math.atan2(endPoint.y - startPoint.y, endPoint.x - startPoint.x); 221 const arrowLength = 15; 222 const arrowAngle = Math.PI / 6; 223 224 const arrowPoint1 = { 225 x: endPoint.x - arrowLength * Math.cos(angle - arrowAngle), 226 y: endPoint.y - arrowLength * Math.sin(angle - arrowAngle), 227 }; 228 229 const arrowPoint2 = { 230 x: endPoint.x - arrowLength * Math.cos(angle + arrowAngle), 231 y: endPoint.y - arrowLength * Math.sin(angle + arrowAngle), 232 }; 233 234 const strokeAttribute = `stroke="${escapeXML(strokeColor)}" stroke-width="${strokeWidth}"`; 235 236 return [ 237 `<g transform="${transform}">`, 238 ` <line x1="${startPoint.x}" y1="${startPoint.y}" x2="${endPoint.x}" y2="${endPoint.y}" ${strokeAttribute}/>`, 239 ` <line x1="${endPoint.x}" y1="${endPoint.y}" x2="${arrowPoint1.x}" y2="${arrowPoint1.y}" ${strokeAttribute}/>`, 240 ` <line x1="${endPoint.x}" y1="${endPoint.y}" x2="${arrowPoint2.x}" y2="${arrowPoint2.y}" ${strokeAttribute}/>`, 241 `</g>`, 242 ].join("\n"); 243} 244 245function textToSVG(shape: TextShape, transform: string): string { 246 const { text, fontSize, fontFamily, color } = shape.props; 247 248 return `<text transform="${transform}" font-size="${fontSize}" font-family="${escapeXML(fontFamily)}" fill="${ 249 escapeXML(color) 250 }">${escapeXML(text)}</text>`; 251} 252 253/** 254 * Export markdown shape as SVG foreignObject 255 * 256 * Uses foreignObject to embed HTML for markdown rendering. 257 * 258 * For better compatibility, the markdown is exported as plain text with basic formatting preserved. 259 */ 260function markdownToSVG(shape: MarkdownShape, transform: string): string { 261 const { md, w, h, fontSize, fontFamily, color, bg, border } = shape.props; 262 const width = w; 263 const height = h ?? fontSize * 10; 264 265 const bgStyle = bg ? `background: ${escapeXML(bg)};` : "background: white;"; 266 const borderStyle = border ? `border: 1px solid ${escapeXML(border)};` : ""; 267 268 const escapedMarkdown = escapeXML(md); 269 270 return [ 271 `<foreignObject transform="${transform}" width="${width}" height="${height}">`, 272 ` <div xmlns="http://www.w3.org/1999/xhtml" style="${bgStyle}${borderStyle} padding: 8px; font-size: ${fontSize}px; font-family: ${ 273 escapeXML(fontFamily) 274 }; color: ${ 275 escapeXML(color) 276 }; width: 100%; height: 100%; overflow: auto; white-space: pre-wrap; box-sizing: border-box;">`, 277 ` ${escapedMarkdown}`, 278 ` </div>`, 279 `</foreignObject>`, 280 ].join("\n"); 281} 282 283/** 284 * Escape special XML characters in strings. 285 */ 286function escapeXML(string_: string): string { 287 return string_.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;") 288 .replaceAll("'", "&apos;"); 289} 290 291/** 292 * Combine multiple bounding boxes into a single bounding box. 293 */ 294function combineBounds(boxes: Box2[]): Box2 | null { 295 if (boxes.length === 0) { 296 return null; 297 } 298 299 let combined = Box2Ops.clone(boxes[0]); 300 for (let index = 1; index < boxes.length; index++) { 301 const box = boxes[index]; 302 combined = { 303 min: { x: Math.min(combined.min.x, box.min.x), y: Math.min(combined.min.y, box.min.y) }, 304 max: { x: Math.max(combined.max.x, box.max.x), y: Math.max(combined.max.y, box.max.y) }, 305 }; 306 } 307 return combined; 308}