web based infinite canvas
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: integrate StatusBar component with persistence and snapping

+529 -196
+4 -182
TODO.txt
··· 131 131 14. Milestone N: Status Bar (Editor HUD) *wb-N* 132 132 ================================================================================ 133 133 134 - Goal: 135 - Add a bottom status bar that surfaces the "always-useful" editor telemetry: 136 - cursor position, zoom, active tool/mode, selection summary, and persistence 137 - state - with a clean core → UI boundary. 138 - 139 - Design inspirations: 140 - - Zoom controls commonly expose percentage + "zoom to fit/selection" shortcuts. 141 - Svelte integration detail: 142 - - Use $effect to subscribe/unsubscribe to external sources (runs client-side; 143 - cleanup function runs on re-run/unmount). 144 - 145 - ------------------------------------------------------------------------------ 146 - N1. Define the StatusBar view model (core, pure TS) 147 - ------------------------------------------------------------------------------ 148 - 149 - /packages/core/src/ui/statusbar.ts 150 - [x] Define StatusBarVM (single object the UI renders): 151 - - cursorWorld: { x, y } " world coords (always) 152 - - cursorScreen: { x, y }? " optional dev-only 153 - - zoomPct: number " e.g. 100, 67, 250 154 - - toolId: ToolId " select/rect/pen/... 155 - - mode: string " 'idle'|'dragging'|'panning'|'text-edit' 156 - - selection: 157 - - count: number 158 - - kind?: string " optional: 'rect', 'mixed', etc. 159 - - bounds?: { w, h } " optional v1 160 - - snap: 161 - - enabled: boolean 162 - - gridSize?: number " if grid enabled 163 - - angleStepDeg?: number " if angle snapping exists 164 - - persistence: 165 - - backend: 'indexeddb' 166 - - state: 'saved'|'saving'|'error' 167 - - lastSavedAt?: number " epoch ms 168 - - pendingWrites?: number " queue depth if you batch writes 169 - - errorMsg?: string 170 - 171 - Notes: 172 - - This VM is intentionally READ-ONLY and derived from existing editor state, 173 - input state, and persistence sink state. 174 - 175 - (DoD): 176 - - StatusBarVM compiles and is stable enough to render even before UI exists. 177 - 178 - ------------------------------------------------------------------------------ 179 - N2. Provide selectors / derivations for StatusBarVM 180 - ------------------------------------------------------------------------------ 181 - 182 - /packages/core/src/ui/statusbar.ts 183 - [x] Implement pure functions: 184 - - getZoomPct(state) -> number 185 - - getToolId(state) -> ToolId 186 - - getSelectionSummary(state) -> { count, kind?, bounds? } 187 - - getSnapSummary(state) -> snap summary (default safe values) 188 - 189 - Cursor position source: 190 - [x] Define a minimal CursorState in core (NOT persisted): 191 - - cursorWorld: Vec2 192 - - cursorScreen?: Vec2 193 - - lastMoveAt: number 194 - 195 - [x] Add updateCursor(world, screen?) action + reducer handler (or direct setter) 196 - that ONLY touches CursorState (no history command, no persistence). 197 - 198 - (DoD): 199 - - You can compute StatusBarVM from (EditorState + CursorState + PersistState). 200 - 201 - ------------------------------------------------------------------------------ 202 - N3. Wire cursor updates from pointer movement (apps/web) 203 - ------------------------------------------------------------------------------ 204 - 205 - /apps/web/src/lib/input.ts 206 - [x] On pointermove (or mousemove when not captured): 207 - - compute world coords using camera.screenToWorld 208 - - dispatch updateCursor(world, screen) 209 - 210 - Performance: 211 - [x] Throttle cursor updates: 212 - - v0: requestAnimationFrame coalescing (only publish latest per frame) 213 - - avoid flooding render/history/persistence 214 - 215 - (DoD): 216 - - Cursor world coordinates update smoothly while moving the mouse. 217 - 218 - ------------------------------------------------------------------------------ 219 - N4. Add persistence status signals (Dexie + persistence sink integration) 220 - ------------------------------------------------------------------------------ 221 - 222 - Goal: 223 - Expose persistence state without touching the history system (Milestone L is 224 - done; persistence is already hooked to history in Milestone M). 225 - 226 - /apps/web/src/lib/status.ts 227 - [x] Extend your persistence sink (from Milestone M) to expose a small status: 228 - - pendingWrites counter (increment on enqueue, decrement on commit) 229 - - lastSavedAt timestamp (set on successful commit) 230 - - lastError (set on failed commit) 231 - [x] Use Dexie liveQuery to observe the current board’s updatedAt from IndexedDB 232 - and reflect it in the UI (helps confirm persisted state across tabs). 233 - 234 - (DoD): 235 - - Status bar can show: "Saving…" when pendingWrites > 0, and "Saved" with time 236 - when pendingWrites reaches 0. 237 - 238 - ------------------------------------------------------------------------------ 239 - N5. Implement StatusBar.svelte using runes 240 - ------------------------------------------------------------------------------ 241 - 242 - /apps/web/src/lib/components/StatusBar.svelte 243 - [ ] Render left → right (suggested): 244 - - Tool + mode 245 - - Cursor: X,Y (world) 246 - - Selection summary 247 - - Snap/grid summary 248 - - Zoom % 249 - - Save state ("Saved 3s ago" / "Saving…" / "Error") 250 - 251 - [ ] Consume state via runes: 252 - - keep a local $state(snapshot) for EditorState 253 - - keep a local $state(cursor) for CursorState 254 - - keep a local $state(persist) for PersistStatus 255 - 256 - Subscriptions: 257 - [ ] Use $effect to subscribe to any external streams and return cleanup 258 - unsubscribe. 259 - 260 - Formatting: 261 - [ ] Cursor formatting: 262 - - v0: integers 263 - - v1: configurable precision (e.g. 0.1 units when zoomed in) 264 - 265 - (DoD): 266 - - Status bar is visible, updates live, and never causes noticeable jank. 267 - 268 - ------------------------------------------------------------------------------ 269 - N6. Interactions (small, high-value) 270 - ------------------------------------------------------------------------------ 271 - 272 - Zoom control: 273 - [ ] Clicking zoomPct opens a tiny menu: 274 - - 50%, 100%, 200% 275 - - Zoom to fit 276 - - Zoom to selection 277 - (Inspiration: zoom/view options + shortcuts in Figma/FigJam.) 278 - 279 - Snap toggles: 280 - [ ] Add quick toggles (optional v0, recommended v1): 281 - - snap enabled 282 - - grid enabled 283 - 284 - (DoD): 285 - - Zoom is discoverable and controllable from the status bar. 286 - 287 - ------------------------------------------------------------------------------ 288 - N7. Tests 289 - ------------------------------------------------------------------------------ 290 - 291 - Core unit tests (/packages/core/test/statusbar.test.ts): 292 - [ ] getZoomPct returns expected values from camera zoom 293 - [ ] selection summary is correct (0, 1, many) 294 - [ ] snap summary defaults safe when features disabled 295 - 296 - Web integration tests (optional v0): 297 - [ ] cursor update throttling: 100 pointermoves in a tick results in <= 1 state 298 - publication per frame (if you implement rAF coalescing) 299 - 300 - Persistence tests (web): 301 - [ ] pendingWrites transitions: 0 -> N -> 0 yields state 'saving' then 'saved' 302 - [ ] error sets 'error' state and preserves lastSavedAt 303 - 304 - ------------------------------------------------------------------------------ 305 - Definition of Done 306 - ------------------------------------------------------------------------------ 307 - 308 - - Status bar shows: 309 - - cursor world position 310 - - zoom percentage 311 - - active tool/mode 312 - - selection count 313 - - persistence state (Saved/Saving/Error + lastSavedAt) 314 - - Cursor updates are throttled and do not spam history or persistence. 315 - - UI subscriptions use $effect with cleanup. 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. 316 138 317 139 ================================================================================ 318 140 15. Milestone O: Export (PNG/SVG) *wb-O*
+46 -12
apps/web/src/lib/canvas/Canvas.svelte
··· 1 1 <script lang="ts"> 2 2 import HistoryViewer from '$lib/components/HistoryViewer.svelte'; 3 + import StatusBar from '$lib/components/StatusBar.svelte'; 3 4 import Toolbar from '$lib/components/Toolbar.svelte'; 4 5 import { createInputAdapter, type InputAdapter } from '$lib/input'; 5 - import { createPersistenceManager } from '$lib/status'; 6 + import { 7 + createPersistenceManager, 8 + createSnapStore, 9 + createStatusStore, 10 + type SnapStore, 11 + type StatusStore 12 + } from '$lib/status'; 6 13 import { 7 14 ArrowTool, 8 15 CursorStore, ··· 33 40 let repo: ReturnType<typeof createWebDocRepo> | null = null; 34 41 let sink: PersistenceSink | null = null; 35 42 let persistenceManager: ReturnType<typeof createPersistenceManager> | null = null; 43 + const fallbackStatusStore = createStatusStore({ backend: 'indexeddb', state: 'saved', pendingWrites: 0 }); 44 + let persistenceStatusStore = $state<StatusStore>(fallbackStatusStore); 36 45 let activeBoardId: string | null = null; 37 46 38 47 const store = new Store(undefined, { ··· 45 54 } 46 55 }); 47 56 const cursorStore = new CursorStore(); 57 + const snapStore: SnapStore = createSnapStore(); 48 58 49 59 function applyLoadedDoc(doc: LoadedDoc) { 50 60 const firstPageId = doc.order.pageIds[0] ?? Object.keys(doc.pages)[0] ?? null; ··· 96 106 } 97 107 98 108 function handleAction(action: Action) { 99 - if (action.type === 'key-down') { 109 + const actionWithSnap = applySnapping(action); 110 + if (actionWithSnap.type === 'key-down') { 100 111 const isPrimary = 101 - (action.modifiers.meta && navigator.platform.toUpperCase().includes('MAC')) || 102 - (action.modifiers.ctrl && !navigator.platform.toUpperCase().includes('MAC')); 112 + (actionWithSnap.modifiers.meta && navigator.platform.toUpperCase().includes('MAC')) || 113 + (actionWithSnap.modifiers.ctrl && !navigator.platform.toUpperCase().includes('MAC')); 103 114 104 - if (isPrimary && !action.modifiers.shift && (action.key === 'z' || action.key === 'Z')) { 115 + if (isPrimary && !actionWithSnap.modifiers.shift && (actionWithSnap.key === 'z' || actionWithSnap.key === 'Z')) { 105 116 store.undo(); 106 117 return; 107 118 } 108 119 109 - if (isPrimary && action.modifiers.shift && (action.key === 'z' || action.key === 'Z')) { 120 + if (isPrimary && actionWithSnap.modifiers.shift && (actionWithSnap.key === 'z' || actionWithSnap.key === 'Z')) { 110 121 store.redo(); 111 122 return; 112 123 } 113 124 } 114 125 115 - applyActionWithHistory(action); 126 + applyActionWithHistory(actionWithSnap); 116 127 } 117 128 118 129 function statesEqual(a: EditorState, b: EditorState): boolean { ··· 148 159 } 149 160 } 150 161 162 + function applySnapping(action: Action): Action { 163 + const snap = snapStore.get(); 164 + if (!snap.snapEnabled || !snap.gridEnabled) { 165 + return action; 166 + } 167 + if (!('world' in action)) { 168 + return action; 169 + } 170 + const snapCoord = (value: number) => Math.round(value / snap.gridSize) * snap.gridSize; 171 + const snappedWorld = { x: snapCoord(action.world.x), y: snapCoord(action.world.y) }; 172 + return { ...action, world: snappedWorld }; 173 + } 174 + 151 175 let canvas: HTMLCanvasElement; 152 176 let renderer: Renderer | null = null; 153 177 let inputAdapter: InputAdapter | null = null; 154 178 179 + function getViewport(): Viewport { 180 + if (canvas) { 181 + const rect = canvas.getBoundingClientRect(); 182 + return { width: rect.width || 1, height: rect.height || 1 }; 183 + } 184 + if (typeof window !== 'undefined') { 185 + return { width: window.innerWidth || 1, height: window.innerHeight || 1 }; 186 + } 187 + return { width: 1, height: 1 }; 188 + } 189 + 155 190 onMount(() => { 156 191 const db = new InkfiniteDB(); 157 192 repo = createWebDocRepo(db); 158 193 persistenceManager = createPersistenceManager(db, repo, { sink: { debounceMs: 200 } }); 159 194 sink = persistenceManager.sink; 195 + persistenceStatusStore = persistenceManager.status; 160 196 let disposed = false; 161 197 162 198 const hydrate = async () => { ··· 183 219 184 220 hydrate(); 185 221 186 - function getViewport(): Viewport { 187 - const rect = canvas.getBoundingClientRect(); 188 - return { width: rect.width, height: rect.height }; 189 - } 190 - 191 222 function getCamera() { 192 223 return store.getState().camera; 193 224 } ··· 226 257 activeBoardId = null; 227 258 persistenceManager?.dispose(); 228 259 persistenceManager = null; 260 + fallbackStatusStore.update(() => ({ backend: 'indexeddb', state: 'saved', pendingWrites: 0 })); 261 + persistenceStatusStore = fallbackStatusStore; 229 262 }); 230 263 </script> 231 264 ··· 233 266 <Toolbar currentTool={currentToolId} onToolChange={handleToolChange} onHistoryClick={handleHistoryClick} /> 234 267 <canvas bind:this={canvas}></canvas> 235 268 <HistoryViewer {store} bind:open={historyViewerOpen} onClose={handleHistoryClose} /> 269 + <StatusBar {store} cursor={cursorStore} persistence={persistenceStatusStore} snap={snapStore} {getViewport} /> 236 270 </div> 237 271 238 272 <style>
+391
apps/web/src/lib/components/StatusBar.svelte
··· 1 + <script lang="ts"> 2 + import type { SnapSettings, SnapStore, StatusStore } from '$lib/status'; 3 + import { 4 + type Box2, 5 + type CursorState, 6 + type CursorStore, 7 + type EditorState, 8 + type PersistenceStatus, 9 + type Store, 10 + EditorState as EditorStateOps, 11 + buildStatusBarVM, 12 + getSelectedShapes, 13 + getShapesOnCurrentPage, 14 + shapeBounds 15 + } from 'inkfinite-core'; 16 + 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 + }; 27 + 28 + let { store, cursor, persistence, snap, getViewport = defaultViewport }: Props = $props(); 29 + 30 + let editorSnapshot: EditorState = EditorStateOps.create(); 31 + let cursorSnapshot: CursorState = { cursorWorld: { x: 0, y: 0 }, lastMoveAt: Date.now() }; 32 + let persistenceSnapshot: PersistenceStatus = { backend: 'indexeddb', state: 'saved', pendingWrites: 0 }; 33 + let snapSnapshot = $state<SnapSettings>({ snapEnabled: false, gridEnabled: false, gridSize: 10 }); 34 + 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 + 39 + function updateVm() { 40 + statusVm = buildStatusBarVM(editorSnapshot, cursorSnapshot, persistenceSnapshot); 41 + } 42 + 43 + $effect(() => { 44 + const currentStore = store; 45 + editorSnapshot = currentStore.getState(); 46 + const unsubscribe = currentStore.subscribe((state) => { 47 + editorSnapshot = state; 48 + updateVm(); 49 + }); 50 + return () => unsubscribe(); 51 + }); 52 + 53 + $effect(() => { 54 + const currentCursor = cursor; 55 + cursorSnapshot = currentCursor.getState(); 56 + const unsubscribe = currentCursor.subscribe((state) => { 57 + cursorSnapshot = state; 58 + updateVm(); 59 + }); 60 + return () => unsubscribe(); 61 + }); 62 + 63 + $effect(() => { 64 + const currentPersistence = persistence; 65 + persistenceSnapshot = currentPersistence.get(); 66 + const unsubscribe = currentPersistence.subscribe((state) => { 67 + persistenceSnapshot = state; 68 + updateVm(); 69 + }); 70 + return () => unsubscribe(); 71 + }); 72 + 73 + $effect(() => { 74 + const currentSnap = snap; 75 + snapSnapshot = currentSnap.get(); 76 + const unsubscribe = currentSnap.subscribe((state) => { 77 + snapSnapshot = state; 78 + updateVm(); 79 + }); 80 + return () => unsubscribe(); 81 + }); 82 + 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 + function formatCursorCoord(value: number): string { 107 + return Math.round(value).toString(); 108 + } 109 + 110 + function formatSelection(): string { 111 + const selection = statusVm.selection; 112 + if (selection.count === 0) { 113 + return 'No selection'; 114 + } 115 + if (selection.count === 1) { 116 + const bounds = selection.bounds; 117 + const size = bounds ? ` ${Math.round(bounds.w)}×${Math.round(bounds.h)}` : ''; 118 + return `${selection.kind ?? 'shape'}${size}`; 119 + } 120 + return `${selection.count} items`; 121 + } 122 + 123 + function formatPersistenceSummary(): string { 124 + const state = statusVm.persistence; 125 + if (state.state === 'error') { 126 + return state.errorMsg ? `Error: ${state.errorMsg}` : 'Error'; 127 + } 128 + if (state.state === 'saving' || (state.pendingWrites ?? 0) > 0) { 129 + return 'Saving…'; 130 + } 131 + if (state.lastSavedAt) { 132 + const seconds = Math.floor((Date.now() - state.lastSavedAt) / 1000); 133 + if (seconds < 1) { 134 + return 'Saved just now'; 135 + } 136 + if (seconds < 60) { 137 + return `Saved ${seconds}s ago`; 138 + } 139 + const minutes = Math.floor(seconds / 60); 140 + return `Saved ${minutes}m ago`; 141 + } 142 + return 'Saved'; 143 + } 144 + 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 + function handleSnapToggle(event: Event) { 216 + const target = event.currentTarget as HTMLInputElement; 217 + snap.update((current) => ({ ...current, snapEnabled: target.checked })); 218 + } 219 + 220 + function handleGridToggle(event: Event) { 221 + const target = event.currentTarget as HTMLInputElement; 222 + snap.update((current) => ({ ...current, gridEnabled: target.checked })); 223 + } 224 + </script> 225 + 226 + <div class="status-bar"> 227 + <div class="status-section"> 228 + <span class="label">Tool</span> 229 + <span class="value">{statusVm.toolId}</span> 230 + <span class="mode">{statusVm.mode}</span> 231 + </div> 232 + 233 + <div class="status-section"> 234 + <span class="label">Cursor</span> 235 + <span class="value"> 236 + {formatCursorCoord(statusVm.cursorWorld.x)}, {formatCursorCoord(statusVm.cursorWorld.y)} 237 + </span> 238 + </div> 239 + 240 + <div class="status-section"> 241 + <span class="label">Selection</span> 242 + <span class="value">{formatSelection()}</span> 243 + </div> 244 + 245 + <div class="status-section snap"> 246 + <span class="label">Snap</span> 247 + <div class="toggle-row"> 248 + <label class="toggle"> 249 + <input type="checkbox" checked={snapSnapshot.snapEnabled} onchange={handleSnapToggle} /> 250 + <span>Main</span> 251 + </label> 252 + <label class="toggle"> 253 + <input type="checkbox" checked={snapSnapshot.gridEnabled} onchange={handleGridToggle} /> 254 + <span>Grid</span> 255 + </label> 256 + </div> 257 + </div> 258 + 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 + <div class="status-section persistence"> 278 + <span class="label">Sync</span> 279 + <span class="value" class:error={statusVm.persistence.state === 'error'}>{formatPersistenceSummary()}</span> 280 + </div> 281 + </div> 282 + 283 + <style> 284 + .status-bar { 285 + display: grid; 286 + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); 287 + gap: 12px; 288 + padding: 8px 16px; 289 + background: var(--surface-elevated); 290 + border-top: 1px solid var(--border); 291 + font-size: 13px; 292 + align-items: center; 293 + min-height: 48px; 294 + } 295 + 296 + .status-section { 297 + display: flex; 298 + flex-direction: column; 299 + gap: 2px; 300 + position: relative; 301 + } 302 + 303 + .status-section.snap, 304 + .status-section.zoom { 305 + align-items: flex-start; 306 + } 307 + 308 + .toggle-row { 309 + display: flex; 310 + gap: 8px; 311 + } 312 + 313 + .toggle { 314 + display: flex; 315 + align-items: center; 316 + gap: 4px; 317 + font-size: 12px; 318 + color: var(--text); 319 + } 320 + 321 + .toggle input { 322 + margin: 0; 323 + } 324 + 325 + .label { 326 + font-size: 11px; 327 + color: var(--text-muted); 328 + text-transform: uppercase; 329 + letter-spacing: 0.05em; 330 + } 331 + 332 + .value { 333 + font-weight: 500; 334 + color: var(--text); 335 + } 336 + 337 + .value.error { 338 + color: var(--error, #d14343); 339 + } 340 + 341 + .mode { 342 + font-size: 12px; 343 + 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 + } 391 + </style>
+41 -1
apps/web/src/lib/status.ts
··· 20 20 21 21 export type PersistenceManagerOptions = { sink?: PersistenceSinkOptions; liveQueryFn?: LiveQueryFactory }; 22 22 23 + export type SnapSettings = { snapEnabled: boolean; gridEnabled: boolean; gridSize: number }; 24 + 25 + export type SnapStore = { 26 + get(): SnapSettings; 27 + subscribe(listener: (snap: SnapSettings) => void): () => void; 28 + update(updater: (snap: SnapSettings) => SnapSettings): void; 29 + set(next: SnapSettings): void; 30 + }; 31 + 23 32 export type PersistenceManager = { 24 33 sink: PersistenceSink; 25 34 status: StatusStore; ··· 120 129 }; 121 130 } 122 131 123 - function createStatusStore(initial: PersistenceStatus): StatusStore { 132 + export function createStatusStore(initial: PersistenceStatus): StatusStore { 124 133 let value = initial; 125 134 const listeners = new Set<StatusListener>(); 126 135 ··· 166 175 167 176 return false; 168 177 } 178 + 179 + export function createSnapStore(initial?: Partial<SnapSettings>): SnapStore { 180 + const defaults: SnapSettings = { snapEnabled: false, gridEnabled: false, gridSize: 10 }; 181 + let value: SnapSettings = { ...defaults, ...initial }; 182 + const listeners = new Set<(snap: SnapSettings) => void>(); 183 + 184 + return { 185 + get() { 186 + return value; 187 + }, 188 + subscribe(listener) { 189 + listeners.add(listener); 190 + listener(value); 191 + return () => { 192 + listeners.delete(listener); 193 + }; 194 + }, 195 + update(updater) { 196 + value = updater(value); 197 + for (const listener of listeners) { 198 + listener(value); 199 + } 200 + }, 201 + set(next) { 202 + value = next; 203 + for (const listener of listeners) { 204 + listener(value); 205 + } 206 + }, 207 + }; 208 + }
+29 -1
apps/web/src/lib/tests/Canvas.history.test.ts
··· 43 43 }; 44 44 }); 45 45 46 - vi.mock("$lib/status", () => ({ createPersistenceManager: persistenceMocks.createPersistenceManager })); 46 + vi.mock( 47 + "$lib/status", 48 + () => ({ 49 + createPersistenceManager: persistenceMocks.createPersistenceManager, 50 + createStatusStore: () => ({ 51 + get: () => ({ backend: "indexeddb", state: "saved", pendingWrites: 0 }), 52 + subscribe: () => () => {}, 53 + update: () => {}, 54 + }), 55 + createSnapStore: () => ({ 56 + get: () => ({ snapEnabled: false, gridEnabled: false, gridSize: 10 }), 57 + subscribe: () => () => {}, 58 + update: () => {}, 59 + set: () => {}, 60 + }), 61 + }), 62 + ); 47 63 48 64 vi.mock("inkfinite-renderer", () => { 49 65 return { createRenderer: vi.fn(() => ({ dispose: vi.fn(), markDirty: vi.fn() })) }; ··· 259 275 }, 260 276 createWebDocRepo, 261 277 createPersistenceSink: vi.fn(() => ({ enqueueDocPatch: sinkEnqueueSpy, flush: vi.fn() })), 278 + buildStatusBarVM: () => ({ 279 + cursorWorld: { x: 0, y: 0 }, 280 + zoomPct: 100, 281 + toolId: "select", 282 + mode: "idle", 283 + selection: { count: 0 }, 284 + snap: { enabled: false }, 285 + persistence: { backend: "indexeddb", state: "saved" }, 286 + }), 287 + getSelectedShapes: () => [], 288 + getShapesOnCurrentPage: () => [], 289 + shapeBounds: () => ({ min: { x: 0, y: 0 }, max: { x: 0, y: 0 } }), 262 290 diffDoc: vi.fn(() => ({})), 263 291 InkfiniteDB: class {}, 264 292 __storeInstances: storeInstances,
+18
apps/web/src/lib/tests/Canvas.svelte.test.ts
··· 17 17 setActiveBoard: () => {}, 18 18 dispose: () => {}, 19 19 }), 20 + createStatusStore: () => ({ 21 + get: () => ({ backend: "indexeddb", state: "saved", pendingWrites: 0 }), 22 + subscribe: () => () => {}, 23 + update: () => {}, 24 + }), 25 + createSnapStore: () => ({ 26 + get: () => ({ snapEnabled: false, gridEnabled: false, gridSize: 10 }), 27 + subscribe: () => () => {}, 28 + update: () => {}, 29 + set: () => {}, 30 + }), 20 31 }; 21 32 }); 22 33 ··· 84 95 const style = window.getComputedStyle(editor as Element); 85 96 expect(style.display).toBe("flex"); 86 97 expect(style.flexDirection).toBe("column"); 98 + }); 99 + 100 + it("should render the status bar", () => { 101 + const { container } = render(Canvas); 102 + const statusBar = container.querySelector(".status-bar"); 103 + 104 + expect(statusBar).toBeTruthy(); 87 105 }); 88 106 89 107 it("should render all tool buttons in toolbar", () => {