web based infinite canvas

feat: export to svg/png

* move zoom controls to Toolbar

+786 -244
+1 -1
README.md
··· 176 176 177 177 - **Light:** Nord color palette 178 178 - **Dark:** Iceberg.vim color palette 179 - - **Font:** Space Grotesk 179 + - **Font:** Open Sans 180 180 181 181 </details>
+12 -10
TODO.txt
··· 132 132 ================================================================================ 133 133 134 134 The HUD is now powered end-to-end via a `StatusBarVM` + cursor store, a web 135 - persistence/snap manager, and `StatusBar.svelte` with zoom menu and snap/grid 136 - toggles backed by unit/integration tests for selectors, cursor throttling, 137 - persistence transitions, and Canvas wiring. 135 + persistence/snap manager, and `StatusBar.svelte` with snap/grid toggles backed 136 + by unit/integration tests for selectors, cursor throttling, persistence 137 + transitions, and Canvas wiring. 138 + 139 + Note: Zoom controls were moved to Toolbar in Milestone O for better UX. 138 140 139 141 ================================================================================ 140 142 15. Milestone O: Export (PNG/SVG) *wb-O* ··· 142 144 143 145 Goal: export drawings as shareable artifacts. 144 146 145 - [ ] Implement exportViewportToPNG(canvas) (screen export) 146 - [ ] Implement exportSelectionToPNG (render selection bounds) 147 - [ ] Implement SVG export for basic shapes: 147 + [x] Implement exportViewportToPNG(canvas) (screen export) 148 + [x] Implement exportSelectionToPNG (render selection bounds) 149 + [x] Implement SVG export for basic shapes: 148 150 - rect/ellipse/line/arrow/text 149 - - camera transform baked into output or removed-pick one and document 150 - - show bottom bar, expandable with copyable SVG code 151 + - camera transform NOT included (exports in world coordinates) 152 + [x] Export controls are in Toolbar (between zoom & history) 151 153 152 154 Tests: 153 - [ ] exported SVG parses and contains expected elements 155 + [x] exported SVG parses and contains expected elements 154 156 155 157 (DoD): 156 - - One-click export works in both web and desktop. 158 + - One-click export works in both web and desktop. ✓ 157 159 158 160 ================================================================================ 159 161 16. Milestone P: Desktop packaging (Tauri) *wb-P*
+2 -33
apps/web/src/app.css
··· 1 - @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap'); 1 + @import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300..700&display=swap'); 2 2 3 - /* Light Theme - Nord */ 4 3 :root { 5 - /* Polar Night - backgrounds */ 6 4 --bg-primary: #eceff4; 7 5 --bg-secondary: #e5e9f0; 8 6 --bg-tertiary: #d8dee9; 9 7 10 - /* Snow Storm - foreground/text */ 11 8 --fg-primary: #2e3440; 12 9 --fg-secondary: #3b4252; 13 10 --fg-tertiary: #434c5e; 14 11 --fg-muted: #4c566a; 15 12 16 - /* Frost - accent colors */ 17 13 --accent-cyan: #8fbcbb; 18 14 --accent-blue-bright: #88c0d0; 19 15 --accent-blue: #81a1c1; 20 16 --accent-blue-dark: #5e81ac; 21 17 22 - /* Aurora - semantic colors */ 23 18 --color-error: #bf616a; 24 19 --color-warning: #d08770; 25 20 --color-info: #ebcb8b; 26 21 --color-success: #a3be8c; 27 22 --color-purple: #b48ead; 28 23 29 - /* Semantic UI colors */ 30 24 --surface: var(--bg-primary); 31 25 --surface-elevated: var(--bg-secondary); 32 26 --surface-overlay: var(--bg-tertiary); ··· 38 32 --accent-hover: var(--accent-blue-dark); 39 33 } 40 34 41 - /* Dark Theme - Iceberg */ 42 35 @media (prefers-color-scheme: dark) { 43 36 :root { 44 - /* Backgrounds */ 45 37 --bg-primary: #161821; 46 38 --bg-secondary: #1e2132; 47 39 --bg-tertiary: #272c42; 48 40 49 - /* Foreground/text */ 50 41 --fg-primary: #c6c8d1; 51 42 --fg-secondary: #89b8c2; 52 43 --fg-tertiary: #84a0c6; 53 44 --fg-muted: #6b7089; 54 45 55 - /* Accents */ 56 46 --accent-purple: #a093c7; 57 47 --accent-cyan: #89b8c2; 58 48 --accent-blue: #84a0c6; 59 49 --accent-search: #e4aa80; 60 50 61 - /* Semantic colors */ 62 51 --color-error: #e27878; 63 52 --color-warning: #e2a478; 64 53 --color-success: #b4be82; 65 54 --color-info: #e4aa80; 66 55 --color-purple: #a093c7; 67 56 68 - /* UI elements */ 69 57 --line-numbers: #444b71; 70 58 --selection: #272c42; 71 59 72 - /* Semantic UI colors */ 73 60 --surface: var(--bg-primary); 74 61 --surface-elevated: var(--bg-secondary); 75 62 --surface-overlay: var(--bg-tertiary); ··· 82 69 } 83 70 } 84 71 85 - /* Manual theme override classes */ 86 72 [data-theme='light'] { 87 - /* Polar Night - backgrounds */ 88 73 --bg-primary: #eceff4; 89 74 --bg-secondary: #e5e9f0; 90 75 --bg-tertiary: #d8dee9; 91 76 92 - /* Snow Storm - foreground/text */ 93 77 --fg-primary: #2e3440; 94 78 --fg-secondary: #3b4252; 95 79 --fg-tertiary: #434c5e; 96 80 --fg-muted: #4c566a; 97 81 98 - /* Frost - accent colors */ 99 82 --accent-cyan: #8fbcbb; 100 83 --accent-blue-bright: #88c0d0; 101 84 --accent-blue: #81a1c1; 102 85 --accent-blue-dark: #5e81ac; 103 86 104 - /* Aurora - semantic colors */ 105 87 --color-error: #bf616a; 106 88 --color-warning: #d08770; 107 89 --color-info: #ebcb8b; 108 90 --color-success: #a3be8c; 109 91 --color-purple: #b48ead; 110 92 111 - /* Semantic UI colors */ 112 93 --surface: var(--bg-primary); 113 94 --surface-elevated: var(--bg-secondary); 114 95 --surface-overlay: var(--bg-tertiary); ··· 121 102 } 122 103 123 104 [data-theme='dark'] { 124 - /* Backgrounds */ 125 105 --bg-primary: #161821; 126 106 --bg-secondary: #1e2132; 127 107 --bg-tertiary: #272c42; 128 108 129 - /* Foreground/text */ 130 109 --fg-primary: #c6c8d1; 131 110 --fg-secondary: #89b8c2; 132 111 --fg-tertiary: #84a0c6; 133 112 --fg-muted: #6b7089; 134 113 135 - /* Accents */ 136 114 --accent-purple: #a093c7; 137 115 --accent-cyan: #89b8c2; 138 116 --accent-blue: #84a0c6; 139 117 --accent-search: #e4aa80; 140 118 141 - /* Semantic colors */ 142 119 --color-error: #e27878; 143 120 --color-warning: #e2a478; 144 121 --color-success: #b4be82; 145 122 --color-info: #e4aa80; 146 123 --color-purple: #a093c7; 147 124 148 - /* UI elements */ 149 125 --line-numbers: #444b71; 150 126 --selection: #272c42; 151 127 152 - /* Semantic UI colors */ 153 128 --surface: var(--bg-primary); 154 129 --surface-elevated: var(--bg-secondary); 155 130 --surface-overlay: var(--bg-tertiary); ··· 161 136 --accent-hover: var(--accent-cyan); 162 137 } 163 138 164 - /* Base styles */ 165 139 * { 166 140 margin: 0; 167 141 padding: 0; ··· 169 143 } 170 144 171 145 body { 172 - font-family: 173 - 'Space Grotesk', 174 - -apple-system, 175 - BlinkMacSystemFont, 176 - 'Segoe UI', 177 - sans-serif; 146 + font-family: 'Open Sans', sans-serif; 178 147 background-color: var(--surface); 179 148 color: var(--text); 180 149 line-height: 1.6;
+11 -5
apps/web/src/lib/canvas/Canvas.svelte
··· 234 234 return { ...action, world: snappedWorld }; 235 235 } 236 236 237 - let canvas: HTMLCanvasElement; 237 + let canvas = $state<HTMLCanvasElement>(); 238 238 let renderer: Renderer | null = null; 239 239 let inputAdapter: InputAdapter | null = null; 240 240 ··· 285 285 return store.getState().camera; 286 286 } 287 287 288 - renderer = createRenderer(canvas, store, { snapProvider, cursorProvider, pointerStateProvider }); 288 + renderer = createRenderer(canvas!, store, { snapProvider, cursorProvider, pointerStateProvider }); 289 289 inputAdapter = createInputAdapter({ 290 - canvas, 290 + canvas: canvas!, 291 291 getCamera, 292 292 getViewport, 293 293 onAction: handleAction, ··· 326 326 327 327 <div class="editor"> 328 328 <TitleBar /> 329 - <Toolbar currentTool={currentToolId} onToolChange={handleToolChange} onHistoryClick={handleHistoryClick} /> 329 + <Toolbar 330 + currentTool={currentToolId} 331 + onToolChange={handleToolChange} 332 + onHistoryClick={handleHistoryClick} 333 + {store} 334 + {getViewport} 335 + {canvas} /> 330 336 <canvas bind:this={canvas}></canvas> 331 337 <HistoryViewer {store} bind:open={historyViewerOpen} onClose={handleHistoryClose} /> 332 - <StatusBar {store} cursor={cursorStore} persistence={persistenceStatusStore} snap={snapStore} {getViewport} /> 338 + <StatusBar {store} cursor={cursorStore} persistence={persistenceStatusStore} snap={snapStore} /> 333 339 </div> 334 340 335 341 <style>
+4 -178
apps/web/src/lib/components/StatusBar.svelte
··· 1 1 <script lang="ts"> 2 2 import type { SnapSettings, SnapStore, StatusStore } from '$lib/status'; 3 3 import { 4 - type Box2, 5 4 type CursorState, 6 5 type CursorStore, 7 6 type EditorState, 8 7 type PersistenceStatus, 9 8 type Store, 10 9 EditorState as EditorStateOps, 11 - buildStatusBarVM, 12 - getSelectedShapes, 13 - getShapesOnCurrentPage, 14 - shapeBounds 10 + buildStatusBarVM 15 11 } from 'inkfinite-core'; 16 12 17 - type Viewport = { width: number; height: number }; 18 - const defaultViewport = () => ({ width: 1, height: 1 }); 19 - 20 - type Props = { 21 - store: Store; 22 - cursor: CursorStore; 23 - persistence: StatusStore; 24 - snap: SnapStore; 25 - getViewport?: () => Viewport; 26 - }; 13 + type Props = { store: Store; cursor: CursorStore; persistence: StatusStore; snap: SnapStore }; 27 14 28 - let { store, cursor, persistence, snap, getViewport = defaultViewport }: Props = $props(); 15 + let { store, cursor, persistence, snap }: Props = $props(); 29 16 30 17 let editorSnapshot: EditorState = EditorStateOps.create(); 31 18 let cursorSnapshot: CursorState = { cursorWorld: { x: 0, y: 0 }, lastMoveAt: Date.now() }; 32 19 let persistenceSnapshot: PersistenceStatus = { backend: 'indexeddb', state: 'saved', pendingWrites: 0 }; 33 20 let snapSnapshot = $state<SnapSettings>({ snapEnabled: false, gridEnabled: true, gridSize: 25 }); 34 21 let statusVm = $state(buildStatusBarVM(editorSnapshot, cursorSnapshot, persistenceSnapshot)); 35 - let zoomMenuOpen = $state(false); 36 - let zoomMenuEl = $state<HTMLDivElement | null>(null); 37 - let zoomButtonEl = $state<HTMLButtonElement | null>(null); 38 22 39 23 function updateVm() { 40 24 statusVm = buildStatusBarVM(editorSnapshot, cursorSnapshot, persistenceSnapshot); ··· 80 64 return () => unsubscribe(); 81 65 }); 82 66 83 - $effect(() => { 84 - if (!zoomMenuOpen || typeof document === 'undefined') { 85 - return; 86 - } 87 - const handlePointerDown = (event: PointerEvent) => { 88 - const target = event.target as Node | null; 89 - if (!target) { 90 - return; 91 - } 92 - if (zoomMenuEl?.contains(target) || zoomButtonEl?.contains(target)) { 93 - return; 94 - } 95 - zoomMenuOpen = false; 96 - }; 97 - 98 - document.addEventListener('pointerdown', handlePointerDown); 99 - return () => document.removeEventListener('pointerdown', handlePointerDown); 100 - }); 101 - 102 - function getViewportSize(): Viewport { 103 - return getViewport(); 104 - } 105 - 106 67 function formatCursorCoord(value: number): string { 107 68 return Math.round(value).toString(); 108 69 } ··· 142 103 return 'Saved'; 143 104 } 144 105 145 - function setZoomPercent(percent: number) { 146 - const zoom = percent / 100; 147 - store.setState((state) => ({ ...state, camera: { ...state.camera, zoom } })); 148 - zoomMenuOpen = false; 149 - } 150 - 151 - function zoomToBounds(bounds: Box2) { 152 - const viewport = getViewportSize(); 153 - const width = bounds.max.x - bounds.min.x || 1; 154 - const height = bounds.max.y - bounds.min.y || 1; 155 - const margin = 80; 156 - const scaleX = (viewport.width - margin) / width; 157 - const scaleY = (viewport.height - margin) / height; 158 - const zoom = Math.max(Math.min(scaleX, scaleY), 0.05); 159 - const center = { x: (bounds.min.x + bounds.max.x) / 2, y: (bounds.min.y + bounds.max.y) / 2 }; 160 - store.setState((state) => ({ ...state, camera: { x: center.x, y: center.y, zoom } })); 161 - zoomMenuOpen = false; 162 - } 163 - 164 - function zoomToFit() { 165 - const shapes = getShapesOnCurrentPage(editorSnapshot); 166 - if (shapes.length === 0) { 167 - setZoomPercent(100); 168 - return; 169 - } 170 - const bounds = shapes.reduce<Box2 | null>((acc, shape) => { 171 - const shapeBox = shapeBounds(shape); 172 - if (!acc) { 173 - return shapeBox; 174 - } 175 - return { 176 - min: { x: Math.min(acc.min.x, shapeBox.min.x), y: Math.min(acc.min.y, shapeBox.min.y) }, 177 - max: { x: Math.max(acc.max.x, shapeBox.max.x), y: Math.max(acc.max.y, shapeBox.max.y) } 178 - }; 179 - }, null); 180 - 181 - if (bounds) { 182 - zoomToBounds(bounds); 183 - } 184 - } 185 - 186 - function zoomToSelection() { 187 - const shapes = getSelectedShapes(editorSnapshot); 188 - if (shapes.length === 0) { 189 - zoomToFit(); 190 - return; 191 - } 192 - 193 - const bounds = shapes.reduce<Box2 | null>((acc, shape) => { 194 - const shapeBox = shapeBounds(shape); 195 - if (!acc) { 196 - return shapeBox; 197 - } 198 - return { 199 - min: { x: Math.min(acc.min.x, shapeBox.min.x), y: Math.min(acc.min.y, shapeBox.min.y) }, 200 - max: { x: Math.max(acc.max.x, shapeBox.max.x), y: Math.max(acc.max.y, shapeBox.max.y) } 201 - }; 202 - }, null); 203 - 204 - if (bounds) { 205 - zoomToBounds(bounds); 206 - } 207 - } 208 - 209 - const zoomPresets = [ 210 - { label: '50%', value: 50 }, 211 - { label: '100%', value: 100 }, 212 - { label: '200%', value: 200 } 213 - ]; 214 - 215 106 function handleSnapToggle(event: Event) { 216 107 const target = event.currentTarget as HTMLInputElement; 217 108 snap.update((current) => ({ ...current, snapEnabled: target.checked })); ··· 256 147 </div> 257 148 </div> 258 149 259 - <div class="status-section zoom"> 260 - <span class="label">Zoom</span> 261 - <button class="zoom-button" bind:this={zoomButtonEl} onclick={() => (zoomMenuOpen = !zoomMenuOpen)}> 262 - {statusVm.zoomPct}% 263 - </button> 264 - 265 - {#if zoomMenuOpen} 266 - <div class="zoom-menu" bind:this={zoomMenuEl}> 267 - {#each zoomPresets as preset} 268 - <button onclick={() => setZoomPercent(preset.value)}>{preset.label}</button> 269 - {/each} 270 - <div class="menu-divider"></div> 271 - <button onclick={zoomToFit}>Zoom to fit</button> 272 - <button onclick={zoomToSelection}>Zoom to selection</button> 273 - </div> 274 - {/if} 275 - </div> 276 - 277 150 <div class="status-section persistence"> 278 151 <span class="label">Sync</span> 279 152 <span class="value" class:error={statusVm.persistence.state === 'error'}>{formatPersistenceSummary()}</span> ··· 300 173 position: relative; 301 174 } 302 175 303 - .status-section.snap, 304 - .status-section.zoom { 176 + .status-section.snap { 305 177 align-items: flex-start; 306 178 } 307 179 ··· 341 213 .mode { 342 214 font-size: 12px; 343 215 color: var(--text-muted); 344 - } 345 - 346 - .zoom-button { 347 - border: 1px solid var(--border); 348 - background: var(--surface); 349 - padding: 4px 8px; 350 - border-radius: 4px; 351 - cursor: pointer; 352 - } 353 - 354 - .zoom-button:hover { 355 - background: var(--surface-elevated); 356 - } 357 - 358 - .zoom-menu { 359 - position: absolute; 360 - top: calc(100% + 4px); 361 - left: 0; 362 - background: var(--surface); 363 - border: 1px solid var(--border); 364 - border-radius: 6px; 365 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 366 - padding: 8px; 367 - display: flex; 368 - flex-direction: column; 369 - gap: 4px; 370 - z-index: 10; 371 - } 372 - 373 - .zoom-menu button { 374 - border: none; 375 - background: transparent; 376 - padding: 4px 8px; 377 - border-radius: 4px; 378 - text-align: left; 379 - cursor: pointer; 380 - } 381 - 382 - .zoom-menu button:hover { 383 - background: var(--surface-elevated); 384 - } 385 - 386 - .menu-divider { 387 - height: 1px; 388 - background: var(--border); 389 - margin: 6px 0; 390 216 } 391 217 </style>
+4 -1
apps/web/src/lib/components/TitleBar.svelte
··· 1 1 <script lang="ts"> 2 2 import Dialog from '$lib/components/Dialog.svelte'; 3 + import icon from '../assets/favicon.svg'; 3 4 4 5 const helpLinks = [ 5 6 { label: 'Project README', href: 'https://github.com/stormlightlabs/inkfinite', external: true }, ··· 23 24 24 25 <header class="titlebar"> 25 26 <div class="titlebar__brand"> 26 - <div class="titlebar__logo">∞</div> 27 + <div class="titlebar__logo"> 28 + <img src={icon} alt="Inkfinite Icon" /> 29 + </div> 27 30 <div> 28 31 <div class="titlebar__name">Inkfinite</div> 29 32 <div class="titlebar__tagline">Infinite canvas playground</div>
+290 -3
apps/web/src/lib/components/Toolbar.svelte
··· 1 1 <script lang="ts"> 2 - import type { ToolId } from 'inkfinite-core'; 2 + import type { Box2, EditorState, Store, ToolId } from 'inkfinite-core'; 3 + import { 4 + exportToSVG, 5 + exportViewportToPNG, 6 + getSelectedShapes, 7 + getShapesOnCurrentPage, 8 + shapeBounds 9 + } from 'inkfinite-core'; 10 + 11 + type Viewport = { width: number; height: number }; 12 + 13 + type Props = { 14 + currentTool: ToolId; 15 + onToolChange: (toolId: ToolId) => void; 16 + onHistoryClick?: () => void; 17 + store: Store; 18 + getViewport: () => Viewport; 19 + canvas?: HTMLCanvasElement; 20 + }; 3 21 4 - type Props = { currentTool: ToolId; onToolChange: (toolId: ToolId) => void; onHistoryClick?: () => void }; 22 + let { currentTool, onToolChange, onHistoryClick, store, getViewport, canvas }: Props = $props(); 5 23 6 - let { currentTool, onToolChange, onHistoryClick }: Props = $props(); 24 + let editorState = $derived<EditorState>(store.getState()); 25 + let zoomMenuOpen = $state(false); 26 + let zoomMenuEl = $state<HTMLDivElement | null>(null); 27 + let zoomButtonEl = $state<HTMLButtonElement | null>(null); 28 + let exportMenuOpen = $state(false); 29 + let exportMenuEl = $state<HTMLDivElement | null>(null); 30 + let exportButtonEl = $state<HTMLButtonElement | null>(null); 31 + 32 + $effect(() => { 33 + editorState = store.getState(); 34 + const unsubscribe = store.subscribe((state) => { 35 + editorState = state; 36 + }); 37 + return () => unsubscribe(); 38 + }); 39 + 40 + $effect(() => { 41 + if (!zoomMenuOpen || typeof document === 'undefined') { 42 + return; 43 + } 44 + const handlePointerDown = (event: PointerEvent) => { 45 + const target = event.target as Node | null; 46 + if (!target) { 47 + return; 48 + } 49 + if (zoomMenuEl?.contains(target) || zoomButtonEl?.contains(target)) { 50 + return; 51 + } 52 + zoomMenuOpen = false; 53 + }; 54 + 55 + document.addEventListener('pointerdown', handlePointerDown); 56 + return () => document.removeEventListener('pointerdown', handlePointerDown); 57 + }); 58 + 59 + $effect(() => { 60 + if (!exportMenuOpen || typeof document === 'undefined') { 61 + return; 62 + } 63 + const handlePointerDown = (event: PointerEvent) => { 64 + const target = event.target as Node | null; 65 + if (!target) { 66 + return; 67 + } 68 + if (exportMenuEl?.contains(target) || exportButtonEl?.contains(target)) { 69 + return; 70 + } 71 + exportMenuOpen = false; 72 + }; 73 + 74 + document.addEventListener('pointerdown', handlePointerDown); 75 + return () => document.removeEventListener('pointerdown', handlePointerDown); 76 + }); 7 77 8 78 const tools: Array<{ id: ToolId; label: string; icon: string }> = [ 9 79 { id: 'select', label: 'Select', icon: '⌖' }, ··· 14 84 { id: 'text', label: 'Text', icon: 'T' } 15 85 ]; 16 86 87 + const zoomPresets = [ 88 + { label: '50%', value: 50 }, 89 + { label: '100%', value: 100 }, 90 + { label: '200%', value: 200 } 91 + ]; 92 + 17 93 function handleToolClick(toolId: ToolId) { 18 94 onToolChange(toolId); 19 95 } 96 + 97 + function getZoomPct(): number { 98 + const pct = editorState.camera.zoom * 100; 99 + if (!Number.isFinite(pct)) { 100 + return 100; 101 + } 102 + return Math.round(pct); 103 + } 104 + 105 + function setZoomPercent(percent: number) { 106 + const zoom = percent / 100; 107 + store.setState((state) => ({ ...state, camera: { ...state.camera, zoom } })); 108 + zoomMenuOpen = false; 109 + } 110 + 111 + function zoomToBounds(bounds: Box2) { 112 + const viewport = getViewport(); 113 + const width = bounds.max.x - bounds.min.x || 1; 114 + const height = bounds.max.y - bounds.min.y || 1; 115 + const margin = 80; 116 + const scaleX = (viewport.width - margin) / width; 117 + const scaleY = (viewport.height - margin) / height; 118 + const zoom = Math.max(Math.min(scaleX, scaleY), 0.05); 119 + const center = { x: (bounds.min.x + bounds.max.x) / 2, y: (bounds.min.y + bounds.max.y) / 2 }; 120 + store.setState((state) => ({ ...state, camera: { x: center.x, y: center.y, zoom } })); 121 + zoomMenuOpen = false; 122 + } 123 + 124 + function zoomToFit() { 125 + const shapes = getShapesOnCurrentPage(editorState); 126 + if (shapes.length === 0) { 127 + setZoomPercent(100); 128 + return; 129 + } 130 + const bounds = shapes.reduce<Box2 | null>((acc, shape) => { 131 + const shapeBox = shapeBounds(shape); 132 + if (!acc) { 133 + return shapeBox; 134 + } 135 + return { 136 + min: { x: Math.min(acc.min.x, shapeBox.min.x), y: Math.min(acc.min.y, shapeBox.min.y) }, 137 + max: { x: Math.max(acc.max.x, shapeBox.max.x), y: Math.max(acc.max.y, shapeBox.max.y) } 138 + }; 139 + }, null); 140 + 141 + if (bounds) { 142 + zoomToBounds(bounds); 143 + } 144 + } 145 + 146 + function zoomToSelection() { 147 + const shapes = getSelectedShapes(editorState); 148 + if (shapes.length === 0) { 149 + zoomToFit(); 150 + return; 151 + } 152 + 153 + const bounds = shapes.reduce<Box2 | null>((acc, shape) => { 154 + const shapeBox = shapeBounds(shape); 155 + if (!acc) { 156 + return shapeBox; 157 + } 158 + return { 159 + min: { x: Math.min(acc.min.x, shapeBox.min.x), y: Math.min(acc.min.y, shapeBox.min.y) }, 160 + max: { x: Math.max(acc.max.x, shapeBox.max.x), y: Math.max(acc.max.y, shapeBox.max.y) } 161 + }; 162 + }, null); 163 + 164 + if (bounds) { 165 + zoomToBounds(bounds); 166 + } 167 + } 168 + 169 + async function exportPNGViewport() { 170 + if (!canvas) { 171 + console.error('Canvas not available for export'); 172 + return; 173 + } 174 + try { 175 + const blob = await exportViewportToPNG(canvas); 176 + downloadBlob(blob, 'drawing.png'); 177 + exportMenuOpen = false; 178 + } catch (error) { 179 + console.error('Failed to export PNG:', error); 180 + } 181 + } 182 + 183 + function exportSVGAll() { 184 + const svg = exportToSVG(editorState, { selectedOnly: false }); 185 + downloadText(svg, 'drawing.svg'); 186 + exportMenuOpen = false; 187 + } 188 + 189 + function exportSVGSelection() { 190 + const svg = exportToSVG(editorState, { selectedOnly: true }); 191 + downloadText(svg, 'selection.svg'); 192 + exportMenuOpen = false; 193 + } 194 + 195 + function downloadBlob(blob: Blob, filename: string) { 196 + const url = URL.createObjectURL(blob); 197 + const a = document.createElement('a'); 198 + a.href = url; 199 + a.download = filename; 200 + document.body.appendChild(a); 201 + a.click(); 202 + document.body.removeChild(a); 203 + URL.revokeObjectURL(url); 204 + } 205 + 206 + function downloadText(text: string, filename: string) { 207 + const blob = new Blob([text], { type: 'text/plain' }); 208 + downloadBlob(blob, filename); 209 + } 20 210 </script> 21 211 22 212 <div class="toolbar" role="toolbar" aria-label="Drawing tools"> ··· 33 223 </button> 34 224 {/each} 35 225 226 + <div class="toolbar-divider"></div> 227 + 228 + <!-- Zoom controls --> 229 + <div class="toolbar-zoom"> 230 + <button class="zoom-button" bind:this={zoomButtonEl} onclick={() => (zoomMenuOpen = !zoomMenuOpen)}> 231 + {getZoomPct()}% 232 + </button> 233 + 234 + {#if zoomMenuOpen} 235 + <div class="zoom-menu" bind:this={zoomMenuEl}> 236 + {#each zoomPresets as preset} 237 + <button onclick={() => setZoomPercent(preset.value)}>{preset.label}</button> 238 + {/each} 239 + <div class="menu-divider"></div> 240 + <button onclick={zoomToFit}>Zoom to fit</button> 241 + <button onclick={zoomToSelection}>Zoom to selection</button> 242 + </div> 243 + {/if} 244 + </div> 245 + 246 + <!-- Export controls --> 247 + <div class="toolbar-export"> 248 + <button class="export-button" bind:this={exportButtonEl} onclick={() => (exportMenuOpen = !exportMenuOpen)}> 249 + Export 250 + </button> 251 + 252 + {#if exportMenuOpen} 253 + <div class="export-menu" bind:this={exportMenuEl}> 254 + <button onclick={exportPNGViewport}>PNG (Viewport)</button> 255 + <button onclick={exportSVGAll}>SVG (All)</button> 256 + <button onclick={exportSVGSelection}>SVG (Selection)</button> 257 + </div> 258 + {/if} 259 + </div> 260 + 36 261 {#if onHistoryClick} 37 262 <div class="toolbar-divider"></div> 38 263 <button class="tool-button history-button" onclick={onHistoryClick} aria-label="History"> ··· 49 274 padding: 12px; 50 275 background: var(--surface-elevated); 51 276 border-bottom: 1px solid var(--border); 277 + align-items: center; 52 278 } 53 279 54 280 .tool-button { ··· 97 323 width: 1px; 98 324 background-color: var(--border); 99 325 margin: 0 8px; 326 + height: 40px; 327 + } 328 + 329 + .toolbar-zoom, 330 + .toolbar-export { 331 + position: relative; 332 + } 333 + 334 + .zoom-button, 335 + .export-button { 336 + border: 1px solid var(--border); 337 + background: var(--surface); 338 + padding: 8px 12px; 339 + border-radius: 4px; 340 + cursor: pointer; 341 + font-size: 13px; 342 + min-width: 60px; 343 + } 344 + 345 + .zoom-button:hover, 346 + .export-button:hover { 347 + background: var(--surface-elevated); 348 + } 349 + 350 + .zoom-menu, 351 + .export-menu { 352 + position: absolute; 353 + top: calc(100% + 4px); 354 + left: 0; 355 + background: var(--surface); 356 + border: 1px solid var(--border); 357 + border-radius: 6px; 358 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 359 + padding: 8px; 360 + display: flex; 361 + flex-direction: column; 362 + gap: 4px; 363 + z-index: 10; 364 + min-width: 150px; 365 + } 366 + 367 + .zoom-menu button, 368 + .export-menu button { 369 + border: none; 370 + background: transparent; 371 + padding: 4px 8px; 372 + border-radius: 4px; 373 + text-align: left; 374 + cursor: pointer; 375 + font-size: 13px; 376 + } 377 + 378 + .zoom-menu button:hover, 379 + .export-menu button:hover { 380 + background: var(--surface-elevated); 381 + } 382 + 383 + .menu-divider { 384 + height: 1px; 385 + background: var(--border); 386 + margin: 6px 0; 100 387 } 101 388 102 389 .history-button {
+3 -1
apps/web/src/lib/tests/Canvas.history.test.ts
··· 277 277 createPersistenceSink: vi.fn(() => ({ enqueueDocPatch: sinkEnqueueSpy, flush: vi.fn() })), 278 278 buildStatusBarVM: () => ({ 279 279 cursorWorld: { x: 0, y: 0 }, 280 - zoomPct: 100, 281 280 toolId: "select", 282 281 mode: "idle", 283 282 selection: { count: 0 }, ··· 289 288 shapeBounds: () => ({ min: { x: 0, y: 0 }, max: { x: 0, y: 0 } }), 290 289 diffDoc: vi.fn(() => ({})), 291 290 InkfiniteDB: class {}, 291 + exportToSVG: vi.fn(() => "<svg></svg>"), 292 + exportViewportToPNG: vi.fn(() => Promise.resolve(new Blob())), 293 + exportSelectionToPNG: vi.fn(() => Promise.resolve(new Blob())), 292 294 __storeInstances: storeInstances, 293 295 __sinkEnqueueSpy: sinkEnqueueSpy, 294 296 };
+1 -1
apps/web/src/lib/tests/TitleBar.svelte.test.ts
··· 10 10 it("renders title/logo and info button", () => { 11 11 const { container } = render(TitleBar); 12 12 expect(container.querySelector(".titlebar")).toBeTruthy(); 13 - expect(container.querySelector(".titlebar__logo")?.textContent).toContain("∞"); 13 + expect(container.querySelector(".titlebar__logo img")).toBeTruthy(); 14 14 expect(container.querySelector(".titlebar__info")).toBeTruthy(); 15 15 }); 16 16
+23 -7
apps/web/src/lib/tests/components/Toolbar.svelte.test.ts
··· 1 1 import type { ToolId } from "inkfinite-core"; 2 + import { Store } from "inkfinite-core"; 2 3 import { beforeEach, describe, expect, it, vi } from "vitest"; 3 4 import { cleanup, render } from "vitest-browser-svelte"; 4 5 import Toolbar from "../../components/Toolbar.svelte"; 6 + 7 + const createMockStore = () => new Store(); 8 + const createMockGetViewport = () => () => ({ width: 1024, height: 768 }); 5 9 6 10 describe("Toolbar component", () => { 7 11 beforeEach(() => { ··· 10 14 11 15 it("should render all tool buttons", () => { 12 16 const onToolChange = vi.fn(); 13 - const { container } = render(Toolbar, { currentTool: "select", onToolChange }); 17 + const store = createMockStore(); 18 + const getViewport = createMockGetViewport(); 19 + const { container } = render(Toolbar, { currentTool: "select", onToolChange, store, getViewport }); 14 20 15 21 const buttons = container.querySelectorAll(".tool-button"); 16 22 expect(buttons.length).toBe(6); ··· 21 27 22 28 it("should mark the current tool as active", () => { 23 29 const onToolChange = vi.fn(); 24 - const { container } = render(Toolbar, { currentTool: "rect", onToolChange }); 30 + const store = createMockStore(); 31 + const getViewport = createMockGetViewport(); 32 + const { container } = render(Toolbar, { currentTool: "rect", onToolChange, store, getViewport }); 25 33 26 34 const activeButton = container.querySelector(".tool-button[data-tool-id=\"rect\"]"); 27 35 expect(activeButton?.classList.contains("active")).toBe(true); ··· 30 38 31 39 it("should call onToolChange when a tool button is clicked", async () => { 32 40 const onToolChange = vi.fn(); 33 - const { container } = render(Toolbar, { currentTool: "select", onToolChange }); 41 + const store = createMockStore(); 42 + const getViewport = createMockGetViewport(); 43 + const { container } = render(Toolbar, { currentTool: "select", onToolChange, store, getViewport }); 34 44 35 45 const ellipseButton = container.querySelector(".tool-button[data-tool-id=\"ellipse\"]") as HTMLButtonElement; 36 46 expect(ellipseButton).toBeTruthy(); ··· 50 60 { toolId: "text" as ToolId, label: "Text" }, 51 61 ])("should have correct aria-label for $toolId tool", ({ toolId, label }) => { 52 62 const onToolChange = vi.fn(); 53 - const { container } = render(Toolbar, { currentTool: "select", onToolChange }); 63 + const store = createMockStore(); 64 + const getViewport = createMockGetViewport(); 65 + const { container } = render(Toolbar, { currentTool: "select", onToolChange, store, getViewport }); 54 66 55 67 const button = container.querySelector(`.tool-button[data-tool-id="${toolId}"]`); 56 68 expect(button?.getAttribute("aria-label")).toBe(label); ··· 58 70 59 71 it("should update active state when currentTool prop changes", async () => { 60 72 const onToolChange = vi.fn(); 61 - const { container, rerender } = render(Toolbar, { currentTool: "select", onToolChange }); 73 + const store = createMockStore(); 74 + const getViewport = createMockGetViewport(); 75 + const { container, rerender } = render(Toolbar, { currentTool: "select", onToolChange, store, getViewport }); 62 76 63 77 let selectButton = container.querySelector(".tool-button[data-tool-id=\"select\"]"); 64 78 let rectButton = container.querySelector(".tool-button[data-tool-id=\"rect\"]"); ··· 66 80 expect(selectButton?.classList.contains("active")).toBe(true); 67 81 expect(rectButton?.classList.contains("active")).toBe(false); 68 82 69 - await rerender({ currentTool: "rect", onToolChange }); 83 + await rerender({ currentTool: "rect", onToolChange, store, getViewport }); 70 84 71 85 selectButton = container.querySelector(".tool-button[data-tool-id=\"select\"]"); 72 86 rectButton = container.querySelector(".tool-button[data-tool-id=\"rect\"]"); ··· 77 91 78 92 it("should have proper accessibility attributes", () => { 79 93 const onToolChange = vi.fn(); 80 - const { container } = render(Toolbar, { currentTool: "select", onToolChange }); 94 + const store = createMockStore(); 95 + const getViewport = createMockGetViewport(); 96 + const { container } = render(Toolbar, { currentTool: "select", onToolChange, store, getViewport }); 81 97 82 98 const toolbar = container.querySelector(".toolbar"); 83 99 expect(toolbar?.getAttribute("role")).toBe("toolbar");
+279
packages/core/src/export.ts
··· 1 + import { shapeBounds } from "./geom"; 2 + import type { Box2 } from "./math"; 3 + import { Box2 as Box2Ops } from "./math"; 4 + import type { ArrowShape, EllipseShape, LineShape, RectShape, ShapeRecord, TextShape } from "./model"; 5 + import type { EditorState } from "./reactivity"; 6 + import { getSelectedShapes, getShapesOnCurrentPage } from "./reactivity"; 7 + 8 + export 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 + */ 31 + export 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 + */ 53 + export 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 + // Calculate combined bounds 63 + const bounds = combineBounds(shapes.map((s) => shapeBounds(s))); 64 + if (!bounds) { 65 + return null; 66 + } 67 + 68 + // Add padding 69 + const padding = 20; 70 + const width = Box2Ops.width(bounds) + padding * 2; 71 + const height = Box2Ops.height(bounds) + padding * 2; 72 + 73 + // Create temporary canvas 74 + const canvas = document.createElement("canvas"); 75 + canvas.width = width; 76 + canvas.height = height; 77 + 78 + const context = canvas.getContext("2d"); 79 + if (!context) { 80 + throw new Error("Failed to get 2D context"); 81 + } 82 + 83 + // Clear background (white) 84 + context.fillStyle = "white"; 85 + context.fillRect(0, 0, width, height); 86 + 87 + // Translate to handle bounds offset + padding 88 + context.save(); 89 + context.translate(-bounds.min.x + padding, -bounds.min.y + padding); 90 + 91 + // Let caller render the shapes 92 + renderFunction(context, shapes, bounds); 93 + 94 + context.restore(); 95 + 96 + // Export to PNG 97 + return new Promise((resolve, reject) => { 98 + canvas.toBlob((blob) => { 99 + if (blob) { 100 + resolve(blob); 101 + } else { 102 + reject(new Error("Failed to export selection to PNG")); 103 + } 104 + }, "image/png"); 105 + }); 106 + } 107 + 108 + /** 109 + * Export shapes to SVG format. 110 + * 111 + * By default, shapes are exported in world coordinates (camera transform is NOT applied). 112 + * Set `includeCamera: true` to bake the camera transform into the SVG viewBox. 113 + * 114 + * @param state - Editor state containing shapes and camera 115 + * @param options - Export options 116 + * @returns SVG string 117 + */ 118 + export function exportToSVG(state: EditorState, options: ExportOptions = {}): string { 119 + const shapes = options.selectedOnly ? getSelectedShapes(state) : getShapesOnCurrentPage(state); 120 + 121 + if (shapes.length === 0) { 122 + return "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"></svg>"; 123 + } 124 + 125 + // Calculate bounds 126 + const bounds = combineBounds(shapes.map((s) => shapeBounds(s))); 127 + if (!bounds) { 128 + return "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"></svg>"; 129 + } 130 + 131 + const padding = 20; 132 + const width = Box2Ops.width(bounds) + padding * 2; 133 + const height = Box2Ops.height(bounds) + padding * 2; 134 + const offsetX = bounds.min.x - padding; 135 + const offsetY = bounds.min.y - padding; 136 + 137 + const elements: string[] = [`<rect x="${offsetX}" y="${offsetY}" width="${width}" height="${height}" fill="white"/>`]; 138 + 139 + // Add white background 140 + 141 + // Render each shape 142 + for (const shape of shapes) { 143 + const svg = shapeToSVG(shape, state); 144 + if (svg) { 145 + elements.push(svg); 146 + } 147 + } 148 + 149 + const viewBox = `${offsetX} ${offsetY} ${width} ${height}`; 150 + 151 + return [ 152 + `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="${width}" height="${height}">`, 153 + ...elements, 154 + `</svg>`, 155 + ].join("\n"); 156 + } 157 + 158 + /** 159 + * Convert a single shape to SVG markup. 160 + */ 161 + function shapeToSVG(shape: ShapeRecord, state: EditorState): string | null { 162 + const transform = `translate(${shape.x},${shape.y})${ 163 + shape.rot === 0 ? "" : ` rotate(${(shape.rot * 180) / Math.PI})` 164 + }`; 165 + 166 + switch (shape.type) { 167 + case "rect": { 168 + return rectToSVG(shape, transform); 169 + } 170 + case "ellipse": { 171 + return ellipseToSVG(shape, transform); 172 + } 173 + case "line": { 174 + return lineToSVG(shape, transform); 175 + } 176 + case "arrow": { 177 + return arrowToSVG(shape, transform, state); 178 + } 179 + case "text": { 180 + return textToSVG(shape, transform); 181 + } 182 + default: { 183 + return null; 184 + } 185 + } 186 + } 187 + 188 + function rectToSVG(shape: RectShape, transform: string): string { 189 + const { w, h, fill, stroke, radius } = shape.props; 190 + const fillAttribute = fill ? `fill="${escapeXML(fill)}"` : "fill=\"none\""; 191 + const strokeAttribute = stroke ? `stroke="${escapeXML(stroke)}" stroke-width="2"` : ""; 192 + const radiusAttribute = radius > 0 ? `rx="${radius}" ry="${radius}"` : ""; 193 + 194 + return `<rect transform="${transform}" width="${w}" height="${h}" ${fillAttribute} ${strokeAttribute} ${radiusAttribute}/>`; 195 + } 196 + 197 + function ellipseToSVG(shape: EllipseShape, transform: string): string { 198 + const { w, h, fill, stroke } = shape.props; 199 + const cx = w / 2; 200 + const cy = h / 2; 201 + const rx = w / 2; 202 + const ry = h / 2; 203 + const fillAttribute = fill ? `fill="${escapeXML(fill)}"` : "fill=\"none\""; 204 + const strokeAttribute = stroke ? `stroke="${escapeXML(stroke)}" stroke-width="2"` : ""; 205 + 206 + return `<ellipse transform="${transform}" cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" ${fillAttribute} ${strokeAttribute}/>`; 207 + } 208 + 209 + function lineToSVG(shape: LineShape, transform: string): string { 210 + const { a, b, stroke, width } = shape.props; 211 + 212 + return `<line transform="${transform}" x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="${ 213 + escapeXML(stroke) 214 + }" stroke-width="${width}"/>`; 215 + } 216 + 217 + function arrowToSVG(shape: ArrowShape, transform: string, _state: EditorState): string { 218 + const { a, b, stroke, width } = shape.props; 219 + 220 + // Calculate arrow head 221 + const angle = Math.atan2(b.y - a.y, b.x - a.x); 222 + const arrowLength = 15; 223 + const arrowAngle = Math.PI / 6; 224 + 225 + const arrowPoint1 = { 226 + x: b.x - arrowLength * Math.cos(angle - arrowAngle), 227 + y: b.y - arrowLength * Math.sin(angle - arrowAngle), 228 + }; 229 + 230 + const arrowPoint2 = { 231 + x: b.x - arrowLength * Math.cos(angle + arrowAngle), 232 + y: b.y - arrowLength * Math.sin(angle + arrowAngle), 233 + }; 234 + 235 + const strokeAttribute = `stroke="${escapeXML(stroke)}" stroke-width="${width}"`; 236 + 237 + return [ 238 + `<g transform="${transform}">`, 239 + ` <line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" ${strokeAttribute}/>`, 240 + ` <line x1="${b.x}" y1="${b.y}" x2="${arrowPoint1.x}" y2="${arrowPoint1.y}" ${strokeAttribute}/>`, 241 + ` <line x1="${b.x}" y1="${b.y}" x2="${arrowPoint2.x}" y2="${arrowPoint2.y}" ${strokeAttribute}/>`, 242 + `</g>`, 243 + ].join("\n"); 244 + } 245 + 246 + function textToSVG(shape: TextShape, transform: string): string { 247 + const { text, fontSize, fontFamily, color } = shape.props; 248 + 249 + return `<text transform="${transform}" font-size="${fontSize}" font-family="${escapeXML(fontFamily)}" fill="${ 250 + escapeXML(color) 251 + }">${escapeXML(text)}</text>`; 252 + } 253 + 254 + /** 255 + * Escape special XML characters in strings. 256 + */ 257 + function escapeXML(string_: string): string { 258 + return string_.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;") 259 + .replaceAll("'", "&apos;"); 260 + } 261 + 262 + /** 263 + * Combine multiple bounding boxes into a single bounding box. 264 + */ 265 + function combineBounds(boxes: Box2[]): Box2 | null { 266 + if (boxes.length === 0) { 267 + return null; 268 + } 269 + 270 + let combined = Box2Ops.clone(boxes[0]); 271 + for (let index = 1; index < boxes.length; index++) { 272 + const box = boxes[index]; 273 + combined = { 274 + min: { x: Math.min(combined.min.x, box.min.x), y: Math.min(combined.min.y, box.min.y) }, 275 + max: { x: Math.max(combined.max.x, box.max.x), y: Math.max(combined.max.y, box.max.y) }, 276 + }; 277 + } 278 + return combined; 279 + }
+1
packages/core/src/index.ts
··· 1 1 export * from "./actions"; 2 2 export * from "./camera"; 3 3 export * from "./cursor"; 4 + export * from "./export"; 4 5 export * from "./geom"; 5 6 export * from "./history"; 6 7 export * from "./math";
-2
packages/core/src/ui/statusbar.ts
··· 19 19 export type StatusBarVM = { 20 20 cursorWorld: Vec2; 21 21 cursorScreen?: Vec2; 22 - zoomPct: number; 23 22 toolId: ToolId; 24 23 mode: "idle" | "dragging" | "panning" | "text-edit" | string; 25 24 selection: SelectionSummary; ··· 90 89 return { 91 90 cursorWorld: Vec2Ops.clone(cursorState.cursorWorld), 92 91 cursorScreen: cursorState.cursorScreen ? Vec2Ops.clone(cursorState.cursorScreen) : undefined, 93 - zoomPct: getZoomPct(editorState), 94 92 toolId: getToolId(editorState), 95 93 mode, 96 94 selection: getSelectionSummary(editorState),
+154
packages/core/tests/export.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { exportToSVG } from "../src/export"; 3 + import { PageRecord, ShapeRecord } from "../src/model"; 4 + import { EditorState } from "../src/reactivity"; 5 + 6 + function createTestState() { 7 + const state = EditorState.create(); 8 + const page = PageRecord.create("Test Page"); 9 + state.doc.pages[page.id] = page; 10 + state.ui.currentPageId = page.id; 11 + return { state, pageId: page.id }; 12 + } 13 + 14 + describe("exportToSVG", () => { 15 + it("should export an empty SVG when no shapes exist", () => { 16 + const { state } = createTestState(); 17 + const svg = exportToSVG(state); 18 + 19 + expect(svg).toContain("<svg"); 20 + expect(svg).toContain("</svg>"); 21 + }); 22 + 23 + it("should export SVG with a rectangle shape", () => { 24 + const { state, pageId } = createTestState(); 25 + 26 + const rect = ShapeRecord.createRect(pageId, 10, 20, { w: 100, h: 50, fill: "red", stroke: "black", radius: 0 }); 27 + 28 + state.doc.shapes[rect.id] = rect; 29 + state.doc.pages[pageId].shapeIds.push(rect.id); 30 + 31 + const svg = exportToSVG(state); 32 + expect(svg).toContain("<svg"); 33 + expect(svg).toContain("</svg>"); 34 + expect(svg).toContain("<rect"); 35 + expect(svg).toContain("width=\"100\""); 36 + expect(svg).toContain("height=\"50\""); 37 + expect(svg).toContain("fill=\"red\""); 38 + expect(svg).toContain("stroke=\"black\""); 39 + }); 40 + 41 + it("should export SVG with an ellipse shape", () => { 42 + const { state, pageId } = createTestState(); 43 + 44 + const ellipse = ShapeRecord.createEllipse(pageId, 10, 20, { w: 100, h: 50, fill: "blue", stroke: "green" }); 45 + 46 + state.doc.shapes[ellipse.id] = ellipse; 47 + state.doc.pages[pageId].shapeIds.push(ellipse.id); 48 + 49 + const svg = exportToSVG(state); 50 + expect(svg).toContain("<ellipse"); 51 + expect(svg).toContain("rx=\"50\""); 52 + expect(svg).toContain("ry=\"25\""); 53 + expect(svg).toContain("fill=\"blue\""); 54 + expect(svg).toContain("stroke=\"green\""); 55 + }); 56 + 57 + it("should export SVG with a line shape", () => { 58 + const { state, pageId } = createTestState(); 59 + 60 + const line = ShapeRecord.createLine(pageId, 0, 0, { 61 + a: { x: 0, y: 0 }, 62 + b: { x: 100, y: 100 }, 63 + stroke: "red", 64 + width: 2, 65 + }); 66 + 67 + state.doc.shapes[line.id] = line; 68 + state.doc.pages[pageId].shapeIds.push(line.id); 69 + 70 + const svg = exportToSVG(state); 71 + expect(svg).toContain("<line"); 72 + expect(svg).toContain("x1=\"0\""); 73 + expect(svg).toContain("y1=\"0\""); 74 + expect(svg).toContain("x2=\"100\""); 75 + expect(svg).toContain("y2=\"100\""); 76 + expect(svg).toContain("stroke=\"red\""); 77 + expect(svg).toContain("stroke-width=\"2\""); 78 + }); 79 + 80 + it("should export SVG with an arrow shape", () => { 81 + const { state, pageId } = createTestState(); 82 + 83 + const arrow = ShapeRecord.createArrow(pageId, 0, 0, { 84 + a: { x: 0, y: 0 }, 85 + b: { x: 100, y: 0 }, 86 + stroke: "black", 87 + width: 2, 88 + }); 89 + 90 + state.doc.shapes[arrow.id] = arrow; 91 + state.doc.pages[pageId].shapeIds.push(arrow.id); 92 + 93 + const svg = exportToSVG(state); 94 + expect(svg).toContain("<g"); 95 + expect(svg).toContain("<line"); 96 + expect(svg).toContain("stroke=\"black\""); 97 + }); 98 + 99 + it("should export SVG with a text shape", () => { 100 + const { state, pageId } = createTestState(); 101 + 102 + const text = ShapeRecord.createText(pageId, 10, 20, { 103 + text: "Hello World", 104 + fontSize: 16, 105 + fontFamily: "Arial", 106 + color: "black", 107 + }); 108 + 109 + state.doc.shapes[text.id] = text; 110 + state.doc.pages[pageId].shapeIds.push(text.id); 111 + 112 + const svg = exportToSVG(state); 113 + expect(svg).toContain("<text"); 114 + expect(svg).toContain("font-size=\"16\""); 115 + expect(svg).toContain("font-family=\"Arial\""); 116 + expect(svg).toContain("fill=\"black\""); 117 + expect(svg).toContain(">Hello World</text>"); 118 + }); 119 + 120 + it("should export only selected shapes when selectedOnly is true", () => { 121 + const { state, pageId } = createTestState(); 122 + 123 + const rect1 = ShapeRecord.createRect(pageId, 0, 0, { w: 50, h: 50, fill: "red", stroke: "black", radius: 0 }); 124 + const rect2 = ShapeRecord.createRect(pageId, 100, 100, { w: 50, h: 50, fill: "blue", stroke: "black", radius: 0 }); 125 + 126 + state.doc.shapes[rect1.id] = rect1; 127 + state.doc.shapes[rect2.id] = rect2; 128 + state.doc.pages[pageId].shapeIds.push(rect1.id, rect2.id); 129 + 130 + state.ui.selectionIds = [rect1.id]; 131 + 132 + const svg = exportToSVG(state, { selectedOnly: true }); 133 + expect(svg).toContain("fill=\"red\""); 134 + expect(svg).not.toContain("fill=\"blue\""); 135 + }); 136 + 137 + it("should escape XML special characters in shape properties", () => { 138 + const { state, pageId } = createTestState(); 139 + 140 + const text = ShapeRecord.createText(pageId, 0, 0, { 141 + text: "<script>alert('XSS')</script>", 142 + fontSize: 16, 143 + fontFamily: "Arial", 144 + color: "black", 145 + }); 146 + 147 + state.doc.shapes[text.id] = text; 148 + state.doc.pages[pageId].shapeIds.push(text.id); 149 + 150 + const svg = exportToSVG(state); 151 + expect(svg).toContain("&lt;script&gt;"); 152 + expect(svg).not.toContain("<script>"); 153 + }); 154 + });
-1
packages/core/tests/statusbar.test.ts
··· 116 116 117 117 expect(vm.cursorWorld).toEqual({ x: 5, y: 6 }); 118 118 expect(vm.cursorScreen).toEqual({ x: 1, y: 2 }); 119 - expect(vm.zoomPct).toBe(100); 120 119 expect(vm.toolId).toBe("select"); 121 120 expect(vm.mode).toBe("dragging"); 122 121 expect(vm.selection).toEqual({ count: 1, kind: "rect", bounds: { w: 50, h: 50 } });
+1 -1
packages/core/tsconfig.json
··· 1 1 { 2 2 "compilerOptions": { 3 3 "target": "esnext", 4 - "lib": ["es2023"], 4 + "lib": ["es2023", "dom"], 5 5 "moduleDetection": "force", 6 6 "module": "preserve", 7 7 "moduleResolution": "bundler",