diff --git a/TODO.txt b/TODO.txt index 67dc91d..11bfaed 100644 --- a/TODO.txt +++ b/TODO.txt @@ -126,14 +126,14 @@ the Toolbar across web and desktop builds. Goal: same app works as a desktop app with filesystem access. Tauri + SvelteKit integration: -[ ] Configure SvelteKit for static/SPA output -[ ] Ensure SSR is disabled for desktop build -[ ] Configure Tauri to load the built assets +[x] Configure SvelteKit for static/SPA output +[x] Ensure SSR is disabled for desktop build +[x] Configure Tauri to load the built assets File dialogs + FS: -[ ] Implement "Save As…" using Tauri dialog + fs APIs -[ ] Implement "Open…" using Tauri dialog + fs APIs -[ ] Add recent files list (v0: store paths in Tauri local storage) +[x] Implement "Save As…" using Tauri dialog + fs APIs +[x] Implement "Open…" using Tauri dialog + fs APIs +[x] Add recent files list (v0: store paths in Tauri local storage) (DoD): - Desktop app opens/saves JSON files on disk and reopens them correctly. @@ -227,11 +227,11 @@ R3. Desktop: real directory + files (Tauri) - v0: show *.Inkfinite.json files in workspace - v1: tree view with folders [ ] Implement file actions: - - New: create new file - - Rename: rename file - - Delete: delete file - - Open: load file into editor - - Export: save JSON + - [x] New: create new file + - [ ] Rename: rename file + - [ ] Delete: delete file + - [x] Open: load file into editor + - [x] Export: save JSON (DoD): - Desktop: pick a folder, browse files, open/save boards from disk. diff --git a/apps/web/src/lib/canvas/Canvas.svelte b/apps/web/src/lib/canvas/Canvas.svelte index c298d90..f136864 100644 --- a/apps/web/src/lib/canvas/Canvas.svelte +++ b/apps/web/src/lib/canvas/Canvas.svelte @@ -4,6 +4,7 @@ import TitleBar from '$lib/components/TitleBar.svelte'; import Toolbar from '$lib/components/Toolbar.svelte'; import { createInputAdapter, type InputAdapter } from '$lib/input'; + import type { DesktopDocRepo } from '$lib/persistence/desktop'; import { createPlatformRepo, detectPlatform } from '$lib/platform'; import { createPersistenceManager, @@ -32,6 +33,7 @@ shapeBounds, switchTool, type Action, + type BoardMeta, type CommandKind, type DocRepo, type LoadedDoc, @@ -53,6 +55,10 @@ }); let persistenceStatusStore = $state(fallbackStatusStore); let activeBoardId: string | null = null; + let desktopRepo: DesktopDocRepo | null = null; + let desktopBoards = $state([]); + let desktopFileName = $state(null); + let removeBeforeUnload: (() => void) | null = null; const store = new Store(undefined, { onHistoryEvent: (event) => { @@ -117,6 +123,40 @@ syncHandleState(); } + function setActiveBoardId(boardId: string) { + activeBoardId = boardId; + persistenceManager?.setActiveBoard(boardId); + } + + function updateDesktopFileState() { + if (!desktopRepo) { + desktopFileName = null; + return; + } + const handle = desktopRepo.getCurrentFile(); + desktopFileName = handle?.name ?? null; + } + + async function refreshDesktopBoards(): Promise { + if (!desktopRepo) { + desktopBoards = []; + return []; + } + try { + const boards = await desktopRepo.listBoards(); + desktopBoards = boards; + return boards; + } catch (error) { + console.error('Failed to list boards', error); + desktopBoards = []; + return []; + } + } + + function isUserCancelled(error: unknown) { + return error instanceof Error && /cancel/i.test(error.message); + } + const handleCursorMap: Record = { n: 'ns-resize', s: 'ns-resize', @@ -724,6 +764,78 @@ } } + async function handleDesktopOpen() { + if (!desktopRepo || !repo) { + return; + } + try { + const opened = await desktopRepo.openFromDialog(); + setActiveBoardId(opened.boardId); + applyLoadedDoc(opened.doc); + updateDesktopFileState(); + await refreshDesktopBoards(); + } catch (error) { + if (isUserCancelled(error)) { + return; + } + console.error('Failed to open board', error); + } + } + + async function handleDesktopNewBoard() { + if (!repo) { + return; + } + try { + const boardId = await repo.createBoard('Untitled'); + const loaded = await repo.loadDoc(boardId); + setActiveBoardId(boardId); + applyLoadedDoc(loaded); + updateDesktopFileState(); + await refreshDesktopBoards(); + } catch (error) { + if (isUserCancelled(error)) { + return; + } + console.error('Failed to create board', error); + } + } + + async function handleDesktopSaveAs() { + if (!repo || !activeBoardId) { + return; + } + try { + const snapshot = await repo.exportBoard(activeBoardId); + const newBoardId = await repo.importBoard(snapshot); + const loaded = await repo.loadDoc(newBoardId); + setActiveBoardId(newBoardId); + applyLoadedDoc(loaded); + updateDesktopFileState(); + await refreshDesktopBoards(); + } catch (error) { + if (isUserCancelled(error)) { + return; + } + console.error('Failed to save board', error); + } + } + + async function handleDesktopRecentSelect(boardId: string) { + if (!repo) { + return; + } + try { + const loaded = await repo.loadDoc(boardId); + setActiveBoardId(boardId); + applyLoadedDoc(loaded); + updateDesktopFileState(); + await refreshDesktopBoards(); + } catch (error) { + console.error('Failed to load board', error); + } + } + function applySnapping(action: Action): Action { const snap = snapStore.get(); if (!snap.snapEnabled || !snap.gridEnabled) { @@ -756,8 +868,23 @@ let disposed = false; const initialize = async () => { - const { repo: platformRepo, platform: detectedPlatform, db } = await createPlatformRepo(); + const { + repo: platformRepo, + platform: detectedPlatform, + db, + desktop: desktopInstance + } = await createPlatformRepo(); + if (disposed) { + return; + } repo = platformRepo; + if (detectedPlatform === 'desktop' && desktopInstance) { + desktopRepo = desktopInstance; + } else { + desktopRepo = null; + desktopBoards = []; + desktopFileName = null; + } if (detectedPlatform === 'web' && db) { persistenceManager = createPersistenceManager(db, repo, { sink: { debounceMs: 200 } }); @@ -765,6 +892,9 @@ persistenceStatusStore = persistenceManager.status; } else { const { createPersistenceSink } = await import('inkfinite-core'); + if (disposed) { + return; + } sink = createPersistenceSink(repo, { debounceMs: 500 }); } @@ -780,22 +910,27 @@ if (disposed) { return; } - activeBoardId = id; + setActiveBoardId(id); const loaded = await repoInstance.loadDoc(id); if (!disposed) { - persistenceManager?.setActiveBoard(id); applyLoadedDoc(loaded); } } else { - const id = await repoInstance.createBoard('Untitled'); + const boards = await refreshDesktopBoards(); + let id = boards[0]?.id ?? null; + if (!id) { + id = await repoInstance.createBoard('Untitled'); + } if (disposed) { return; } - activeBoardId = id; + setActiveBoardId(id); const loaded = await repoInstance.loadDoc(id); if (!disposed) { applyLoadedDoc(loaded); + updateDesktopFileState(); } + await refreshDesktopBoards(); } } catch (error) { console.error('Failed to load board', error); @@ -803,35 +938,46 @@ }; await hydrate(); + if (disposed) { + return; + } function getCamera() { return store.getState().camera; } - renderer = createRenderer(canvas!, store, { + const currentCanvas = canvas; + if (!currentCanvas) { + return; + } + + renderer = createRenderer(currentCanvas, store, { snapProvider, cursorProvider, pointerStateProvider, handleProvider }); inputAdapter = createInputAdapter({ - canvas: canvas!, + canvas: currentCanvas, getCamera, getViewport, onAction: handleAction, onCursorUpdate: (world, screen) => cursorStore.updateCursor(world, screen) }); - function handleBeforeUnload() { - if (sink) { - void sink.flush(); + if (typeof window !== 'undefined') { + function handleBeforeUnload() { + if (sink) { + void sink.flush(); + } } - } - window.addEventListener('beforeunload', handleBeforeUnload); + window.addEventListener('beforeunload', handleBeforeUnload); + removeBeforeUnload = () => window.removeEventListener('beforeunload', handleBeforeUnload); + } }; - initialize(); + void initialize(); return () => { disposed = true; @@ -839,12 +985,17 @@ }); onDestroy(() => { + removeBeforeUnload?.(); + removeBeforeUnload = null; renderer?.dispose(); inputAdapter?.dispose(); if (sink) { void sink.flush(); } repo = null; + desktopRepo = null; + desktopBoards = []; + desktopFileName = null; sink = null; activeBoardId = null; persistenceManager?.dispose(); @@ -855,7 +1006,16 @@
- + import Dialog from '$lib/components/Dialog.svelte'; + import type { Platform } from '$lib/platform'; + import type { BoardMeta } from 'inkfinite-core'; import icon from '../assets/favicon.svg'; const helpLinks = [ - { label: 'Project README', href: 'https://github.com/stormlightlabs/inkfinite', external: true }, - { label: 'Issue Tracker', href: 'https://github.com/stormlightlabs/inkfinite/issues', external: true } + { + label: 'Project README', + href: 'https://github.com/stormlightlabs/inkfinite', + external: true + }, + { + label: 'Issue Tracker', + href: 'https://github.com/stormlightlabs/inkfinite/issues', + external: true + } ]; const keyboardTips = [ @@ -13,6 +23,19 @@ 'Scroll to zoom, double-click to reset view' ]; + type DesktopControls = { + fileName: string | null; + recentBoards: BoardMeta[]; + onOpen?: () => void | Promise; + onNew?: () => void | Promise; + onSaveAs?: () => void | Promise; + onSelectBoard?: (boardId: string) => void | Promise; + }; + + type Props = { platform?: Platform; desktop?: DesktopControls }; + + let { platform = 'web', desktop }: Props = $props(); + let infoOpen = $state(false); function openInfo() { infoOpen = true; @@ -20,6 +43,28 @@ function closeInfo() { infoOpen = false; } + + function invokeDesktopAction(action?: () => void | Promise) { + if (action) { + void action(); + } + } + + function handleRecentSelect(event: Event) { + if (!desktop?.onSelectBoard) { + return; + } + const select = event.currentTarget as HTMLSelectElement; + const boardId = select.value; + if (boardId) { + void desktop.onSelectBoard(boardId); + } + select.value = ''; + } + + function desktopFileLabel() { + return desktop?.fileName ?? 'Unsaved board'; + }
@@ -32,6 +77,45 @@
Infinite canvas playground
+ {#if platform === 'desktop' && desktop} +
+
{desktopFileLabel()}
+
+ + + + {#if desktop.recentBoards.length > 0} + + {/if} +
+
+ {/if}