web based infinite canvas
2
fork

Configure Feed

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

feat: Status Bar view model

+530 -25
+208 -25
TODO.txt
··· 128 128 and history-driven syncing. 129 129 130 130 ================================================================================ 131 - 14. Milestone N: Desktop packaging (Tauri) *wb-N* 131 + 14. Milestone N: Status Bar (Editor HUD) *wb-N* 132 132 ================================================================================ 133 133 134 - Goal: same app works as a desktop app with filesystem access. 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/types.ts 150 + [ ] 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/selectors.ts 183 + [ ] 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 + [ ] Define a minimal CursorState in core (NOT persisted): 191 + - cursorWorld: Vec2 192 + - cursorScreen?: Vec2 193 + - lastMoveAt: number 194 + 195 + [ ] 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/pointer.ts 206 + [ ] On pointermove (or mousemove when not captured): 207 + - compute world coords using camera.screenToWorld 208 + - dispatch updateCursor(world, screen) 209 + 210 + Performance: 211 + [ ] 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 + [ ] 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 + [ ] 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") 135 250 136 - Tauri + SvelteKit integration: 137 - [ ] Configure SvelteKit for static/SPA output 138 - [ ] Ensure SSR is disabled for desktop build 139 - [ ] Configure Tauri to load the built assets 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. 140 259 141 - File dialogs + FS: 142 - [ ] Implement "Save As…" using Tauri dialog + fs APIs 143 - [ ] Implement "Open…" using Tauri dialog + fs APIs 144 - [ ] Add recent files list (v0: store paths in Tauri local storage) 260 + Formatting: 261 + [ ] Cursor formatting: 262 + - v0: integers 263 + - v1: configurable precision (e.g. 0.1 units when zoomed in) 145 264 146 265 (DoD): 147 - - Desktop app opens/saves JSON files on disk and reopens them correctly. 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. 148 316 149 317 ================================================================================ 150 318 15. Milestone O: Export (PNG/SVG) *wb-O* ··· 165 333 (DoD): 166 334 - One-click export works in both web and desktop. 167 335 336 + ================================================================================ 337 + 16. Milestone P: Desktop packaging (Tauri) *wb-P* 338 + ================================================================================ 339 + 340 + Goal: same app works as a desktop app with filesystem access. 341 + 342 + Tauri + SvelteKit integration: 343 + [ ] Configure SvelteKit for static/SPA output 344 + [ ] Ensure SSR is disabled for desktop build 345 + [ ] Configure Tauri to load the built assets 346 + 347 + File dialogs + FS: 348 + [ ] Implement "Save As…" using Tauri dialog + fs APIs 349 + [ ] Implement "Open…" using Tauri dialog + fs APIs 350 + [ ] Add recent files list (v0: store paths in Tauri local storage) 351 + 352 + (DoD): 353 + - Desktop app opens/saves JSON files on disk and reopens them correctly. 168 354 169 355 ================================================================================ 170 - 16. Milestone P: Performance + big docs (pragmatic) *wb-P* 356 + 17. Milestone Q: Performance + big docs (pragmatic) *wb-Q* 171 357 ================================================================================ 172 358 173 359 Goal: the editor stays responsive with many shapes. ··· 175 361 [ ] Add spatial index (v0: simple grid buckets): 176 362 - rebuild index on doc changes 177 363 - query nearby shapes for hit testing 178 - 179 364 [ ] Add view culling: 180 365 - compute viewport bounds in world space 181 366 - render only shapes whose bounds intersect viewport 182 - 183 367 [ ] Reduce redraw frequency: 184 368 - rAF only while dirty 185 369 - optionally batch multiple store updates into one redraw 186 - 187 370 [ ] Add microbench harness: 188 371 - generate 10k shapes doc 189 372 - measure hit test and render time ··· 192 375 - 10k simple shapes pans/zooms smoothly on a typical machine. 193 376 194 377 ================================================================================ 195 - 17. Milestone Q: File Browser (web: Dexie inspector, desktop: FS) *wb-Q* 378 + 18. Milestone R: File Browser (web: Dexie inspector, desktop: FS) *wb-R* 196 379 ================================================================================ 197 380 198 - Goal: A unified “Open board” experience: 381 + Goal: A unified "Open board" experience: 199 382 - Web: browse Dexie-backed boards + a useful persistence/migration inspector 200 383 - Desktop: browse real directories/files (native file browser semantics) 201 384 202 385 -------------------------------------------------------------------------------- 203 - Q1. Shared UX contracts 386 + R1. Shared UX contracts 204 387 -------------------------------------------------------------------------------- 205 388 206 389 /packages/core/src/persist/DocRepo.ts: ··· 219 402 - Svelte UI can render the browser purely from the ViewModel. 220 403 221 404 -------------------------------------------------------------------------------- 222 - Q2. Web: Boards list + Dexie “Inspector” drawer 405 + R2. Web: Boards list + Dexie "Inspector" drawer 223 406 -------------------------------------------------------------------------------- 224 407 225 408 /apps/web/src/lib/filebrowser/FileBrowser.svelte: ··· 229 412 - open / create / rename / delete 230 413 231 414 Inspector drawer (selected board): 232 - [ ] Show “Storage: IndexedDB (Dexie)” 415 + [ ] Show "Storage: IndexedDB (Dexie)" 233 416 [ ] Show schema info: 234 417 - declared schema version (your constant) 235 418 - installed schema version (best-effort display) ··· 239 422 - last updatedAt 240 423 [ ] Show migration info: 241 424 - list applied migrations from migrations table (id + appliedAt) 242 - - show “pending” migrations if any (based on known list vs applied) 425 + - show "pending" migrations if any (based on known list vs applied) 243 426 244 427 Safe deletes: 245 428 [ ] deleteBoard must be a single atomic transaction (boards + related tables) ··· 248 431 - Web: you can browse boards, open one, and verify migrations + row counts. 249 432 250 433 -------------------------------------------------------------------------------- 251 - Q3. Desktop: real directory + files (Tauri) 434 + R3. Desktop: real directory + files (Tauri) 252 435 -------------------------------------------------------------------------------- 253 436 254 - [ ] Add “Workspace folder” concept: 437 + [ ] Add "Workspace folder" concept: 255 438 - pick directory 256 439 - remember last workspace path 257 440 [ ] Implement directory listing: ··· 268 451 - Desktop: pick a folder, browse files, open/save boards from disk. 269 452 270 453 -------------------------------------------------------------------------------- 271 - Q4. Parity behaviors 454 + R4. Parity behaviors 272 455 -------------------------------------------------------------------------------- 273 456 274 457 [ ] Same shortcuts: ··· 281 464 - Web and desktop feel like the same app, with storage differences made explicit. 282 465 283 466 ================================================================================ 284 - 18. Milestone R: Quality polish (what makes it feel "real") *wb-R* 467 + 19. Milestone S: Quality polish (what makes it feel "real") *wb-S* 285 468 ================================================================================ 286 469 287 470 Goal: the UX crosses the "this is legit" threshold.
+63
packages/core/src/cursor.ts
··· 1 + import { BehaviorSubject, type Subscription } from "rxjs"; 2 + import { Vec2 } from "./math"; 3 + 4 + /** 5 + * Cursor position + timing in world/screen space. 6 + * 7 + * CursorState is intentionally separate from EditorState so it can be updated 8 + * with high frequency (e.g., on pointer move) without touching history or 9 + * triggering document persistence. 10 + */ 11 + export type CursorState = { cursorWorld: Vec2; cursorScreen?: Vec2; lastMoveAt: number }; 12 + 13 + export const CursorState = { 14 + /** 15 + * Create a cursor state positioned at origin with no screen point. 16 + */ 17 + create(world?: Vec2, screen?: Vec2, timestamp = Date.now()): CursorState { 18 + return { 19 + cursorWorld: Vec2.clone(world ?? { x: 0, y: 0 }), 20 + cursorScreen: screen ? Vec2.clone(screen) : undefined, 21 + lastMoveAt: timestamp, 22 + }; 23 + }, 24 + }; 25 + 26 + export type CursorListener = (state: CursorState) => void; 27 + 28 + /** 29 + * Store that tracks cursor movement separately from the undoable editor state. 30 + */ 31 + export class CursorStore { 32 + private readonly state$: BehaviorSubject<CursorState>; 33 + 34 + constructor(initialState?: CursorState) { 35 + this.state$ = new BehaviorSubject(initialState ?? CursorState.create()); 36 + } 37 + 38 + /** 39 + * Read the latest cursor snapshot. 40 + */ 41 + getState(): CursorState { 42 + return this.state$.value; 43 + } 44 + 45 + /** 46 + * Subscribe to cursor updates. 47 + */ 48 + subscribe(listener: CursorListener): () => void { 49 + const subscription: Subscription = this.state$.subscribe(listener); 50 + return () => subscription.unsubscribe(); 51 + } 52 + 53 + /** 54 + * Update the cursor position without touching editor history/persistence. 55 + */ 56 + updateCursor(world: Vec2, screen?: Vec2, timestamp = Date.now()): void { 57 + this.state$.next({ 58 + cursorWorld: Vec2.clone(world), 59 + cursorScreen: screen ? Vec2.clone(screen) : undefined, 60 + lastMoveAt: timestamp, 61 + }); 62 + } 63 + }
+2
packages/core/src/index.ts
··· 1 1 export * from "./actions"; 2 2 export * from "./camera"; 3 + export * from "./cursor"; 3 4 export * from "./geom"; 4 5 export * from "./history"; 5 6 export * from "./math"; ··· 8 9 export * from "./persistence/web"; 9 10 export * from "./reactivity"; 10 11 export * from "./tools"; 12 + export * from "./ui/statusbar";
+115
packages/core/src/ui/statusbar.ts
··· 1 + import type { CursorState } from "../cursor"; 2 + import { shapeBounds } from "../geom"; 3 + import { type Box2, Box2 as Box2Ops, type Vec2, Vec2 as Vec2Ops } from "../math"; 4 + import type { EditorState, ToolId } from "../reactivity"; 5 + import { getSelectedShapes } from "../reactivity"; 6 + 7 + export type SelectionSummary = { count: number; kind?: string; bounds?: { w: number; h: number } }; 8 + 9 + export type SnapSummary = { enabled: boolean; gridSize?: number; angleStepDeg?: number }; 10 + 11 + export type PersistenceStatus = { 12 + backend: "indexeddb"; 13 + state: "saved" | "saving" | "error"; 14 + lastSavedAt?: number; 15 + pendingWrites?: number; 16 + errorMsg?: string; 17 + }; 18 + 19 + export type StatusBarVM = { 20 + cursorWorld: Vec2; 21 + cursorScreen?: Vec2; 22 + zoomPct: number; 23 + toolId: ToolId; 24 + mode: "idle" | "dragging" | "panning" | "text-edit" | string; 25 + selection: SelectionSummary; 26 + snap: SnapSummary; 27 + persistence: PersistenceStatus; 28 + }; 29 + 30 + /** 31 + * Convert the current camera zoom factor into a human-friendly percentage. 32 + */ 33 + export function getZoomPct(state: EditorState): number { 34 + const pct = state.camera.zoom * 100; 35 + if (!Number.isFinite(pct)) { 36 + return 100; 37 + } 38 + return Math.round(pct); 39 + } 40 + 41 + /** 42 + * Get the active tool identifier from UI state. 43 + */ 44 + export function getToolId(state: EditorState): ToolId { 45 + return state.ui.toolId; 46 + } 47 + 48 + /** 49 + * Summarize the current selection for display. 50 + */ 51 + export function getSelectionSummary(state: EditorState): SelectionSummary { 52 + const shapes = getSelectedShapes(state); 53 + const count = shapes.length; 54 + 55 + if (count === 0) { 56 + return { count: 0 }; 57 + } 58 + 59 + const combinedBounds = combineBounds(shapes.map((shape) => shapeBounds(shape))); 60 + 61 + const kind = count === 1 62 + ? shapes[0].type 63 + : (shapes.every((shape) => shape.type === shapes[0].type) ? shapes[0].type : "mixed"); 64 + 65 + return { 66 + count, 67 + kind, 68 + bounds: combinedBounds ? { w: Box2Ops.width(combinedBounds), h: Box2Ops.height(combinedBounds) } : undefined, 69 + }; 70 + } 71 + 72 + const SNAP_DEFAULT: SnapSummary = { enabled: false }; 73 + 74 + /** 75 + * Provide safe defaults for snap/grid summary until features are enabled. 76 + */ 77 + export function getSnapSummary(_: EditorState): SnapSummary { 78 + return { ...SNAP_DEFAULT }; 79 + } 80 + 81 + /** 82 + * Compose the full StatusBar view model from editor/cursor/persistence state. 83 + */ 84 + export function buildStatusBarVM( 85 + editorState: EditorState, 86 + cursorState: CursorState, 87 + persistence: PersistenceStatus, 88 + mode: StatusBarVM["mode"] = "idle", 89 + ): StatusBarVM { 90 + return { 91 + cursorWorld: Vec2Ops.clone(cursorState.cursorWorld), 92 + cursorScreen: cursorState.cursorScreen ? Vec2Ops.clone(cursorState.cursorScreen) : undefined, 93 + zoomPct: getZoomPct(editorState), 94 + toolId: getToolId(editorState), 95 + mode, 96 + selection: getSelectionSummary(editorState), 97 + snap: getSnapSummary(editorState), 98 + persistence: { ...persistence }, 99 + }; 100 + } 101 + 102 + function combineBounds(boxes: Box2[]): Box2 | null { 103 + if (boxes.length === 0) { 104 + return null; 105 + } 106 + let combined = Box2Ops.clone(boxes[0]); 107 + for (let index = 1; index < boxes.length; index++) { 108 + const box = boxes[index]; 109 + combined = { 110 + min: { x: Math.min(combined.min.x, box.min.x), y: Math.min(combined.min.y, box.min.y) }, 111 + max: { x: Math.max(combined.max.x, box.max.x), y: Math.max(combined.max.y, box.max.y) }, 112 + }; 113 + } 114 + return combined; 115 + }
+142
packages/core/tests/statusbar.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { CursorState as CursorStateOps } from "../src/cursor"; 3 + import { ShapeRecord } from "../src/model"; 4 + import type { ShapeRecord as ShapeRecordType } from "../src/model"; 5 + import { EditorState } from "../src/reactivity"; 6 + import { 7 + buildStatusBarVM, 8 + getSelectionSummary, 9 + getSnapSummary, 10 + getToolId, 11 + getZoomPct, 12 + type PersistenceStatus, 13 + } from "../src/ui/statusbar"; 14 + 15 + describe("Status bar selectors", () => { 16 + describe("getZoomPct", () => { 17 + it("rounds zoom values to percentages", () => { 18 + const state = { ...EditorState.create(), camera: { x: 0, y: 0, zoom: 1.234 } }; 19 + expect(getZoomPct(state)).toBe(123); 20 + }); 21 + 22 + it("falls back to 100 for invalid zoom", () => { 23 + const state = { ...EditorState.create(), camera: { x: 0, y: 0, zoom: Number.NaN } }; 24 + expect(getZoomPct(state)).toBe(100); 25 + }); 26 + }); 27 + 28 + describe("getToolId", () => { 29 + it("returns the active tool id", () => { 30 + const base = EditorState.create(); 31 + const state: EditorState = { ...base, ui: { ...base.ui, toolId: "rect", currentPageId: null, selectionIds: [] } }; 32 + expect(getToolId(state)).toBe("rect"); 33 + }); 34 + }); 35 + 36 + describe("getSelectionSummary", () => { 37 + it("returns zero summary when nothing is selected", () => { 38 + const state = buildState([], []); 39 + expect(getSelectionSummary(state)).toEqual({ count: 0 }); 40 + }); 41 + 42 + it("describes a single selected shape", () => { 43 + const rect = ShapeRecord.createRect( 44 + "page-1", 45 + 10, 46 + 20, 47 + { w: 40, h: 20, fill: "#000", stroke: "#fff", radius: 0 }, 48 + "shape-rect", 49 + ); 50 + const state = buildState([rect], ["shape-rect"]); 51 + expect(getSelectionSummary(state)).toEqual({ count: 1, kind: "rect", bounds: { w: 40, h: 20 } }); 52 + }); 53 + 54 + it("summarizes multiple selections with combined bounds and mixed kind", () => { 55 + const rect = ShapeRecord.createRect( 56 + "page-1", 57 + 10, 58 + 20, 59 + { w: 40, h: 20, fill: "#000", stroke: "#fff", radius: 0 }, 60 + "shape-rect", 61 + ); 62 + const ellipse = ShapeRecord.createEllipse( 63 + "page-1", 64 + 100, 65 + 50, 66 + { w: 20, h: 20, fill: "#f00", stroke: "#111" }, 67 + "shape-ellipse", 68 + ); 69 + const state = buildState([rect, ellipse], ["shape-rect", "shape-ellipse"]); 70 + 71 + expect(getSelectionSummary(state)).toEqual({ count: 2, kind: "mixed", bounds: { w: 110, h: 50 } }); 72 + }); 73 + 74 + it("marks kind when all selected shapes match", () => { 75 + const rectA = ShapeRecord.createRect( 76 + "page-1", 77 + 0, 78 + 0, 79 + { w: 10, h: 10, fill: "#000", stroke: "#fff", radius: 0 }, 80 + "shape-1", 81 + ); 82 + const rectB = ShapeRecord.createRect( 83 + "page-1", 84 + 20, 85 + 20, 86 + { w: 10, h: 10, fill: "#111", stroke: "#eee", radius: 0 }, 87 + "shape-2", 88 + ); 89 + const state = buildState([rectA, rectB], ["shape-1", "shape-2"]); 90 + 91 + expect(getSelectionSummary(state)).toEqual({ count: 2, kind: "rect", bounds: { w: 30, h: 30 } }); 92 + }); 93 + }); 94 + 95 + describe("getSnapSummary", () => { 96 + it("returns safe defaults when snapping is disabled", () => { 97 + const state = buildState([], []); 98 + expect(getSnapSummary(state)).toEqual({ enabled: false }); 99 + }); 100 + }); 101 + 102 + describe("buildStatusBarVM", () => { 103 + it("composes slices into a status bar view model", () => { 104 + const rect = ShapeRecord.createRect( 105 + "page-1", 106 + 0, 107 + 0, 108 + { w: 50, h: 50, fill: "#000", stroke: "#fff", radius: 0 }, 109 + "shape-rect", 110 + ); 111 + const state = buildState([rect], ["shape-rect"]); 112 + const cursorState = CursorStateOps.create({ x: 5, y: 6 }, { x: 1, y: 2 }, 42); 113 + const persistence: PersistenceStatus = { backend: "indexeddb", state: "saving", pendingWrites: 1 }; 114 + 115 + const vm = buildStatusBarVM(state, cursorState, persistence, "dragging"); 116 + 117 + expect(vm.cursorWorld).toEqual({ x: 5, y: 6 }); 118 + expect(vm.cursorScreen).toEqual({ x: 1, y: 2 }); 119 + expect(vm.zoomPct).toBe(100); 120 + expect(vm.toolId).toBe("select"); 121 + expect(vm.mode).toBe("dragging"); 122 + expect(vm.selection).toEqual({ count: 1, kind: "rect", bounds: { w: 50, h: 50 } }); 123 + expect(vm.snap).toEqual({ enabled: false }); 124 + expect(vm.persistence).toEqual({ backend: "indexeddb", state: "saving", pendingWrites: 1 }); 125 + expect(vm.cursorWorld).not.toBe(cursorState.cursorWorld); 126 + expect(vm.persistence).not.toBe(persistence); 127 + }); 128 + }); 129 + }); 130 + 131 + function buildState(shapes: ShapeRecordType[], selectionIds: string[]) { 132 + const base = EditorState.create(); 133 + const pageId = "page-1"; 134 + const docShapes = Object.fromEntries(shapes.map((shape) => [shape.id, shape])); 135 + const page = { id: pageId, name: "Page 1", shapeIds: shapes.map((shape) => shape.id) }; 136 + 137 + return { 138 + ...base, 139 + doc: { pages: { [pageId]: page }, shapes: docShapes, bindings: {} }, 140 + ui: { ...base.ui, currentPageId: pageId, selectionIds }, 141 + }; 142 + }