tangled
alpha
login
or
join now
desertthunder.dev
/
inkfinite
web based infinite canvas
2
fork
atom
overview
issues
pulls
pipelines
fix: filebrowser state
desertthunder.dev
1 month ago
91a506e6
6ebf9789
+198
-120
5 changed files
expand all
collapse all
unified
split
apps
web
src
lib
canvas
Canvas.svelte
canvas-store.svelte.ts
filebrowser
FileBrowser.svelte
tests
components
FileBrowser.svelte.test.ts
routes
+layout.svelte
+1
-1
apps/web/src/lib/canvas/Canvas.svelte
···
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}
···
76
cursor={c.cursorStore}
77
persistence={persistenceStatusStore}
78
snap={c.snapStore} />
79
+
{#if c.fileBrowser.vm && c.fileBrowser.open}
80
<FileBrowser
81
bind:vm={c.fileBrowser.vm}
82
bind:open={c.fileBrowser.open}
+1
-4
apps/web/src/lib/canvas/canvas-store.svelte.ts
···
514
return {
515
platform: () => platform,
516
desktop,
517
-
fileBrowser: {
518
-
...fileBrowser,
519
-
fetchInspectorData: (boardId: string) => fileBrowser.fetchInspectorData(boardId, webDb),
520
-
},
521
tools: toolController,
522
history,
523
textEditor,
···
514
return {
515
platform: () => platform,
516
desktop,
517
+
fileBrowser,
0
0
0
518
tools: toolController,
519
history,
520
textEditor,
+165
-114
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';
0
0
0
0
0
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>;
0
0
0
11
open?: boolean;
12
onClose?: () => void;
13
children?: Snippet;
···
18
onUpdate,
19
fetchInspectorData,
20
open = $bindable(false),
21
-
onClose,
22
children: _children
23
}: Props = $props();
24
···
45
onUpdate?.(updated);
46
}
47
0
0
0
0
0
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
}
···
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;
···
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>
0
0
0
0
0
0
178
</div>
179
-
{/if}
0
0
0
0
0
0
0
0
0
0
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'}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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">
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
198
<button
199
-
class="filebrowser__btn filebrowser__btn--primary"
200
-
onclick={() => handleRenameBoard(board.id)}>
201
-
Save
0
0
0
0
202
</button>
203
<button
204
-
class="filebrowser__btn filebrowser__btn--secondary"
205
-
onclick={cancelRename}>
206
-
Cancel
0
0
0
0
0
0
0
0
0
0
0
0
0
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">
···
346
</Sheet>
347
348
<style>
0
0
0
0
0
349
.filebrowser {
350
display: flex;
351
flex-direction: column;
···
362
border-bottom: 1px solid var(--border, #e0e0e0);
363
}
364
0
0
0
0
0
0
365
.filebrowser__title {
366
margin: 0;
367
font-size: 1.25rem;
368
font-weight: 600;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
369
}
370
371
.filebrowser__action {
···
1
<script lang="ts">
2
import Sheet from '$lib/components/Sheet.svelte';
3
+
import type {
4
+
BoardInspectorData,
5
+
BoardMeta,
6
+
FileBrowserViewModel,
7
+
InkfiniteDB
8
+
} from 'inkfinite-core';
9
import { BoardStatsOps } from 'inkfinite-core';
10
import type { Snippet } from 'svelte';
11
12
type Props = {
13
vm: FileBrowserViewModel;
14
onUpdate?: (vm: FileBrowserViewModel) => void;
15
+
fetchInspectorData?: (
16
+
boardId: string,
17
+
webDb: InkfiniteDB | null
18
+
) => Promise<BoardInspectorData>;
19
open?: boolean;
20
onClose?: () => void;
21
children?: Snippet;
···
26
onUpdate,
27
fetchInspectorData,
28
open = $bindable(false),
29
+
onClose: handleClose,
30
children: _children
31
}: Props = $props();
32
···
53
onUpdate?.(updated);
54
}
55
56
+
function closeBrowser() {
57
+
open = false;
58
+
handleClose?.();
59
+
}
60
+
61
async function handleOpenBoard(boardId: string) {
62
try {
63
await vm.actions.open(boardId);
64
+
closeBrowser();
65
} catch (error) {
66
console.error('Failed to open board:', error);
67
}
···
119
inspectorError = null;
120
121
try {
122
+
inspectorData = await fetchInspectorData(board.id, null);
123
} catch (error) {
124
inspectorError = error instanceof Error ? error.message : 'Failed to load inspector data';
125
inspectorData = null;
···
143
}
144
</script>
145
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>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
152
<button
153
+
class="filebrowser__close"
154
+
type="button"
155
+
onclick={closeBrowser}
156
+
aria-label="Close board browser">
157
+
×
0
158
</button>
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>
166
</div>
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>
178
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>
201
</div>
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">
243
<button
244
+
class="filebrowser__board-action"
245
+
onclick={(e) => {
246
+
e.stopPropagation();
247
+
handleInspectBoard(board);
248
+
}}
249
+
aria-label="Inspect board">
250
+
ℹ️
251
</button>
252
<button
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
+
🗑️
269
</button>
270
</div>
271
+
{/if}
272
+
</div>
273
+
{/each}
274
+
{/if}
275
+
</div>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
276
</div>
277
+
</Sheet>
278
279
<Sheet bind:open={inspectorOpen} title="Board Inspector" side="right">
280
<div class="inspector">
···
370
</Sheet>
371
372
<style>
373
+
:global(.filebrowser-sheet) {
374
+
padding: 0;
375
+
width: min(520px, 90vw);
376
+
}
377
+
378
.filebrowser {
379
display: flex;
380
flex-direction: column;
···
391
border-bottom: 1px solid var(--border, #e0e0e0);
392
}
393
394
+
.filebrowser__title-row {
395
+
display: flex;
396
+
align-items: center;
397
+
gap: 8px;
398
+
}
399
+
400
.filebrowser__title {
401
margin: 0;
402
font-size: 1.25rem;
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);
420
}
421
422
.filebrowser__action {
+30
apps/web/src/lib/tests/components/FileBrowser.svelte.test.ts
···
269
});
270
});
271
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
272
describe("callbacks", () => {
273
it("should call onUpdate when search changes", async () => {
274
const boards = createMockBoards();
···
269
});
270
});
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
+
302
describe("callbacks", () => {
303
it("should call onUpdate when search changes", async () => {
304
const boards = createMockBoards();
+1
-1
apps/web/src/routes/+layout.svelte
···
6
</script>
7
8
<svelte:head>
9
-
<link rel="icon" href={favicon} />
10
<title>Inkfinite - Infinite Canvas</title>
11
</svelte:head>
12
···
6
</script>
7
8
<svelte:head>
9
+
<link rel="icon" href={favicon} type="image/svg+xml" />
10
<title>Inkfinite - Infinite Canvas</title>
11
</svelte:head>
12