web based infinite canvas
2
fork

Configure Feed

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

feat: reactive core

+1192 -13
+13 -13
TODO.txt
··· 18 18 - /apps/web (SvelteKit) 19 19 - /apps/desktop (Tauri wrapper) 20 20 - /packages/core (pure TS engine) 21 - - /packages/renderer-canvas2d (Canvas2D renderer) 21 + - /packages/renderer (Canvas2D renderer) 22 22 23 23 [ ] Add TypeScript config(s): 24 24 - one shared tsconfig.base.json ··· 114 114 115 115 Goal: a fast, deterministic state container for the editor using RxJS 116 116 117 - Store (/packages/core/src/store) - RxJS + SvelteKit (runes) friendly 117 + Store (/packages/core/src/reactivity.ts) - RxJS + SvelteKit (runes) friendly 118 118 119 119 Core types: 120 - [ ] Define EditorState: 120 + [x] Define EditorState: 121 121 - doc: { pages, shapes, bindings } 122 122 - ui: { currentPageId, selectionIds: string[], toolId: ToolId } 123 123 - camera: { x, y, zoom } 124 124 125 125 RxJS store (BehaviorSubject-backed): 126 - [ ] Implement createEditorStore(initial: EditorState) that exposes: 126 + [x] Implement createEditorStore(initial: EditorState) that exposes: 127 127 - state$: Observable<EditorState> (read stream) 128 128 - getState(): EditorState (sync snapshot) 129 129 - setState(updater: (s) => s): void (mutation API) ··· 135 135 - subscribe must return an unsubscribe function. 136 136 137 137 Selectors (pure functions, no RxJS): 138 - [ ] Implement selectors in /packages/core/src/store/selectors.ts: 138 + [x] Implement selectors 139 139 - getCurrentPage(state) 140 140 - getShapesOnCurrentPage(state) 141 141 - getSelectedShapes(state) 142 142 143 143 Invariants (pick “repair” and test it): 144 - [ ] Implement enforceInvariants(state): EditorState (repair strategy): 144 + [x] Implement enforceInvariants(state): EditorState (repair strategy): 145 145 - selectionIds := selectionIds filtered to existing shapes 146 146 - currentPageId must exist: 147 147 - if missing, set to first existing page 148 148 - if no pages exist, create a default page and set it 149 - [ ] Ensure setState always runs enforceInvariants before publishing next state 149 + [x] Ensure setState always runs enforceInvariants before publishing next state 150 150 151 - Tests (vitest): 152 - [ ] subscribe immediately receives current state upon subscription (BehaviorSubject behavior) 153 - [ ] subscribe fires exactly once per setState call 154 - [ ] invariants are enforced on any update (selection filtered, page fixed/created) 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 155 156 156 (DoD): 157 157 - Renderer can subscribe to state$ (or subscribe()) and redraw on any change. ··· 163 163 164 164 Goal: draw the document from state, no interactivity yet. 165 165 166 - Renderer (/packages/renderer-canvas2d): 166 + Renderer (/packages/renderer): 167 167 [ ] createRenderer(canvas, store) -> { dispose() } 168 168 [ ] Implement render loop strategy: 169 169 - requestAnimationFrame redraw on "dirty" flag ··· 551 551 store/ 552 552 tools/ 553 553 554 - packages/renderer-canvas2d/src/ 554 + packages/renderer/src/ 555 555 renderer.ts 556 556 draw/ 557 557 text/
+224
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 { Document, PageRecord, ShapeRecord } from "./model"; 5 + import { Document as DocumentOps } from "./model"; 6 + 7 + export type ToolId = "select" | "rect" | "ellipse" | "line" | "arrow" | "text" | "pen"; 8 + 9 + export type UIState = { currentPageId: string | null; selectionIds: string[]; toolId: ToolId }; 10 + 11 + export type EditorState = { doc: Document; ui: UIState; camera: Camera }; 12 + 13 + export const EditorState = { 14 + /** 15 + * Create initial editor state 16 + */ 17 + create(): EditorState { 18 + return { 19 + doc: DocumentOps.create(), 20 + ui: { currentPageId: null, selectionIds: [], toolId: "select" }, 21 + camera: CameraOps.create(), 22 + }; 23 + }, 24 + 25 + /** 26 + * Clone editor state 27 + */ 28 + clone(state: EditorState): EditorState { 29 + return { 30 + doc: DocumentOps.clone(state.doc), 31 + ui: { currentPageId: state.ui.currentPageId, selectionIds: [...state.ui.selectionIds], toolId: state.ui.toolId }, 32 + camera: CameraOps.clone(state.camera), 33 + }; 34 + }, 35 + }; 36 + 37 + export type StateUpdater = (state: EditorState) => EditorState; 38 + export type StateListener = (state: EditorState) => void; 39 + 40 + /** 41 + * Reactive store for editor state 42 + * 43 + * Features: 44 + * - Observable state using RxJS BehaviorSubject 45 + * - Immutable state updates 46 + * - Invariant enforcement (repairs invalid state) 47 + * - Subscription management 48 + */ 49 + export class Store { 50 + private readonly state$: BehaviorSubject<EditorState>; 51 + 52 + constructor(initialState?: EditorState) { 53 + this.state$ = new BehaviorSubject(initialState ?? EditorState.create()); 54 + } 55 + 56 + /** 57 + * Get the current state snapshot 58 + */ 59 + getState(): EditorState { 60 + return this.state$.value; 61 + } 62 + 63 + /** 64 + * Update the state using an updater function 65 + * 66 + * The updater receives the current state and returns a new state. 67 + * Invariants are enforced after the update. 68 + * 69 + * @param updater - Function that transforms current state to new state 70 + */ 71 + setState(updater: StateUpdater): void { 72 + const currentState = this.state$.value; 73 + const newState = updater(currentState); 74 + const repairedState = enforceInvariants(newState); 75 + this.state$.next(repairedState); 76 + } 77 + 78 + /** 79 + * Subscribe to state changes 80 + * 81 + * The listener is called immediately with the current state, 82 + * and then on every state change. 83 + * 84 + * @param listener - Function called with new state 85 + * @returns Unsubscribe function 86 + */ 87 + subscribe(listener: StateListener): () => void { 88 + const subscription: Subscription = this.state$.subscribe(listener); 89 + return () => subscription.unsubscribe(); 90 + } 91 + 92 + /** 93 + * Get the underlying RxJS observable 94 + */ 95 + getObservable() { 96 + return this.state$.asObservable(); 97 + } 98 + } 99 + 100 + /** 101 + * Enforce state invariants by repairing invalid state 102 + * 103 + * Invariants: 104 + * 1. currentPageId must reference an existing page (or be null) 105 + * 2. selectionIds must only reference existing shapes 106 + * 3. selectionIds must only reference shapes on the current page 107 + * 108 + * @param state - State to validate and repair 109 + * @returns Repaired state 110 + */ 111 + function enforceInvariants(state: EditorState): EditorState { 112 + const pages = Object.keys(state.doc.pages); 113 + const shapes = state.doc.shapes; 114 + 115 + let currentPageId = state.ui.currentPageId; 116 + if (currentPageId !== null && !state.doc.pages[currentPageId]) { 117 + currentPageId = pages.length > 0 ? pages[0] : null; 118 + } 119 + 120 + let selectionIds = state.ui.selectionIds; 121 + if (currentPageId === null) { 122 + selectionIds = []; 123 + } else { 124 + const currentPage = state.doc.pages[currentPageId]; 125 + const validShapeIds = new Set(currentPage?.shapeIds); 126 + 127 + selectionIds = selectionIds.filter((id) => { 128 + return shapes[id] && validShapeIds.has(id); 129 + }); 130 + } 131 + 132 + if (currentPageId === state.ui.currentPageId && arraysEqual(selectionIds, state.ui.selectionIds)) { 133 + return state; 134 + } 135 + 136 + return { ...state, ui: { ...state.ui, currentPageId, selectionIds } }; 137 + } 138 + 139 + /** 140 + * Check if two arrays are equal 141 + */ 142 + function arraysEqual(a: string[], b: string[]): boolean { 143 + if (a.length !== b.length) return false; 144 + let index = 0; 145 + for (const item of a) { 146 + if (item !== b[index]) return false; 147 + index++; 148 + } 149 + return true; 150 + } 151 + 152 + /** 153 + * Get the current page record 154 + * 155 + * @param state - Editor state 156 + * @returns Current page or null if no page is selected 157 + */ 158 + export function getCurrentPage(state: EditorState): PageRecord | null { 159 + if (state.ui.currentPageId === null) { 160 + return null; 161 + } 162 + return state.doc.pages[state.ui.currentPageId] ?? null; 163 + } 164 + 165 + /** 166 + * Get all shapes on the current page 167 + * 168 + * @param state - Editor state 169 + * @returns Array of shapes on current page (empty if no page selected) 170 + */ 171 + export function getShapesOnCurrentPage(state: EditorState): ShapeRecord[] { 172 + const currentPage = getCurrentPage(state); 173 + if (!currentPage) { 174 + return []; 175 + } 176 + 177 + return currentPage.shapeIds.map((id) => state.doc.shapes[id]).filter((shape): shape is ShapeRecord => 178 + shape !== undefined 179 + ); 180 + } 181 + 182 + /** 183 + * Get all selected shapes 184 + * 185 + * @param state - Editor state 186 + * @returns Array of selected shapes (empty if no selection) 187 + */ 188 + export function getSelectedShapes(state: EditorState): ShapeRecord[] { 189 + return state.ui.selectionIds.map((id) => state.doc.shapes[id]).filter((shape): shape is ShapeRecord => 190 + shape !== undefined 191 + ); 192 + } 193 + 194 + /** 195 + * Check if a shape is selected 196 + * 197 + * @param state - Editor state 198 + * @param shapeId - Shape ID to check 199 + * @returns True if shape is selected 200 + */ 201 + export function isShapeSelected(state: EditorState, shapeId: string): boolean { 202 + return state.ui.selectionIds.includes(shapeId); 203 + } 204 + 205 + /** 206 + * Get all pages 207 + * 208 + * @param state - Editor state 209 + * @returns Array of all pages 210 + */ 211 + export function getAllPages(state: EditorState): PageRecord[] { 212 + return Object.values(state.doc.pages); 213 + } 214 + 215 + /** 216 + * Get shape by ID 217 + * 218 + * @param state - Editor state 219 + * @param shapeId - Shape ID 220 + * @returns Shape or undefined if not found 221 + */ 222 + export function getShape(state: EditorState, shapeId: string): ShapeRecord | undefined { 223 + return state.doc.shapes[shapeId]; 224 + }
+955
packages/core/tests/reactivity.test.ts
··· 1 + import { describe, expect, it, vi } from 'vitest'; 2 + import { Camera } from '../src/camera'; 3 + import { PageRecord, ShapeRecord } from '../src/model'; 4 + import { 5 + EditorState as EditorStateOps, 6 + getAllPages, 7 + getCurrentPage, 8 + getSelectedShapes, 9 + getShape, 10 + getShapesOnCurrentPage, 11 + isShapeSelected, 12 + Store, 13 + } from '../src/reactivity'; 14 + 15 + describe('EditorState', () => { 16 + describe('create', () => { 17 + it('should create initial editor state', () => { 18 + const state = EditorStateOps.create(); 19 + 20 + expect(state.doc.pages).toEqual({}); 21 + expect(state.doc.shapes).toEqual({}); 22 + expect(state.doc.bindings).toEqual({}); 23 + expect(state.ui.currentPageId).toBeNull(); 24 + expect(state.ui.selectionIds).toEqual([]); 25 + expect(state.ui.toolId).toBe('select'); 26 + expect(state.camera).toEqual({ x: 0, y: 0, zoom: 1 }); 27 + }); 28 + }); 29 + 30 + describe('clone', () => { 31 + it('should deep clone editor state', () => { 32 + const state = EditorStateOps.create(); 33 + const page = PageRecord.create('Page 1', 'page1'); 34 + const shape = ShapeRecord.createRect( 35 + 'page1', 36 + 0, 37 + 0, 38 + { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 39 + 'shape1', 40 + ); 41 + 42 + page.shapeIds = ['shape1']; 43 + state.doc.pages = { page1: page }; 44 + state.doc.shapes = { shape1: shape }; 45 + state.ui.currentPageId = 'page1'; 46 + state.ui.selectionIds = ['shape1']; 47 + state.camera = { x: 100, y: 200, zoom: 1.5 }; 48 + 49 + const cloned = EditorStateOps.clone(state); 50 + 51 + expect(cloned).toEqual(state); 52 + expect(cloned).not.toBe(state); 53 + expect(cloned.doc).not.toBe(state.doc); 54 + expect(cloned.ui).not.toBe(state.ui); 55 + expect(cloned.camera).not.toBe(state.camera); 56 + expect(cloned.ui.selectionIds).not.toBe(state.ui.selectionIds); 57 + }); 58 + }); 59 + }); 60 + 61 + describe('Store', () => { 62 + describe('constructor', () => { 63 + it('should create store with default initial state', () => { 64 + const store = new Store(); 65 + const state = store.getState(); 66 + 67 + expect(state.doc.pages).toEqual({}); 68 + expect(state.ui.currentPageId).toBeNull(); 69 + expect(state.ui.selectionIds).toEqual([]); 70 + expect(state.ui.toolId).toBe('select'); 71 + }); 72 + 73 + it('should create store with custom initial state', () => { 74 + const initialState = EditorStateOps.create(); 75 + initialState.ui.toolId = 'rect'; 76 + initialState.camera = { x: 100, y: 200, zoom: 2 }; 77 + 78 + const store = new Store(initialState); 79 + const state = store.getState(); 80 + 81 + expect(state.ui.toolId).toBe('rect'); 82 + expect(state.camera.x).toBe(100); 83 + expect(state.camera.y).toBe(200); 84 + expect(state.camera.zoom).toBe(2); 85 + }); 86 + }); 87 + 88 + describe('getState', () => { 89 + it('should return current state', () => { 90 + const store = new Store(); 91 + const state = store.getState(); 92 + 93 + expect(state).toBeDefined(); 94 + expect(state.doc).toBeDefined(); 95 + expect(state.ui).toBeDefined(); 96 + expect(state.camera).toBeDefined(); 97 + }); 98 + 99 + it('should return same state on multiple calls if no updates', () => { 100 + const store = new Store(); 101 + const state1 = store.getState(); 102 + const state2 = store.getState(); 103 + 104 + expect(state1).toBe(state2); 105 + }); 106 + }); 107 + 108 + describe('setState', () => { 109 + it('should update state using updater function', () => { 110 + const store = new Store(); 111 + 112 + store.setState((state) => ({ 113 + ...state, 114 + ui: { ...state.ui, toolId: 'ellipse' }, 115 + })); 116 + 117 + const state = store.getState(); 118 + expect(state.ui.toolId).toBe('ellipse'); 119 + }); 120 + 121 + it('should update camera position', () => { 122 + const store = new Store(); 123 + 124 + store.setState((state) => ({ 125 + ...state, 126 + camera: Camera.pan(state.camera, { x: 50, y: 30 }), 127 + })); 128 + 129 + const state = store.getState(); 130 + expect(state.camera.x).toBe(-50); 131 + expect(state.camera.y).toBe(-30); 132 + }); 133 + 134 + it('should update document', () => { 135 + const store = new Store(); 136 + const page = PageRecord.create('Page 1', 'page1'); 137 + 138 + store.setState((state) => ({ 139 + ...state, 140 + doc: { 141 + ...state.doc, 142 + pages: { page1: page }, 143 + }, 144 + })); 145 + 146 + const state = store.getState(); 147 + expect(state.doc.pages.page1).toBeDefined(); 148 + expect(state.doc.pages.page1.name).toBe('Page 1'); 149 + }); 150 + }); 151 + 152 + describe('subscribe', () => { 153 + it('should call listener immediately with current state', () => { 154 + const store = new Store(); 155 + const listener = vi.fn(); 156 + 157 + store.subscribe(listener); 158 + 159 + expect(listener).toHaveBeenCalledTimes(1); 160 + expect(listener).toHaveBeenCalledWith(store.getState()); 161 + }); 162 + 163 + it('should call listener on state change', () => { 164 + const store = new Store(); 165 + const listener = vi.fn(); 166 + 167 + store.subscribe(listener); 168 + listener.mockClear(); 169 + 170 + store.setState((state) => ({ 171 + ...state, 172 + ui: { ...state.ui, toolId: 'rect' }, 173 + })); 174 + 175 + expect(listener).toHaveBeenCalledTimes(1); 176 + }); 177 + 178 + it('should call listener exactly once per setState', () => { 179 + const store = new Store(); 180 + const listener = vi.fn(); 181 + 182 + store.subscribe(listener); 183 + listener.mockClear(); 184 + 185 + store.setState((state) => ({ 186 + ...state, 187 + ui: { ...state.ui, toolId: 'rect' }, 188 + })); 189 + 190 + expect(listener).toHaveBeenCalledTimes(1); 191 + 192 + listener.mockClear(); 193 + store.setState((state) => ({ 194 + ...state, 195 + ui: { ...state.ui, toolId: 'ellipse' }, 196 + })); 197 + 198 + expect(listener).toHaveBeenCalledTimes(1); 199 + }); 200 + 201 + it('should support multiple subscribers', () => { 202 + const store = new Store(); 203 + const listener1 = vi.fn(); 204 + const listener2 = vi.fn(); 205 + 206 + store.subscribe(listener1); 207 + store.subscribe(listener2); 208 + 209 + listener1.mockClear(); 210 + listener2.mockClear(); 211 + 212 + store.setState((state) => ({ 213 + ...state, 214 + ui: { ...state.ui, toolId: 'arrow' }, 215 + })); 216 + 217 + expect(listener1).toHaveBeenCalledTimes(1); 218 + expect(listener2).toHaveBeenCalledTimes(1); 219 + }); 220 + 221 + it('should unsubscribe when unsubscribe function is called', () => { 222 + const store = new Store(); 223 + const listener = vi.fn(); 224 + 225 + const unsubscribe = store.subscribe(listener); 226 + listener.mockClear(); 227 + 228 + unsubscribe(); 229 + 230 + store.setState((state) => ({ 231 + ...state, 232 + ui: { ...state.ui, toolId: 'line' }, 233 + })); 234 + 235 + expect(listener).not.toHaveBeenCalled(); 236 + }); 237 + 238 + it('should handle multiple subscriptions and unsubscriptions independently', () => { 239 + const store = new Store(); 240 + const listener1 = vi.fn(); 241 + const listener2 = vi.fn(); 242 + 243 + const unsubscribe1 = store.subscribe(listener1); 244 + const unsubscribe2 = store.subscribe(listener2); 245 + 246 + listener1.mockClear(); 247 + listener2.mockClear(); 248 + 249 + unsubscribe1(); 250 + 251 + store.setState((state) => ({ 252 + ...state, 253 + ui: { ...state.ui, toolId: 'text' }, 254 + })); 255 + 256 + expect(listener1).not.toHaveBeenCalled(); 257 + expect(listener2).toHaveBeenCalledTimes(1); 258 + 259 + listener2.mockClear(); 260 + unsubscribe2(); 261 + 262 + store.setState((state) => ({ 263 + ...state, 264 + ui: { ...state.ui, toolId: 'pen' }, 265 + })); 266 + 267 + expect(listener2).not.toHaveBeenCalled(); 268 + }); 269 + }); 270 + 271 + describe('getObservable', () => { 272 + it('should return RxJS observable', () => { 273 + const store = new Store(); 274 + const observable = store.getObservable(); 275 + 276 + expect(observable).toBeDefined(); 277 + expect(typeof observable.subscribe).toBe('function'); 278 + }); 279 + 280 + it('should emit values on state changes', () => { 281 + const store = new Store(); 282 + const observable = store.getObservable(); 283 + 284 + const states: string[] = []; 285 + observable.subscribe((state) => { 286 + states.push(state.ui.toolId); 287 + }); 288 + 289 + store.setState((state) => ({ 290 + ...state, 291 + ui: { ...state.ui, toolId: 'rect' }, 292 + })); 293 + 294 + expect(states).toEqual(['select', 'rect']); 295 + }); 296 + }); 297 + }); 298 + 299 + describe('Invariants', () => { 300 + describe('currentPageId invariant', () => { 301 + it('should repair invalid currentPageId to null when page does not exist', () => { 302 + const store = new Store(); 303 + 304 + store.setState((state) => ({ 305 + ...state, 306 + ui: { ...state.ui, currentPageId: 'nonexistent' }, 307 + })); 308 + 309 + const state = store.getState(); 310 + expect(state.ui.currentPageId).toBeNull(); 311 + }); 312 + 313 + it('should repair invalid currentPageId to first page when pages exist', () => { 314 + const store = new Store(); 315 + const page1 = PageRecord.create('Page 1', 'page1'); 316 + const page2 = PageRecord.create('Page 2', 'page2'); 317 + 318 + store.setState((state) => ({ 319 + ...state, 320 + doc: { 321 + ...state.doc, 322 + pages: { page1, page2 }, 323 + }, 324 + ui: { ...state.ui, currentPageId: 'nonexistent' }, 325 + })); 326 + 327 + const state = store.getState(); 328 + expect(state.ui.currentPageId).toBe('page1'); 329 + }); 330 + 331 + it('should keep valid currentPageId', () => { 332 + const store = new Store(); 333 + const page = PageRecord.create('Page 1', 'page1'); 334 + 335 + store.setState((state) => ({ 336 + ...state, 337 + doc: { 338 + ...state.doc, 339 + pages: { page1: page }, 340 + }, 341 + ui: { ...state.ui, currentPageId: 'page1' }, 342 + })); 343 + 344 + const state = store.getState(); 345 + expect(state.ui.currentPageId).toBe('page1'); 346 + }); 347 + }); 348 + 349 + describe('selectionIds invariant', () => { 350 + it('should clear selection when currentPageId is null', () => { 351 + const store = new Store(); 352 + 353 + store.setState((state) => ({ 354 + ...state, 355 + ui: { ...state.ui, currentPageId: null, selectionIds: ['shape1', 'shape2'] }, 356 + })); 357 + 358 + const state = store.getState(); 359 + expect(state.ui.selectionIds).toEqual([]); 360 + }); 361 + 362 + it('should remove non-existent shapes from selection', () => { 363 + const store = new Store(); 364 + const page = PageRecord.create('Page 1', 'page1'); 365 + const shape = ShapeRecord.createRect( 366 + 'page1', 367 + 0, 368 + 0, 369 + { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 370 + 'shape1', 371 + ); 372 + 373 + page.shapeIds = ['shape1']; 374 + 375 + store.setState((state) => ({ 376 + ...state, 377 + doc: { 378 + ...state.doc, 379 + pages: { page1: page }, 380 + shapes: { shape1: shape }, 381 + }, 382 + ui: { 383 + ...state.ui, 384 + currentPageId: 'page1', 385 + selectionIds: ['shape1', 'nonexistent', 'shape2'], 386 + }, 387 + })); 388 + 389 + const state = store.getState(); 390 + expect(state.ui.selectionIds).toEqual(['shape1']); 391 + }); 392 + 393 + it('should remove shapes not on current page from selection', () => { 394 + const store = new Store(); 395 + const page1 = PageRecord.create('Page 1', 'page1'); 396 + const page2 = PageRecord.create('Page 2', 'page2'); 397 + 398 + const shape1 = ShapeRecord.createRect( 399 + 'page1', 400 + 0, 401 + 0, 402 + { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 403 + 'shape1', 404 + ); 405 + const shape2 = ShapeRecord.createRect( 406 + 'page2', 407 + 0, 408 + 0, 409 + { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 410 + 'shape2', 411 + ); 412 + 413 + page1.shapeIds = ['shape1']; 414 + page2.shapeIds = ['shape2']; 415 + 416 + store.setState((state) => ({ 417 + ...state, 418 + doc: { 419 + ...state.doc, 420 + pages: { page1, page2 }, 421 + shapes: { shape1, shape2 }, 422 + }, 423 + ui: { 424 + ...state.ui, 425 + currentPageId: 'page1', 426 + selectionIds: ['shape1', 'shape2'], 427 + }, 428 + })); 429 + 430 + const state = store.getState(); 431 + expect(state.ui.selectionIds).toEqual(['shape1']); 432 + }); 433 + 434 + it('should keep valid selection', () => { 435 + const store = new Store(); 436 + const page = PageRecord.create('Page 1', 'page1'); 437 + const shape1 = ShapeRecord.createRect( 438 + 'page1', 439 + 0, 440 + 0, 441 + { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 442 + 'shape1', 443 + ); 444 + const shape2 = ShapeRecord.createRect( 445 + 'page1', 446 + 50, 447 + 50, 448 + { w: 75, h: 75, fill: '#000', stroke: '#fff', radius: 0 }, 449 + 'shape2', 450 + ); 451 + 452 + page.shapeIds = ['shape1', 'shape2']; 453 + 454 + store.setState((state) => ({ 455 + ...state, 456 + doc: { 457 + ...state.doc, 458 + pages: { page1: page }, 459 + shapes: { shape1, shape2 }, 460 + }, 461 + ui: { 462 + ...state.ui, 463 + currentPageId: 'page1', 464 + selectionIds: ['shape1', 'shape2'], 465 + }, 466 + })); 467 + 468 + const state = store.getState(); 469 + expect(state.ui.selectionIds).toEqual(['shape1', 'shape2']); 470 + }); 471 + 472 + it('should maintain reference equality when no repair needed', () => { 473 + const store = new Store(); 474 + const page = PageRecord.create('Page 1', 'page1'); 475 + const shape = ShapeRecord.createRect( 476 + 'page1', 477 + 0, 478 + 0, 479 + { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 480 + 'shape1', 481 + ); 482 + 483 + page.shapeIds = ['shape1']; 484 + 485 + store.setState((state) => ({ 486 + ...state, 487 + doc: { 488 + ...state.doc, 489 + pages: { page1: page }, 490 + shapes: { shape1: shape }, 491 + }, 492 + ui: { 493 + ...state.ui, 494 + currentPageId: 'page1', 495 + selectionIds: ['shape1'], 496 + }, 497 + })); 498 + 499 + const stateBefore = store.getState(); 500 + 501 + store.setState((state) => ({ 502 + ...state, 503 + camera: { ...state.camera, x: 100 }, 504 + })); 505 + 506 + const stateAfter = store.getState(); 507 + 508 + // UI should maintain reference equality if not repaired 509 + expect(stateAfter.ui.currentPageId).toBe(stateBefore.ui.currentPageId); 510 + }); 511 + }); 512 + 513 + describe('invariant repair on deletion', () => { 514 + it('should clear selection when selected shape is deleted', () => { 515 + const store = new Store(); 516 + const page = PageRecord.create('Page 1', 'page1'); 517 + const shape1 = ShapeRecord.createRect( 518 + 'page1', 519 + 0, 520 + 0, 521 + { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 522 + 'shape1', 523 + ); 524 + 525 + page.shapeIds = ['shape1']; 526 + 527 + store.setState((state) => ({ 528 + ...state, 529 + doc: { 530 + ...state.doc, 531 + pages: { page1: page }, 532 + shapes: { shape1 }, 533 + }, 534 + ui: { 535 + ...state.ui, 536 + currentPageId: 'page1', 537 + selectionIds: ['shape1'], 538 + }, 539 + })); 540 + 541 + // Delete the shape 542 + store.setState((state) => { 543 + const newPage = { ...state.doc.pages.page1, shapeIds: [] }; 544 + return { 545 + ...state, 546 + doc: { 547 + ...state.doc, 548 + pages: { page1: newPage }, 549 + shapes: {}, 550 + }, 551 + }; 552 + }); 553 + 554 + const state = store.getState(); 555 + expect(state.ui.selectionIds).toEqual([]); 556 + }); 557 + 558 + it('should update currentPageId when current page is deleted', () => { 559 + const store = new Store(); 560 + const page1 = PageRecord.create('Page 1', 'page1'); 561 + const page2 = PageRecord.create('Page 2', 'page2'); 562 + 563 + store.setState((state) => ({ 564 + ...state, 565 + doc: { 566 + ...state.doc, 567 + pages: { page1, page2 }, 568 + }, 569 + ui: { 570 + ...state.ui, 571 + currentPageId: 'page1', 572 + }, 573 + })); 574 + 575 + // Delete page1 576 + store.setState((state) => ({ 577 + ...state, 578 + doc: { 579 + ...state.doc, 580 + pages: { page2 }, 581 + }, 582 + })); 583 + 584 + const state = store.getState(); 585 + expect(state.ui.currentPageId).toBe('page2'); 586 + }); 587 + }); 588 + }); 589 + 590 + describe('Selectors', () => { 591 + describe('getCurrentPage', () => { 592 + it('should return null when no page is selected', () => { 593 + const state = EditorStateOps.create(); 594 + const result = getCurrentPage(state); 595 + 596 + expect(result).toBeNull(); 597 + }); 598 + 599 + it('should return current page', () => { 600 + const state = EditorStateOps.create(); 601 + const page = PageRecord.create('Page 1', 'page1'); 602 + 603 + state.doc.pages = { page1: page }; 604 + state.ui.currentPageId = 'page1'; 605 + 606 + const result = getCurrentPage(state); 607 + 608 + expect(result).toBe(page); 609 + expect(result?.name).toBe('Page 1'); 610 + }); 611 + 612 + it('should return null when currentPageId does not exist', () => { 613 + const state = EditorStateOps.create(); 614 + state.ui.currentPageId = 'nonexistent'; 615 + 616 + const result = getCurrentPage(state); 617 + 618 + expect(result).toBeNull(); 619 + }); 620 + }); 621 + 622 + describe('getShapesOnCurrentPage', () => { 623 + it('should return empty array when no page is selected', () => { 624 + const state = EditorStateOps.create(); 625 + const result = getShapesOnCurrentPage(state); 626 + 627 + expect(result).toEqual([]); 628 + }); 629 + 630 + it('should return empty array for empty page', () => { 631 + const state = EditorStateOps.create(); 632 + const page = PageRecord.create('Page 1', 'page1'); 633 + 634 + state.doc.pages = { page1: page }; 635 + state.ui.currentPageId = 'page1'; 636 + 637 + const result = getShapesOnCurrentPage(state); 638 + 639 + expect(result).toEqual([]); 640 + }); 641 + 642 + it('should return all shapes on current page', () => { 643 + const state = EditorStateOps.create(); 644 + const page = PageRecord.create('Page 1', 'page1'); 645 + const shape1 = ShapeRecord.createRect( 646 + 'page1', 647 + 0, 648 + 0, 649 + { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 650 + 'shape1', 651 + ); 652 + const shape2 = ShapeRecord.createEllipse('page1', 50, 50, { w: 75, h: 75, fill: '#000', stroke: '#fff' }, 'shape2'); 653 + 654 + page.shapeIds = ['shape1', 'shape2']; 655 + state.doc.pages = { page1: page }; 656 + state.doc.shapes = { shape1, shape2 }; 657 + state.ui.currentPageId = 'page1'; 658 + 659 + const result = getShapesOnCurrentPage(state); 660 + 661 + expect(result).toHaveLength(2); 662 + expect(result[0]).toBe(shape1); 663 + expect(result[1]).toBe(shape2); 664 + }); 665 + 666 + it('should filter out undefined shapes', () => { 667 + const state = EditorStateOps.create(); 668 + const page = PageRecord.create('Page 1', 'page1'); 669 + const shape1 = ShapeRecord.createRect( 670 + 'page1', 671 + 0, 672 + 0, 673 + { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 674 + 'shape1', 675 + ); 676 + 677 + page.shapeIds = ['shape1', 'nonexistent']; 678 + state.doc.pages = { page1: page }; 679 + state.doc.shapes = { shape1 }; 680 + state.ui.currentPageId = 'page1'; 681 + 682 + const result = getShapesOnCurrentPage(state); 683 + 684 + expect(result).toHaveLength(1); 685 + expect(result[0]).toBe(shape1); 686 + }); 687 + 688 + it('should not include shapes from other pages', () => { 689 + const state = EditorStateOps.create(); 690 + const page1 = PageRecord.create('Page 1', 'page1'); 691 + const page2 = PageRecord.create('Page 2', 'page2'); 692 + 693 + const shape1 = ShapeRecord.createRect( 694 + 'page1', 695 + 0, 696 + 0, 697 + { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 698 + 'shape1', 699 + ); 700 + const shape2 = ShapeRecord.createRect( 701 + 'page2', 702 + 0, 703 + 0, 704 + { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 705 + 'shape2', 706 + ); 707 + 708 + page1.shapeIds = ['shape1']; 709 + page2.shapeIds = ['shape2']; 710 + 711 + state.doc.pages = { page1, page2 }; 712 + state.doc.shapes = { shape1, shape2 }; 713 + state.ui.currentPageId = 'page1'; 714 + 715 + const result = getShapesOnCurrentPage(state); 716 + 717 + expect(result).toHaveLength(1); 718 + expect(result[0]).toBe(shape1); 719 + }); 720 + }); 721 + 722 + describe('getSelectedShapes', () => { 723 + it('should return empty array when no selection', () => { 724 + const state = EditorStateOps.create(); 725 + const result = getSelectedShapes(state); 726 + 727 + expect(result).toEqual([]); 728 + }); 729 + 730 + it('should return selected shapes', () => { 731 + const state = EditorStateOps.create(); 732 + const page = PageRecord.create('Page 1', 'page1'); 733 + const shape1 = ShapeRecord.createRect( 734 + 'page1', 735 + 0, 736 + 0, 737 + { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 738 + 'shape1', 739 + ); 740 + const shape2 = ShapeRecord.createEllipse('page1', 50, 50, { w: 75, h: 75, fill: '#000', stroke: '#fff' }, 'shape2'); 741 + const shape3 = ShapeRecord.createLine( 742 + 'page1', 743 + 100, 744 + 100, 745 + { a: { x: 0, y: 0 }, b: { x: 100, y: 0 }, stroke: '#000', width: 2 }, 746 + 'shape3', 747 + ); 748 + 749 + page.shapeIds = ['shape1', 'shape2', 'shape3']; 750 + state.doc.pages = { page1: page }; 751 + state.doc.shapes = { shape1, shape2, shape3 }; 752 + state.ui.currentPageId = 'page1'; 753 + state.ui.selectionIds = ['shape1', 'shape3']; 754 + 755 + const result = getSelectedShapes(state); 756 + 757 + expect(result).toHaveLength(2); 758 + expect(result[0]).toBe(shape1); 759 + expect(result[1]).toBe(shape3); 760 + }); 761 + 762 + it('should filter out undefined shapes', () => { 763 + const state = EditorStateOps.create(); 764 + const shape1 = ShapeRecord.createRect( 765 + 'page1', 766 + 0, 767 + 0, 768 + { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 769 + 'shape1', 770 + ); 771 + 772 + state.doc.shapes = { shape1 }; 773 + state.ui.selectionIds = ['shape1', 'nonexistent']; 774 + 775 + const result = getSelectedShapes(state); 776 + 777 + expect(result).toHaveLength(1); 778 + expect(result[0]).toBe(shape1); 779 + }); 780 + }); 781 + 782 + describe('isShapeSelected', () => { 783 + it('should return false when shape is not selected', () => { 784 + const state = EditorStateOps.create(); 785 + state.ui.selectionIds = ['shape1', 'shape2']; 786 + 787 + expect(isShapeSelected(state, 'shape3')).toBe(false); 788 + }); 789 + 790 + it('should return true when shape is selected', () => { 791 + const state = EditorStateOps.create(); 792 + state.ui.selectionIds = ['shape1', 'shape2']; 793 + 794 + expect(isShapeSelected(state, 'shape1')).toBe(true); 795 + expect(isShapeSelected(state, 'shape2')).toBe(true); 796 + }); 797 + 798 + it('should return false for empty selection', () => { 799 + const state = EditorStateOps.create(); 800 + state.ui.selectionIds = []; 801 + 802 + expect(isShapeSelected(state, 'shape1')).toBe(false); 803 + }); 804 + }); 805 + 806 + describe('getAllPages', () => { 807 + it('should return empty array when no pages', () => { 808 + const state = EditorStateOps.create(); 809 + const result = getAllPages(state); 810 + 811 + expect(result).toEqual([]); 812 + }); 813 + 814 + it('should return all pages', () => { 815 + const state = EditorStateOps.create(); 816 + const page1 = PageRecord.create('Page 1', 'page1'); 817 + const page2 = PageRecord.create('Page 2', 'page2'); 818 + 819 + state.doc.pages = { page1, page2 }; 820 + 821 + const result = getAllPages(state); 822 + 823 + expect(result).toHaveLength(2); 824 + expect(result).toContain(page1); 825 + expect(result).toContain(page2); 826 + }); 827 + }); 828 + 829 + describe('getShape', () => { 830 + it('should return undefined for non-existent shape', () => { 831 + const state = EditorStateOps.create(); 832 + const result = getShape(state, 'nonexistent'); 833 + 834 + expect(result).toBeUndefined(); 835 + }); 836 + 837 + it('should return shape by ID', () => { 838 + const state = EditorStateOps.create(); 839 + const shape = ShapeRecord.createRect( 840 + 'page1', 841 + 0, 842 + 0, 843 + { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 844 + 'shape1', 845 + ); 846 + 847 + state.doc.shapes = { shape1: shape }; 848 + 849 + const result = getShape(state, 'shape1'); 850 + 851 + expect(result).toBe(shape); 852 + }); 853 + }); 854 + }); 855 + 856 + describe('Integration scenarios', () => { 857 + it('should handle complete workflow: create page, add shapes, select shapes', () => { 858 + const store = new Store(); 859 + 860 + // Create page 861 + const page = PageRecord.create('Page 1', 'page1'); 862 + store.setState((state) => ({ 863 + ...state, 864 + doc: { 865 + ...state.doc, 866 + pages: { page1: page }, 867 + }, 868 + ui: { 869 + ...state.ui, 870 + currentPageId: 'page1', 871 + }, 872 + })); 873 + 874 + let state = store.getState(); 875 + expect(getCurrentPage(state)?.name).toBe('Page 1'); 876 + 877 + // Add shapes 878 + const shape1 = ShapeRecord.createRect( 879 + 'page1', 880 + 0, 881 + 0, 882 + { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, 883 + 'shape1', 884 + ); 885 + const shape2 = ShapeRecord.createEllipse('page1', 50, 50, { w: 75, h: 75, fill: '#000', stroke: '#fff' }, 'shape2'); 886 + 887 + store.setState((state) => { 888 + const updatedPage = { ...state.doc.pages.page1, shapeIds: ['shape1', 'shape2'] }; 889 + 890 + return { 891 + ...state, 892 + doc: { 893 + ...state.doc, 894 + pages: { page1: updatedPage }, 895 + shapes: { shape1, shape2 }, 896 + }, 897 + }; 898 + }); 899 + 900 + state = store.getState(); 901 + expect(getShapesOnCurrentPage(state)).toHaveLength(2); 902 + 903 + // Select shapes 904 + store.setState((state) => ({ 905 + ...state, 906 + ui: { 907 + ...state.ui, 908 + selectionIds: ['shape1', 'shape2'], 909 + }, 910 + })); 911 + 912 + state = store.getState(); 913 + expect(getSelectedShapes(state)).toHaveLength(2); 914 + expect(isShapeSelected(state, 'shape1')).toBe(true); 915 + }); 916 + 917 + it('should handle camera operations while maintaining state', () => { 918 + const store = new Store(); 919 + const listener = vi.fn(); 920 + 921 + store.subscribe(listener); 922 + listener.mockClear(); 923 + 924 + store.setState((state) => ({ 925 + ...state, 926 + camera: Camera.pan(state.camera, { x: 100, y: 50 }), 927 + })); 928 + 929 + expect(listener).toHaveBeenCalledTimes(1); 930 + 931 + const state = store.getState(); 932 + expect(state.camera.x).toBe(-100); 933 + expect(state.camera.y).toBe(-50); 934 + }); 935 + 936 + it('should handle tool switching', () => { 937 + const store = new Store(); 938 + 939 + expect(store.getState().ui.toolId).toBe('select'); 940 + 941 + store.setState((state) => ({ 942 + ...state, 943 + ui: { ...state.ui, toolId: 'rect' }, 944 + })); 945 + 946 + expect(store.getState().ui.toolId).toBe('rect'); 947 + 948 + store.setState((state) => ({ 949 + ...state, 950 + ui: { ...state.ui, toolId: 'ellipse' }, 951 + })); 952 + 953 + expect(store.getState().ui.toolId).toBe('ellipse'); 954 + }); 955 + });