diff --git a/TODO.txt b/TODO.txt --- a/TODO.txt +++ b/TODO.txt @@ -173,14 +173,14 @@ -------------------------------------------------------------------------------- /packages/core/src/persist/DocRepo.ts: -[ ] Define DocRepo interface (web + desktop): +[x] Define DocRepo interface (web + desktop): - listBoards(): Promise - createBoard(name): Promise - openBoard(id): Promise - renameBoard(id, name): Promise - deleteBoard(id): Promise -[ ] Define FileBrowserViewModel: +[x] Define FileBrowserViewModel: - query, filteredBoards, selectedId - actions: open/create/rename/delete @@ -224,8 +224,8 @@ - pick directory - remember last workspace path [ ] Implement directory listing: - - v0: show *.Inkfinite.json files in workspace - - v1: tree view with folders + - show *.inkfinite.json files in workspace + - tree view with folders [ ] Implement file actions: - [x] New: create new file - [ ] Rename: rename file @@ -247,10 +247,10 @@ - name + updatedAt in both modes (DoD): -- Web and desktop feel like the same app, with storage differences made explicit. +- Web and desktop feel like the same app, with storage differences made explicit ================================================================================ -19. Milestone S: Quality polish (what makes it feel "real") *wb-S* +19. Milestone S: Quality polish. *wb-S* ================================================================================ Comprehensive UX polish adds BEM CSS, space-drag panning, richer keyboard diff --git a/packages/core/eslint.config.js b/packages/core/eslint.config.js --- a/packages/core/eslint.config.js +++ b/packages/core/eslint.config.js @@ -8,9 +8,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); export default defineConfig( - { - ignores: ["dist/**", "*.config.js"], - }, + { ignores: ["dist/**", "*.config.js"] }, js.configs.recommended, ...ts.configs.recommended, { @@ -19,14 +17,11 @@ parserOptions: { tsconfigRootDir: __dirname, project: "./tsconfig.json" }, }, rules: { - "@typescript-eslint/no-unused-vars": [ - "error", - { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - caughtErrorsIgnorePattern: "^_", - }, - ], + "@typescript-eslint/no-unused-vars": ["error", { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }], }, }, ); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -16,5 +16,5 @@ "verbatimModuleSyntax": true, "skipLibCheck": true }, - "include": ["src"] + "include": ["src", "tests"] } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,9 +6,11 @@ export * from "./history"; export * from "./math"; export * from "./model"; +export * from "./persist/DocRepo"; export * from "./persistence/db"; export * from "./persistence/desktop"; export * from "./persistence/web"; export * from "./reactivity"; export * from "./tools"; +export * from "./ui/filebrowser"; export * from "./ui/statusbar"; diff --git a/packages/core/tests/filebrowser.test.ts b/packages/core/tests/filebrowser.test.ts new file mode 100644 --- /dev/null +++ b/packages/core/tests/filebrowser.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import type { DocRepo } from "../src/persist/DocRepo"; +import { FileBrowserVM } from "../src/ui/filebrowser"; + +function createRepoMock(): DocRepo { + return { + listBoards: vi.fn(async () => []), + createBoard: vi.fn(async () => "board:new"), + openBoard: vi.fn(async () => {}), + renameBoard: vi.fn(async () => {}), + deleteBoard: vi.fn(async () => {}), + }; +} + +const boards = [{ id: "board:alpha", name: "Alpha Board", createdAt: 1, updatedAt: 10 }, { + id: "board:beta", + name: "Beta Board", + createdAt: 2, + updatedAt: 20, +}, { id: "board:gamma", name: "Gamma", createdAt: 3, updatedAt: 30 }]; + +describe("FileBrowserVM", () => { + it("filters boards by query and maintains selection", () => { + const repo = createRepoMock(); + const vm = FileBrowserVM.create({ repo, boards }); + expect(vm.filteredBoards).toHaveLength(3); + expect(vm.selectedId).toBe("board:alpha"); + + const betaOnly = FileBrowserVM.setQuery(vm, "beta"); + expect(betaOnly.filteredBoards).toHaveLength(1); + expect(betaOnly.filteredBoards[0].id).toBe("board:beta"); + expect(betaOnly.selectedId).toBe("board:beta"); + }); + + it("updates boards list immutably", () => { + const repo = createRepoMock(); + const vm = FileBrowserVM.create({ repo, boards: boards.slice(0, 2) }); + const next = FileBrowserVM.setBoards(vm, boards); + expect(next.boards).toHaveLength(3); + expect(next.filteredBoards).toHaveLength(3); + expect(next).not.toBe(vm); + }); + + it("selects the first available board when selection is invalid", () => { + const repo = createRepoMock(); + const vm = FileBrowserVM.create({ repo, boards }); + const betaOnly = FileBrowserVM.setQuery(vm, "beta"); + const updated = FileBrowserVM.select(betaOnly, "missing"); + expect(updated.selectedId).toBe("board:beta"); + }); + + it("invokes repo actions", async () => { + const repo = createRepoMock(); + const vm = FileBrowserVM.create({ repo, boards }); + + await vm.actions.open("board:alpha"); + expect(repo.openBoard).toHaveBeenCalledWith("board:alpha"); + + await vm.actions.create("Untitled"); + expect(repo.createBoard).toHaveBeenCalledWith("Untitled"); + + await vm.actions.rename("board:alpha", "Renamed"); + expect(repo.renameBoard).toHaveBeenCalledWith("board:alpha", "Renamed"); + + await vm.actions.delete("board:alpha"); + expect(repo.deleteBoard).toHaveBeenCalledWith("board:alpha"); + }); +}); diff --git a/apps/web/src/lib/platform.ts b/apps/web/src/lib/platform.ts --- a/apps/web/src/lib/platform.ts +++ b/apps/web/src/lib/platform.ts @@ -1,4 +1,4 @@ -import type { DocRepo } from "inkfinite-core"; +import type { PersistentDocRepo } from "inkfinite-core"; import { createWebDocRepo, InkfiniteDB } from "inkfinite-core"; import { createDesktopFileOps } from "./fileops"; import { createDesktopDocRepo, type DesktopDocRepo } from "./persistence/desktop"; @@ -12,7 +12,12 @@ return "web"; } -export type PlatformRepoResult = { repo: DocRepo; platform: Platform; db?: InkfiniteDB; desktop?: DesktopDocRepo }; +export type PlatformRepoResult = { + repo: PersistentDocRepo; + platform: Platform; + db?: InkfiniteDB; + desktop?: DesktopDocRepo; +}; /** * Create the appropriate DocRepo based on platform diff --git a/apps/web/src/lib/status.ts b/apps/web/src/lib/status.ts --- a/apps/web/src/lib/status.ts +++ b/apps/web/src/lib/status.ts @@ -2,9 +2,9 @@ import { createPersistenceSink, type DocPatch, - type DocRepo, type PersistenceSink, type PersistenceSinkOptions, + type PersistentDocRepo, } from "inkfinite-core"; import type { InkfiniteDB, PersistenceStatus } from "inkfinite-core"; @@ -38,7 +38,7 @@ export function createPersistenceManager( db: InkfiniteDB, - repo: DocRepo, + repo: PersistentDocRepo, options?: PersistenceManagerOptions, ): PersistenceManager { const sink = createPersistenceSink(repo, options?.sink); diff --git a/packages/core/src/persist/DocRepo.ts b/packages/core/src/persist/DocRepo.ts new file mode 100644 --- /dev/null +++ b/packages/core/src/persist/DocRepo.ts @@ -0,0 +1,34 @@ +export type Timestamp = number; + +export type BoardMeta = { id: string; name: string; createdAt: Timestamp; updatedAt: Timestamp }; + +/** + * Shared document repository contract used by both web and desktop persistence layers. + * Provides the minimal operations required for listing and managing boards. + */ +export interface DocRepo { + /** + * Fetch all boards ordered by most recently updated first. + */ + listBoards(): Promise; + + /** + * Create a new board and return its identifier. + */ + createBoard(name: string): Promise; + + /** + * Load the requested board into the active editing context. + */ + openBoard(boardId: string): Promise; + + /** + * Rename the board. + */ + renameBoard(boardId: string, name: string): Promise; + + /** + * Delete the board and all associated records. + */ + deleteBoard(boardId: string): Promise; +} diff --git a/packages/core/src/persistence/db.ts b/packages/core/src/persistence/db.ts --- a/packages/core/src/persistence/db.ts +++ b/packages/core/src/persistence/db.ts @@ -1,6 +1,7 @@ import Dexie, { type Transaction } from "dexie"; import { PageRecord as PageOps } from "../model"; -import type { BindingRow, BoardMeta, MetaRow, MigrationRow, PageRow, ShapeRow, Timestamp } from "./web"; +import type { BoardMeta, Timestamp } from "../persist/DocRepo"; +import type { BindingRow, MetaRow, MigrationRow, PageRow, ShapeRow } from "./web"; export const DB_NAME = "inkfinite"; diff --git a/packages/core/src/persistence/desktop.ts b/packages/core/src/persistence/desktop.ts --- a/packages/core/src/persistence/desktop.ts +++ b/packages/core/src/persistence/desktop.ts @@ -1,5 +1,6 @@ import type { BindingRecord, Document, PageRecord, ShapeRecord } from "../model"; -import type { BoardMeta, DocOrder, LoadedDoc } from "./web"; +import type { BoardMeta } from "../persist/DocRepo"; +import type { DocOrder, LoadedDoc } from "./web"; /** * Desktop file representation - combines board metadata with document content diff --git a/packages/core/src/persistence/web.ts b/packages/core/src/persistence/web.ts --- a/packages/core/src/persistence/web.ts +++ b/packages/core/src/persistence/web.ts @@ -1,4 +1,3 @@ -/* eslint-disable unicorn/no-await-expression-member */ import Dexie from "dexie"; import { type BindingRecord, @@ -10,10 +9,7 @@ type ShapeRecord, ShapeRecord as ShapeOps, } from "../model"; - -export type Timestamp = number; - -export type BoardMeta = { id: string; name: string; createdAt: Timestamp; updatedAt: Timestamp }; +import type { BoardMeta, DocRepo, Timestamp } from "../persist/DocRepo"; export type PageRow = PageRecord & { boardId: string; updatedAt: Timestamp }; @@ -50,11 +46,7 @@ export type PersistenceSinkOptions = { debounceMs?: number }; -export interface DocRepo { - listBoards(): Promise; - createBoard(name: string): Promise; - renameBoard(boardId: string, name: string): Promise; - deleteBoard(boardId: string): Promise; +export interface PersistentDocRepo extends DocRepo { loadDoc(boardId: string): Promise; applyDocPatch(boardId: string, patch: DocPatch): Promise; exportBoard(boardId: string): Promise; @@ -74,9 +66,9 @@ const shapeOrderKey = (boardId: string) => `${SHAPE_ORDER_META_PREFIX}${boardId}`; /** - * Create a Dexie-backed DocRepo used by the web app. + * Create a Dexie-backed persistent DocRepo used by the web app. */ -export function createWebDocRepo(database: DexieLike, options?: WebRepoOptions): DocRepo { +export function createWebDocRepo(database: DexieLike, options?: WebRepoOptions): PersistentDocRepo { const now = () => options?.now?.() ?? Date.now(); const boards = () => database.table("boards"); @@ -256,7 +248,24 @@ return boardId; } - return { listBoards, createBoard, renameBoard, deleteBoard, loadDoc, applyDocPatch, exportBoard, importBoard }; + async function openBoard(boardId: string): Promise { + const exists = await boards().get(boardId); + if (!exists) { + throw new Error(`Board ${boardId} not found`); + } + } + + return { + listBoards, + createBoard, + openBoard, + renameBoard, + deleteBoard, + loadDoc, + applyDocPatch, + exportBoard, + importBoard, + }; } /** @@ -295,7 +304,7 @@ /** * Batch doc patches and flush them with a debounce to cut down on Dexie writes. */ -export function createPersistenceSink(repo: DocRepo, options?: PersistenceSinkOptions): PersistenceSink { +export function createPersistenceSink(repo: PersistentDocRepo, options?: PersistenceSinkOptions): PersistenceSink { const debounceMs = options?.debounceMs ?? 200; let pendingBoardId: string | null = null; let pendingPatch: DocPatch | null = null; diff --git a/packages/core/src/ui/filebrowser.ts b/packages/core/src/ui/filebrowser.ts new file mode 100644 --- /dev/null +++ b/packages/core/src/ui/filebrowser.ts @@ -0,0 +1,93 @@ +import type { BoardMeta, DocRepo } from "../persist/DocRepo"; + +export type FileBrowserActions = { + open(boardId: string): Promise; + create(name: string): Promise; + rename(boardId: string, name: string): Promise; + delete(boardId: string): Promise; +}; + +export type FileBrowserViewModel = { + /** All known boards pulled from the DocRepo */ + boards: BoardMeta[]; + /** Current search query */ + query: string; + /** Boards that match the query (preserves incoming order) */ + filteredBoards: BoardMeta[]; + /** Selected board identifier, or null if nothing is selected */ + selectedId: string | null; + /** Bound repository actions */ + actions: FileBrowserActions; +}; + +export type FileBrowserOptions = { repo: DocRepo; boards?: BoardMeta[]; query?: string; selectedId?: string | null }; + +export const FileBrowserVM = { + create(options: FileBrowserOptions): FileBrowserViewModel { + const boards = [...(options.boards ?? [])]; + const query = normalizeQuery(options.query); + const filteredBoards = filterBoards(boards, query); + const selectedId = resolveSelection(options.selectedId ?? null, filteredBoards); + const actions = createActions(options.repo); + return { boards, query, filteredBoards, selectedId, actions }; + }, + + setBoards(vm: FileBrowserViewModel, boards: BoardMeta[]): FileBrowserViewModel { + const cloned = [...boards]; + const filteredBoards = filterBoards(cloned, vm.query); + const selectedId = resolveSelection(vm.selectedId, filteredBoards); + return { ...vm, boards: cloned, filteredBoards, selectedId }; + }, + + setQuery(vm: FileBrowserViewModel, query: string): FileBrowserViewModel { + const normalized = normalizeQuery(query); + const filteredBoards = filterBoards(vm.boards, normalized); + const selectedId = resolveSelection(vm.selectedId, filteredBoards); + return { ...vm, query: normalized, filteredBoards, selectedId }; + }, + + select(vm: FileBrowserViewModel, boardId: string | null): FileBrowserViewModel { + const selectedId = resolveSelection(boardId, vm.filteredBoards); + return { ...vm, selectedId }; + }, +}; + +function normalizeQuery(query?: string | null): string { + return query?.trim() ?? ""; +} + +function filterBoards(boards: BoardMeta[], query: string): BoardMeta[] { + if (!query) { + return [...boards]; + } + const needle = query.toLowerCase(); + return boards.filter((board) => { + const nameMatch = board.name.toLowerCase().includes(needle); + const idMatch = board.id.toLowerCase().includes(needle); + return nameMatch || idMatch; + }); +} + +function resolveSelection(requested: string | null, boards: BoardMeta[]): string | null { + if (requested && boards.some((board) => board.id === requested)) { + return requested; + } + return boards[0]?.id ?? null; +} + +function createActions(repo: DocRepo): FileBrowserActions { + return { + async open(boardId: string) { + await repo.openBoard(boardId); + }, + async create(name: string) { + return repo.createBoard(name); + }, + async rename(boardId: string, name: string) { + await repo.renameBoard(boardId, name); + }, + async delete(boardId: string) { + await repo.deleteBoard(boardId); + }, + }; +} diff --git a/apps/web/src/lib/canvas/Canvas.svelte b/apps/web/src/lib/canvas/Canvas.svelte --- a/apps/web/src/lib/canvas/Canvas.svelte +++ b/apps/web/src/lib/canvas/Canvas.svelte @@ -3,1005 +3,61 @@ import StatusBar from '$lib/components/StatusBar.svelte'; 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, - createSnapStore, - createStatusStore, - type SnapStore, - type StatusStore - } from '$lib/status'; - import { - ArrowTool, - Camera, - CursorStore, - EditorState, - EllipseTool, - LineTool, - RectTool, - SelectTool, - ShapeRecord, - SnapshotCommand, - Store, - TextTool, - createToolMap, - diffDoc, - getShapesOnCurrentPage, - routeAction, - shapeBounds, - switchTool, - type Action, - type BoardMeta, - type CommandKind, - type DocRepo, - type LoadedDoc, - type PersistenceSink, - type ToolId, - type Viewport - } from 'inkfinite-core'; - import { createRenderer, type Renderer } from 'inkfinite-renderer'; - import { onDestroy, onMount } from 'svelte'; + import { createCanvasController } from './canvas-store.svelte.ts'; - let repo: DocRepo | null = null; - let sink: PersistenceSink | null = null; - let persistenceManager: ReturnType | null = null; - const platform = detectPlatform(); - const fallbackStatusStore = createStatusStore({ - backend: platform === 'desktop' ? 'filesystem' : 'indexeddb', - state: 'saved', - pendingWrites: 0 - }); - 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) => { - if (!activeBoardId || event.kind !== 'doc' || !sink) { - return; - } - const patch = diffDoc(event.beforeState.doc, event.afterState.doc); - sink.enqueueDocPatch(activeBoardId, patch); - } - }); - const cursorStore = new CursorStore(); - const snapStore: SnapStore = createSnapStore(); - const pointerState = $state({ - isPointerDown: false, - snappedWorld: null as { x: number; y: number } | null - }); - const handleState = $state<{ hover: string | null; active: string | null }>({ - hover: null, - active: null - }); - let textEditor = $state<{ shapeId: string; value: string } | null>(null); + let canvasEl = $state(null); let textEditorEl = $state(null); - const panState = $state({ isPanning: false, spaceHeld: false, lastScreen: { x: 0, y: 0 } }); - const snapProvider = { get: () => snapStore.get() }; - const cursorProvider = { get: () => cursorStore.getState() }; - const pointerStateProvider = { get: () => pointerState }; - const handleProvider = { get: () => ({ ...handleState }) }; - let pendingCommandStart: EditorState | null = null; - - function applyLoadedDoc(doc: LoadedDoc) { - const firstPageId = doc.order.pageIds[0] ?? Object.keys(doc.pages)[0] ?? null; - store.setState((state) => ({ - ...state, - doc: { pages: doc.pages, shapes: doc.shapes, bindings: doc.bindings }, - ui: { ...state.ui, currentPageId: firstPageId, selectionIds: [] } - })); - initializeSelection(firstPageId, doc); - } - - function initializeSelection(pageId: string | null, doc: LoadedDoc) { - if (!pageId) { - return; - } - const page = doc.pages[pageId]; - const firstShapeId = page?.shapeIds[0]; - if (!firstShapeId) { - return; - } - const state = editorSnapshot; - if (state.ui.selectionIds.length === 1 && state.ui.selectionIds[0] === firstShapeId) { - return; - } - const before = EditorState.clone(state); - const after = { ...state, ui: { ...state.ui, selectionIds: [firstShapeId] } }; - const command = new SnapshotCommand( - 'Initialize Selection', - 'ui', - before, - EditorState.clone(after) - ); - store.executeCommand(command); - 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', - e: 'ew-resize', - w: 'ew-resize', - ne: 'nesw-resize', - sw: 'nesw-resize', - nw: 'nwse-resize', - se: 'nwse-resize', - rotate: 'alias', - 'line-start': 'crosshair', - 'line-end': 'crosshair' - }; - - function refreshCursor() { - if (!canvas) { - return; - } - let cursor = 'default'; - if (textEditor) { - cursor = 'text'; - } else if (panState.isPanning) { - cursor = 'grabbing'; - } else if (panState.spaceHeld) { - cursor = 'grab'; - } else { - const activeHandle = handleState.active; - const hoverHandle = handleState.hover; - const targetHandle = activeHandle ?? hoverHandle; - if (targetHandle) { - cursor = handleCursorMap[targetHandle] ?? 'default'; - } else if (pointerState.isPointerDown) { - cursor = 'grabbing'; - } - } - canvas.style.cursor = cursor; - } - - function setHandleHover(handle: string | null) { - if (handleState.hover === handle) { - return; - } - handleState.hover = handle; - refreshCursor(); - } - - function syncHandleState() { - handleState.active = selectTool.getActiveHandle ? selectTool.getActiveHandle() : null; - refreshCursor(); - } - - function getTextEditorLayout() { - if (!textEditor) { - return null; - } - const state = store.getState(); - const shape = state.doc.shapes[textEditor.shapeId]; - if (!shape || shape.type !== 'text') { - return null; - } - const viewport = getViewport(); - const screenPos = Camera.worldToScreen(state.camera, { x: shape.x, y: shape.y }, viewport); - const widthWorld = shape.props.w ?? 240; - const zoom = state.camera.zoom; - return { - left: screenPos.x, - top: screenPos.y, - width: widthWorld * zoom, - height: shape.props.fontSize * 1.4 * zoom, - fontSize: shape.props.fontSize * zoom - }; - } - - function startTextEditing(shapeId: string) { - const state = store.getState(); - const shape = state.doc.shapes[shapeId]; - if (!shape || shape.type !== 'text') { - return; - } - textEditor = { shapeId, value: shape.props.text }; - refreshCursor(); - queueMicrotask(() => { - textEditorEl?.focus(); - textEditorEl?.select(); - }); - } - - function commitTextEditing() { - if (!textEditor) { - return; - } - const { shapeId, value } = textEditor; - const currentState = store.getState(); - const shape = currentState.doc.shapes[shapeId]; - textEditor = null; - refreshCursor(); - if (!shape || shape.type !== 'text' || shape.props.text === value) { - return; - } - const before = EditorState.clone(currentState); - const updatedShape = { ...shape, props: { ...shape.props, text: value } }; - const newShapes = { ...currentState.doc.shapes, [shapeId]: updatedShape }; - const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; - const command = new SnapshotCommand('Edit text', 'doc', before, EditorState.clone(after)); - store.executeCommand(command); - } - - function cancelTextEditing() { - textEditor = null; - refreshCursor(); - } - - function handleCanvasDoubleClick(event: MouseEvent) { - if (!canvas) { - return; - } - const rect = canvas.getBoundingClientRect(); - const screen = { x: event.clientX - rect.left, y: event.clientY - rect.top }; - const world = Camera.screenToWorld(store.getState().camera, screen, getViewport()); - const shapeId = findTextShapeAt(world); - if (shapeId) { - startTextEditing(shapeId); - } - } - - function findTextShapeAt(point: { x: number; y: number }): string | null { - const shapes = getShapesOnCurrentPage(store.getState()); - for (let index = shapes.length - 1; index >= 0; index--) { - const shape = shapes[index]; - if (!shape || shape.type !== 'text') { - continue; - } - const bounds = shapeBounds(shape); - if ( - point.x >= bounds.min.x && - point.x <= bounds.max.x && - point.y >= bounds.min.y && - point.y <= bounds.max.y - ) { - return shape.id; - } - } - return null; - } - - function handleTextEditorInput(event: Event) { - if (!textEditor) { - return; - } - const target = event.currentTarget as HTMLTextAreaElement; - textEditor = { ...textEditor, value: target.value }; - } - - function handleTextEditorKeyDown(event: KeyboardEvent) { - if (event.key === 'Escape') { - event.preventDefault(); - cancelTextEditing(); - return; - } - if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { - event.preventDefault(); - commitTextEditing(); - } - } - - function handleTextEditorBlur() { - commitTextEditing(); - } - - function handlePointerLeave() { - setHandleHover(null); - } - - const selectTool = new SelectTool(); - const rectTool = new RectTool(); - const ellipseTool = new EllipseTool(); - const lineTool = new LineTool(); - const arrowTool = new ArrowTool(); - const textTool = new TextTool(); - const tools = createToolMap([selectTool, rectTool, ellipseTool, lineTool, arrowTool, textTool]); - - let currentToolId = $state('select'); - let editorSnapshot = $state(store.getState()); let historyViewerOpen = $state(false); - store.subscribe((state) => { - currentToolId = state.ui.toolId; - editorSnapshot = state; + const controller = createCanvasController({ + setHistoryViewerOpen(value: boolean) { + historyViewerOpen = value; + } }); - function handleToolChange(toolId: ToolId) { - store.setState((state) => switchTool(state, toolId, tools)); - } - - function handleHistoryClick() { - historyViewerOpen = true; - } - - function handleHistoryClose() { - historyViewerOpen = false; - } - - function handleBringForward() { - const currentState = store.getState(); - const selectedIds = currentState.ui.selectionIds; - const currentPageId = currentState.ui.currentPageId; - - if (selectedIds.length === 0 || !currentPageId) { - return; - } - - const before = EditorState.clone(currentState); - const page = currentState.doc.pages[currentPageId]; - if (!page) return; - - const newShapeIds = [...page.shapeIds]; - - for (const shapeId of selectedIds) { - const currentIndex = newShapeIds.indexOf(shapeId); - if (currentIndex !== -1 && currentIndex < newShapeIds.length - 1) { - [newShapeIds[currentIndex], newShapeIds[currentIndex + 1]] = [ - newShapeIds[currentIndex + 1], - newShapeIds[currentIndex] - ]; - } - } - - const after = { - ...currentState, - doc: { - ...currentState.doc, - pages: { ...currentState.doc.pages, [currentPageId]: { ...page, shapeIds: newShapeIds } } - } - }; - - const command = new SnapshotCommand('Bring Forward', 'doc', before, EditorState.clone(after)); - store.executeCommand(command); - syncHandleState(); - } - - function handleSendBackward() { - const currentState = store.getState(); - const selectedIds = currentState.ui.selectionIds; - const currentPageId = currentState.ui.currentPageId; - - if (selectedIds.length === 0 || !currentPageId) { - return; - } - - const before = EditorState.clone(currentState); - const page = currentState.doc.pages[currentPageId]; - if (!page) return; - - const newShapeIds = [...page.shapeIds]; - - for (let i = selectedIds.length - 1; i >= 0; i--) { - const shapeId = selectedIds[i]; - const currentIndex = newShapeIds.indexOf(shapeId); - if (currentIndex > 0) { - [newShapeIds[currentIndex], newShapeIds[currentIndex - 1]] = [ - newShapeIds[currentIndex - 1], - newShapeIds[currentIndex] - ]; - } - } - - const after = { - ...currentState, - doc: { - ...currentState.doc, - pages: { ...currentState.doc.pages, [currentPageId]: { ...page, shapeIds: newShapeIds } } - } - }; - - const command = new SnapshotCommand('Send Backward', 'doc', before, EditorState.clone(after)); - store.executeCommand(command); - syncHandleState(); - } - - function handleDuplicate() { - const currentState = store.getState(); - const selectedIds = currentState.ui.selectionIds; - - if (selectedIds.length === 0) { - return; - } - - const before = EditorState.clone(currentState); - const newShapes = { ...currentState.doc.shapes }; - const newPages = { ...currentState.doc.pages }; - const duplicatedIds: string[] = []; - - const DUPLICATE_OFFSET = 20; - - for (const shapeId of selectedIds) { - const shape = currentState.doc.shapes[shapeId]; - if (!shape) continue; - - const cloned = ShapeRecord.clone(shape); - const newId = `shape:${crypto.randomUUID()}`; - const duplicated = { - ...cloned, - id: newId, - x: shape.x + DUPLICATE_OFFSET, - y: shape.y + DUPLICATE_OFFSET - }; - - newShapes[newId] = duplicated; - duplicatedIds.push(newId); - - const currentPageId = currentState.ui.currentPageId; - if (currentPageId) { - const page = newPages[currentPageId]; - if (page) { - newPages[currentPageId] = { ...page, shapeIds: [...page.shapeIds, newId] }; - } - } - } - - const after = { - ...currentState, - doc: { ...currentState.doc, shapes: newShapes, pages: newPages }, - ui: { ...currentState.ui, selectionIds: duplicatedIds } - }; - - const command = new SnapshotCommand('Duplicate', 'doc', before, EditorState.clone(after)); - store.executeCommand(command); - syncHandleState(); - } - - function handleNudge(arrowKey: string, largeNudge: boolean) { - const currentState = store.getState(); - const selectedIds = currentState.ui.selectionIds; - - if (selectedIds.length === 0) { - return; - } - - const nudgeDistance = largeNudge ? 10 : 1; - let deltaX = 0; - let deltaY = 0; - - switch (arrowKey) { - case 'ArrowLeft': - deltaX = -nudgeDistance; - break; - case 'ArrowRight': - deltaX = nudgeDistance; - break; - case 'ArrowUp': - deltaY = -nudgeDistance; - break; - case 'ArrowDown': - deltaY = nudgeDistance; - break; - } - - const before = EditorState.clone(currentState); - const newShapes = { ...currentState.doc.shapes }; - - for (const shapeId of selectedIds) { - const shape = newShapes[shapeId]; - if (shape) { - newShapes[shapeId] = { ...shape, x: shape.x + deltaX, y: shape.y + deltaY }; - } - } - - const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; - const command = new SnapshotCommand('Nudge', 'doc', before, EditorState.clone(after)); - store.executeCommand(command); - syncHandleState(); - } - - function applyActionWithHistory(action: Action) { - const before = store.getState(); - const nextState = routeAction(before, action, tools); - if (statesEqual(before, nextState)) { - syncHandleState(); - return; - } - - const kind = getCommandKind(before, nextState); - const commandName = describeAction(action, kind); - const command = new SnapshotCommand( - commandName, - kind, - EditorState.clone(before), - EditorState.clone(nextState) - ); - store.executeCommand(command); - syncHandleState(); - } - - function handleAction(action: Action) { - if (textEditor && (action.type === 'pointer-down' || action.type === 'pointer-up')) { - commitTextEditing(); - } - - if ( - action.type === 'pointer-move' && - 'world' in action && - !panState.isPanning && - !panState.spaceHeld - ) { - const hover = selectTool.getHandleAtPoint(store.getState(), action.world); - setHandleHover(hover); - } - - if (action.type === 'pointer-move' && (panState.isPanning || panState.spaceHeld)) { - setHandleHover(null); - } - - if (action.type === 'key-down' && action.key === ' ') { - panState.spaceHeld = true; - setHandleHover(null); - refreshCursor(); - return; - } - - if (action.type === 'key-up' && action.key === ' ') { - panState.spaceHeld = false; - panState.isPanning = false; - refreshCursor(); - return; - } - - if (action.type === 'pointer-down' && action.button === 0 && panState.spaceHeld) { - panState.isPanning = true; - panState.lastScreen = { x: action.screen.x, y: action.screen.y }; - refreshCursor(); - return; - } - - if (action.type === 'pointer-move' && panState.isPanning) { - const deltaX = action.screen.x - panState.lastScreen.x; - const deltaY = action.screen.y - panState.lastScreen.y; - const currentCamera = store.getState().camera; - const newCamera = Camera.pan(currentCamera, { x: deltaX, y: deltaY }); - store.setState((state) => ({ ...state, camera: newCamera })); - panState.lastScreen = { x: action.screen.x, y: action.screen.y }; - refreshCursor(); - return; - } - - if (action.type === 'pointer-up' && action.button === 0 && panState.isPanning) { - panState.isPanning = false; - refreshCursor(); - return; - } - - if (panState.isPanning || panState.spaceHeld) { - return; - } - - const actionWithSnap = applySnapping(action); - if ('world' in actionWithSnap) { - pointerState.snappedWorld = actionWithSnap.world ?? null; - } - - if (actionWithSnap.type === 'pointer-down' && actionWithSnap.button === 0) { - pointerState.isPointerDown = true; - setHandleHover(null); - refreshCursor(); - pendingCommandStart = EditorState.clone(store.getState()); - const changed = applyImmediateAction(actionWithSnap); - if (!changed) { - pendingCommandStart = null; - } - return; - } - - if ( - actionWithSnap.type === 'pointer-move' && - pointerState.isPointerDown && - pendingCommandStart - ) { - void applyImmediateAction(actionWithSnap); - return; - } - - if (actionWithSnap.type === 'pointer-up' && actionWithSnap.button === 0) { - pointerState.isPointerDown = false; - setHandleHover(null); - refreshCursor(); - if (pendingCommandStart) { - const committed = commitPendingCommand(actionWithSnap, pendingCommandStart); - pendingCommandStart = null; - if (committed) { - return; - } - } - pointerState.snappedWorld = null; - } - - if (actionWithSnap.type === 'key-down') { - const isPrimary = - (actionWithSnap.modifiers.meta && navigator.platform.toUpperCase().includes('MAC')) || - (actionWithSnap.modifiers.ctrl && !navigator.platform.toUpperCase().includes('MAC')); - - if ( - isPrimary && - !actionWithSnap.modifiers.shift && - (actionWithSnap.key === 'z' || actionWithSnap.key === 'Z') - ) { - store.undo(); - return; - } - - if ( - isPrimary && - actionWithSnap.modifiers.shift && - (actionWithSnap.key === 'z' || actionWithSnap.key === 'Z') - ) { - store.redo(); - return; - } - - if (isPrimary && (actionWithSnap.key === 'd' || actionWithSnap.key === 'D')) { - handleDuplicate(); - return; - } - - if (isPrimary && actionWithSnap.key === ']') { - handleBringForward(); - return; - } - - if (isPrimary && actionWithSnap.key === '[') { - handleSendBackward(); - return; - } - - if (actionWithSnap.key.startsWith('Arrow')) { - handleNudge(actionWithSnap.key, actionWithSnap.modifiers.shift); - return; - } - } - - applyActionWithHistory(actionWithSnap); - } - - function applyImmediateAction(action: Action): boolean { - const before = store.getState(); - const nextState = routeAction(before, action, tools); - if (statesEqual(before, nextState)) { - syncHandleState(); - return false; - } - store.setState(() => nextState); - syncHandleState(); - return true; - } - - function commitPendingCommand(action: Action, startState: EditorState): boolean { - const before = store.getState(); - const nextState = routeAction(before, action, tools); - const finalState = statesEqual(before, nextState) ? before : nextState; - if (statesEqual(startState, finalState)) { - syncHandleState(); - return false; - } - const kind = getCommandKind(startState, finalState); - const commandName = describeAction(action, kind); - const command = new SnapshotCommand( - commandName, - kind, - EditorState.clone(startState), - EditorState.clone(finalState) - ); - store.executeCommand(command); - syncHandleState(); - return true; - } - - function statesEqual(a: EditorState, b: EditorState): boolean { - return a.doc === b.doc && a.camera === b.camera && a.ui === b.ui; - } - - function getCommandKind(before: EditorState, after: EditorState): CommandKind { - if (before.doc !== after.doc) { - return 'doc'; - } - if (before.camera !== after.camera) { - return 'camera'; - } - return 'ui'; - } - - function describeAction(action: Action, kind: CommandKind): string { - switch (action.type) { - case 'pointer-down': - return 'Pointer down'; - case 'pointer-move': - return 'Pointer move'; - case 'pointer-up': - return 'Pointer up'; - case 'wheel': - return 'Wheel'; - case 'key-down': - return 'Key down'; - case 'key-up': - return 'Key up'; - default: - return kind === 'doc' ? 'Edit' : kind === 'camera' ? 'Camera change' : 'UI change'; - } - } - - 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) { - return action; - } - if (!('world' in action)) { - return action; - } - const snapCoord = (value: number) => Math.round(value / snap.gridSize) * snap.gridSize; - const snappedWorld = { x: snapCoord(action.world.x), y: snapCoord(action.world.y) }; - return { ...action, world: snappedWorld }; - } - - let canvas = $state(); - let renderer: Renderer | null = null; - let inputAdapter: InputAdapter | null = null; - - function getViewport(): Viewport { - if (canvas) { - const rect = canvas.getBoundingClientRect(); - return { width: rect.width || 1, height: rect.height || 1 }; - } - if (typeof window !== 'undefined') { - return { width: window.innerWidth || 1, height: window.innerHeight || 1 }; - } - return { width: 1, height: 1 }; - } - - onMount(() => { - let disposed = false; - - const initialize = async () => { - 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 } }); - sink = persistenceManager.sink; - persistenceStatusStore = persistenceManager.status; - } else { - const { createPersistenceSink } = await import('inkfinite-core'); - if (disposed) { - return; - } - sink = createPersistenceSink(repo, { debounceMs: 500 }); - } - - const hydrate = async () => { - const repoInstance = repo; - if (!repoInstance) { - return; - } - try { - if (detectedPlatform === 'web') { - const boards = await repoInstance.listBoards(); - const id = boards[0]?.id ?? (await repoInstance.createBoard('My board')); - if (disposed) { - return; - } - setActiveBoardId(id); - const loaded = await repoInstance.loadDoc(id); - if (!disposed) { - applyLoadedDoc(loaded); - } - } else { - const boards = await refreshDesktopBoards(); - let id = boards[0]?.id ?? null; - if (!id) { - id = await repoInstance.createBoard('Untitled'); - } - if (disposed) { - return; - } - setActiveBoardId(id); - const loaded = await repoInstance.loadDoc(id); - if (!disposed) { - applyLoadedDoc(loaded); - updateDesktopFileState(); - } - await refreshDesktopBoards(); - } - } catch (error) { - console.error('Failed to load board', error); - } - }; - - await hydrate(); - if (disposed) { - return; - } - - function getCamera() { - return store.getState().camera; - } - - const currentCanvas = canvas; - if (!currentCanvas) { - return; - } - - renderer = createRenderer(currentCanvas, store, { - snapProvider, - cursorProvider, - pointerStateProvider, - handleProvider - }); - inputAdapter = createInputAdapter({ - canvas: currentCanvas, - getCamera, - getViewport, - onAction: handleAction, - onCursorUpdate: (world, screen) => cursorStore.updateCursor(world, screen) - }); - - if (typeof window !== 'undefined') { - function handleBeforeUnload() { - if (sink) { - void sink.flush(); - } - } - - window.addEventListener('beforeunload', handleBeforeUnload); - removeBeforeUnload = () => window.removeEventListener('beforeunload', handleBeforeUnload); - } - }; - - void initialize(); - - return () => { - disposed = true; - }; + const { + platform: readPlatform, + desktopBoards: readDesktopBoards, + desktopFileName: readDesktopFileName, + handleDesktopOpen, + handleDesktopNewBoard, + handleDesktopSaveAs, + handleDesktopRecentSelect, + currentToolId: readCurrentToolId, + handleToolChange, + handleHistoryClick, + handleHistoryClose, + store, + getViewport, + handleCanvasDoubleClick, + handlePointerLeave, + textEditor: readTextEditor, + getTextEditorLayout, + handleTextEditorInput, + handleTextEditorKeyDown, + handleTextEditorBlur, + cursorStore, + persistenceStatusStore: readPersistenceStatusStore, + snapStore, + setCanvasRef, + setTextEditorElRef + } = controller; + + let platform = $derived(readPlatform()); + let desktopBoards = $derived(readDesktopBoards()); + let desktopFileName = $derived(readDesktopFileName()); + let currentToolId = $derived(readCurrentToolId()); + let textEditor = $derived(readTextEditor()); + let persistenceStatusStore = $derived(readPersistenceStatusStore()); + + $effect(() => { + setCanvasRef(canvasEl); + return () => setCanvasRef(null); }); - 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(); - persistenceManager = null; - fallbackStatusStore.update(() => ({ backend: 'indexeddb', state: 'saved', pendingWrites: 0 })); - persistenceStatusStore = fallbackStatusStore; + $effect(() => { + setTextEditorElRef(textEditorEl); + return () => setTextEditorElRef(null); }); @@ -1022,10 +78,10 @@ onHistoryClick={handleHistoryClick} {store} {getViewport} - {canvas} /> + canvas={canvasEl ?? undefined} />
{#if textEditor} diff --git a/apps/web/src/lib/canvas/canvas-store.svelte.ts b/apps/web/src/lib/canvas/canvas-store.svelte.ts new file mode 100644 --- /dev/null +++ b/apps/web/src/lib/canvas/canvas-store.svelte.ts @@ -0,0 +1,993 @@ +import { createInputAdapter, type InputAdapter } from "$lib/input"; +import type { DesktopDocRepo } from "$lib/persistence/desktop"; +import { createPlatformRepo, detectPlatform } from "$lib/platform"; +import { + createPersistenceManager, + createSnapStore, + createStatusStore, + type SnapStore, + type StatusStore, +} from "$lib/status"; +import { + type Action, + ArrowTool, + type BoardMeta, + Camera, + type CommandKind, + createToolMap, + CursorStore, + diffDoc, + EditorState, + EllipseTool, + getShapesOnCurrentPage, + LineTool, + type LoadedDoc, + type PersistenceSink, + type PersistentDocRepo, + RectTool, + routeAction, + SelectTool, + shapeBounds, + ShapeRecord, + SnapshotCommand, + Store, + switchTool, + TextTool, + type ToolId, + type Viewport, +} from "inkfinite-core"; +import { createRenderer, type Renderer } from "inkfinite-renderer"; +import { onDestroy, onMount } from "svelte"; + +export type CanvasControllerBindings = { setHistoryViewerOpen(value: boolean): void }; + +export type CanvasController = ReturnType; + +export function createCanvasController(bindings: CanvasControllerBindings) { + let repo: PersistentDocRepo | null = null; + let sink: PersistenceSink | null = null; + let persistenceManager: ReturnType | null = null; + const platform = detectPlatform(); + const fallbackStatusStore = createStatusStore({ + backend: platform === "desktop" ? "filesystem" : "indexeddb", + state: "saved", + pendingWrites: 0, + }); + 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) => { + if (!activeBoardId || event.kind !== "doc" || !sink) { + return; + } + const patch = diffDoc(event.beforeState.doc, event.afterState.doc); + sink.enqueueDocPatch(activeBoardId, patch); + }, + }); + const cursorStore = new CursorStore(); + const snapStore: SnapStore = createSnapStore(); + const pointerState = $state({ isPointerDown: false, snappedWorld: null as { x: number; y: number } | null }); + const handleState = $state<{ hover: string | null; active: string | null }>({ hover: null, active: null }); + let textEditor = $state<{ shapeId: string; value: string } | null>(null); + let textEditorEl: HTMLTextAreaElement | null = null; + const panState = $state({ isPanning: false, spaceHeld: false, lastScreen: { x: 0, y: 0 } }); + const snapProvider = { get: () => snapStore.get() }; + const cursorProvider = { get: () => cursorStore.getState() }; + const pointerStateProvider = { get: () => pointerState }; + const handleProvider = { get: () => ({ ...handleState }) }; + let pendingCommandStart: EditorState | null = null; + let canvas: HTMLCanvasElement | null = null; + + function setCanvasRef(node: HTMLCanvasElement | null) { + canvas = node; + } + + function setTextEditorElRef(node: HTMLTextAreaElement | null) { + textEditorEl = node; + } + + function applyLoadedDoc(doc: LoadedDoc) { + const firstPageId = doc.order.pageIds[0] ?? Object.keys(doc.pages)[0] ?? null; + store.setState((state) => ({ + ...state, + doc: { pages: doc.pages, shapes: doc.shapes, bindings: doc.bindings }, + ui: { ...state.ui, currentPageId: firstPageId, selectionIds: [] }, + })); + initializeSelection(firstPageId, doc); + } + + function initializeSelection(pageId: string | null, doc: LoadedDoc) { + if (!pageId) { + return; + } + const page = doc.pages[pageId]; + const firstShapeId = page?.shapeIds[0]; + if (!firstShapeId) { + return; + } + const state = editorSnapshot; + if (state.ui.selectionIds.length === 1 && state.ui.selectionIds[0] === firstShapeId) { + return; + } + const before = EditorState.clone(state); + const after = { ...state, ui: { ...state.ui, selectionIds: [firstShapeId] } }; + const command = new SnapshotCommand("Initialize Selection", "ui", before, EditorState.clone(after)); + store.executeCommand(command); + 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", + e: "ew-resize", + w: "ew-resize", + ne: "nesw-resize", + sw: "nesw-resize", + nw: "nwse-resize", + se: "nwse-resize", + rotate: "alias", + "line-start": "crosshair", + "line-end": "crosshair", + }; + + function refreshCursor() { + if (!canvas) { + return; + } + let cursor = "default"; + if (textEditor) { + cursor = "text"; + } else if (panState.isPanning) { + cursor = "grabbing"; + } else if (panState.spaceHeld) { + cursor = "grab"; + } else { + const activeHandle = handleState.active; + const hoverHandle = handleState.hover; + const targetHandle = activeHandle ?? hoverHandle; + if (targetHandle) { + cursor = handleCursorMap[targetHandle] ?? "default"; + } else if (pointerState.isPointerDown) { + cursor = "grabbing"; + } + } + canvas.style.cursor = cursor; + } + + function setHandleHover(handle: string | null) { + if (handleState.hover === handle) { + return; + } + handleState.hover = handle; + refreshCursor(); + } + + function syncHandleState() { + handleState.active = selectTool.getActiveHandle ? selectTool.getActiveHandle() : null; + refreshCursor(); + } + + function getTextEditorLayout() { + if (!textEditor) { + return null; + } + const state = store.getState(); + const shape = state.doc.shapes[textEditor.shapeId]; + if (!shape || shape.type !== "text") { + return null; + } + const viewport = getViewport(); + const screenPos = Camera.worldToScreen(state.camera, { x: shape.x, y: shape.y }, viewport); + const widthWorld = shape.props.w ?? 240; + const zoom = state.camera.zoom; + return { + left: screenPos.x, + top: screenPos.y, + width: widthWorld * zoom, + height: shape.props.fontSize * 1.4 * zoom, + fontSize: shape.props.fontSize * zoom, + }; + } + + function startTextEditing(shapeId: string) { + const state = store.getState(); + const shape = state.doc.shapes[shapeId]; + if (!shape || shape.type !== "text") { + return; + } + textEditor = { shapeId, value: shape.props.text }; + refreshCursor(); + queueMicrotask(() => { + textEditorEl?.focus(); + textEditorEl?.select(); + }); + } + + function commitTextEditing() { + if (!textEditor) { + return; + } + const { shapeId, value } = textEditor; + const currentState = store.getState(); + const shape = currentState.doc.shapes[shapeId]; + textEditor = null; + refreshCursor(); + if (!shape || shape.type !== "text" || shape.props.text === value) { + return; + } + const before = EditorState.clone(currentState); + const updatedShape = { ...shape, props: { ...shape.props, text: value } }; + const newShapes = { ...currentState.doc.shapes, [shapeId]: updatedShape }; + const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; + const command = new SnapshotCommand("Edit text", "doc", before, EditorState.clone(after)); + store.executeCommand(command); + } + + function cancelTextEditing() { + textEditor = null; + refreshCursor(); + } + + function handleCanvasDoubleClick(event: MouseEvent) { + if (!canvas) { + return; + } + const rect = canvas.getBoundingClientRect(); + const screen = { x: event.clientX - rect.left, y: event.clientY - rect.top }; + const world = Camera.screenToWorld(store.getState().camera, screen, getViewport()); + const shapeId = findTextShapeAt(world); + if (shapeId) { + startTextEditing(shapeId); + } + } + + function findTextShapeAt(point: { x: number; y: number }): string | null { + const shapes = getShapesOnCurrentPage(store.getState()); + for (let index = shapes.length - 1; index >= 0; index--) { + const shape = shapes[index]; + if (!shape || shape.type !== "text") { + continue; + } + const bounds = shapeBounds(shape); + if (point.x >= bounds.min.x && point.x <= bounds.max.x && point.y >= bounds.min.y && point.y <= bounds.max.y) { + return shape.id; + } + } + return null; + } + + function handleTextEditorInput(event: Event) { + if (!textEditor) { + return; + } + const target = event.currentTarget as HTMLTextAreaElement; + textEditor = { ...textEditor, value: target.value }; + } + + function handleTextEditorKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + cancelTextEditing(); + return; + } + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + commitTextEditing(); + } + } + + function handleTextEditorBlur() { + commitTextEditing(); + } + + function handlePointerLeave() { + setHandleHover(null); + } + + const selectTool = new SelectTool(); + const rectTool = new RectTool(); + const ellipseTool = new EllipseTool(); + const lineTool = new LineTool(); + const arrowTool = new ArrowTool(); + const textTool = new TextTool(); + const tools = createToolMap([selectTool, rectTool, ellipseTool, lineTool, arrowTool, textTool]); + + let currentToolId = $state("select"); + let editorSnapshot = $state(store.getState()); + + store.subscribe((state) => { + currentToolId = state.ui.toolId; + editorSnapshot = state; + }); + + function handleToolChange(toolId: ToolId) { + store.setState((state) => switchTool(state, toolId, tools)); + } + + function handleHistoryClick() { + bindings.setHistoryViewerOpen(true); + } + + function handleHistoryClose() { + bindings.setHistoryViewerOpen(false); + } + + function handleBringForward() { + const currentState = store.getState(); + const selectedIds = currentState.ui.selectionIds; + const currentPageId = currentState.ui.currentPageId; + + if (selectedIds.length === 0 || !currentPageId) { + return; + } + + const before = EditorState.clone(currentState); + const page = currentState.doc.pages[currentPageId]; + if (!page) return; + + const newShapeIds = [...page.shapeIds]; + + for (const shapeId of selectedIds) { + const currentIndex = newShapeIds.indexOf(shapeId); + if (currentIndex !== -1 && currentIndex < newShapeIds.length - 1) { + [newShapeIds[currentIndex], newShapeIds[currentIndex + 1]] = [ + newShapeIds[currentIndex + 1], + newShapeIds[currentIndex], + ]; + } + } + + const after = { + ...currentState, + doc: { + ...currentState.doc, + pages: { ...currentState.doc.pages, [currentPageId]: { ...page, shapeIds: newShapeIds } }, + }, + }; + + const command = new SnapshotCommand("Bring Forward", "doc", before, EditorState.clone(after)); + store.executeCommand(command); + syncHandleState(); + } + + function handleSendBackward() { + const currentState = store.getState(); + const selectedIds = currentState.ui.selectionIds; + const currentPageId = currentState.ui.currentPageId; + + if (selectedIds.length === 0 || !currentPageId) { + return; + } + + const before = EditorState.clone(currentState); + const page = currentState.doc.pages[currentPageId]; + if (!page) return; + + const newShapeIds = [...page.shapeIds]; + + for (let i = selectedIds.length - 1; i >= 0; i--) { + const shapeId = selectedIds[i]; + const currentIndex = newShapeIds.indexOf(shapeId); + if (currentIndex > 0) { + [newShapeIds[currentIndex], newShapeIds[currentIndex - 1]] = [ + newShapeIds[currentIndex - 1], + newShapeIds[currentIndex], + ]; + } + } + + const after = { + ...currentState, + doc: { + ...currentState.doc, + pages: { ...currentState.doc.pages, [currentPageId]: { ...page, shapeIds: newShapeIds } }, + }, + }; + + const command = new SnapshotCommand("Send Backward", "doc", before, EditorState.clone(after)); + store.executeCommand(command); + syncHandleState(); + } + + function handleDuplicate() { + const currentState = store.getState(); + const selectedIds = currentState.ui.selectionIds; + + if (selectedIds.length === 0) { + return; + } + + const before = EditorState.clone(currentState); + const newShapes = { ...currentState.doc.shapes }; + const newPages = { ...currentState.doc.pages }; + const duplicatedIds: string[] = []; + + const DUPLICATE_OFFSET = 20; + + for (const shapeId of selectedIds) { + const shape = currentState.doc.shapes[shapeId]; + if (!shape) continue; + + const cloned = ShapeRecord.clone(shape); + const newId = `shape:${crypto.randomUUID()}`; + const duplicated = { ...cloned, id: newId, x: shape.x + DUPLICATE_OFFSET, y: shape.y + DUPLICATE_OFFSET }; + + newShapes[newId] = duplicated; + duplicatedIds.push(newId); + + const currentPageId = currentState.ui.currentPageId; + if (currentPageId) { + const page = newPages[currentPageId]; + if (page) { + newPages[currentPageId] = { ...page, shapeIds: [...page.shapeIds, newId] }; + } + } + } + + const after = { + ...currentState, + doc: { ...currentState.doc, shapes: newShapes, pages: newPages }, + ui: { ...currentState.ui, selectionIds: duplicatedIds }, + }; + + const command = new SnapshotCommand("Duplicate", "doc", before, EditorState.clone(after)); + store.executeCommand(command); + syncHandleState(); + } + + function handleNudge(arrowKey: string, largeNudge: boolean) { + const currentState = store.getState(); + const selectedIds = currentState.ui.selectionIds; + + if (selectedIds.length === 0) { + return; + } + + const nudgeDistance = largeNudge ? 10 : 1; + let deltaX = 0; + let deltaY = 0; + + switch (arrowKey) { + case "ArrowLeft": + deltaX = -nudgeDistance; + break; + case "ArrowRight": + deltaX = nudgeDistance; + break; + case "ArrowUp": + deltaY = -nudgeDistance; + break; + case "ArrowDown": + deltaY = nudgeDistance; + break; + } + + const before = EditorState.clone(currentState); + const newShapes = { ...currentState.doc.shapes }; + + for (const shapeId of selectedIds) { + const shape = newShapes[shapeId]; + if (shape) { + newShapes[shapeId] = { ...shape, x: shape.x + deltaX, y: shape.y + deltaY }; + } + } + + const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; + const command = new SnapshotCommand("Nudge", "doc", before, EditorState.clone(after)); + store.executeCommand(command); + syncHandleState(); + } + + function applyActionWithHistory(action: Action) { + const before = store.getState(); + const nextState = routeAction(before, action, tools); + if (statesEqual(before, nextState)) { + syncHandleState(); + return; + } + + const kind = getCommandKind(before, nextState); + const commandName = describeAction(action, kind); + const command = new SnapshotCommand(commandName, kind, EditorState.clone(before), EditorState.clone(nextState)); + store.executeCommand(command); + syncHandleState(); + } + + function handleAction(action: Action) { + if (textEditor && (action.type === "pointer-down" || action.type === "pointer-up")) { + commitTextEditing(); + } + + if (action.type === "pointer-move" && "world" in action && !panState.isPanning && !panState.spaceHeld) { + const hover = selectTool.getHandleAtPoint(store.getState(), action.world); + setHandleHover(hover); + } + + if (action.type === "pointer-move" && (panState.isPanning || panState.spaceHeld)) { + setHandleHover(null); + } + + if (action.type === "key-down" && action.key === " ") { + panState.spaceHeld = true; + setHandleHover(null); + refreshCursor(); + return; + } + + if (action.type === "key-up" && action.key === " ") { + panState.spaceHeld = false; + panState.isPanning = false; + refreshCursor(); + return; + } + + if (action.type === "pointer-down" && action.button === 0 && panState.spaceHeld) { + panState.isPanning = true; + panState.lastScreen = { x: action.screen.x, y: action.screen.y }; + refreshCursor(); + return; + } + + if (action.type === "pointer-move" && panState.isPanning) { + const deltaX = action.screen.x - panState.lastScreen.x; + const deltaY = action.screen.y - panState.lastScreen.y; + const currentCamera = store.getState().camera; + const newCamera = Camera.pan(currentCamera, { x: deltaX, y: deltaY }); + store.setState((state) => ({ ...state, camera: newCamera })); + panState.lastScreen = { x: action.screen.x, y: action.screen.y }; + refreshCursor(); + return; + } + + if (action.type === "pointer-up" && action.button === 0 && panState.isPanning) { + panState.isPanning = false; + refreshCursor(); + return; + } + + if (panState.isPanning || panState.spaceHeld) { + return; + } + + const actionWithSnap = applySnapping(action); + if ("world" in actionWithSnap) { + pointerState.snappedWorld = actionWithSnap.world ?? null; + } + + if (actionWithSnap.type === "pointer-down" && actionWithSnap.button === 0) { + pointerState.isPointerDown = true; + setHandleHover(null); + refreshCursor(); + pendingCommandStart = EditorState.clone(store.getState()); + const changed = applyImmediateAction(actionWithSnap); + if (!changed) { + pendingCommandStart = null; + } + return; + } + + if (actionWithSnap.type === "pointer-move" && pointerState.isPointerDown && pendingCommandStart) { + void applyImmediateAction(actionWithSnap); + return; + } + + if (actionWithSnap.type === "pointer-up" && actionWithSnap.button === 0) { + pointerState.isPointerDown = false; + setHandleHover(null); + refreshCursor(); + if (pendingCommandStart) { + const committed = commitPendingCommand(actionWithSnap, pendingCommandStart); + pendingCommandStart = null; + if (committed) { + return; + } + } + pointerState.snappedWorld = null; + } + + if (actionWithSnap.type === "key-down") { + const isPrimary = (actionWithSnap.modifiers.meta && navigator.platform.toUpperCase().includes("MAC")) + || (actionWithSnap.modifiers.ctrl && !navigator.platform.toUpperCase().includes("MAC")); + + if (isPrimary && !actionWithSnap.modifiers.shift && (actionWithSnap.key === "z" || actionWithSnap.key === "Z")) { + store.undo(); + return; + } + + if (isPrimary && actionWithSnap.modifiers.shift && (actionWithSnap.key === "z" || actionWithSnap.key === "Z")) { + store.redo(); + return; + } + + if (isPrimary && (actionWithSnap.key === "d" || actionWithSnap.key === "D")) { + handleDuplicate(); + return; + } + + if (isPrimary && actionWithSnap.key === "]") { + handleBringForward(); + return; + } + + if (isPrimary && actionWithSnap.key === "[") { + handleSendBackward(); + return; + } + + if (actionWithSnap.key.startsWith("Arrow")) { + handleNudge(actionWithSnap.key, actionWithSnap.modifiers.shift); + return; + } + } + + applyActionWithHistory(actionWithSnap); + } + + function applyImmediateAction(action: Action): boolean { + const before = store.getState(); + const nextState = routeAction(before, action, tools); + if (statesEqual(before, nextState)) { + syncHandleState(); + return false; + } + store.setState(() => nextState); + syncHandleState(); + return true; + } + + function commitPendingCommand(action: Action, startState: EditorState): boolean { + const before = store.getState(); + const nextState = routeAction(before, action, tools); + const finalState = statesEqual(before, nextState) ? before : nextState; + if (statesEqual(startState, finalState)) { + syncHandleState(); + return false; + } + const kind = getCommandKind(startState, finalState); + const commandName = describeAction(action, kind); + const command = new SnapshotCommand( + commandName, + kind, + EditorState.clone(startState), + EditorState.clone(finalState), + ); + store.executeCommand(command); + syncHandleState(); + return true; + } + + function statesEqual(a: EditorState, b: EditorState): boolean { + return a.doc === b.doc && a.camera === b.camera && a.ui === b.ui; + } + + function getCommandKind(before: EditorState, after: EditorState): CommandKind { + if (before.doc !== after.doc) { + return "doc"; + } + if (before.camera !== after.camera) { + return "camera"; + } + return "ui"; + } + + function describeAction(action: Action, kind: CommandKind): string { + switch (action.type) { + case "pointer-down": + return "Pointer down"; + case "pointer-move": + return "Pointer move"; + case "pointer-up": + return "Pointer up"; + case "wheel": + return "Wheel"; + case "key-down": + return "Key down"; + case "key-up": + return "Key up"; + default: + return kind === "doc" ? "Edit" : kind === "camera" ? "Camera change" : "UI change"; + } + } + + 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) { + return action; + } + if (!("world" in action)) { + return action; + } + const snapCoord = (value: number) => Math.round(value / snap.gridSize) * snap.gridSize; + const snappedWorld = { x: snapCoord(action.world.x), y: snapCoord(action.world.y) }; + return { ...action, world: snappedWorld }; + } + + let renderer: Renderer | null = null; + let inputAdapter: InputAdapter | null = null; + + function getViewport(): Viewport { + if (canvas) { + const rect = canvas.getBoundingClientRect(); + return { width: rect.width || 1, height: rect.height || 1 }; + } + if (typeof window !== "undefined") { + return { width: window.innerWidth || 1, height: window.innerHeight || 1 }; + } + return { width: 1, height: 1 }; + } + + onMount(() => { + let disposed = false; + + const initialize = async () => { + 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 } }); + sink = persistenceManager.sink; + persistenceStatusStore = persistenceManager.status; + } else { + const { createPersistenceSink } = await import("inkfinite-core"); + if (disposed) { + return; + } + sink = createPersistenceSink(repo, { debounceMs: 500 }); + } + + const hydrate = async () => { + const repoInstance = repo; + if (!repoInstance) { + return; + } + try { + if (detectedPlatform === "web") { + const boards = await repoInstance.listBoards(); + const id = boards[0]?.id ?? (await repoInstance.createBoard("My board")); + if (disposed) { + return; + } + setActiveBoardId(id); + const loaded = await repoInstance.loadDoc(id); + if (!disposed) { + applyLoadedDoc(loaded); + } + } else { + const boards = await refreshDesktopBoards(); + let id = boards[0]?.id ?? null; + if (!id) { + id = await repoInstance.createBoard("Untitled"); + } + if (disposed) { + return; + } + setActiveBoardId(id); + const loaded = await repoInstance.loadDoc(id); + if (!disposed) { + applyLoadedDoc(loaded); + updateDesktopFileState(); + } + await refreshDesktopBoards(); + } + } catch (error) { + console.error("Failed to load board", error); + } + }; + + await hydrate(); + if (disposed) { + return; + } + + function getCamera() { + return store.getState().camera; + } + + const currentCanvas = canvas; + if (!currentCanvas) { + return; + } + + renderer = createRenderer(currentCanvas, store, { + snapProvider, + cursorProvider, + pointerStateProvider, + handleProvider, + }); + inputAdapter = createInputAdapter({ + canvas: currentCanvas, + getCamera, + getViewport, + onAction: handleAction, + onCursorUpdate: (world, screen) => cursorStore.updateCursor(world, screen), + }); + + if (typeof window !== "undefined") { + function handleBeforeUnload() { + if (sink) { + void sink.flush(); + } + } + + window.addEventListener("beforeunload", handleBeforeUnload); + removeBeforeUnload = () => window.removeEventListener("beforeunload", handleBeforeUnload); + } + }; + + void initialize(); + + return () => { + disposed = true; + }; + }); + + 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(); + persistenceManager = null; + fallbackStatusStore.update(() => ({ backend: "indexeddb", state: "saved", pendingWrites: 0 })); + persistenceStatusStore = fallbackStatusStore; + }); + + return { + platform: () => platform, + desktopBoards: () => desktopBoards, + desktopFileName: () => desktopFileName, + handleDesktopOpen, + handleDesktopNewBoard, + handleDesktopSaveAs, + handleDesktopRecentSelect, + currentToolId: () => currentToolId, + handleToolChange, + handleHistoryClick, + handleHistoryClose, + store, + getViewport, + handleCanvasDoubleClick, + handlePointerLeave, + textEditor: () => textEditor, + getTextEditorLayout, + handleTextEditorInput, + handleTextEditorKeyDown, + handleTextEditorBlur, + cursorStore, + persistenceStatusStore: () => persistenceStatusStore, + snapStore, + setCanvasRef, + setTextEditorElRef, + }; +} diff --git a/apps/web/src/lib/persistence/desktop.ts b/apps/web/src/lib/persistence/desktop.ts --- a/apps/web/src/lib/persistence/desktop.ts +++ b/apps/web/src/lib/persistence/desktop.ts @@ -3,7 +3,7 @@ * Used when the web app is running inside Tauri */ -import type { BoardExport, BoardMeta, DocPatch, DocRepo, LoadedDoc, PageRecord } from "inkfinite-core"; +import type { BoardExport, BoardMeta, DocPatch, LoadedDoc, PageRecord, PersistentDocRepo } from "inkfinite-core"; import { createFileData, createId, @@ -15,13 +15,13 @@ } from "inkfinite-core"; import type { DesktopFileOps } from "../fileops"; -export type DesktopDocRepo = DocRepo & { +export type DesktopDocRepo = PersistentDocRepo & { kind: "desktop"; getCurrentFile(): FileHandle | null; openFromDialog(): Promise<{ boardId: string; doc: LoadedDoc }>; }; -export function isDesktopRepo(repo: DocRepo): repo is DesktopDocRepo { +export function isDesktopRepo(repo: PersistentDocRepo): repo is DesktopDocRepo { return (repo as DesktopDocRepo).kind === "desktop"; } @@ -167,6 +167,10 @@ } } + async function openBoard(boardId: string): Promise { + await loadDoc(boardId); + } + async function applyDocPatch(boardId: string, patch: DocPatch): Promise { if (!currentBoard || !currentDoc || !currentFile) { throw new Error("No board loaded"); @@ -287,6 +291,7 @@ kind: "desktop", listBoards, createBoard, + openBoard, renameBoard, deleteBoard, loadDoc, @@ -301,7 +306,7 @@ /** * Get current file handle (for showing in title bar, etc.) */ -export function getCurrentFile(repo: DocRepo): FileHandle | null { +export function getCurrentFile(repo: PersistentDocRepo): FileHandle | null { if (isDesktopRepo(repo)) { return repo.getCurrentFile(); } diff --git a/apps/web/src/lib/tests/Canvas.history.test.ts b/apps/web/src/lib/tests/Canvas.history.test.ts --- a/apps/web/src/lib/tests/Canvas.history.test.ts +++ b/apps/web/src/lib/tests/Canvas.history.test.ts @@ -178,10 +178,17 @@ const createWebDocRepo = vi.fn(() => ({ listBoards: vi.fn(async () => [{ id: "board:1", name: "Board 1", createdAt: 0, updatedAt: 0 }]), createBoard: vi.fn(async () => "board:new"), + openBoard: vi.fn(async () => {}), renameBoard: vi.fn(), deleteBoard: vi.fn(), loadDoc: vi.fn(async () => createDoc()), applyDocPatch: vi.fn(), + exportBoard: vi.fn(async () => ({ + board: { id: "board:1", name: "", createdAt: 0, updatedAt: 0 }, + doc: createDoc(), + order: { pageIds: [], shapeOrder: {} }, + })), + importBoard: vi.fn(async () => "board:new"), })); const routeAction = vi.fn((state: any, action: any) => { diff --git a/apps/web/src/lib/tests/Canvas.keyboard.test.ts b/apps/web/src/lib/tests/Canvas.keyboard.test.ts --- a/apps/web/src/lib/tests/Canvas.keyboard.test.ts +++ b/apps/web/src/lib/tests/Canvas.keyboard.test.ts @@ -68,6 +68,9 @@ createWebDocRepo: vi.fn(() => ({ listBoards: async () => [{ id: "board-1", name: "Test Board", createdAt: 0, updatedAt: 0 }], createBoard: async () => "board-1", + openBoard: async () => {}, + renameBoard: async () => {}, + deleteBoard: async () => {}, loadDoc: async () => ({ pages: { "page:1": { id: "page:1", name: "Page 1", shapeIds: ["shape:1"] } }, shapes: { @@ -84,6 +87,13 @@ bindings: {}, order: { pageIds: ["page:1"] }, }), + applyDocPatch: async () => {}, + exportBoard: async () => ({ + board: { id: "board-1", name: "Test Board", createdAt: 0, updatedAt: 0 }, + doc: { pages: {}, shapes: {}, bindings: {} }, + order: { pageIds: [], shapeOrder: {} }, + }), + importBoard: async () => "board-1", })), }; }); diff --git a/apps/web/src/lib/tests/persistence.desktop.test.ts b/apps/web/src/lib/tests/persistence.desktop.test.ts --- a/apps/web/src/lib/tests/persistence.desktop.test.ts +++ b/apps/web/src/lib/tests/persistence.desktop.test.ts @@ -1,4 +1,11 @@ -import { createFileData, type DesktopFileOps, type FileHandle, PageRecord, serializeDesktopFile } from "inkfinite-core"; +import { + type BoardMeta, + createFileData, + type DesktopFileOps, + type FileHandle, + PageRecord, + serializeDesktopFile, +} from "inkfinite-core"; import { beforeEach, describe, expect, it } from "vitest"; import { createDesktopDocRepo } from "../persistence/desktop"; @@ -101,7 +108,7 @@ expect(Object.keys(opened.doc.pages)).toEqual([page.id]); const boards = await repo.listBoards(); - expect(boards.some((entry) => entry.id === "board-dialog")).toBe(true); + expect(boards.some((entry: BoardMeta) => entry.id === "board-dialog")).toBe(true); }); it("renames the current board and updates the file", async () => { diff --git a/apps/web/src/lib/tests/status.test.ts b/apps/web/src/lib/tests/status.test.ts --- a/apps/web/src/lib/tests/status.test.ts +++ b/apps/web/src/lib/tests/status.test.ts @@ -1,12 +1,14 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import type { Observable, Observer, Subscription } from "dexie"; -import type { DocPatch, DocRepo, InkfiniteDB, PageRecord } from "inkfinite-core"; +import type { DocPatch, InkfiniteDB, PageRecord, PersistentDocRepo } from "inkfinite-core"; import { describe, expect, it, vi } from "vitest"; import { createPersistenceManager, type PersistenceManagerOptions } from "../status"; -function createMockRepo(): DocRepo { +function createMockRepo(): PersistentDocRepo { return { listBoards: vi.fn(async () => []), createBoard: vi.fn(async () => "board:mock"), + openBoard: vi.fn(async () => {}), renameBoard: vi.fn(async () => {}), deleteBoard: vi.fn(async () => {}), loadDoc: vi.fn(async () => ({ pages: {}, shapes: {}, bindings: {}, order: { pageIds: [], shapeOrder: {} } })), @@ -71,7 +73,7 @@ } function createStatusTracker( - overrides?: { repo?: DocRepo; options?: PersistenceManagerOptions; db?: Partial }, + overrides?: { repo?: PersistentDocRepo; options?: PersistenceManagerOptions; db?: Partial }, ) { const repo = overrides?.repo ?? createMockRepo(); const live = overrides?.options?.liveQueryFn ? null : createMockLiveQuery();