web based infinite canvas

feat: fefactor persistence layer to unify DocRepo interface

+1326 -1038
+6 -6
TODO.txt
··· 173 173 -------------------------------------------------------------------------------- 174 174 175 175 /packages/core/src/persist/DocRepo.ts: 176 - [ ] Define DocRepo interface (web + desktop): 176 + [x] Define DocRepo interface (web + desktop): 177 177 - listBoards(): Promise<BoardMeta[]> 178 178 - createBoard(name): Promise<string> 179 179 - openBoard(id): Promise<void> 180 180 - renameBoard(id, name): Promise<void> 181 181 - deleteBoard(id): Promise<void> 182 182 183 - [ ] Define FileBrowserViewModel: 183 + [x] Define FileBrowserViewModel: 184 184 - query, filteredBoards, selectedId 185 185 - actions: open/create/rename/delete 186 186 ··· 224 224 - pick directory 225 225 - remember last workspace path 226 226 [ ] Implement directory listing: 227 - - v0: show *.Inkfinite.json files in workspace 228 - - v1: tree view with folders 227 + - show *.inkfinite.json files in workspace 228 + - tree view with folders 229 229 [ ] Implement file actions: 230 230 - [x] New: create new file 231 231 - [ ] Rename: rename file ··· 247 247 - name + updatedAt in both modes 248 248 249 249 (DoD): 250 - - Web and desktop feel like the same app, with storage differences made explicit. 250 + - Web and desktop feel like the same app, with storage differences made explicit 251 251 252 252 ================================================================================ 253 - 19. Milestone S: Quality polish (what makes it feel "real") *wb-S* 253 + 19. Milestone S: Quality polish. *wb-S* 254 254 ================================================================================ 255 255 256 256 Comprehensive UX polish adds BEM CSS, space-drag panning, richer keyboard
+47 -991
apps/web/src/lib/canvas/Canvas.svelte
··· 3 3 import StatusBar from '$lib/components/StatusBar.svelte'; 4 4 import TitleBar from '$lib/components/TitleBar.svelte'; 5 5 import Toolbar from '$lib/components/Toolbar.svelte'; 6 - import { createInputAdapter, type InputAdapter } from '$lib/input'; 7 - import type { DesktopDocRepo } from '$lib/persistence/desktop'; 8 - import { createPlatformRepo, detectPlatform } from '$lib/platform'; 9 - import { 10 - createPersistenceManager, 11 - createSnapStore, 12 - createStatusStore, 13 - type SnapStore, 14 - type StatusStore 15 - } from '$lib/status'; 16 - import { 17 - ArrowTool, 18 - Camera, 19 - CursorStore, 20 - EditorState, 21 - EllipseTool, 22 - LineTool, 23 - RectTool, 24 - SelectTool, 25 - ShapeRecord, 26 - SnapshotCommand, 27 - Store, 28 - TextTool, 29 - createToolMap, 30 - diffDoc, 31 - getShapesOnCurrentPage, 32 - routeAction, 33 - shapeBounds, 34 - switchTool, 35 - type Action, 36 - type BoardMeta, 37 - type CommandKind, 38 - type DocRepo, 39 - type LoadedDoc, 40 - type PersistenceSink, 41 - type ToolId, 42 - type Viewport 43 - } from 'inkfinite-core'; 44 - import { createRenderer, type Renderer } from 'inkfinite-renderer'; 45 - import { onDestroy, onMount } from 'svelte'; 6 + import { createCanvasController } from './canvas-store.svelte.ts'; 46 7 47 - let repo: DocRepo | null = null; 48 - let sink: PersistenceSink | null = null; 49 - let persistenceManager: ReturnType<typeof createPersistenceManager> | null = null; 50 - const platform = detectPlatform(); 51 - const fallbackStatusStore = createStatusStore({ 52 - backend: platform === 'desktop' ? 'filesystem' : 'indexeddb', 53 - state: 'saved', 54 - pendingWrites: 0 55 - }); 56 - let persistenceStatusStore = $state<StatusStore>(fallbackStatusStore); 57 - let activeBoardId: string | null = null; 58 - let desktopRepo: DesktopDocRepo | null = null; 59 - let desktopBoards = $state<BoardMeta[]>([]); 60 - let desktopFileName = $state<string | null>(null); 61 - let removeBeforeUnload: (() => void) | null = null; 62 - 63 - const store = new Store(undefined, { 64 - onHistoryEvent: (event) => { 65 - if (!activeBoardId || event.kind !== 'doc' || !sink) { 66 - return; 67 - } 68 - const patch = diffDoc(event.beforeState.doc, event.afterState.doc); 69 - sink.enqueueDocPatch(activeBoardId, patch); 70 - } 71 - }); 72 - const cursorStore = new CursorStore(); 73 - const snapStore: SnapStore = createSnapStore(); 74 - const pointerState = $state({ 75 - isPointerDown: false, 76 - snappedWorld: null as { x: number; y: number } | null 77 - }); 78 - const handleState = $state<{ hover: string | null; active: string | null }>({ 79 - hover: null, 80 - active: null 81 - }); 82 - let textEditor = $state<{ shapeId: string; value: string } | null>(null); 8 + let canvasEl = $state<HTMLCanvasElement | null>(null); 83 9 let textEditorEl = $state<HTMLTextAreaElement | null>(null); 84 - const panState = $state({ isPanning: false, spaceHeld: false, lastScreen: { x: 0, y: 0 } }); 85 - const snapProvider = { get: () => snapStore.get() }; 86 - const cursorProvider = { get: () => cursorStore.getState() }; 87 - const pointerStateProvider = { get: () => pointerState }; 88 - const handleProvider = { get: () => ({ ...handleState }) }; 89 - let pendingCommandStart: EditorState | null = null; 90 - 91 - function applyLoadedDoc(doc: LoadedDoc) { 92 - const firstPageId = doc.order.pageIds[0] ?? Object.keys(doc.pages)[0] ?? null; 93 - store.setState((state) => ({ 94 - ...state, 95 - doc: { pages: doc.pages, shapes: doc.shapes, bindings: doc.bindings }, 96 - ui: { ...state.ui, currentPageId: firstPageId, selectionIds: [] } 97 - })); 98 - initializeSelection(firstPageId, doc); 99 - } 100 - 101 - function initializeSelection(pageId: string | null, doc: LoadedDoc) { 102 - if (!pageId) { 103 - return; 104 - } 105 - const page = doc.pages[pageId]; 106 - const firstShapeId = page?.shapeIds[0]; 107 - if (!firstShapeId) { 108 - return; 109 - } 110 - const state = editorSnapshot; 111 - if (state.ui.selectionIds.length === 1 && state.ui.selectionIds[0] === firstShapeId) { 112 - return; 113 - } 114 - const before = EditorState.clone(state); 115 - const after = { ...state, ui: { ...state.ui, selectionIds: [firstShapeId] } }; 116 - const command = new SnapshotCommand( 117 - 'Initialize Selection', 118 - 'ui', 119 - before, 120 - EditorState.clone(after) 121 - ); 122 - store.executeCommand(command); 123 - syncHandleState(); 124 - } 125 - 126 - function setActiveBoardId(boardId: string) { 127 - activeBoardId = boardId; 128 - persistenceManager?.setActiveBoard(boardId); 129 - } 130 - 131 - function updateDesktopFileState() { 132 - if (!desktopRepo) { 133 - desktopFileName = null; 134 - return; 135 - } 136 - const handle = desktopRepo.getCurrentFile(); 137 - desktopFileName = handle?.name ?? null; 138 - } 139 - 140 - async function refreshDesktopBoards(): Promise<BoardMeta[]> { 141 - if (!desktopRepo) { 142 - desktopBoards = []; 143 - return []; 144 - } 145 - try { 146 - const boards = await desktopRepo.listBoards(); 147 - desktopBoards = boards; 148 - return boards; 149 - } catch (error) { 150 - console.error('Failed to list boards', error); 151 - desktopBoards = []; 152 - return []; 153 - } 154 - } 155 - 156 - function isUserCancelled(error: unknown) { 157 - return error instanceof Error && /cancel/i.test(error.message); 158 - } 159 - 160 - const handleCursorMap: Record<string, string> = { 161 - n: 'ns-resize', 162 - s: 'ns-resize', 163 - e: 'ew-resize', 164 - w: 'ew-resize', 165 - ne: 'nesw-resize', 166 - sw: 'nesw-resize', 167 - nw: 'nwse-resize', 168 - se: 'nwse-resize', 169 - rotate: 'alias', 170 - 'line-start': 'crosshair', 171 - 'line-end': 'crosshair' 172 - }; 173 - 174 - function refreshCursor() { 175 - if (!canvas) { 176 - return; 177 - } 178 - let cursor = 'default'; 179 - if (textEditor) { 180 - cursor = 'text'; 181 - } else if (panState.isPanning) { 182 - cursor = 'grabbing'; 183 - } else if (panState.spaceHeld) { 184 - cursor = 'grab'; 185 - } else { 186 - const activeHandle = handleState.active; 187 - const hoverHandle = handleState.hover; 188 - const targetHandle = activeHandle ?? hoverHandle; 189 - if (targetHandle) { 190 - cursor = handleCursorMap[targetHandle] ?? 'default'; 191 - } else if (pointerState.isPointerDown) { 192 - cursor = 'grabbing'; 193 - } 194 - } 195 - canvas.style.cursor = cursor; 196 - } 197 - 198 - function setHandleHover(handle: string | null) { 199 - if (handleState.hover === handle) { 200 - return; 201 - } 202 - handleState.hover = handle; 203 - refreshCursor(); 204 - } 205 - 206 - function syncHandleState() { 207 - handleState.active = selectTool.getActiveHandle ? selectTool.getActiveHandle() : null; 208 - refreshCursor(); 209 - } 210 - 211 - function getTextEditorLayout() { 212 - if (!textEditor) { 213 - return null; 214 - } 215 - const state = store.getState(); 216 - const shape = state.doc.shapes[textEditor.shapeId]; 217 - if (!shape || shape.type !== 'text') { 218 - return null; 219 - } 220 - const viewport = getViewport(); 221 - const screenPos = Camera.worldToScreen(state.camera, { x: shape.x, y: shape.y }, viewport); 222 - const widthWorld = shape.props.w ?? 240; 223 - const zoom = state.camera.zoom; 224 - return { 225 - left: screenPos.x, 226 - top: screenPos.y, 227 - width: widthWorld * zoom, 228 - height: shape.props.fontSize * 1.4 * zoom, 229 - fontSize: shape.props.fontSize * zoom 230 - }; 231 - } 232 - 233 - function startTextEditing(shapeId: string) { 234 - const state = store.getState(); 235 - const shape = state.doc.shapes[shapeId]; 236 - if (!shape || shape.type !== 'text') { 237 - return; 238 - } 239 - textEditor = { shapeId, value: shape.props.text }; 240 - refreshCursor(); 241 - queueMicrotask(() => { 242 - textEditorEl?.focus(); 243 - textEditorEl?.select(); 244 - }); 245 - } 246 - 247 - function commitTextEditing() { 248 - if (!textEditor) { 249 - return; 250 - } 251 - const { shapeId, value } = textEditor; 252 - const currentState = store.getState(); 253 - const shape = currentState.doc.shapes[shapeId]; 254 - textEditor = null; 255 - refreshCursor(); 256 - if (!shape || shape.type !== 'text' || shape.props.text === value) { 257 - return; 258 - } 259 - const before = EditorState.clone(currentState); 260 - const updatedShape = { ...shape, props: { ...shape.props, text: value } }; 261 - const newShapes = { ...currentState.doc.shapes, [shapeId]: updatedShape }; 262 - const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; 263 - const command = new SnapshotCommand('Edit text', 'doc', before, EditorState.clone(after)); 264 - store.executeCommand(command); 265 - } 266 - 267 - function cancelTextEditing() { 268 - textEditor = null; 269 - refreshCursor(); 270 - } 271 - 272 - function handleCanvasDoubleClick(event: MouseEvent) { 273 - if (!canvas) { 274 - return; 275 - } 276 - const rect = canvas.getBoundingClientRect(); 277 - const screen = { x: event.clientX - rect.left, y: event.clientY - rect.top }; 278 - const world = Camera.screenToWorld(store.getState().camera, screen, getViewport()); 279 - const shapeId = findTextShapeAt(world); 280 - if (shapeId) { 281 - startTextEditing(shapeId); 282 - } 283 - } 284 - 285 - function findTextShapeAt(point: { x: number; y: number }): string | null { 286 - const shapes = getShapesOnCurrentPage(store.getState()); 287 - for (let index = shapes.length - 1; index >= 0; index--) { 288 - const shape = shapes[index]; 289 - if (!shape || shape.type !== 'text') { 290 - continue; 291 - } 292 - const bounds = shapeBounds(shape); 293 - if ( 294 - point.x >= bounds.min.x && 295 - point.x <= bounds.max.x && 296 - point.y >= bounds.min.y && 297 - point.y <= bounds.max.y 298 - ) { 299 - return shape.id; 300 - } 301 - } 302 - return null; 303 - } 304 - 305 - function handleTextEditorInput(event: Event) { 306 - if (!textEditor) { 307 - return; 308 - } 309 - const target = event.currentTarget as HTMLTextAreaElement; 310 - textEditor = { ...textEditor, value: target.value }; 311 - } 312 - 313 - function handleTextEditorKeyDown(event: KeyboardEvent) { 314 - if (event.key === 'Escape') { 315 - event.preventDefault(); 316 - cancelTextEditing(); 317 - return; 318 - } 319 - if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { 320 - event.preventDefault(); 321 - commitTextEditing(); 322 - } 323 - } 324 - 325 - function handleTextEditorBlur() { 326 - commitTextEditing(); 327 - } 328 - 329 - function handlePointerLeave() { 330 - setHandleHover(null); 331 - } 332 - 333 - const selectTool = new SelectTool(); 334 - const rectTool = new RectTool(); 335 - const ellipseTool = new EllipseTool(); 336 - const lineTool = new LineTool(); 337 - const arrowTool = new ArrowTool(); 338 - const textTool = new TextTool(); 339 - const tools = createToolMap([selectTool, rectTool, ellipseTool, lineTool, arrowTool, textTool]); 340 - 341 - let currentToolId = $state<ToolId>('select'); 342 - let editorSnapshot = $state(store.getState()); 343 10 let historyViewerOpen = $state(false); 344 11 345 - store.subscribe((state) => { 346 - currentToolId = state.ui.toolId; 347 - editorSnapshot = state; 348 - }); 349 - 350 - function handleToolChange(toolId: ToolId) { 351 - store.setState((state) => switchTool(state, toolId, tools)); 352 - } 353 - 354 - function handleHistoryClick() { 355 - historyViewerOpen = true; 356 - } 357 - 358 - function handleHistoryClose() { 359 - historyViewerOpen = false; 360 - } 361 - 362 - function handleBringForward() { 363 - const currentState = store.getState(); 364 - const selectedIds = currentState.ui.selectionIds; 365 - const currentPageId = currentState.ui.currentPageId; 366 - 367 - if (selectedIds.length === 0 || !currentPageId) { 368 - return; 369 - } 370 - 371 - const before = EditorState.clone(currentState); 372 - const page = currentState.doc.pages[currentPageId]; 373 - if (!page) return; 374 - 375 - const newShapeIds = [...page.shapeIds]; 376 - 377 - for (const shapeId of selectedIds) { 378 - const currentIndex = newShapeIds.indexOf(shapeId); 379 - if (currentIndex !== -1 && currentIndex < newShapeIds.length - 1) { 380 - [newShapeIds[currentIndex], newShapeIds[currentIndex + 1]] = [ 381 - newShapeIds[currentIndex + 1], 382 - newShapeIds[currentIndex] 383 - ]; 384 - } 385 - } 386 - 387 - const after = { 388 - ...currentState, 389 - doc: { 390 - ...currentState.doc, 391 - pages: { ...currentState.doc.pages, [currentPageId]: { ...page, shapeIds: newShapeIds } } 392 - } 393 - }; 394 - 395 - const command = new SnapshotCommand('Bring Forward', 'doc', before, EditorState.clone(after)); 396 - store.executeCommand(command); 397 - syncHandleState(); 398 - } 399 - 400 - function handleSendBackward() { 401 - const currentState = store.getState(); 402 - const selectedIds = currentState.ui.selectionIds; 403 - const currentPageId = currentState.ui.currentPageId; 404 - 405 - if (selectedIds.length === 0 || !currentPageId) { 406 - return; 407 - } 408 - 409 - const before = EditorState.clone(currentState); 410 - const page = currentState.doc.pages[currentPageId]; 411 - if (!page) return; 412 - 413 - const newShapeIds = [...page.shapeIds]; 414 - 415 - for (let i = selectedIds.length - 1; i >= 0; i--) { 416 - const shapeId = selectedIds[i]; 417 - const currentIndex = newShapeIds.indexOf(shapeId); 418 - if (currentIndex > 0) { 419 - [newShapeIds[currentIndex], newShapeIds[currentIndex - 1]] = [ 420 - newShapeIds[currentIndex - 1], 421 - newShapeIds[currentIndex] 422 - ]; 423 - } 424 - } 425 - 426 - const after = { 427 - ...currentState, 428 - doc: { 429 - ...currentState.doc, 430 - pages: { ...currentState.doc.pages, [currentPageId]: { ...page, shapeIds: newShapeIds } } 431 - } 432 - }; 433 - 434 - const command = new SnapshotCommand('Send Backward', 'doc', before, EditorState.clone(after)); 435 - store.executeCommand(command); 436 - syncHandleState(); 437 - } 438 - 439 - function handleDuplicate() { 440 - const currentState = store.getState(); 441 - const selectedIds = currentState.ui.selectionIds; 442 - 443 - if (selectedIds.length === 0) { 444 - return; 445 - } 446 - 447 - const before = EditorState.clone(currentState); 448 - const newShapes = { ...currentState.doc.shapes }; 449 - const newPages = { ...currentState.doc.pages }; 450 - const duplicatedIds: string[] = []; 451 - 452 - const DUPLICATE_OFFSET = 20; 453 - 454 - for (const shapeId of selectedIds) { 455 - const shape = currentState.doc.shapes[shapeId]; 456 - if (!shape) continue; 457 - 458 - const cloned = ShapeRecord.clone(shape); 459 - const newId = `shape:${crypto.randomUUID()}`; 460 - const duplicated = { 461 - ...cloned, 462 - id: newId, 463 - x: shape.x + DUPLICATE_OFFSET, 464 - y: shape.y + DUPLICATE_OFFSET 465 - }; 466 - 467 - newShapes[newId] = duplicated; 468 - duplicatedIds.push(newId); 469 - 470 - const currentPageId = currentState.ui.currentPageId; 471 - if (currentPageId) { 472 - const page = newPages[currentPageId]; 473 - if (page) { 474 - newPages[currentPageId] = { ...page, shapeIds: [...page.shapeIds, newId] }; 475 - } 476 - } 477 - } 478 - 479 - const after = { 480 - ...currentState, 481 - doc: { ...currentState.doc, shapes: newShapes, pages: newPages }, 482 - ui: { ...currentState.ui, selectionIds: duplicatedIds } 483 - }; 484 - 485 - const command = new SnapshotCommand('Duplicate', 'doc', before, EditorState.clone(after)); 486 - store.executeCommand(command); 487 - syncHandleState(); 488 - } 489 - 490 - function handleNudge(arrowKey: string, largeNudge: boolean) { 491 - const currentState = store.getState(); 492 - const selectedIds = currentState.ui.selectionIds; 493 - 494 - if (selectedIds.length === 0) { 495 - return; 496 - } 497 - 498 - const nudgeDistance = largeNudge ? 10 : 1; 499 - let deltaX = 0; 500 - let deltaY = 0; 501 - 502 - switch (arrowKey) { 503 - case 'ArrowLeft': 504 - deltaX = -nudgeDistance; 505 - break; 506 - case 'ArrowRight': 507 - deltaX = nudgeDistance; 508 - break; 509 - case 'ArrowUp': 510 - deltaY = -nudgeDistance; 511 - break; 512 - case 'ArrowDown': 513 - deltaY = nudgeDistance; 514 - break; 515 - } 516 - 517 - const before = EditorState.clone(currentState); 518 - const newShapes = { ...currentState.doc.shapes }; 519 - 520 - for (const shapeId of selectedIds) { 521 - const shape = newShapes[shapeId]; 522 - if (shape) { 523 - newShapes[shapeId] = { ...shape, x: shape.x + deltaX, y: shape.y + deltaY }; 524 - } 525 - } 526 - 527 - const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; 528 - const command = new SnapshotCommand('Nudge', 'doc', before, EditorState.clone(after)); 529 - store.executeCommand(command); 530 - syncHandleState(); 531 - } 532 - 533 - function applyActionWithHistory(action: Action) { 534 - const before = store.getState(); 535 - const nextState = routeAction(before, action, tools); 536 - if (statesEqual(before, nextState)) { 537 - syncHandleState(); 538 - return; 539 - } 540 - 541 - const kind = getCommandKind(before, nextState); 542 - const commandName = describeAction(action, kind); 543 - const command = new SnapshotCommand( 544 - commandName, 545 - kind, 546 - EditorState.clone(before), 547 - EditorState.clone(nextState) 548 - ); 549 - store.executeCommand(command); 550 - syncHandleState(); 551 - } 552 - 553 - function handleAction(action: Action) { 554 - if (textEditor && (action.type === 'pointer-down' || action.type === 'pointer-up')) { 555 - commitTextEditing(); 556 - } 557 - 558 - if ( 559 - action.type === 'pointer-move' && 560 - 'world' in action && 561 - !panState.isPanning && 562 - !panState.spaceHeld 563 - ) { 564 - const hover = selectTool.getHandleAtPoint(store.getState(), action.world); 565 - setHandleHover(hover); 566 - } 567 - 568 - if (action.type === 'pointer-move' && (panState.isPanning || panState.spaceHeld)) { 569 - setHandleHover(null); 570 - } 571 - 572 - if (action.type === 'key-down' && action.key === ' ') { 573 - panState.spaceHeld = true; 574 - setHandleHover(null); 575 - refreshCursor(); 576 - return; 577 - } 578 - 579 - if (action.type === 'key-up' && action.key === ' ') { 580 - panState.spaceHeld = false; 581 - panState.isPanning = false; 582 - refreshCursor(); 583 - return; 584 - } 585 - 586 - if (action.type === 'pointer-down' && action.button === 0 && panState.spaceHeld) { 587 - panState.isPanning = true; 588 - panState.lastScreen = { x: action.screen.x, y: action.screen.y }; 589 - refreshCursor(); 590 - return; 591 - } 592 - 593 - if (action.type === 'pointer-move' && panState.isPanning) { 594 - const deltaX = action.screen.x - panState.lastScreen.x; 595 - const deltaY = action.screen.y - panState.lastScreen.y; 596 - const currentCamera = store.getState().camera; 597 - const newCamera = Camera.pan(currentCamera, { x: deltaX, y: deltaY }); 598 - store.setState((state) => ({ ...state, camera: newCamera })); 599 - panState.lastScreen = { x: action.screen.x, y: action.screen.y }; 600 - refreshCursor(); 601 - return; 602 - } 603 - 604 - if (action.type === 'pointer-up' && action.button === 0 && panState.isPanning) { 605 - panState.isPanning = false; 606 - refreshCursor(); 607 - return; 608 - } 609 - 610 - if (panState.isPanning || panState.spaceHeld) { 611 - return; 612 - } 613 - 614 - const actionWithSnap = applySnapping(action); 615 - if ('world' in actionWithSnap) { 616 - pointerState.snappedWorld = actionWithSnap.world ?? null; 617 - } 618 - 619 - if (actionWithSnap.type === 'pointer-down' && actionWithSnap.button === 0) { 620 - pointerState.isPointerDown = true; 621 - setHandleHover(null); 622 - refreshCursor(); 623 - pendingCommandStart = EditorState.clone(store.getState()); 624 - const changed = applyImmediateAction(actionWithSnap); 625 - if (!changed) { 626 - pendingCommandStart = null; 627 - } 628 - return; 629 - } 630 - 631 - if ( 632 - actionWithSnap.type === 'pointer-move' && 633 - pointerState.isPointerDown && 634 - pendingCommandStart 635 - ) { 636 - void applyImmediateAction(actionWithSnap); 637 - return; 638 - } 639 - 640 - if (actionWithSnap.type === 'pointer-up' && actionWithSnap.button === 0) { 641 - pointerState.isPointerDown = false; 642 - setHandleHover(null); 643 - refreshCursor(); 644 - if (pendingCommandStart) { 645 - const committed = commitPendingCommand(actionWithSnap, pendingCommandStart); 646 - pendingCommandStart = null; 647 - if (committed) { 648 - return; 649 - } 650 - } 651 - pointerState.snappedWorld = null; 652 - } 653 - 654 - if (actionWithSnap.type === 'key-down') { 655 - const isPrimary = 656 - (actionWithSnap.modifiers.meta && navigator.platform.toUpperCase().includes('MAC')) || 657 - (actionWithSnap.modifiers.ctrl && !navigator.platform.toUpperCase().includes('MAC')); 658 - 659 - if ( 660 - isPrimary && 661 - !actionWithSnap.modifiers.shift && 662 - (actionWithSnap.key === 'z' || actionWithSnap.key === 'Z') 663 - ) { 664 - store.undo(); 665 - return; 666 - } 667 - 668 - if ( 669 - isPrimary && 670 - actionWithSnap.modifiers.shift && 671 - (actionWithSnap.key === 'z' || actionWithSnap.key === 'Z') 672 - ) { 673 - store.redo(); 674 - return; 675 - } 676 - 677 - if (isPrimary && (actionWithSnap.key === 'd' || actionWithSnap.key === 'D')) { 678 - handleDuplicate(); 679 - return; 680 - } 681 - 682 - if (isPrimary && actionWithSnap.key === ']') { 683 - handleBringForward(); 684 - return; 685 - } 686 - 687 - if (isPrimary && actionWithSnap.key === '[') { 688 - handleSendBackward(); 689 - return; 690 - } 691 - 692 - if (actionWithSnap.key.startsWith('Arrow')) { 693 - handleNudge(actionWithSnap.key, actionWithSnap.modifiers.shift); 694 - return; 695 - } 696 - } 697 - 698 - applyActionWithHistory(actionWithSnap); 699 - } 700 - 701 - function applyImmediateAction(action: Action): boolean { 702 - const before = store.getState(); 703 - const nextState = routeAction(before, action, tools); 704 - if (statesEqual(before, nextState)) { 705 - syncHandleState(); 706 - return false; 707 - } 708 - store.setState(() => nextState); 709 - syncHandleState(); 710 - return true; 711 - } 712 - 713 - function commitPendingCommand(action: Action, startState: EditorState): boolean { 714 - const before = store.getState(); 715 - const nextState = routeAction(before, action, tools); 716 - const finalState = statesEqual(before, nextState) ? before : nextState; 717 - if (statesEqual(startState, finalState)) { 718 - syncHandleState(); 719 - return false; 12 + const controller = createCanvasController({ 13 + setHistoryViewerOpen(value: boolean) { 14 + historyViewerOpen = value; 720 15 } 721 - const kind = getCommandKind(startState, finalState); 722 - const commandName = describeAction(action, kind); 723 - const command = new SnapshotCommand( 724 - commandName, 725 - kind, 726 - EditorState.clone(startState), 727 - EditorState.clone(finalState) 728 - ); 729 - store.executeCommand(command); 730 - syncHandleState(); 731 - return true; 732 - } 16 + }); 733 17 734 - function statesEqual(a: EditorState, b: EditorState): boolean { 735 - return a.doc === b.doc && a.camera === b.camera && a.ui === b.ui; 736 - } 18 + const { 19 + platform: readPlatform, 20 + desktopBoards: readDesktopBoards, 21 + desktopFileName: readDesktopFileName, 22 + handleDesktopOpen, 23 + handleDesktopNewBoard, 24 + handleDesktopSaveAs, 25 + handleDesktopRecentSelect, 26 + currentToolId: readCurrentToolId, 27 + handleToolChange, 28 + handleHistoryClick, 29 + handleHistoryClose, 30 + store, 31 + getViewport, 32 + handleCanvasDoubleClick, 33 + handlePointerLeave, 34 + textEditor: readTextEditor, 35 + getTextEditorLayout, 36 + handleTextEditorInput, 37 + handleTextEditorKeyDown, 38 + handleTextEditorBlur, 39 + cursorStore, 40 + persistenceStatusStore: readPersistenceStatusStore, 41 + snapStore, 42 + setCanvasRef, 43 + setTextEditorElRef 44 + } = controller; 737 45 738 - function getCommandKind(before: EditorState, after: EditorState): CommandKind { 739 - if (before.doc !== after.doc) { 740 - return 'doc'; 741 - } 742 - if (before.camera !== after.camera) { 743 - return 'camera'; 744 - } 745 - return 'ui'; 746 - } 46 + let platform = $derived(readPlatform()); 47 + let desktopBoards = $derived(readDesktopBoards()); 48 + let desktopFileName = $derived(readDesktopFileName()); 49 + let currentToolId = $derived(readCurrentToolId()); 50 + let textEditor = $derived(readTextEditor()); 51 + let persistenceStatusStore = $derived(readPersistenceStatusStore()); 747 52 748 - function describeAction(action: Action, kind: CommandKind): string { 749 - switch (action.type) { 750 - case 'pointer-down': 751 - return 'Pointer down'; 752 - case 'pointer-move': 753 - return 'Pointer move'; 754 - case 'pointer-up': 755 - return 'Pointer up'; 756 - case 'wheel': 757 - return 'Wheel'; 758 - case 'key-down': 759 - return 'Key down'; 760 - case 'key-up': 761 - return 'Key up'; 762 - default: 763 - return kind === 'doc' ? 'Edit' : kind === 'camera' ? 'Camera change' : 'UI change'; 764 - } 765 - } 766 - 767 - async function handleDesktopOpen() { 768 - if (!desktopRepo || !repo) { 769 - return; 770 - } 771 - try { 772 - const opened = await desktopRepo.openFromDialog(); 773 - setActiveBoardId(opened.boardId); 774 - applyLoadedDoc(opened.doc); 775 - updateDesktopFileState(); 776 - await refreshDesktopBoards(); 777 - } catch (error) { 778 - if (isUserCancelled(error)) { 779 - return; 780 - } 781 - console.error('Failed to open board', error); 782 - } 783 - } 784 - 785 - async function handleDesktopNewBoard() { 786 - if (!repo) { 787 - return; 788 - } 789 - try { 790 - const boardId = await repo.createBoard('Untitled'); 791 - const loaded = await repo.loadDoc(boardId); 792 - setActiveBoardId(boardId); 793 - applyLoadedDoc(loaded); 794 - updateDesktopFileState(); 795 - await refreshDesktopBoards(); 796 - } catch (error) { 797 - if (isUserCancelled(error)) { 798 - return; 799 - } 800 - console.error('Failed to create board', error); 801 - } 802 - } 803 - 804 - async function handleDesktopSaveAs() { 805 - if (!repo || !activeBoardId) { 806 - return; 807 - } 808 - try { 809 - const snapshot = await repo.exportBoard(activeBoardId); 810 - const newBoardId = await repo.importBoard(snapshot); 811 - const loaded = await repo.loadDoc(newBoardId); 812 - setActiveBoardId(newBoardId); 813 - applyLoadedDoc(loaded); 814 - updateDesktopFileState(); 815 - await refreshDesktopBoards(); 816 - } catch (error) { 817 - if (isUserCancelled(error)) { 818 - return; 819 - } 820 - console.error('Failed to save board', error); 821 - } 822 - } 823 - 824 - async function handleDesktopRecentSelect(boardId: string) { 825 - if (!repo) { 826 - return; 827 - } 828 - try { 829 - const loaded = await repo.loadDoc(boardId); 830 - setActiveBoardId(boardId); 831 - applyLoadedDoc(loaded); 832 - updateDesktopFileState(); 833 - await refreshDesktopBoards(); 834 - } catch (error) { 835 - console.error('Failed to load board', error); 836 - } 837 - } 838 - 839 - function applySnapping(action: Action): Action { 840 - const snap = snapStore.get(); 841 - if (!snap.snapEnabled || !snap.gridEnabled) { 842 - return action; 843 - } 844 - if (!('world' in action)) { 845 - return action; 846 - } 847 - const snapCoord = (value: number) => Math.round(value / snap.gridSize) * snap.gridSize; 848 - const snappedWorld = { x: snapCoord(action.world.x), y: snapCoord(action.world.y) }; 849 - return { ...action, world: snappedWorld }; 850 - } 851 - 852 - let canvas = $state<HTMLCanvasElement>(); 853 - let renderer: Renderer | null = null; 854 - let inputAdapter: InputAdapter | null = null; 855 - 856 - function getViewport(): Viewport { 857 - if (canvas) { 858 - const rect = canvas.getBoundingClientRect(); 859 - return { width: rect.width || 1, height: rect.height || 1 }; 860 - } 861 - if (typeof window !== 'undefined') { 862 - return { width: window.innerWidth || 1, height: window.innerHeight || 1 }; 863 - } 864 - return { width: 1, height: 1 }; 865 - } 866 - 867 - onMount(() => { 868 - let disposed = false; 869 - 870 - const initialize = async () => { 871 - const { 872 - repo: platformRepo, 873 - platform: detectedPlatform, 874 - db, 875 - desktop: desktopInstance 876 - } = await createPlatformRepo(); 877 - if (disposed) { 878 - return; 879 - } 880 - repo = platformRepo; 881 - if (detectedPlatform === 'desktop' && desktopInstance) { 882 - desktopRepo = desktopInstance; 883 - } else { 884 - desktopRepo = null; 885 - desktopBoards = []; 886 - desktopFileName = null; 887 - } 888 - 889 - if (detectedPlatform === 'web' && db) { 890 - persistenceManager = createPersistenceManager(db, repo, { sink: { debounceMs: 200 } }); 891 - sink = persistenceManager.sink; 892 - persistenceStatusStore = persistenceManager.status; 893 - } else { 894 - const { createPersistenceSink } = await import('inkfinite-core'); 895 - if (disposed) { 896 - return; 897 - } 898 - sink = createPersistenceSink(repo, { debounceMs: 500 }); 899 - } 900 - 901 - const hydrate = async () => { 902 - const repoInstance = repo; 903 - if (!repoInstance) { 904 - return; 905 - } 906 - try { 907 - if (detectedPlatform === 'web') { 908 - const boards = await repoInstance.listBoards(); 909 - const id = boards[0]?.id ?? (await repoInstance.createBoard('My board')); 910 - if (disposed) { 911 - return; 912 - } 913 - setActiveBoardId(id); 914 - const loaded = await repoInstance.loadDoc(id); 915 - if (!disposed) { 916 - applyLoadedDoc(loaded); 917 - } 918 - } else { 919 - const boards = await refreshDesktopBoards(); 920 - let id = boards[0]?.id ?? null; 921 - if (!id) { 922 - id = await repoInstance.createBoard('Untitled'); 923 - } 924 - if (disposed) { 925 - return; 926 - } 927 - setActiveBoardId(id); 928 - const loaded = await repoInstance.loadDoc(id); 929 - if (!disposed) { 930 - applyLoadedDoc(loaded); 931 - updateDesktopFileState(); 932 - } 933 - await refreshDesktopBoards(); 934 - } 935 - } catch (error) { 936 - console.error('Failed to load board', error); 937 - } 938 - }; 939 - 940 - await hydrate(); 941 - if (disposed) { 942 - return; 943 - } 944 - 945 - function getCamera() { 946 - return store.getState().camera; 947 - } 948 - 949 - const currentCanvas = canvas; 950 - if (!currentCanvas) { 951 - return; 952 - } 953 - 954 - renderer = createRenderer(currentCanvas, store, { 955 - snapProvider, 956 - cursorProvider, 957 - pointerStateProvider, 958 - handleProvider 959 - }); 960 - inputAdapter = createInputAdapter({ 961 - canvas: currentCanvas, 962 - getCamera, 963 - getViewport, 964 - onAction: handleAction, 965 - onCursorUpdate: (world, screen) => cursorStore.updateCursor(world, screen) 966 - }); 967 - 968 - if (typeof window !== 'undefined') { 969 - function handleBeforeUnload() { 970 - if (sink) { 971 - void sink.flush(); 972 - } 973 - } 974 - 975 - window.addEventListener('beforeunload', handleBeforeUnload); 976 - removeBeforeUnload = () => window.removeEventListener('beforeunload', handleBeforeUnload); 977 - } 978 - }; 979 - 980 - void initialize(); 981 - 982 - return () => { 983 - disposed = true; 984 - }; 53 + $effect(() => { 54 + setCanvasRef(canvasEl); 55 + return () => setCanvasRef(null); 985 56 }); 986 57 987 - onDestroy(() => { 988 - removeBeforeUnload?.(); 989 - removeBeforeUnload = null; 990 - renderer?.dispose(); 991 - inputAdapter?.dispose(); 992 - if (sink) { 993 - void sink.flush(); 994 - } 995 - repo = null; 996 - desktopRepo = null; 997 - desktopBoards = []; 998 - desktopFileName = null; 999 - sink = null; 1000 - activeBoardId = null; 1001 - persistenceManager?.dispose(); 1002 - persistenceManager = null; 1003 - fallbackStatusStore.update(() => ({ backend: 'indexeddb', state: 'saved', pendingWrites: 0 })); 1004 - persistenceStatusStore = fallbackStatusStore; 58 + $effect(() => { 59 + setTextEditorElRef(textEditorEl); 60 + return () => setTextEditorElRef(null); 1005 61 }); 1006 62 </script> 1007 63 ··· 1022 78 onHistoryClick={handleHistoryClick} 1023 79 {store} 1024 80 {getViewport} 1025 - {canvas} /> 81 + canvas={canvasEl ?? undefined} /> 1026 82 <div class="canvas-container"> 1027 83 <canvas 1028 - bind:this={canvas} 84 + bind:this={canvasEl} 1029 85 ondblclick={handleCanvasDoubleClick} 1030 86 onpointerleave={handlePointerLeave}></canvas> 1031 87 {#if textEditor}
+993
apps/web/src/lib/canvas/canvas-store.svelte.ts
··· 1 + import { createInputAdapter, type InputAdapter } from "$lib/input"; 2 + import type { DesktopDocRepo } from "$lib/persistence/desktop"; 3 + import { createPlatformRepo, detectPlatform } from "$lib/platform"; 4 + import { 5 + createPersistenceManager, 6 + createSnapStore, 7 + createStatusStore, 8 + type SnapStore, 9 + type StatusStore, 10 + } from "$lib/status"; 11 + import { 12 + type Action, 13 + ArrowTool, 14 + type BoardMeta, 15 + Camera, 16 + type CommandKind, 17 + createToolMap, 18 + CursorStore, 19 + diffDoc, 20 + EditorState, 21 + EllipseTool, 22 + getShapesOnCurrentPage, 23 + LineTool, 24 + type LoadedDoc, 25 + type PersistenceSink, 26 + type PersistentDocRepo, 27 + RectTool, 28 + routeAction, 29 + SelectTool, 30 + shapeBounds, 31 + ShapeRecord, 32 + SnapshotCommand, 33 + Store, 34 + switchTool, 35 + TextTool, 36 + type ToolId, 37 + type Viewport, 38 + } from "inkfinite-core"; 39 + import { createRenderer, type Renderer } from "inkfinite-renderer"; 40 + import { onDestroy, onMount } from "svelte"; 41 + 42 + export type CanvasControllerBindings = { setHistoryViewerOpen(value: boolean): void }; 43 + 44 + export type CanvasController = ReturnType<typeof createCanvasController>; 45 + 46 + export function createCanvasController(bindings: CanvasControllerBindings) { 47 + let repo: PersistentDocRepo | null = null; 48 + let sink: PersistenceSink | null = null; 49 + let persistenceManager: ReturnType<typeof createPersistenceManager> | null = null; 50 + const platform = detectPlatform(); 51 + const fallbackStatusStore = createStatusStore({ 52 + backend: platform === "desktop" ? "filesystem" : "indexeddb", 53 + state: "saved", 54 + pendingWrites: 0, 55 + }); 56 + let persistenceStatusStore = $state<StatusStore>(fallbackStatusStore); 57 + let activeBoardId: string | null = null; 58 + let desktopRepo: DesktopDocRepo | null = null; 59 + let desktopBoards = $state<BoardMeta[]>([]); 60 + let desktopFileName = $state<string | null>(null); 61 + let removeBeforeUnload: (() => void) | null = null; 62 + 63 + const store = new Store(undefined, { 64 + onHistoryEvent: (event) => { 65 + if (!activeBoardId || event.kind !== "doc" || !sink) { 66 + return; 67 + } 68 + const patch = diffDoc(event.beforeState.doc, event.afterState.doc); 69 + sink.enqueueDocPatch(activeBoardId, patch); 70 + }, 71 + }); 72 + const cursorStore = new CursorStore(); 73 + const snapStore: SnapStore = createSnapStore(); 74 + const pointerState = $state({ isPointerDown: false, snappedWorld: null as { x: number; y: number } | null }); 75 + const handleState = $state<{ hover: string | null; active: string | null }>({ hover: null, active: null }); 76 + let textEditor = $state<{ shapeId: string; value: string } | null>(null); 77 + let textEditorEl: HTMLTextAreaElement | null = null; 78 + const panState = $state({ isPanning: false, spaceHeld: false, lastScreen: { x: 0, y: 0 } }); 79 + const snapProvider = { get: () => snapStore.get() }; 80 + const cursorProvider = { get: () => cursorStore.getState() }; 81 + const pointerStateProvider = { get: () => pointerState }; 82 + const handleProvider = { get: () => ({ ...handleState }) }; 83 + let pendingCommandStart: EditorState | null = null; 84 + let canvas: HTMLCanvasElement | null = null; 85 + 86 + function setCanvasRef(node: HTMLCanvasElement | null) { 87 + canvas = node; 88 + } 89 + 90 + function setTextEditorElRef(node: HTMLTextAreaElement | null) { 91 + textEditorEl = node; 92 + } 93 + 94 + function applyLoadedDoc(doc: LoadedDoc) { 95 + const firstPageId = doc.order.pageIds[0] ?? Object.keys(doc.pages)[0] ?? null; 96 + store.setState((state) => ({ 97 + ...state, 98 + doc: { pages: doc.pages, shapes: doc.shapes, bindings: doc.bindings }, 99 + ui: { ...state.ui, currentPageId: firstPageId, selectionIds: [] }, 100 + })); 101 + initializeSelection(firstPageId, doc); 102 + } 103 + 104 + function initializeSelection(pageId: string | null, doc: LoadedDoc) { 105 + if (!pageId) { 106 + return; 107 + } 108 + const page = doc.pages[pageId]; 109 + const firstShapeId = page?.shapeIds[0]; 110 + if (!firstShapeId) { 111 + return; 112 + } 113 + const state = editorSnapshot; 114 + if (state.ui.selectionIds.length === 1 && state.ui.selectionIds[0] === firstShapeId) { 115 + return; 116 + } 117 + const before = EditorState.clone(state); 118 + const after = { ...state, ui: { ...state.ui, selectionIds: [firstShapeId] } }; 119 + const command = new SnapshotCommand("Initialize Selection", "ui", before, EditorState.clone(after)); 120 + store.executeCommand(command); 121 + syncHandleState(); 122 + } 123 + 124 + function setActiveBoardId(boardId: string) { 125 + activeBoardId = boardId; 126 + persistenceManager?.setActiveBoard(boardId); 127 + } 128 + 129 + function updateDesktopFileState() { 130 + if (!desktopRepo) { 131 + desktopFileName = null; 132 + return; 133 + } 134 + const handle = desktopRepo.getCurrentFile(); 135 + desktopFileName = handle?.name ?? null; 136 + } 137 + 138 + async function refreshDesktopBoards(): Promise<BoardMeta[]> { 139 + if (!desktopRepo) { 140 + desktopBoards = []; 141 + return []; 142 + } 143 + try { 144 + const boards = await desktopRepo.listBoards(); 145 + desktopBoards = boards; 146 + return boards; 147 + } catch (error) { 148 + console.error("Failed to list boards", error); 149 + desktopBoards = []; 150 + return []; 151 + } 152 + } 153 + 154 + function isUserCancelled(error: unknown) { 155 + return error instanceof Error && /cancel/i.test(error.message); 156 + } 157 + 158 + const handleCursorMap: Record<string, string> = { 159 + n: "ns-resize", 160 + s: "ns-resize", 161 + e: "ew-resize", 162 + w: "ew-resize", 163 + ne: "nesw-resize", 164 + sw: "nesw-resize", 165 + nw: "nwse-resize", 166 + se: "nwse-resize", 167 + rotate: "alias", 168 + "line-start": "crosshair", 169 + "line-end": "crosshair", 170 + }; 171 + 172 + function refreshCursor() { 173 + if (!canvas) { 174 + return; 175 + } 176 + let cursor = "default"; 177 + if (textEditor) { 178 + cursor = "text"; 179 + } else if (panState.isPanning) { 180 + cursor = "grabbing"; 181 + } else if (panState.spaceHeld) { 182 + cursor = "grab"; 183 + } else { 184 + const activeHandle = handleState.active; 185 + const hoverHandle = handleState.hover; 186 + const targetHandle = activeHandle ?? hoverHandle; 187 + if (targetHandle) { 188 + cursor = handleCursorMap[targetHandle] ?? "default"; 189 + } else if (pointerState.isPointerDown) { 190 + cursor = "grabbing"; 191 + } 192 + } 193 + canvas.style.cursor = cursor; 194 + } 195 + 196 + function setHandleHover(handle: string | null) { 197 + if (handleState.hover === handle) { 198 + return; 199 + } 200 + handleState.hover = handle; 201 + refreshCursor(); 202 + } 203 + 204 + function syncHandleState() { 205 + handleState.active = selectTool.getActiveHandle ? selectTool.getActiveHandle() : null; 206 + refreshCursor(); 207 + } 208 + 209 + function getTextEditorLayout() { 210 + if (!textEditor) { 211 + return null; 212 + } 213 + const state = store.getState(); 214 + const shape = state.doc.shapes[textEditor.shapeId]; 215 + if (!shape || shape.type !== "text") { 216 + return null; 217 + } 218 + const viewport = getViewport(); 219 + const screenPos = Camera.worldToScreen(state.camera, { x: shape.x, y: shape.y }, viewport); 220 + const widthWorld = shape.props.w ?? 240; 221 + const zoom = state.camera.zoom; 222 + return { 223 + left: screenPos.x, 224 + top: screenPos.y, 225 + width: widthWorld * zoom, 226 + height: shape.props.fontSize * 1.4 * zoom, 227 + fontSize: shape.props.fontSize * zoom, 228 + }; 229 + } 230 + 231 + function startTextEditing(shapeId: string) { 232 + const state = store.getState(); 233 + const shape = state.doc.shapes[shapeId]; 234 + if (!shape || shape.type !== "text") { 235 + return; 236 + } 237 + textEditor = { shapeId, value: shape.props.text }; 238 + refreshCursor(); 239 + queueMicrotask(() => { 240 + textEditorEl?.focus(); 241 + textEditorEl?.select(); 242 + }); 243 + } 244 + 245 + function commitTextEditing() { 246 + if (!textEditor) { 247 + return; 248 + } 249 + const { shapeId, value } = textEditor; 250 + const currentState = store.getState(); 251 + const shape = currentState.doc.shapes[shapeId]; 252 + textEditor = null; 253 + refreshCursor(); 254 + if (!shape || shape.type !== "text" || shape.props.text === value) { 255 + return; 256 + } 257 + const before = EditorState.clone(currentState); 258 + const updatedShape = { ...shape, props: { ...shape.props, text: value } }; 259 + const newShapes = { ...currentState.doc.shapes, [shapeId]: updatedShape }; 260 + const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; 261 + const command = new SnapshotCommand("Edit text", "doc", before, EditorState.clone(after)); 262 + store.executeCommand(command); 263 + } 264 + 265 + function cancelTextEditing() { 266 + textEditor = null; 267 + refreshCursor(); 268 + } 269 + 270 + function handleCanvasDoubleClick(event: MouseEvent) { 271 + if (!canvas) { 272 + return; 273 + } 274 + const rect = canvas.getBoundingClientRect(); 275 + const screen = { x: event.clientX - rect.left, y: event.clientY - rect.top }; 276 + const world = Camera.screenToWorld(store.getState().camera, screen, getViewport()); 277 + const shapeId = findTextShapeAt(world); 278 + if (shapeId) { 279 + startTextEditing(shapeId); 280 + } 281 + } 282 + 283 + function findTextShapeAt(point: { x: number; y: number }): string | null { 284 + const shapes = getShapesOnCurrentPage(store.getState()); 285 + for (let index = shapes.length - 1; index >= 0; index--) { 286 + const shape = shapes[index]; 287 + if (!shape || shape.type !== "text") { 288 + continue; 289 + } 290 + const bounds = shapeBounds(shape); 291 + if (point.x >= bounds.min.x && point.x <= bounds.max.x && point.y >= bounds.min.y && point.y <= bounds.max.y) { 292 + return shape.id; 293 + } 294 + } 295 + return null; 296 + } 297 + 298 + function handleTextEditorInput(event: Event) { 299 + if (!textEditor) { 300 + return; 301 + } 302 + const target = event.currentTarget as HTMLTextAreaElement; 303 + textEditor = { ...textEditor, value: target.value }; 304 + } 305 + 306 + function handleTextEditorKeyDown(event: KeyboardEvent) { 307 + if (event.key === "Escape") { 308 + event.preventDefault(); 309 + cancelTextEditing(); 310 + return; 311 + } 312 + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { 313 + event.preventDefault(); 314 + commitTextEditing(); 315 + } 316 + } 317 + 318 + function handleTextEditorBlur() { 319 + commitTextEditing(); 320 + } 321 + 322 + function handlePointerLeave() { 323 + setHandleHover(null); 324 + } 325 + 326 + const selectTool = new SelectTool(); 327 + const rectTool = new RectTool(); 328 + const ellipseTool = new EllipseTool(); 329 + const lineTool = new LineTool(); 330 + const arrowTool = new ArrowTool(); 331 + const textTool = new TextTool(); 332 + const tools = createToolMap([selectTool, rectTool, ellipseTool, lineTool, arrowTool, textTool]); 333 + 334 + let currentToolId = $state<ToolId>("select"); 335 + let editorSnapshot = $state(store.getState()); 336 + 337 + store.subscribe((state) => { 338 + currentToolId = state.ui.toolId; 339 + editorSnapshot = state; 340 + }); 341 + 342 + function handleToolChange(toolId: ToolId) { 343 + store.setState((state) => switchTool(state, toolId, tools)); 344 + } 345 + 346 + function handleHistoryClick() { 347 + bindings.setHistoryViewerOpen(true); 348 + } 349 + 350 + function handleHistoryClose() { 351 + bindings.setHistoryViewerOpen(false); 352 + } 353 + 354 + function handleBringForward() { 355 + const currentState = store.getState(); 356 + const selectedIds = currentState.ui.selectionIds; 357 + const currentPageId = currentState.ui.currentPageId; 358 + 359 + if (selectedIds.length === 0 || !currentPageId) { 360 + return; 361 + } 362 + 363 + const before = EditorState.clone(currentState); 364 + const page = currentState.doc.pages[currentPageId]; 365 + if (!page) return; 366 + 367 + const newShapeIds = [...page.shapeIds]; 368 + 369 + for (const shapeId of selectedIds) { 370 + const currentIndex = newShapeIds.indexOf(shapeId); 371 + if (currentIndex !== -1 && currentIndex < newShapeIds.length - 1) { 372 + [newShapeIds[currentIndex], newShapeIds[currentIndex + 1]] = [ 373 + newShapeIds[currentIndex + 1], 374 + newShapeIds[currentIndex], 375 + ]; 376 + } 377 + } 378 + 379 + const after = { 380 + ...currentState, 381 + doc: { 382 + ...currentState.doc, 383 + pages: { ...currentState.doc.pages, [currentPageId]: { ...page, shapeIds: newShapeIds } }, 384 + }, 385 + }; 386 + 387 + const command = new SnapshotCommand("Bring Forward", "doc", before, EditorState.clone(after)); 388 + store.executeCommand(command); 389 + syncHandleState(); 390 + } 391 + 392 + function handleSendBackward() { 393 + const currentState = store.getState(); 394 + const selectedIds = currentState.ui.selectionIds; 395 + const currentPageId = currentState.ui.currentPageId; 396 + 397 + if (selectedIds.length === 0 || !currentPageId) { 398 + return; 399 + } 400 + 401 + const before = EditorState.clone(currentState); 402 + const page = currentState.doc.pages[currentPageId]; 403 + if (!page) return; 404 + 405 + const newShapeIds = [...page.shapeIds]; 406 + 407 + for (let i = selectedIds.length - 1; i >= 0; i--) { 408 + const shapeId = selectedIds[i]; 409 + const currentIndex = newShapeIds.indexOf(shapeId); 410 + if (currentIndex > 0) { 411 + [newShapeIds[currentIndex], newShapeIds[currentIndex - 1]] = [ 412 + newShapeIds[currentIndex - 1], 413 + newShapeIds[currentIndex], 414 + ]; 415 + } 416 + } 417 + 418 + const after = { 419 + ...currentState, 420 + doc: { 421 + ...currentState.doc, 422 + pages: { ...currentState.doc.pages, [currentPageId]: { ...page, shapeIds: newShapeIds } }, 423 + }, 424 + }; 425 + 426 + const command = new SnapshotCommand("Send Backward", "doc", before, EditorState.clone(after)); 427 + store.executeCommand(command); 428 + syncHandleState(); 429 + } 430 + 431 + function handleDuplicate() { 432 + const currentState = store.getState(); 433 + const selectedIds = currentState.ui.selectionIds; 434 + 435 + if (selectedIds.length === 0) { 436 + return; 437 + } 438 + 439 + const before = EditorState.clone(currentState); 440 + const newShapes = { ...currentState.doc.shapes }; 441 + const newPages = { ...currentState.doc.pages }; 442 + const duplicatedIds: string[] = []; 443 + 444 + const DUPLICATE_OFFSET = 20; 445 + 446 + for (const shapeId of selectedIds) { 447 + const shape = currentState.doc.shapes[shapeId]; 448 + if (!shape) continue; 449 + 450 + const cloned = ShapeRecord.clone(shape); 451 + const newId = `shape:${crypto.randomUUID()}`; 452 + const duplicated = { ...cloned, id: newId, x: shape.x + DUPLICATE_OFFSET, y: shape.y + DUPLICATE_OFFSET }; 453 + 454 + newShapes[newId] = duplicated; 455 + duplicatedIds.push(newId); 456 + 457 + const currentPageId = currentState.ui.currentPageId; 458 + if (currentPageId) { 459 + const page = newPages[currentPageId]; 460 + if (page) { 461 + newPages[currentPageId] = { ...page, shapeIds: [...page.shapeIds, newId] }; 462 + } 463 + } 464 + } 465 + 466 + const after = { 467 + ...currentState, 468 + doc: { ...currentState.doc, shapes: newShapes, pages: newPages }, 469 + ui: { ...currentState.ui, selectionIds: duplicatedIds }, 470 + }; 471 + 472 + const command = new SnapshotCommand("Duplicate", "doc", before, EditorState.clone(after)); 473 + store.executeCommand(command); 474 + syncHandleState(); 475 + } 476 + 477 + function handleNudge(arrowKey: string, largeNudge: boolean) { 478 + const currentState = store.getState(); 479 + const selectedIds = currentState.ui.selectionIds; 480 + 481 + if (selectedIds.length === 0) { 482 + return; 483 + } 484 + 485 + const nudgeDistance = largeNudge ? 10 : 1; 486 + let deltaX = 0; 487 + let deltaY = 0; 488 + 489 + switch (arrowKey) { 490 + case "ArrowLeft": 491 + deltaX = -nudgeDistance; 492 + break; 493 + case "ArrowRight": 494 + deltaX = nudgeDistance; 495 + break; 496 + case "ArrowUp": 497 + deltaY = -nudgeDistance; 498 + break; 499 + case "ArrowDown": 500 + deltaY = nudgeDistance; 501 + break; 502 + } 503 + 504 + const before = EditorState.clone(currentState); 505 + const newShapes = { ...currentState.doc.shapes }; 506 + 507 + for (const shapeId of selectedIds) { 508 + const shape = newShapes[shapeId]; 509 + if (shape) { 510 + newShapes[shapeId] = { ...shape, x: shape.x + deltaX, y: shape.y + deltaY }; 511 + } 512 + } 513 + 514 + const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; 515 + const command = new SnapshotCommand("Nudge", "doc", before, EditorState.clone(after)); 516 + store.executeCommand(command); 517 + syncHandleState(); 518 + } 519 + 520 + function applyActionWithHistory(action: Action) { 521 + const before = store.getState(); 522 + const nextState = routeAction(before, action, tools); 523 + if (statesEqual(before, nextState)) { 524 + syncHandleState(); 525 + return; 526 + } 527 + 528 + const kind = getCommandKind(before, nextState); 529 + const commandName = describeAction(action, kind); 530 + const command = new SnapshotCommand(commandName, kind, EditorState.clone(before), EditorState.clone(nextState)); 531 + store.executeCommand(command); 532 + syncHandleState(); 533 + } 534 + 535 + function handleAction(action: Action) { 536 + if (textEditor && (action.type === "pointer-down" || action.type === "pointer-up")) { 537 + commitTextEditing(); 538 + } 539 + 540 + if (action.type === "pointer-move" && "world" in action && !panState.isPanning && !panState.spaceHeld) { 541 + const hover = selectTool.getHandleAtPoint(store.getState(), action.world); 542 + setHandleHover(hover); 543 + } 544 + 545 + if (action.type === "pointer-move" && (panState.isPanning || panState.spaceHeld)) { 546 + setHandleHover(null); 547 + } 548 + 549 + if (action.type === "key-down" && action.key === " ") { 550 + panState.spaceHeld = true; 551 + setHandleHover(null); 552 + refreshCursor(); 553 + return; 554 + } 555 + 556 + if (action.type === "key-up" && action.key === " ") { 557 + panState.spaceHeld = false; 558 + panState.isPanning = false; 559 + refreshCursor(); 560 + return; 561 + } 562 + 563 + if (action.type === "pointer-down" && action.button === 0 && panState.spaceHeld) { 564 + panState.isPanning = true; 565 + panState.lastScreen = { x: action.screen.x, y: action.screen.y }; 566 + refreshCursor(); 567 + return; 568 + } 569 + 570 + if (action.type === "pointer-move" && panState.isPanning) { 571 + const deltaX = action.screen.x - panState.lastScreen.x; 572 + const deltaY = action.screen.y - panState.lastScreen.y; 573 + const currentCamera = store.getState().camera; 574 + const newCamera = Camera.pan(currentCamera, { x: deltaX, y: deltaY }); 575 + store.setState((state) => ({ ...state, camera: newCamera })); 576 + panState.lastScreen = { x: action.screen.x, y: action.screen.y }; 577 + refreshCursor(); 578 + return; 579 + } 580 + 581 + if (action.type === "pointer-up" && action.button === 0 && panState.isPanning) { 582 + panState.isPanning = false; 583 + refreshCursor(); 584 + return; 585 + } 586 + 587 + if (panState.isPanning || panState.spaceHeld) { 588 + return; 589 + } 590 + 591 + const actionWithSnap = applySnapping(action); 592 + if ("world" in actionWithSnap) { 593 + pointerState.snappedWorld = actionWithSnap.world ?? null; 594 + } 595 + 596 + if (actionWithSnap.type === "pointer-down" && actionWithSnap.button === 0) { 597 + pointerState.isPointerDown = true; 598 + setHandleHover(null); 599 + refreshCursor(); 600 + pendingCommandStart = EditorState.clone(store.getState()); 601 + const changed = applyImmediateAction(actionWithSnap); 602 + if (!changed) { 603 + pendingCommandStart = null; 604 + } 605 + return; 606 + } 607 + 608 + if (actionWithSnap.type === "pointer-move" && pointerState.isPointerDown && pendingCommandStart) { 609 + void applyImmediateAction(actionWithSnap); 610 + return; 611 + } 612 + 613 + if (actionWithSnap.type === "pointer-up" && actionWithSnap.button === 0) { 614 + pointerState.isPointerDown = false; 615 + setHandleHover(null); 616 + refreshCursor(); 617 + if (pendingCommandStart) { 618 + const committed = commitPendingCommand(actionWithSnap, pendingCommandStart); 619 + pendingCommandStart = null; 620 + if (committed) { 621 + return; 622 + } 623 + } 624 + pointerState.snappedWorld = null; 625 + } 626 + 627 + if (actionWithSnap.type === "key-down") { 628 + const isPrimary = (actionWithSnap.modifiers.meta && navigator.platform.toUpperCase().includes("MAC")) 629 + || (actionWithSnap.modifiers.ctrl && !navigator.platform.toUpperCase().includes("MAC")); 630 + 631 + if (isPrimary && !actionWithSnap.modifiers.shift && (actionWithSnap.key === "z" || actionWithSnap.key === "Z")) { 632 + store.undo(); 633 + return; 634 + } 635 + 636 + if (isPrimary && actionWithSnap.modifiers.shift && (actionWithSnap.key === "z" || actionWithSnap.key === "Z")) { 637 + store.redo(); 638 + return; 639 + } 640 + 641 + if (isPrimary && (actionWithSnap.key === "d" || actionWithSnap.key === "D")) { 642 + handleDuplicate(); 643 + return; 644 + } 645 + 646 + if (isPrimary && actionWithSnap.key === "]") { 647 + handleBringForward(); 648 + return; 649 + } 650 + 651 + if (isPrimary && actionWithSnap.key === "[") { 652 + handleSendBackward(); 653 + return; 654 + } 655 + 656 + if (actionWithSnap.key.startsWith("Arrow")) { 657 + handleNudge(actionWithSnap.key, actionWithSnap.modifiers.shift); 658 + return; 659 + } 660 + } 661 + 662 + applyActionWithHistory(actionWithSnap); 663 + } 664 + 665 + function applyImmediateAction(action: Action): boolean { 666 + const before = store.getState(); 667 + const nextState = routeAction(before, action, tools); 668 + if (statesEqual(before, nextState)) { 669 + syncHandleState(); 670 + return false; 671 + } 672 + store.setState(() => nextState); 673 + syncHandleState(); 674 + return true; 675 + } 676 + 677 + function commitPendingCommand(action: Action, startState: EditorState): boolean { 678 + const before = store.getState(); 679 + const nextState = routeAction(before, action, tools); 680 + const finalState = statesEqual(before, nextState) ? before : nextState; 681 + if (statesEqual(startState, finalState)) { 682 + syncHandleState(); 683 + return false; 684 + } 685 + const kind = getCommandKind(startState, finalState); 686 + const commandName = describeAction(action, kind); 687 + const command = new SnapshotCommand( 688 + commandName, 689 + kind, 690 + EditorState.clone(startState), 691 + EditorState.clone(finalState), 692 + ); 693 + store.executeCommand(command); 694 + syncHandleState(); 695 + return true; 696 + } 697 + 698 + function statesEqual(a: EditorState, b: EditorState): boolean { 699 + return a.doc === b.doc && a.camera === b.camera && a.ui === b.ui; 700 + } 701 + 702 + function getCommandKind(before: EditorState, after: EditorState): CommandKind { 703 + if (before.doc !== after.doc) { 704 + return "doc"; 705 + } 706 + if (before.camera !== after.camera) { 707 + return "camera"; 708 + } 709 + return "ui"; 710 + } 711 + 712 + function describeAction(action: Action, kind: CommandKind): string { 713 + switch (action.type) { 714 + case "pointer-down": 715 + return "Pointer down"; 716 + case "pointer-move": 717 + return "Pointer move"; 718 + case "pointer-up": 719 + return "Pointer up"; 720 + case "wheel": 721 + return "Wheel"; 722 + case "key-down": 723 + return "Key down"; 724 + case "key-up": 725 + return "Key up"; 726 + default: 727 + return kind === "doc" ? "Edit" : kind === "camera" ? "Camera change" : "UI change"; 728 + } 729 + } 730 + 731 + async function handleDesktopOpen() { 732 + if (!desktopRepo || !repo) { 733 + return; 734 + } 735 + try { 736 + const opened = await desktopRepo.openFromDialog(); 737 + setActiveBoardId(opened.boardId); 738 + applyLoadedDoc(opened.doc); 739 + updateDesktopFileState(); 740 + await refreshDesktopBoards(); 741 + } catch (error) { 742 + if (isUserCancelled(error)) { 743 + return; 744 + } 745 + console.error("Failed to open board", error); 746 + } 747 + } 748 + 749 + async function handleDesktopNewBoard() { 750 + if (!repo) { 751 + return; 752 + } 753 + try { 754 + const boardId = await repo.createBoard("Untitled"); 755 + const loaded = await repo.loadDoc(boardId); 756 + setActiveBoardId(boardId); 757 + applyLoadedDoc(loaded); 758 + updateDesktopFileState(); 759 + await refreshDesktopBoards(); 760 + } catch (error) { 761 + if (isUserCancelled(error)) { 762 + return; 763 + } 764 + console.error("Failed to create board", error); 765 + } 766 + } 767 + 768 + async function handleDesktopSaveAs() { 769 + if (!repo || !activeBoardId) { 770 + return; 771 + } 772 + try { 773 + const snapshot = await repo.exportBoard(activeBoardId); 774 + const newBoardId = await repo.importBoard(snapshot); 775 + const loaded = await repo.loadDoc(newBoardId); 776 + setActiveBoardId(newBoardId); 777 + applyLoadedDoc(loaded); 778 + updateDesktopFileState(); 779 + await refreshDesktopBoards(); 780 + } catch (error) { 781 + if (isUserCancelled(error)) { 782 + return; 783 + } 784 + console.error("Failed to save board", error); 785 + } 786 + } 787 + 788 + async function handleDesktopRecentSelect(boardId: string) { 789 + if (!repo) { 790 + return; 791 + } 792 + try { 793 + const loaded = await repo.loadDoc(boardId); 794 + setActiveBoardId(boardId); 795 + applyLoadedDoc(loaded); 796 + updateDesktopFileState(); 797 + await refreshDesktopBoards(); 798 + } catch (error) { 799 + console.error("Failed to load board", error); 800 + } 801 + } 802 + 803 + function applySnapping(action: Action): Action { 804 + const snap = snapStore.get(); 805 + if (!snap.snapEnabled || !snap.gridEnabled) { 806 + return action; 807 + } 808 + if (!("world" in action)) { 809 + return action; 810 + } 811 + const snapCoord = (value: number) => Math.round(value / snap.gridSize) * snap.gridSize; 812 + const snappedWorld = { x: snapCoord(action.world.x), y: snapCoord(action.world.y) }; 813 + return { ...action, world: snappedWorld }; 814 + } 815 + 816 + let renderer: Renderer | null = null; 817 + let inputAdapter: InputAdapter | null = null; 818 + 819 + function getViewport(): Viewport { 820 + if (canvas) { 821 + const rect = canvas.getBoundingClientRect(); 822 + return { width: rect.width || 1, height: rect.height || 1 }; 823 + } 824 + if (typeof window !== "undefined") { 825 + return { width: window.innerWidth || 1, height: window.innerHeight || 1 }; 826 + } 827 + return { width: 1, height: 1 }; 828 + } 829 + 830 + onMount(() => { 831 + let disposed = false; 832 + 833 + const initialize = async () => { 834 + const { repo: platformRepo, platform: detectedPlatform, db, desktop: desktopInstance } = 835 + await createPlatformRepo(); 836 + if (disposed) { 837 + return; 838 + } 839 + repo = platformRepo; 840 + if (detectedPlatform === "desktop" && desktopInstance) { 841 + desktopRepo = desktopInstance; 842 + } else { 843 + desktopRepo = null; 844 + desktopBoards = []; 845 + desktopFileName = null; 846 + } 847 + 848 + if (detectedPlatform === "web" && db) { 849 + persistenceManager = createPersistenceManager(db, repo, { sink: { debounceMs: 200 } }); 850 + sink = persistenceManager.sink; 851 + persistenceStatusStore = persistenceManager.status; 852 + } else { 853 + const { createPersistenceSink } = await import("inkfinite-core"); 854 + if (disposed) { 855 + return; 856 + } 857 + sink = createPersistenceSink(repo, { debounceMs: 500 }); 858 + } 859 + 860 + const hydrate = async () => { 861 + const repoInstance = repo; 862 + if (!repoInstance) { 863 + return; 864 + } 865 + try { 866 + if (detectedPlatform === "web") { 867 + const boards = await repoInstance.listBoards(); 868 + const id = boards[0]?.id ?? (await repoInstance.createBoard("My board")); 869 + if (disposed) { 870 + return; 871 + } 872 + setActiveBoardId(id); 873 + const loaded = await repoInstance.loadDoc(id); 874 + if (!disposed) { 875 + applyLoadedDoc(loaded); 876 + } 877 + } else { 878 + const boards = await refreshDesktopBoards(); 879 + let id = boards[0]?.id ?? null; 880 + if (!id) { 881 + id = await repoInstance.createBoard("Untitled"); 882 + } 883 + if (disposed) { 884 + return; 885 + } 886 + setActiveBoardId(id); 887 + const loaded = await repoInstance.loadDoc(id); 888 + if (!disposed) { 889 + applyLoadedDoc(loaded); 890 + updateDesktopFileState(); 891 + } 892 + await refreshDesktopBoards(); 893 + } 894 + } catch (error) { 895 + console.error("Failed to load board", error); 896 + } 897 + }; 898 + 899 + await hydrate(); 900 + if (disposed) { 901 + return; 902 + } 903 + 904 + function getCamera() { 905 + return store.getState().camera; 906 + } 907 + 908 + const currentCanvas = canvas; 909 + if (!currentCanvas) { 910 + return; 911 + } 912 + 913 + renderer = createRenderer(currentCanvas, store, { 914 + snapProvider, 915 + cursorProvider, 916 + pointerStateProvider, 917 + handleProvider, 918 + }); 919 + inputAdapter = createInputAdapter({ 920 + canvas: currentCanvas, 921 + getCamera, 922 + getViewport, 923 + onAction: handleAction, 924 + onCursorUpdate: (world, screen) => cursorStore.updateCursor(world, screen), 925 + }); 926 + 927 + if (typeof window !== "undefined") { 928 + function handleBeforeUnload() { 929 + if (sink) { 930 + void sink.flush(); 931 + } 932 + } 933 + 934 + window.addEventListener("beforeunload", handleBeforeUnload); 935 + removeBeforeUnload = () => window.removeEventListener("beforeunload", handleBeforeUnload); 936 + } 937 + }; 938 + 939 + void initialize(); 940 + 941 + return () => { 942 + disposed = true; 943 + }; 944 + }); 945 + 946 + onDestroy(() => { 947 + removeBeforeUnload?.(); 948 + removeBeforeUnload = null; 949 + renderer?.dispose(); 950 + inputAdapter?.dispose(); 951 + if (sink) { 952 + void sink.flush(); 953 + } 954 + repo = null; 955 + desktopRepo = null; 956 + desktopBoards = []; 957 + desktopFileName = null; 958 + sink = null; 959 + activeBoardId = null; 960 + persistenceManager?.dispose(); 961 + persistenceManager = null; 962 + fallbackStatusStore.update(() => ({ backend: "indexeddb", state: "saved", pendingWrites: 0 })); 963 + persistenceStatusStore = fallbackStatusStore; 964 + }); 965 + 966 + return { 967 + platform: () => platform, 968 + desktopBoards: () => desktopBoards, 969 + desktopFileName: () => desktopFileName, 970 + handleDesktopOpen, 971 + handleDesktopNewBoard, 972 + handleDesktopSaveAs, 973 + handleDesktopRecentSelect, 974 + currentToolId: () => currentToolId, 975 + handleToolChange, 976 + handleHistoryClick, 977 + handleHistoryClose, 978 + store, 979 + getViewport, 980 + handleCanvasDoubleClick, 981 + handlePointerLeave, 982 + textEditor: () => textEditor, 983 + getTextEditorLayout, 984 + handleTextEditorInput, 985 + handleTextEditorKeyDown, 986 + handleTextEditorBlur, 987 + cursorStore, 988 + persistenceStatusStore: () => persistenceStatusStore, 989 + snapStore, 990 + setCanvasRef, 991 + setTextEditorElRef, 992 + }; 993 + }
+9 -4
apps/web/src/lib/persistence/desktop.ts
··· 3 3 * Used when the web app is running inside Tauri 4 4 */ 5 5 6 - import type { BoardExport, BoardMeta, DocPatch, DocRepo, LoadedDoc, PageRecord } from "inkfinite-core"; 6 + import type { BoardExport, BoardMeta, DocPatch, LoadedDoc, PageRecord, PersistentDocRepo } from "inkfinite-core"; 7 7 import { 8 8 createFileData, 9 9 createId, ··· 15 15 } from "inkfinite-core"; 16 16 import type { DesktopFileOps } from "../fileops"; 17 17 18 - export type DesktopDocRepo = DocRepo & { 18 + export type DesktopDocRepo = PersistentDocRepo & { 19 19 kind: "desktop"; 20 20 getCurrentFile(): FileHandle | null; 21 21 openFromDialog(): Promise<{ boardId: string; doc: LoadedDoc }>; 22 22 }; 23 23 24 - export function isDesktopRepo(repo: DocRepo): repo is DesktopDocRepo { 24 + export function isDesktopRepo(repo: PersistentDocRepo): repo is DesktopDocRepo { 25 25 return (repo as DesktopDocRepo).kind === "desktop"; 26 26 } 27 27 ··· 167 167 } 168 168 } 169 169 170 + async function openBoard(boardId: string): Promise<void> { 171 + await loadDoc(boardId); 172 + } 173 + 170 174 async function applyDocPatch(boardId: string, patch: DocPatch): Promise<void> { 171 175 if (!currentBoard || !currentDoc || !currentFile) { 172 176 throw new Error("No board loaded"); ··· 287 291 kind: "desktop", 288 292 listBoards, 289 293 createBoard, 294 + openBoard, 290 295 renameBoard, 291 296 deleteBoard, 292 297 loadDoc, ··· 301 306 /** 302 307 * Get current file handle (for showing in title bar, etc.) 303 308 */ 304 - export function getCurrentFile(repo: DocRepo): FileHandle | null { 309 + export function getCurrentFile(repo: PersistentDocRepo): FileHandle | null { 305 310 if (isDesktopRepo(repo)) { 306 311 return repo.getCurrentFile(); 307 312 }
+7 -2
apps/web/src/lib/platform.ts
··· 1 - import type { DocRepo } from "inkfinite-core"; 1 + import type { PersistentDocRepo } from "inkfinite-core"; 2 2 import { createWebDocRepo, InkfiniteDB } from "inkfinite-core"; 3 3 import { createDesktopFileOps } from "./fileops"; 4 4 import { createDesktopDocRepo, type DesktopDocRepo } from "./persistence/desktop"; ··· 12 12 return "web"; 13 13 } 14 14 15 - export type PlatformRepoResult = { repo: DocRepo; platform: Platform; db?: InkfiniteDB; desktop?: DesktopDocRepo }; 15 + export type PlatformRepoResult = { 16 + repo: PersistentDocRepo; 17 + platform: Platform; 18 + db?: InkfiniteDB; 19 + desktop?: DesktopDocRepo; 20 + }; 16 21 17 22 /** 18 23 * Create the appropriate DocRepo based on platform
+2 -2
apps/web/src/lib/status.ts
··· 2 2 import { 3 3 createPersistenceSink, 4 4 type DocPatch, 5 - type DocRepo, 6 5 type PersistenceSink, 7 6 type PersistenceSinkOptions, 7 + type PersistentDocRepo, 8 8 } from "inkfinite-core"; 9 9 import type { InkfiniteDB, PersistenceStatus } from "inkfinite-core"; 10 10 ··· 38 38 39 39 export function createPersistenceManager( 40 40 db: InkfiniteDB, 41 - repo: DocRepo, 41 + repo: PersistentDocRepo, 42 42 options?: PersistenceManagerOptions, 43 43 ): PersistenceManager { 44 44 const sink = createPersistenceSink(repo, options?.sink);
+7
apps/web/src/lib/tests/Canvas.history.test.ts
··· 178 178 const createWebDocRepo = vi.fn(() => ({ 179 179 listBoards: vi.fn(async () => [{ id: "board:1", name: "Board 1", createdAt: 0, updatedAt: 0 }]), 180 180 createBoard: vi.fn(async () => "board:new"), 181 + openBoard: vi.fn(async () => {}), 181 182 renameBoard: vi.fn(), 182 183 deleteBoard: vi.fn(), 183 184 loadDoc: vi.fn(async () => createDoc()), 184 185 applyDocPatch: vi.fn(), 186 + exportBoard: vi.fn(async () => ({ 187 + board: { id: "board:1", name: "", createdAt: 0, updatedAt: 0 }, 188 + doc: createDoc(), 189 + order: { pageIds: [], shapeOrder: {} }, 190 + })), 191 + importBoard: vi.fn(async () => "board:new"), 185 192 })); 186 193 187 194 const routeAction = vi.fn((state: any, action: any) => {
+10
apps/web/src/lib/tests/Canvas.keyboard.test.ts
··· 68 68 createWebDocRepo: vi.fn(() => ({ 69 69 listBoards: async () => [{ id: "board-1", name: "Test Board", createdAt: 0, updatedAt: 0 }], 70 70 createBoard: async () => "board-1", 71 + openBoard: async () => {}, 72 + renameBoard: async () => {}, 73 + deleteBoard: async () => {}, 71 74 loadDoc: async () => ({ 72 75 pages: { "page:1": { id: "page:1", name: "Page 1", shapeIds: ["shape:1"] } }, 73 76 shapes: { ··· 84 87 bindings: {}, 85 88 order: { pageIds: ["page:1"] }, 86 89 }), 90 + applyDocPatch: async () => {}, 91 + exportBoard: async () => ({ 92 + board: { id: "board-1", name: "Test Board", createdAt: 0, updatedAt: 0 }, 93 + doc: { pages: {}, shapes: {}, bindings: {} }, 94 + order: { pageIds: [], shapeOrder: {} }, 95 + }), 96 + importBoard: async () => "board-1", 87 97 })), 88 98 }; 89 99 });
+9 -2
apps/web/src/lib/tests/persistence.desktop.test.ts
··· 1 - import { createFileData, type DesktopFileOps, type FileHandle, PageRecord, serializeDesktopFile } from "inkfinite-core"; 1 + import { 2 + type BoardMeta, 3 + createFileData, 4 + type DesktopFileOps, 5 + type FileHandle, 6 + PageRecord, 7 + serializeDesktopFile, 8 + } from "inkfinite-core"; 2 9 import { beforeEach, describe, expect, it } from "vitest"; 3 10 import { createDesktopDocRepo } from "../persistence/desktop"; 4 11 ··· 101 108 expect(Object.keys(opened.doc.pages)).toEqual([page.id]); 102 109 103 110 const boards = await repo.listBoards(); 104 - expect(boards.some((entry) => entry.id === "board-dialog")).toBe(true); 111 + expect(boards.some((entry: BoardMeta) => entry.id === "board-dialog")).toBe(true); 105 112 }); 106 113 107 114 it("renames the current board and updates the file", async () => {
+5 -3
apps/web/src/lib/tests/status.test.ts
··· 1 + /* eslint-disable @typescript-eslint/no-explicit-any */ 1 2 import type { Observable, Observer, Subscription } from "dexie"; 2 - import type { DocPatch, DocRepo, InkfiniteDB, PageRecord } from "inkfinite-core"; 3 + import type { DocPatch, InkfiniteDB, PageRecord, PersistentDocRepo } from "inkfinite-core"; 3 4 import { describe, expect, it, vi } from "vitest"; 4 5 import { createPersistenceManager, type PersistenceManagerOptions } from "../status"; 5 6 6 - function createMockRepo(): DocRepo { 7 + function createMockRepo(): PersistentDocRepo { 7 8 return { 8 9 listBoards: vi.fn(async () => []), 9 10 createBoard: vi.fn(async () => "board:mock"), 11 + openBoard: vi.fn(async () => {}), 10 12 renameBoard: vi.fn(async () => {}), 11 13 deleteBoard: vi.fn(async () => {}), 12 14 loadDoc: vi.fn(async () => ({ pages: {}, shapes: {}, bindings: {}, order: { pageIds: [], shapeOrder: {} } })), ··· 71 73 } 72 74 73 75 function createStatusTracker( 74 - overrides?: { repo?: DocRepo; options?: PersistenceManagerOptions; db?: Partial<InkfiniteDB> }, 76 + overrides?: { repo?: PersistentDocRepo; options?: PersistenceManagerOptions; db?: Partial<InkfiniteDB> }, 75 77 ) { 76 78 const repo = overrides?.repo ?? createMockRepo(); 77 79 const live = overrides?.options?.liveQueryFn ? null : createMockLiveQuery();
+6 -11
packages/core/eslint.config.js
··· 8 8 const __dirname = dirname(fileURLToPath(import.meta.url)); 9 9 10 10 export default defineConfig( 11 - { 12 - ignores: ["dist/**", "*.config.js"], 13 - }, 11 + { ignores: ["dist/**", "*.config.js"] }, 14 12 js.configs.recommended, 15 13 ...ts.configs.recommended, 16 14 { ··· 19 17 parserOptions: { tsconfigRootDir: __dirname, project: "./tsconfig.json" }, 20 18 }, 21 19 rules: { 22 - "@typescript-eslint/no-unused-vars": [ 23 - "error", 24 - { 25 - argsIgnorePattern: "^_", 26 - varsIgnorePattern: "^_", 27 - caughtErrorsIgnorePattern: "^_", 28 - }, 29 - ], 20 + "@typescript-eslint/no-unused-vars": ["error", { 21 + argsIgnorePattern: "^_", 22 + varsIgnorePattern: "^_", 23 + caughtErrorsIgnorePattern: "^_", 24 + }], 30 25 }, 31 26 }, 32 27 );
+2
packages/core/src/index.ts
··· 6 6 export * from "./history"; 7 7 export * from "./math"; 8 8 export * from "./model"; 9 + export * from "./persist/DocRepo"; 9 10 export * from "./persistence/db"; 10 11 export * from "./persistence/desktop"; 11 12 export * from "./persistence/web"; 12 13 export * from "./reactivity"; 13 14 export * from "./tools"; 15 + export * from "./ui/filebrowser"; 14 16 export * from "./ui/statusbar";
+34
packages/core/src/persist/DocRepo.ts
··· 1 + export type Timestamp = number; 2 + 3 + export type BoardMeta = { id: string; name: string; createdAt: Timestamp; updatedAt: Timestamp }; 4 + 5 + /** 6 + * Shared document repository contract used by both web and desktop persistence layers. 7 + * Provides the minimal operations required for listing and managing boards. 8 + */ 9 + export interface DocRepo { 10 + /** 11 + * Fetch all boards ordered by most recently updated first. 12 + */ 13 + listBoards(): Promise<BoardMeta[]>; 14 + 15 + /** 16 + * Create a new board and return its identifier. 17 + */ 18 + createBoard(name: string): Promise<string>; 19 + 20 + /** 21 + * Load the requested board into the active editing context. 22 + */ 23 + openBoard(boardId: string): Promise<void>; 24 + 25 + /** 26 + * Rename the board. 27 + */ 28 + renameBoard(boardId: string, name: string): Promise<void>; 29 + 30 + /** 31 + * Delete the board and all associated records. 32 + */ 33 + deleteBoard(boardId: string): Promise<void>; 34 + }
+2 -1
packages/core/src/persistence/db.ts
··· 1 1 import Dexie, { type Transaction } from "dexie"; 2 2 import { PageRecord as PageOps } from "../model"; 3 - import type { BindingRow, BoardMeta, MetaRow, MigrationRow, PageRow, ShapeRow, Timestamp } from "./web"; 3 + import type { BoardMeta, Timestamp } from "../persist/DocRepo"; 4 + import type { BindingRow, MetaRow, MigrationRow, PageRow, ShapeRow } from "./web"; 4 5 5 6 export const DB_NAME = "inkfinite"; 6 7
+2 -1
packages/core/src/persistence/desktop.ts
··· 1 1 import type { BindingRecord, Document, PageRecord, ShapeRecord } from "../model"; 2 - import type { BoardMeta, DocOrder, LoadedDoc } from "./web"; 2 + import type { BoardMeta } from "../persist/DocRepo"; 3 + import type { DocOrder, LoadedDoc } from "./web"; 3 4 4 5 /** 5 6 * Desktop file representation - combines board metadata with document content
+23 -14
packages/core/src/persistence/web.ts
··· 1 - /* eslint-disable unicorn/no-await-expression-member */ 2 1 import Dexie from "dexie"; 3 2 import { 4 3 type BindingRecord, ··· 10 9 type ShapeRecord, 11 10 ShapeRecord as ShapeOps, 12 11 } from "../model"; 13 - 14 - export type Timestamp = number; 15 - 16 - export type BoardMeta = { id: string; name: string; createdAt: Timestamp; updatedAt: Timestamp }; 12 + import type { BoardMeta, DocRepo, Timestamp } from "../persist/DocRepo"; 17 13 18 14 export type PageRow = PageRecord & { boardId: string; updatedAt: Timestamp }; 19 15 ··· 50 46 51 47 export type PersistenceSinkOptions = { debounceMs?: number }; 52 48 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>; 49 + export interface PersistentDocRepo extends DocRepo { 58 50 loadDoc(boardId: string): Promise<LoadedDoc>; 59 51 applyDocPatch(boardId: string, patch: DocPatch): Promise<void>; 60 52 exportBoard(boardId: string): Promise<BoardExport>; ··· 74 66 const shapeOrderKey = (boardId: string) => `${SHAPE_ORDER_META_PREFIX}${boardId}`; 75 67 76 68 /** 77 - * Create a Dexie-backed DocRepo used by the web app. 69 + * Create a Dexie-backed persistent DocRepo used by the web app. 78 70 */ 79 - export function createWebDocRepo(database: DexieLike, options?: WebRepoOptions): DocRepo { 71 + export function createWebDocRepo(database: DexieLike, options?: WebRepoOptions): PersistentDocRepo { 80 72 const now = () => options?.now?.() ?? Date.now(); 81 73 82 74 const boards = () => database.table<BoardMeta>("boards"); ··· 256 248 return boardId; 257 249 } 258 250 259 - return { listBoards, createBoard, renameBoard, deleteBoard, loadDoc, applyDocPatch, exportBoard, importBoard }; 251 + async function openBoard(boardId: string): Promise<void> { 252 + const exists = await boards().get(boardId); 253 + if (!exists) { 254 + throw new Error(`Board ${boardId} not found`); 255 + } 256 + } 257 + 258 + return { 259 + listBoards, 260 + createBoard, 261 + openBoard, 262 + renameBoard, 263 + deleteBoard, 264 + loadDoc, 265 + applyDocPatch, 266 + exportBoard, 267 + importBoard, 268 + }; 260 269 } 261 270 262 271 /** ··· 295 304 /** 296 305 * Batch doc patches and flush them with a debounce to cut down on Dexie writes. 297 306 */ 298 - export function createPersistenceSink(repo: DocRepo, options?: PersistenceSinkOptions): PersistenceSink { 307 + export function createPersistenceSink(repo: PersistentDocRepo, options?: PersistenceSinkOptions): PersistenceSink { 299 308 const debounceMs = options?.debounceMs ?? 200; 300 309 let pendingBoardId: string | null = null; 301 310 let pendingPatch: DocPatch | null = null;
+93
packages/core/src/ui/filebrowser.ts
··· 1 + import type { BoardMeta, DocRepo } from "../persist/DocRepo"; 2 + 3 + export type FileBrowserActions = { 4 + open(boardId: string): Promise<void>; 5 + create(name: string): Promise<string>; 6 + rename(boardId: string, name: string): Promise<void>; 7 + delete(boardId: string): Promise<void>; 8 + }; 9 + 10 + export type FileBrowserViewModel = { 11 + /** All known boards pulled from the DocRepo */ 12 + boards: BoardMeta[]; 13 + /** Current search query */ 14 + query: string; 15 + /** Boards that match the query (preserves incoming order) */ 16 + filteredBoards: BoardMeta[]; 17 + /** Selected board identifier, or null if nothing is selected */ 18 + selectedId: string | null; 19 + /** Bound repository actions */ 20 + actions: FileBrowserActions; 21 + }; 22 + 23 + export type FileBrowserOptions = { repo: DocRepo; boards?: BoardMeta[]; query?: string; selectedId?: string | null }; 24 + 25 + export const FileBrowserVM = { 26 + create(options: FileBrowserOptions): FileBrowserViewModel { 27 + const boards = [...(options.boards ?? [])]; 28 + const query = normalizeQuery(options.query); 29 + const filteredBoards = filterBoards(boards, query); 30 + const selectedId = resolveSelection(options.selectedId ?? null, filteredBoards); 31 + const actions = createActions(options.repo); 32 + return { boards, query, filteredBoards, selectedId, actions }; 33 + }, 34 + 35 + setBoards(vm: FileBrowserViewModel, boards: BoardMeta[]): FileBrowserViewModel { 36 + const cloned = [...boards]; 37 + const filteredBoards = filterBoards(cloned, vm.query); 38 + const selectedId = resolveSelection(vm.selectedId, filteredBoards); 39 + return { ...vm, boards: cloned, filteredBoards, selectedId }; 40 + }, 41 + 42 + setQuery(vm: FileBrowserViewModel, query: string): FileBrowserViewModel { 43 + const normalized = normalizeQuery(query); 44 + const filteredBoards = filterBoards(vm.boards, normalized); 45 + const selectedId = resolveSelection(vm.selectedId, filteredBoards); 46 + return { ...vm, query: normalized, filteredBoards, selectedId }; 47 + }, 48 + 49 + select(vm: FileBrowserViewModel, boardId: string | null): FileBrowserViewModel { 50 + const selectedId = resolveSelection(boardId, vm.filteredBoards); 51 + return { ...vm, selectedId }; 52 + }, 53 + }; 54 + 55 + function normalizeQuery(query?: string | null): string { 56 + return query?.trim() ?? ""; 57 + } 58 + 59 + function filterBoards(boards: BoardMeta[], query: string): BoardMeta[] { 60 + if (!query) { 61 + return [...boards]; 62 + } 63 + const needle = query.toLowerCase(); 64 + return boards.filter((board) => { 65 + const nameMatch = board.name.toLowerCase().includes(needle); 66 + const idMatch = board.id.toLowerCase().includes(needle); 67 + return nameMatch || idMatch; 68 + }); 69 + } 70 + 71 + function resolveSelection(requested: string | null, boards: BoardMeta[]): string | null { 72 + if (requested && boards.some((board) => board.id === requested)) { 73 + return requested; 74 + } 75 + return boards[0]?.id ?? null; 76 + } 77 + 78 + function createActions(repo: DocRepo): FileBrowserActions { 79 + return { 80 + async open(boardId: string) { 81 + await repo.openBoard(boardId); 82 + }, 83 + async create(name: string) { 84 + return repo.createBoard(name); 85 + }, 86 + async rename(boardId: string, name: string) { 87 + await repo.renameBoard(boardId, name); 88 + }, 89 + async delete(boardId: string) { 90 + await repo.deleteBoard(boardId); 91 + }, 92 + }; 93 + }
+68
packages/core/tests/filebrowser.test.ts
··· 1 + import { describe, expect, it, vi } from "vitest"; 2 + import type { DocRepo } from "../src/persist/DocRepo"; 3 + import { FileBrowserVM } from "../src/ui/filebrowser"; 4 + 5 + function createRepoMock(): DocRepo { 6 + return { 7 + listBoards: vi.fn(async () => []), 8 + createBoard: vi.fn(async () => "board:new"), 9 + openBoard: vi.fn(async () => {}), 10 + renameBoard: vi.fn(async () => {}), 11 + deleteBoard: vi.fn(async () => {}), 12 + }; 13 + } 14 + 15 + const boards = [{ id: "board:alpha", name: "Alpha Board", createdAt: 1, updatedAt: 10 }, { 16 + id: "board:beta", 17 + name: "Beta Board", 18 + createdAt: 2, 19 + updatedAt: 20, 20 + }, { id: "board:gamma", name: "Gamma", createdAt: 3, updatedAt: 30 }]; 21 + 22 + describe("FileBrowserVM", () => { 23 + it("filters boards by query and maintains selection", () => { 24 + const repo = createRepoMock(); 25 + const vm = FileBrowserVM.create({ repo, boards }); 26 + expect(vm.filteredBoards).toHaveLength(3); 27 + expect(vm.selectedId).toBe("board:alpha"); 28 + 29 + const betaOnly = FileBrowserVM.setQuery(vm, "beta"); 30 + expect(betaOnly.filteredBoards).toHaveLength(1); 31 + expect(betaOnly.filteredBoards[0].id).toBe("board:beta"); 32 + expect(betaOnly.selectedId).toBe("board:beta"); 33 + }); 34 + 35 + it("updates boards list immutably", () => { 36 + const repo = createRepoMock(); 37 + const vm = FileBrowserVM.create({ repo, boards: boards.slice(0, 2) }); 38 + const next = FileBrowserVM.setBoards(vm, boards); 39 + expect(next.boards).toHaveLength(3); 40 + expect(next.filteredBoards).toHaveLength(3); 41 + expect(next).not.toBe(vm); 42 + }); 43 + 44 + it("selects the first available board when selection is invalid", () => { 45 + const repo = createRepoMock(); 46 + const vm = FileBrowserVM.create({ repo, boards }); 47 + const betaOnly = FileBrowserVM.setQuery(vm, "beta"); 48 + const updated = FileBrowserVM.select(betaOnly, "missing"); 49 + expect(updated.selectedId).toBe("board:beta"); 50 + }); 51 + 52 + it("invokes repo actions", async () => { 53 + const repo = createRepoMock(); 54 + const vm = FileBrowserVM.create({ repo, boards }); 55 + 56 + await vm.actions.open("board:alpha"); 57 + expect(repo.openBoard).toHaveBeenCalledWith("board:alpha"); 58 + 59 + await vm.actions.create("Untitled"); 60 + expect(repo.createBoard).toHaveBeenCalledWith("Untitled"); 61 + 62 + await vm.actions.rename("board:alpha", "Renamed"); 63 + expect(repo.renameBoard).toHaveBeenCalledWith("board:alpha", "Renamed"); 64 + 65 + await vm.actions.delete("board:alpha"); 66 + expect(repo.deleteBoard).toHaveBeenCalledWith("board:alpha"); 67 + }); 68 + });
+1 -1
packages/core/tsconfig.json
··· 16 16 "verbatimModuleSyntax": true, 17 17 "skipLibCheck": true 18 18 }, 19 - "include": ["src"] 19 + "include": ["src", "tests"] 20 20 }