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
76
cursor={c.cursorStore}
77
77
persistence={persistenceStatusStore}
78
78
snap={c.snapStore} />
79
79
-
{#if c.fileBrowser.vm}
79
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
517
-
fileBrowser: {
518
518
-
...fileBrowser,
519
519
-
fetchInspectorData: (boardId: string) => fileBrowser.fetchInspectorData(boardId, webDb),
520
520
-
},
517
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
3
-
import type { BoardInspectorData, BoardMeta, FileBrowserViewModel } from 'inkfinite-core';
3
3
+
import type {
4
4
+
BoardInspectorData,
5
5
+
BoardMeta,
6
6
+
FileBrowserViewModel,
7
7
+
InkfiniteDB
8
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
10
-
fetchInspectorData?: (boardId: string) => Promise<BoardInspectorData>;
15
15
+
fetchInspectorData?: (
16
16
+
boardId: string,
17
17
+
webDb: InkfiniteDB | null
18
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
21
-
onClose,
29
29
+
onClose: handleClose,
22
30
children: _children
23
31
}: Props = $props();
24
32
···
45
53
onUpdate?.(updated);
46
54
}
47
55
56
56
+
function closeBrowser() {
57
57
+
open = false;
58
58
+
handleClose?.();
59
59
+
}
60
60
+
48
61
async function handleOpenBoard(boardId: string) {
49
62
try {
50
63
await vm.actions.open(boardId);
51
51
-
onClose?.();
64
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
109
-
inspectorData = await fetchInspectorData(board.id);
122
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
133
-
<!-- svelte-ignore a11y_autofocus -->
134
134
-
<div class="filebrowser">
135
135
-
<div class="filebrowser__header">
136
136
-
<h2 class="filebrowser__title">Boards</h2>
137
137
-
<button
138
138
-
class="filebrowser__action filebrowser__action--create"
139
139
-
onclick={() => (isCreating = true)}
140
140
-
aria-label="Create new board">
141
141
-
+ New
142
142
-
</button>
143
143
-
</div>
144
144
-
145
145
-
<div class="filebrowser__search">
146
146
-
<input
147
147
-
type="search"
148
148
-
class="filebrowser__search-input"
149
149
-
placeholder="Search boards..."
150
150
-
value={searchQuery}
151
151
-
oninput={handleSearchInput}
152
152
-
onchange={handleSearchChange}
153
153
-
aria-label="Search boards" />
154
154
-
</div>
155
155
-
156
156
-
{#if isCreating}
157
157
-
<div class="filebrowser__create-form">
158
158
-
<input
159
159
-
type="text"
160
160
-
class="filebrowser__input"
161
161
-
placeholder="Board name"
162
162
-
bind:value={newBoardName}
163
163
-
aria-label="New board name"
164
164
-
autofocus />
165
165
-
<div class="filebrowser__create-actions">
166
166
-
<button class="filebrowser__btn filebrowser__btn--primary" onclick={handleCreateBoard}>
167
167
-
Create
168
168
-
</button>
146
146
+
<Sheet bind:open onClose={closeBrowser} title="Boards" side="left" class="filebrowser-sheet">
147
147
+
<!-- svelte-ignore a11y_autofocus -->
148
148
+
<div class="filebrowser">
149
149
+
<div class="filebrowser__header">
150
150
+
<div class="filebrowser__title-row">
151
151
+
<h2 class="filebrowser__title">Boards</h2>
169
152
<button
170
170
-
class="filebrowser__btn filebrowser__btn--secondary"
171
171
-
onclick={() => {
172
172
-
isCreating = false;
173
173
-
newBoardName = '';
174
174
-
}}>
175
175
-
Cancel
153
153
+
class="filebrowser__close"
154
154
+
type="button"
155
155
+
onclick={closeBrowser}
156
156
+
aria-label="Close board browser">
157
157
+
×
176
158
</button>
177
159
</div>
160
160
+
<button
161
161
+
class="filebrowser__action filebrowser__action--create"
162
162
+
onclick={() => (isCreating = true)}
163
163
+
aria-label="Create new board">
164
164
+
+ New
165
165
+
</button>
178
166
</div>
179
179
-
{/if}
167
167
+
168
168
+
<div class="filebrowser__search">
169
169
+
<input
170
170
+
type="search"
171
171
+
class="filebrowser__search-input"
172
172
+
placeholder="Search boards..."
173
173
+
value={searchQuery}
174
174
+
oninput={handleSearchInput}
175
175
+
onchange={handleSearchChange}
176
176
+
aria-label="Search boards" />
177
177
+
</div>
180
178
181
181
-
<div class="filebrowser__list">
182
182
-
{#if vm.filteredBoards.length === 0}
183
183
-
<div class="filebrowser__empty">
184
184
-
{vm.query ? 'No boards match your search' : 'No boards yet'}
179
179
+
{#if isCreating}
180
180
+
<div class="filebrowser__create-form">
181
181
+
<input
182
182
+
type="text"
183
183
+
class="filebrowser__input"
184
184
+
placeholder="Board name"
185
185
+
bind:value={newBoardName}
186
186
+
aria-label="New board name"
187
187
+
autofocus />
188
188
+
<div class="filebrowser__create-actions">
189
189
+
<button class="filebrowser__btn filebrowser__btn--primary" onclick={handleCreateBoard}>
190
190
+
Create
191
191
+
</button>
192
192
+
<button
193
193
+
class="filebrowser__btn filebrowser__btn--secondary"
194
194
+
onclick={() => {
195
195
+
isCreating = false;
196
196
+
newBoardName = '';
197
197
+
}}>
198
198
+
Cancel
199
199
+
</button>
200
200
+
</div>
185
201
</div>
186
186
-
{:else}
187
187
-
{#each vm.filteredBoards as board (board.id)}
188
188
-
<div class="filebrowser__board">
189
189
-
{#if editingBoardId === board.id}
190
190
-
<div class="filebrowser__edit-form">
191
191
-
<input
192
192
-
type="text"
193
193
-
class="filebrowser__input"
194
194
-
bind:value={editingBoardName}
195
195
-
aria-label="Board name"
196
196
-
autofocus />
197
197
-
<div class="filebrowser__edit-actions">
202
202
+
{/if}
203
203
+
204
204
+
<div class="filebrowser__list">
205
205
+
{#if vm.filteredBoards.length === 0}
206
206
+
<div class="filebrowser__empty">
207
207
+
{vm.query ? 'No boards match your search' : 'No boards yet'}
208
208
+
</div>
209
209
+
{:else}
210
210
+
{#each vm.filteredBoards as board (board.id)}
211
211
+
<div class="filebrowser__board">
212
212
+
{#if editingBoardId === board.id}
213
213
+
<div class="filebrowser__edit-form">
214
214
+
<input
215
215
+
type="text"
216
216
+
class="filebrowser__input"
217
217
+
bind:value={editingBoardName}
218
218
+
aria-label="Board name"
219
219
+
autofocus />
220
220
+
<div class="filebrowser__edit-actions">
221
221
+
<button
222
222
+
class="filebrowser__btn filebrowser__btn--primary"
223
223
+
onclick={() => handleRenameBoard(board.id)}>
224
224
+
Save
225
225
+
</button>
226
226
+
<button
227
227
+
class="filebrowser__btn filebrowser__btn--secondary"
228
228
+
onclick={cancelRename}>
229
229
+
Cancel
230
230
+
</button>
231
231
+
</div>
232
232
+
</div>
233
233
+
{:else}
234
234
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
235
235
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
236
236
+
<div class="filebrowser__board-info" onclick={() => handleOpenBoard(board.id)}>
237
237
+
<div class="filebrowser__board-name">{board.name}</div>
238
238
+
<div class="filebrowser__board-meta">
239
239
+
Updated: {formatTimestamp(board.updatedAt)}
240
240
+
</div>
241
241
+
</div>
242
242
+
<div class="filebrowser__board-actions">
198
243
<button
199
199
-
class="filebrowser__btn filebrowser__btn--primary"
200
200
-
onclick={() => handleRenameBoard(board.id)}>
201
201
-
Save
244
244
+
class="filebrowser__board-action"
245
245
+
onclick={(e) => {
246
246
+
e.stopPropagation();
247
247
+
handleInspectBoard(board);
248
248
+
}}
249
249
+
aria-label="Inspect board">
250
250
+
ℹ️
202
251
</button>
203
252
<button
204
204
-
class="filebrowser__btn filebrowser__btn--secondary"
205
205
-
onclick={cancelRename}>
206
206
-
Cancel
253
253
+
class="filebrowser__board-action"
254
254
+
onclick={(e) => {
255
255
+
e.stopPropagation();
256
256
+
startRename(board);
257
257
+
}}
258
258
+
aria-label="Rename board">
259
259
+
✏️
260
260
+
</button>
261
261
+
<button
262
262
+
class="filebrowser__board-action"
263
263
+
onclick={(e) => {
264
264
+
e.stopPropagation();
265
265
+
handleDeleteBoard(board.id);
266
266
+
}}
267
267
+
aria-label="Delete board">
268
268
+
🗑️
207
269
</button>
208
270
</div>
209
209
-
</div>
210
210
-
{:else}
211
211
-
<!-- svelte-ignore a11y_click_events_have_key_events -->
212
212
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
213
213
-
<div class="filebrowser__board-info" onclick={() => handleOpenBoard(board.id)}>
214
214
-
<div class="filebrowser__board-name">{board.name}</div>
215
215
-
<div class="filebrowser__board-meta">
216
216
-
Updated: {formatTimestamp(board.updatedAt)}
217
217
-
</div>
218
218
-
</div>
219
219
-
<div class="filebrowser__board-actions">
220
220
-
<button
221
221
-
class="filebrowser__board-action"
222
222
-
onclick={(e) => {
223
223
-
e.stopPropagation();
224
224
-
handleInspectBoard(board);
225
225
-
}}
226
226
-
aria-label="Inspect board">
227
227
-
ℹ️
228
228
-
</button>
229
229
-
<button
230
230
-
class="filebrowser__board-action"
231
231
-
onclick={(e) => {
232
232
-
e.stopPropagation();
233
233
-
startRename(board);
234
234
-
}}
235
235
-
aria-label="Rename board">
236
236
-
✏️
237
237
-
</button>
238
238
-
<button
239
239
-
class="filebrowser__board-action"
240
240
-
onclick={(e) => {
241
241
-
e.stopPropagation();
242
242
-
handleDeleteBoard(board.id);
243
243
-
}}
244
244
-
aria-label="Delete board">
245
245
-
🗑️
246
246
-
</button>
247
247
-
</div>
248
248
-
{/if}
249
249
-
</div>
250
250
-
{/each}
251
251
-
{/if}
271
271
+
{/if}
272
272
+
</div>
273
273
+
{/each}
274
274
+
{/if}
275
275
+
</div>
252
276
</div>
253
253
-
</div>
277
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
373
+
:global(.filebrowser-sheet) {
374
374
+
padding: 0;
375
375
+
width: min(520px, 90vw);
376
376
+
}
377
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
394
+
.filebrowser__title-row {
395
395
+
display: flex;
396
396
+
align-items: center;
397
397
+
gap: 8px;
398
398
+
}
399
399
+
365
400
.filebrowser__title {
366
401
margin: 0;
367
402
font-size: 1.25rem;
368
403
font-weight: 600;
404
404
+
}
405
405
+
406
406
+
.filebrowser__close {
407
407
+
background: none;
408
408
+
border: none;
409
409
+
color: var(--text-secondary, #666);
410
410
+
font-size: 1.25rem;
411
411
+
cursor: pointer;
412
412
+
padding: 4px;
413
413
+
border-radius: 4px;
414
414
+
}
415
415
+
416
416
+
.filebrowser__close:hover,
417
417
+
.filebrowser__close:focus-visible {
418
418
+
background-color: rgba(0, 0, 0, 0.05);
419
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
272
+
describe("overlay behavior", () => {
273
273
+
it("should render a close button that closes the browser", async () => {
274
274
+
const boards = createMockBoards();
275
275
+
const vm = createMockVM(boards);
276
276
+
const onClose = vi.fn();
277
277
+
278
278
+
render(FileBrowser, { vm, open: true, onClose });
279
279
+
280
280
+
const closeButton = page.getByLabelText(/close board browser/i);
281
281
+
await closeButton.click();
282
282
+
283
283
+
await expect.poll(() => onClose).toHaveBeenCalled();
284
284
+
});
285
285
+
286
286
+
it("should close when clicking the backdrop", async () => {
287
287
+
const boards = createMockBoards();
288
288
+
const vm = createMockVM(boards);
289
289
+
const onClose = vi.fn();
290
290
+
291
291
+
render(FileBrowser, { vm, open: true, onClose });
292
292
+
293
293
+
await expect.poll(() => document.querySelector(".sheet__backdrop")).not.toBeNull();
294
294
+
295
295
+
const backdrop = document.querySelector(".sheet__backdrop");
296
296
+
backdrop?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
297
297
+
298
298
+
await expect.poll(() => onClose).toHaveBeenCalled();
299
299
+
});
300
300
+
});
301
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
9
-
<link rel="icon" href={favicon} />
9
9
+
<link rel="icon" href={favicon} type="image/svg+xml" />
10
10
<title>Inkfinite - Infinite Canvas</title>
11
11
</svelte:head>
12
12