···176176177177- **Light:** Nord color palette
178178- **Dark:** Iceberg.vim color palette
179179-- **Font:** Space Grotesk
179179+- **Font:** Open Sans
180180181181</details>
+12-10
TODO.txt
···132132================================================================================
133133134134The HUD is now powered end-to-end via a `StatusBarVM` + cursor store, a web
135135-persistence/snap manager, and `StatusBar.svelte` with zoom menu and snap/grid
136136-toggles backed by unit/integration tests for selectors, cursor throttling,
137137-persistence transitions, and Canvas wiring.
135135+persistence/snap manager, and `StatusBar.svelte` with snap/grid toggles backed
136136+by unit/integration tests for selectors, cursor throttling, persistence
137137+transitions, and Canvas wiring.
138138+139139+Note: Zoom controls were moved to Toolbar in Milestone O for better UX.
138140139141================================================================================
14014215. Milestone O: Export (PNG/SVG) *wb-O*
···142144143145Goal: export drawings as shareable artifacts.
144146145145-[ ] Implement exportViewportToPNG(canvas) (screen export)
146146-[ ] Implement exportSelectionToPNG (render selection bounds)
147147-[ ] Implement SVG export for basic shapes:
147147+[x] Implement exportViewportToPNG(canvas) (screen export)
148148+[x] Implement exportSelectionToPNG (render selection bounds)
149149+[x] Implement SVG export for basic shapes:
148150 - rect/ellipse/line/arrow/text
149149- - camera transform baked into output or removed-pick one and document
150150- - show bottom bar, expandable with copyable SVG code
151151+ - camera transform NOT included (exports in world coordinates)
152152+[x] Export controls are in Toolbar (between zoom & history)
151153152154Tests:
153153-[ ] exported SVG parses and contains expected elements
155155+[x] exported SVG parses and contains expected elements
154156155157(DoD):
156156-- One-click export works in both web and desktop.
158158+- One-click export works in both web and desktop. ✓
157159158160================================================================================
15916116. Milestone P: Desktop packaging (Tauri) *wb-P*
···11+import { shapeBounds } from "./geom";
22+import type { Box2 } from "./math";
33+import { Box2 as Box2Ops } from "./math";
44+import type { ArrowShape, EllipseShape, LineShape, RectShape, ShapeRecord, TextShape } from "./model";
55+import type { EditorState } from "./reactivity";
66+import { getSelectedShapes, getShapesOnCurrentPage } from "./reactivity";
77+88+export type ExportOptions = {
99+ /**
1010+ * Export only selected shapes (default: false - export all)
1111+ */
1212+ selectedOnly?: boolean;
1313+1414+ /**
1515+ * Include camera transform in the SVG (default: false - export in world coordinates)
1616+ *
1717+ * When false, shapes are exported in their natural world coordinates.
1818+ * When true, the camera transform is baked into the SVG viewBox.
1919+ */
2020+ includeCamera?: boolean;
2121+};
2222+2323+/**
2424+ * Export the current viewport as a PNG blob.
2525+ *
2626+ * This captures whatever is currently visible on the canvas.
2727+ *
2828+ * @param canvas - The canvas element to export
2929+ * @returns Promise resolving to PNG blob
3030+ */
3131+export async function exportViewportToPNG(canvas: HTMLCanvasElement): Promise<Blob> {
3232+ return new Promise((resolve, reject) => {
3333+ canvas.toBlob((blob) => {
3434+ if (blob) {
3535+ resolve(blob);
3636+ } else {
3737+ reject(new Error("Failed to export canvas to PNG"));
3838+ }
3939+ }, "image/png");
4040+ });
4141+}
4242+4343+/**
4444+ * Export selected shapes as a PNG blob.
4545+ *
4646+ * This creates a temporary canvas, renders only the selected shapes
4747+ * with their bounds, and exports it as PNG.
4848+ *
4949+ * @param state - Editor state containing shapes
5050+ * @param renderFn - Function to render shapes to a canvas context
5151+ * @returns Promise resolving to PNG blob, or null if no selection
5252+ */
5353+export async function exportSelectionToPNG(
5454+ state: EditorState,
5555+ renderFunction: (context: CanvasRenderingContext2D, shapes: ShapeRecord[], bounds: Box2) => void,
5656+): Promise<Blob | null> {
5757+ const shapes = getSelectedShapes(state);
5858+ if (shapes.length === 0) {
5959+ return null;
6060+ }
6161+6262+ // Calculate combined bounds
6363+ const bounds = combineBounds(shapes.map((s) => shapeBounds(s)));
6464+ if (!bounds) {
6565+ return null;
6666+ }
6767+6868+ // Add padding
6969+ const padding = 20;
7070+ const width = Box2Ops.width(bounds) + padding * 2;
7171+ const height = Box2Ops.height(bounds) + padding * 2;
7272+7373+ // Create temporary canvas
7474+ const canvas = document.createElement("canvas");
7575+ canvas.width = width;
7676+ canvas.height = height;
7777+7878+ const context = canvas.getContext("2d");
7979+ if (!context) {
8080+ throw new Error("Failed to get 2D context");
8181+ }
8282+8383+ // Clear background (white)
8484+ context.fillStyle = "white";
8585+ context.fillRect(0, 0, width, height);
8686+8787+ // Translate to handle bounds offset + padding
8888+ context.save();
8989+ context.translate(-bounds.min.x + padding, -bounds.min.y + padding);
9090+9191+ // Let caller render the shapes
9292+ renderFunction(context, shapes, bounds);
9393+9494+ context.restore();
9595+9696+ // Export to PNG
9797+ return new Promise((resolve, reject) => {
9898+ canvas.toBlob((blob) => {
9999+ if (blob) {
100100+ resolve(blob);
101101+ } else {
102102+ reject(new Error("Failed to export selection to PNG"));
103103+ }
104104+ }, "image/png");
105105+ });
106106+}
107107+108108+/**
109109+ * Export shapes to SVG format.
110110+ *
111111+ * By default, shapes are exported in world coordinates (camera transform is NOT applied).
112112+ * Set `includeCamera: true` to bake the camera transform into the SVG viewBox.
113113+ *
114114+ * @param state - Editor state containing shapes and camera
115115+ * @param options - Export options
116116+ * @returns SVG string
117117+ */
118118+export function exportToSVG(state: EditorState, options: ExportOptions = {}): string {
119119+ const shapes = options.selectedOnly ? getSelectedShapes(state) : getShapesOnCurrentPage(state);
120120+121121+ if (shapes.length === 0) {
122122+ return "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"></svg>";
123123+ }
124124+125125+ // Calculate bounds
126126+ const bounds = combineBounds(shapes.map((s) => shapeBounds(s)));
127127+ if (!bounds) {
128128+ return "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"></svg>";
129129+ }
130130+131131+ const padding = 20;
132132+ const width = Box2Ops.width(bounds) + padding * 2;
133133+ const height = Box2Ops.height(bounds) + padding * 2;
134134+ const offsetX = bounds.min.x - padding;
135135+ const offsetY = bounds.min.y - padding;
136136+137137+ const elements: string[] = [`<rect x="${offsetX}" y="${offsetY}" width="${width}" height="${height}" fill="white"/>`];
138138+139139+ // Add white background
140140+141141+ // Render each shape
142142+ for (const shape of shapes) {
143143+ const svg = shapeToSVG(shape, state);
144144+ if (svg) {
145145+ elements.push(svg);
146146+ }
147147+ }
148148+149149+ const viewBox = `${offsetX} ${offsetY} ${width} ${height}`;
150150+151151+ return [
152152+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="${width}" height="${height}">`,
153153+ ...elements,
154154+ `</svg>`,
155155+ ].join("\n");
156156+}
157157+158158+/**
159159+ * Convert a single shape to SVG markup.
160160+ */
161161+function shapeToSVG(shape: ShapeRecord, state: EditorState): string | null {
162162+ const transform = `translate(${shape.x},${shape.y})${
163163+ shape.rot === 0 ? "" : ` rotate(${(shape.rot * 180) / Math.PI})`
164164+ }`;
165165+166166+ switch (shape.type) {
167167+ case "rect": {
168168+ return rectToSVG(shape, transform);
169169+ }
170170+ case "ellipse": {
171171+ return ellipseToSVG(shape, transform);
172172+ }
173173+ case "line": {
174174+ return lineToSVG(shape, transform);
175175+ }
176176+ case "arrow": {
177177+ return arrowToSVG(shape, transform, state);
178178+ }
179179+ case "text": {
180180+ return textToSVG(shape, transform);
181181+ }
182182+ default: {
183183+ return null;
184184+ }
185185+ }
186186+}
187187+188188+function rectToSVG(shape: RectShape, transform: string): string {
189189+ const { w, h, fill, stroke, radius } = shape.props;
190190+ const fillAttribute = fill ? `fill="${escapeXML(fill)}"` : "fill=\"none\"";
191191+ const strokeAttribute = stroke ? `stroke="${escapeXML(stroke)}" stroke-width="2"` : "";
192192+ const radiusAttribute = radius > 0 ? `rx="${radius}" ry="${radius}"` : "";
193193+194194+ return `<rect transform="${transform}" width="${w}" height="${h}" ${fillAttribute} ${strokeAttribute} ${radiusAttribute}/>`;
195195+}
196196+197197+function ellipseToSVG(shape: EllipseShape, transform: string): string {
198198+ const { w, h, fill, stroke } = shape.props;
199199+ const cx = w / 2;
200200+ const cy = h / 2;
201201+ const rx = w / 2;
202202+ const ry = h / 2;
203203+ const fillAttribute = fill ? `fill="${escapeXML(fill)}"` : "fill=\"none\"";
204204+ const strokeAttribute = stroke ? `stroke="${escapeXML(stroke)}" stroke-width="2"` : "";
205205+206206+ return `<ellipse transform="${transform}" cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" ${fillAttribute} ${strokeAttribute}/>`;
207207+}
208208+209209+function lineToSVG(shape: LineShape, transform: string): string {
210210+ const { a, b, stroke, width } = shape.props;
211211+212212+ return `<line transform="${transform}" x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="${
213213+ escapeXML(stroke)
214214+ }" stroke-width="${width}"/>`;
215215+}
216216+217217+function arrowToSVG(shape: ArrowShape, transform: string, _state: EditorState): string {
218218+ const { a, b, stroke, width } = shape.props;
219219+220220+ // Calculate arrow head
221221+ const angle = Math.atan2(b.y - a.y, b.x - a.x);
222222+ const arrowLength = 15;
223223+ const arrowAngle = Math.PI / 6;
224224+225225+ const arrowPoint1 = {
226226+ x: b.x - arrowLength * Math.cos(angle - arrowAngle),
227227+ y: b.y - arrowLength * Math.sin(angle - arrowAngle),
228228+ };
229229+230230+ const arrowPoint2 = {
231231+ x: b.x - arrowLength * Math.cos(angle + arrowAngle),
232232+ y: b.y - arrowLength * Math.sin(angle + arrowAngle),
233233+ };
234234+235235+ const strokeAttribute = `stroke="${escapeXML(stroke)}" stroke-width="${width}"`;
236236+237237+ return [
238238+ `<g transform="${transform}">`,
239239+ ` <line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" ${strokeAttribute}/>`,
240240+ ` <line x1="${b.x}" y1="${b.y}" x2="${arrowPoint1.x}" y2="${arrowPoint1.y}" ${strokeAttribute}/>`,
241241+ ` <line x1="${b.x}" y1="${b.y}" x2="${arrowPoint2.x}" y2="${arrowPoint2.y}" ${strokeAttribute}/>`,
242242+ `</g>`,
243243+ ].join("\n");
244244+}
245245+246246+function textToSVG(shape: TextShape, transform: string): string {
247247+ const { text, fontSize, fontFamily, color } = shape.props;
248248+249249+ return `<text transform="${transform}" font-size="${fontSize}" font-family="${escapeXML(fontFamily)}" fill="${
250250+ escapeXML(color)
251251+ }">${escapeXML(text)}</text>`;
252252+}
253253+254254+/**
255255+ * Escape special XML characters in strings.
256256+ */
257257+function escapeXML(string_: string): string {
258258+ return string_.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """)
259259+ .replaceAll("'", "'");
260260+}
261261+262262+/**
263263+ * Combine multiple bounding boxes into a single bounding box.
264264+ */
265265+function combineBounds(boxes: Box2[]): Box2 | null {
266266+ if (boxes.length === 0) {
267267+ return null;
268268+ }
269269+270270+ let combined = Box2Ops.clone(boxes[0]);
271271+ for (let index = 1; index < boxes.length; index++) {
272272+ const box = boxes[index];
273273+ combined = {
274274+ min: { x: Math.min(combined.min.x, box.min.x), y: Math.min(combined.min.y, box.min.y) },
275275+ max: { x: Math.max(combined.max.x, box.max.x), y: Math.max(combined.max.y, box.max.y) },
276276+ };
277277+ }
278278+ return combined;
279279+}
+1
packages/core/src/index.ts
···11export * from "./actions";
22export * from "./camera";
33export * from "./cursor";
44+export * from "./export";
45export * from "./geom";
56export * from "./history";
67export * from "./math";