web based infinite canvas
2
fork

Configure Feed

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

feat: Implement desktop file operations and UI controls

+650 -133
+11 -11
TODO.txt
··· 126 126 Goal: same app works as a desktop app with filesystem access. 127 127 128 128 Tauri + SvelteKit integration: 129 - [ ] Configure SvelteKit for static/SPA output 130 - [ ] Ensure SSR is disabled for desktop build 131 - [ ] Configure Tauri to load the built assets 129 + [x] Configure SvelteKit for static/SPA output 130 + [x] Ensure SSR is disabled for desktop build 131 + [x] Configure Tauri to load the built assets 132 132 133 133 File dialogs + FS: 134 - [ ] Implement "Save As…" using Tauri dialog + fs APIs 135 - [ ] Implement "Open…" using Tauri dialog + fs APIs 136 - [ ] Add recent files list (v0: store paths in Tauri local storage) 134 + [x] Implement "Save As…" using Tauri dialog + fs APIs 135 + [x] Implement "Open…" using Tauri dialog + fs APIs 136 + [x] Add recent files list (v0: store paths in Tauri local storage) 137 137 138 138 (DoD): 139 139 - Desktop app opens/saves JSON files on disk and reopens them correctly. ··· 227 227 - v0: show *.Inkfinite.json files in workspace 228 228 - v1: tree view with folders 229 229 [ ] Implement file actions: 230 - - New: create new file 231 - - Rename: rename file 232 - - Delete: delete file 233 - - Open: load file into editor 234 - - Export: save JSON 230 + - [x] New: create new file 231 + - [ ] Rename: rename file 232 + - [ ] Delete: delete file 233 + - [x] Open: load file into editor 234 + - [x] Export: save JSON 235 235 236 236 (DoD): 237 237 - Desktop: pick a folder, browse files, open/save boards from disk.
+174 -14
apps/web/src/lib/canvas/Canvas.svelte
··· 4 4 import TitleBar from '$lib/components/TitleBar.svelte'; 5 5 import Toolbar from '$lib/components/Toolbar.svelte'; 6 6 import { createInputAdapter, type InputAdapter } from '$lib/input'; 7 + import type { DesktopDocRepo } from '$lib/persistence/desktop'; 7 8 import { createPlatformRepo, detectPlatform } from '$lib/platform'; 8 9 import { 9 10 createPersistenceManager, ··· 32 33 shapeBounds, 33 34 switchTool, 34 35 type Action, 36 + type BoardMeta, 35 37 type CommandKind, 36 38 type DocRepo, 37 39 type LoadedDoc, ··· 53 55 }); 54 56 let persistenceStatusStore = $state<StatusStore>(fallbackStatusStore); 55 57 let activeBoardId: string | null = null; 58 + let desktopRepo: DesktopDocRepo | null = null; 59 + let desktopBoards = $state<BoardMeta[]>([]); 60 + let desktopFileName = $state<string | null>(null); 61 + let removeBeforeUnload: (() => void) | null = null; 56 62 57 63 const store = new Store(undefined, { 58 64 onHistoryEvent: (event) => { ··· 115 121 ); 116 122 store.executeCommand(command); 117 123 syncHandleState(); 124 + } 125 + 126 + function setActiveBoardId(boardId: string) { 127 + activeBoardId = boardId; 128 + persistenceManager?.setActiveBoard(boardId); 129 + } 130 + 131 + function updateDesktopFileState() { 132 + if (!desktopRepo) { 133 + desktopFileName = null; 134 + return; 135 + } 136 + const handle = desktopRepo.getCurrentFile(); 137 + desktopFileName = handle?.name ?? null; 138 + } 139 + 140 + async function refreshDesktopBoards(): Promise<BoardMeta[]> { 141 + if (!desktopRepo) { 142 + desktopBoards = []; 143 + return []; 144 + } 145 + try { 146 + const boards = await desktopRepo.listBoards(); 147 + desktopBoards = boards; 148 + return boards; 149 + } catch (error) { 150 + console.error('Failed to list boards', error); 151 + desktopBoards = []; 152 + return []; 153 + } 154 + } 155 + 156 + function isUserCancelled(error: unknown) { 157 + return error instanceof Error && /cancel/i.test(error.message); 118 158 } 119 159 120 160 const handleCursorMap: Record<string, string> = { ··· 724 764 } 725 765 } 726 766 767 + async function handleDesktopOpen() { 768 + if (!desktopRepo || !repo) { 769 + return; 770 + } 771 + try { 772 + const opened = await desktopRepo.openFromDialog(); 773 + setActiveBoardId(opened.boardId); 774 + applyLoadedDoc(opened.doc); 775 + updateDesktopFileState(); 776 + await refreshDesktopBoards(); 777 + } catch (error) { 778 + if (isUserCancelled(error)) { 779 + return; 780 + } 781 + console.error('Failed to open board', error); 782 + } 783 + } 784 + 785 + async function handleDesktopNewBoard() { 786 + if (!repo) { 787 + return; 788 + } 789 + try { 790 + const boardId = await repo.createBoard('Untitled'); 791 + const loaded = await repo.loadDoc(boardId); 792 + setActiveBoardId(boardId); 793 + applyLoadedDoc(loaded); 794 + updateDesktopFileState(); 795 + await refreshDesktopBoards(); 796 + } catch (error) { 797 + if (isUserCancelled(error)) { 798 + return; 799 + } 800 + console.error('Failed to create board', error); 801 + } 802 + } 803 + 804 + async function handleDesktopSaveAs() { 805 + if (!repo || !activeBoardId) { 806 + return; 807 + } 808 + try { 809 + const snapshot = await repo.exportBoard(activeBoardId); 810 + const newBoardId = await repo.importBoard(snapshot); 811 + const loaded = await repo.loadDoc(newBoardId); 812 + setActiveBoardId(newBoardId); 813 + applyLoadedDoc(loaded); 814 + updateDesktopFileState(); 815 + await refreshDesktopBoards(); 816 + } catch (error) { 817 + if (isUserCancelled(error)) { 818 + return; 819 + } 820 + console.error('Failed to save board', error); 821 + } 822 + } 823 + 824 + async function handleDesktopRecentSelect(boardId: string) { 825 + if (!repo) { 826 + return; 827 + } 828 + try { 829 + const loaded = await repo.loadDoc(boardId); 830 + setActiveBoardId(boardId); 831 + applyLoadedDoc(loaded); 832 + updateDesktopFileState(); 833 + await refreshDesktopBoards(); 834 + } catch (error) { 835 + console.error('Failed to load board', error); 836 + } 837 + } 838 + 727 839 function applySnapping(action: Action): Action { 728 840 const snap = snapStore.get(); 729 841 if (!snap.snapEnabled || !snap.gridEnabled) { ··· 756 868 let disposed = false; 757 869 758 870 const initialize = async () => { 759 - const { repo: platformRepo, platform: detectedPlatform, db } = await createPlatformRepo(); 871 + const { 872 + repo: platformRepo, 873 + platform: detectedPlatform, 874 + db, 875 + desktop: desktopInstance 876 + } = await createPlatformRepo(); 877 + if (disposed) { 878 + return; 879 + } 760 880 repo = platformRepo; 881 + if (detectedPlatform === 'desktop' && desktopInstance) { 882 + desktopRepo = desktopInstance; 883 + } else { 884 + desktopRepo = null; 885 + desktopBoards = []; 886 + desktopFileName = null; 887 + } 761 888 762 889 if (detectedPlatform === 'web' && db) { 763 890 persistenceManager = createPersistenceManager(db, repo, { sink: { debounceMs: 200 } }); ··· 765 892 persistenceStatusStore = persistenceManager.status; 766 893 } else { 767 894 const { createPersistenceSink } = await import('inkfinite-core'); 895 + if (disposed) { 896 + return; 897 + } 768 898 sink = createPersistenceSink(repo, { debounceMs: 500 }); 769 899 } 770 900 ··· 780 910 if (disposed) { 781 911 return; 782 912 } 783 - activeBoardId = id; 913 + setActiveBoardId(id); 784 914 const loaded = await repoInstance.loadDoc(id); 785 915 if (!disposed) { 786 - persistenceManager?.setActiveBoard(id); 787 916 applyLoadedDoc(loaded); 788 917 } 789 918 } else { 790 - const id = await repoInstance.createBoard('Untitled'); 919 + const boards = await refreshDesktopBoards(); 920 + let id = boards[0]?.id ?? null; 921 + if (!id) { 922 + id = await repoInstance.createBoard('Untitled'); 923 + } 791 924 if (disposed) { 792 925 return; 793 926 } 794 - activeBoardId = id; 927 + setActiveBoardId(id); 795 928 const loaded = await repoInstance.loadDoc(id); 796 929 if (!disposed) { 797 930 applyLoadedDoc(loaded); 931 + updateDesktopFileState(); 798 932 } 933 + await refreshDesktopBoards(); 799 934 } 800 935 } catch (error) { 801 936 console.error('Failed to load board', error); ··· 803 938 }; 804 939 805 940 await hydrate(); 941 + if (disposed) { 942 + return; 943 + } 806 944 807 945 function getCamera() { 808 946 return store.getState().camera; 809 947 } 810 948 811 - renderer = createRenderer(canvas!, store, { 949 + const currentCanvas = canvas; 950 + if (!currentCanvas) { 951 + return; 952 + } 953 + 954 + renderer = createRenderer(currentCanvas, store, { 812 955 snapProvider, 813 956 cursorProvider, 814 957 pointerStateProvider, 815 958 handleProvider 816 959 }); 817 960 inputAdapter = createInputAdapter({ 818 - canvas: canvas!, 961 + canvas: currentCanvas, 819 962 getCamera, 820 963 getViewport, 821 964 onAction: handleAction, 822 965 onCursorUpdate: (world, screen) => cursorStore.updateCursor(world, screen) 823 966 }); 824 967 825 - function handleBeforeUnload() { 826 - if (sink) { 827 - void sink.flush(); 968 + if (typeof window !== 'undefined') { 969 + function handleBeforeUnload() { 970 + if (sink) { 971 + void sink.flush(); 972 + } 828 973 } 974 + 975 + window.addEventListener('beforeunload', handleBeforeUnload); 976 + removeBeforeUnload = () => window.removeEventListener('beforeunload', handleBeforeUnload); 829 977 } 830 - 831 - window.addEventListener('beforeunload', handleBeforeUnload); 832 978 }; 833 979 834 - initialize(); 980 + void initialize(); 835 981 836 982 return () => { 837 983 disposed = true; ··· 839 985 }); 840 986 841 987 onDestroy(() => { 988 + removeBeforeUnload?.(); 989 + removeBeforeUnload = null; 842 990 renderer?.dispose(); 843 991 inputAdapter?.dispose(); 844 992 if (sink) { 845 993 void sink.flush(); 846 994 } 847 995 repo = null; 996 + desktopRepo = null; 997 + desktopBoards = []; 998 + desktopFileName = null; 848 999 sink = null; 849 1000 activeBoardId = null; 850 1001 persistenceManager?.dispose(); ··· 855 1006 </script> 856 1007 857 1008 <div class="editor"> 858 - <TitleBar /> 1009 + <TitleBar 1010 + {platform} 1011 + desktop={{ 1012 + fileName: desktopFileName, 1013 + recentBoards: desktopBoards, 1014 + onOpen: handleDesktopOpen, 1015 + onNew: handleDesktopNewBoard, 1016 + onSaveAs: handleDesktopSaveAs, 1017 + onSelectBoard: handleDesktopRecentSelect 1018 + }} /> 859 1019 <Toolbar 860 1020 currentTool={currentToolId} 861 1021 onToolChange={handleToolChange}
+141 -6
apps/web/src/lib/components/TitleBar.svelte
··· 1 1 <script lang="ts"> 2 2 import Dialog from '$lib/components/Dialog.svelte'; 3 + import type { Platform } from '$lib/platform'; 4 + import type { BoardMeta } from 'inkfinite-core'; 3 5 import icon from '../assets/favicon.svg'; 4 6 5 7 const helpLinks = [ 6 - { label: 'Project README', href: 'https://github.com/stormlightlabs/inkfinite', external: true }, 7 - { label: 'Issue Tracker', href: 'https://github.com/stormlightlabs/inkfinite/issues', external: true } 8 + { 9 + label: 'Project README', 10 + href: 'https://github.com/stormlightlabs/inkfinite', 11 + external: true 12 + }, 13 + { 14 + label: 'Issue Tracker', 15 + href: 'https://github.com/stormlightlabs/inkfinite/issues', 16 + external: true 17 + } 8 18 ]; 9 19 10 20 const keyboardTips = [ ··· 13 23 'Scroll to zoom, double-click to reset view' 14 24 ]; 15 25 26 + type DesktopControls = { 27 + fileName: string | null; 28 + recentBoards: BoardMeta[]; 29 + onOpen?: () => void | Promise<void>; 30 + onNew?: () => void | Promise<void>; 31 + onSaveAs?: () => void | Promise<void>; 32 + onSelectBoard?: (boardId: string) => void | Promise<void>; 33 + }; 34 + 35 + type Props = { platform?: Platform; desktop?: DesktopControls }; 36 + 37 + let { platform = 'web', desktop }: Props = $props(); 38 + 16 39 let infoOpen = $state(false); 17 40 function openInfo() { 18 41 infoOpen = true; ··· 20 43 function closeInfo() { 21 44 infoOpen = false; 22 45 } 46 + 47 + function invokeDesktopAction(action?: () => void | Promise<void>) { 48 + if (action) { 49 + void action(); 50 + } 51 + } 52 + 53 + function handleRecentSelect(event: Event) { 54 + if (!desktop?.onSelectBoard) { 55 + return; 56 + } 57 + const select = event.currentTarget as HTMLSelectElement; 58 + const boardId = select.value; 59 + if (boardId) { 60 + void desktop.onSelectBoard(boardId); 61 + } 62 + select.value = ''; 63 + } 64 + 65 + function desktopFileLabel() { 66 + return desktop?.fileName ?? 'Unsaved board'; 67 + } 23 68 </script> 24 69 25 70 <header class="titlebar"> ··· 32 77 <div class="titlebar__tagline">Infinite canvas playground</div> 33 78 </div> 34 79 </div> 80 + {#if platform === 'desktop' && desktop} 81 + <div class="titlebar__desktop"> 82 + <div class="titlebar__file" aria-live="polite">{desktopFileLabel()}</div> 83 + <div class="titlebar__desktop-actions"> 84 + <button 85 + class="titlebar__desktop-button" 86 + type="button" 87 + onclick={() => invokeDesktopAction(desktop.onNew)} 88 + aria-label="Create new board"> 89 + New… 90 + </button> 91 + <button 92 + class="titlebar__desktop-button" 93 + type="button" 94 + onclick={() => invokeDesktopAction(desktop.onOpen)} 95 + aria-label="Open board from disk"> 96 + Open… 97 + </button> 98 + <button 99 + class="titlebar__desktop-button" 100 + type="button" 101 + onclick={() => invokeDesktopAction(desktop.onSaveAs)} 102 + aria-label="Save board as new file"> 103 + Save As… 104 + </button> 105 + {#if desktop.recentBoards.length > 0} 106 + <label class="titlebar__recent"> 107 + <span>Recent</span> 108 + <select onchange={handleRecentSelect} aria-label="Switch to recent board"> 109 + <option value="">Select…</option> 110 + {#each desktop.recentBoards as board (`${board.id}:${board.name}`)} 111 + <option value={board.id}>{board.name}</option> 112 + {/each} 113 + </select> 114 + </label> 115 + {/if} 116 + </div> 117 + </div> 118 + {/if} 35 119 <div class="titlebar__spacer"></div> 36 120 <button class="titlebar__info" onclick={openInfo} aria-label="About Inkfinite"> 37 121 <span aria-hidden="true">ℹ︎</span> ··· 43 127 <section class="about"> 44 128 <h1>About Inkfinite</h1> 45 129 <p> 46 - Inkfinite is a Svelte-native infinite canvas prototype. The goal is to build a cross-platform editor with a 47 - framework-agnostic core so the same engine powers both the web and desktop apps. 130 + Inkfinite is a Svelte-native infinite canvas prototype. The goal is to build a cross-platform 131 + editor with a framework-agnostic core so the same engine powers both the web and desktop 132 + apps. 48 133 </p> 49 134 50 135 <div class="about__section"> 51 136 <h2>Quick Tips</h2> 52 137 <ul> 53 - {#each keyboardTips as tip} 138 + {#each keyboardTips as tip (tip)} 54 139 <li>{tip}</li> 55 140 {/each} 56 141 </ul> ··· 59 144 <div class="about__section"> 60 145 <h2>Need help?</h2> 61 146 <ul> 62 - {#each helpLinks as link} 147 + {#each helpLinks as link (link.href)} 63 148 <li> 149 + <!-- eslint-disable-next-line svelte/no-navigation-without-resolve --> 64 150 <a href={link.href} target={link.external ? '_blank' : undefined} rel="noreferrer"> 65 151 {link.label} 66 152 </a> ··· 85 171 display: flex; 86 172 align-items: center; 87 173 gap: 12px; 174 + } 175 + 176 + .titlebar__desktop { 177 + display: flex; 178 + align-items: center; 179 + gap: 12px; 180 + } 181 + 182 + .titlebar__file { 183 + font-size: 13px; 184 + color: var(--text-secondary); 185 + } 186 + 187 + .titlebar__desktop-actions { 188 + display: flex; 189 + align-items: center; 190 + gap: 8px; 191 + flex-wrap: wrap; 192 + } 193 + 194 + .titlebar__desktop-button { 195 + border: 1px solid var(--border); 196 + background: var(--surface); 197 + color: var(--text); 198 + border-radius: 6px; 199 + padding: 4px 10px; 200 + font-size: 13px; 201 + cursor: pointer; 202 + } 203 + 204 + .titlebar__desktop-button:hover { 205 + background: var(--surface-elevated); 206 + } 207 + 208 + .titlebar__recent { 209 + display: flex; 210 + align-items: center; 211 + gap: 6px; 212 + font-size: 12px; 213 + color: var(--text-secondary); 214 + } 215 + 216 + .titlebar__recent select { 217 + font-size: 12px; 218 + padding: 4px 6px; 219 + border-radius: 4px; 220 + border: 1px solid var(--border); 221 + background: var(--surface); 222 + color: var(--text); 88 223 } 89 224 90 225 .titlebar__logo {
+117 -36
apps/web/src/lib/persistence/desktop.ts
··· 3 3 * Used when the web app is running inside Tauri 4 4 */ 5 5 6 - import type { BoardExport, BoardMeta, DocPatch, DocRepo, LoadedDoc } from "inkfinite-core"; 6 + import type { BoardExport, BoardMeta, DocPatch, DocRepo, LoadedDoc, PageRecord } from "inkfinite-core"; 7 7 import { 8 8 createFileData, 9 9 createId, 10 10 type DesktopFileData, 11 11 type FileHandle, 12 12 loadedDocFromFileData, 13 - PageRecord, 14 13 parseDesktopFile, 15 14 serializeDesktopFile, 16 15 } from "inkfinite-core"; 17 16 import type { DesktopFileOps } from "../fileops"; 18 17 18 + export type DesktopDocRepo = DocRepo & { 19 + kind: "desktop"; 20 + getCurrentFile(): FileHandle | null; 21 + openFromDialog(): Promise<{ boardId: string; doc: LoadedDoc }>; 22 + }; 23 + 24 + export function isDesktopRepo(repo: DocRepo): repo is DesktopDocRepo { 25 + return (repo as DesktopDocRepo).kind === "desktop"; 26 + } 27 + 19 28 /** 20 29 * Create a desktop file-based DocRepo 21 30 * This implementation manages a single document loaded from disk 22 31 */ 23 - export function createDesktopDocRepo(fileOps: DesktopFileOps): DocRepo { 32 + export function createDesktopDocRepo(fileOps: DesktopFileOps): DesktopDocRepo { 24 33 let currentFile: FileHandle | null = null; 25 34 let currentBoard: BoardMeta | null = null; 26 35 let currentDoc: LoadedDoc | null = null; 36 + const boardFiles = new Map<string, FileHandle>(); 37 + 38 + type StoredHandle = { path: string; name?: string }; 39 + 40 + function setCurrentState(file: FileHandle, board: BoardMeta, doc: LoadedDoc) { 41 + currentFile = file; 42 + currentBoard = board; 43 + currentDoc = doc; 44 + boardFiles.set(board.id, file); 45 + } 46 + 47 + async function loadFromHandle(handle: StoredHandle): Promise<LoadedDoc> { 48 + const content = await fileOps.readFile(handle.path); 49 + const fileData = parseDesktopFile(content); 50 + const doc = loadedDocFromFileData(fileData); 51 + const normalizedHandle: FileHandle = { 52 + path: handle.path, 53 + name: handle.name ?? handle.path.split("/").pop() ?? "Untitled", 54 + }; 55 + setCurrentState(normalizedHandle, fileData.board, doc); 56 + await fileOps.addRecentFile(normalizedHandle); 57 + return doc; 58 + } 59 + 60 + async function loadFromPath(path: string): Promise<LoadedDoc> { 61 + const handle: FileHandle = { path, name: path.split("/").pop() || "Untitled" }; 62 + return loadFromHandle(handle); 63 + } 27 64 28 65 async function listBoards(): Promise<BoardMeta[]> { 29 - // TODO: cache metadata or read it from files 30 - // const recent = await fileOps.getRecentFiles(); 31 - return []; 66 + const recent = await fileOps.getRecentFiles(); 67 + const boards: BoardMeta[] = []; 68 + 69 + for (const handle of recent) { 70 + try { 71 + const content = await fileOps.readFile(handle.path); 72 + const fileData = parseDesktopFile(content); 73 + boards.push(fileData.board); 74 + boardFiles.set(fileData.board.id, handle); 75 + } catch { 76 + await fileOps.removeRecentFile(handle.path); 77 + } 78 + } 79 + 80 + return boards.sort((a, b) => b.updatedAt - a.updatedAt); 81 + } 82 + 83 + function createDefaultPage(name: string): PageRecord { 84 + return { id: createId("page"), name, shapeIds: [] }; 32 85 } 33 86 34 87 async function createBoard(name: string): Promise<string> { 35 88 const boardId = createId("board"); 36 89 const timestamp = Date.now(); 37 - const page = PageRecord.create("Page 1"); 90 + const page = createDefaultPage("Page 1"); 38 91 39 92 const board: BoardMeta = { 40 93 id: boardId, ··· 55 108 56 109 await fileOps.writeFile(path, serializeDesktopFile(fileData)); 57 110 58 - currentFile = { path, name: path.split("/").pop() || name }; 59 - currentBoard = board; 60 - currentDoc = loadedDocFromFileData(fileData); 111 + const handle = { path, name: path.split("/").pop() || name }; 112 + setCurrentState(handle, board, loadedDocFromFileData(fileData)); 61 113 62 - await fileOps.addRecentFile(currentFile); 114 + await fileOps.addRecentFile(handle); 63 115 return boardId; 64 116 } 65 117 66 118 async function renameBoard(boardId: string, name: string): Promise<void> { 119 + if (!currentBoard || currentBoard.id !== boardId) { 120 + await loadDoc(boardId); 121 + } 67 122 if (!currentBoard || !currentDoc || !currentFile) { 68 123 throw new Error("No board loaded"); 69 124 } ··· 79 134 ); 80 135 81 136 await fileOps.writeFile(currentFile.path, serializeDesktopFile(fileData)); 137 + boardFiles.set(currentBoard.id, currentFile); 82 138 } 83 139 84 - async function deleteBoard(_boardId: string): Promise<void> { 85 - if (currentFile) { 86 - await fileOps.removeRecentFile(currentFile.path); 140 + async function deleteBoard(boardId: string): Promise<void> { 141 + const handle = boardFiles.get(boardId); 142 + if (handle) { 143 + await fileOps.removeRecentFile(handle.path); 144 + boardFiles.delete(boardId); 145 + } 146 + if (currentBoard?.id === boardId) { 87 147 currentFile = null; 88 148 currentBoard = null; 89 149 currentDoc = null; ··· 94 154 if (currentDoc && currentBoard?.id === boardId) { 95 155 return currentDoc; 96 156 } 97 - 98 - const path = await fileOps.showOpenDialog(); 99 - if (!path) { 100 - throw new Error("Open cancelled"); 157 + const handle = boardFiles.get(boardId); 158 + if (!handle) { 159 + throw new Error(`Unknown board: ${boardId}`); 101 160 } 102 - 103 - const content = await fileOps.readFile(path); 104 - const fileData = parseDesktopFile(content); 105 - 106 - currentFile = { path, name: path.split("/").pop() || "Untitled" }; 107 - currentBoard = fileData.board; 108 - currentDoc = loadedDocFromFileData(fileData); 109 - 110 - await fileOps.addRecentFile(currentFile); 111 - 112 - return currentDoc; 161 + try { 162 + return await loadFromHandle(handle); 163 + } catch (error) { 164 + await fileOps.removeRecentFile(handle.path); 165 + boardFiles.delete(boardId); 166 + throw error; 167 + } 113 168 } 114 169 115 170 async function applyDocPatch(boardId: string, patch: DocPatch): Promise<void> { ··· 173 228 ); 174 229 175 230 await fileOps.writeFile(currentFile.path, serializeDesktopFile(fileData)); 231 + boardFiles.set(currentBoard.id, currentFile); 176 232 } 177 233 178 234 async function exportBoard(_boardId: string): Promise<BoardExport> { ··· 207 263 208 264 await fileOps.writeFile(path, serializeDesktopFile(fileData)); 209 265 210 - currentFile = { path, name: path.split("/").pop() || board.name }; 211 - currentBoard = board; 212 - currentDoc = loadedDocFromFileData(fileData); 266 + const handle = { path, name: path.split("/").pop() || board.name }; 267 + setCurrentState(handle, board, loadedDocFromFileData(fileData)); 213 268 214 - await fileOps.addRecentFile(currentFile); 269 + await fileOps.addRecentFile(handle); 215 270 216 271 return boardId; 217 272 } 218 273 219 - return { listBoards, createBoard, renameBoard, deleteBoard, loadDoc, applyDocPatch, exportBoard, importBoard }; 274 + async function openFromDialog(): Promise<{ boardId: string; doc: LoadedDoc }> { 275 + const path = await fileOps.showOpenDialog(); 276 + if (!path) { 277 + throw new Error("Open cancelled"); 278 + } 279 + const doc = await loadFromPath(path); 280 + if (!currentBoard) { 281 + throw new Error("Failed to open file"); 282 + } 283 + return { boardId: currentBoard.id, doc }; 284 + } 285 + 286 + return { 287 + kind: "desktop", 288 + listBoards, 289 + createBoard, 290 + renameBoard, 291 + deleteBoard, 292 + loadDoc, 293 + applyDocPatch, 294 + exportBoard, 295 + importBoard, 296 + getCurrentFile: () => currentFile, 297 + openFromDialog, 298 + }; 220 299 } 221 300 222 301 /** 223 302 * Get current file handle (for showing in title bar, etc.) 224 303 */ 225 - export function getCurrentFile(_repo: unknown): FileHandle | null { 226 - // TODO: expose this properly 304 + export function getCurrentFile(repo: DocRepo): FileHandle | null { 305 + if (isDesktopRepo(repo)) { 306 + return repo.getCurrentFile(); 307 + } 227 308 return null; 228 309 }
+5 -6
apps/web/src/lib/platform.ts
··· 1 1 import type { DocRepo } from "inkfinite-core"; 2 2 import { createWebDocRepo, InkfiniteDB } from "inkfinite-core"; 3 3 import { createDesktopFileOps } from "./fileops"; 4 - import { createDesktopDocRepo } from "./persistence/desktop"; 4 + import { createDesktopDocRepo, type DesktopDocRepo } from "./persistence/desktop"; 5 5 6 6 export type Platform = "web" | "desktop"; 7 7 8 - /** 9 - * Detect if we're running in Tauri 10 - */ 11 8 export function detectPlatform(): Platform { 12 9 if (typeof window !== "undefined" && "__TAURI__" in window) { 13 10 return "desktop"; ··· 15 12 return "web"; 16 13 } 17 14 15 + export type PlatformRepoResult = { repo: DocRepo; platform: Platform; db?: InkfiniteDB; desktop?: DesktopDocRepo }; 16 + 18 17 /** 19 18 * Create the appropriate DocRepo based on platform 20 19 */ 21 - export async function createPlatformRepo(): Promise<{ repo: DocRepo; platform: Platform; db?: InkfiniteDB }> { 20 + export async function createPlatformRepo(): Promise<PlatformRepoResult> { 22 21 const platform = detectPlatform(); 23 22 24 23 if (platform === "desktop") { 25 24 const fileOps = createDesktopFileOps(); 26 25 const repo = createDesktopDocRepo(fileOps); 27 - return { repo, platform }; 26 + return { repo, platform, desktop: repo }; 28 27 } else { 29 28 const db = new InkfiniteDB(); 30 29 const repo = createWebDocRepo(db);
+3 -1
apps/web/src/lib/tests/Canvas.history.test.ts
··· 73 73 order: { pageIds: ["page:1"], shapeOrder: { "page:1": [] } }, 74 74 }); 75 75 76 - vi.mock("inkfinite-core", () => { 76 + vi.mock("inkfinite-core", async () => { 77 + const actual = await vi.importActual<typeof import("inkfinite-core")>("inkfinite-core"); 77 78 const { sinkEnqueueSpy, storeInstances } = coreMocks; 78 79 79 80 class BaseTool { ··· 235 236 } 236 237 237 238 return { 239 + ...actual, 238 240 ArrowTool: class extends BaseTool { 239 241 constructor() { 240 242 super("arrow");
+2 -11
apps/web/src/lib/tests/Canvas.keyboard.test.ts
··· 100 100 it("should handle space key for panning mode", async () => { 101 101 render(Canvas); 102 102 await vi.waitFor(() => expect(actionHandlers.length).toBeGreaterThan(0)); 103 + await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); 104 + coreMocks.executeCommandSpy.mockClear(); 103 105 104 106 const handler = actionHandlers[0]; 105 107 106 - // Press space key 107 108 handler({ 108 109 type: "key-down", 109 110 key: " ", ··· 113 114 timestamp: Date.now(), 114 115 }); 115 116 116 - // Space key should enable panning mode (no command executed) 117 117 expect(coreMocks.executeCommandSpy).not.toHaveBeenCalled(); 118 118 119 - // Release space key 120 119 handler({ 121 120 type: "key-up", 122 121 key: " ", ··· 134 133 135 134 const handler = actionHandlers[0]; 136 135 137 - // Wait for shapes to load 138 136 await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); 139 137 coreMocks.executeCommandSpy.mockClear(); 140 138 141 - // Press ArrowRight to nudge 142 139 handler({ 143 140 type: "key-down", 144 141 key: "ArrowRight", ··· 148 145 timestamp: Date.now(), 149 146 }); 150 147 151 - // Should execute a nudge command 152 148 await vi.waitFor(() => { 153 149 const calls = coreMocks.executeCommandSpy.mock.calls; 154 150 const nudgeCalls = calls.filter((call) => call[0]?.name === "Nudge"); ··· 164 160 await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); 165 161 coreMocks.executeCommandSpy.mockClear(); 166 162 167 - // Press ArrowDown with Shift 168 163 handler({ 169 164 type: "key-down", 170 165 key: "ArrowDown", ··· 189 184 await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); 190 185 coreMocks.executeCommandSpy.mockClear(); 191 186 192 - // Press Cmd+D (on Mac) or Ctrl+D (on other platforms) 193 187 const isMac = navigator.userAgent.toUpperCase().includes("MAC"); 194 188 handler({ 195 189 type: "key-down", ··· 265 259 await vi.waitFor(() => expect(coreMocks.executeCommandSpy).toHaveBeenCalled(), { timeout: 2000 }); 266 260 coreMocks.executeCommandSpy.mockClear(); 267 261 268 - // Press space 269 262 handler({ 270 263 type: "key-down", 271 264 key: " ", ··· 275 268 timestamp: Date.now(), 276 269 }); 277 270 278 - // Try to nudge with arrow key while space is held 279 271 handler({ 280 272 type: "key-down", 281 273 key: "ArrowRight", ··· 285 277 timestamp: Date.now(), 286 278 }); 287 279 288 - // Should not execute nudge command while panning mode is active 289 280 expect(coreMocks.executeCommandSpy).not.toHaveBeenCalled(); 290 281 }); 291 282 });
+36 -1
apps/web/src/lib/tests/TitleBar.svelte.test.ts
··· 1 - import { beforeEach, describe, expect, it } from "vitest"; 1 + import { tick } from "svelte"; 2 + import { beforeEach, describe, expect, it, vi } from "vitest"; 2 3 import { cleanup, render } from "vitest-browser-svelte"; 3 4 import TitleBar from "../components/TitleBar.svelte"; 4 5 ··· 22 23 button.click(); 23 24 await new Promise((resolve) => setTimeout(resolve, 0)); 24 25 expect(container.querySelector(".about")).toBeTruthy(); 26 + }); 27 + 28 + it("shows desktop controls when running on desktop", async () => { 29 + const onOpen = vi.fn(); 30 + const onNew = vi.fn(); 31 + const onSaveAs = vi.fn(); 32 + const onSelectBoard = vi.fn(); 33 + const recentBoards = [{ id: "board-1", name: "Board 1", createdAt: Date.now(), updatedAt: Date.now() }]; 34 + 35 + const { container } = render(TitleBar, { 36 + platform: "desktop", 37 + desktop: { fileName: "Board 1", recentBoards, onOpen, onNew, onSaveAs, onSelectBoard }, 38 + }); 39 + 40 + const buttons = container.querySelectorAll(".titlebar__desktop-button"); 41 + expect(buttons).toHaveLength(3); 42 + 43 + (buttons[0] as HTMLButtonElement).click(); 44 + await tick(); 45 + expect(onNew).toHaveBeenCalled(); 46 + 47 + (buttons[1] as HTMLButtonElement).click(); 48 + await tick(); 49 + expect(onOpen).toHaveBeenCalled(); 50 + 51 + (buttons[2] as HTMLButtonElement).click(); 52 + await tick(); 53 + expect(onSaveAs).toHaveBeenCalled(); 54 + 55 + const select = container.querySelector(".titlebar__recent select") as HTMLSelectElement; 56 + select.value = recentBoards[0].id; 57 + select.dispatchEvent(new Event("change", { bubbles: true })); 58 + await tick(); 59 + expect(onSelectBoard).toHaveBeenCalledWith(recentBoards[0].id); 25 60 }); 26 61 });
+17 -28
apps/web/src/lib/tests/Toolbar.accessibility.test.ts
··· 3 3 import { cleanup, render } from "vitest-browser-svelte"; 4 4 import Toolbar from "../components/Toolbar.svelte"; 5 5 6 + // TODO: reuse this pattern 7 + function renderToolbar(store: Store) { 8 + const target = document.createElement("div"); 9 + document.body.appendChild(target); 10 + return render(Toolbar, { 11 + target, 12 + props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 13 + }); 14 + } 15 + 6 16 describe("Toolbar accessibility", () => { 7 17 beforeEach(() => { 8 18 cleanup(); ··· 10 20 11 21 it("should have proper ARIA labels on tool buttons", () => { 12 22 const store = new Store(); 13 - const { container } = render(Toolbar, { 14 - target: document.body, 15 - props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 16 - }); 23 + const { container } = renderToolbar(store); 17 24 18 25 const selectButton = container.querySelector("[data-tool-id=\"select\"]"); 19 26 expect(selectButton?.getAttribute("aria-label")).toBe("Select"); ··· 26 33 27 34 it("should have ARIA attributes on zoom button", () => { 28 35 const store = new Store(); 29 - const { container } = render(Toolbar, { 30 - target: document.body, 31 - props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 32 - }); 36 + const { container } = renderToolbar(store); 33 37 34 38 const zoomButton = container.querySelector(".toolbar__zoom-button"); 35 39 expect(zoomButton?.getAttribute("aria-label")).toBe("Zoom level"); ··· 39 43 40 44 it("should have proper menu roles when zoom menu is open", async () => { 41 45 const store = new Store(); 42 - const { container } = render(Toolbar, { 43 - target: document.body, 44 - props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 45 - }); 46 + const { container } = renderToolbar(store); 46 47 47 48 const zoomButton = container.querySelector(".toolbar__zoom-button") as HTMLButtonElement; 48 49 zoomButton.click(); ··· 62 63 63 64 it("should have ARIA attributes on export button", () => { 64 65 const store = new Store(); 65 - const { container } = render(Toolbar, { 66 - target: document.body, 67 - props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 68 - }); 66 + const { container } = renderToolbar(store); 69 67 70 68 const exportButton = container.querySelector(".toolbar__export-button"); 71 69 expect(exportButton?.getAttribute("aria-label")).toBe("Export drawing"); ··· 75 73 76 74 it("should have proper menu roles when export menu is open", async () => { 77 75 const store = new Store(); 78 - const { container } = render(Toolbar, { 79 - target: document.body, 80 - props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 81 - }); 76 + const { container } = renderToolbar(store); 82 77 83 78 const exportButton = container.querySelector(".toolbar__export-button") as HTMLButtonElement; 84 79 exportButton.click(); ··· 99 94 100 95 it("should have visible focus states on buttons", () => { 101 96 const store = new Store(); 102 - const { container } = render(Toolbar, { 103 - target: document.body, 104 - props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 105 - }); 97 + const { container } = renderToolbar(store); 106 98 107 99 const selectButton = container.querySelector(".toolbar__tool-button") as HTMLElement; 108 100 selectButton.focus(); ··· 112 104 113 105 it("should update aria-expanded when menus are toggled", async () => { 114 106 const store = new Store(); 115 - const { container } = render(Toolbar, { 116 - target: document.body, 117 - props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 118 - }); 107 + const { container } = renderToolbar(store); 119 108 120 109 const zoomButton = container.querySelector(".toolbar__zoom-button") as HTMLButtonElement; 121 110
+12 -12
apps/web/src/lib/tests/Toolbar.colors.test.ts
··· 53 53 cleanup(); 54 54 }); 55 55 56 - it("updates fill color for selected shapes", () => { 57 - const store = createStoreWithRect(); 58 - const { container } = render(Toolbar, { 59 - target: document.body, 56 + function renderToolbar(store: Store) { 57 + const target = document.createElement("div"); 58 + document.body.appendChild(target); 59 + return render(Toolbar, { 60 + target, 60 61 props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 61 62 }); 63 + } 64 + 65 + it("updates fill color for selected shapes", () => { 66 + const store = createStoreWithRect(); 67 + const { container } = renderToolbar(store); 62 68 63 69 const input = container.querySelector("input[aria-label=\"Fill color\"]") as HTMLInputElement | null; 64 70 expect(input).toBeTruthy(); ··· 76 82 77 83 it("updates stroke color for selectable shapes", () => { 78 84 const store = createStoreWithRect(); 79 - const { container } = render(Toolbar, { 80 - target: document.body, 81 - props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 82 - }); 85 + const { container } = renderToolbar(store); 83 86 84 87 const input = container.querySelector("input[aria-label=\"Stroke color\"]") as HTMLInputElement | null; 85 88 expect(input).toBeTruthy(); ··· 97 100 98 101 it("disables fill control when selection has no fillable shapes", () => { 99 102 const store = createStoreWithLine(); 100 - const { container } = render(Toolbar, { 101 - target: document.body, 102 - props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 103 - }); 103 + const { container } = renderToolbar(store); 104 104 105 105 const fillInput = container.querySelector("input[aria-label=\"Fill color\"]") as HTMLInputElement | null; 106 106 const strokeInput = container.querySelector("input[aria-label=\"Stroke color\"]") as HTMLInputElement | null;
+126
apps/web/src/lib/tests/persistence.desktop.test.ts
··· 1 + import { createFileData, type DesktopFileOps, type FileHandle, PageRecord, serializeDesktopFile } from "inkfinite-core"; 2 + import { beforeEach, describe, expect, it } from "vitest"; 3 + import { createDesktopDocRepo } from "../persistence/desktop"; 4 + 5 + function createFakeFileOps() { 6 + const files = new Map<string, string>(); 7 + const recent: FileHandle[] = []; 8 + let nextOpen: string | null = null; 9 + let nextSave: string | null = null; 10 + 11 + const ops: DesktopFileOps = { 12 + async showOpenDialog() { 13 + const value = nextOpen; 14 + nextOpen = null; 15 + return value; 16 + }, 17 + async showSaveDialog(defaultName) { 18 + const value = nextSave ?? `/tmp/${defaultName ?? "untitled"}`; 19 + nextSave = null; 20 + return value; 21 + }, 22 + async readFile(path) { 23 + const content = files.get(path); 24 + if (content === undefined) { 25 + throw new Error(`Missing file: ${path}`); 26 + } 27 + return content; 28 + }, 29 + async writeFile(path, content) { 30 + files.set(path, content); 31 + }, 32 + async getRecentFiles() { 33 + return [...recent]; 34 + }, 35 + async addRecentFile(handle) { 36 + const filtered = recent.filter((entry) => entry.path !== handle.path); 37 + recent.splice(0, recent.length, handle, ...filtered); 38 + }, 39 + async removeRecentFile(path) { 40 + const index = recent.findIndex((entry) => entry.path === path); 41 + if (index >= 0) { 42 + recent.splice(index, 1); 43 + } 44 + }, 45 + async clearRecentFiles() { 46 + recent.splice(0, recent.length); 47 + }, 48 + }; 49 + 50 + return { 51 + ops, 52 + files, 53 + recent, 54 + setNextOpen(path: string | null) { 55 + nextOpen = path; 56 + }, 57 + setNextSave(path: string | null) { 58 + nextSave = path; 59 + }, 60 + }; 61 + } 62 + 63 + describe("createDesktopDocRepo", () => { 64 + const fake = createFakeFileOps(); 65 + 66 + beforeEach(() => { 67 + fake.files.clear(); 68 + fake.recent.splice(0, fake.recent.length); 69 + fake.setNextOpen(null); 70 + fake.setNextSave(null); 71 + }); 72 + 73 + it("creates a board and lists it via recent files", async () => { 74 + const repo = createDesktopDocRepo(fake.ops); 75 + fake.setNextSave("/tmp/board-one.inkfinite.json"); 76 + const boardId = await repo.createBoard("Board One"); 77 + 78 + const boards = await repo.listBoards(); 79 + expect(boards).toHaveLength(1); 80 + expect(boards[0].id).toBe(boardId); 81 + expect(boards[0].name).toBe("Board One"); 82 + 83 + const loaded = await repo.loadDoc(boardId); 84 + expect(Object.keys(loaded.pages)).toHaveLength(1); 85 + }); 86 + 87 + it("opens an existing file via dialog", async () => { 88 + const repo = createDesktopDocRepo(fake.ops); 89 + const page = PageRecord.create("Dialog Page"); 90 + const board = { id: "board-dialog", name: "Dialog Board", createdAt: Date.now(), updatedAt: Date.now() }; 91 + const fileData = createFileData(board, { [page.id]: page }, {}, {}, { 92 + pageIds: [page.id], 93 + shapeOrder: { [page.id]: [] }, 94 + }); 95 + const path = "/tmp/dialog-board.inkfinite.json"; 96 + fake.files.set(path, serializeDesktopFile(fileData)); 97 + fake.setNextOpen(path); 98 + 99 + const opened = await repo.openFromDialog(); 100 + expect(opened.boardId).toBe("board-dialog"); 101 + expect(Object.keys(opened.doc.pages)).toEqual([page.id]); 102 + 103 + const boards = await repo.listBoards(); 104 + expect(boards.some((entry) => entry.id === "board-dialog")).toBe(true); 105 + }); 106 + 107 + it("renames the current board and updates the file", async () => { 108 + const repo = createDesktopDocRepo(fake.ops); 109 + fake.setNextSave("/tmp/rename-board.inkfinite.json"); 110 + const boardId = await repo.createBoard("Old Name"); 111 + await repo.renameBoard(boardId, "New Name"); 112 + 113 + const stored = fake.files.get("/tmp/rename-board.inkfinite.json"); 114 + expect(stored).toBeTruthy(); 115 + const parsed = JSON.parse(String(stored)); 116 + expect(parsed.board.name).toBe("New Name"); 117 + }); 118 + 119 + it("prunes missing recents when listing boards", async () => { 120 + const repo = createDesktopDocRepo(fake.ops); 121 + fake.recent.push({ path: "/tmp/missing.inkfinite.json", name: "Missing" }); 122 + const boards = await repo.listBoards(); 123 + expect(boards).toHaveLength(0); 124 + expect(fake.recent).toHaveLength(0); 125 + }); 126 + });
+2
apps/web/src/routes/+layout.ts
··· 1 1 export const prerender = true; 2 + export const ssr = false; 3 + export const csr = true;
+4 -7
apps/web/svelte.config.js
··· 1 - import adapter from '@sveltejs/adapter-static'; 2 - import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 1 + import adapter from "@sveltejs/adapter-static"; 2 + import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; 3 3 4 4 /** @type {import('@sveltejs/kit').Config} */ 5 5 const config = { 6 - // Consult https://svelte.dev/docs/kit/integrations 7 - // for more information about preprocessors 8 - preprocess: vitePreprocess(), 9 - 10 - kit: { adapter: adapter() } 6 + preprocess: vitePreprocess(), 7 + kit: { adapter: adapter({ fallback: "index.html" }), prerender: { entries: [] } }, 11 8 }; 12 9 13 10 export default config;