web based infinite canvas
2
fork

Configure Feed

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

feat: add FileBrowser component for managing boards

+2060 -836
+4 -5
TODO.txt
··· 198 198 - open / create / rename / delete 199 199 200 200 Inspector drawer (selected board): 201 - [ ] Show "Storage: IndexedDB (Dexie)" 201 + [ ] Show "Storage" 202 202 [ ] Show schema info: 203 - - declared schema version (your constant) 204 - - installed schema version (best-effort display) 203 + - declared schema version 204 + - installed schema version 205 205 [ ] Show board-level stats (computed live): 206 - - row counts: pages/shapes/bindings for this board (Option A) 207 - OR doc size bytes for docs row (Option B) 206 + - row counts: pages/shapes/bindings for this board & doc size bytes for docs row 208 207 - last updatedAt 209 208 [ ] Show migration info: 210 209 - list applied migrations from migrations table (id + appliedAt)
+44 -61
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 FileBrowser from '$lib/filebrowser/FileBrowser.svelte'; 6 7 import { createCanvasController } from './canvas-store.svelte.ts'; 7 8 8 9 let canvasEl = $state<HTMLCanvasElement | null>(null); 9 10 let textEditorEl = $state<HTMLTextAreaElement | null>(null); 10 11 let historyViewerOpen = $state(false); 11 12 12 - const controller = createCanvasController({ 13 + const c = createCanvasController({ 13 14 setHistoryViewerOpen(value: boolean) { 14 15 historyViewerOpen = value; 15 16 } 16 17 }); 17 18 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; 45 - 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()); 19 + let platform = $derived(c.platform()); 20 + let textEditorCurrent = $derived(c.textEditor.current); 21 + let persistenceStatusStore = $derived(c.persistenceStatusStore()); 52 22 53 23 $effect(() => { 54 - setCanvasRef(canvasEl); 55 - return () => setCanvasRef(null); 24 + c.setCanvasRef(canvasEl); 25 + return () => c.setCanvasRef(null); 56 26 }); 57 27 58 28 $effect(() => { 59 - setTextEditorElRef(textEditorEl); 60 - return () => setTextEditorElRef(null); 29 + c.textEditor.setRef(textEditorEl); 30 + return () => c.textEditor.setRef(null); 61 31 }); 62 32 </script> 63 33 ··· 65 35 <TitleBar 66 36 {platform} 67 37 desktop={{ 68 - fileName: desktopFileName, 69 - recentBoards: desktopBoards, 70 - onOpen: handleDesktopOpen, 71 - onNew: handleDesktopNewBoard, 72 - onSaveAs: handleDesktopSaveAs, 73 - onSelectBoard: handleDesktopRecentSelect 74 - }} /> 38 + fileName: c.desktop.fileName, 39 + recentBoards: c.desktop.boards, 40 + onOpen: c.desktop.handleOpen, 41 + onNew: c.desktop.handleNew, 42 + onSaveAs: () => c.desktop.handleSaveAs(null), 43 + onSelectBoard: c.desktop.handleRecentSelect 44 + }} 45 + onOpenBrowser={c.fileBrowser.handleOpen} /> 75 46 <Toolbar 76 - currentTool={currentToolId} 77 - onToolChange={handleToolChange} 78 - onHistoryClick={handleHistoryClick} 79 - {store} 80 - {getViewport} 47 + currentTool={c.tools.currentToolId} 48 + onToolChange={c.tools.handleChange} 49 + onHistoryClick={c.history.handleClick} 50 + store={c.store} 51 + getViewport={c.getViewport} 81 52 canvas={canvasEl ?? undefined} /> 82 53 <div class="canvas-container"> 83 54 <canvas 84 55 bind:this={canvasEl} 85 - ondblclick={handleCanvasDoubleClick} 86 - onpointerleave={handlePointerLeave}></canvas> 87 - {#if textEditor} 88 - {@const layout = getTextEditorLayout()} 56 + ondblclick={c.handleCanvasDoubleClick} 57 + onpointerleave={c.handlePointerLeave}></canvas> 58 + {#if textEditorCurrent} 59 + {@const layout = c.textEditor.getLayout()} 89 60 {#if layout} 90 61 <textarea 91 62 bind:this={textEditorEl} 92 63 class="canvas-text-editor" 93 64 style={`left:${layout.left}px;top:${layout.top}px;width:${layout.width}px;height:${layout.height}px;font-size:${layout.fontSize}px;`} 94 - value={textEditor.value} 95 - oninput={handleTextEditorInput} 96 - onkeydown={handleTextEditorKeyDown} 97 - onblur={handleTextEditorBlur} 65 + value={textEditorCurrent.value} 66 + oninput={c.textEditor.handleInput} 67 + onkeydown={c.textEditor.handleKeyDown} 68 + onblur={c.textEditor.handleBlur} 98 69 spellcheck="false"></textarea> 99 70 {/if} 100 71 {/if} 101 72 </div> 102 - <HistoryViewer {store} bind:open={historyViewerOpen} onClose={handleHistoryClose} /> 103 - <StatusBar {store} cursor={cursorStore} persistence={persistenceStatusStore} snap={snapStore} /> 73 + <HistoryViewer store={c.store} bind:open={historyViewerOpen} onClose={c.history.handleClose} /> 74 + <StatusBar 75 + store={c.store} 76 + cursor={c.cursorStore} 77 + persistence={persistenceStatusStore} 78 + snap={c.snapStore} /> 79 + {#if c.fileBrowser.vm} 80 + <FileBrowser 81 + bind:vm={c.fileBrowser.vm} 82 + bind:open={c.fileBrowser.open} 83 + onUpdate={c.fileBrowser.handleUpdate} 84 + fetchInspectorData={c.fileBrowser.fetchInspectorData} 85 + onClose={c.fileBrowser.handleClose} /> 86 + {/if} 104 87 </div> 105 88 106 89 <style>
+91
apps/web/src/lib/canvas/canvas-helpers.ts
··· 1 + import type { Action, CommandKind, EditorState } from "inkfinite-core"; 2 + 3 + export const handleCursorMap: Record<string, string> = { 4 + n: "ns-resize", 5 + s: "ns-resize", 6 + e: "ew-resize", 7 + w: "ew-resize", 8 + ne: "nesw-resize", 9 + sw: "nesw-resize", 10 + nw: "nwse-resize", 11 + se: "nwse-resize", 12 + rotate: "alias", 13 + "line-start": "crosshair", 14 + "line-end": "crosshair", 15 + }; 16 + 17 + export function computeCursor( 18 + textEditing: boolean, 19 + pan: { isPanning: boolean; spaceHeld: boolean }, 20 + handle: { hover: string | null; active: string | null }, 21 + pointerDown: boolean, 22 + ): string { 23 + if (textEditing) { 24 + return "text"; 25 + } 26 + if (pan.isPanning) { 27 + return "grabbing"; 28 + } 29 + if (pan.spaceHeld) { 30 + return "grab"; 31 + } 32 + const targetHandle = handle.active ?? handle.hover; 33 + if (targetHandle) { 34 + return handleCursorMap[targetHandle] ?? "default"; 35 + } 36 + if (pointerDown) { 37 + return "grabbing"; 38 + } 39 + return "default"; 40 + } 41 + 42 + export function statesEqual(a: EditorState, b: EditorState): boolean { 43 + return a.doc === b.doc && a.camera === b.camera && a.ui === b.ui; 44 + } 45 + 46 + export function getCommandKind(before: EditorState, after: EditorState): CommandKind { 47 + if (before.doc !== after.doc) { 48 + return "doc"; 49 + } 50 + if (before.camera !== after.camera) { 51 + return "camera"; 52 + } 53 + return "ui"; 54 + } 55 + 56 + export function describeAction(action: Action, kind: CommandKind): string { 57 + switch (action.type) { 58 + case "key-down": { 59 + if (action.key.startsWith("Arrow")) { 60 + return "Nudge"; 61 + } 62 + const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; 63 + if (primaryModifier && (action.key === "d" || action.key === "D")) { 64 + return "Duplicate"; 65 + } 66 + if (primaryModifier && action.key === "]") { 67 + return "Bring Forward"; 68 + } 69 + if (primaryModifier && action.key === "[") { 70 + return "Send Backward"; 71 + } 72 + return "Key down"; 73 + } 74 + case "pointer-down": 75 + return "Pointer down"; 76 + case "pointer-move": 77 + return "Pointer move"; 78 + case "pointer-up": 79 + return "Pointer up"; 80 + case "wheel": 81 + return "Wheel"; 82 + case "key-up": 83 + return "Key up"; 84 + default: 85 + return kind === "doc" ? "Edit" : kind === "camera" ? "Camera change" : "UI change"; 86 + } 87 + } 88 + 89 + export function isUserCancelled(error: unknown) { 90 + return error instanceof Error && /cancel/i.test(error.message); 91 + }
+295 -754
apps/web/src/lib/canvas/canvas-store.svelte.ts
··· 11 11 import { 12 12 type Action, 13 13 ArrowTool, 14 - type BoardMeta, 15 14 Camera, 16 - type CommandKind, 15 + createId, 17 16 createToolMap, 18 17 CursorStore, 19 18 diffDoc, 20 19 EditorState, 21 20 EllipseTool, 22 21 getShapesOnCurrentPage, 22 + InkfiniteDB, 23 23 LineTool, 24 24 type LoadedDoc, 25 25 type PersistenceSink, ··· 31 31 ShapeRecord, 32 32 SnapshotCommand, 33 33 Store, 34 - switchTool, 35 34 TextTool, 36 - type ToolId, 37 35 type Viewport, 38 36 } from "inkfinite-core"; 39 37 import { createRenderer, type Renderer } from "inkfinite-renderer"; 40 38 import { onDestroy, onMount } from "svelte"; 39 + import { SvelteSet } from "svelte/reactivity"; 40 + import { computeCursor, describeAction, getCommandKind, statesEqual } from "./canvas-helpers"; 41 + import { DesktopFileController } from "./controllers/desktop-file-controller.svelte"; 42 + import { FileBrowserController } from "./controllers/filebrowser-controller.svelte"; 43 + import { HistoryController } from "./controllers/history-controller"; 44 + import { TextEditorController } from "./controllers/texteditor-controller.svelte"; 45 + import { ToolController } from "./controllers/tool-controller.svelte"; 46 + import { HandleState } from "./store/handle-state.svelte"; 47 + import { PanState } from "./store/pan-state.svelte"; 48 + import { PointerState } from "./store/pointer-state.svelte"; 41 49 42 50 export type CanvasControllerBindings = { setHistoryViewerOpen(value: boolean): void }; 43 51 ··· 56 64 let persistenceStatusStore = $state<StatusStore>(fallbackStatusStore); 57 65 let activeBoardId: string | null = null; 58 66 let desktopRepo: DesktopDocRepo | null = null; 59 - let desktopBoards = $state<BoardMeta[]>([]); 60 - let desktopFileName = $state<string | null>(null); 61 67 let removeBeforeUnload: (() => void) | null = null; 68 + let webDb: InkfiniteDB | null = null; 69 + let canvas = $state<HTMLCanvasElement | null>(null); 70 + 71 + const pointerState = new PointerState(); 72 + const handleState = new HandleState(); 73 + const panState = new PanState(); 62 74 63 75 const store = new Store(undefined, { 64 76 onHistoryEvent: (event) => { ··· 69 81 sink.enqueueDocPatch(activeBoardId, patch); 70 82 }, 71 83 }); 84 + 72 85 const cursorStore = new CursorStore(); 73 86 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; 87 + 88 + function getViewport(): Viewport { 89 + if (canvas) { 90 + const rect = canvas.getBoundingClientRect(); 91 + return { width: rect.width || 1, height: rect.height || 1 }; 92 + } 93 + if (typeof window !== "undefined") { 94 + return { width: window.innerWidth || 1, height: window.innerHeight || 1 }; 95 + } 96 + return { width: 1, height: 1 }; 97 + } 85 98 86 - function setCanvasRef(node: HTMLCanvasElement | null) { 87 - canvas = node; 99 + function refreshCursor() { 100 + if (!canvas) { 101 + return; 102 + } 103 + const cursor = computeCursor( 104 + textEditor.isEditing, 105 + { isPanning: panState.isPanning, spaceHeld: panState.spaceHeld }, 106 + { hover: handleState.hover, active: handleState.active }, 107 + pointerState.isPointerDown, 108 + ); 109 + canvas.style.cursor = cursor; 88 110 } 89 111 90 - function setTextEditorElRef(node: HTMLTextAreaElement | null) { 91 - textEditorEl = node; 112 + function setActiveBoardId(boardId: string) { 113 + activeBoardId = boardId; 114 + persistenceManager?.setActiveBoard(boardId); 92 115 } 93 116 94 117 function applyLoadedDoc(doc: LoadedDoc) { ··· 98 121 doc: { pages: doc.pages, shapes: doc.shapes, bindings: doc.bindings }, 99 122 ui: { ...state.ui, currentPageId: firstPageId, selectionIds: [] }, 100 123 })); 101 - initializeSelection(firstPageId, doc); 102 124 } 103 125 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 - } 126 + const selectTool = new SelectTool(); 127 + const rectTool = new RectTool(); 128 + const ellipseTool = new EllipseTool(); 129 + const lineTool = new LineTool(); 130 + const arrowTool = new ArrowTool(); 131 + const textTool = new TextTool(); 132 + const tools = createToolMap([selectTool, rectTool, ellipseTool, lineTool, arrowTool, textTool]); 137 133 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 - } 134 + const textEditor = new TextEditorController(store, getViewport, refreshCursor); 135 + const toolController = new ToolController(store, tools); 136 + const history = new HistoryController(bindings); 137 + const desktop = new DesktopFileController(() => repo, () => desktopRepo, (boardId, doc) => { 138 + setActiveBoardId(boardId); 139 + applyLoadedDoc(doc); 140 + }); 141 + const fileBrowser = new FileBrowserController(() => repo); 195 142 196 143 function setHandleHover(handle: string | null) { 197 144 if (handleState.hover === handle) { ··· 206 153 refreshCursor(); 207 154 } 208 155 209 - function getTextEditorLayout() { 210 - if (!textEditor) { 211 - return null; 156 + function applySnapping(action: Action): Action { 157 + if (!("world" in action) || !action.world) { 158 + return action; 212 159 } 213 - const state = store.getState(); 214 - const shape = state.doc.shapes[textEditor.shapeId]; 215 - if (!shape || shape.type !== "text") { 216 - return null; 160 + const snap = snapStore.get(); 161 + if (!snap.snapEnabled || !snap.gridEnabled) { 162 + return action; 217 163 } 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 - }; 164 + const gridSize = snap.gridSize; 165 + const snappedX = Math.round(action.world.x / gridSize) * gridSize; 166 + const snappedY = Math.round(action.world.y / gridSize) * gridSize; 167 + return { ...action, world: { x: snappedX, y: snappedY } }; 229 168 } 230 169 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; 170 + let pendingCommandStart: EditorState | null = null; 171 + 172 + function duplicateSelection(state: EditorState): EditorState | null { 173 + const selectedIds = state.ui.selectionIds; 174 + if (selectedIds.length === 0) { 175 + return null; 236 176 } 237 - textEditor = { shapeId, value: shape.props.text }; 238 - refreshCursor(); 239 - queueMicrotask(() => { 240 - textEditorEl?.focus(); 241 - textEditorEl?.select(); 242 - }); 243 - } 177 + const shapes = { ...state.doc.shapes }; 178 + const pages = { ...state.doc.pages }; 179 + const nextSelection: string[] = []; 244 180 245 - function commitTextEditing() { 246 - if (!textEditor) { 247 - return; 181 + for (const id of selectedIds) { 182 + const shape = shapes[id]; 183 + if (!shape) continue; 184 + const cloned = ShapeRecord.clone(shape); 185 + const newId = createId("shape"); 186 + const shifted = { ...cloned, id: newId, x: cloned.x + 12, y: cloned.y + 12 }; 187 + shapes[newId] = shifted; 188 + const originalPage = state.doc.pages[shape.pageId]; 189 + if (!originalPage) continue; 190 + const existingPage = pages[shape.pageId]; 191 + const pageClone = !existingPage || existingPage === originalPage 192 + ? { ...originalPage, shapeIds: [...originalPage.shapeIds] } 193 + : { ...existingPage, shapeIds: [...existingPage.shapeIds] }; 194 + pageClone.shapeIds.push(newId); 195 + pages[shape.pageId] = pageClone; 196 + nextSelection.push(newId); 248 197 } 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; 198 + 199 + if (!nextSelection.length) { 200 + return null; 256 201 } 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 202 265 - function cancelTextEditing() { 266 - textEditor = null; 267 - refreshCursor(); 203 + return { ...state, doc: { ...state.doc, shapes, pages }, ui: { ...state.ui, selectionIds: nextSelection } }; 268 204 } 269 205 270 - function handleCanvasDoubleClick(event: MouseEvent) { 271 - if (!canvas) { 272 - return; 206 + function reorderSelection(state: EditorState, direction: "forward" | "backward"): EditorState | null { 207 + const pageId = state.ui.currentPageId; 208 + if (!pageId) return null; 209 + const page = state.doc.pages[pageId]; 210 + if (!page) return null; 211 + const selection = new SvelteSet(state.ui.selectionIds); 212 + if (selection.size === 0) { 213 + return null; 273 214 } 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 - } 215 + 216 + const shapeIds = [...page.shapeIds]; 217 + let changed = false; 282 218 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; 219 + if (direction === "forward") { 220 + for (let index = shapeIds.length - 2; index >= 0; index--) { 221 + const id = shapeIds[index]; 222 + if (!selection.has(id)) continue; 223 + const nextId = shapeIds[index + 1]; 224 + if (nextId && !selection.has(nextId)) { 225 + shapeIds[index] = nextId; 226 + shapeIds[index + 1] = id; 227 + changed = true; 228 + } 289 229 } 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; 230 + } else { 231 + for (let index = 1; index < shapeIds.length; index++) { 232 + const id = shapeIds[index]; 233 + if (!selection.has(id)) continue; 234 + const prevId = shapeIds[index - 1]; 235 + if (prevId && !selection.has(prevId)) { 236 + shapeIds[index] = prevId; 237 + shapeIds[index - 1] = id; 238 + changed = true; 239 + } 293 240 } 294 241 } 295 - return null; 296 - } 297 242 298 - function handleTextEditorInput(event: Event) { 299 - if (!textEditor) { 300 - return; 243 + if (!changed) { 244 + return null; 301 245 } 302 - const target = event.currentTarget as HTMLTextAreaElement; 303 - textEditor = { ...textEditor, value: target.value }; 304 - } 305 246 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 - } 247 + return { ...state, doc: { ...state.doc, pages: { ...state.doc.pages, [pageId]: { ...page, shapeIds } } } }; 316 248 } 317 249 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 - } 250 + function handleKeyboardShortcuts(state: EditorState, action: Action): EditorState | null { 251 + if (action.type !== "key-down") { 252 + return null; 377 253 } 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; 254 + const selectionIds = state.ui.selectionIds; 255 + if (selectionIds.length === 0) { 256 + return null; 399 257 } 400 258 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 - ]; 259 + if (action.key.startsWith("Arrow")) { 260 + const step = action.modifiers.shift ? 10 : 1; 261 + let dx = 0; 262 + let dy = 0; 263 + switch (action.key) { 264 + case "ArrowLeft": 265 + dx = -step; 266 + break; 267 + case "ArrowRight": 268 + dx = step; 269 + break; 270 + case "ArrowUp": 271 + dy = -step; 272 + break; 273 + case "ArrowDown": 274 + dy = step; 275 + break; 415 276 } 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] }; 277 + if (dx !== 0 || dy !== 0) { 278 + const shapes = { ...state.doc.shapes }; 279 + let changed = false; 280 + for (const id of selectionIds) { 281 + const shape = shapes[id]; 282 + if (!shape) continue; 283 + shapes[id] = { ...shape, x: shape.x + dx, y: shape.y + dy }; 284 + changed = true; 285 + } 286 + if (!changed) { 287 + return null; 462 288 } 289 + return { ...state, doc: { ...state.doc, shapes } }; 463 290 } 464 291 } 465 292 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; 293 + const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; 294 + if (primaryModifier && (action.key === "d" || action.key === "D")) { 295 + return duplicateSelection(state); 483 296 } 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; 297 + if (primaryModifier && action.key === "]") { 298 + return reorderSelection(state, "forward"); 502 299 } 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 - } 300 + if (primaryModifier && action.key === "[") { 301 + return reorderSelection(state, "backward"); 512 302 } 513 303 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(); 304 + return null; 518 305 } 519 306 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)); 307 + function commitSnapshot(beforeState: EditorState, afterState: EditorState, action: Action) { 308 + const kind = getCommandKind(beforeState, afterState); 309 + const name = describeAction(action, kind); 310 + const command = new SnapshotCommand(name, kind, EditorState.clone(beforeState), EditorState.clone(afterState)); 531 311 store.executeCommand(command); 532 312 syncHandleState(); 533 313 } 534 314 535 315 function handleAction(action: Action) { 536 - if (textEditor && (action.type === "pointer-down" || action.type === "pointer-up")) { 537 - commitTextEditing(); 316 + if (textEditor.isEditing && (action.type === "pointer-down" || action.type === "pointer-up")) { 317 + textEditor.commit(); 538 318 } 539 319 540 320 if (action.type === "pointer-move" && "world" in action && !panState.isPanning && !panState.spaceHeld) { ··· 542 322 setHandleHover(hover); 543 323 } 544 324 545 - if (action.type === "pointer-move" && (panState.isPanning || panState.spaceHeld)) { 546 - setHandleHover(null); 547 - } 548 - 549 - if (action.type === "key-down" && action.key === " ") { 325 + if (action.type === "key-down" && action.key === " " && !action.repeat) { 550 326 panState.spaceHeld = true; 551 - setHandleHover(null); 552 327 refreshCursor(); 553 328 return; 554 329 } ··· 560 335 return; 561 336 } 562 337 563 - if (action.type === "pointer-down" && action.button === 0 && panState.spaceHeld) { 338 + if (action.type === "pointer-down" && (action.button === 1 || (action.button === 0 && panState.spaceHeld))) { 564 339 panState.isPanning = true; 565 - panState.lastScreen = { x: action.screen.x, y: action.screen.y }; 340 + panState.lastScreen = action.screen; 566 341 refreshCursor(); 567 342 return; 568 343 } 569 344 570 345 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(); 346 + const delta = { x: action.screen.x - panState.lastScreen.x, y: action.screen.y - panState.lastScreen.y }; 347 + panState.lastScreen = action.screen; 348 + store.setState((state) => ({ ...state, camera: Camera.pan(state.camera, delta) })); 578 349 return; 579 350 } 580 351 581 - if (action.type === "pointer-up" && action.button === 0 && panState.isPanning) { 352 + if (action.type === "pointer-up" && panState.isPanning) { 582 353 panState.isPanning = false; 583 354 refreshCursor(); 584 355 return; ··· 590 361 591 362 const actionWithSnap = applySnapping(action); 592 363 if ("world" in actionWithSnap) { 593 - pointerState.snappedWorld = actionWithSnap.world ?? null; 364 + pointerState.snappedWorld = actionWithSnap.world; 594 365 } 595 366 596 367 if (actionWithSnap.type === "pointer-down" && actionWithSnap.button === 0) { 597 368 pointerState.isPointerDown = true; 598 369 setHandleHover(null); 599 - refreshCursor(); 600 370 pendingCommandStart = EditorState.clone(store.getState()); 601 - const changed = applyImmediateAction(actionWithSnap); 602 - if (!changed) { 603 - pendingCommandStart = null; 604 - } 605 - return; 606 371 } 607 372 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 373 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 - } 374 + const shortcutResult = handleKeyboardShortcuts(before, actionWithSnap); 375 + const after = shortcutResult ?? routeAction(before, actionWithSnap, tools); 697 376 698 - function statesEqual(a: EditorState, b: EditorState): boolean { 699 - return a.doc === b.doc && a.camera === b.camera && a.ui === b.ui; 700 - } 377 + if (!statesEqual(before, after)) { 378 + const kind = getCommandKind(before, after); 379 + const shouldCommitImmediately = !pendingCommandStart && kind === "doc"; 701 380 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"; 381 + if (shouldCommitImmediately) { 382 + commitSnapshot(before, after, actionWithSnap); 383 + } else { 384 + store.setState(() => after); 385 + syncHandleState(); 386 + } 708 387 } 709 - return "ui"; 710 - } 711 388 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 - } 389 + if (actionWithSnap.type === "pointer-up" && actionWithSnap.button === 0) { 390 + pointerState.isPointerDown = false; 391 + refreshCursor(); 730 392 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; 393 + if (pendingCommandStart && !statesEqual(pendingCommandStart, after)) { 394 + commitSnapshot(pendingCommandStart, after, actionWithSnap); 744 395 } 745 - console.error("Failed to open board", error); 396 + pendingCommandStart = null; 746 397 } 747 398 } 748 399 749 - async function handleDesktopNewBoard() { 750 - if (!repo) { 400 + function handleCanvasDoubleClick(event: MouseEvent) { 401 + if (!canvas) { 751 402 return; 752 403 } 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 - } 404 + const rect = canvas.getBoundingClientRect(); 405 + const screen = { x: event.clientX - rect.left, y: event.clientY - rect.top }; 406 + const world = Camera.screenToWorld(store.getState().camera, screen, getViewport()); 767 407 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; 408 + const shapes = getShapesOnCurrentPage(store.getState()); 409 + for (let index = shapes.length - 1; index >= 0; index--) { 410 + const shape = shapes[index]; 411 + if (shape.type === "text") { 412 + const bounds = shapeBounds(shape); 413 + if (world.x >= bounds.min.x && world.x <= bounds.max.x && world.y >= bounds.min.y && world.y <= bounds.max.y) { 414 + textEditor.start(shape.id); 415 + return; 416 + } 783 417 } 784 - console.error("Failed to save board", error); 785 418 } 786 419 } 787 420 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 - } 421 + function handlePointerLeave() { 422 + setHandleHover(null); 801 423 } 802 424 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 }; 425 + function setCanvasRef(node: HTMLCanvasElement | null) { 426 + canvas = node; 814 427 } 815 428 816 429 let renderer: Renderer | null = null; 817 430 let inputAdapter: InputAdapter | null = null; 431 + let canvasInitialized = false; 818 432 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 - } 433 + // Initialize canvas-dependent systems when canvas becomes available 434 + $effect(() => { 435 + if (!canvas || canvasInitialized) return; 829 436 830 - onMount(() => { 831 - let disposed = false; 437 + canvasInitialized = true; 832 438 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 - } 439 + renderer = createRenderer(canvas, store, { 440 + snapProvider: { get: () => snapStore.get() }, 441 + cursorProvider: { get: () => cursorStore.getState() }, 442 + pointerStateProvider: { 443 + get: () => ({ isPointerDown: pointerState.isPointerDown, snappedWorld: pointerState.snappedWorld }), 444 + }, 445 + handleProvider: { get: () => handleState.getSnapshot() }, 446 + }); 859 447 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 - }; 448 + const unsubStore = store.subscribe(() => renderer?.markDirty()); 449 + const unsubSnap = snapStore.subscribe(() => renderer?.markDirty()); 898 450 899 - await hydrate(); 900 - if (disposed) { 901 - return; 902 - } 451 + inputAdapter = createInputAdapter({ 452 + canvas, 453 + getCamera: () => store.getState().camera, 454 + getViewport, 455 + onAction: handleAction, 456 + onCursorUpdate: (world, screen) => cursorStore.updateCursor(world, screen), 457 + }); 903 458 904 - function getCamera() { 905 - return store.getState().camera; 906 - } 459 + return () => { 460 + unsubStore(); 461 + unsubSnap(); 462 + inputAdapter?.dispose(); 463 + inputAdapter = null; 464 + renderer?.dispose(); 465 + renderer = null; 466 + canvasInitialized = false; 467 + }; 468 + }); 907 469 908 - const currentCanvas = canvas; 909 - if (!currentCanvas) { 910 - return; 470 + onMount(async () => { 471 + if (platform === "desktop") { 472 + const desktopPlatformRepo = await createPlatformRepo(); 473 + if (desktopPlatformRepo && "type" in desktopPlatformRepo && desktopPlatformRepo.type === "desktop") { 474 + desktopRepo = desktopPlatformRepo.repo as DesktopDocRepo; 475 + repo = desktopRepo; 476 + await desktop.refreshBoards(); 911 477 } 478 + } else { 479 + webDb = new InkfiniteDB(); 480 + const { createWebDocRepo, createPersistenceSink } = await import("inkfinite-core"); 481 + const { liveQuery } = await import("dexie"); 482 + repo = createWebDocRepo(webDb); 483 + sink = createPersistenceSink(repo); 484 + persistenceManager = createPersistenceManager(webDb, repo, { liveQueryFn: liveQuery }); 485 + persistenceStatusStore = persistenceManager.status; 912 486 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); 487 + const boards = await repo.listBoards(); 488 + if (boards.length > 0) { 489 + const boardId = boards[0].id; 490 + const doc = await repo.loadDoc(boardId); 491 + setActiveBoardId(boardId); 492 + applyLoadedDoc(doc); 936 493 } 937 - }; 938 494 939 - void initialize(); 940 - 941 - return () => { 942 - disposed = true; 943 - }; 495 + removeBeforeUnload = () => { 496 + window.removeEventListener("beforeunload", handleBeforeUnload); 497 + }; 498 + window.addEventListener("beforeunload", handleBeforeUnload); 499 + } 944 500 }); 945 501 502 + function handleBeforeUnload() { 503 + sink?.flush(); 504 + } 505 + 946 506 onDestroy(() => { 947 - removeBeforeUnload?.(); 948 - removeBeforeUnload = null; 949 507 renderer?.dispose(); 950 508 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 509 persistenceManager?.dispose(); 961 - persistenceManager = null; 510 + removeBeforeUnload?.(); 962 511 fallbackStatusStore.update(() => ({ backend: "indexeddb", state: "saved", pendingWrites: 0 })); 963 512 persistenceStatusStore = fallbackStatusStore; 964 513 }); 965 514 966 515 return { 967 516 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, 517 + desktop, 518 + fileBrowser: { 519 + ...fileBrowser, 520 + fetchInspectorData: (boardId: string) => fileBrowser.fetchInspectorData(boardId, webDb), 521 + }, 522 + tools: toolController, 523 + history, 524 + textEditor, 978 525 store, 979 526 getViewport, 980 527 handleCanvasDoubleClick, 981 528 handlePointerLeave, 982 - textEditor: () => textEditor, 983 - getTextEditorLayout, 984 - handleTextEditorInput, 985 - handleTextEditorKeyDown, 986 - handleTextEditorBlur, 987 529 cursorStore, 988 530 persistenceStatusStore: () => persistenceStatusStore, 989 531 snapStore, 990 532 setCanvasRef, 991 - setTextEditorElRef, 992 533 }; 993 534 }
+117
apps/web/src/lib/canvas/controllers/desktop-file-controller.svelte.ts
··· 1 + import type { DesktopDocRepo } from "$lib/persistence/desktop"; 2 + import type { BoardMeta, LoadedDoc, PersistentDocRepo } from "inkfinite-core"; 3 + 4 + function isUserCancelled(error: unknown) { 5 + return error instanceof Error && /cancel/i.test(error.message); 6 + } 7 + 8 + export class DesktopFileController { 9 + boards = $state<BoardMeta[]>([]); 10 + fileName = $state<string | null>(null); 11 + 12 + constructor( 13 + private getRepo: () => PersistentDocRepo | null, 14 + private getDesktopRepo: () => DesktopDocRepo | null, 15 + private onLoadDoc: (boardId: string, doc: LoadedDoc) => void, 16 + ) {} 17 + 18 + private updateFileState = () => { 19 + const desktopRepo = this.getDesktopRepo(); 20 + if (!desktopRepo) { 21 + this.fileName = null; 22 + return; 23 + } 24 + const handle = desktopRepo.getCurrentFile(); 25 + this.fileName = handle?.name ?? null; 26 + }; 27 + 28 + refreshBoards = async (): Promise<BoardMeta[]> => { 29 + const desktopRepo = this.getDesktopRepo(); 30 + if (!desktopRepo) { 31 + this.boards = []; 32 + return []; 33 + } 34 + try { 35 + const boards = await desktopRepo.listBoards(); 36 + this.boards = boards; 37 + return boards; 38 + } catch (error) { 39 + console.error("Failed to list boards", error); 40 + this.boards = []; 41 + return []; 42 + } 43 + }; 44 + 45 + handleOpen = async () => { 46 + const desktopRepo = this.getDesktopRepo(); 47 + const repo = this.getRepo(); 48 + if (!desktopRepo || !repo) { 49 + return; 50 + } 51 + try { 52 + const opened = await desktopRepo.openFromDialog(); 53 + this.onLoadDoc(opened.boardId, opened.doc); 54 + this.updateFileState(); 55 + await this.refreshBoards(); 56 + } catch (error) { 57 + if (isUserCancelled(error)) { 58 + return; 59 + } 60 + console.error("Failed to open board", error); 61 + } 62 + }; 63 + 64 + handleNew = async () => { 65 + const repo = this.getRepo(); 66 + if (!repo) { 67 + return; 68 + } 69 + try { 70 + const boardId = await repo.createBoard("Untitled"); 71 + const loaded = await repo.loadDoc(boardId); 72 + this.onLoadDoc(boardId, loaded); 73 + this.updateFileState(); 74 + await this.refreshBoards(); 75 + } catch (error) { 76 + if (isUserCancelled(error)) { 77 + return; 78 + } 79 + console.error("Failed to create board", error); 80 + } 81 + }; 82 + 83 + handleSaveAs = async (activeBoardId: string | null) => { 84 + const repo = this.getRepo(); 85 + if (!repo || !activeBoardId) { 86 + return; 87 + } 88 + try { 89 + const snapshot = await repo.exportBoard(activeBoardId); 90 + const newBoardId = await repo.importBoard(snapshot); 91 + const loaded = await repo.loadDoc(newBoardId); 92 + this.onLoadDoc(newBoardId, loaded); 93 + this.updateFileState(); 94 + await this.refreshBoards(); 95 + } catch (error) { 96 + if (isUserCancelled(error)) { 97 + return; 98 + } 99 + console.error("Failed to save board", error); 100 + } 101 + }; 102 + 103 + handleRecentSelect = async (boardId: string) => { 104 + const repo = this.getRepo(); 105 + if (!repo) { 106 + return; 107 + } 108 + try { 109 + const loaded = await repo.loadDoc(boardId); 110 + this.onLoadDoc(boardId, loaded); 111 + this.updateFileState(); 112 + await this.refreshBoards(); 113 + } catch (error) { 114 + console.error("Failed to load board", error); 115 + } 116 + }; 117 + }
+56
apps/web/src/lib/canvas/controllers/filebrowser-controller.svelte.ts
··· 1 + import { 2 + FileBrowserVM, 3 + getBoardInspectorData, 4 + InkfiniteDB, 5 + KNOWN_MIGRATION_IDS, 6 + type BoardInspectorData, 7 + type FileBrowserViewModel, 8 + type PersistentDocRepo, 9 + } from "inkfinite-core"; 10 + 11 + export class FileBrowserController { 12 + open = $state(false); 13 + vm = $state<FileBrowserViewModel | null>(null); 14 + 15 + constructor( 16 + private getRepo: () => PersistentDocRepo | null, 17 + ) {} 18 + 19 + handleOpen = () => { 20 + this.open = true; 21 + void this.refreshBoards(); 22 + }; 23 + 24 + handleClose = () => { 25 + this.open = false; 26 + }; 27 + 28 + handleUpdate = (vm: FileBrowserViewModel) => { 29 + this.vm = vm; 30 + void this.refreshBoards(); 31 + }; 32 + 33 + refreshBoards = async () => { 34 + const repo = this.getRepo(); 35 + if (!repo) { 36 + return; 37 + } 38 + try { 39 + const boards = await repo.listBoards(); 40 + if (this.vm) { 41 + this.vm = FileBrowserVM.setBoards(this.vm, boards); 42 + } else if (repo) { 43 + this.vm = FileBrowserVM.create({ repo, boards }); 44 + } 45 + } catch (error) { 46 + console.error("Failed to list boards", error); 47 + } 48 + }; 49 + 50 + fetchInspectorData = async (boardId: string, webDb: InkfiniteDB | null): Promise<BoardInspectorData> => { 51 + if (!webDb) { 52 + throw new Error("Database not available"); 53 + } 54 + return getBoardInspectorData(webDb, boardId, KNOWN_MIGRATION_IDS); 55 + }; 56 + }
+15
apps/web/src/lib/canvas/controllers/history-controller.ts
··· 1 + import type { CanvasControllerBindings } from "../canvas-store.svelte"; 2 + 3 + export class HistoryController { 4 + constructor( 5 + private bindings: CanvasControllerBindings, 6 + ) {} 7 + 8 + handleClick = () => { 9 + this.bindings.setHistoryViewerOpen(true); 10 + }; 11 + 12 + handleClose = () => { 13 + this.bindings.setHistoryViewerOpen(false); 14 + }; 15 + }
+105
apps/web/src/lib/canvas/controllers/texteditor-controller.svelte.ts
··· 1 + import { Camera, EditorState, SnapshotCommand, type Store, type Viewport } from "inkfinite-core"; 2 + 3 + export class TextEditorController { 4 + current = $state<{ shapeId: string; value: string } | null>(null); 5 + private textEditorEl: HTMLTextAreaElement | null = null; 6 + 7 + constructor( 8 + private store: Store, 9 + private getViewport: () => Viewport, 10 + private refreshCursor: () => void, 11 + ) {} 12 + 13 + get isEditing() { 14 + return this.current !== null; 15 + } 16 + 17 + setRef = (el: HTMLTextAreaElement | null) => { 18 + this.textEditorEl = el; 19 + }; 20 + 21 + getLayout = () => { 22 + if (!this.current) { 23 + return null; 24 + } 25 + const state = this.store.getState(); 26 + const shape = state.doc.shapes[this.current.shapeId]; 27 + if (!shape || shape.type !== "text") { 28 + return null; 29 + } 30 + const viewport = this.getViewport(); 31 + const screenPos = Camera.worldToScreen(state.camera, { x: shape.x, y: shape.y }, viewport); 32 + const widthWorld = shape.props.w ?? 240; 33 + const zoom = state.camera.zoom; 34 + return { 35 + left: screenPos.x, 36 + top: screenPos.y, 37 + width: widthWorld * zoom, 38 + height: shape.props.fontSize * 1.4 * zoom, 39 + fontSize: shape.props.fontSize * zoom, 40 + }; 41 + }; 42 + 43 + start = (shapeId: string) => { 44 + const state = this.store.getState(); 45 + const shape = state.doc.shapes[shapeId]; 46 + if (!shape || shape.type !== "text") { 47 + return; 48 + } 49 + this.current = { shapeId, value: shape.props.text }; 50 + this.refreshCursor(); 51 + queueMicrotask(() => { 52 + this.textEditorEl?.focus(); 53 + this.textEditorEl?.select(); 54 + }); 55 + }; 56 + 57 + handleInput = (event: Event) => { 58 + if (!this.current) { 59 + return; 60 + } 61 + const target = event.currentTarget as HTMLTextAreaElement; 62 + this.current = { ...this.current, value: target.value }; 63 + }; 64 + 65 + handleKeyDown = (event: KeyboardEvent) => { 66 + if (event.key === "Escape") { 67 + event.preventDefault(); 68 + this.cancel(); 69 + return; 70 + } 71 + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { 72 + event.preventDefault(); 73 + this.commit(); 74 + } 75 + }; 76 + 77 + handleBlur = () => { 78 + this.commit(); 79 + }; 80 + 81 + commit = () => { 82 + if (!this.current) { 83 + return; 84 + } 85 + const { shapeId, value } = this.current; 86 + const currentState = this.store.getState(); 87 + const shape = currentState.doc.shapes[shapeId]; 88 + this.current = null; 89 + this.refreshCursor(); 90 + if (!shape || shape.type !== "text" || shape.props.text === value) { 91 + return; 92 + } 93 + const before = EditorState.clone(currentState); 94 + const updatedShape = { ...shape, props: { ...shape.props, text: value } }; 95 + const newShapes = { ...currentState.doc.shapes, [shapeId]: updatedShape }; 96 + const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; 97 + const command = new SnapshotCommand("Edit text", "doc", before, EditorState.clone(after)); 98 + this.store.executeCommand(command); 99 + }; 100 + 101 + cancel = () => { 102 + this.current = null; 103 + this.refreshCursor(); 104 + }; 105 + }
+18
apps/web/src/lib/canvas/controllers/tool-controller.svelte.ts
··· 1 + import { switchTool, type Store, type ToolId } from "inkfinite-core"; 2 + 3 + export class ToolController { 4 + currentToolId = $state<ToolId>("select"); 5 + 6 + constructor( 7 + private store: Store, 8 + private tools: Map<ToolId, any>, 9 + ) { 10 + store.subscribe((state) => { 11 + this.currentToolId = state.ui.toolId; 12 + }); 13 + } 14 + 15 + handleChange = (toolId: ToolId) => { 16 + this.store.setState((state) => switchTool(state, toolId, this.tools)); 17 + }; 18 + }
+8
apps/web/src/lib/canvas/store/handle-state.svelte.ts
··· 1 + export class HandleState { 2 + hover = $state<string | null>(null); 3 + active = $state<string | null>(null); 4 + 5 + getSnapshot() { 6 + return { hover: this.hover, active: this.active }; 7 + } 8 + }
+5
apps/web/src/lib/canvas/store/pan-state.svelte.ts
··· 1 + export class PanState { 2 + isPanning = $state(false); 3 + spaceHeld = $state(false); 4 + lastScreen = $state({ x: 0, y: 0 }); 5 + }
+4
apps/web/src/lib/canvas/store/pointer-state.svelte.ts
··· 1 + export class PointerState { 2 + isPointerDown = $state(false); 3 + snappedWorld = $state<{ x: number; y: number } | null>(null); 4 + }
+8 -2
apps/web/src/lib/components/TitleBar.svelte
··· 32 32 onSelectBoard?: (boardId: string) => void | Promise<void>; 33 33 }; 34 34 35 - type Props = { platform?: Platform; desktop?: DesktopControls }; 35 + type Props = { platform?: Platform; desktop?: DesktopControls; onOpenBrowser?: () => void }; 36 36 37 - let { platform = 'web', desktop }: Props = $props(); 37 + let { platform = 'web', desktop, onOpenBrowser }: Props = $props(); 38 38 39 39 let infoOpen = $state(false); 40 40 function openInfo() { ··· 117 117 </div> 118 118 {/if} 119 119 <div class="titlebar__spacer"></div> 120 + {#if platform === 'web' && onOpenBrowser} 121 + <button class="titlebar__info" onclick={onOpenBrowser} aria-label="Browse boards"> 122 + <span aria-hidden="true">📁</span> 123 + <span class="titlebar__info-label">Boards</span> 124 + </button> 125 + {/if} 120 126 <button class="titlebar__info" onclick={openInfo} aria-label="About Inkfinite"> 121 127 <span aria-hidden="true">ℹ︎</span> 122 128 <span class="titlebar__info-label">Info</span>
+662
apps/web/src/lib/filebrowser/FileBrowser.svelte
··· 1 + <script lang="ts"> 2 + import Sheet from '$lib/components/Sheet.svelte'; 3 + import type { BoardInspectorData, BoardMeta, FileBrowserViewModel } from 'inkfinite-core'; 4 + import { BoardStatsOps } from 'inkfinite-core'; 5 + import type { Snippet } from 'svelte'; 6 + 7 + type Props = { 8 + vm: FileBrowserViewModel; 9 + onUpdate?: (vm: FileBrowserViewModel) => void; 10 + fetchInspectorData?: (boardId: string) => Promise<BoardInspectorData>; 11 + open?: boolean; 12 + onClose?: () => void; 13 + children?: Snippet; 14 + }; 15 + 16 + let { 17 + vm = $bindable(), 18 + onUpdate, 19 + fetchInspectorData, 20 + open = $bindable(false), 21 + onClose, 22 + children: _children 23 + }: Props = $props(); 24 + 25 + let searchQuery = $state(vm.query); 26 + let inspectorOpen = $state(false); 27 + let inspectorData = $state<BoardInspectorData | null>(null); 28 + let inspectorLoading = $state(false); 29 + let inspectorError = $state<string | null>(null); 30 + 31 + let isCreating = $state(false); 32 + let newBoardName = $state(''); 33 + let editingBoardId = $state<string | null>(null); 34 + let editingBoardName = $state(''); 35 + 36 + function handleSearchInput(event: Event) { 37 + const target = event.target as HTMLInputElement; 38 + searchQuery = target.value; 39 + const updated = vm; 40 + onUpdate?.(updated); 41 + } 42 + 43 + function handleSearchChange() { 44 + const updated = { ...vm, query: searchQuery }; 45 + onUpdate?.(updated); 46 + } 47 + 48 + async function handleOpenBoard(boardId: string) { 49 + try { 50 + await vm.actions.open(boardId); 51 + onClose?.(); 52 + } catch (error) { 53 + console.error('Failed to open board:', error); 54 + } 55 + } 56 + 57 + async function handleCreateBoard() { 58 + if (!newBoardName.trim()) return; 59 + try { 60 + const boardId = await vm.actions.create(newBoardName); 61 + isCreating = false; 62 + newBoardName = ''; 63 + onUpdate?.(vm); 64 + await handleOpenBoard(boardId); 65 + } catch (error) { 66 + console.error('Failed to create board:', error); 67 + } 68 + } 69 + 70 + async function handleRenameBoard(boardId: string) { 71 + if (!editingBoardName.trim()) return; 72 + try { 73 + await vm.actions.rename(boardId, editingBoardName); 74 + editingBoardId = null; 75 + editingBoardName = ''; 76 + onUpdate?.(vm); 77 + } catch (error) { 78 + console.error('Failed to rename board:', error); 79 + } 80 + } 81 + 82 + async function handleDeleteBoard(boardId: string) { 83 + if (!confirm('Are you sure you want to delete this board? This action cannot be undone.')) { 84 + return; 85 + } 86 + try { 87 + await vm.actions.delete(boardId); 88 + if (inspectorOpen && vm.selectedId === boardId) { 89 + inspectorOpen = false; 90 + inspectorData = null; 91 + } 92 + onUpdate?.(vm); 93 + } catch (error) { 94 + console.error('Failed to delete board:', error); 95 + } 96 + } 97 + 98 + async function handleInspectBoard(board: BoardMeta) { 99 + if (!fetchInspectorData) { 100 + console.warn('Inspector data fetcher not provided'); 101 + return; 102 + } 103 + 104 + inspectorOpen = true; 105 + inspectorLoading = true; 106 + inspectorError = null; 107 + 108 + try { 109 + inspectorData = await fetchInspectorData(board.id); 110 + } catch (error) { 111 + inspectorError = error instanceof Error ? error.message : 'Failed to load inspector data'; 112 + inspectorData = null; 113 + } finally { 114 + inspectorLoading = false; 115 + } 116 + } 117 + 118 + function formatTimestamp(timestamp: number): string { 119 + return new Date(timestamp).toLocaleString(); 120 + } 121 + 122 + function startRename(board: BoardMeta) { 123 + editingBoardId = board.id; 124 + editingBoardName = board.name; 125 + } 126 + 127 + function cancelRename() { 128 + editingBoardId = null; 129 + editingBoardName = ''; 130 + } 131 + </script> 132 + 133 + <!-- svelte-ignore a11y_autofocus --> 134 + <div class="filebrowser"> 135 + <div class="filebrowser__header"> 136 + <h2 class="filebrowser__title">Boards</h2> 137 + <button 138 + class="filebrowser__action filebrowser__action--create" 139 + onclick={() => (isCreating = true)} 140 + aria-label="Create new board"> 141 + + New 142 + </button> 143 + </div> 144 + 145 + <div class="filebrowser__search"> 146 + <input 147 + type="search" 148 + class="filebrowser__search-input" 149 + placeholder="Search boards..." 150 + value={searchQuery} 151 + oninput={handleSearchInput} 152 + onchange={handleSearchChange} 153 + aria-label="Search boards" /> 154 + </div> 155 + 156 + {#if isCreating} 157 + <div class="filebrowser__create-form"> 158 + <input 159 + type="text" 160 + class="filebrowser__input" 161 + placeholder="Board name" 162 + bind:value={newBoardName} 163 + aria-label="New board name" 164 + autofocus /> 165 + <div class="filebrowser__create-actions"> 166 + <button class="filebrowser__btn filebrowser__btn--primary" onclick={handleCreateBoard}> 167 + Create 168 + </button> 169 + <button 170 + class="filebrowser__btn filebrowser__btn--secondary" 171 + onclick={() => { 172 + isCreating = false; 173 + newBoardName = ''; 174 + }}> 175 + Cancel 176 + </button> 177 + </div> 178 + </div> 179 + {/if} 180 + 181 + <div class="filebrowser__list"> 182 + {#if vm.filteredBoards.length === 0} 183 + <div class="filebrowser__empty"> 184 + {vm.query ? 'No boards match your search' : 'No boards yet'} 185 + </div> 186 + {:else} 187 + {#each vm.filteredBoards as board (board.id)} 188 + <div class="filebrowser__board"> 189 + {#if editingBoardId === board.id} 190 + <div class="filebrowser__edit-form"> 191 + <input 192 + type="text" 193 + class="filebrowser__input" 194 + bind:value={editingBoardName} 195 + aria-label="Board name" 196 + autofocus /> 197 + <div class="filebrowser__edit-actions"> 198 + <button 199 + class="filebrowser__btn filebrowser__btn--primary" 200 + onclick={() => handleRenameBoard(board.id)}> 201 + Save 202 + </button> 203 + <button 204 + class="filebrowser__btn filebrowser__btn--secondary" 205 + onclick={cancelRename}> 206 + Cancel 207 + </button> 208 + </div> 209 + </div> 210 + {:else} 211 + <!-- svelte-ignore a11y_click_events_have_key_events --> 212 + <!-- svelte-ignore a11y_no_static_element_interactions --> 213 + <div class="filebrowser__board-info" onclick={() => handleOpenBoard(board.id)}> 214 + <div class="filebrowser__board-name">{board.name}</div> 215 + <div class="filebrowser__board-meta"> 216 + Updated: {formatTimestamp(board.updatedAt)} 217 + </div> 218 + </div> 219 + <div class="filebrowser__board-actions"> 220 + <button 221 + class="filebrowser__board-action" 222 + onclick={(e) => { 223 + e.stopPropagation(); 224 + handleInspectBoard(board); 225 + }} 226 + aria-label="Inspect board"> 227 + ℹ️ 228 + </button> 229 + <button 230 + class="filebrowser__board-action" 231 + onclick={(e) => { 232 + e.stopPropagation(); 233 + startRename(board); 234 + }} 235 + aria-label="Rename board"> 236 + ✏️ 237 + </button> 238 + <button 239 + class="filebrowser__board-action" 240 + onclick={(e) => { 241 + e.stopPropagation(); 242 + handleDeleteBoard(board.id); 243 + }} 244 + aria-label="Delete board"> 245 + 🗑️ 246 + </button> 247 + </div> 248 + {/if} 249 + </div> 250 + {/each} 251 + {/if} 252 + </div> 253 + </div> 254 + 255 + <Sheet bind:open={inspectorOpen} title="Board Inspector" side="right"> 256 + <div class="inspector"> 257 + <div class="inspector__header"> 258 + <h3 class="inspector__title">Board Inspector</h3> 259 + <button 260 + class="inspector__close" 261 + onclick={() => (inspectorOpen = false)} 262 + aria-label="Close inspector"> 263 + × 264 + </button> 265 + </div> 266 + 267 + {#if inspectorLoading} 268 + <div class="inspector__loading">Loading...</div> 269 + {:else if inspectorError} 270 + <div class="inspector__error">{inspectorError}</div> 271 + {:else if inspectorData} 272 + <div class="inspector__content"> 273 + <section class="inspector__section"> 274 + <h4 class="inspector__section-title">Storage</h4> 275 + <div class="inspector__item"> 276 + <span class="inspector__label">Storage Type:</span> 277 + <span class="inspector__value">IndexedDB (Dexie)</span> 278 + </div> 279 + </section> 280 + 281 + <section class="inspector__section"> 282 + <h4 class="inspector__section-title">Schema</h4> 283 + <div class="inspector__item"> 284 + <span class="inspector__label">Declared Version:</span> 285 + <span class="inspector__value">{inspectorData.schema.declaredVersion}</span> 286 + </div> 287 + <div class="inspector__item"> 288 + <span class="inspector__label">Installed Version:</span> 289 + <span class="inspector__value">{inspectorData.schema.installedVersion}</span> 290 + </div> 291 + </section> 292 + 293 + <section class="inspector__section"> 294 + <h4 class="inspector__section-title">Statistics</h4> 295 + <div class="inspector__item"> 296 + <span class="inspector__label">Pages:</span> 297 + <span class="inspector__value">{inspectorData.stats.pageCount}</span> 298 + </div> 299 + <div class="inspector__item"> 300 + <span class="inspector__label">Shapes:</span> 301 + <span class="inspector__value">{inspectorData.stats.shapeCount}</span> 302 + </div> 303 + <div class="inspector__item"> 304 + <span class="inspector__label">Bindings:</span> 305 + <span class="inspector__value">{inspectorData.stats.bindingCount}</span> 306 + </div> 307 + <div class="inspector__item"> 308 + <span class="inspector__label">Doc Size:</span> 309 + <span class="inspector__value" 310 + >{BoardStatsOps.formatDocSize(inspectorData.stats.docSizeBytes)}</span> 311 + </div> 312 + <div class="inspector__item"> 313 + <span class="inspector__label">Last Updated:</span> 314 + <span class="inspector__value" 315 + >{formatTimestamp(inspectorData.stats.lastUpdated)}</span> 316 + </div> 317 + </section> 318 + 319 + <section class="inspector__section"> 320 + <h4 class="inspector__section-title">Migrations</h4> 321 + {#if inspectorData.migrations.length === 0} 322 + <div class="inspector__empty">No migrations applied yet</div> 323 + {:else} 324 + <div class="inspector__migrations"> 325 + {#each inspectorData.migrations as migration (migration.id)} 326 + <div class="inspector__migration"> 327 + <span class="inspector__migration-id">{migration.id}</span> 328 + <span class="inspector__migration-date" 329 + >{formatTimestamp(migration.appliedAt)}</span> 330 + </div> 331 + {/each} 332 + </div> 333 + {/if} 334 + {#if inspectorData.pendingMigrations.length > 0} 335 + <div class="inspector__pending"> 336 + <h5 class="inspector__pending-title">Pending Migrations:</h5> 337 + {#each inspectorData.pendingMigrations as migrationId (migrationId)} 338 + <div class="inspector__pending-migration">{migrationId}</div> 339 + {/each} 340 + </div> 341 + {/if} 342 + </section> 343 + </div> 344 + {/if} 345 + </div> 346 + </Sheet> 347 + 348 + <style> 349 + .filebrowser { 350 + display: flex; 351 + flex-direction: column; 352 + height: 100%; 353 + background-color: var(--surface); 354 + color: var(--text); 355 + } 356 + 357 + .filebrowser__header { 358 + display: flex; 359 + align-items: center; 360 + justify-content: space-between; 361 + padding: 1rem; 362 + border-bottom: 1px solid var(--border, #e0e0e0); 363 + } 364 + 365 + .filebrowser__title { 366 + margin: 0; 367 + font-size: 1.25rem; 368 + font-weight: 600; 369 + } 370 + 371 + .filebrowser__action { 372 + padding: 0.5rem 1rem; 373 + background-color: var(--primary, #007bff); 374 + color: white; 375 + border: none; 376 + border-radius: 4px; 377 + cursor: pointer; 378 + font-size: 0.875rem; 379 + font-weight: 500; 380 + } 381 + 382 + .filebrowser__action:hover { 383 + background-color: var(--primary-hover, #0056b3); 384 + } 385 + 386 + .filebrowser__search { 387 + padding: 0.5rem 1rem; 388 + border-bottom: 1px solid var(--border, #e0e0e0); 389 + } 390 + 391 + .filebrowser__search-input { 392 + width: 100%; 393 + padding: 0.5rem; 394 + border: 1px solid var(--border, #e0e0e0); 395 + border-radius: 4px; 396 + font-size: 0.875rem; 397 + background-color: var(--input-bg, white); 398 + color: var(--text); 399 + } 400 + 401 + .filebrowser__search-input:focus { 402 + outline: none; 403 + border-color: var(--primary, #007bff); 404 + } 405 + 406 + .filebrowser__create-form, 407 + .filebrowser__edit-form { 408 + padding: 1rem; 409 + border-bottom: 1px solid var(--border, #e0e0e0); 410 + background-color: var(--surface-hover, #f5f5f5); 411 + } 412 + 413 + .filebrowser__input { 414 + width: 100%; 415 + padding: 0.5rem; 416 + border: 1px solid var(--border, #e0e0e0); 417 + border-radius: 4px; 418 + font-size: 0.875rem; 419 + margin-bottom: 0.5rem; 420 + background-color: var(--input-bg, white); 421 + color: var(--text); 422 + } 423 + 424 + .filebrowser__input:focus { 425 + outline: none; 426 + border-color: var(--primary, #007bff); 427 + } 428 + 429 + .filebrowser__create-actions, 430 + .filebrowser__edit-actions { 431 + display: flex; 432 + gap: 0.5rem; 433 + } 434 + 435 + .filebrowser__btn { 436 + padding: 0.375rem 0.75rem; 437 + border: none; 438 + border-radius: 4px; 439 + cursor: pointer; 440 + font-size: 0.875rem; 441 + } 442 + 443 + .filebrowser__btn--primary { 444 + background-color: var(--primary, #007bff); 445 + color: white; 446 + } 447 + 448 + .filebrowser__btn--primary:hover { 449 + background-color: var(--primary-hover, #0056b3); 450 + } 451 + 452 + .filebrowser__btn--secondary { 453 + background-color: var(--secondary, #6c757d); 454 + color: white; 455 + } 456 + 457 + .filebrowser__btn--secondary:hover { 458 + background-color: var(--secondary-hover, #5a6268); 459 + } 460 + 461 + .filebrowser__list { 462 + flex: 1; 463 + overflow-y: auto; 464 + } 465 + 466 + .filebrowser__empty { 467 + padding: 2rem; 468 + text-align: center; 469 + color: var(--text-muted, #6c757d); 470 + } 471 + 472 + .filebrowser__board { 473 + display: flex; 474 + align-items: center; 475 + justify-content: space-between; 476 + padding: 0.75rem 1rem; 477 + border-bottom: 1px solid var(--border, #e0e0e0); 478 + cursor: pointer; 479 + transition: background-color 0.15s; 480 + } 481 + 482 + .filebrowser__board:hover { 483 + background-color: var(--surface-hover, #f5f5f5); 484 + } 485 + 486 + .filebrowser__board-info { 487 + flex: 1; 488 + } 489 + 490 + .filebrowser__board-name { 491 + font-weight: 500; 492 + margin-bottom: 0.25rem; 493 + } 494 + 495 + .filebrowser__board-meta { 496 + font-size: 0.75rem; 497 + color: var(--text-muted, #6c757d); 498 + } 499 + 500 + .filebrowser__board-actions { 501 + display: flex; 502 + gap: 0.5rem; 503 + } 504 + 505 + .filebrowser__board-action { 506 + padding: 0.25rem 0.5rem; 507 + background: transparent; 508 + border: none; 509 + cursor: pointer; 510 + font-size: 1rem; 511 + opacity: 0.7; 512 + transition: opacity 0.15s; 513 + } 514 + 515 + .filebrowser__board-action:hover { 516 + opacity: 1; 517 + } 518 + 519 + /* Inspector styles */ 520 + .inspector { 521 + display: flex; 522 + flex-direction: column; 523 + height: 100%; 524 + background-color: var(--surface); 525 + color: var(--text); 526 + } 527 + 528 + .inspector__header { 529 + display: flex; 530 + align-items: center; 531 + justify-content: space-between; 532 + padding: 1rem; 533 + border-bottom: 1px solid var(--border, #e0e0e0); 534 + } 535 + 536 + .inspector__title { 537 + margin: 0; 538 + font-size: 1.125rem; 539 + font-weight: 600; 540 + } 541 + 542 + .inspector__close { 543 + background: transparent; 544 + border: none; 545 + font-size: 1.5rem; 546 + cursor: pointer; 547 + padding: 0; 548 + width: 2rem; 549 + height: 2rem; 550 + display: flex; 551 + align-items: center; 552 + justify-content: center; 553 + border-radius: 4px; 554 + transition: background-color 0.15s; 555 + } 556 + 557 + .inspector__close:hover { 558 + background-color: var(--surface-hover, #f5f5f5); 559 + } 560 + 561 + .inspector__loading { 562 + padding: 2rem; 563 + text-align: center; 564 + color: var(--text-muted, #6c757d); 565 + } 566 + 567 + .inspector__error { 568 + padding: 1rem; 569 + margin: 1rem; 570 + background-color: var(--error-bg, #f8d7da); 571 + color: var(--error-text, #721c24); 572 + border-radius: 4px; 573 + border: 1px solid var(--error-border, #f5c6cb); 574 + } 575 + 576 + .inspector__content { 577 + flex: 1; 578 + overflow-y: auto; 579 + padding: 1rem; 580 + } 581 + 582 + .inspector__section { 583 + margin-bottom: 1.5rem; 584 + } 585 + 586 + .inspector__section-title { 587 + margin: 0 0 0.75rem 0; 588 + font-size: 0.875rem; 589 + font-weight: 600; 590 + text-transform: uppercase; 591 + color: var(--text-muted, #6c757d); 592 + } 593 + 594 + .inspector__item { 595 + display: flex; 596 + justify-content: space-between; 597 + padding: 0.5rem 0; 598 + border-bottom: 1px solid var(--border-light, #f0f0f0); 599 + } 600 + 601 + .inspector__label { 602 + font-weight: 500; 603 + color: var(--text); 604 + } 605 + 606 + .inspector__value { 607 + color: var(--text-muted, #6c757d); 608 + } 609 + 610 + .inspector__empty { 611 + padding: 1rem; 612 + text-align: center; 613 + color: var(--text-muted, #6c757d); 614 + font-size: 0.875rem; 615 + } 616 + 617 + .inspector__migrations { 618 + display: flex; 619 + flex-direction: column; 620 + gap: 0.5rem; 621 + } 622 + 623 + .inspector__migration { 624 + display: flex; 625 + justify-content: space-between; 626 + padding: 0.5rem; 627 + background-color: var(--surface-hover, #f5f5f5); 628 + border-radius: 4px; 629 + } 630 + 631 + .inspector__migration-id { 632 + font-weight: 500; 633 + font-family: monospace; 634 + } 635 + 636 + .inspector__migration-date { 637 + font-size: 0.75rem; 638 + color: var(--text-muted, #6c757d); 639 + } 640 + 641 + .inspector__pending { 642 + margin-top: 1rem; 643 + padding: 0.75rem; 644 + background-color: var(--warning-bg, #fff3cd); 645 + border: 1px solid var(--warning-border, #ffeaa7); 646 + border-radius: 4px; 647 + } 648 + 649 + .inspector__pending-title { 650 + margin: 0 0 0.5rem 0; 651 + font-size: 0.875rem; 652 + font-weight: 600; 653 + color: var(--warning-text, #856404); 654 + } 655 + 656 + .inspector__pending-migration { 657 + font-family: monospace; 658 + font-size: 0.875rem; 659 + padding: 0.25rem 0; 660 + color: var(--warning-text, #856404); 661 + } 662 + </style>
+9 -3
apps/web/src/lib/tests/Canvas.history.test.ts
··· 35 35 }; 36 36 }); 37 37 38 - vi.mock("../input", () => { 38 + vi.mock("$lib/input", () => { 39 39 return { 40 40 createInputAdapter: vi.fn((config) => { 41 41 actionHandlers.push(config.onAction); ··· 332 332 333 333 it("wraps pointer actions in SnapshotCommands and enqueues persistence", async () => { 334 334 render(Canvas); 335 - await new Promise((resolve) => setTimeout(resolve, 0)); 335 + // Wait for onMount to complete and input adapter to be created 336 + await vi.waitFor(() => { 337 + expect(actionHandlers.length).toBeGreaterThan(0); 338 + }); 339 + await vi.waitFor(() => { 340 + expect(persistenceMocks.createPersistenceManager).toHaveBeenCalled(); 341 + }); 336 342 const handler = actionHandlers.at(-1); 337 343 expect(handler).toBeTypeOf("function"); 338 344 ··· 368 374 const stores = (InkfiniteCore as any).__storeInstances as Array<{ commands: any[] }>; 369 375 expect(stores.at(-1)?.commands).toHaveLength(1); 370 376 expect(stores.at(-1)?.commands[0].kind).toBe("doc"); 371 - expect(persistenceMocks.state.instance?.sink.enqueueDocPatch).toHaveBeenCalledTimes(1); 377 + expect(sinkEnqueueSpy).toHaveBeenCalledTimes(1); 372 378 }); 373 379 });
+70 -11
apps/web/src/lib/tests/Canvas.keyboard.test.ts
··· 1 - import type { Action, Command } from "inkfinite-core"; 1 + import type { Action, Command, Store } from "inkfinite-core"; 2 2 import { beforeEach, describe, expect, it, vi } from "vitest"; 3 3 import { cleanup, render } from "vitest-browser-svelte"; 4 4 5 5 const actionHandlers: Array<(action: Action) => void> = []; 6 - const coreMocks = vi.hoisted(() => ({ storeInstances: [] as unknown[], executeCommandSpy: vi.fn() })); 6 + const coreMocks = vi.hoisted(() => ({ storeInstances: [] as Store[], executeCommandSpy: vi.fn() })); 7 + 8 + async function selectShapeAt(handler: (action: Action) => void, position: { x: number; y: number }) { 9 + const timestamp = Date.now(); 10 + handler({ 11 + type: "pointer-down", 12 + button: 0, 13 + buttons: { left: true, middle: false, right: false }, 14 + world: position, 15 + screen: position, 16 + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, 17 + timestamp, 18 + }); 19 + handler({ 20 + type: "pointer-up", 21 + button: 0, 22 + buttons: { left: false, middle: false, right: false }, 23 + world: position, 24 + screen: position, 25 + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, 26 + timestamp: timestamp + 16, 27 + }); 28 + await Promise.resolve(); 29 + } 30 + 31 + async function selectDefaultShape(handler: (action: Action) => void) { 32 + await selectShapeAt(handler, { x: 110, y: 110 }); 33 + } 34 + 35 + async function selectSecondaryShape(handler: (action: Action) => void) { 36 + await selectShapeAt(handler, { x: 210, y: 210 }); 37 + } 38 + 39 + async function waitForDocumentReady() { 40 + await vi.waitFor(() => { 41 + const store = coreMocks.storeInstances.at(-1); 42 + expect(store).toBeTruthy(); 43 + const pages = Object.keys(store!.getState().doc.pages); 44 + expect(pages.length).toBeGreaterThan(0); 45 + }); 46 + } 7 47 8 - vi.mock("../input", () => { 48 + vi.mock("$lib/input", () => { 9 49 return { 10 50 createInputAdapter: vi.fn((config) => { 11 51 actionHandlers.push(config.onAction); ··· 50 90 const { executeCommandSpy } = coreMocks; 51 91 52 92 class MockStore extends actual.Store { 93 + constructor(...args: ConstructorParameters<typeof actual.Store>) { 94 + super(...args); 95 + coreMocks.storeInstances.push(this as unknown as Store); 96 + } 97 + 53 98 executeCommand(command: unknown) { 54 99 executeCommandSpy(command); 55 100 return super.executeCommand(command as Command); ··· 72 117 renameBoard: async () => {}, 73 118 deleteBoard: async () => {}, 74 119 loadDoc: async () => ({ 75 - pages: { "page:1": { id: "page:1", name: "Page 1", shapeIds: ["shape:1"] } }, 120 + pages: { "page:1": { id: "page:1", name: "Page 1", shapeIds: ["shape:1", "shape:2"] } }, 76 121 shapes: { 77 122 "shape:1": { 78 123 id: "shape:1", ··· 83 128 rot: 0, 84 129 props: { w: 50, h: 50, fill: "#ff0000", stroke: "#000000", radius: 0 }, 85 130 }, 131 + "shape:2": { 132 + id: "shape:2", 133 + type: "ellipse", 134 + pageId: "page:1", 135 + x: 200, 136 + y: 200, 137 + rot: 0, 138 + props: { w: 40, h: 40, fill: "#00ff00", stroke: "#000000" }, 139 + }, 86 140 }, 87 141 bindings: {}, 88 142 order: { pageIds: ["page:1"] }, ··· 110 164 it("should handle space key for panning mode", async () => { 111 165 render(Canvas); 112 166 await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); 113 - await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); 114 167 coreMocks.executeCommandSpy.mockClear(); 115 168 116 169 const handler = actionHandlers[0]; ··· 140 193 it("should nudge selected shapes with arrow keys", async () => { 141 194 render(Canvas); 142 195 await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); 196 + await waitForDocumentReady(); 143 197 144 198 const handler = actionHandlers[0]; 199 + await selectDefaultShape(handler); 145 200 146 - await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); 147 201 coreMocks.executeCommandSpy.mockClear(); 148 202 149 203 handler({ ··· 165 219 it("should nudge by 10px with shift modifier", async () => { 166 220 render(Canvas); 167 221 await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); 222 + await waitForDocumentReady(); 168 223 169 224 const handler = actionHandlers[0]; 170 - await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); 225 + await selectDefaultShape(handler); 171 226 coreMocks.executeCommandSpy.mockClear(); 172 227 173 228 handler({ ··· 189 244 it("should duplicate selected shapes with Cmd/Ctrl+D", async () => { 190 245 render(Canvas); 191 246 await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); 247 + await waitForDocumentReady(); 192 248 193 249 const handler = actionHandlers[0]; 194 - await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); 250 + await selectDefaultShape(handler); 195 251 coreMocks.executeCommandSpy.mockClear(); 196 252 197 253 const isMac = navigator.userAgent.toUpperCase().includes("MAC"); ··· 214 270 it("should bring shapes forward with Cmd/Ctrl+]", async () => { 215 271 render(Canvas); 216 272 await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); 273 + await waitForDocumentReady(); 217 274 218 275 const handler = actionHandlers[0]; 219 - await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); 276 + await selectDefaultShape(handler); 220 277 coreMocks.executeCommandSpy.mockClear(); 221 278 222 279 const isMac = navigator.userAgent.toUpperCase().includes("MAC"); ··· 239 296 it("should send shapes backward with Cmd/Ctrl+[", async () => { 240 297 render(Canvas); 241 298 await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); 299 + await waitForDocumentReady(); 242 300 243 301 const handler = actionHandlers[0]; 244 - await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); 302 + await selectSecondaryShape(handler); 245 303 coreMocks.executeCommandSpy.mockClear(); 246 304 247 305 const isMac = navigator.userAgent.toUpperCase().includes("MAC"); ··· 264 322 it("should not process tool actions while space is held", async () => { 265 323 render(Canvas); 266 324 await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); 325 + await waitForDocumentReady(); 267 326 268 327 const handler = actionHandlers[0]; 269 - await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); 328 + await selectDefaultShape(handler); 270 329 coreMocks.executeCommandSpy.mockClear(); 271 330 272 331 handler({
+305
apps/web/src/lib/tests/components/FileBrowser.svelte.test.ts
··· 1 + import FileBrowser from "$lib/filebrowser/FileBrowser.svelte"; 2 + import type { BoardMeta, FileBrowserViewModel } from "inkfinite-core"; 3 + import { FileBrowserVM } from "inkfinite-core"; 4 + import { describe, expect, it, vi } from "vitest"; 5 + import { render } from "vitest-browser-svelte"; 6 + import { page } from "vitest/browser"; 7 + 8 + const mockRepo = { 9 + listBoards: vi.fn(), 10 + createBoard: vi.fn(), 11 + openBoard: vi.fn(), 12 + renameBoard: vi.fn(), 13 + deleteBoard: vi.fn(), 14 + }; 15 + 16 + function createMockBoards(): BoardMeta[] { 17 + return [{ id: "board-1", name: "Board 1", createdAt: 1000, updatedAt: 2000 }, { 18 + id: "board-2", 19 + name: "Board 2", 20 + createdAt: 1500, 21 + updatedAt: 2500, 22 + }, { id: "board-3", name: "Test Board", createdAt: 2000, updatedAt: 3000 }]; 23 + } 24 + 25 + function createMockVM(boards: BoardMeta[]): FileBrowserViewModel { 26 + return FileBrowserVM.create({ repo: mockRepo, boards }); 27 + } 28 + 29 + describe("FileBrowser", () => { 30 + describe("boards list", () => { 31 + it("should render boards when provided", async () => { 32 + const boards = createMockBoards(); 33 + const vm = createMockVM(boards); 34 + 35 + render(FileBrowser, { vm, open: true }); 36 + 37 + await expect.element(page.getByText("Board 1")).toBeVisible(); 38 + await expect.element(page.getByText("Board 2")).toBeVisible(); 39 + await expect.element(page.getByText("Test Board")).toBeVisible(); 40 + }); 41 + 42 + it("should show empty state when no boards", async () => { 43 + const vm = createMockVM([]); 44 + 45 + render(FileBrowser, { vm, open: true }); 46 + 47 + await expect.element(page.getByText("No boards yet")).toBeVisible(); 48 + }); 49 + 50 + it("should show filtered empty state when query has no matches", async () => { 51 + const boards = createMockBoards(); 52 + const vm = FileBrowserVM.setQuery(createMockVM(boards), "NonExistent"); 53 + 54 + render(FileBrowser, { vm, open: true }); 55 + 56 + await expect.element(page.getByText("No boards match your search")).toBeVisible(); 57 + }); 58 + }); 59 + 60 + describe("search functionality", () => { 61 + it("should have search input", async () => { 62 + const vm = createMockVM(createMockBoards()); 63 + 64 + render(FileBrowser, { vm, open: true }); 65 + 66 + const searchInput = page.getByPlaceholder("Search boards..."); 67 + await expect.element(searchInput).toBeInTheDocument(); 68 + }); 69 + 70 + it("should update query on input", async () => { 71 + const boards = createMockBoards(); 72 + const vm = createMockVM(boards); 73 + const onUpdate = vi.fn(); 74 + 75 + render(FileBrowser, { vm, open: true, onUpdate }); 76 + 77 + const searchInput = page.getByPlaceholder("Search boards..."); 78 + await searchInput.fill("Test"); 79 + 80 + await expect.poll(() => searchInput.query()).toHaveValue("Test"); 81 + }); 82 + }); 83 + 84 + describe("board actions", () => { 85 + it("should show create board button", async () => { 86 + const vm = createMockVM(createMockBoards()); 87 + 88 + render(FileBrowser, { vm, open: true }); 89 + 90 + const createButton = page.getByRole("button", { name: /create new board/i }); 91 + await expect.element(createButton).toBeVisible(); 92 + }); 93 + 94 + it("should show create form when new button is clicked", async () => { 95 + const vm = createMockVM(createMockBoards()); 96 + 97 + render(FileBrowser, { vm, open: true }); 98 + 99 + const newButton = page.getByRole("button", { name: /create new board/i }); 100 + await newButton.click(); 101 + 102 + await expect.element(page.getByPlaceholder("Board name")).toBeVisible(); 103 + await expect.element(page.getByRole("button", { name: /^create$/i })).toBeVisible(); 104 + }); 105 + 106 + it("should have inspect buttons for each board", async () => { 107 + const boards = createMockBoards(); 108 + const vm = createMockVM(boards); 109 + 110 + render(FileBrowser, { vm, open: true }); 111 + 112 + const inspectButtons = page.getByLabelText(/inspect board/i); 113 + await expect.poll(() => inspectButtons.all()).toHaveLength(3); 114 + }); 115 + 116 + it("should have rename buttons for each board", async () => { 117 + const boards = createMockBoards(); 118 + const vm = createMockVM(boards); 119 + 120 + render(FileBrowser, { vm, open: true }); 121 + 122 + const renameButtons = page.getByLabelText(/rename board/i); 123 + await expect.poll(() => renameButtons.all()).toHaveLength(3); 124 + }); 125 + 126 + it("should have delete buttons for each board", async () => { 127 + const boards = createMockBoards(); 128 + const vm = createMockVM(boards); 129 + 130 + render(FileBrowser, { vm, open: true }); 131 + 132 + const deleteButtons = page.getByLabelText(/delete board/i); 133 + await expect.poll(() => deleteButtons.all()).toHaveLength(3); 134 + }); 135 + }); 136 + 137 + describe("inspector drawer", () => { 138 + it("should not show inspector initially", async () => { 139 + const vm = createMockVM(createMockBoards()); 140 + 141 + render(FileBrowser, { vm, open: true }); 142 + 143 + await expect.poll(() => document.querySelector(".inspector__title")).toBeNull(); 144 + }); 145 + 146 + it("should show inspector when inspect button is clicked", async () => { 147 + const boards = createMockBoards(); 148 + const vm = createMockVM(boards); 149 + const fetchInspectorData = vi.fn().mockResolvedValue({ 150 + stats: { pageCount: 2, shapeCount: 10, bindingCount: 3, docSizeBytes: 2048, lastUpdated: 3000 }, 151 + schema: { declaredVersion: 1, installedVersion: 1 }, 152 + migrations: [{ id: "MIG-0001", appliedAt: 1000 }, { id: "MIG-0002", appliedAt: 2000 }], 153 + pendingMigrations: [], 154 + }); 155 + 156 + render(FileBrowser, { vm, open: true, fetchInspectorData }); 157 + 158 + const inspectButtons = page.getByLabelText(/inspect board/i); 159 + const buttons = inspectButtons.all(); 160 + const firstButton = buttons[0]; 161 + await firstButton.click(); 162 + 163 + await expect.element(page.getByText("Board Inspector")).toBeVisible(); 164 + }); 165 + 166 + it("should display board statistics in inspector", async () => { 167 + const boards = createMockBoards(); 168 + const vm = createMockVM(boards); 169 + const fetchInspectorData = vi.fn().mockResolvedValue({ 170 + stats: { pageCount: 2, shapeCount: 10, bindingCount: 3, docSizeBytes: 2048, lastUpdated: 3000 }, 171 + schema: { declaredVersion: 1, installedVersion: 1 }, 172 + migrations: [], 173 + pendingMigrations: [], 174 + }); 175 + 176 + render(FileBrowser, { vm, open: true, fetchInspectorData }); 177 + 178 + const inspectButtons = page.getByLabelText(/inspect board/i); 179 + const buttons = inspectButtons.all(); 180 + const firstButton = buttons[0]; 181 + await firstButton.click(); 182 + 183 + await expect.element(page.getByText("Statistics")).toBeVisible(); 184 + 185 + await expect.element(page.getByText("Pages:")).toBeVisible(); 186 + await expect.element(page.getByText("Shapes:")).toBeVisible(); 187 + await expect.element(page.getByText("Bindings:")).toBeVisible(); 188 + }); 189 + 190 + it("should display schema information in inspector", async () => { 191 + const boards = createMockBoards(); 192 + const vm = createMockVM(boards); 193 + const fetchInspectorData = vi.fn().mockResolvedValue({ 194 + stats: { pageCount: 2, shapeCount: 10, bindingCount: 3, docSizeBytes: 2048, lastUpdated: 3000 }, 195 + schema: { declaredVersion: 1, installedVersion: 1 }, 196 + migrations: [], 197 + pendingMigrations: [], 198 + }); 199 + 200 + render(FileBrowser, { vm, open: true, fetchInspectorData }); 201 + 202 + const inspectButtons = page.getByLabelText(/inspect board/i); 203 + const buttons = inspectButtons.all(); 204 + const firstButton = buttons[0]; 205 + await firstButton.click(); 206 + 207 + await expect.element(page.getByText("Schema")).toBeVisible(); 208 + await expect.element(page.getByText("Declared Version:")).toBeVisible(); 209 + await expect.element(page.getByText("Installed Version:")).toBeVisible(); 210 + }); 211 + 212 + it("should display migrations in inspector", async () => { 213 + const boards = createMockBoards(); 214 + const vm = createMockVM(boards); 215 + const fetchInspectorData = vi.fn().mockResolvedValue({ 216 + stats: { pageCount: 2, shapeCount: 10, bindingCount: 3, docSizeBytes: 2048, lastUpdated: 3000 }, 217 + schema: { declaredVersion: 1, installedVersion: 1 }, 218 + migrations: [{ id: "MIG-0001", appliedAt: 1000 }, { id: "MIG-0002", appliedAt: 2000 }], 219 + pendingMigrations: [], 220 + }); 221 + 222 + render(FileBrowser, { vm, open: true, fetchInspectorData }); 223 + 224 + const inspectButtons = page.getByLabelText(/inspect board/i); 225 + const buttons = inspectButtons.all(); 226 + const firstButton = buttons[0]; 227 + await firstButton.click(); 228 + 229 + await expect.element(page.getByText("Migrations")).toBeVisible(); 230 + await expect.element(page.getByText("MIG-0001")).toBeVisible(); 231 + await expect.element(page.getByText("MIG-0002")).toBeVisible(); 232 + }); 233 + 234 + it("should show pending migrations warning when present", async () => { 235 + const boards = createMockBoards(); 236 + const vm = createMockVM(boards); 237 + const fetchInspectorData = vi.fn().mockResolvedValue({ 238 + stats: { pageCount: 2, shapeCount: 10, bindingCount: 3, docSizeBytes: 2048, lastUpdated: 3000 }, 239 + schema: { declaredVersion: 1, installedVersion: 1 }, 240 + migrations: [{ id: "MIG-0001", appliedAt: 1000 }], 241 + pendingMigrations: ["MIG-0002", "MIG-0003"], 242 + }); 243 + 244 + render(FileBrowser, { vm, open: true, fetchInspectorData }); 245 + 246 + const inspectButtons = page.getByLabelText(/inspect board/i); 247 + const buttons = inspectButtons.all(); 248 + const firstButton = buttons[0]; 249 + await firstButton.click(); 250 + 251 + await expect.element(page.getByText(/Pending Migrations:/i)).toBeVisible(); 252 + await expect.element(page.getByText("MIG-0002")).toBeVisible(); 253 + await expect.element(page.getByText("MIG-0003")).toBeVisible(); 254 + }); 255 + 256 + it("should show error when inspector data fetch fails", async () => { 257 + const boards = createMockBoards(); 258 + const vm = createMockVM(boards); 259 + const fetchInspectorData = vi.fn().mockRejectedValue(new Error("Failed to fetch")); 260 + 261 + render(FileBrowser, { vm, open: true, fetchInspectorData }); 262 + 263 + const inspectButtons = page.getByLabelText(/inspect board/i); 264 + const buttons = inspectButtons.all(); 265 + const firstButton = buttons[0]; 266 + await firstButton.click(); 267 + 268 + await expect.element(page.getByText("Failed to fetch")).toBeVisible(); 269 + }); 270 + }); 271 + 272 + describe("callbacks", () => { 273 + it("should call onUpdate when search changes", async () => { 274 + const boards = createMockBoards(); 275 + const vm = createMockVM(boards); 276 + const onUpdate = vi.fn(); 277 + 278 + render(FileBrowser, { vm, open: true, onUpdate }); 279 + 280 + const searchInput = page.getByPlaceholder("Search boards..."); 281 + await searchInput.fill("Test"); 282 + 283 + const input = document.querySelector("[placeholder=\"Search boards...\"]") as HTMLInputElement; 284 + input?.dispatchEvent(new Event("change", { bubbles: true })); 285 + 286 + await expect.poll(() => onUpdate).toHaveBeenCalled(); 287 + }); 288 + 289 + it("should call onClose when board is opened", async () => { 290 + const boards = createMockBoards(); 291 + const vm = createMockVM(boards); 292 + const onClose = vi.fn(); 293 + 294 + mockRepo.openBoard.mockResolvedValue(undefined); 295 + 296 + render(FileBrowser, { vm, open: true, onClose }); 297 + 298 + const boardName = page.getByText("Board 1"); 299 + await boardName.click(); 300 + 301 + await expect.poll(() => mockRepo.openBoard).toHaveBeenCalledWith("board-1"); 302 + await expect.poll(() => onClose).toHaveBeenCalled(); 303 + }); 304 + }); 305 + });
+1
packages/core/src/index.ts
··· 9 9 export * from "./persist/DocRepo"; 10 10 export * from "./persistence/db"; 11 11 export * from "./persistence/desktop"; 12 + export * from "./persistence/stats"; 12 13 export * from "./persistence/web"; 13 14 export * from "./reactivity"; 14 15 export * from "./tools";
+5
packages/core/src/persistence/db.ts
··· 86 86 }]; 87 87 88 88 /** 89 + * Known migration IDs for tracking pending migrations in the inspector. 90 + */ 91 + export const KNOWN_MIGRATION_IDS = MIGRATIONS.map((m) => m.id); 92 + 93 + /** 89 94 * Run pending logical migrations during schema upgrades 90 95 */ 91 96 export async function runMigrations(tx: Transaction): Promise<void> {
+61
packages/core/src/persistence/stats.ts
··· 1 + import type { Timestamp } from "../persist/DocRepo"; 2 + 3 + export type BoardStats = { 4 + pageCount: number; 5 + shapeCount: number; 6 + bindingCount: number; 7 + docSizeBytes: number; 8 + lastUpdated: Timestamp; 9 + }; 10 + 11 + export type SchemaInfo = { declaredVersion: number; installedVersion: number }; 12 + 13 + export type MigrationInfo = { id: string; appliedAt: Timestamp }; 14 + 15 + export type BoardInspectorData = { 16 + stats: BoardStats; 17 + schema: SchemaInfo; 18 + migrations: MigrationInfo[]; 19 + pendingMigrations: string[]; 20 + }; 21 + 22 + /** 23 + * Calculate board statistics from row counts and doc size. 24 + */ 25 + export const BoardStatsOps = { 26 + create( 27 + options: { 28 + pageCount: number; 29 + shapeCount: number; 30 + bindingCount: number; 31 + docSizeBytes: number; 32 + lastUpdated: Timestamp; 33 + }, 34 + ): BoardStats { 35 + return { 36 + pageCount: options.pageCount, 37 + shapeCount: options.shapeCount, 38 + bindingCount: options.bindingCount, 39 + docSizeBytes: options.docSizeBytes, 40 + lastUpdated: options.lastUpdated, 41 + }; 42 + }, 43 + 44 + /** 45 + * Format doc size in human-readable format (e.g., "1.2 KB", "3.4 MB") 46 + */ 47 + formatDocSize(bytes: number): string { 48 + if (bytes === 0) return "0 B"; 49 + if (bytes < 1024) return `${bytes} B`; 50 + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; 51 + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; 52 + }, 53 + }; 54 + 55 + /** 56 + * Determine pending migrations by comparing known migration list with applied migrations. 57 + */ 58 + export function getPendingMigrations(knownMigrationIds: string[], appliedMigrations: MigrationInfo[]): string[] { 59 + const appliedIds = new Set(appliedMigrations.map((m) => m.id)); 60 + return knownMigrationIds.filter((id) => !appliedIds.has(id)); 61 + }
+69
packages/core/src/persistence/web.ts
··· 10 10 ShapeRecord as ShapeOps, 11 11 } from "../model"; 12 12 import type { BoardMeta, DocRepo, Timestamp } from "../persist/DocRepo"; 13 + import type { BoardInspectorData, BoardStats, MigrationInfo, SchemaInfo } from "./stats"; 14 + import { BoardStatsOps, getPendingMigrations } from "./stats"; 13 15 14 16 export type PageRow = PageRecord & { boardId: string; updatedAt: Timestamp }; 15 17 ··· 467 469 468 470 return !(hasUpserts || hasDeletes || hasOrder); 469 471 } 472 + 473 + /** 474 + * Fetch board statistics for a given board. 475 + */ 476 + export async function getBoardStats(database: DexieLike, boardId: string): Promise<BoardStats> { 477 + const pages = database.table<PageRow>("pages"); 478 + const shapes = database.table<ShapeRow>("shapes"); 479 + const bindings = database.table<BindingRow>("bindings"); 480 + const boards = database.table<BoardMeta>("boards"); 481 + 482 + const [pageCount, shapeCount, bindingCount, board] = await Promise.all([ 483 + pages.where("boardId").equals(boardId).count(), 484 + shapes.where("boardId").equals(boardId).count(), 485 + bindings.where("boardId").equals(boardId).count(), 486 + boards.get(boardId), 487 + ]); 488 + 489 + const allRows = await Promise.all([ 490 + pages.where("boardId").equals(boardId).toArray(), 491 + shapes.where("boardId").equals(boardId).toArray(), 492 + bindings.where("boardId").equals(boardId).toArray(), 493 + ]); 494 + 495 + const docSizeBytes = JSON.stringify({ pages: allRows[0], shapes: allRows[1], bindings: allRows[2] }).length; 496 + 497 + return BoardStatsOps.create({ 498 + pageCount, 499 + shapeCount, 500 + bindingCount, 501 + docSizeBytes, 502 + lastUpdated: board?.updatedAt ?? 0, 503 + }); 504 + } 505 + 506 + /** 507 + * Fetch schema information from the database. 508 + */ 509 + export async function getSchemaInfo(database: Dexie): Promise<SchemaInfo> { 510 + return { declaredVersion: database.verno, installedVersion: database.verno }; 511 + } 512 + 513 + /** 514 + * Fetch applied migrations from the migrations table. 515 + */ 516 + export async function getAppliedMigrations(database: DexieLike): Promise<MigrationInfo[]> { 517 + const migrations = database.table<MigrationRow>("migrations"); 518 + return migrations.orderBy("appliedAt").toArray(); 519 + } 520 + 521 + /** 522 + * Fetch complete inspector data for a board including stats, schema, and migrations. 523 + */ 524 + export async function getBoardInspectorData( 525 + database: Dexie, 526 + boardId: string, 527 + knownMigrationIds: string[], 528 + ): Promise<BoardInspectorData> { 529 + const [stats, schema, migrations] = await Promise.all([ 530 + getBoardStats(database, boardId), 531 + getSchemaInfo(database), 532 + getAppliedMigrations(database), 533 + ]); 534 + 535 + const pendingMigrations = getPendingMigrations(knownMigrationIds, migrations); 536 + 537 + return { stats, schema, migrations, pendingMigrations }; 538 + }
+108
packages/core/tests/stats.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import type { MigrationInfo } from "../src/persistence/stats"; 3 + import { BoardStatsOps, getPendingMigrations } from "../src/persistence/stats"; 4 + 5 + describe("BoardStatsOps", () => { 6 + describe("create", () => { 7 + it("creates board stats with provided values", () => { 8 + const stats = BoardStatsOps.create({ 9 + pageCount: 5, 10 + shapeCount: 20, 11 + bindingCount: 3, 12 + docSizeBytes: 1024, 13 + lastUpdated: 1234567890, 14 + }); 15 + 16 + expect(stats).toMatchObject({ 17 + pageCount: 5, 18 + shapeCount: 20, 19 + bindingCount: 3, 20 + docSizeBytes: 1024, 21 + lastUpdated: 1234567890, 22 + }); 23 + }); 24 + 25 + it("handles zero values", () => { 26 + const stats = BoardStatsOps.create({ 27 + pageCount: 0, 28 + shapeCount: 0, 29 + bindingCount: 0, 30 + docSizeBytes: 0, 31 + lastUpdated: 0, 32 + }); 33 + 34 + expect(stats.pageCount).toBe(0); 35 + expect(stats.shapeCount).toBe(0); 36 + expect(stats.bindingCount).toBe(0); 37 + expect(stats.docSizeBytes).toBe(0); 38 + }); 39 + }); 40 + 41 + describe("formatDocSize", () => { 42 + it("formats bytes correctly", () => { 43 + expect(BoardStatsOps.formatDocSize(0)).toBe("0 B"); 44 + expect(BoardStatsOps.formatDocSize(100)).toBe("100 B"); 45 + expect(BoardStatsOps.formatDocSize(1023)).toBe("1023 B"); 46 + }); 47 + 48 + it("formats kilobytes correctly", () => { 49 + expect(BoardStatsOps.formatDocSize(1024)).toBe("1.0 KB"); 50 + expect(BoardStatsOps.formatDocSize(1536)).toBe("1.5 KB"); 51 + expect(BoardStatsOps.formatDocSize(10240)).toBe("10.0 KB"); 52 + }); 53 + 54 + it("formats megabytes correctly", () => { 55 + expect(BoardStatsOps.formatDocSize(1024 * 1024)).toBe("1.0 MB"); 56 + expect(BoardStatsOps.formatDocSize(1.5 * 1024 * 1024)).toBe("1.5 MB"); 57 + expect(BoardStatsOps.formatDocSize(10 * 1024 * 1024)).toBe("10.0 MB"); 58 + }); 59 + 60 + it("rounds to one decimal place", () => { 61 + expect(BoardStatsOps.formatDocSize(1234)).toBe("1.2 KB"); 62 + expect(BoardStatsOps.formatDocSize(1567)).toBe("1.5 KB"); 63 + expect(BoardStatsOps.formatDocSize(1234567)).toBe("1.2 MB"); 64 + }); 65 + }); 66 + }); 67 + 68 + describe("getPendingMigrations", () => { 69 + it("returns empty array when all migrations are applied", () => { 70 + const knownIds = ["MIG-0001", "MIG-0002"]; 71 + const applied: MigrationInfo[] = [{ id: "MIG-0001", appliedAt: 1000 }, { id: "MIG-0002", appliedAt: 2000 }]; 72 + 73 + const pending = getPendingMigrations(knownIds, applied); 74 + expect(pending).toEqual([]); 75 + }); 76 + 77 + it("returns pending migrations", () => { 78 + const knownIds = ["MIG-0001", "MIG-0002", "MIG-0003"]; 79 + const applied: MigrationInfo[] = [{ id: "MIG-0001", appliedAt: 1000 }]; 80 + 81 + const pending = getPendingMigrations(knownIds, applied); 82 + expect(pending).toEqual(["MIG-0002", "MIG-0003"]); 83 + }); 84 + 85 + it("handles no applied migrations", () => { 86 + const knownIds = ["MIG-0001", "MIG-0002"]; 87 + const applied: MigrationInfo[] = []; 88 + 89 + const pending = getPendingMigrations(knownIds, applied); 90 + expect(pending).toEqual(["MIG-0001", "MIG-0002"]); 91 + }); 92 + 93 + it("ignores applied migrations not in known list", () => { 94 + const knownIds = ["MIG-0001", "MIG-0002"]; 95 + const applied: MigrationInfo[] = [{ id: "MIG-0001", appliedAt: 1000 }, { id: "MIG-0003", appliedAt: 2000 }]; 96 + 97 + const pending = getPendingMigrations(knownIds, applied); 98 + expect(pending).toEqual(["MIG-0002"]); 99 + }); 100 + 101 + it("preserves order of known migrations", () => { 102 + const knownIds = ["MIG-0001", "MIG-0002", "MIG-0003", "MIG-0004"]; 103 + const applied: MigrationInfo[] = [{ id: "MIG-0002", appliedAt: 2000 }, { id: "MIG-0004", appliedAt: 4000 }]; 104 + 105 + const pending = getPendingMigrations(knownIds, applied); 106 + expect(pending).toEqual(["MIG-0001", "MIG-0003"]); 107 + }); 108 + });