web based infinite canvas

fix: filebrowser state

+198 -120
+1 -1
apps/web/src/lib/canvas/Canvas.svelte
··· 76 76 cursor={c.cursorStore} 77 77 persistence={persistenceStatusStore} 78 78 snap={c.snapStore} /> 79 - {#if c.fileBrowser.vm} 79 + {#if c.fileBrowser.vm && c.fileBrowser.open} 80 80 <FileBrowser 81 81 bind:vm={c.fileBrowser.vm} 82 82 bind:open={c.fileBrowser.open}
+1 -4
apps/web/src/lib/canvas/canvas-store.svelte.ts
··· 514 514 return { 515 515 platform: () => platform, 516 516 desktop, 517 - fileBrowser: { 518 - ...fileBrowser, 519 - fetchInspectorData: (boardId: string) => fileBrowser.fetchInspectorData(boardId, webDb), 520 - }, 517 + fileBrowser, 521 518 tools: toolController, 522 519 history, 523 520 textEditor,
+165 -114
apps/web/src/lib/filebrowser/FileBrowser.svelte
··· 1 1 <script lang="ts"> 2 2 import Sheet from '$lib/components/Sheet.svelte'; 3 - import type { BoardInspectorData, BoardMeta, FileBrowserViewModel } from 'inkfinite-core'; 3 + import type { 4 + BoardInspectorData, 5 + BoardMeta, 6 + FileBrowserViewModel, 7 + InkfiniteDB 8 + } from 'inkfinite-core'; 4 9 import { BoardStatsOps } from 'inkfinite-core'; 5 10 import type { Snippet } from 'svelte'; 6 11 7 12 type Props = { 8 13 vm: FileBrowserViewModel; 9 14 onUpdate?: (vm: FileBrowserViewModel) => void; 10 - fetchInspectorData?: (boardId: string) => Promise<BoardInspectorData>; 15 + fetchInspectorData?: ( 16 + boardId: string, 17 + webDb: InkfiniteDB | null 18 + ) => Promise<BoardInspectorData>; 11 19 open?: boolean; 12 20 onClose?: () => void; 13 21 children?: Snippet; ··· 18 26 onUpdate, 19 27 fetchInspectorData, 20 28 open = $bindable(false), 21 - onClose, 29 + onClose: handleClose, 22 30 children: _children 23 31 }: Props = $props(); 24 32 ··· 45 53 onUpdate?.(updated); 46 54 } 47 55 56 + function closeBrowser() { 57 + open = false; 58 + handleClose?.(); 59 + } 60 + 48 61 async function handleOpenBoard(boardId: string) { 49 62 try { 50 63 await vm.actions.open(boardId); 51 - onClose?.(); 64 + closeBrowser(); 52 65 } catch (error) { 53 66 console.error('Failed to open board:', error); 54 67 } ··· 106 119 inspectorError = null; 107 120 108 121 try { 109 - inspectorData = await fetchInspectorData(board.id); 122 + inspectorData = await fetchInspectorData(board.id, null); 110 123 } catch (error) { 111 124 inspectorError = error instanceof Error ? error.message : 'Failed to load inspector data'; 112 125 inspectorData = null; ··· 130 143 } 131 144 </script> 132 145 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> 146 + <Sheet bind:open onClose={closeBrowser} title="Boards" side="left" class="filebrowser-sheet"> 147 + <!-- svelte-ignore a11y_autofocus --> 148 + <div class="filebrowser"> 149 + <div class="filebrowser__header"> 150 + <div class="filebrowser__title-row"> 151 + <h2 class="filebrowser__title">Boards</h2> 169 152 <button 170 - class="filebrowser__btn filebrowser__btn--secondary" 171 - onclick={() => { 172 - isCreating = false; 173 - newBoardName = ''; 174 - }}> 175 - Cancel 153 + class="filebrowser__close" 154 + type="button" 155 + onclick={closeBrowser} 156 + aria-label="Close board browser"> 157 + × 176 158 </button> 177 159 </div> 160 + <button 161 + class="filebrowser__action filebrowser__action--create" 162 + onclick={() => (isCreating = true)} 163 + aria-label="Create new board"> 164 + + New 165 + </button> 178 166 </div> 179 - {/if} 167 + 168 + <div class="filebrowser__search"> 169 + <input 170 + type="search" 171 + class="filebrowser__search-input" 172 + placeholder="Search boards..." 173 + value={searchQuery} 174 + oninput={handleSearchInput} 175 + onchange={handleSearchChange} 176 + aria-label="Search boards" /> 177 + </div> 180 178 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'} 179 + {#if isCreating} 180 + <div class="filebrowser__create-form"> 181 + <input 182 + type="text" 183 + class="filebrowser__input" 184 + placeholder="Board name" 185 + bind:value={newBoardName} 186 + aria-label="New board name" 187 + autofocus /> 188 + <div class="filebrowser__create-actions"> 189 + <button class="filebrowser__btn filebrowser__btn--primary" onclick={handleCreateBoard}> 190 + Create 191 + </button> 192 + <button 193 + class="filebrowser__btn filebrowser__btn--secondary" 194 + onclick={() => { 195 + isCreating = false; 196 + newBoardName = ''; 197 + }}> 198 + Cancel 199 + </button> 200 + </div> 185 201 </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"> 202 + {/if} 203 + 204 + <div class="filebrowser__list"> 205 + {#if vm.filteredBoards.length === 0} 206 + <div class="filebrowser__empty"> 207 + {vm.query ? 'No boards match your search' : 'No boards yet'} 208 + </div> 209 + {:else} 210 + {#each vm.filteredBoards as board (board.id)} 211 + <div class="filebrowser__board"> 212 + {#if editingBoardId === board.id} 213 + <div class="filebrowser__edit-form"> 214 + <input 215 + type="text" 216 + class="filebrowser__input" 217 + bind:value={editingBoardName} 218 + aria-label="Board name" 219 + autofocus /> 220 + <div class="filebrowser__edit-actions"> 221 + <button 222 + class="filebrowser__btn filebrowser__btn--primary" 223 + onclick={() => handleRenameBoard(board.id)}> 224 + Save 225 + </button> 226 + <button 227 + class="filebrowser__btn filebrowser__btn--secondary" 228 + onclick={cancelRename}> 229 + Cancel 230 + </button> 231 + </div> 232 + </div> 233 + {:else} 234 + <!-- svelte-ignore a11y_click_events_have_key_events --> 235 + <!-- svelte-ignore a11y_no_static_element_interactions --> 236 + <div class="filebrowser__board-info" onclick={() => handleOpenBoard(board.id)}> 237 + <div class="filebrowser__board-name">{board.name}</div> 238 + <div class="filebrowser__board-meta"> 239 + Updated: {formatTimestamp(board.updatedAt)} 240 + </div> 241 + </div> 242 + <div class="filebrowser__board-actions"> 198 243 <button 199 - class="filebrowser__btn filebrowser__btn--primary" 200 - onclick={() => handleRenameBoard(board.id)}> 201 - Save 244 + class="filebrowser__board-action" 245 + onclick={(e) => { 246 + e.stopPropagation(); 247 + handleInspectBoard(board); 248 + }} 249 + aria-label="Inspect board"> 250 + ℹ️ 202 251 </button> 203 252 <button 204 - class="filebrowser__btn filebrowser__btn--secondary" 205 - onclick={cancelRename}> 206 - Cancel 253 + class="filebrowser__board-action" 254 + onclick={(e) => { 255 + e.stopPropagation(); 256 + startRename(board); 257 + }} 258 + aria-label="Rename board"> 259 + ✏️ 260 + </button> 261 + <button 262 + class="filebrowser__board-action" 263 + onclick={(e) => { 264 + e.stopPropagation(); 265 + handleDeleteBoard(board.id); 266 + }} 267 + aria-label="Delete board"> 268 + 🗑️ 207 269 </button> 208 270 </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} 271 + {/if} 272 + </div> 273 + {/each} 274 + {/if} 275 + </div> 252 276 </div> 253 - </div> 277 + </Sheet> 254 278 255 279 <Sheet bind:open={inspectorOpen} title="Board Inspector" side="right"> 256 280 <div class="inspector"> ··· 346 370 </Sheet> 347 371 348 372 <style> 373 + :global(.filebrowser-sheet) { 374 + padding: 0; 375 + width: min(520px, 90vw); 376 + } 377 + 349 378 .filebrowser { 350 379 display: flex; 351 380 flex-direction: column; ··· 362 391 border-bottom: 1px solid var(--border, #e0e0e0); 363 392 } 364 393 394 + .filebrowser__title-row { 395 + display: flex; 396 + align-items: center; 397 + gap: 8px; 398 + } 399 + 365 400 .filebrowser__title { 366 401 margin: 0; 367 402 font-size: 1.25rem; 368 403 font-weight: 600; 404 + } 405 + 406 + .filebrowser__close { 407 + background: none; 408 + border: none; 409 + color: var(--text-secondary, #666); 410 + font-size: 1.25rem; 411 + cursor: pointer; 412 + padding: 4px; 413 + border-radius: 4px; 414 + } 415 + 416 + .filebrowser__close:hover, 417 + .filebrowser__close:focus-visible { 418 + background-color: rgba(0, 0, 0, 0.05); 419 + color: var(--text); 369 420 } 370 421 371 422 .filebrowser__action {
+30
apps/web/src/lib/tests/components/FileBrowser.svelte.test.ts
··· 269 269 }); 270 270 }); 271 271 272 + describe("overlay behavior", () => { 273 + it("should render a close button that closes the browser", async () => { 274 + const boards = createMockBoards(); 275 + const vm = createMockVM(boards); 276 + const onClose = vi.fn(); 277 + 278 + render(FileBrowser, { vm, open: true, onClose }); 279 + 280 + const closeButton = page.getByLabelText(/close board browser/i); 281 + await closeButton.click(); 282 + 283 + await expect.poll(() => onClose).toHaveBeenCalled(); 284 + }); 285 + 286 + it("should close when clicking the backdrop", async () => { 287 + const boards = createMockBoards(); 288 + const vm = createMockVM(boards); 289 + const onClose = vi.fn(); 290 + 291 + render(FileBrowser, { vm, open: true, onClose }); 292 + 293 + await expect.poll(() => document.querySelector(".sheet__backdrop")).not.toBeNull(); 294 + 295 + const backdrop = document.querySelector(".sheet__backdrop"); 296 + backdrop?.dispatchEvent(new MouseEvent("click", { bubbles: true })); 297 + 298 + await expect.poll(() => onClose).toHaveBeenCalled(); 299 + }); 300 + }); 301 + 272 302 describe("callbacks", () => { 273 303 it("should call onUpdate when search changes", async () => { 274 304 const boards = createMockBoards();
+1 -1
apps/web/src/routes/+layout.svelte
··· 6 6 </script> 7 7 8 8 <svelte:head> 9 - <link rel="icon" href={favicon} /> 9 + <link rel="icon" href={favicon} type="image/svg+xml" /> 10 10 <title>Inkfinite - Infinite Canvas</title> 11 11 </svelte:head> 12 12