web based infinite canvas
1import type { BoardMeta, DocRepo } from "../persistence/repo";
2
3export type FileBrowserActions = {
4 open(boardId: string): Promise<void>;
5 create(name: string): Promise<string>;
6 rename(boardId: string, name: string): Promise<void>;
7 delete(boardId: string): Promise<void>;
8};
9
10export type FileBrowserViewModel = {
11 /** All known boards pulled from the DocRepo */
12 boards: BoardMeta[];
13 /** Current search query */
14 query: string;
15 /** Boards that match the query (preserves incoming order) */
16 filteredBoards: BoardMeta[];
17 /** Selected board identifier, or null if nothing is selected */
18 selectedId: string | null;
19 /** Bound repository actions */
20 actions: FileBrowserActions;
21};
22
23export type FileBrowserOptions = { repo: DocRepo; boards?: BoardMeta[]; query?: string; selectedId?: string | null };
24
25export const FileBrowserVM = {
26 create(options: FileBrowserOptions): FileBrowserViewModel {
27 const boards = [...(options.boards ?? [])];
28 const query = normalizeQuery(options.query);
29 const filteredBoards = filterBoards(boards, query);
30 const selectedId = resolveSelection(options.selectedId ?? null, filteredBoards);
31 const actions = createActions(options.repo);
32 return { boards, query, filteredBoards, selectedId, actions };
33 },
34
35 setBoards(vm: FileBrowserViewModel, boards: BoardMeta[]): FileBrowserViewModel {
36 const cloned = [...boards];
37 const filteredBoards = filterBoards(cloned, vm.query);
38 const selectedId = resolveSelection(vm.selectedId, filteredBoards);
39 return { ...vm, boards: cloned, filteredBoards, selectedId };
40 },
41
42 setQuery(vm: FileBrowserViewModel, query: string): FileBrowserViewModel {
43 const normalized = normalizeQuery(query);
44 const filteredBoards = filterBoards(vm.boards, normalized);
45 const selectedId = resolveSelection(vm.selectedId, filteredBoards);
46 return { ...vm, query: normalized, filteredBoards, selectedId };
47 },
48
49 select(vm: FileBrowserViewModel, boardId: string | null): FileBrowserViewModel {
50 const selectedId = resolveSelection(boardId, vm.filteredBoards);
51 return { ...vm, selectedId };
52 },
53};
54
55function normalizeQuery(query?: string | null): string {
56 return query?.trim() ?? "";
57}
58
59function filterBoards(boards: BoardMeta[], query: string): BoardMeta[] {
60 if (!query) {
61 return [...boards];
62 }
63 const needle = query.toLowerCase();
64 return boards.filter((board) => {
65 const nameMatch = board.name.toLowerCase().includes(needle);
66 const idMatch = board.id.toLowerCase().includes(needle);
67 return nameMatch || idMatch;
68 });
69}
70
71function resolveSelection(requested: string | null, boards: BoardMeta[]): string | null {
72 if (requested && boards.some((board) => board.id === requested)) {
73 return requested;
74 }
75 return boards[0]?.id ?? null;
76}
77
78function createActions(repo: DocRepo): FileBrowserActions {
79 return {
80 async open(boardId: string) {
81 await repo.openBoard(boardId);
82 },
83 async create(name: string) {
84 return repo.createBoard(name);
85 },
86 async rename(boardId: string, name: string) {
87 await repo.renameBoard(boardId, name);
88 },
89 async delete(boardId: string) {
90 await repo.deleteBoard(boardId);
91 },
92 };
93}