diff --git a/TODO.md b/TODO.md index 49728e3..31942a6 100644 --- a/TODO.md +++ b/TODO.md @@ -257,8 +257,3 @@ Acceptance criteria: - How should bundling/packaging work? ### QA - -- [x] The desktop kept an expired proposal visible even though the backend had - discarded it, leaving Accept and Reject unable to complete. The desktop - now clears the review when its deadline passes and confirms that the - document did not change. diff --git a/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts b/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts index faecbab..04b3eae 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts @@ -129,6 +129,21 @@ describe('Tauri desktop session command boundary', () => { expect(tauri.invoke).toHaveBeenCalledWith('save_as', expect.any(Object)); }); + it('turns structured Tauri failures into readable errors', async () => { + const files = fileOps(); + tauri.invoke.mockImplementation(async (command: string) => { + if (command === 'record_renderer_error') return undefined; + throw { code: 'document_locked', message: 'Another editor holds the document lock.' }; + }); + + const repo = createDesktopSessionRepo(files.ops); + + await expect(repo.createBoard('Locked')).rejects.toThrow('Another editor holds the document lock.'); + expect(tauri.invoke).toHaveBeenCalledWith('record_renderer_error', { + message: 'create_document failed: Another editor holds the document lock.' + }); + }); + it('clears a live proposal when its review window expires', async () => { vi.useFakeTimers(); vi.setSystemTime(1_000); diff --git a/apps/desktop/src/lib/persistence/desktop-session.test.ts b/apps/desktop/src/lib/persistence/desktop-session.test.ts index ab6b538..073cfda 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.test.ts @@ -464,4 +464,46 @@ describe('Rust-backed desktop session repository', () => { expect(repo.getCurrentFile()?.path).toBe('/tmp/Untitled.inkfinite'); expect(repo.getSessionStatus()?.dirty).toBe(false); }); + + it('flushes pending editor writes before replacing the open session', async () => { + fileOps.setOpenPath('/tmp/Funtitled.inkfinite'); + const repo = createDesktopSessionRepo(fileOps.ops, { api: session.api }); + const draft = await repo.openDraft(); + const pendingFailure = new Error('Pending write failed'); + + await expect( + repo.openFromDialog(async () => { + throw pendingFailure; + }) + ).rejects.toBe(pendingFailure); + + expect(repo.getSessionStatus()?.snapshot.document_id).toBe(draft.boardId); + expect(repo.isDraft()).toBe(true); + }); + + it('returns the committed document after accepting a proposal', async () => { + const repo = createDesktopSessionRepo(fileOps.ops, { api: session.api }); + const opened = await repo.openDraft(); + const pageId = Object.keys(opened.doc.pages)[0]; + session.api.acceptProposal = ({ session_id }) => + session.api.commit({ + session_id, + transaction: { + id: 'transaction:accepted-proposal', + actor_id: 'actor:desktop', + origin: 'agent', + base_heads: repo.getSessionStatus()?.snapshot.heads ?? [], + description: 'Accepted proposal fixture', + operations: [ + { type: 'rename_page', page_id: pageId, name: 'Accepted proposal', expected_version: 1 } + ], + timestamp: Date.now() + } + }); + + const committed = await repo.acceptProposal('proposal:accepted'); + + expect(committed.pages[pageId].name).toBe('Accepted proposal'); + expect((await repo.loadDoc(opened.boardId)).pages[pageId].name).toBe('Accepted proposal'); + }); }); diff --git a/apps/desktop/src/lib/persistence/desktop-session.ts b/apps/desktop/src/lib/persistence/desktop-session.ts index 0bf9bbe..d5ceafc 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.ts @@ -199,13 +199,28 @@ function createSessionApi(): SessionApi { }; } +/** Converts errors crossing the Tauri boundary into readable renderer text. */ +function describeError(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + if (typeof error === 'object' && error !== null) { + const message = (error as { message?: unknown }).message; + if (typeof message === 'string' && message.trim()) return message; + try { + return JSON.stringify(error); + } catch { + return 'Unknown structured error'; + } + } + return String(error); +} + function invokeSession(command: string, args: Record): Promise { return invoke(command, args).catch((error: unknown) => { - const detail = - error instanceof Error ? error.message : typeof error === 'string' ? error : JSON.stringify(error); + const detail = describeError(error); const message = `${command} failed: ${detail}`; void invoke('record_renderer_error', { message }).catch(() => undefined); - throw error; + throw new Error(detail, { cause: error }); }); } @@ -215,7 +230,7 @@ export type DesktopSessionRepo = PersistentDocRepo & { openDraft(): Promise<{ boardId: string; doc: LoadedDoc }>; isDraft(): boolean; getCurrentFile(): FileHandle | null; - openFromDialog(): Promise<{ boardId: string; doc: LoadedDoc }>; + openFromDialog(prepareToOpen?: () => Promise): Promise<{ boardId: string; doc: LoadedDoc }>; saveAs(prepareToSave?: () => Promise): Promise<{ boardId: string; doc: LoadedDoc }>; getWorkspaceDir(): Promise; setWorkspaceDir(path: string | null): Promise; @@ -227,7 +242,7 @@ export type DesktopSessionRepo = PersistentDocRepo & { getSessionStatus(): SessionStatus | null; getProposal(): Proposal | null; subscribeProposal(listener: (update: ProposalUpdate) => void): () => void; - acceptProposal(proposalId: string, operationPositions?: number[]): Promise; + acceptProposal(proposalId: string, operationPositions?: number[]): Promise; rejectProposal(proposalId: string): Promise; authorizeApply(): Promise; syncConnect(peerId: string): Promise; @@ -542,9 +557,10 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: return currentBoard.id; } - async function openFromDialog(): Promise<{ boardId: string; doc: LoadedDoc }> { + async function openFromDialog(prepareToOpen?: () => Promise): Promise<{ boardId: string; doc: LoadedDoc }> { const path = await fileOps.showOpenDialog(); if (!path) throw new Error('Open cancelled'); + await prepareToOpen?.(); const doc = await openPath(path); if (!currentBoard) throw new Error('Failed to open document'); return { boardId: currentBoard.id, doc }; @@ -630,9 +646,12 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: return () => proposalListeners.delete(listener); } - async function acceptProposal(proposalId: string, operationPositions?: number[]): Promise { + async function acceptProposal(proposalId: string, operationPositions?: number[]): Promise { if (!currentStatus) throw new Error('No board loaded'); - if (clearExpiredProposal(proposalId)) return; + if (clearExpiredProposal(proposalId)) { + if (!currentDoc) throw new Error('No board loaded'); + return currentDoc; + } const result = await api.acceptProposal({ session_id: currentStatus.session_id, proposal_id: proposalId, @@ -641,6 +660,8 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: updateStatus(result.status); notifyProposal({ proposal: null }); await saveCurrentSession(); + if (!currentDoc) throw new Error('Accepted proposal did not return a document'); + return currentDoc; } async function rejectProposal(proposalId: string): Promise { @@ -733,12 +754,7 @@ export function createDesktopPersistenceSink( const pendingWrites = Math.max(0, (status.pendingWrites ?? 1) - 1); const persistenceError = error ?? lastError; if (persistenceError) { - return { - ...status, - state: 'error', - pendingWrites, - errorMsg: persistenceError instanceof Error ? persistenceError.message : String(persistenceError) - }; + return { ...status, state: 'error', pendingWrites, errorMsg: describeError(persistenceError) }; } return { ...status, diff --git a/apps/web/src/lib/tests/input.test.ts b/apps/web/src/lib/tests/input.test.ts index a306cde..fdbe543 100644 --- a/apps/web/src/lib/tests/input.test.ts +++ b/apps/web/src/lib/tests/input.test.ts @@ -490,6 +490,17 @@ describe('InputAdapter', () => { expect(event.defaultPrevented).toBe(true); }); + it('prevents browser behavior for the board browser shortcut', () => { + const event = createKeyboardEvent('keydown', { + key: 'b', + code: 'KeyB', + metaKey: true + }); + window.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + }); + it('should not capture keyboard events when captureKeyboard is false', () => { const testActions: ActionType[] = []; const testAdapter = new InputAdapter({ diff --git a/apps/web/src/lib/tests/keyboard-shortcuts.test.ts b/apps/web/src/lib/tests/keyboard-shortcuts.test.ts index 21cde7e..ce4624e 100644 --- a/apps/web/src/lib/tests/keyboard-shortcuts.test.ts +++ b/apps/web/src/lib/tests/keyboard-shortcuts.test.ts @@ -1,222 +1,63 @@ -/** - * Unit tests for keyboard shortcuts (Cmd+O, Cmd+N) - */ - -import type { KeyDownAction } from "@inkfinite/core"; -import { describe, expect, it, vi } from "vitest"; - -describe("Keyboard shortcuts", () => { - describe("Cmd+O / Ctrl+O (Open file browser)", () => { - it("should trigger with Cmd+O on Mac", () => { - const action: KeyDownAction = { - type: "key-down", - key: "o", - code: "KeyO", - modifiers: { ctrl: false, shift: false, alt: false, meta: true }, - repeat: false, - timestamp: Date.now(), - }; - - const handleOpen = vi.fn(); - const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; - - if (primaryModifier && (action.key === "o" || action.key === "O")) { - handleOpen(); - } - - expect(handleOpen).toHaveBeenCalled(); - }); - - it("should trigger with Ctrl+O on Windows/Linux", () => { - const action: KeyDownAction = { - type: "key-down", - key: "o", - code: "KeyO", - modifiers: { ctrl: true, shift: false, alt: false, meta: false }, - repeat: false, - timestamp: Date.now(), - }; - - const handleOpen = vi.fn(); - const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; - - if (primaryModifier && (action.key === "o" || action.key === "O")) { - handleOpen(); - } - - expect(handleOpen).toHaveBeenCalled(); - }); - - it("should handle uppercase O", () => { - const action: KeyDownAction = { - type: "key-down", - key: "O", - code: "KeyO", - modifiers: { ctrl: false, shift: true, alt: false, meta: true }, - repeat: false, - timestamp: Date.now(), - }; - - const handleOpen = vi.fn(); - const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; - - if (primaryModifier && (action.key === "o" || action.key === "O")) { - handleOpen(); - } - - expect(handleOpen).toHaveBeenCalled(); - }); - - it("should not trigger without modifier", () => { - const action: KeyDownAction = { - type: "key-down", - key: "o", - code: "KeyO", - modifiers: { ctrl: false, shift: false, alt: false, meta: false }, - repeat: false, - timestamp: Date.now(), - }; - - const handleOpen = vi.fn(); - const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; - - if (primaryModifier && (action.key === "o" || action.key === "O")) { - handleOpen(); - } - - expect(handleOpen).not.toHaveBeenCalled(); - }); - }); - - describe("Cmd+N / Ctrl+N (New board)", () => { - it("should trigger with Cmd+N on Mac", () => { - const action: KeyDownAction = { - type: "key-down", - key: "n", - code: "KeyN", - modifiers: { ctrl: false, shift: false, alt: false, meta: true }, - repeat: false, - timestamp: Date.now(), - }; - - const handleNew = vi.fn(); - const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; - - if (primaryModifier && (action.key === "n" || action.key === "N")) { - handleNew(); - } - - expect(handleNew).toHaveBeenCalled(); - }); - - it("should trigger with Ctrl+N on Windows/Linux", () => { - const action: KeyDownAction = { - type: "key-down", - key: "n", - code: "KeyN", - modifiers: { ctrl: true, shift: false, alt: false, meta: false }, - repeat: false, - timestamp: Date.now(), - }; - - const handleNew = vi.fn(); - const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; - - if (primaryModifier && (action.key === "n" || action.key === "N")) { - handleNew(); - } - - expect(handleNew).toHaveBeenCalled(); - }); - - it("should handle uppercase N", () => { - const action: KeyDownAction = { - type: "key-down", - key: "N", - code: "KeyN", - modifiers: { ctrl: false, shift: true, alt: false, meta: true }, - repeat: false, - timestamp: Date.now(), - }; - - const handleNew = vi.fn(); - const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; - - if (primaryModifier && (action.key === "n" || action.key === "N")) { - handleNew(); - } - - expect(handleNew).toHaveBeenCalled(); - }); - - it("should not trigger without modifier", () => { - const action: KeyDownAction = { - type: "key-down", - key: "n", - code: "KeyN", - modifiers: { ctrl: false, shift: false, alt: false, meta: false }, - repeat: false, - timestamp: Date.now(), - }; - - const handleNew = vi.fn(); - const primaryModifier = action.modifiers.meta || action.modifiers.ctrl; - - if (primaryModifier && (action.key === "n" || action.key === "N")) { - handleNew(); - } - - expect(handleNew).not.toHaveBeenCalled(); - }); - }); - - describe("Other existing shortcuts", () => { - it("should not conflict with Cmd+D (duplicate)", () => { - const actionD: KeyDownAction = { - type: "key-down", - key: "d", - code: "KeyD", - modifiers: { ctrl: false, shift: false, alt: false, meta: true }, - repeat: false, - timestamp: Date.now(), - }; - - const handleOpen = vi.fn(); - const handleDuplicate = vi.fn(); - const primaryModifier = actionD.modifiers.meta || actionD.modifiers.ctrl; - - if (primaryModifier && (actionD.key === "o" || actionD.key === "O")) { - handleOpen(); - } else if (primaryModifier && (actionD.key === "d" || actionD.key === "D")) { - handleDuplicate(); - } - - expect(handleOpen).not.toHaveBeenCalled(); - expect(handleDuplicate).toHaveBeenCalled(); - }); - - it("should not conflict with arrow key navigation", () => { - const actionArrow: KeyDownAction = { - type: "key-down", - key: "ArrowLeft", - code: "ArrowLeft", - modifiers: { ctrl: false, shift: false, alt: false, meta: false }, - repeat: false, - timestamp: Date.now(), - }; - - const handleOpen = vi.fn(); - const handleNav = vi.fn(); - const primaryModifier = actionArrow.modifiers.meta || actionArrow.modifiers.ctrl; - - if (primaryModifier && (actionArrow.key === "o" || actionArrow.key === "O")) { - handleOpen(); - } else if (actionArrow.key.startsWith("Arrow")) { - handleNav(); - } - - expect(handleOpen).not.toHaveBeenCalled(); - expect(handleNav).toHaveBeenCalled(); - }); - }); +import { Action, EditorState, Store, type Tool } from '@inkfinite/core'; +import { EditorRuntime, type SelectionTool } from '@inkfinite/runtime'; +import { describe, expect, it, vi } from 'vitest'; + +class IdleSelectionTool implements Tool { + readonly id = 'select' as const; + + onEnter(state: EditorState) { + return state; + } + + onExit(state: EditorState) { + return state; + } + + onAction(state: EditorState) { + return state; + } +} + +function runtimeWithBoardBrowser(onBrowseRequested: () => void) { + const tool = Object.assign(new IdleSelectionTool(), { + getHandleAtPoint: () => null + }) satisfies SelectionTool; + return new EditorRuntime({ + store: new Store(), + tools: new Map([[tool.id, tool]]), + selectionTool: tool, + getSnapSettings: () => ({ snapEnabled: false, gridEnabled: false, gridSize: 25 }), + onTransactionDraft: vi.fn(), + onBrowseRequested + }); +} + +describe('board browser keyboard shortcut', () => { + it.each([ + { label: 'Cmd+B', modifiers: { ctrl: false, shift: false, alt: false, meta: true } }, + { label: 'Ctrl+B', modifiers: { ctrl: true, shift: false, alt: false, meta: false } } + ])('opens Boards with $label', ({ modifiers }) => { + const onBrowseRequested = vi.fn(); + const runtime = runtimeWithBoardBrowser(onBrowseRequested); + + runtime.handleAction(Action.keyDown('b', 'KeyB', modifiers)); + + expect(onBrowseRequested).toHaveBeenCalledOnce(); + }); + + it.each([ + { key: 'o', code: 'KeyO' }, + { key: 'n', code: 'KeyN' }, + { key: 'd', code: 'KeyD' }, + { key: 'ArrowLeft', code: 'ArrowLeft' } + ])('does not open Boards for $code', ({ key, code }) => { + const onBrowseRequested = vi.fn(); + const runtime = runtimeWithBoardBrowser(onBrowseRequested); + + runtime.handleAction( + Action.keyDown(key, code, { ctrl: false, shift: false, alt: false, meta: true }) + ); + + expect(onBrowseRequested).not.toHaveBeenCalled(); + }); }); diff --git a/packages/input-dom/src/index.ts b/packages/input-dom/src/index.ts index 9d9d6e8..8db7cc2 100644 --- a/packages/input-dom/src/index.ts +++ b/packages/input-dom/src/index.ts @@ -399,7 +399,7 @@ export class InputAdapter { * - Space (scroll) * - Arrow keys (scroll) * - Backspace/Delete (navigation) - * - Cmd/Ctrl+Z, Cmd/Ctrl+Y (browser undo/redo) + * - Cmd/Ctrl+B (board browser), Cmd/Ctrl+Z, Cmd/Ctrl+Y (browser undo/redo) * - Camera shortcuts (+, -, 0, Shift+1, Shift+2) * - Tab (focus change) */ @@ -435,6 +435,10 @@ export class InputAdapter { return true; } + if (Modifiers.isPrimaryModifier(modifiers) && (key === 'b' || key === 'B')) { + return true; + } + return false; } } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 605678e..255a2a6 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -36,7 +36,8 @@ export type EditorRuntimeOptions = { selectionTool: SelectionTool; getSnapSettings: () => SnapSettings; onTransactionDraft: (draft: RuntimeTransactionDraft) => void; - onOpenRequested?: () => void; + /** Opens the board browser from its dedicated Cmd/Ctrl+B shortcut. */ + onBrowseRequested?: () => void; onHandleHover?: (handle: string | null) => void; onInteractionChanged?: () => void; onSnappedWorldChanged?: (world: { x: number; y: number }) => void; @@ -120,7 +121,7 @@ export class EditorRuntime { } const before = store.getState(); - const shortcut = applyKeyboardShortcut(before, routedAction, this.options.onOpenRequested); + const shortcut = applyKeyboardShortcut(before, routedAction, this.options.onBrowseRequested); const after = shortcut ?? routeAction(before, routedAction, this.options.tools); if (!statesEqual(before, after)) { @@ -189,12 +190,12 @@ function snapAction(action: Action, snap: SnapSettings): Action { function applyKeyboardShortcut( state: EditorState, action: Action, - onOpenRequested: (() => void) | undefined, + onBrowseRequested: (() => void) | undefined, ): EditorState | null { if (action.type !== "key-down") return null; const primary = action.modifiers.meta || action.modifiers.ctrl; - if (primary && ["o", "O", "n", "N"].includes(action.key)) { - onOpenRequested?.(); + if (primary && (action.key === "b" || action.key === "B")) { + onBrowseRequested?.(); return null; } if (state.ui.selectionIds.length === 0) return null; diff --git a/packages/ui/src/lib/editor/canvas/Canvas.svelte b/packages/ui/src/lib/editor/canvas/Canvas.svelte index a3cd5f4..b365b68 100644 --- a/packages/ui/src/lib/editor/canvas/Canvas.svelte +++ b/packages/ui/src/lib/editor/canvas/Canvas.svelte @@ -6,6 +6,7 @@ import StencilPalette from '../components/StencilPalette.svelte'; import LayerPanel from '../components/LayerPanel.svelte'; import ProposalReview from '../components/ProposalReview.svelte'; + import ProposalGhostLayer from '../components/ProposalGhostLayer.svelte'; import NavigationControls from './NavigationControls.svelte'; import { ContextMenu, type ContextMenuEntry } from '../../index'; import { createCanvasController } from './canvas-store.svelte'; @@ -204,26 +205,10 @@ oncontextmenu={handleCanvasContextMenu} onpointerleave={c.handlePointerLeave}> {#if liveProposal} - + {/if} @@ -373,32 +358,6 @@ default; } - .proposal-ghost-layer { - position: absolute; - inset: 0; - pointer-events: none; - z-index: 1; - } - - .proposal-ghost { - position: absolute; - box-sizing: border-box; - border: 1px dashed color-mix(in srgb, var(--ink-accent) 82%, white 18%); - background: color-mix(in srgb, var(--ink-accent) 13%, transparent); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--ink-accent) 18%, transparent) inset; - animation: proposal-pulse 1.8s ease-in-out infinite; - } - - @keyframes proposal-pulse { - 0%, - 100% { - opacity: 0.58; - } - 50% { - opacity: 0.95; - } - } - .canvas-text-editor { position: absolute; border: 1px solid var(--ink-accent); diff --git a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts index 9601240..9a3f438 100644 --- a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts @@ -266,7 +266,8 @@ export function createCanvasController( (boardId, doc) => { setActiveBoardId(boardId); applyLoadedDoc(doc); - } + }, + () => sink?.flush() ?? Promise.resolve() ); const fileBrowser = new FileBrowserController( () => repo, @@ -274,7 +275,8 @@ export function createCanvasController( setActiveBoardId(boardId); applyLoadedDoc(doc); }, - () => platformSession?.inspectBoard + () => platformSession?.inspectBoard, + () => sink?.flush() ?? Promise.resolve() ); const runtime = new EditorRuntime({ store, @@ -289,7 +291,7 @@ export function createCanvasController( store.executeCommand(new SnapshotCommand(name, kind, before, after)); syncHandleState(); }, - onOpenRequested: () => fileBrowser.handleOpen(), + onBrowseRequested: () => fileBrowser.handleOpen(), onHandleHover: setHandleHover, onInteractionChanged: syncHandleState, onSnappedWorldChanged: (world) => { @@ -566,9 +568,10 @@ export function createCanvasController( marqueeRect: () => marqueeRect, proposal: () => proposal, proposalMessage: () => proposalMessage, - acceptProposal: (operationPositions?: number[]) => { + acceptProposal: async (operationPositions?: number[]) => { if (!desktopRepo || !proposal) return Promise.reject(new Error('No proposal is open')); - return desktopRepo.acceptProposal(proposal.id, operationPositions); + const doc = await desktopRepo.acceptProposal(proposal.id, operationPositions); + applyLoadedDoc(doc); }, rejectProposal: () => { if (!desktopRepo || !proposal) return Promise.reject(new Error('No proposal is open')); diff --git a/packages/ui/src/lib/editor/canvas/controllers/desktop-file-controller.svelte.ts b/packages/ui/src/lib/editor/canvas/controllers/desktop-file-controller.svelte.ts index 918cdf0..6c5fee1 100644 --- a/packages/ui/src/lib/editor/canvas/controllers/desktop-file-controller.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/controllers/desktop-file-controller.svelte.ts @@ -12,7 +12,8 @@ export class DesktopFileController { constructor( private getRepo: () => PersistentDocRepo | null, private getDesktopRepo: () => DesktopDocumentRepo | null, - private onLoadDoc: (boardId: string, doc: LoadedDoc) => void + private onLoadDoc: (boardId: string, doc: LoadedDoc) => void, + private prepareToSwitch: () => Promise ) {} get repo(): DesktopDocumentRepo | null { @@ -52,7 +53,7 @@ export class DesktopFileController { return; } try { - const opened = await desktopRepo.openFromDialog(); + const opened = await desktopRepo.openFromDialog(this.prepareToSwitch); this.isDraft = false; this.onLoadDoc(opened.boardId, opened.doc); await this.refreshBoards(); @@ -70,6 +71,7 @@ export class DesktopFileController { return; } try { + await this.prepareToSwitch(); const boardId = await repo.createBoard('Untitled'); const loaded = await repo.loadDoc(boardId); this.isDraft = false; @@ -108,6 +110,7 @@ export class DesktopFileController { return; } try { + await this.prepareToSwitch(); const loaded = await repo.loadDoc(boardId); this.isDraft = desktopRepo?.isDraft() ?? false; this.onLoadDoc(boardId, loaded); diff --git a/packages/ui/src/lib/editor/canvas/controllers/filebrowser-controller.svelte.ts b/packages/ui/src/lib/editor/canvas/controllers/filebrowser-controller.svelte.ts index ec7918f..792712c 100644 --- a/packages/ui/src/lib/editor/canvas/controllers/filebrowser-controller.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/controllers/filebrowser-controller.svelte.ts @@ -13,7 +13,10 @@ export class FileBrowserController { constructor( private getRepo: () => PersistentDocRepo | null, private onLoadDoc?: (boardId: string, doc: LoadedDoc) => void, - private getInspector?: () => ((boardId: string) => Promise) | undefined + private getInspector?: () => + | ((boardId: string) => Promise) + | undefined, + private prepareToSwitch?: () => Promise ) {} handleOpen = () => { @@ -55,9 +58,11 @@ export class FileBrowserController { private createBrowserRepo(repo: PersistentDocRepo): PersistentDocRepo { const onLoadDoc = this.onLoadDoc; + const prepareToSwitch = this.prepareToSwitch; return { ...repo, async openBoard(boardId) { + await prepareToSwitch?.(); await repo.openBoard(boardId); const doc = await repo.loadDoc(boardId); onLoadDoc?.(boardId, doc); diff --git a/packages/ui/src/lib/editor/components/LayerPanel.svelte b/packages/ui/src/lib/editor/components/LayerPanel.svelte index 68d635e..74751e6 100644 --- a/packages/ui/src/lib/editor/components/LayerPanel.svelte +++ b/packages/ui/src/lib/editor/components/LayerPanel.svelte @@ -354,8 +354,8 @@ diff --git a/packages/ui/src/lib/editor/components/ProposalGhostLayer.svelte.test.ts b/packages/ui/src/lib/editor/components/ProposalGhostLayer.svelte.test.ts new file mode 100644 index 0000000..f82cd26 --- /dev/null +++ b/packages/ui/src/lib/editor/components/ProposalGhostLayer.svelte.test.ts @@ -0,0 +1,65 @@ +import { Camera } from '@inkfinite/core'; +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; + +import type { LiveProposal } from '../platform'; +import ProposalGhostLayer from './ProposalGhostLayer.svelte'; + +function proposalWith(operation: unknown): LiveProposal { + return { + id: 'proposal:ghost', + transaction: { operations: [operation] }, + preview: { created: ['shape:ghost'], changed: [], deleted: [] }, + affected_regions: [ + { page_id: 'page:one', bounds: { x: 10, y: 20, width: 30, height: 40 } } + ], + warnings: [], + expires_at: Date.now() + 60_000 + }; +} + +describe('ProposalGhostLayer', () => { + it('draws a proposed root-layer shape at its canvas transform', async () => { + const proposal = proposalWith({ + type: 'create_shape', + shape: { + id: 'shape:ghost', + kind: 'rect', + parent: { kind: 'layer', id: 'layer:one' }, + transform: { translation: { x: 10, y: 20 }, rotation: 0, scale_x: 1, scale_y: 1 }, + properties: { width: 30, height: 40, radius: 6, fill: '#88edc4' } + } + }); + const screen = render(ProposalGhostLayer, { + proposal, + camera: Camera.create(0, 0, 2), + viewport: { width: 800, height: 600 } + }); + + const ghost = screen.getByTestId('proposal-created-shape'); + await expect.element(ghost).toHaveAttribute('data-shape-id', 'shape:ghost'); + await expect.element(ghost).toHaveAttribute('transform', 'matrix(2 0 0 2 420 340)'); + expect(ghost.element().style.getPropertyValue('--proposal-fill')).toBe('#88edc4'); + await expect + .element(screen.getByTestId('proposal-created-shape-outline')) + .toHaveAttribute('width', '30'); + await expect + .element(screen.getByTestId('proposal-affected-region')) + .not.toBeInTheDocument(); + }); + + it('keeps an affected-region outline for operations it cannot materialize', async () => { + const screen = render(ProposalGhostLayer, { + proposal: proposalWith({ type: 'patch_shape', shape_id: 'shape:existing' }), + camera: Camera.create(0, 0, 2), + viewport: { width: 800, height: 600 } + }); + + const region = screen.getByTestId('proposal-affected-region'); + await expect.element(region).toHaveAttribute('x', '420'); + await expect.element(region).toHaveAttribute('y', '340'); + await expect.element(region).toHaveAttribute('width', '60'); + await expect.element(region).toHaveAttribute('height', '80'); + await expect.element(screen.getByTestId('proposal-created-shape')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/lib/editor/components/ProposalReview.svelte b/packages/ui/src/lib/editor/components/ProposalReview.svelte index 92d8b1c..9d795cb 100644 --- a/packages/ui/src/lib/editor/components/ProposalReview.svelte +++ b/packages/ui/src/lib/editor/components/ProposalReview.svelte @@ -96,7 +96,7 @@

