web based infinite canvas
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """)
288 .replaceAll("'", "'");
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}