web based infinite canvas

feat: dexie/persistence

+1125 -533
+86 -488
TODO.txt
··· 1 - ============================================================================= 2 Author intent: 3 - Build a Svelte-native editor core (TS) + renderer + UI. 4 - Keep the "engine" framework-agnostic so Web + Tauri share it. ··· 8 - [ ] Task 9 - (DoD) Definition of Done for the milestone 10 - Files shown are ideas, not requirements 11 - ============================================================================== 12 - 1. Milestone A: Repo skeleton + dev loop *wb-A* 13 - ============================================================================== 14 15 Goal: a monorepo that can run a blank canvas in web + desktop. 16 ··· 40 - `pnpm dev:desktop` launches a Tauri window that shows the same canvas. 41 - `pnpm test` runs at least 1 passing core test. 42 43 - ============================================================================== 44 - 2. Milestone B: Math + coordinate systems *wb-B* 45 - ============================================================================== 46 - 47 - Goal: a correct, testable camera transform (world <-> screen). 48 - 49 - Core primitives (/packages/core/src/math.ts): 50 - [x] Define Vec2 { x, y } + helpers: 51 - - add, sub, mulScalar, len, normalize, dot 52 - [x] Define Box2 { min: Vec2, max: Vec2 }: 53 - - fromPoints, containsPoint, intersectsBox 54 - [x] Define Mat3 (2D affine) or equivalent: 55 - - identity 56 - - translate(tx, ty) 57 - - scale(sx, sy) 58 - - rotate(theta) 59 - - multiply(a, b) 60 - - transformPoint(m, p) 61 - 62 - Camera (/packages/core/src/camera.ts): 63 - [x] Define Camera { x, y, zoom } (world origin + scale) 64 - [x] Implement worldToScreen(camera, p) 65 - [x] Implement screenToWorld(camera, p) 66 - [x] Implement cameraPan(camera, deltaScreen) -> camera' 67 - [x] Implement cameraZoomAt(camera, factor, anchorScreenPoint) -> camera' 68 - 69 - Tests (/packages/core/tests/camera.test.ts): 70 - [x] worldToScreen(screenToWorld(p)) round-trip within epsilon 71 - [x] zoomAt keeps anchor point stable (screen position unchanged) 72 - [x] pan moves world under cursor as expected 73 74 - (DoD): 75 - - All math/camera functions are unit-tested and pass. 76 77 - ============================================================================== 78 - 3. Milestone C: Document model (records) *wb-C* 79 - ============================================================================== 80 81 - Goal: define the minimal data model that can represent a drawing. 82 83 - Records & ID (/packages/core/src/model): 84 - [x] Implement createId(prefix) -> uuid (v4) 85 - [x] Define PageRecord { id, name, shapeIds: string[] } 86 - [x] Define ShapeRecord base: 87 - - id, type, pageId 88 - - x, y, rot 89 - - props: object (type-specific) 90 91 - [x] Define shape types (minimal): 92 - - rect: { w, h, fill, stroke, radius } 93 - - ellipse: { w, h, fill, stroke } 94 - - line: { a: Vec2, b: Vec2, stroke, width } 95 - - arrow: { a: Vec2, b: Vec2, stroke, width } 96 - - text: { text, fontSize, fontFamily, color, w? } 97 98 - [x] Define BindingRecord (for arrow endpoints): 99 - - id, type: "arrow-end" 100 - - fromShapeId (arrow id) 101 - - toShapeId (target shape id) 102 - - handle: "start" | "end" 103 - - anchor: e.g. { kind: "center" } for v0 104 105 - Validation: 106 - [x] validateDoc(doc) -> { ok | errors[] } 107 108 - (DoD): 109 - - You can serialize a doc with a page + 1 shape to JSON and validate it. 110 111 - ============================================================================== 112 - 4. Milestone D: Store + selectors (reactive core) *wb-D* 113 - ============================================================================== 114 115 - Goal: a fast, deterministic state container for the editor using RxJS 116 117 - Store (/packages/core/src/reactivity.ts) - RxJS + SvelteKit (runes) friendly 118 119 - Core types: 120 - [x] Define EditorState: 121 - - doc: { pages, shapes, bindings } 122 - - ui: { currentPageId, selectionIds: string[], toolId: ToolId } 123 - - camera: { x, y, zoom } 124 125 - RxJS store (BehaviorSubject-backed): 126 - [x] Implement createEditorStore(initial: EditorState) that exposes: 127 - - state$: Observable<EditorState> (read stream) 128 - - getState(): EditorState (sync snapshot) 129 - - setState(updater: (s) => s): void (mutation API) 130 - - subscribe(listener): () => void (Svelte-compatible subscribe) 131 - - select(selector, eq?): Observable<T> (derived streams) 132 133 - Notes: 134 - - Use BehaviorSubject so new subscribers immediately get the current value. 135 - - subscribe must return an unsubscribe function. 136 137 - Selectors (pure functions, no RxJS): 138 - [x] Implement selectors 139 - - getCurrentPage(state) 140 - - getShapesOnCurrentPage(state) 141 - - getSelectedShapes(state) 142 143 - Invariants (pick "repair" and test it): 144 - [x] Implement enforceInvariants(state): EditorState (repair strategy): 145 - - selectionIds := selectionIds filtered to existing shapes 146 - - currentPageId must exist: 147 - - if missing, set to first existing page 148 - - if no pages exist, create a default page and set it 149 - [x] Ensure setState always runs enforceInvariants before publishing next state 150 151 - Tests 152 - [x] subscribe immediately receives current state upon subscription (BehaviorSubject behavior) 153 - [x] subscribe fires exactly once per setState call 154 - [x] invariants are enforced on any update (selection filtered, page fixed/created) 155 - 156 - (DoD): 157 - - Renderer can subscribe to state$ (or subscribe()) and redraw on any change. 158 - - SvelteKit can bridge to runes with $effect unsubscribe cleanup. 159 - 160 - ============================================================================== 161 - 5. Milestone E: Canvas renderer (read-only) *wb-E* 162 - ============================================================================== 163 - 164 - Goal: draw the document from state, no interactivity yet. 165 - 166 - Renderer (/packages/renderer): 167 - [x] createRenderer(canvas, store) -> { dispose() } 168 - [x] Implement render loop strategy: 169 - - requestAnimationFrame redraw on "dirty" flag 170 - - mark dirty on store updates 171 - [x] Implement draw pipeline: 172 - - clear canvas 173 - - apply camera transform 174 - - draw shapes (rect/ellipse/line/arrow/text) 175 - - draw selection outline if selectionIds non-empty 176 - [x] Implement text measurement fallback: 177 - - if text shape has w? else measureText and derive bounds 178 - [x] Implement pixel ratio handling: 179 - - set canvas width/height by devicePixelRatio 180 - - scale context accordingly 181 - 182 - (DoD): 183 - - With a hardcoded doc in the store, shapes appear at correct coordinates 184 - and stay stable while resizing the browser window. 185 - 186 - ============================================================================== 187 - 6. Milestone F: Hit testing (picking) *wb-F* 188 - ============================================================================== 189 - 190 - Goal: determine what the cursor is over. 191 - 192 - Geometry (/packages/core/src/geom): 193 - [x] shapeBounds(shape) -> Box2 194 - [x] pointInRect(p, rectShape) -> bool 195 - [x] pointInEllipse(p, ellipseShape) -> bool 196 - [x] pointNearSegment(p, a, b, tolerance) -> bool 197 - [x] hitTestPoint(state, worldPoint) -> shapeId? (topmost wins) 198 - 199 - Layering: 200 - [x] Define draw order = page.shapeIds order 201 - [x] hitTest uses reverse order for topmost selection 202 - 203 - (DoD): 204 - - You can hover shapes and log the hit shape id (no selection yet). 205 - 206 - ============================================================================== 207 - 7. Milestone G: Input system (pointer + keyboard) *wb-G* 208 - ============================================================================== 209 - 210 - Goal: normalize events into editor actions. 211 - 212 - Input adapter (/apps/web/src/lib/input): 213 - [x] Capture pointerdown/move/up 214 - [x] Convert screen coords -> world coords using camera 215 - [x] Track pointer state: isDown, startWorld, lastWorld, buttons 216 - 217 - Keyboard: 218 - [x] Capture keydown/keyup 219 - [x] Normalize modifiers (ctrl/cmd, shift, alt) 220 - 221 - Action bus (/packages/core/src/actions.ts): 222 - [x] Define Action union: 223 - - PointerDown, PointerMove, PointerUp 224 - - KeyDown, KeyUp 225 - - Wheel (for zoom) 226 - [x] dispatch(action) -> store updates via tool state machine (next milestone) 227 - 228 - (DoD): 229 - - You can pan/zoom camera via wheel/drag with a temporary "camera tool" 230 - (even before selection tool exists). 231 - 232 - ============================================================================== 233 - 8. Milestone H: Tool state machine (foundation) *wb-H* 234 - ============================================================================== 235 - 236 - Goal: tools are explicit, testable state machines (RxJS based) 237 - 238 - Tools (/packages/core/src/tools): 239 - [x] Define ToolId: "select" | "rect" | "ellipse" | "line" | "arrow" | "text" | "pen" 240 - [x] Define Tool interface: 241 - - id 242 - - onEnter(state) 243 - - onAction(state, action) -> newState 244 - - onExit(state) 245 - 246 - Tool router: 247 - [x] routeAction(state, action) -> newState (delegates to active tool) 248 - 249 - (DoD): 250 - - A dummy tool can consume pointer events and update state deterministically. 251 - 252 - 253 - ============================================================================== 254 - 9. Milestone I: Select/move tool (MVP interaction) *wb-I* 255 - ============================================================================== 256 - 257 - Goal: select shapes and drag them. 258 - 259 - Selection: 260 - [x] PointerDown: 261 - - if hit shape: selection = [shapeId] (or add with shift) 262 - - else selection = [] 263 - [x] PointerMove while dragging selected: 264 - - translate selected shapes by deltaWorld 265 - [x] PointerUp: 266 - - end drag 267 - 268 - Marquee select (smallest slices): 269 - [x] Implement marquee start (on empty canvas pointerdown) 270 - [x] Render marquee rectangle overlay 271 - [x] On pointerup, select shapes whose bounds intersect marquee 272 - 273 - UX: 274 - [x] Escape clears selection 275 - [x] Delete removes selected shapes 276 - 277 - (DoD): 278 - - You can select and move shapes reliably. 279 - 280 - ============================================================================== 281 - 10. Milestone J: Create basic shapes via tools *wb-J* 282 - ============================================================================== 283 - 284 - Goal: place shapes with dedicated tools. 285 - 286 - Rect tool (repeat pattern for others): 287 - [x] PointerDown on canvas: 288 - - create a draft rect shape with w/h=0 at startWorld 289 - - selection = [newId] 290 - [x] PointerMove: 291 - - update w/h based on currentWorld - startWorld 292 - [x] PointerUp: 293 - - if too small, delete it (click-cancel behavior) 294 - - else finalize 295 - 296 - [x] Implement ellipse tool (same mechanics) 297 - [x] Implement line tool (a=startWorld, b=currentWorld) 298 - [x] Implement arrow tool (same as line but type="arrow") 299 - [x] Implement text tool: 300 - - click to create text shape 301 - - open in-place editor overlay (contenteditable) in Svelte 302 303 - (DoD): 304 - - You can draw rect/ellipse/line/arrow/text on the canvas. 305 306 ================================================================================ 307 11. Milestone K: Bindings for arrows (v0) *wb-K* 308 ================================================================================ 309 310 - Goal: arrow endpoints can "stick" to shapes. 311 - 312 - Binding creation: 313 - [x] On arrow finalize: 314 - - hit test start/end points 315 - - if point hits a target shape, create binding record for that handle 316 - 317 - Binding resolution: 318 - [x] resolveArrowEndpoints(state, arrowId) -> { a, b } in world coords 319 - - if bound, compute endpoint at target shape bounds center (v0) 320 - - else use arrow props a/b 321 - 322 - Live update: 323 - [x] When a target shape moves, bound arrow rerenders automatically 324 - - no mutation required if resolve happens during render 325 - 326 - (DoD): 327 - - Arrows remain connected to moved shapes (center-to-center is fine for v0). 328 329 ================================================================================ 330 12. Milestone L: History (undo/redo) *wb-L* 331 ================================================================================ 332 333 - Goal: every user-visible change is undoable. 334 - 335 - History model (/packages/core/src/history): 336 - [x] Define Command: 337 - - do(state) -> state 338 - - undo(state) -> state 339 - [x] Wrap mutations as commands: 340 - - CreateShapeCommand 341 - - UpdateShapeCommand (with before/after snapshot) 342 - - DeleteShapesCommand 343 - - SetSelectionCommand 344 - - SetCameraCommand 345 - [x] Implement stacks: 346 - - undoStack, redoStack 347 - [x] Wire shortcuts: 348 - - Ctrl/Cmd+Z undo 349 - - Ctrl/Cmd+Shift+Z redo 350 - 351 - (DoD): 352 - - Undo/redo works for create/move/delete and camera changes. 353 - 354 - ============================================================================== 355 - 13. Milestone M: Persistence (web) via Dexie + History integration *wb-M* 356 - ============================================================================== 357 - 358 - Goal: 359 - - Persist boards to IndexedDB using Dexie (with schema versions + data upgrades). 360 - - Integrate with the (already implemented) history/command system so: 361 - - do/undo/redo that changes the document is persisted 362 - - non-document UI changes (selection/tool/camera, etc.) are NOT persisted 363 - 364 - Storage shape (v0 choice): Normalized tables 365 - - boards, pages, shapes, bindings + meta + migrations 366 - 367 - ------------------------------------------------------------------------------ 368 - M1. Dexie DB + schema v1 369 - ------------------------------------------------------------------------------ 370 - 371 - /apps/web/src/lib/db.ts 372 - [ ] Create a Dexie DB class `InkfiniteDB` extending Dexie. 373 - [ ] db.version(1).stores({ 374 - boards: 'id, name, createdAt, updatedAt', 375 - pages: '[boardId+id], boardId, updatedAt', 376 - shapes: '[boardId+id], boardId, type, updatedAt', 377 - bindings: '[boardId+id], boardId, type, updatedAt', 378 - meta: 'key', 379 - migrations: 'id, appliedAt' 380 - }) 381 - 382 - [ ] Implement schema upgrade hook(s): 383 - - db.version(N).upgrade(tx => runMigrations(tx)) 384 - 385 - (DoD): 386 - - DB opens; can insert + read a boards row. 387 - 388 - ------------------------------------------------------------------------------ 389 - M2. Repo API (what the editor uses) 390 - ------------------------------------------------------------------------------ 391 - 392 - /packages/core/src/persist/web.ts 393 - [ ] Define BoardMeta: { id, name, createdAt, updatedAt }. 394 - [ ] Implement DocRepo methods: 395 - - listBoards(): Promise<BoardMeta[]> 396 - - createBoard(name): Promise<boardId> 397 - - renameBoard(boardId, name): Promise<void> 398 - - deleteBoard(boardId): Promise<void> 399 - - loadDoc(boardId): Promise<{ pages, shapes, bindings, order }> 400 - - applyDocPatch(boardId, patch): Promise<void> 401 - 402 - Transactions: 403 - [ ] Ensure create/delete/applyDocPatch are atomic using db.transaction('rw', ...). 404 - 405 - Bulk writes: 406 - [ ] applyDocPatch uses bulkPut / bulkAdd when writing many shapes/pages/bindings. 407 - 408 - (DoD): 409 - - createBoard -> applyDocPatch -> loadDoc round-trips a simple doc. 410 - 411 - ------------------------------------------------------------------------------ 412 - M3. Migration system (schema + logical migrations) 413 - ------------------------------------------------------------------------------ 414 - 415 - Schema versions: 416 - [ ] When adding indexes/stores: 417 - - bump db.version(N).stores(...) 418 - - attach .upgrade(tx => ...) for data backfill/reshape 419 - 420 - Logical migrations: 421 - [ ] Create `runMigrations(tx)` called from Version.upgrade(): 422 - - reads applied ids from migrations table 423 - - runs missing migrations in order 424 - - writes (id, appliedAt) as each completes 425 426 - Smallest initial migrations: 427 - [ ] MIG-0001: backfill boards.createdAt / updatedAt if missing 428 - [ ] MIG-0002: ensure every board has a default page row 429 430 - (DoD): 431 - - Upgrading applies each logical migration exactly once. 432 433 - ------------------------------------------------------------------------------ 434 - M4. History integration (persist on do/undo/redo) 435 - ------------------------------------------------------------------------------ 436 - 437 - Goal: 438 - - Any history step that changes the document produces a persistence write. 439 - - Undo/redo produces persistence writes too. 440 - - UI-only commands do not touch IndexedDB. 441 - 442 - Step 1: Tag commands with persistence intent 443 - [ ] In your Command types (history layer), add a doc-impact tag: 444 - - affectsDoc: boolean 445 - - OR kind: 'doc' | 'ui' | 'camera' 446 - 447 - Rules (v0): 448 - - 'doc' commands persist 449 - - 'ui' and 'camera' do not persist 450 - 451 - Step 2: Provide a single hook point in history 452 - [ ] Add a history callback or event: 453 - - onApplied({ kind, beforeState, afterState, commandId, op: 'do'|'undo'|'redo' }) 454 - This MUST fire after every do/undo/redo. 455 - 456 - Step 3: Compute a persistence patch (smallest practical approach) 457 - [ ] Implement `diffDoc(before.doc, after.doc) -> DocPatch` that outputs: 458 - - upserts: pages[], shapes[], bindings[] 459 - - deletes: pageIds[], shapeIds[], bindingIds[] 460 - - order updates (page order, per-page shape order) 461 - 462 - Note: 463 - - Keep it minimal: only compare ids + updatedAt (or stable hash) initially. 464 - - If this gets complicated, fall back to “persist full doc snapshot” v0 and 465 - switch to patching later (but still behind applyDocPatch). 466 - 467 - Step 4: Persist after history events (with batching) 468 - [ ] Implement `createPersistenceSink(repo)` that exposes: 469 - - enqueueDocPatch(boardId, patch): void 470 - - flush(): Promise<void> 471 - 472 - [ ] Wire history -> sink: 473 - - if event.kind === 'doc': 474 - sink.enqueueDocPatch(boardId, diffDoc(before, after)) 475 - - else: 476 - no-op 477 - 478 - [ ] Batch + flush policy: 479 - - debounce 100–250ms 480 - - force flush on: 481 - - board switch 482 - - page unload (best-effort) 483 - - explicit Save action 484 - 485 - Step 5: Update updatedAt correctly 486 - [ ] On any persisted doc change, update: 487 - - boards.updatedAt = now() 488 - - updatedAt on modified rows (pages/shapes/bindings) if you store it 489 - 490 - (DoD): 491 - - Creating/moving/deleting shapes persists through refresh. 492 - - Undo and redo also persist through refresh. 493 - - Selection changes do NOT write to Dexie. 494 - 495 - ------------------------------------------------------------------------------ 496 - M5. Export / Import (backups + portability) 497 - ------------------------------------------------------------------------------ 498 - 499 - [ ] Export board: 500 - - loadDoc(boardId) -> JSON (doc + meta) 501 - [ ] Import board: 502 - - createBoard(name) 503 - - applyDocPatch(full snapshot as upserts) 504 - 505 - (DoD): 506 - - Export -> Import recreates the same drawing. 507 - 508 - ------------------------------------------------------------------------------ 509 - Tests (vitest; use fake IndexedDB) 510 - ------------------------------------------------------------------------------ 511 - 512 - Dexie correctness: 513 - [ ] applyDocPatch uses a single transaction for multi-table writes 514 - [ ] deleteBoard deletes boards + related rows atomically 515 - 516 - History integration: 517 - [ ] doc command do => exactly 1 persistence flush (after debounce) 518 - [ ] undo => persists (document matches expected) 519 - [ ] redo => persists (document matches expected) 520 - [ ] ui-only command => 0 DB writes 521 - [ ] batching: 10 rapid doc commands => <= 2 writes (depending on debounce window) 522 - 523 - ------------------------------------------------------------------------------ 524 - Definition of Done 525 - ------------------------------------------------------------------------------ 526 - 527 - - Web: create board, draw, refresh -> content persists. 528 - - Undo/redo across refresh works. 529 - - Migration system exists and is exercised by at least one schema bump + upgrade. 530 - - Renderer redraws from in-memory state; persistence is driven by history events. 531 - 532 - ============================================================================== 533 - 14. Milestone N: Desktop packaging (Tauri) *wb-N* 534 - ============================================================================== 535 536 Goal: same app works as a desktop app with filesystem access. 537 ··· 548 (DoD): 549 - Desktop app opens/saves JSON files on disk and reopens them correctly. 550 551 - ============================================================================== 552 - 15. Milestone O: Export (PNG/SVG) *wb-O* 553 - ============================================================================== 554 555 Goal: export drawings as shareable artifacts. 556 ··· 567 - One-click export works in both web and desktop. 568 569 570 - ============================================================================== 571 - 16. Milestone P: Performance + big docs (pragmatic) *wb-P* 572 - ============================================================================== 573 574 Goal: the editor stays responsive with many shapes. 575 ··· 592 (DoD): 593 - 10k simple shapes pans/zooms smoothly on a typical machine. 594 595 - ============================================================================== 596 - 17. Milestone Q: File Browser (web: Dexie inspector, desktop: FS) *wb-Q* 597 - ============================================================================== 598 599 Goal: A unified “Open board” experience: 600 - Web: browse Dexie-backed boards + a useful persistence/migration inspector 601 - Desktop: browse real directories/files (native file browser semantics) 602 603 - ------------------------------------------------------------------------------ 604 Q1. Shared UX contracts 605 - ------------------------------------------------------------------------------ 606 607 /packages/core/src/persist/DocRepo.ts: 608 [ ] Define DocRepo interface (web + desktop): ··· 619 (DoD): 620 - Svelte UI can render the browser purely from the ViewModel. 621 622 - ------------------------------------------------------------------------------ 623 Q2. Web: Boards list + Dexie “Inspector” drawer 624 - ------------------------------------------------------------------------------ 625 626 /apps/web/src/lib/filebrowser/FileBrowser.svelte: 627 [ ] Boards panel: ··· 648 (DoD): 649 - Web: you can browse boards, open one, and verify migrations + row counts. 650 651 - ------------------------------------------------------------------------------ 652 Q3. Desktop: real directory + files (Tauri) 653 - ------------------------------------------------------------------------------ 654 655 [ ] Add “Workspace folder” concept: 656 - pick directory ··· 668 (DoD): 669 - Desktop: pick a folder, browse files, open/save boards from disk. 670 671 - ------------------------------------------------------------------------------ 672 Q4. Parity behaviors 673 - ------------------------------------------------------------------------------ 674 675 [ ] Same shortcuts: 676 - Ctrl/Cmd+O opens file browser ··· 681 (DoD): 682 - Web and desktop feel like the same app, with storage differences made explicit. 683 684 - ============================================================================== 685 - 18. Milestone R: Quality polish (what makes it feel "real") *wb-R* 686 - ============================================================================== 687 688 Goal: the UX crosses the "this is legit" threshold. 689 ··· 706 (DoD): 707 - A user can comfortably draw and edit without surprises. 708 709 - ============================================================================== 710 - References (URLs) *wb-refs* 711 - ============================================================================== 712 713 tldraw conceptual references (inspiration only): 714 - https://tldraw.dev/docs/shapes
··· 1 + ================================================================================ 2 + 3 Author intent: 4 - Build a Svelte-native editor core (TS) + renderer + UI. 5 - Keep the "engine" framework-agnostic so Web + Tauri share it. ··· 9 - [ ] Task 10 - (DoD) Definition of Done for the milestone 11 - Files shown are ideas, not requirements 12 + 13 + ================================================================================ 14 + 1. Milestone A: Repo skeleton + dev loop *wb-A* 15 + ================================================================================ 16 17 Goal: a monorepo that can run a blank canvas in web + desktop. 18 ··· 42 - `pnpm dev:desktop` launches a Tauri window that shows the same canvas. 43 - `pnpm test` runs at least 1 passing core test. 44 45 + ================================================================================ 46 + 2. Milestone B: Math + coordinate systems *wb-B* 47 + ================================================================================ 48 49 + Camera math, matrix utilities, and transforms are fully implemented and verified 50 + so world and screen coordinates map precisely. 51 52 + ================================================================================ 53 + 3. Milestone C: Document model (records) *wb-C* 54 + ================================================================================ 55 56 + Document/page/shape/binding records plus validation let the editor serialize and 57 + reason about drawings safely. 58 59 + ================================================================================ 60 + 4. Milestone D: Store + selectors (reactive core) *wb-D* 61 + ================================================================================ 62 63 + The reactive store, invariants, and selectors supply deterministic state streams 64 + for both renderer and UI subscribers. 65 66 + ================================================================================ 67 + 5. Milestone E: Canvas renderer (read-only) *wb-E* 68 + ================================================================================ 69 70 + The renderer now draws the document via Canvas2D with camera transforms, 71 + DPI scaling, text sizing, and selection outlines. 72 73 + ================================================================================ 74 + 6. Milestone F: Hit testing (picking) *wb-F* 75 + ================================================================================ 76 77 + Geometry helpers compute bounds and intersections so hit testing can always 78 + return the topmost shape under the cursor. 79 80 + ================================================================================ 81 + 7. Milestone G: Input system (pointer + keyboard) *wb-G* 82 + ================================================================================ 83 84 + Pointer and keyboard adapters now normalize events, map them into actions, and 85 + feed the editor consistently across platforms. 86 87 + ================================================================================ 88 + 8. Milestone H: Tool state machine (foundation) *wb-H* 89 + ================================================================================ 90 91 + Tool interfaces and the router manage lifecycle hooks so each tool is an 92 + explicit, testable state machine. 93 94 95 + ================================================================================ 96 + 9. Milestone I: Select/move tool (MVP interaction) *wb-I* 97 + ================================================================================ 98 99 + Selection logic handles hit selection, marquee, dragging, deletion, and escape 100 + so shapes can be moved reliably. 101 102 + ================================================================================ 103 + 10. Milestone J: Create basic shapes via tools *wb-J* 104 + ================================================================================ 105 106 + Rect, ellipse, line, arrow, and text tools now create shapes via click-drag 107 + interactions with proper finalize/cancel behavior. 108 109 ================================================================================ 110 11. Milestone K: Bindings for arrows (v0) *wb-K* 111 ================================================================================ 112 113 + Arrow endpoints bind to target shapes and stay attached by recalculating anchors 114 + whenever shapes move. 115 116 ================================================================================ 117 12. Milestone L: History (undo/redo) *wb-L* 118 ================================================================================ 119 120 + All document-affecting actions run through undoable commands with history stacks 121 + and keyboard shortcuts. 122 123 + ================================================================================ 124 + 13. Milestone M: Persistence (web) via Dexie + History integration *wb-M* 125 + ================================================================================ 126 127 + Document changes now persist to IndexedDB via Dexie with migrations, repo API, 128 + and history-driven syncing. 129 130 + ================================================================================ 131 + 14. Milestone N: Desktop packaging (Tauri) *wb-N* 132 + ================================================================================ 133 134 Goal: same app works as a desktop app with filesystem access. 135 ··· 146 (DoD): 147 - Desktop app opens/saves JSON files on disk and reopens them correctly. 148 149 + ================================================================================ 150 + 15. Milestone O: Export (PNG/SVG) *wb-O* 151 + ================================================================================ 152 153 Goal: export drawings as shareable artifacts. 154 ··· 165 - One-click export works in both web and desktop. 166 167 168 + ================================================================================ 169 + 16. Milestone P: Performance + big docs (pragmatic) *wb-P* 170 + ================================================================================ 171 172 Goal: the editor stays responsive with many shapes. 173 ··· 190 (DoD): 191 - 10k simple shapes pans/zooms smoothly on a typical machine. 192 193 + ================================================================================ 194 + 17. Milestone Q: File Browser (web: Dexie inspector, desktop: FS) *wb-Q* 195 + ================================================================================ 196 197 Goal: A unified “Open board” experience: 198 - Web: browse Dexie-backed boards + a useful persistence/migration inspector 199 - Desktop: browse real directories/files (native file browser semantics) 200 201 + -------------------------------------------------------------------------------- 202 Q1. Shared UX contracts 203 + -------------------------------------------------------------------------------- 204 205 /packages/core/src/persist/DocRepo.ts: 206 [ ] Define DocRepo interface (web + desktop): ··· 217 (DoD): 218 - Svelte UI can render the browser purely from the ViewModel. 219 220 + -------------------------------------------------------------------------------- 221 Q2. Web: Boards list + Dexie “Inspector” drawer 222 + -------------------------------------------------------------------------------- 223 224 /apps/web/src/lib/filebrowser/FileBrowser.svelte: 225 [ ] Boards panel: ··· 246 (DoD): 247 - Web: you can browse boards, open one, and verify migrations + row counts. 248 249 + -------------------------------------------------------------------------------- 250 Q3. Desktop: real directory + files (Tauri) 251 + -------------------------------------------------------------------------------- 252 253 [ ] Add “Workspace folder” concept: 254 - pick directory ··· 266 (DoD): 267 - Desktop: pick a folder, browse files, open/save boards from disk. 268 269 + -------------------------------------------------------------------------------- 270 Q4. Parity behaviors 271 + -------------------------------------------------------------------------------- 272 273 [ ] Same shortcuts: 274 - Ctrl/Cmd+O opens file browser ··· 279 (DoD): 280 - Web and desktop feel like the same app, with storage differences made explicit. 281 282 + ================================================================================ 283 + 18. Milestone R: Quality polish (what makes it feel "real") *wb-R* 284 + ================================================================================ 285 286 Goal: the UX crosses the "this is legit" threshold. 287 ··· 304 (DoD): 305 - A user can comfortably draw and edit without surprises. 306 307 + ================================================================================ 308 + References (URLs) *wb-refs* 309 + ================================================================================ 310 311 tldraw conceptual references (inspiration only): 312 - https://tldraw.dev/docs/shapes
+1 -1
apps/web/package.json
··· 15 "test": "npm run test:unit -- --run", 16 "format": "prettier --write ." 17 }, 18 - "dependencies": { "inkfinite-core": "workspace:*", "inkfinite-renderer": "workspace:*" }, 19 "devDependencies": { 20 "@eslint/compat": "^1.4.0", 21 "@eslint/js": "^9.39.1",
··· 15 "test": "npm run test:unit -- --run", 16 "format": "prettier --write ." 17 }, 18 + "dependencies": { "dexie": "^4.2.1", "inkfinite-core": "workspace:*", "inkfinite-renderer": "workspace:*" }, 19 "devDependencies": { 20 "@eslint/compat": "^1.4.0", 21 "@eslint/js": "^9.39.1",
+74 -40
apps/web/src/lib/canvas/Canvas.svelte
··· 1 <script lang="ts"> 2 import { 3 ArrowTool, 4 EllipseTool, 5 LineTool, 6 - PageRecord, 7 RectTool, 8 SelectTool, 9 - ShapeRecord, 10 Store, 11 TextTool, 12 createToolMap, 13 routeAction, 14 switchTool, 15 type Action, 16 type ToolId, 17 type Viewport 18 } from 'inkfinite-core'; 19 import { createRenderer, type Renderer } from 'inkfinite-renderer'; 20 import { onDestroy, onMount } from 'svelte'; 21 - import HistoryViewer from '../components/HistoryViewer.svelte'; 22 - import Toolbar from '../components/Toolbar.svelte'; 23 - import { createInputAdapter, type InputAdapter } from '../input'; 24 25 - const store = new Store(); 26 27 - store.setState((state) => { 28 - const page = PageRecord.create('Page 1'); 29 - const rect1 = ShapeRecord.createRect(page.id, -200, -100, { 30 - w: 150, 31 - h: 100, 32 - fill: '#ff6b6b', 33 - stroke: '#c92a2a', 34 - radius: 8 35 - }); 36 - const rect2 = ShapeRecord.createRect(page.id, 50, -50, { 37 - w: 120, 38 - h: 80, 39 - fill: '#4dabf7', 40 - stroke: '#1971c2', 41 - radius: 8 42 - }); 43 - const ellipse = ShapeRecord.createEllipse(page.id, -100, 100, { 44 - w: 100, 45 - h: 100, 46 - fill: '#51cf66', 47 - stroke: '#2f9e44' 48 - }); 49 - 50 - page.shapeIds.push(rect1.id, rect2.id, ellipse.id); 51 52 - return { 53 ...state, 54 - doc: { 55 - ...state.doc, 56 - pages: { [page.id]: page }, 57 - shapes: { [rect1.id]: rect1, [rect2.id]: rect2, [ellipse.id]: ellipse } 58 - }, 59 - ui: { ...state.ui, currentPageId: page.id } 60 - }; 61 - }); 62 63 const selectTool = new SelectTool(); 64 const rectTool = new RectTool(); ··· 112 let inputAdapter: InputAdapter | null = null; 113 114 onMount(() => { 115 - renderer = createRenderer(canvas, store); 116 117 function getViewport(): Viewport { 118 const rect = canvas.getBoundingClientRect(); ··· 123 return store.getState().camera; 124 } 125 126 inputAdapter = createInputAdapter({ canvas, getCamera, getViewport, onAction: handleAction }); 127 }); 128 129 onDestroy(() => { 130 renderer?.dispose(); 131 inputAdapter?.dispose(); 132 }); 133 </script> 134
··· 1 <script lang="ts"> 2 + import HistoryViewer from '$lib/components/HistoryViewer.svelte'; 3 + import Toolbar from '$lib/components/Toolbar.svelte'; 4 + import { createInputAdapter, type InputAdapter } from '$lib/input'; 5 import { 6 ArrowTool, 7 EllipseTool, 8 + InkfiniteDB, 9 LineTool, 10 RectTool, 11 SelectTool, 12 Store, 13 TextTool, 14 + createPersistenceSink, 15 createToolMap, 16 + createWebDocRepo, 17 + diffDoc, 18 routeAction, 19 switchTool, 20 type Action, 21 + type LoadedDoc, 22 type ToolId, 23 type Viewport 24 } from 'inkfinite-core'; 25 import { createRenderer, type Renderer } from 'inkfinite-renderer'; 26 import { onDestroy, onMount } from 'svelte'; 27 28 + let repo: ReturnType<typeof createWebDocRepo> | null = null; 29 + let sink: ReturnType<typeof createPersistenceSink> | null = null; 30 + let activeBoardId: string | null = null; 31 32 + const store = new Store(undefined, { 33 + onHistoryEvent: (event) => { 34 + if (!activeBoardId || event.kind !== 'doc' || !sink) { 35 + return; 36 + } 37 + const patch = diffDoc(event.beforeState.doc, event.afterState.doc); 38 + sink.enqueueDocPatch(activeBoardId, patch); 39 + } 40 + }); 41 42 + function applyLoadedDoc(doc: LoadedDoc) { 43 + const firstPageId = doc.order.pageIds[0] ?? Object.keys(doc.pages)[0] ?? null; 44 + store.setState((state) => ({ 45 ...state, 46 + doc: { pages: doc.pages, shapes: doc.shapes, bindings: doc.bindings }, 47 + ui: { ...state.ui, currentPageId: firstPageId } 48 + })); 49 + } 50 51 const selectTool = new SelectTool(); 52 const rectTool = new RectTool(); ··· 100 let inputAdapter: InputAdapter | null = null; 101 102 onMount(() => { 103 + const db = new InkfiniteDB(); 104 + repo = createWebDocRepo(db); 105 + sink = createPersistenceSink(repo, { debounceMs: 200 }); 106 + let disposed = false; 107 + 108 + const hydrate = async () => { 109 + const repoInstance = repo; 110 + if (!repoInstance) { 111 + return; 112 + } 113 + try { 114 + const boards = await repoInstance.listBoards(); 115 + const id = boards[0]?.id ?? (await repoInstance.createBoard('My board')); 116 + if (disposed) { 117 + return; 118 + } 119 + activeBoardId = id; 120 + const loaded = await repoInstance.loadDoc(id); 121 + if (!disposed) { 122 + applyLoadedDoc(loaded); 123 + } 124 + } catch (error) { 125 + console.error('Failed to load board', error); 126 + } 127 + }; 128 + 129 + hydrate(); 130 131 function getViewport(): Viewport { 132 const rect = canvas.getBoundingClientRect(); ··· 137 return store.getState().camera; 138 } 139 140 + renderer = createRenderer(canvas, store); 141 inputAdapter = createInputAdapter({ canvas, getCamera, getViewport, onAction: handleAction }); 142 + 143 + function handleBeforeUnload() { 144 + if (sink) { 145 + void sink.flush(); 146 + } 147 + } 148 + 149 + window.addEventListener('beforeunload', handleBeforeUnload); 150 + 151 + return () => { 152 + disposed = true; 153 + window.removeEventListener('beforeunload', handleBeforeUnload); 154 + }; 155 }); 156 157 onDestroy(() => { 158 renderer?.dispose(); 159 inputAdapter?.dispose(); 160 + if (sink) { 161 + void sink.flush(); 162 + } 163 + repo = null; 164 + sink = null; 165 + activeBoardId = null; 166 }); 167 </script> 168
+1
apps/web/vite.config.ts
··· 7 plugins: [sveltekit(), devtoolsJson()], 8 test: { 9 ui: false, 10 expect: { requireAssertions: true }, 11 projects: [{ 12 extends: "./vite.config.ts",
··· 7 plugins: [sveltekit(), devtoolsJson()], 8 test: { 9 ui: false, 10 + watch: false, 11 expect: { requireAssertions: true }, 12 projects: [{ 13 extends: "./vite.config.ts",
+4 -1
eslint.config.js
··· 22 }], 23 "unicorn/prefer-ternary": "off", 24 "unicorn/no-null": "off", 25 - "unicorn/prevent-abbreviations": ["error", { "replacements": { "i": false, "props": false, "doc": false } }], 26 }, 27 }], 28 );
··· 22 }], 23 "unicorn/prefer-ternary": "off", 24 "unicorn/no-null": "off", 25 + "unicorn/no-array-reverse": "off", 26 + "unicorn/prevent-abbreviations": ["error", { 27 + "replacements": { "i": false, "props": false, "doc": false, "db": false }, 28 + }], 29 }, 30 }], 31 );
+1
packages/core/package.json
··· 23 "devDependencies": { 24 "@types/node": "^25.0.3", 25 "@vitest/coverage-v8": "^4.0.16", 26 "bumpp": "^10.3.2", 27 "tsdown": "^0.18.1", 28 "typescript": "^5.9.3",
··· 23 "devDependencies": { 24 "@types/node": "^25.0.3", 25 "@vitest/coverage-v8": "^4.0.16", 26 + "fake-indexeddb": "^6.0.0", 27 "bumpp": "^10.3.2", 28 "tsdown": "^0.18.1", 29 "typescript": "^5.9.3",
+20
packages/core/src/history.ts
··· 2 import type { ShapeRecord } from "./model"; 3 import type { EditorState } from "./reactivity"; 4 5 /** 6 * Command interface for undo/redo operations 7 * ··· 10 export interface Command { 11 /** Display name for this command (shown in history UI) */ 12 readonly name: string; 13 14 /** 15 * Execute the command and return the new state ··· 31 */ 32 export class CreateShapeCommand implements Command { 33 readonly name: string; 34 35 constructor(private readonly shape: ShapeRecord, private readonly pageId: string) { 36 this.name = `Create ${shape.type}`; ··· 79 */ 80 export class UpdateShapeCommand implements Command { 81 readonly name: string; 82 83 constructor( 84 private readonly shapeId: string, ··· 102 */ 103 export class DeleteShapesCommand implements Command { 104 readonly name: string; 105 106 constructor(private readonly shapes: ShapeRecord[], private readonly pageId: string) { 107 this.name = shapes.length === 1 ? `Delete ${shapes[0].type}` : `Delete ${shapes.length} shapes`; ··· 162 */ 163 export class SetSelectionCommand implements Command { 164 readonly name = "Change selection"; 165 166 constructor(private readonly before: string[], private readonly after: string[]) {} 167 ··· 179 */ 180 export class SetCameraCommand implements Command { 181 readonly name = "Move camera"; 182 183 constructor(private readonly before: Camera, private readonly after: Camera) {} 184
··· 2 import type { ShapeRecord } from "./model"; 3 import type { EditorState } from "./reactivity"; 4 5 + export type CommandKind = "doc" | "ui" | "camera"; 6 + 7 + export type HistoryOperation = "do" | "undo" | "redo"; 8 + 9 + export type HistoryAppliedEvent = { 10 + op: HistoryOperation; 11 + commandId: number; 12 + command: Command; 13 + kind: CommandKind; 14 + beforeState: EditorState; 15 + afterState: EditorState; 16 + }; 17 + 18 /** 19 * Command interface for undo/redo operations 20 * ··· 23 export interface Command { 24 /** Display name for this command (shown in history UI) */ 25 readonly name: string; 26 + /** Command category, used for persistence decisions */ 27 + readonly kind: CommandKind; 28 29 /** 30 * Execute the command and return the new state ··· 46 */ 47 export class CreateShapeCommand implements Command { 48 readonly name: string; 49 + readonly kind = "doc" as const; 50 51 constructor(private readonly shape: ShapeRecord, private readonly pageId: string) { 52 this.name = `Create ${shape.type}`; ··· 95 */ 96 export class UpdateShapeCommand implements Command { 97 readonly name: string; 98 + readonly kind = "doc" as const; 99 100 constructor( 101 private readonly shapeId: string, ··· 119 */ 120 export class DeleteShapesCommand implements Command { 121 readonly name: string; 122 + readonly kind = "doc" as const; 123 124 constructor(private readonly shapes: ShapeRecord[], private readonly pageId: string) { 125 this.name = shapes.length === 1 ? `Delete ${shapes[0].type}` : `Delete ${shapes.length} shapes`; ··· 180 */ 181 export class SetSelectionCommand implements Command { 182 readonly name = "Change selection"; 183 + readonly kind = "ui" as const; 184 185 constructor(private readonly before: string[], private readonly after: string[]) {} 186 ··· 198 */ 199 export class SetCameraCommand implements Command { 200 readonly name = "Move camera"; 201 + readonly kind = "camera" as const; 202 203 constructor(private readonly before: Camera, private readonly after: Camera) {} 204
+2
packages/core/src/index.ts
··· 4 export * from "./history"; 5 export * from "./math"; 6 export * from "./model"; 7 export * from "./reactivity"; 8 export * from "./tools";
··· 4 export * from "./history"; 5 export * from "./math"; 6 export * from "./model"; 7 + export * from "./persistence/db"; 8 + export * from "./persistence/web"; 9 export * from "./reactivity"; 10 export * from "./tools";
+103
packages/core/src/persistence/db.ts
···
··· 1 + import Dexie, { type Transaction } from "dexie"; 2 + import { PageRecord as PageOps } from "../model"; 3 + import type { BindingRow, BoardMeta, MetaRow, MigrationRow, PageRow, ShapeRow, Timestamp } from "./web"; 4 + 5 + export const DB_NAME = "inkfinite"; 6 + 7 + type Migration = { id: string; apply(tx: Transaction): Promise<void> }; 8 + 9 + const PAGE_ORDER_META_PREFIX = "page-order:"; 10 + const SHAPE_ORDER_META_PREFIX = "shape-order:"; 11 + 12 + const pageOrderKey = (boardId: string) => `${PAGE_ORDER_META_PREFIX}${boardId}`; 13 + const shapeOrderKey = (boardId: string) => `${SHAPE_ORDER_META_PREFIX}${boardId}`; 14 + 15 + /** 16 + * Dexie wrapper for Inkfinite persistence 17 + */ 18 + export class InkfiniteDB extends Dexie { 19 + boards!: Dexie.Table<BoardMeta, string>; 20 + pages!: Dexie.Table<PageRow, [string, string]>; 21 + shapes!: Dexie.Table<ShapeRow, [string, string]>; 22 + bindings!: Dexie.Table<BindingRow, [string, string]>; 23 + meta!: Dexie.Table<MetaRow, string>; 24 + migrations!: Dexie.Table<MigrationRow, string>; 25 + 26 + constructor(name = DB_NAME) { 27 + super(name); 28 + 29 + this.version(1).stores({ 30 + boards: "id, name, createdAt, updatedAt", 31 + pages: "[boardId+id], boardId, updatedAt", 32 + shapes: "[boardId+id], boardId, type, updatedAt", 33 + bindings: "[boardId+id], boardId, type, updatedAt", 34 + meta: "key", 35 + migrations: "id, appliedAt", 36 + }).upgrade(async (tx) => { 37 + await runMigrations(tx); 38 + }); 39 + } 40 + } 41 + 42 + const MIGRATIONS: Migration[] = [{ 43 + id: "MIG-0001", 44 + async apply(tx) { 45 + const boards = tx.table<BoardMeta>("boards"); 46 + const rows = await boards.toArray(); 47 + const timestamp = Date.now(); 48 + 49 + for (const row of rows) { 50 + const patch: Partial<BoardMeta> = {}; 51 + if (!row.createdAt) { 52 + patch.createdAt = timestamp; 53 + } 54 + if (!row.updatedAt) { 55 + patch.updatedAt = timestamp; 56 + } 57 + 58 + if (Object.keys(patch).length > 0) { 59 + await boards.update(row.id, patch); 60 + } 61 + } 62 + }, 63 + }, { 64 + id: "MIG-0002", 65 + async apply(tx) { 66 + const boards = tx.table<BoardMeta>("boards"); 67 + const pages = tx.table<PageRow>("pages"); 68 + const meta = tx.table<MetaRow>("meta"); 69 + const rows = await boards.toArray(); 70 + const timestamp = Date.now(); 71 + 72 + for (const row of rows) { 73 + const pageCount = await pages.where("boardId").equals(row.id).count(); 74 + if (pageCount > 0) { 75 + continue; 76 + } 77 + 78 + const defaultPage = PageOps.create("Page 1"); 79 + await pages.add({ ...defaultPage, boardId: row.id, updatedAt: timestamp }); 80 + await meta.put({ key: pageOrderKey(row.id), value: [defaultPage.id] }); 81 + await meta.put({ key: shapeOrderKey(row.id), value: { [defaultPage.id]: [...defaultPage.shapeIds] } }); 82 + await boards.update(row.id, { updatedAt: timestamp }); 83 + } 84 + }, 85 + }]; 86 + 87 + /** 88 + * Run pending logical migrations during schema upgrades 89 + */ 90 + export async function runMigrations(tx: Transaction): Promise<void> { 91 + const migrations = tx.table<MigrationRow>("migrations"); 92 + const applied = await migrations.toArray(); 93 + const appliedIds = new Set(applied.map((row) => row.id)); 94 + 95 + for (const migration of MIGRATIONS) { 96 + if (appliedIds.has(migration.id)) { 97 + continue; 98 + } 99 + 100 + await migration.apply(tx); 101 + await migrations.put({ id: migration.id, appliedAt: Date.now() as Timestamp }); 102 + } 103 + }
+460
packages/core/src/persistence/web.ts
···
··· 1 + /* eslint-disable unicorn/no-await-expression-member */ 2 + import Dexie from "dexie"; 3 + import { 4 + type BindingRecord, 5 + BindingRecord as BindingOps, 6 + createId, 7 + type Document, 8 + type PageRecord, 9 + PageRecord as PageOps, 10 + type ShapeRecord, 11 + ShapeRecord as ShapeOps, 12 + } from "../model"; 13 + 14 + export type Timestamp = number; 15 + 16 + export type BoardMeta = { id: string; name: string; createdAt: Timestamp; updatedAt: Timestamp }; 17 + 18 + export type PageRow = PageRecord & { boardId: string; updatedAt: Timestamp }; 19 + 20 + export type ShapeRow = ShapeRecord & { boardId: string; updatedAt: Timestamp }; 21 + 22 + export type BindingRow = BindingRecord & { boardId: string; updatedAt: Timestamp }; 23 + 24 + export type MetaRow = { key: string; value: unknown }; 25 + 26 + export type MigrationRow = { id: string; appliedAt: Timestamp }; 27 + 28 + export type DocOrder = { 29 + pageIds: string[]; 30 + /** Optional per-page shape order overrides */ 31 + shapeOrder?: Record<string, string[]>; 32 + }; 33 + 34 + export type DocPatch = { 35 + upserts?: { pages?: PageRecord[]; shapes?: ShapeRecord[]; bindings?: BindingRecord[] }; 36 + deletes?: { pageIds?: string[]; shapeIds?: string[]; bindingIds?: string[] }; 37 + order?: Partial<DocOrder>; 38 + }; 39 + 40 + export type LoadedDoc = { 41 + pages: Record<string, PageRecord>; 42 + shapes: Record<string, ShapeRecord>; 43 + bindings: Record<string, BindingRecord>; 44 + order: DocOrder; 45 + }; 46 + 47 + export type BoardExport = { board: BoardMeta; doc: Document; order: DocOrder }; 48 + 49 + export type PersistenceSink = { enqueueDocPatch(boardId: string, patch: DocPatch): void; flush(): Promise<void> }; 50 + 51 + export type PersistenceSinkOptions = { debounceMs?: number }; 52 + 53 + export interface DocRepo { 54 + listBoards(): Promise<BoardMeta[]>; 55 + createBoard(name: string): Promise<string>; 56 + renameBoard(boardId: string, name: string): Promise<void>; 57 + deleteBoard(boardId: string): Promise<void>; 58 + loadDoc(boardId: string): Promise<LoadedDoc>; 59 + applyDocPatch(boardId: string, patch: DocPatch): Promise<void>; 60 + exportBoard(boardId: string): Promise<BoardExport>; 61 + importBoard(snapshot: BoardExport): Promise<string>; 62 + } 63 + 64 + export type WebRepoOptions = { now?: () => Timestamp }; 65 + 66 + type DexieLike = Pick<Dexie, "table" | "transaction">; 67 + 68 + const DEFAULT_BOARD_NAME = "Untitled Board"; 69 + 70 + const PAGE_ORDER_META_PREFIX = "page-order:"; 71 + const SHAPE_ORDER_META_PREFIX = "shape-order:"; 72 + 73 + const pageOrderKey = (boardId: string) => `${PAGE_ORDER_META_PREFIX}${boardId}`; 74 + const shapeOrderKey = (boardId: string) => `${SHAPE_ORDER_META_PREFIX}${boardId}`; 75 + 76 + /** 77 + * Create a Dexie-backed DocRepo used by the web app. 78 + */ 79 + export function createWebDocRepo(database: DexieLike, options?: WebRepoOptions): DocRepo { 80 + const now = () => options?.now?.() ?? Date.now(); 81 + 82 + const boards = () => database.table<BoardMeta>("boards"); 83 + const pages = () => database.table<PageRow>("pages"); 84 + const shapes = () => database.table<ShapeRow>("shapes"); 85 + const bindings = () => database.table<BindingRow>("bindings"); 86 + const meta = () => database.table<MetaRow>("meta"); 87 + 88 + async function listBoards(): Promise<BoardMeta[]> { 89 + return boards().orderBy("updatedAt").reverse().toArray(); 90 + } 91 + 92 + async function createBoard(name: string): Promise<string> { 93 + const boardId = createId("board"); 94 + const timestamp = now(); 95 + const page = PageOps.create("Page 1"); 96 + const pageRow: PageRow = { ...page, boardId, updatedAt: timestamp }; 97 + 98 + await database.transaction("rw", boards(), pages(), meta(), async () => { 99 + await boards().add({ id: boardId, name: name || DEFAULT_BOARD_NAME, createdAt: timestamp, updatedAt: timestamp }); 100 + await pages().add(pageRow); 101 + await meta().put({ key: pageOrderKey(boardId), value: [page.id] }); 102 + await meta().put({ key: shapeOrderKey(boardId), value: { [page.id]: [...page.shapeIds] } }); 103 + }); 104 + 105 + return boardId; 106 + } 107 + 108 + async function renameBoard(boardId: string, name: string): Promise<void> { 109 + await boards().update(boardId, { name, updatedAt: now() }); 110 + } 111 + 112 + async function deleteBoard(boardId: string): Promise<void> { 113 + await database.transaction("rw", [boards(), pages(), shapes(), bindings(), meta()], async () => { 114 + const pageKeys = (await pages().where("boardId").equals(boardId).toArray()).map((row) => 115 + [row.boardId, row.id] as [string, string] 116 + ); 117 + const shapeKeys = (await shapes().where("boardId").equals(boardId).toArray()).map((row) => 118 + [row.boardId, row.id] as [string, string] 119 + ); 120 + const bindingKeys = (await bindings().where("boardId").equals(boardId).toArray()).map((row) => 121 + [row.boardId, row.id] as [string, string] 122 + ); 123 + 124 + await boards().delete(boardId); 125 + if (pageKeys.length > 0) await pages().bulkDelete(pageKeys); 126 + if (shapeKeys.length > 0) await shapes().bulkDelete(shapeKeys); 127 + if (bindingKeys.length > 0) await bindings().bulkDelete(bindingKeys); 128 + await meta().delete(pageOrderKey(boardId)); 129 + await meta().delete(shapeOrderKey(boardId)); 130 + }); 131 + } 132 + 133 + async function loadDoc(boardId: string): Promise<LoadedDoc> { 134 + const pageRows = await pages().where("boardId").equals(boardId).toArray(); 135 + const [shapeRows, bindingRows, order] = await Promise.all([ 136 + shapes().where("boardId").equals(boardId).toArray(), 137 + bindings().where("boardId").equals(boardId).toArray(), 138 + loadOrder(boardId, pageRows), 139 + ]); 140 + 141 + const docPages: Record<string, PageRecord> = {}; 142 + for (const row of pageRows) { 143 + docPages[row.id] = clonePageRow(row); 144 + } 145 + 146 + const docShapes: Record<string, ShapeRecord> = {}; 147 + for (const row of shapeRows) { 148 + docShapes[row.id] = cloneShapeRow(row); 149 + } 150 + 151 + const docBindings: Record<string, BindingRecord> = {}; 152 + for (const row of bindingRows) { 153 + docBindings[row.id] = cloneBindingRow(row); 154 + } 155 + 156 + return { pages: docPages, shapes: docShapes, bindings: docBindings, order }; 157 + } 158 + 159 + async function loadOrder(boardId: string, fallbackPages: PageRow[]): Promise<DocOrder> { 160 + const pageOrderRow = await meta().get(pageOrderKey(boardId)); 161 + const shapeOrderRow = await meta().get(shapeOrderKey(boardId)); 162 + const fallbackPageIds = fallbackPages.map((row) => row.id); 163 + const fallbackShapeOrder = shapeOrderFromPageRows(fallbackPages); 164 + 165 + return { 166 + pageIds: (pageOrderRow?.value as string[] | undefined) ?? fallbackPageIds, 167 + shapeOrder: (shapeOrderRow?.value as Record<string, string[]> | undefined) ?? fallbackShapeOrder, 168 + }; 169 + } 170 + 171 + async function applyDocPatch(boardId: string, patch: DocPatch): Promise<void> { 172 + const timestamp = now(); 173 + 174 + await database.transaction("rw", [boards(), pages(), shapes(), bindings(), meta()], async () => { 175 + const pageDeleteKeys = patch.deletes?.pageIds?.map((id) => [boardId, id] as [string, string]) ?? []; 176 + const shapeDeleteKeys = patch.deletes?.shapeIds?.map((id) => [boardId, id] as [string, string]) ?? []; 177 + const bindingDeleteKeys = patch.deletes?.bindingIds?.map((id) => [boardId, id] as [string, string]) ?? []; 178 + 179 + if (pageDeleteKeys.length > 0) await pages().bulkDelete(pageDeleteKeys); 180 + if (shapeDeleteKeys.length > 0) await shapes().bulkDelete(shapeDeleteKeys); 181 + if (bindingDeleteKeys.length > 0) await bindings().bulkDelete(bindingDeleteKeys); 182 + 183 + const upsertPages = 184 + patch.upserts?.pages?.map((page) => ({ ...PageOps.clone(page), boardId, updatedAt: timestamp })) ?? []; 185 + const upsertShapes = 186 + patch.upserts?.shapes?.map((shape) => ({ ...ShapeOps.clone(shape), boardId, updatedAt: timestamp })) ?? []; 187 + const upsertBindings = 188 + patch.upserts?.bindings?.map((binding) => ({ ...BindingOps.clone(binding), boardId, updatedAt: timestamp })) 189 + ?? []; 190 + 191 + if (upsertPages.length > 0) await pages().bulkPut(upsertPages); 192 + if (upsertShapes.length > 0) await shapes().bulkPut(upsertShapes); 193 + if (upsertBindings.length > 0) await bindings().bulkPut(upsertBindings); 194 + 195 + if (patch.order?.pageIds) { 196 + await meta().put({ key: pageOrderKey(boardId), value: [...patch.order.pageIds] }); 197 + } 198 + 199 + if (patch.order?.shapeOrder) { 200 + await meta().put({ key: shapeOrderKey(boardId), value: patch.order.shapeOrder }); 201 + } 202 + 203 + await boards().update(boardId, { updatedAt: timestamp }); 204 + }); 205 + } 206 + 207 + async function exportBoard(boardId: string): Promise<BoardExport> { 208 + const board = await boards().get(boardId); 209 + if (!board) { 210 + throw new Error(`Board ${boardId} not found`); 211 + } 212 + 213 + const { pages, shapes, bindings, order } = await loadDoc(boardId); 214 + const doc: Document = { pages, shapes, bindings }; 215 + return { board, doc, order }; 216 + } 217 + 218 + async function importBoard(snapshot: BoardExport): Promise<string> { 219 + const boardId = snapshot.board.id ?? createId("board"); 220 + const timestamp = now(); 221 + const board: BoardMeta = { 222 + id: boardId, 223 + name: snapshot.board.name || DEFAULT_BOARD_NAME, 224 + createdAt: snapshot.board.createdAt ?? timestamp, 225 + updatedAt: timestamp, 226 + }; 227 + 228 + await database.transaction("rw", [boards(), pages(), shapes(), bindings(), meta()], async () => { 229 + await boards().put(board); 230 + 231 + const pageRows = Object.values(snapshot.doc.pages).map((page) => ({ 232 + ...PageOps.clone(page), 233 + boardId, 234 + updatedAt: timestamp, 235 + })); 236 + const shapeRows = Object.values(snapshot.doc.shapes).map((shape) => ({ 237 + ...ShapeOps.clone(shape), 238 + boardId, 239 + updatedAt: timestamp, 240 + })); 241 + const bindingRows = Object.values(snapshot.doc.bindings).map((binding) => ({ 242 + ...BindingOps.clone(binding), 243 + boardId, 244 + updatedAt: timestamp, 245 + })); 246 + 247 + if (pageRows.length > 0) await pages().bulkPut(pageRows); 248 + if (shapeRows.length > 0) await shapes().bulkPut(shapeRows); 249 + if (bindingRows.length > 0) await bindings().bulkPut(bindingRows); 250 + 251 + const order = snapshot.order ?? deriveDocOrderFromDocument(snapshot.doc); 252 + await meta().put({ key: pageOrderKey(boardId), value: order.pageIds }); 253 + await meta().put({ key: shapeOrderKey(boardId), value: order.shapeOrder ?? {} }); 254 + }); 255 + 256 + return boardId; 257 + } 258 + 259 + return { listBoards, createBoard, renameBoard, deleteBoard, loadDoc, applyDocPatch, exportBoard, importBoard }; 260 + } 261 + 262 + /** 263 + * Compute a patch between two documents. Current implementation sends the full snapshot (upsert all rows). 264 + */ 265 + export function diffDoc(before: Document, after: Document): DocPatch { 266 + const patch: DocPatch = {}; 267 + 268 + const deletedPages = difference(Object.keys(before.pages), Object.keys(after.pages)); 269 + const deletedShapes = difference(Object.keys(before.shapes), Object.keys(after.shapes)); 270 + const deletedBindings = difference(Object.keys(before.bindings), Object.keys(after.bindings)); 271 + 272 + if (deletedPages.length > 0 || deletedShapes.length > 0 || deletedBindings.length > 0) { 273 + patch.deletes = {}; 274 + if (deletedPages.length > 0) patch.deletes.pageIds = deletedPages; 275 + if (deletedShapes.length > 0) patch.deletes.shapeIds = deletedShapes; 276 + if (deletedBindings.length > 0) patch.deletes.bindingIds = deletedBindings; 277 + } 278 + 279 + const pageUpserts = Object.values(after.pages).map((page) => PageOps.clone(page)); 280 + const shapeUpserts = Object.values(after.shapes).map((shape) => ShapeOps.clone(shape)); 281 + const bindingUpserts = Object.values(after.bindings).map((binding) => BindingOps.clone(binding)); 282 + 283 + if (pageUpserts.length > 0 || shapeUpserts.length > 0 || bindingUpserts.length > 0) { 284 + patch.upserts = {}; 285 + if (pageUpserts.length > 0) patch.upserts.pages = pageUpserts; 286 + if (shapeUpserts.length > 0) patch.upserts.shapes = shapeUpserts; 287 + if (bindingUpserts.length > 0) patch.upserts.bindings = bindingUpserts; 288 + } 289 + 290 + patch.order = deriveDocOrderFromDocument(after); 291 + 292 + return patch; 293 + } 294 + 295 + /** 296 + * Batch doc patches and flush them with a debounce to cut down on Dexie writes. 297 + */ 298 + export function createPersistenceSink(repo: DocRepo, options?: PersistenceSinkOptions): PersistenceSink { 299 + const debounceMs = options?.debounceMs ?? 200; 300 + let pendingBoardId: string | null = null; 301 + let pendingPatch: DocPatch | null = null; 302 + let timer: ReturnType<typeof setTimeout> | null = null; 303 + let inflight: Promise<void> | null = null; 304 + 305 + const scheduleFlush = () => { 306 + if (timer) { 307 + clearTimeout(timer); 308 + } 309 + timer = setTimeout(() => { 310 + timer = null; 311 + void flush(); 312 + }, debounceMs); 313 + }; 314 + 315 + const resetPending = () => { 316 + pendingBoardId = null; 317 + pendingPatch = null; 318 + if (timer) { 319 + clearTimeout(timer); 320 + timer = null; 321 + } 322 + }; 323 + 324 + async function flush(): Promise<void> { 325 + if (inflight) { 326 + await inflight; 327 + return; 328 + } 329 + 330 + if (!pendingBoardId || !pendingPatch || isPatchEmpty(pendingPatch)) { 331 + resetPending(); 332 + return; 333 + } 334 + 335 + const boardId = pendingBoardId; 336 + const patch = pendingPatch; 337 + resetPending(); 338 + 339 + inflight = repo.applyDocPatch(boardId, patch).finally(() => { 340 + inflight = null; 341 + }); 342 + 343 + await inflight; 344 + } 345 + 346 + function enqueueDocPatch(boardId: string, patch: DocPatch): void { 347 + if (!boardId) { 348 + throw new Error("boardId is required to persist edits"); 349 + } 350 + 351 + if (pendingBoardId && pendingBoardId !== boardId) { 352 + void flush(); 353 + } 354 + 355 + pendingBoardId = boardId; 356 + pendingPatch = clonePatch(patch); 357 + if (!isPatchEmpty(pendingPatch)) { 358 + scheduleFlush(); 359 + } 360 + } 361 + 362 + return { enqueueDocPatch, flush }; 363 + } 364 + 365 + function clonePageRow(row: PageRow): PageRecord { 366 + const { boardId: _boardId, updatedAt: _updatedAt, ...rest } = row; 367 + return PageOps.clone(rest); 368 + } 369 + 370 + function cloneShapeRow(row: ShapeRow): ShapeRecord { 371 + const { boardId: _boardId, updatedAt: _updatedAt, ...rest } = row; 372 + return ShapeOps.clone(rest as ShapeRecord); 373 + } 374 + 375 + function cloneBindingRow(row: BindingRow): BindingRecord { 376 + const { boardId: _boardId, updatedAt: _updatedAt, ...rest } = row; 377 + return BindingOps.clone(rest); 378 + } 379 + 380 + function difference(before: string[], after: string[]): string[] { 381 + const afterSet = new Set(after); 382 + return before.filter((id) => !afterSet.has(id)); 383 + } 384 + 385 + function deriveDocOrderFromDocument(doc: Document): DocOrder { 386 + return { pageIds: Object.keys(doc.pages), shapeOrder: shapeOrderFromPagesRecords(doc.pages) }; 387 + } 388 + 389 + function shapeOrderFromPagesRecords(pages: Record<string, PageRecord>): Record<string, string[]> { 390 + return Object.fromEntries(Object.values(pages).map((page) => [page.id, [...page.shapeIds]])); 391 + } 392 + 393 + function shapeOrderFromPageRows(rows: PageRow[]): Record<string, string[]> { 394 + return Object.fromEntries(rows.map((row) => [row.id, [...row.shapeIds]])); 395 + } 396 + 397 + function clonePatch(patch: DocPatch): DocPatch { 398 + const cloned: DocPatch = {}; 399 + 400 + if (patch.upserts) { 401 + cloned.upserts = {}; 402 + if (patch.upserts.pages) cloned.upserts.pages = patch.upserts.pages.map((page) => PageOps.clone(page)); 403 + if (patch.upserts.shapes) cloned.upserts.shapes = patch.upserts.shapes.map((shape) => ShapeOps.clone(shape)); 404 + if (patch.upserts.bindings) { 405 + cloned.upserts.bindings = patch.upserts.bindings.map((binding) => BindingOps.clone(binding)); 406 + } 407 + if (!cloned.upserts.pages && !cloned.upserts.shapes && !cloned.upserts.bindings) { 408 + delete cloned.upserts; 409 + } 410 + } 411 + 412 + if (patch.deletes) { 413 + cloned.deletes = {}; 414 + if (patch.deletes.pageIds) cloned.deletes.pageIds = [...patch.deletes.pageIds]; 415 + if (patch.deletes.shapeIds) cloned.deletes.shapeIds = [...patch.deletes.shapeIds]; 416 + if (patch.deletes.bindingIds) cloned.deletes.bindingIds = [...patch.deletes.bindingIds]; 417 + if (!cloned.deletes.pageIds?.length && !cloned.deletes.shapeIds?.length && !cloned.deletes.bindingIds?.length) { 418 + delete cloned.deletes; 419 + } 420 + } 421 + 422 + if (patch.order) { 423 + const pageIds = patch.order.pageIds ? [...patch.order.pageIds] : undefined; 424 + const shapeOrder = cloneShapeOrderMap(patch.order.shapeOrder); 425 + if (pageIds || shapeOrder) { 426 + cloned.order = {}; 427 + if (pageIds) { 428 + cloned.order.pageIds = pageIds; 429 + } 430 + if (shapeOrder) { 431 + cloned.order.shapeOrder = shapeOrder; 432 + } 433 + } 434 + } 435 + 436 + return cloned; 437 + } 438 + 439 + function cloneShapeOrderMap(shapeOrder?: Record<string, string[]>): Record<string, string[]> | undefined { 440 + if (!shapeOrder) { 441 + return undefined; 442 + } 443 + 444 + return Object.fromEntries(Object.entries(shapeOrder).map(([pageId, shapeIds]) => [pageId, [...shapeIds]])); 445 + } 446 + 447 + function isPatchEmpty(patch: DocPatch): boolean { 448 + const hasUpserts = Boolean(patch.upserts?.pages?.length) 449 + || Boolean(patch.upserts?.shapes?.length) 450 + || Boolean(patch.upserts?.bindings?.length); 451 + 452 + const hasDeletes = Boolean(patch.deletes?.pageIds?.length) 453 + || Boolean(patch.deletes?.shapeIds?.length) 454 + || Boolean(patch.deletes?.bindingIds?.length); 455 + 456 + const hasOrder = Boolean(patch.order?.pageIds?.length) 457 + || Boolean(patch.order?.shapeOrder && Object.keys(patch.order.shapeOrder).length > 0); 458 + 459 + return !(hasUpserts || hasDeletes || hasOrder); 460 + }
+44 -2
packages/core/src/reactivity.ts
··· 1 import { BehaviorSubject, type Subscription } from "rxjs"; 2 import type { Camera } from "./camera"; 3 import { Camera as CameraOps } from "./camera"; 4 - import { type Command, History, type HistoryState } from "./history"; 5 import type { Document, PageRecord, ShapeRecord } from "./model"; 6 import { Document as DocumentOps } from "./model"; 7 ··· 37 38 export type StateUpdater = (state: EditorState) => EditorState; 39 export type StateListener = (state: EditorState) => void; 40 41 /** 42 * Reactive store for editor state ··· 51 export class Store { 52 private readonly state$: BehaviorSubject<EditorState>; 53 private history: HistoryState; 54 55 - constructor(initialState?: EditorState) { 56 this.state$ = new BehaviorSubject(initialState ?? EditorState.create()); 57 this.history = History.create(); 58 } 59 60 /** ··· 94 this.history = newHistory; 95 const repairedState = enforceInvariants(newState); 96 this.state$.next(repairedState); 97 } 98 99 /** ··· 103 */ 104 undo(): boolean { 105 const currentState = this.state$.value; 106 const result = History.undo(this.history, currentState); 107 108 if (!result) { ··· 113 this.history = newHistory; 114 const repairedState = enforceInvariants(newState); 115 this.state$.next(repairedState); 116 return true; 117 } 118 ··· 123 */ 124 redo(): boolean { 125 const currentState = this.state$.value; 126 const result = History.redo(this.history, currentState); 127 128 if (!result) { ··· 133 this.history = newHistory; 134 const repairedState = enforceInvariants(newState); 135 this.state$.next(repairedState); 136 return true; 137 } 138 ··· 183 */ 184 getObservable() { 185 return this.state$.asObservable(); 186 } 187 } 188
··· 1 import { BehaviorSubject, type Subscription } from "rxjs"; 2 import type { Camera } from "./camera"; 3 import { Camera as CameraOps } from "./camera"; 4 + import { 5 + type Command, 6 + History, 7 + type HistoryAppliedEvent, 8 + type HistoryEntry, 9 + type HistoryOperation, 10 + type HistoryState, 11 + } from "./history"; 12 import type { Document, PageRecord, ShapeRecord } from "./model"; 13 import { Document as DocumentOps } from "./model"; 14 ··· 44 45 export type StateUpdater = (state: EditorState) => EditorState; 46 export type StateListener = (state: EditorState) => void; 47 + export type StoreOptions = { onHistoryEvent?: (event: HistoryAppliedEvent) => void }; 48 49 /** 50 * Reactive store for editor state ··· 59 export class Store { 60 private readonly state$: BehaviorSubject<EditorState>; 61 private history: HistoryState; 62 + private readonly historyListener?: (event: HistoryAppliedEvent) => void; 63 64 + constructor(initialState?: EditorState, options?: StoreOptions) { 65 this.state$ = new BehaviorSubject(initialState ?? EditorState.create()); 66 this.history = History.create(); 67 + this.historyListener = options?.onHistoryEvent; 68 } 69 70 /** ··· 104 this.history = newHistory; 105 const repairedState = enforceInvariants(newState); 106 this.state$.next(repairedState); 107 + const entry = this.history.undoStack.at(-1); 108 + if (entry) { 109 + this.emitHistoryEvent("do", entry, currentState, repairedState); 110 + } 111 } 112 113 /** ··· 117 */ 118 undo(): boolean { 119 const currentState = this.state$.value; 120 + const entry = this.history.undoStack.at(-1); 121 const result = History.undo(this.history, currentState); 122 123 if (!result) { ··· 128 this.history = newHistory; 129 const repairedState = enforceInvariants(newState); 130 this.state$.next(repairedState); 131 + if (entry) { 132 + this.emitHistoryEvent("undo", entry, currentState, repairedState); 133 + } 134 return true; 135 } 136 ··· 141 */ 142 redo(): boolean { 143 const currentState = this.state$.value; 144 + const entry = this.history.redoStack.at(-1); 145 const result = History.redo(this.history, currentState); 146 147 if (!result) { ··· 152 this.history = newHistory; 153 const repairedState = enforceInvariants(newState); 154 this.state$.next(repairedState); 155 + if (entry) { 156 + this.emitHistoryEvent("redo", entry, currentState, repairedState); 157 + } 158 return true; 159 } 160 ··· 205 */ 206 getObservable() { 207 return this.state$.asObservable(); 208 + } 209 + 210 + private emitHistoryEvent( 211 + op: HistoryOperation, 212 + entry: HistoryEntry, 213 + beforeState: EditorState, 214 + afterState: EditorState, 215 + ): void { 216 + if (!this.historyListener) { 217 + return; 218 + } 219 + 220 + this.historyListener({ 221 + op, 222 + commandId: entry.timestamp, 223 + command: entry.command, 224 + kind: entry.command.kind, 225 + beforeState, 226 + afterState, 227 + }); 228 } 229 } 230
+315
packages/core/tests/persistence/web.test.ts
···
··· 1 + import "fake-indexeddb/auto"; 2 + import Dexie from "dexie"; 3 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 4 + import { InkfiniteDB, runMigrations } from "../../src"; 5 + import { 6 + createPersistenceSink, 7 + CreateShapeCommand, 8 + createWebDocRepo, 9 + diffDoc, 10 + SetSelectionCommand, 11 + Store, 12 + } from "../../src"; 13 + import { Document as DocumentOps, PageRecord, ShapeRecord } from "../../src/model"; 14 + 15 + const openDbs: Dexie[] = []; 16 + 17 + function createTestDb(name = `inkfinite-test-${Math.random().toString(36).slice(2)}`) { 18 + const database = new InkfiniteDB(name); 19 + openDbs.push(database); 20 + return database; 21 + } 22 + 23 + afterEach(async () => { 24 + await Promise.all(openDbs.map(async (database) => { 25 + try { 26 + await database.delete(); 27 + } catch { 28 + /* No-op */ 29 + } 30 + })); 31 + openDbs.length = 0; 32 + vi.useRealTimers(); 33 + }); 34 + 35 + describe("DocRepo (Dexie)", () => { 36 + it("createBoard seeds default page + order and rename persists", async () => { 37 + const db = createTestDb(); 38 + const repo = createWebDocRepo(db); 39 + const boardId = await repo.createBoard("Seeded"); 40 + const loaded = await repo.loadDoc(boardId); 41 + 42 + expect(Object.keys(loaded.pages)).toHaveLength(1); 43 + expect(loaded.order.pageIds).toHaveLength(1); 44 + expect(loaded.order.shapeOrder?.[loaded.order.pageIds[0]!]).toEqual([]); 45 + 46 + await repo.renameBoard(boardId, "Renamed"); 47 + const boardRow = await db.table("boards").get(boardId); 48 + expect(boardRow?.name).toBe("Renamed"); 49 + }); 50 + 51 + it("round-trips docs via applyDocPatch + loadDoc", async () => { 52 + const database = createTestDb(); 53 + const repo = createWebDocRepo(database); 54 + const boardId = await repo.createBoard("Round trip"); 55 + 56 + const page = PageRecord.create("Canvas"); 57 + const rect = ShapeRecord.createRect(page.id, 0, 0, { w: 100, h: 80, fill: "#000", stroke: "#fff", radius: 8 }); 58 + page.shapeIds.push(rect.id); 59 + 60 + const doc = DocumentOps.create(); 61 + doc.pages[page.id] = page; 62 + doc.shapes[rect.id] = rect; 63 + 64 + await repo.applyDocPatch(boardId, diffDoc(DocumentOps.create(), doc)); 65 + 66 + const loaded = await repo.loadDoc(boardId); 67 + expect(loaded.pages[page.id]).toEqual(page); 68 + expect(loaded.shapes[rect.id]).toEqual(rect); 69 + expect(loaded.order.pageIds).toContain(page.id); 70 + }); 71 + 72 + it("applyDocPatch performs a single Dexie transaction", async () => { 73 + const database = createTestDb(); 74 + const repo = createWebDocRepo(database); 75 + const boardId = await repo.createBoard("Tx board"); 76 + 77 + const page = PageRecord.create("Tx Page"); 78 + const rect = ShapeRecord.createRect(page.id, 10, 10, { w: 50, h: 50, fill: "#ccc", stroke: "#111", radius: 4 }); 79 + page.shapeIds.push(rect.id); 80 + 81 + const doc = DocumentOps.create(); 82 + doc.pages[page.id] = page; 83 + doc.shapes[rect.id] = rect; 84 + 85 + const transactionSpy = vi.spyOn(database, "transaction"); 86 + await repo.applyDocPatch(boardId, diffDoc(DocumentOps.create(), doc)); 87 + expect(transactionSpy).toHaveBeenCalledTimes(1); 88 + }); 89 + 90 + it("deleteBoard removes rows across all tables", async () => { 91 + const database = createTestDb(); 92 + const repo = createWebDocRepo(database); 93 + const boardId = await repo.createBoard("Delete board"); 94 + 95 + const page = PageRecord.create("Delete Page"); 96 + const rect = ShapeRecord.createRect(page.id, 0, 0, { w: 40, h: 40, fill: "#f00", stroke: "#000", radius: 2 }); 97 + page.shapeIds.push(rect.id); 98 + 99 + const doc = DocumentOps.create(); 100 + doc.pages[page.id] = page; 101 + doc.shapes[rect.id] = rect; 102 + 103 + await repo.applyDocPatch(boardId, diffDoc(DocumentOps.create(), doc)); 104 + await repo.deleteBoard(boardId); 105 + 106 + expect(await database.table("boards").toArray()).toHaveLength(0); 107 + expect(await database.table("pages").toArray()).toHaveLength(0); 108 + expect(await database.table("shapes").toArray()).toHaveLength(0); 109 + expect(await database.table("bindings").toArray()).toHaveLength(0); 110 + }); 111 + 112 + it("exportBoard + importBoard round-trip doc + metadata", async () => { 113 + const db = createTestDb(); 114 + const repo = createWebDocRepo(db); 115 + const boardId = await repo.createBoard("Source"); 116 + 117 + const page = PageRecord.create("Canvas"); 118 + const rect = ShapeRecord.createRect(page.id, 5, 5, { w: 20, h: 10, fill: "#123", stroke: "#456", radius: 1 }); 119 + page.shapeIds.push(rect.id); 120 + 121 + const doc = DocumentOps.create(); 122 + doc.pages[page.id] = page; 123 + doc.shapes[rect.id] = rect; 124 + 125 + await repo.applyDocPatch(boardId, diffDoc(DocumentOps.create(), doc)); 126 + 127 + const snapshot = await repo.exportBoard(boardId); 128 + const importedId = await repo.importBoard({ 129 + ...snapshot, 130 + board: { ...snapshot.board, id: "board:imported", name: "Imported" }, 131 + }); 132 + 133 + const imported = await repo.loadDoc(importedId); 134 + expect(imported.pages).toEqual(snapshot.doc.pages); 135 + expect(imported.shapes).toEqual(snapshot.doc.shapes); 136 + }); 137 + }); 138 + 139 + describe("History persistence sink", () => { 140 + let repo: ReturnType<typeof createWebDocRepo>; 141 + let boardId: string; 142 + 143 + beforeEach(async () => { 144 + const database = createTestDb(); 145 + repo = createWebDocRepo(database); 146 + boardId = await repo.createBoard("Persisted"); 147 + }); 148 + 149 + it("doc command triggers exactly one persistence flush", async () => { 150 + const { store, sink, applySpy, pageId } = await createStoreWithSink(repo, boardId); 151 + 152 + const rect = ShapeRecord.createRect(pageId, 0, 0, { w: 10, h: 10, fill: "#222", stroke: "#fff", radius: 0 }); 153 + 154 + store.executeCommand(new CreateShapeCommand(rect, pageId)); 155 + expect(applySpy).toHaveBeenCalledTimes(0); 156 + 157 + await sink.flush(); 158 + 159 + expect(applySpy).toHaveBeenCalledTimes(1); 160 + const loaded = await repo.loadDoc(boardId); 161 + expect(loaded.shapes[rect.id]).toBeDefined(); 162 + }); 163 + 164 + it("undo and redo both persist document changes", async () => { 165 + const { store, sink, applySpy, pageId } = await createStoreWithSink(repo, boardId); 166 + 167 + const rect = ShapeRecord.createRect(pageId, 0, 0, { w: 25, h: 25, fill: "#0f0", stroke: "#090", radius: 0 }); 168 + 169 + store.executeCommand(new CreateShapeCommand(rect, pageId)); 170 + await sink.flush(); 171 + applySpy.mockClear(); 172 + 173 + store.undo(); 174 + await sink.flush(); 175 + expect(applySpy).toHaveBeenCalledTimes(1); 176 + let loaded = await repo.loadDoc(boardId); 177 + expect(loaded.shapes[rect.id]).toBeUndefined(); 178 + 179 + applySpy.mockClear(); 180 + store.redo(); 181 + await sink.flush(); 182 + expect(applySpy).toHaveBeenCalledTimes(1); 183 + loaded = await repo.loadDoc(boardId); 184 + expect(loaded.shapes[rect.id]).toBeDefined(); 185 + }); 186 + 187 + it("ui-only commands never hit persistence", async () => { 188 + const { store, sink, applySpy, pageId } = await createStoreWithSink(repo, boardId); 189 + 190 + const rect = ShapeRecord.createRect(pageId, 0, 0, { w: 15, h: 15, fill: "#aaa", stroke: "#bbb", radius: 0 }); 191 + 192 + store.executeCommand(new CreateShapeCommand(rect, pageId)); 193 + await sink.flush(); 194 + applySpy.mockClear(); 195 + 196 + store.executeCommand(new SetSelectionCommand([], [rect.id])); 197 + await sink.flush(); 198 + 199 + expect(applySpy).toHaveBeenCalledTimes(0); 200 + }); 201 + 202 + it("batches rapid doc commands into one flush", async () => { 203 + const { store, sink, applySpy, pageId } = await createStoreWithSink(repo, boardId); 204 + 205 + for (let i = 0; i < 10; i++) { 206 + const rect = ShapeRecord.createRect(pageId, i * 5, 0, { w: 5, h: 5, fill: "#444", stroke: "#111", radius: 0 }); 207 + store.executeCommand(new CreateShapeCommand(rect, pageId)); 208 + } 209 + 210 + await sink.flush(); 211 + 212 + expect(applySpy).toHaveBeenCalledTimes(1); 213 + const loaded = await repo.loadDoc(boardId); 214 + expect(Object.keys(loaded.shapes).length).toBeGreaterThanOrEqual(10); 215 + }); 216 + }); 217 + 218 + describe("runMigrations", () => { 219 + it("backfills timestamps and default page/order", async () => { 220 + const db = new Dexie(`migrations-test-${Math.random().toString(36).slice(2)}`); 221 + db.version(1).stores({ 222 + boards: "id, name, createdAt, updatedAt", 223 + pages: "[boardId+id], boardId, updatedAt", 224 + shapes: "[boardId+id], boardId, type, updatedAt", 225 + bindings: "[boardId+id], boardId, type, updatedAt", 226 + meta: "key", 227 + migrations: "id, appliedAt", 228 + }); 229 + openDbs.push(db); 230 + await db.open(); 231 + await db.table("boards").add({ id: "board:legacy", name: "Legacy Board" }); 232 + 233 + await db.transaction("rw", db.tables, async (tx) => { 234 + await runMigrations(tx); 235 + }); 236 + 237 + const board = await db.table("boards").get("board:legacy"); 238 + expect(board?.createdAt).toBeTypeOf("number"); 239 + expect(board?.updatedAt).toBeTypeOf("number"); 240 + 241 + const pages = await db.table("pages").where("boardId").equals("board:legacy").toArray(); 242 + expect(pages).toHaveLength(1); 243 + 244 + const pageOrder = await db.table("meta").get(`page-order:board:legacy`); 245 + expect(pageOrder?.value).toEqual([pages[0]!.id]); 246 + 247 + const shapeOrder = await db.table("meta").get(`shape-order:board:legacy`); 248 + expect(shapeOrder?.value).toEqual({ [pages[0]!.id]: [] }); 249 + 250 + const migrations = await db.table("migrations").toArray(); 251 + expect(migrations.map((row) => row.id)).toContain("MIG-0001"); 252 + expect(migrations.map((row) => row.id)).toContain("MIG-0002"); 253 + }); 254 + 255 + it("does not re-apply already applied migrations", async () => { 256 + const db = new Dexie(`migrations-repeat-${Math.random().toString(36).slice(2)}`); 257 + db.version(1).stores({ 258 + boards: "id, name, createdAt, updatedAt", 259 + pages: "[boardId+id], boardId, updatedAt", 260 + shapes: "[boardId+id], boardId, type, updatedAt", 261 + bindings: "[boardId+id], boardId, type, updatedAt", 262 + meta: "key", 263 + migrations: "id, appliedAt", 264 + }); 265 + openDbs.push(db); 266 + await db.open(); 267 + await db.table("boards").add({ id: "board:legacy-two", name: "Legacy", createdAt: 1, updatedAt: 1 }); 268 + 269 + await db.transaction("rw", db.tables, async (tx) => { 270 + await runMigrations(tx); 271 + }); 272 + 273 + const migrationsAfterFirstRun = await db.table("migrations").toArray(); 274 + 275 + await db.table("pages").clear(); 276 + await db.table("meta").clear(); 277 + 278 + await db.transaction("rw", db.tables, async (tx) => { 279 + await runMigrations(tx); 280 + }); 281 + 282 + const migrationsAfterSecondRun = await db.table("migrations").toArray(); 283 + expect(migrationsAfterSecondRun).toHaveLength(migrationsAfterFirstRun.length); 284 + }); 285 + }); 286 + 287 + async function createStoreWithSink(repo: ReturnType<typeof createWebDocRepo>, boardId: string) { 288 + const sink = createPersistenceSink(repo, { debounceMs: 10 }); 289 + const applySpy = vi.spyOn(repo, "applyDocPatch"); 290 + const store = new Store(undefined, { 291 + onHistoryEvent: (event) => { 292 + if (event.kind !== "doc") { 293 + return; 294 + } 295 + const patch = diffDoc(event.beforeState.doc, event.afterState.doc); 296 + sink.enqueueDocPatch(boardId, patch); 297 + }, 298 + }); 299 + 300 + const pageId = await hydrateStoreFromRepo(store, repo, boardId); 301 + return { store, sink, applySpy, pageId }; 302 + } 303 + 304 + async function hydrateStoreFromRepo(store: Store, repo: ReturnType<typeof createWebDocRepo>, boardId: string) { 305 + const loaded = await repo.loadDoc(boardId); 306 + const firstPageId = loaded.order.pageIds[0] ?? Object.keys(loaded.pages)[0]; 307 + 308 + store.setState((state) => ({ 309 + ...state, 310 + doc: { pages: loaded.pages, shapes: loaded.shapes, bindings: loaded.bindings }, 311 + ui: { ...state.ui, currentPageId: firstPageId ?? null }, 312 + })); 313 + 314 + return firstPageId!; 315 + }
+1
packages/core/vitest.config.ts
··· 3 export default defineConfig({ 4 test: { 5 ui: false, 6 coverage: { 7 provider: "v8", 8 reporter: ["text", "html", "json"],
··· 3 export default defineConfig({ 4 test: { 5 ui: false, 6 + watch: false, 7 coverage: { 8 provider: "v8", 9 reporter: ["text", "html", "json"],
+1 -1
packages/renderer/vitest.config.ts
··· 1 import { defineConfig } from "vitest/config"; 2 3 - export default defineConfig({ test: { environment: "jsdom", globals: true, ui: false } });
··· 1 import { defineConfig } from "vitest/config"; 2 3 + export default defineConfig({ test: { environment: "jsdom", globals: true, ui: false, watch: false } });
+12
pnpm-lock.yaml
··· 32 33 apps/web: 34 dependencies: 35 inkfinite-core: 36 specifier: workspace:* 37 version: link:../../packages/core ··· 127 bumpp: 128 specifier: ^10.3.2 129 version: 10.3.2(magicast@0.5.1) 130 tsdown: 131 specifier: ^0.18.1 132 version: 0.18.1(typescript@5.9.3) ··· 1245 1246 exsolve@1.0.8: 1247 resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} 1248 1249 fast-deep-equal@3.1.3: 1250 resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} ··· 3157 expect-type@1.3.0: {} 3158 3159 exsolve@1.0.8: {} 3160 3161 fast-deep-equal@3.1.3: {} 3162
··· 32 33 apps/web: 34 dependencies: 35 + dexie: 36 + specifier: ^4.2.1 37 + version: 4.2.1 38 inkfinite-core: 39 specifier: workspace:* 40 version: link:../../packages/core ··· 130 bumpp: 131 specifier: ^10.3.2 132 version: 10.3.2(magicast@0.5.1) 133 + fake-indexeddb: 134 + specifier: ^6.0.0 135 + version: 6.2.5 136 tsdown: 137 specifier: ^0.18.1 138 version: 0.18.1(typescript@5.9.3) ··· 1251 1252 exsolve@1.0.8: 1253 resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} 1254 + 1255 + fake-indexeddb@6.2.5: 1256 + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} 1257 + engines: {node: '>=18'} 1258 1259 fast-deep-equal@3.1.3: 1260 resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} ··· 3167 expect-type@1.3.0: {} 3168 3169 exsolve@1.0.8: {} 3170 + 3171 + fake-indexeddb@6.2.5: {} 3172 3173 fast-deep-equal@3.1.3: {} 3174