- Ghost regions mark the document geometry this transaction would change. + Ghost shapes preview proposed creations. Outlined regions mark other affected geometry.

diff --git a/packages/ui/src/lib/editor/components/StatusBar.svelte b/packages/ui/src/lib/editor/components/StatusBar.svelte index d6c87f7..250cabb 100644 --- a/packages/ui/src/lib/editor/components/StatusBar.svelte +++ b/packages/ui/src/lib/editor/components/StatusBar.svelte @@ -211,8 +211,12 @@ {themeStore.current === 'dark' ? 'Light' : 'Dark'} - {#if platform === 'web' && onOpenBrowser} - diff --git a/packages/ui/src/lib/editor/components/StatusBar.svelte.test.ts b/packages/ui/src/lib/editor/components/StatusBar.svelte.test.ts index 03bdbea..e2e13f1 100644 --- a/packages/ui/src/lib/editor/components/StatusBar.svelte.test.ts +++ b/packages/ui/src/lib/editor/components/StatusBar.svelte.test.ts @@ -42,6 +42,7 @@ describe('StatusBar', () => { }); it('identifies a safely persisted desktop draft', async () => { + const onOpenBrowser = vi.fn(); const screen = render(StatusBar, { store: new Store(), cursor: new CursorStore(), @@ -52,10 +53,13 @@ describe('StatusBar', () => { }), snap: createSnapStore(), platform: 'desktop', - draft: true + draft: true, + onOpenBrowser }); await expect.element(screen.getByText('Draft saved')).toBeInTheDocument(); + await screen.getByRole('button', { name: 'Browse boards' }).click(); + expect(onOpenBrowser).toHaveBeenCalledOnce(); }); it('keeps editor utilities together and opens the info dialog', async () => { diff --git a/packages/ui/src/lib/editor/components/Toolbar.svelte b/packages/ui/src/lib/editor/components/Toolbar.svelte index b2db38a..4951d36 100644 --- a/packages/ui/src/lib/editor/components/Toolbar.svelte +++ b/packages/ui/src/lib/editor/components/Toolbar.svelte @@ -629,7 +629,7 @@