From bca338a9f2f207bd361c591602efa77a5078171e Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sat, 18 Jul 2026 02:17:45 -0500 Subject: [PATCH] feat: ordered layers with state --- ROADMAP.md | 12 + TODO.md | 71 +- .../src/lib/persistence/desktop-session.ts | 1372 +++++----- apps/web/src/lib/persistence/dexie.ts | 193 +- apps/web/src/lib/persistence/repository.ts | 902 ++++--- .../web/src/lib/tests/markdown-editor.test.ts | 860 ++++--- .../src/lib/tests/runtime.integration.test.ts | 174 +- crates/inkfinite-core/src/engine/diff.rs | 148 ++ crates/inkfinite-core/src/engine/error.rs | 43 + crates/inkfinite-core/src/engine/geometry.rs | 102 + crates/inkfinite-core/src/engine/hierarchy.rs | 360 +++ crates/inkfinite-core/src/engine/history.rs | 502 ++++ crates/inkfinite-core/src/engine/mod.rs | 2282 +---------------- .../inkfinite-core/src/engine/operations.rs | 679 +++++ crates/inkfinite-core/src/engine/policy.rs | 106 + crates/inkfinite-core/src/engine/query.rs | 135 + crates/inkfinite-core/src/engine/repair.rs | 235 ++ crates/inkfinite-core/src/engine/tests.rs | 57 +- .../inkfinite-core/src/engine/validation.rs | 226 ++ packages/core/src/geom.ts | 4 +- packages/core/src/index.ts | 35 +- packages/core/src/layers.ts | 115 + packages/core/src/model.ts | 806 +++--- packages/core/src/persistence/desktop.ts | 156 +- packages/core/src/persistence/document.ts | 105 +- packages/core/src/reactivity.ts | 585 +++-- packages/core/src/tools/select.ts | 1964 +++++++------- packages/core/tests/layers.test.ts | 87 + packages/renderer/src/index.ts | 22 +- packages/renderer/tests/index.test.ts | 79 + .../ui/src/lib/editor/canvas/Canvas.svelte | 2 + .../lib/editor/canvas/canvas-store.svelte.ts | 22 +- .../lib/editor/components/LayerPanel.svelte | 245 ++ .../components/LayerPanel.svelte.test.ts | 51 + 34 files changed, 7034 insertions(+), 5703 deletions(-) create mode 100644 crates/inkfinite-core/src/engine/diff.rs create mode 100644 crates/inkfinite-core/src/engine/error.rs create mode 100644 crates/inkfinite-core/src/engine/geometry.rs create mode 100644 crates/inkfinite-core/src/engine/hierarchy.rs create mode 100644 crates/inkfinite-core/src/engine/history.rs create mode 100644 crates/inkfinite-core/src/engine/operations.rs create mode 100644 crates/inkfinite-core/src/engine/policy.rs create mode 100644 crates/inkfinite-core/src/engine/query.rs create mode 100644 crates/inkfinite-core/src/engine/repair.rs create mode 100644 crates/inkfinite-core/src/engine/validation.rs create mode 100644 packages/core/src/layers.ts create mode 100644 packages/core/tests/layers.test.ts create mode 100644 packages/ui/src/lib/editor/components/LayerPanel.svelte create mode 100644 packages/ui/src/lib/editor/components/LayerPanel.svelte.test.ts diff --git a/ROADMAP.md b/ROADMAP.md index 5fb9c3f..41fcbbc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -186,6 +186,18 @@ this frame cost, so overlays remain on the single canvas. The complete machine, runtime, budget, and strategy record is in [`fixtures/v2/performance/v2-11.json`](fixtures/v2/performance/v2-11.json). +V2-12 completed on July 18, 2026. The shared editor now migrates flat pages to +one stable default layer without changing shape order, tracks the active layer, +and persists ordered layer records through the web and desktop adapters. Canvas +rendering follows layer and child order, isolates opacity with saved context +state, and skips hidden layers. Selection, hit testing, marquee, and editing +exclude hidden and locked content. The accessible Svelte panel creates, +selects, renames, reorders, hides, locks, changes opacity, and deletes layers; +non-empty deletion requires an explicit move or content-deletion choice. Rust +queries omit hidden shapes, locked layers reject changes while still allowing +an explicit unlock, and the existing Automerge two-replica tests continue to +cover ordered-list convergence. + V2-07 completed on July 17, 2026. `inkfinite-core::file` imports the frozen v1 desktop and web envelopes into normalized pages, default layers, scene containers, bindings, styles, and deterministic draw order. It writes compact diff --git a/TODO.md b/TODO.md index 001e4c7..10621a9 100644 --- a/TODO.md +++ b/TODO.md @@ -63,35 +63,8 @@ commands. ### V2-09: Extract the editor runtime and transaction drafts -What to build: Split the large Svelte canvas controller into a framework-neutral -editor runtime and a thin Svelte adapter, with ephemeral gesture previews and -durable transaction drafts. - -Blocked by: V2-06, V2-08 - -Acceptance criteria: - -- [x] Camera, tools, selection, input routing, and gesture previews have no - Svelte or persistence dependency. -- [x] Pointer movement stays local; each completed drag, resize, text edit, - stencil insertion, or shortcut produces one transaction draft. -- [x] The frontend document mirror changes only through snapshots or commit/sync - patches from Rust. -- [x] Svelte adapters compose controls from `@inkfinite/ui`; reusable visual - components do not gain `@inkfinite/runtime`, persistence, or Tauri - dependencies. -- [x] Existing keyboard, selection, text, Markdown, arrow, pen, grid snap, - stencil, and history behavior remains covered. -- [x] One browser integration test performs drag → Rust commit → patch → redraw - → undo and compares the original document. - -Verification: - -```sh -pnpm --filter @inkfinite/core test --run -pnpm --filter @inkfinite/web test -pnpm --filter @inkfinite/web check -``` +Split the large Svelte canvas controller into a framework-neutral editor runtime and a +thin Svelte adapter, with ephemeral gesture previews and durable transaction drafts. ## Milestone 4: Editor structure and scale @@ -100,30 +73,12 @@ budget passes with measured optimizations. ### V2-10: Fix cursor mapping across viewport changes -Implemented current-bound coordinate mapping, reactive viewport invalidation, -and pointer-capture cleanup across resize, scrolling, and device-pixel-ratio -changes. - -Blocked by: V2-09 - -Acceptance criteria: - -- [x] Coordinate conversion reads current canvas bounds and viewport dimensions - for each relevant event; no cached rect survives a layout change. -- [x] Cursor status, hit testing, marquee, handles, text/Markdown overlays, wheel - zoom, and drag previews agree after resize and scroll. -- [x] Pointer-up outside the canvas cannot leave a stuck drag or cursor state. -- [x] Browser tests reproduce the previous offset/stuck failure and pass at two - DPR values. - -Verification: - -- Run the focused browser input/canvas tests, then the full web test suite. +Implemented current-bound coordinate mapping, reactive viewport invalidation, and +pointer-capture cleanup across resize, scrolling, and device-pixel-ratio changes. ### V2-11: Meet the 10,000-shape rendering budget -What to build: Optimize measured rendering and hit testing while retaining a -simple Canvas 2D design. +Optimized measured rendering and hit testing while retaining a simple Canvas 2D design. Blocked by: V2-09, V2-10 @@ -154,24 +109,24 @@ opacity without regressing existing stencils. ### V2-12: Ship layers through model, renderer, interaction, and UI -What to build: Add ordered layers with visibility, locking, active-layer state, -opacity, and a complete Svelte panel. +Shipped ordered layers with visibility, locking, active-layer state, opacity, +and a complete Svelte panel. Blocked by: V2-07, V2-09, V2-11 Acceptance criteria: -- [ ] New and imported pages always have a default layer; migration preserves +- [x] New and imported pages always have a default layer; migration preserves exact shape order and is idempotent. -- [ ] Rendering follows page layer order and child order, skips hidden layers, +- [x] Rendering follows page layer order and child order, skips hidden layers, and composites layer opacity without leaking canvas state. -- [ ] Hit testing, marquee, selection UI, editing, and agent transactions ignore +- [x] Hit testing, marquee, selection UI, editing, and agent transactions ignore hidden shapes and reject locked-layer changes. -- [ ] New shapes use the active layer. Moving and reordering layers or shapes is +- [x] New shapes use the active layer. Moving and reordering layers or shapes is one undoable transaction and converges under concurrent edits. -- [ ] The panel lists, selects, creates, renames, reorders, hides, locks, deletes, +- [x] The panel lists, selects, creates, renames, reorders, hides, locks, deletes, and changes opacity with accessible controls. -- [ ] Deleting a non-empty layer requires an explicit move destination or an +- [x] Deleting a non-empty layer requires an explicit move destination or an explicit content deletion; the last layer cannot disappear. Verification: diff --git a/apps/desktop/src/lib/persistence/desktop-session.ts b/apps/desktop/src/lib/persistence/desktop-session.ts index af1e03a..345d5fe 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.ts @@ -1,762 +1,776 @@ -import { invoke } from "@tauri-apps/api/core"; +import { invoke } from '@tauri-apps/api/core'; import type { - BoardExport, - BoardMeta, - DesktopFileOps, - DocPatch, - FileHandle, - LoadedDoc, - PageRecord as LegacyPageRecord, - BindingRecord as LegacyBindingRecord, - PersistenceSink, - PersistentDocRepo, - ShapeRecord as LegacyShapeRecord, -} from "@inkfinite/core"; -import { createId } from "@inkfinite/core"; + BoardExport, + BoardMeta, + DesktopFileOps, + DocPatch, + FileHandle, + LoadedDoc, + LayerRecord as LegacyLayerRecord, + PageRecord as LegacyPageRecord, + BindingRecord as LegacyBindingRecord, + PersistenceSink, + PersistentDocRepo, + ShapeRecord as LegacyShapeRecord +} from '@inkfinite/core'; +import { createId } from '@inkfinite/core'; import type { - BindingRecord as V2BindingRecord, - ChangeHash, - CommitResult, - ContainerLayout, - DocumentSnapshot, - Query, - QueryResult, - Provenance, - ShapeProperties, - ShapeRecord, - ShapeStyle, - TransactionDraft, - Transform, - JsonValue, -} from "@inkfinite/bindings"; - -/** Serialized status returned by every Rust-owned desktop session command. */ + BindingRecord as V2BindingRecord, + ChangeHash, + CommitResult, + ContainerLayout, + DocumentSnapshot, + Query, + QueryResult, + Provenance, + ShapeProperties, + ShapeRecord, + ShapeStyle, + TransactionDraft, + Transform, + JsonValue +} from '@inkfinite/bindings'; + +const ACTOR_ID = 'actor:desktop'; + +/** Serialized status returned by every desktop session command. */ export type SessionStatus = { - session_id: string; - path: string; - actor_id: string; - snapshot: DocumentSnapshot; - dirty: boolean; - lock_held: boolean; - recovery_available: boolean; - can_undo: boolean; - can_redo: boolean; - sync: { status: "disabled" }; + session_id: string; + path: string; + actor_id: string; + snapshot: DocumentSnapshot; + dirty: boolean; + lock_held: boolean; + recovery_available: boolean; + can_undo: boolean; + can_redo: boolean; + sync: { status: 'disabled' }; }; -/** Result returned after Rust creates or opens a desktop session. */ +/** Result returned after creating or opening a desktop session. */ export type SessionOpened = { session_id: string; status: SessionStatus }; -/** Result returned after Rust commits, undoes, or redoes a transaction. */ + +/** Result returned after committing, undoing, or redoing a transaction. */ export type SessionCommit = { commit: CommitResult; status: SessionStatus }; -/** Result returned after Rust persists a session. */ + +/** Result returned after persisting a session. */ export type SessionSaved = { save: { path: string; heads: ChangeHash[] }; status: SessionStatus }; /** Typed command boundary used by the desktop adapter and its tests. */ -export type SessionApi = { - createDocument(args: { - path: string; - document_id: string; - actor_id: string; - page_name?: string; - }): Promise; - openDocument(args: { path: string; actor_id: string }): Promise; - snapshot(args: { session_id: string }): Promise; - commit(args: { session_id: string; transaction: TransactionDraft }): Promise; - undo(args: { session_id: string; actor_id: string }): Promise; - redo(args: { session_id: string; actor_id: string }): Promise; - save(args: { session_id: string; expected_heads: ChangeHash[] }): Promise; - saveAs(args: { session_id: string; path: string; expected_heads: ChangeHash[] }): Promise; - query(args: { session_id: string; query: Query }): Promise; - validate(args: { session_id: string }): Promise; - close(args: { session_id: string }): Promise; -}; - -const actorId = "actor:desktop"; +export interface SessionApi { + createDocument(args: { + path: string; + document_id: string; + actor_id: string; + page_name?: string; + }): Promise; + openDocument(args: { path: string; actor_id: string }): Promise; + snapshot(args: { session_id: string }): Promise; + commit(args: { session_id: string; transaction: TransactionDraft }): Promise; + undo(args: { session_id: string; actor_id: string }): Promise; + redo(args: { session_id: string; actor_id: string }): Promise; + save(args: { session_id: string; expected_heads: ChangeHash[] }): Promise; + saveAs(args: { session_id: string; path: string; expected_heads: ChangeHash[] }): Promise; + query(args: { session_id: string; query: Query }): Promise; + validate(args: { session_id: string }): Promise; + close(args: { session_id: string }): Promise; +} function createSessionApi(): SessionApi { - return { - createDocument: (args) => invoke("create_document", args), - openDocument: (args) => invoke("open_document", args), - snapshot: (args) => invoke("snapshot", args), - commit: (args) => invoke("commit", args), - undo: (args) => invoke("undo", args), - redo: (args) => invoke("redo", args), - save: (args) => invoke("save", args), - saveAs: (args) => invoke("save_as", args), - query: (args) => invoke("query", args), - validate: (args) => invoke("validate", args), - close: (args) => invoke("close", args), - }; + return { + createDocument: (args) => invoke('create_document', args), + openDocument: (args) => invoke('open_document', args), + snapshot: (args) => invoke('snapshot', args), + commit: (args) => invoke('commit', args), + undo: (args) => invoke('undo', args), + redo: (args) => invoke('redo', args), + save: (args) => invoke('save', args), + saveAs: (args) => invoke('save_as', args), + query: (args) => invoke('query', args), + validate: (args) => invoke('validate', args), + close: (args) => invoke('close', args) + }; } -/** Persistent document repository backed by one Rust-owned Tauri session. */ +/** Persistent document repository backed by one backend/tauri-owned session. */ export type DesktopSessionRepo = PersistentDocRepo & { - kind: "desktop"; - getCurrentFile(): FileHandle | null; - openFromDialog(): Promise<{ boardId: string; doc: LoadedDoc }>; - getWorkspaceDir(): Promise; - setWorkspaceDir(path: string | null): Promise; - pickWorkspaceDir(): Promise; - undo(): Promise; - redo(): Promise; - query(query: Query): Promise; - validate(): Promise; - getSessionStatus(): SessionStatus | null; - closeSession(): Promise; + kind: 'desktop'; + getCurrentFile(): FileHandle | null; + openFromDialog(): Promise<{ boardId: string; doc: LoadedDoc }>; + getWorkspaceDir(): Promise; + setWorkspaceDir(path: string | null): Promise; + pickWorkspaceDir(): Promise; + undo(): Promise; + redo(): Promise; + query(query: Query): Promise; + validate(): Promise; + getSessionStatus(): SessionStatus | null; + closeSession(): Promise; }; /** * Creates the desktop repository adapter. Document bytes cross the Tauri * command boundary only; this adapter keeps the renderer's legacy mirror in - * memory until Rust returns a committed snapshot. + * memory until the backend returns a committed snapshot. */ -export function createDesktopSessionRepo( - fileOps: DesktopFileOps, - options: { api?: SessionApi } = {}, -): DesktopSessionRepo { - const api = options.api ?? createSessionApi(); - let currentFile: FileHandle | null = null; - let currentBoard: BoardMeta | null = null; - let currentDoc: LoadedDoc | null = null; - let currentStatus: SessionStatus | null = null; - const boardFiles = new Map(); - - function setCurrentState(status: SessionStatus, boardName?: string) { - currentStatus = status; - currentFile = { - path: status.path, - name: fileName(status.path), - }; - currentBoard = { - id: status.snapshot.document_id, - name: boardName || fileStem(status.path), - createdAt: currentBoard?.createdAt ?? Date.now(), - updatedAt: Date.now(), - }; - currentDoc = loadedDocFromSnapshot(status.snapshot); - boardFiles.set(currentBoard.id, currentFile); - boardFiles.set(boardIdForPath(status.path), currentFile); - } - - function updateStatus(status: SessionStatus) { - const previousPath = currentFile?.path; - currentStatus = status; - currentFile = { path: status.path, name: fileName(status.path) }; - currentDoc = loadedDocFromSnapshot(status.snapshot); - if (currentBoard) { - currentBoard = { ...currentBoard, updatedAt: Date.now() }; - boardFiles.set(currentBoard.id, currentFile); - } - if (previousPath && previousPath !== status.path) boardFiles.delete(boardIdForPath(previousPath)); - boardFiles.set(boardIdForPath(status.path), currentFile); - } - - async function closeCurrentSession() { - if (!currentStatus) return; - await api.close({ session_id: currentStatus.session_id }); - currentStatus = null; - currentFile = null; - currentBoard = null; - currentDoc = null; - } - - async function saveCurrentSession() { - if (!currentStatus) return; - const saved = await api.save({ - session_id: currentStatus.session_id, - expected_heads: currentStatus.snapshot.heads, - }); - updateStatus(saved.status); - } - - async function openPath(path: string, boardName?: string): Promise { - if (currentStatus && currentStatus.path !== path) { - if (currentStatus.dirty) await saveCurrentSession(); - await closeCurrentSession(); - } - if (currentStatus?.path === path && currentDoc) return currentDoc; - - const opened = await api.openDocument({ path, actor_id: actorId }); - setCurrentState(opened.status, boardName); - const handle = currentFile; - if (handle) await fileOps.addRecentFile(handle); - return currentDoc!; - } - - async function listBoards(): Promise { - const workspace = await fileOps.getWorkspaceDir(); - const handles: FileHandle[] = []; - if (workspace) { - const entries = await listDocumentEntries(fileOps, workspace); - for (const entry of entries) { - if (!entry.isDir) handles.push({ path: entry.path, name: entry.name }); - } - } else { - handles.push(...(await fileOps.getRecentFiles())); - } - - const boards = handles.map((handle) => { - const id = boardIdForPath(handle.path); - boardFiles.set(id, handle); - return { - id, - name: fileStem(handle.name), - createdAt: 0, - updatedAt: 0, - } satisfies BoardMeta; - }); - if (currentBoard) { - const currentIndex = boards.findIndex((board) => boardFiles.get(board.id)?.path === currentFile?.path); - if (currentIndex >= 0) { - boards[currentIndex] = currentBoard; - } else if (!boards.some((board) => board.id === currentBoard?.id)) { - boards.unshift(currentBoard); - } - } - return boards; - } - - async function createBoard(name: string): Promise { - const boardName = name.trim() || "Untitled Board"; - const workspace = await fileOps.getWorkspaceDir(); - const path = workspace - ? joinPath(workspace, `${safeFileStem(boardName)}.inkfinite`) - : await fileOps.showSaveDialog(`${safeFileStem(boardName)}.inkfinite`); - if (!path) throw new Error("Save cancelled"); - - if (currentStatus) { - if (currentStatus.dirty) await saveCurrentSession(); - await closeCurrentSession(); - } - const opened = await api.createDocument({ - path, - document_id: createId("board"), - actor_id: actorId, - page_name: "Page 1", - }); - setCurrentState(opened.status, boardName); - if (!workspace && currentFile) await fileOps.addRecentFile(currentFile); - return opened.status.snapshot.document_id; - } - - async function renameBoard(boardId: string, name: string): Promise { - await ensureBoardLoaded(boardId); - if (!currentStatus || !currentFile || !currentBoard) throw new Error("No board loaded"); - const nextName = name.trim() || "Untitled Board"; - const nextPath = joinPath( - parentPath(currentFile.path), - `${safeFileStem(nextName)}${canonicalExtension(currentFile.path)}`, - ); - const saved = await api.saveAs({ - session_id: currentStatus.session_id, - path: nextPath, - expected_heads: currentStatus.snapshot.heads, - }); - const oldPath = currentFile.path; - updateStatus(saved.status); - currentBoard = { ...currentBoard, name: nextName, updatedAt: Date.now() }; - if (nextPath !== oldPath) { - await fileOps.deleteFile(oldPath); - } - if (currentFile) await fileOps.addRecentFile(currentFile); - } - - async function deleteBoard(boardId: string): Promise { - const handle = boardFiles.get(boardId); - if (!handle) return; - const workspace = await fileOps.getWorkspaceDir(); - if (currentStatus && currentFile?.path === handle.path) await closeCurrentSession(); - if (workspace) { - await fileOps.deleteFile(handle.path); - } else { - await fileOps.removeRecentFile(handle.path); - } - boardFiles.delete(boardId); - } - - async function loadDoc(boardId: string): Promise { - await ensureBoardLoaded(boardId); - if (!currentDoc) throw new Error("No board loaded"); - return currentDoc; - } - - async function openBoard(boardId: string): Promise { - await ensureBoardLoaded(boardId); - } - - async function applyDocPatch(boardId: string, patch: DocPatch): Promise { - await ensureBoardLoaded(boardId); - if (!currentStatus || !currentDoc) throw new Error("No board loaded"); - const nextDoc = applyPatch(currentDoc, patch); - const target = documentFromLoadedDoc(nextDoc, currentStatus.snapshot, actorId); - const operations = operationsForMirror(currentStatus.snapshot, target); - if (operations.length === 0) return; - - const transaction: TransactionDraft = { - id: createId("transaction"), - actor_id: actorId, - origin: "human", - base_heads: currentStatus.snapshot.heads, - description: "Update desktop document mirror", - operations, - timestamp: Date.now(), - }; - const committed = await api.commit({ session_id: currentStatus.session_id, transaction }); - updateStatus(committed.status); - // Desktop edits are persisted by the Rust service before the input event - // queue advances, so reopening cannot lose a completed gesture. - await saveCurrentSession(); - } - - async function exportBoard(boardId: string): Promise { - const doc = await loadDoc(boardId); - if (!currentBoard) throw new Error("No board loaded"); - return { - board: currentBoard, - doc: { pages: doc.pages, shapes: doc.shapes, bindings: doc.bindings }, - order: doc.order, - }; - } - - async function importBoard(snapshot: BoardExport): Promise { - if (!currentStatus || !currentBoard) throw new Error("No board loaded"); - const path = await fileOps.showSaveDialog(`${safeFileStem(snapshot.board.name)}.inkfinite`); - if (!path) throw new Error("Save cancelled"); - const saved = await api.saveAs({ - session_id: currentStatus.session_id, - path, - expected_heads: currentStatus.snapshot.heads, - }); - updateStatus(saved.status); - currentBoard = { ...currentBoard, name: snapshot.board.name, updatedAt: Date.now() }; - if (currentFile) await fileOps.addRecentFile(currentFile); - return currentBoard.id; - } - - async function openFromDialog(): Promise<{ boardId: string; doc: LoadedDoc }> { - const path = await fileOps.showOpenDialog(); - if (!path) throw new Error("Open cancelled"); - const doc = await openPath(path); - if (!currentBoard) throw new Error("Failed to open document"); - return { boardId: currentBoard.id, doc }; - } - - async function ensureBoardLoaded(boardId: string): Promise { - if (currentBoard?.id === boardId && currentDoc) return; - const handle = boardFiles.get(boardId); - if (!handle) throw new Error(`Unknown board: ${boardId}`); - await openPath(handle.path, fileStem(handle.name)); - } - - async function undo(): Promise { - if (!currentStatus) return; - const result = await api.undo({ session_id: currentStatus.session_id, actor_id: actorId }); - updateStatus(result.status); - await saveCurrentSession(); - } - - async function redo(): Promise { - if (!currentStatus) return; - const result = await api.redo({ session_id: currentStatus.session_id, actor_id: actorId }); - updateStatus(result.status); - await saveCurrentSession(); - } - - async function query(queryValue: Query): Promise { - if (!currentStatus) throw new Error("No board loaded"); - return api.query({ session_id: currentStatus.session_id, query: queryValue }); - } - - async function validate(): Promise { - if (!currentStatus) throw new Error("No board loaded"); - const status = await api.validate({ session_id: currentStatus.session_id }); - updateStatus(status); - return status; - } - - return { - kind: "desktop", - listBoards, - createBoard, - openBoard, - renameBoard, - deleteBoard, - loadDoc, - applyDocPatch, - exportBoard, - importBoard, - getCurrentFile: () => currentFile, - openFromDialog, - getWorkspaceDir: () => fileOps.getWorkspaceDir(), - setWorkspaceDir: (path: string | null) => fileOps.setWorkspaceDir(path), - pickWorkspaceDir: () => fileOps.pickWorkspaceDir(), - undo, - redo, - query, - validate, - getSessionStatus: () => currentStatus, - closeSession: closeCurrentSession, - }; +export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: SessionApi } = {}): DesktopSessionRepo { + const api = opts.api ?? createSessionApi(); + let currentFile: FileHandle | null = null; + let currentBoard: BoardMeta | null = null; + let currentDoc: LoadedDoc | null = null; + let currentStatus: SessionStatus | null = null; + const boardFiles = new Map(); + + function setCurrentState(status: SessionStatus, boardName?: string) { + currentStatus = status; + currentFile = { path: status.path, name: fileName(status.path) }; + currentBoard = { + id: status.snapshot.document_id, + name: boardName || fileStem(status.path), + createdAt: currentBoard?.createdAt ?? Date.now(), + updatedAt: Date.now() + }; + currentDoc = loadedDocFromSnapshot(status.snapshot); + boardFiles.set(currentBoard.id, currentFile); + boardFiles.set(boardIdForPath(status.path), currentFile); + } + + function updateStatus(status: SessionStatus) { + const previousPath = currentFile?.path; + currentStatus = status; + currentFile = { path: status.path, name: fileName(status.path) }; + currentDoc = loadedDocFromSnapshot(status.snapshot); + if (currentBoard) { + currentBoard = { ...currentBoard, updatedAt: Date.now() }; + boardFiles.set(currentBoard.id, currentFile); + } + if (previousPath && previousPath !== status.path) boardFiles.delete(boardIdForPath(previousPath)); + boardFiles.set(boardIdForPath(status.path), currentFile); + } + + async function closeCurrentSession() { + if (!currentStatus) return; + await api.close({ session_id: currentStatus.session_id }); + currentStatus = null; + currentFile = null; + currentBoard = null; + currentDoc = null; + } + + async function saveCurrentSession() { + if (!currentStatus) return; + const saved = await api.save({ + session_id: currentStatus.session_id, + expected_heads: currentStatus.snapshot.heads + }); + updateStatus(saved.status); + } + + async function openPath(path: string, boardName?: string): Promise { + if (currentStatus && currentStatus.path !== path) { + if (currentStatus.dirty) await saveCurrentSession(); + await closeCurrentSession(); + } + if (currentStatus?.path === path && currentDoc) return currentDoc; + + const opened = await api.openDocument({ path, actor_id: ACTOR_ID }); + setCurrentState(opened.status, boardName); + const handle = currentFile; + if (handle) await fileOps.addRecentFile(handle); + return currentDoc!; + } + + async function listBoards(): Promise { + const workspace = await fileOps.getWorkspaceDir(); + const handles: FileHandle[] = []; + if (workspace) { + const entries = await listDocumentEntries(fileOps, workspace); + for (const entry of entries) { + if (!entry.isDir) handles.push({ path: entry.path, name: entry.name }); + } + } else { + handles.push(...(await fileOps.getRecentFiles())); + } + + const boards = handles.map((handle) => { + const id = boardIdForPath(handle.path); + boardFiles.set(id, handle); + return { id, name: fileStem(handle.name), createdAt: 0, updatedAt: 0 } satisfies BoardMeta; + }); + if (currentBoard) { + const currentIndex = boards.findIndex((board) => boardFiles.get(board.id)?.path === currentFile?.path); + if (currentIndex >= 0) { + boards[currentIndex] = currentBoard; + } else if (!boards.some((board) => board.id === currentBoard?.id)) { + boards.unshift(currentBoard); + } + } + return boards; + } + + async function createBoard(name: string): Promise { + const boardName = name.trim() || 'Untitled Board'; + const workspace = await fileOps.getWorkspaceDir(); + const path = workspace + ? joinPath(workspace, `${safeFileStem(boardName)}.inkfinite`) + : await fileOps.showSaveDialog(`${safeFileStem(boardName)}.inkfinite`); + if (!path) throw new Error('Save cancelled'); + + if (currentStatus) { + if (currentStatus.dirty) await saveCurrentSession(); + await closeCurrentSession(); + } + const opened = await api.createDocument({ + path, + document_id: createId('board'), + actor_id: ACTOR_ID, + page_name: 'Page 1' + }); + setCurrentState(opened.status, boardName); + if (!workspace && currentFile) await fileOps.addRecentFile(currentFile); + return opened.status.snapshot.document_id; + } + + async function renameBoard(boardId: string, name: string): Promise { + await ensureBoardLoaded(boardId); + if (!currentStatus || !currentFile || !currentBoard) throw new Error('No board loaded'); + const nextName = name.trim() || 'Untitled Board'; + const nextPath = joinPath( + parentPath(currentFile.path), + `${safeFileStem(nextName)}${canonicalExtension(currentFile.path)}` + ); + const saved = await api.saveAs({ + session_id: currentStatus.session_id, + path: nextPath, + expected_heads: currentStatus.snapshot.heads + }); + const oldPath = currentFile.path; + updateStatus(saved.status); + currentBoard = { ...currentBoard, name: nextName, updatedAt: Date.now() }; + if (nextPath !== oldPath) { + await fileOps.deleteFile(oldPath); + } + if (currentFile) await fileOps.addRecentFile(currentFile); + } + + async function deleteBoard(boardId: string): Promise { + const handle = boardFiles.get(boardId); + if (!handle) return; + const workspace = await fileOps.getWorkspaceDir(); + if (currentStatus && currentFile?.path === handle.path) await closeCurrentSession(); + if (workspace) { + await fileOps.deleteFile(handle.path); + } else { + await fileOps.removeRecentFile(handle.path); + } + boardFiles.delete(boardId); + } + + async function loadDoc(boardId: string): Promise { + await ensureBoardLoaded(boardId); + if (!currentDoc) throw new Error('No board loaded'); + return currentDoc; + } + + async function openBoard(boardId: string): Promise { + await ensureBoardLoaded(boardId); + } + + async function applyDocPatch(boardId: string, patch: DocPatch): Promise { + await ensureBoardLoaded(boardId); + if (!currentStatus || !currentDoc) throw new Error('No board loaded'); + const nextDoc = applyPatch(currentDoc, patch); + const target = documentFromLoadedDoc(nextDoc, currentStatus.snapshot, ACTOR_ID); + const operations = operationsForMirror(currentStatus.snapshot, target); + if (operations.length === 0) return; + + const transaction: TransactionDraft = { + id: createId('transaction'), + actor_id: ACTOR_ID, + origin: 'human', + base_heads: currentStatus.snapshot.heads, + description: 'Update desktop document mirror', + operations, + timestamp: Date.now() + }; + const committed = await api.commit({ session_id: currentStatus.session_id, transaction }); + updateStatus(committed.status); + // Desktop edits are persisted by the backend service before the input event + // queue advances, so reopening cannot lose a completed gesture. + await saveCurrentSession(); + } + + async function exportBoard(boardId: string): Promise { + const doc = await loadDoc(boardId); + if (!currentBoard) throw new Error('No board loaded'); + return { + board: currentBoard, + doc: { pages: doc.pages, shapes: doc.shapes, bindings: doc.bindings }, + order: doc.order + }; + } + + async function importBoard(snapshot: BoardExport): Promise { + if (!currentStatus || !currentBoard) throw new Error('No board loaded'); + const path = await fileOps.showSaveDialog(`${safeFileStem(snapshot.board.name)}.inkfinite`); + if (!path) throw new Error('Save cancelled'); + const saved = await api.saveAs({ + session_id: currentStatus.session_id, + path, + expected_heads: currentStatus.snapshot.heads + }); + updateStatus(saved.status); + currentBoard = { ...currentBoard, name: snapshot.board.name, updatedAt: Date.now() }; + if (currentFile) await fileOps.addRecentFile(currentFile); + return currentBoard.id; + } + + async function openFromDialog(): Promise<{ boardId: string; doc: LoadedDoc }> { + const path = await fileOps.showOpenDialog(); + if (!path) throw new Error('Open cancelled'); + const doc = await openPath(path); + if (!currentBoard) throw new Error('Failed to open document'); + return { boardId: currentBoard.id, doc }; + } + + async function ensureBoardLoaded(boardId: string): Promise { + if (currentBoard?.id === boardId && currentDoc) return; + const handle = boardFiles.get(boardId); + if (!handle) throw new Error(`Unknown board: ${boardId}`); + await openPath(handle.path, fileStem(handle.name)); + } + + async function undo(): Promise { + if (!currentStatus) return; + const result = await api.undo({ session_id: currentStatus.session_id, actor_id: ACTOR_ID }); + updateStatus(result.status); + await saveCurrentSession(); + } + + async function redo(): Promise { + if (!currentStatus) return; + const result = await api.redo({ session_id: currentStatus.session_id, actor_id: ACTOR_ID }); + updateStatus(result.status); + await saveCurrentSession(); + } + + async function query(queryValue: Query): Promise { + if (!currentStatus) throw new Error('No board loaded'); + return api.query({ session_id: currentStatus.session_id, query: queryValue }); + } + + async function validate(): Promise { + if (!currentStatus) throw new Error('No board loaded'); + const status = await api.validate({ session_id: currentStatus.session_id }); + updateStatus(status); + return status; + } + + return { + kind: 'desktop', + listBoards, + createBoard, + openBoard, + renameBoard, + deleteBoard, + loadDoc, + applyDocPatch, + exportBoard, + importBoard, + getCurrentFile: () => currentFile, + openFromDialog, + getWorkspaceDir: () => fileOps.getWorkspaceDir(), + setWorkspaceDir: (path: string | null) => fileOps.setWorkspaceDir(path), + pickWorkspaceDir: () => fileOps.pickWorkspaceDir(), + undo, + redo, + query, + validate, + getSessionStatus: () => currentStatus, + closeSession: closeCurrentSession + }; } /** Creates a serialized desktop persistence queue for editor history events. */ export function createDesktopPersistenceSink(repo: DesktopSessionRepo): PersistenceSink { - let queue = Promise.resolve(); - let lastError: unknown = null; - return { - enqueueDocPatch(boardId, patch) { - queue = queue - .catch(() => undefined) - .then(() => repo.applyDocPatch(boardId, patch)) - .catch((error) => { - lastError = error; - }); - }, - async flush() { - await queue; - if (lastError) { - const error = lastError; - lastError = null; - throw error; - } - }, - }; + let queue = Promise.resolve(); + let lastError: unknown = null; + return { + enqueueDocPatch(boardId, patch) { + queue = queue + .catch(() => undefined) + .then(() => repo.applyDocPatch(boardId, patch)) + .catch((error) => { + lastError = error; + }); + }, + async flush() { + await queue; + if (lastError) { + const error = lastError; + lastError = null; + throw error; + } + } + }; } -/** Narrows the shared repository contract to the Rust-backed desktop adapter. */ +/** Narrows the shared repository contract to the desktop adapter. */ export function isDesktopSessionRepo(repo: PersistentDocRepo): repo is DesktopSessionRepo { - return (repo as DesktopSessionRepo).kind === "desktop"; + return (repo as DesktopSessionRepo).kind === 'desktop'; } async function listDocumentEntries(fileOps: DesktopFileOps, directory: string) { - const [canonical, legacy] = await Promise.all([ - fileOps.readDirectory(directory, "*.inkfinite"), - fileOps.readDirectory(directory, "*.inkfinite.json"), - ]); - const canonicalPaths = new Set(canonical.map((entry) => entry.path)); - const seen = new Set(); - return [...canonical, ...legacy].filter((entry) => { - if (entry.path.endsWith(".inkfinite.json") && canonicalPaths.has(entry.path.slice(0, -5))) { - return false; - } - if (seen.has(entry.path)) return false; - seen.add(entry.path); - return true; - }); + const [canonical, legacy] = await Promise.all([ + fileOps.readDirectory(directory, '*.inkfinite'), + fileOps.readDirectory(directory, '*.inkfinite.json') + ]); + const canonicalPaths = new Set(canonical.map((entry) => entry.path)); + const seen = new Set(); + return [...canonical, ...legacy].filter((entry) => { + if (entry.path.endsWith('.inkfinite.json') && canonicalPaths.has(entry.path.slice(0, -5))) { + return false; + } + if (seen.has(entry.path)) return false; + seen.add(entry.path); + return true; + }); } function loadedDocFromSnapshot(snapshot: DocumentSnapshot): LoadedDoc { - const pages: Record = {}; - const shapes: Record = {}; - const bindings: Record = {}; - const shapeOrder: Record = {}; - - for (const pageId of snapshot.document.page_ids) { - const page = snapshot.document.pages[pageId]; - if (!page) continue; - const flattened: string[] = []; - for (const layerId of page.layer_ids) { - const layer = snapshot.document.layers[layerId]; - if (!layer || !layer.visible) continue; - for (const shapeId of layer.shape_ids) { - flattenShape(snapshot, page.id, shapeId, undefined, flattened, shapes); - } - } - pages[page.id] = { id: page.id, name: page.name, shapeIds: flattened }; - shapeOrder[page.id] = [...flattened]; - } - - for (const binding of Object.values(snapshot.document.bindings)) { - bindings[binding.id] = { - id: binding.id, - type: binding.kind as "arrow-end", - fromShapeId: binding.source_shape_id, - toShapeId: binding.target_shape_id, - handle: binding.source_handle as "start" | "end", - anchor: binding.anchor.kind === "center" - ? { kind: "center" } - : { kind: "edge", nx: binding.anchor.x, ny: binding.anchor.y }, - }; - } - - return { - pages, - shapes, - bindings, - order: { pageIds: [...snapshot.document.page_ids], shapeOrder }, - }; + const pages: Record = {}; + const layers: Record = {}; + const shapes: Record = {}; + const bindings: Record = {}; + const shapeOrder: Record = {}; + + for (const pageId of snapshot.document.page_ids) { + const page = snapshot.document.pages[pageId]; + if (!page) continue; + const flattened: string[] = []; + for (const layerId of page.layer_ids) { + const layer = snapshot.document.layers[layerId]; + if (!layer) continue; + const layerShapeIds: string[] = []; + for (const shapeId of layer.shape_ids) { + flattenShape(snapshot, page.id, layer.id, shapeId, undefined, layerShapeIds, shapes); + } + flattened.push(...layerShapeIds); + layers[layer.id] = { + id: layer.id, + pageId: page.id, + name: layer.name, + shapeIds: layerShapeIds, + visible: layer.visible, + locked: layer.locked, + opacity: layer.opacity + }; + } + pages[page.id] = { id: page.id, name: page.name, shapeIds: flattened, layerIds: [...page.layer_ids] }; + shapeOrder[page.id] = [...flattened]; + } + + for (const binding of Object.values(snapshot.document.bindings)) { + bindings[binding.id] = { + id: binding.id, + type: binding.kind as 'arrow-end', + fromShapeId: binding.source_shape_id, + toShapeId: binding.target_shape_id, + handle: binding.source_handle as 'start' | 'end', + anchor: + binding.anchor.kind === 'center' + ? { kind: 'center' } + : { kind: 'edge', nx: binding.anchor.x, ny: binding.anchor.y } + }; + } + + return { pages, layers, shapes, bindings, order: { pageIds: [...snapshot.document.page_ids], shapeOrder, layers } }; } function flattenShape( - snapshot: DocumentSnapshot, - pageId: string, - shapeId: string, - groupId: string | undefined, - flattened: string[], - shapes: Record, + snapshot: DocumentSnapshot, + pageId: string, + layerId: string, + shapeId: string, + groupId: string | undefined, + flattened: string[], + shapes: Record ) { - const shape = snapshot.document.shapes[shapeId]; - if (!shape) return; - if (shape.kind !== "container") { - flattened.push(shape.id); - shapes[shape.id] = legacyShapeFromV2(shape, pageId, groupId); - } - for (const childId of shape.child_ids) { - flattenShape(snapshot, pageId, childId, shape.kind === "container" ? shape.id : groupId, flattened, shapes); - } + const shape = snapshot.document.shapes[shapeId]; + if (!shape) return; + if (shape.kind !== 'container') { + flattened.push(shape.id); + shapes[shape.id] = { ...legacyShapeFromV2(shape, pageId, groupId), layerId }; + } + for (const childId of shape.child_ids) { + flattenShape( + snapshot, + pageId, + layerId, + childId, + shape.kind === 'container' ? shape.id : groupId, + flattened, + shapes + ); + } } function legacyShapeFromV2(shape: ShapeRecord, pageId: string, groupId?: string): LegacyShapeRecord { - const properties = { ...(shape.properties as Record) }; - if ("width" in properties) { - properties.w = properties.width; - delete properties.width; - } - if ("height" in properties) { - properties.h = properties.height; - delete properties.height; - } - return { - id: shape.id, - type: shape.kind as LegacyShapeRecord["type"], - pageId, - x: shape.transform.translation.x, - y: shape.transform.translation.y, - rot: shape.transform.rotation, - ...(groupId ? { groupId } : {}), - props: properties as LegacyShapeRecord["props"], - } as LegacyShapeRecord; + const properties = { ...(shape.properties as Record) }; + if ('width' in properties) { + properties.w = properties.width; + delete properties.width; + } + if ('height' in properties) { + properties.h = properties.height; + delete properties.height; + } + return { + id: shape.id, + type: shape.kind as LegacyShapeRecord['type'], + pageId, + x: shape.transform.translation.x, + y: shape.transform.translation.y, + rot: shape.transform.rotation, + ...(groupId ? { groupId } : {}), + props: properties as LegacyShapeRecord['props'] + } as LegacyShapeRecord; } function applyPatch(doc: LoadedDoc, patch: DocPatch): LoadedDoc { - const next = structuredClone(doc); - for (const id of patch.deletes?.pageIds ?? []) delete next.pages[id]; - for (const id of patch.deletes?.shapeIds ?? []) delete next.shapes[id]; - for (const id of patch.deletes?.bindingIds ?? []) delete next.bindings[id]; - for (const page of patch.upserts?.pages ?? []) next.pages[page.id] = page; - for (const shape of patch.upserts?.shapes ?? []) next.shapes[shape.id] = shape; - for (const binding of patch.upserts?.bindings ?? []) next.bindings[binding.id] = binding; - if (patch.order?.pageIds) next.order.pageIds = [...patch.order.pageIds]; - if (patch.order?.shapeOrder) next.order.shapeOrder = { - ...(next.order.shapeOrder ?? {}), - ...structuredClone(patch.order.shapeOrder), - }; - return next; + const next = structuredClone(doc); + for (const id of patch.deletes?.pageIds ?? []) delete next.pages[id]; + for (const id of patch.deletes?.shapeIds ?? []) delete next.shapes[id]; + for (const id of patch.deletes?.bindingIds ?? []) delete next.bindings[id]; + for (const page of patch.upserts?.pages ?? []) next.pages[page.id] = page; + for (const shape of patch.upserts?.shapes ?? []) next.shapes[shape.id] = shape; + for (const binding of patch.upserts?.bindings ?? []) next.bindings[binding.id] = binding; + if (patch.order?.pageIds) next.order.pageIds = [...patch.order.pageIds]; + if (patch.order?.shapeOrder) + next.order.shapeOrder = { ...(next.order.shapeOrder ?? {}), ...structuredClone(patch.order.shapeOrder) }; + if (patch.order?.layers) { + next.layers = structuredClone(patch.order.layers); + next.order.layers = structuredClone(patch.order.layers); + } + return next; } -function documentFromLoadedDoc( - doc: LoadedDoc, - current: DocumentSnapshot, - actor: string, -): DocumentSnapshot { - const pages = structuredClone(current.document.pages); - const layers = structuredClone(current.document.layers); - const shapes: Record = {}; - const groupChildren = new Map(); - const shapePages = new Map(); - - for (const pageId of doc.order.pageIds) { - const page = doc.pages[pageId]; - const currentPage = pages[pageId]; - if (!page || !currentPage || currentPage.layer_ids.length === 0) { - throw new Error(`Desktop mirror cannot update unknown page ${pageId}`); - } - const layerId = currentPage.layer_ids[0]; - const roots: string[] = []; - for (const shapeId of page.shapeIds) { - const shape = doc.shapes[shapeId]; - if (!shape) continue; - if (shape.groupId) { - const children = groupChildren.get(shape.groupId) ?? []; - children.push(shape.id); - groupChildren.set(shape.groupId, children); - if (!roots.includes(shape.groupId)) roots.push(shape.groupId); - } else { - roots.push(shape.id); - } - shapePages.set(shape.id, { pageId, layerId }); - } - pages[pageId] = { ...currentPage, name: page.name }; - layers[layerId] = { ...layers[layerId], shape_ids: roots }; - } - - for (const shape of Object.values(doc.shapes)) { - const location = shapePages.get(shape.id); - if (!location) continue; - const existing = current.document.shapes[shape.id]; - shapes[shape.id] = shapeFromLegacy(shape, location, existing, actor); - } - for (const [groupId, childIds] of groupChildren) { - const location = shapePages.get(childIds[0]); - if (!location) continue; - const existing = current.document.shapes[groupId]; - shapes[groupId] = { - id: groupId, - kind: "container", - parent: { kind: "layer", id: location.layerId }, - transform: identityTransform(), - child_ids: childIds, - layout: { kind: "free" } satisfies ContainerLayout, - properties: {}, - metadata: existing?.metadata ?? defaultMetadata(actor), - style: existing?.style ?? defaultStyle(), - version: existing?.version ?? 1, - }; - } - - const bindings: Record = {}; - for (const binding of Object.values(doc.bindings)) { - bindings[binding.id] = { - id: binding.id, - kind: binding.type, - source_shape_id: binding.fromShapeId, - target_shape_id: binding.toShapeId, - source_handle: binding.handle, - anchor: binding.anchor.kind === "center" - ? { kind: "center" } - : { kind: "edge", x: binding.anchor.nx, y: binding.anchor.ny }, - version: current.document.bindings[binding.id]?.version ?? 1, - }; - } - - return { - ...current, - document: { - ...current.document, - page_ids: [...doc.order.pageIds], - pages, - layers, - shapes, - bindings, - }, - }; +function documentFromLoadedDoc(doc: LoadedDoc, current: DocumentSnapshot, actor: string): DocumentSnapshot { + const pages = structuredClone(current.document.pages); + const layers = structuredClone(current.document.layers); + const shapes: Record = {}; + const groupChildren = new Map(); + const shapePages = new Map(); + + for (const pageId of doc.order.pageIds) { + const page = doc.pages[pageId]; + const currentPage = pages[pageId]; + if (!page || !currentPage) { + throw new Error(`Desktop mirror cannot update unknown page ${pageId}`); + } + const layerIds = page.layerIds?.length ? page.layerIds : currentPage.layer_ids; + for (const layerId of layerIds) { + const legacyLayer = doc.layers?.[layerId]; + const currentLayer = current.document.layers[layerId]; + if (!legacyLayer && !currentLayer) continue; + const layerShapeIds = + legacyLayer?.shapeIds ?? page.shapeIds.filter((id) => doc.shapes[id]?.layerId === layerId); + const roots: string[] = []; + for (const shapeId of layerShapeIds) { + const shape = doc.shapes[shapeId]; + if (!shape) continue; + if (shape.groupId) { + const children = groupChildren.get(shape.groupId) ?? []; + children.push(shape.id); + groupChildren.set(shape.groupId, children); + if (!roots.includes(shape.groupId)) roots.push(shape.groupId); + } else { + roots.push(shape.id); + } + shapePages.set(shape.id, { pageId, layerId }); + } + layers[layerId] = { + id: layerId, + page_id: pageId, + name: legacyLayer?.name ?? currentLayer?.name ?? 'Layer', + shape_ids: roots, + visible: legacyLayer?.visible ?? currentLayer?.visible ?? true, + locked: legacyLayer?.locked ?? currentLayer?.locked ?? false, + opacity: legacyLayer?.opacity ?? currentLayer?.opacity ?? 1, + version: currentLayer?.version ?? 1 + }; + } + pages[pageId] = { ...currentPage, name: page.name, layer_ids: [...layerIds] }; + } + const retainedLayerIds = new Set(Object.values(pages).flatMap((page) => page.layer_ids)); + for (const layerId of Object.keys(layers)) { + if (!retainedLayerIds.has(layerId)) delete layers[layerId]; + } + + for (const shape of Object.values(doc.shapes)) { + const location = shapePages.get(shape.id); + if (!location) continue; + const existing = current.document.shapes[shape.id]; + shapes[shape.id] = shapeFromLegacy(shape, location, existing, actor); + } + for (const [groupId, childIds] of groupChildren) { + const location = shapePages.get(childIds[0]); + if (!location) continue; + const existing = current.document.shapes[groupId]; + shapes[groupId] = { + id: groupId, + kind: 'container', + parent: { kind: 'layer', id: location.layerId }, + transform: identityTransform(), + child_ids: childIds, + layout: { kind: 'free' } satisfies ContainerLayout, + properties: {}, + metadata: existing?.metadata ?? defaultMetadata(actor), + style: existing?.style ?? defaultStyle(), + version: existing?.version ?? 1 + }; + } + + const bindings: Record = {}; + for (const binding of Object.values(doc.bindings)) { + bindings[binding.id] = { + id: binding.id, + kind: binding.type, + source_shape_id: binding.fromShapeId, + target_shape_id: binding.toShapeId, + source_handle: binding.handle, + anchor: + binding.anchor.kind === 'center' + ? { kind: 'center' } + : { kind: 'edge', x: binding.anchor.nx, y: binding.anchor.ny }, + version: current.document.bindings[binding.id]?.version ?? 1 + }; + } + + return { + ...current, + document: { ...current.document, page_ids: [...doc.order.pageIds], pages, layers, shapes, bindings } + }; } function shapeFromLegacy( - shape: LegacyShapeRecord, - location: { pageId: string; layerId: string }, - existing: ShapeRecord | undefined, - actor: string, + shape: LegacyShapeRecord, + location: { pageId: string; layerId: string }, + existing: ShapeRecord | undefined, + actor: string ): ShapeRecord { - const properties = structuredClone(shape.props) as Record; - if ("w" in properties) { - properties.width = properties.w; - delete properties.w; - } - if ("h" in properties) { - properties.height = properties.h; - delete properties.h; - } - return { - id: shape.id, - kind: shape.type, - parent: shape.groupId - ? { kind: "shape", id: shape.groupId } - : { kind: "layer", id: location.layerId }, - transform: { - translation: { x: shape.x, y: shape.y }, - rotation: shape.rot, - scale_x: existing?.transform.scale_x ?? 1, - scale_y: existing?.transform.scale_y ?? 1, - } satisfies Transform, - child_ids: [], - layout: null, - properties: properties as ShapeProperties, - metadata: existing?.metadata ?? defaultMetadata(actor), - style: existing?.style ?? defaultStyle(), - version: existing?.version ?? 1, - }; + const properties = structuredClone(shape.props) as Record; + if ('w' in properties) { + properties.width = properties.w; + delete properties.w; + } + if ('h' in properties) { + properties.height = properties.h; + delete properties.h; + } + return { + id: shape.id, + kind: shape.type, + parent: shape.groupId ? { kind: 'shape', id: shape.groupId } : { kind: 'layer', id: location.layerId }, + transform: { + translation: { x: shape.x, y: shape.y }, + rotation: shape.rot, + scale_x: existing?.transform.scale_x ?? 1, + scale_y: existing?.transform.scale_y ?? 1 + } satisfies Transform, + child_ids: [], + layout: null, + properties: properties as ShapeProperties, + metadata: existing?.metadata ?? defaultMetadata(actor), + style: existing?.style ?? defaultStyle(), + version: existing?.version ?? 1 + }; } -function operationsForMirror(current: DocumentSnapshot, target: DocumentSnapshot): TransactionDraft["operations"] { - const operations: TransactionDraft["operations"] = []; - for (const binding of Object.values(current.document.bindings)) { - operations.push({ type: "delete_binding", binding_id: binding.id, expected_version: binding.version }); - } - for (const page of Object.values(current.document.pages)) { - if (target.document.pages[page.id]?.name !== page.name) { - operations.push({ - type: "rename_page", - page_id: page.id, - name: target.document.pages[page.id]?.name ?? page.name, - expected_version: page.version, - }); - } - } - const currentRoots = Object.values(current.document.layers).flatMap((layer) => layer.shape_ids); - for (const shapeId of currentRoots) { - const shape = current.document.shapes[shapeId]; - if (shape) operations.push({ type: "delete_shape", shape_id: shape.id, expected_version: shape.version }); - } - const created = new Set(); - const createShape = (shapeId: string) => { - if (created.has(shapeId)) return; - const shape = target.document.shapes[shapeId]; - if (!shape) return; - if (shape.parent.kind === "shape") createShape(shape.parent.id); - operations.push({ type: "create_shape", shape, anchor: { position: "last" } }); - created.add(shapeId); - }; - for (const layer of Object.values(target.document.layers)) { - for (const shapeId of layer.shape_ids) createShape(shapeId); - } - for (const binding of Object.values(target.document.bindings)) { - operations.push({ type: "create_binding", binding }); - } - return operations; +function operationsForMirror(current: DocumentSnapshot, target: DocumentSnapshot): TransactionDraft['operations'] { + const operations: TransactionDraft['operations'] = []; + for (const binding of Object.values(current.document.bindings)) { + operations.push({ type: 'delete_binding', binding_id: binding.id, expected_version: binding.version }); + } + for (const page of Object.values(current.document.pages)) { + if (target.document.pages[page.id]?.name !== page.name) { + operations.push({ + type: 'rename_page', + page_id: page.id, + name: target.document.pages[page.id]?.name ?? page.name, + expected_version: page.version + }); + } + } + const currentRoots = Object.values(current.document.layers).flatMap((layer) => layer.shape_ids); + for (const shapeId of currentRoots) { + const shape = current.document.shapes[shapeId]; + if (shape) operations.push({ type: 'delete_shape', shape_id: shape.id, expected_version: shape.version }); + } + const created = new Set(); + const createShape = (shapeId: string) => { + if (created.has(shapeId)) return; + const shape = target.document.shapes[shapeId]; + if (!shape) return; + if (shape.parent.kind === 'shape') createShape(shape.parent.id); + operations.push({ type: 'create_shape', shape, anchor: { position: 'last' } }); + created.add(shapeId); + }; + for (const layer of Object.values(target.document.layers)) { + for (const shapeId of layer.shape_ids) createShape(shapeId); + } + for (const binding of Object.values(target.document.bindings)) { + operations.push({ type: 'create_binding', binding }); + } + return operations; } function defaultMetadata(actor: string) { - return { - name: null, - role: null, - description: null, - tags: [], - locked: false, - agent_editable: true, - provenance: { - actor_id: actor, - origin: "human", - timestamp: Date.now(), - source: null, - } satisfies Provenance, - }; + return { + name: null, + role: null, + description: null, + tags: [], + locked: false, + agent_editable: true, + provenance: { actor_id: actor, origin: 'human', timestamp: Date.now(), source: null } satisfies Provenance + }; } function defaultStyle(): ShapeStyle { - return { opacity: 1, fill_opacity: null, stroke_opacity: null }; + return { opacity: 1, fill_opacity: null, stroke_opacity: null }; } function identityTransform(): Transform { - return { translation: { x: 0, y: 0 }, rotation: 0, scale_x: 1, scale_y: 1 }; + return { translation: { x: 0, y: 0 }, rotation: 0, scale_x: 1, scale_y: 1 }; } function fileName(path: string): string { - return path.split(/[\\/]/).pop() || "Untitled.inkfinite"; + return path.split(/[\\/]/).pop() || 'Untitled.inkfinite'; } function fileStem(name: string): string { - return name.replace(/\.inkfinite(?:\.json)?$/i, ""); + return name.replace(/\.inkfinite(?:\.json)?$/i, ''); } function safeFileStem(name: string): string { - return name.replace(/[\\/:*?"<>|]/g, "-").trim() || "Untitled"; + return name.replace(/[\\/:*?"<>|]/g, '-').trim() || 'Untitled'; } function canonicalExtension(path: string): string { - return path.endsWith(".inkfinite.json") ? ".inkfinite.json" : ".inkfinite"; + return path.endsWith('.inkfinite.json') ? '.inkfinite.json' : '.inkfinite'; } function parentPath(path: string): string { - const separator = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); - return separator < 0 ? "." : path.slice(0, separator); + const separator = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return separator < 0 ? '.' : path.slice(0, separator); } function joinPath(parent: string, child: string): string { - const separator = parent.includes("\\") ? "\\" : "/"; - return `${parent.replace(/[\\/]$/, "")}${separator}${child}`; + const separator = parent.includes('\\') ? '\\' : '/'; + return `${parent.replace(/[\\/]$/, '')}${separator}${child}`; } function boardIdForPath(path: string): string { - return `path:${path}`; + return `path:${path}`; } diff --git a/apps/web/src/lib/persistence/dexie.ts b/apps/web/src/lib/persistence/dexie.ts index ff0ab6f..3ef865a 100644 --- a/apps/web/src/lib/persistence/dexie.ts +++ b/apps/web/src/lib/persistence/dexie.ts @@ -1,119 +1,122 @@ -import { type DocPatch, type PersistenceSink, type PersistentDocRepo } from "@inkfinite/core"; -import { createStatusStore, type EditorPlatformAdapter, type EditorPlatformSession } from "@inkfinite/ui/editor"; -import { liveQuery } from "dexie"; -import { InkfiniteDB, KNOWN_MIGRATION_IDS } from "./database"; -import { - createDexieDocRepo, - createPersistenceSink, - getBoardInspectorData, - type PersistenceSinkOptions, -} from "./repository"; +import { type DocPatch, type PersistenceSink, type PersistentDocRepo } from '@inkfinite/core'; +import { createStatusStore } from '@inkfinite/ui/editor'; +import type { EditorPlatformAdapter, EditorPlatformSession } from '@inkfinite/ui/editor'; +import { liveQuery } from 'dexie'; +import { InkfiniteDB, KNOWN_MIGRATION_IDS } from './database'; +import { createDexieDocRepo, createPersistenceSink, getBoardInspectorData } from './repository'; +import type { PersistenceSinkOptions } from './repository'; type LiveQueryFactory = typeof liveQuery; /** Test and tuning hooks for the Dexie persistence adapter. */ export type DexieAdapterOptions = { - database?: InkfiniteDB; - sink?: PersistenceSinkOptions; - liveQueryFn?: LiveQueryFactory; + database?: InkfiniteDB; + sink?: PersistenceSinkOptions; + liveQueryFn?: LiveQueryFactory; }; /** Creates the static web application's Dexie-backed editor adapter. */ -export function createDexiePlatformAdapter(options: DexieAdapterOptions = {}): EditorPlatformAdapter { - return { - kind: "web", - async connect() { - const database = options.database ?? new InkfiniteDB(); - const repo = createDexieDocRepo(database); - return createDexieSession(database, repo, options); - }, - }; +export function createDexiePlatformAdapter(opts: DexieAdapterOptions = {}): EditorPlatformAdapter { + return { + kind: 'web', + async connect() { + const database = opts.database ?? new InkfiniteDB(); + const repo = createDexieDocRepo(database); + return createDexieSession(database, repo, opts); + } + }; } /** Connects an existing Dexie database and repository to the editor contract. */ export function createDexieSession( - database: InkfiniteDB, - repo: PersistentDocRepo, - options: Omit = {}, + database: InkfiniteDB, + repo: PersistentDocRepo, + opts: Omit = {} ): EditorPlatformSession { - const sink = createPersistenceSink(repo, options.sink); - const status = createStatusStore({ backend: "indexeddb", state: "saved", pendingWrites: 0 }); - const liveQueryFactory = options.liveQueryFn ?? liveQuery; - let activeBoardId: string | null = null; - let subscription: { unsubscribe(): void } | null = null; + const sink = createPersistenceSink(repo, opts.sink); + const status = createStatusStore({ backend: 'indexeddb', state: 'saved', pendingWrites: 0 }); + const liveQueryFactory = opts.liveQueryFn ?? liveQuery; + let activeBoardId: string | null = null; + let subscription: { unsubscribe(): void } | null = null; - function markSaved(timestamp?: number) { - status.update((current) => ({ - ...current, - pendingWrites: 0, - state: "saved", - lastSavedAt: timestamp ?? current.lastSavedAt, - errorMsg: undefined, - })); - } + function markSaved(timestamp?: number) { + status.update((current) => ({ + ...current, + pendingWrites: 0, + state: 'saved', + lastSavedAt: timestamp ?? current.lastSavedAt, + errorMsg: undefined + })); + } - function markError(error: unknown) { - status.update((current) => ({ - ...current, - state: "error", - errorMsg: error instanceof Error ? error.message : String(error), - })); - } + function markError(error: unknown) { + status.update((current) => ({ + ...current, + state: 'error', + errorMsg: error instanceof Error ? error.message : String(error) + })); + } - const trackedSink: PersistenceSink = { - enqueueDocPatch(boardId, patch) { - if (hasPatchChanges(patch)) { - status.update((current) => ({ - ...current, - pendingWrites: (current.pendingWrites ?? 0) + 1, - state: "saving", - errorMsg: undefined, - })); - } - sink.enqueueDocPatch(boardId, patch); - }, - async flush() { - try { - await sink.flush(); - } catch (error) { - markError(error); - throw error; - } - }, - }; + const trackedSink: PersistenceSink = { + enqueueDocPatch(boardId, patch) { + if (hasPatchChanges(patch)) { + status.update((current) => ({ + ...current, + pendingWrites: (current.pendingWrites ?? 0) + 1, + state: 'saving', + errorMsg: undefined + })); + } + sink.enqueueDocPatch(boardId, patch); + }, + async flush() { + try { + await sink.flush(); + } catch (error) { + markError(error); + throw error; + } + } + }; - return { - repo, - sink: trackedSink, - status, - inspectBoard: (boardId) => getBoardInspectorData(database, boardId, KNOWN_MIGRATION_IDS), - setActiveBoard(boardId) { - if (activeBoardId === boardId) return; - subscription?.unsubscribe(); - subscription = null; - activeBoardId = boardId; - if (!boardId) return; + return { + repo, + sink: trackedSink, + status, + inspectBoard: (boardId) => getBoardInspectorData(database, boardId, KNOWN_MIGRATION_IDS), + setActiveBoard(boardId) { + if (activeBoardId === boardId) return; + subscription?.unsubscribe(); + subscription = null; + activeBoardId = boardId; + if (!boardId) return; - subscription = liveQueryFactory(() => database.boards.get(boardId)).subscribe({ - next(board) { - if (board?.updatedAt !== undefined) markSaved(board.updatedAt); - }, - error: markError, - }); - }, - dispose() { - subscription?.unsubscribe(); - subscription = null; - }, - }; + subscription = liveQueryFactory(() => database.boards.get(boardId)).subscribe({ + next(board) { + if (board?.updatedAt !== undefined) markSaved(board.updatedAt); + }, + error: markError + }); + }, + dispose() { + subscription?.unsubscribe(); + subscription = null; + } + }; } function hasPatchChanges(patch: DocPatch): boolean { - const upserts = patch.upserts; - if (upserts?.pages?.length || upserts?.shapes?.length || upserts?.bindings?.length) return true; + const upserts = patch.upserts; + if (upserts?.pages?.length || upserts?.shapes?.length || upserts?.bindings?.length) + return true; - const deletes = patch.deletes; - if (deletes?.pageIds?.length || deletes?.shapeIds?.length || deletes?.bindingIds?.length) return true; + const deletes = patch.deletes; + if (deletes?.pageIds?.length || deletes?.shapeIds?.length || deletes?.bindingIds?.length) + return true; - return Boolean(patch.order?.pageIds?.length || Object.keys(patch.order?.shapeOrder ?? {}).length); + return Boolean( + patch.order?.pageIds?.length || + Object.keys(patch.order?.shapeOrder ?? {}).length || + Object.keys(patch.order?.layers ?? {}).length + ); } diff --git a/apps/web/src/lib/persistence/repository.ts b/apps/web/src/lib/persistence/repository.ts index dfb6831..06b2c65 100644 --- a/apps/web/src/lib/persistence/repository.ts +++ b/apps/web/src/lib/persistence/repository.ts @@ -1,28 +1,32 @@ import { - type BindingRecord, - BindingRecord as BindingOps, - type BoardExport, - type BoardInspectorData, - type BoardMeta, - type BoardStats, - BoardStatsOps, - createId, - type DocOrder, - type DocPatch, - type Document, - getPendingMigrations, - type LoadedDoc, - type MigrationInfo, - type PageRecord, - PageRecord as PageOps, - type PersistenceSink, - type PersistentDocRepo, - type SchemaInfo, - type ShapeRecord, - ShapeRecord as ShapeOps, - type Timestamp, -} from "@inkfinite/core"; -import Dexie from "dexie"; + BindingRecord as BindingOps, + BoardStatsOps, + createId, + getPendingMigrations, + LayerRecord as LayerOps, + PageRecord as PageOps, + ShapeRecord as ShapeOps +} from '@inkfinite/core'; +import type { + BindingRecord, + BoardExport, + BoardInspectorData, + BoardMeta, + BoardStats, + DocOrder, + DocPatch, + Document, + LoadedDoc, + LayerRecord, + MigrationInfo, + PageRecord, + PersistenceSink, + PersistentDocRepo, + SchemaInfo, + ShapeRecord, + Timestamp +} from '@inkfinite/core'; +import Dexie from 'dexie'; /** IndexedDB row for a page scoped to its board. */ export type PageRow = PageRecord & { boardId: string; updatedAt: Timestamp }; @@ -45,444 +49,544 @@ export type PersistenceSinkOptions = { debounceMs?: number }; /** Clock override used by deterministic repository tests. */ export type WebRepoOptions = { now?: () => Timestamp }; -type DexieLike = Pick; +type DexieLike = Pick; -const DEFAULT_BOARD_NAME = "Untitled Board"; - -const PAGE_ORDER_META_PREFIX = "page-order:"; -const SHAPE_ORDER_META_PREFIX = "shape-order:"; +const DEFAULT_BOARD_NAME = 'Untitled Board'; +const PAGE_ORDER_META_PREFIX = 'page-order:'; +const SHAPE_ORDER_META_PREFIX = 'shape-order:'; +const LAYERS_META_PREFIX = 'layers:'; const pageOrderKey = (boardId: string) => `${PAGE_ORDER_META_PREFIX}${boardId}`; + const shapeOrderKey = (boardId: string) => `${SHAPE_ORDER_META_PREFIX}${boardId}`; +const layersKey = (boardId: string) => `${LAYERS_META_PREFIX}${boardId}`; + /** * Create a Dexie-backed persistent DocRepo used by the web app. */ -export function createDexieDocRepo(database: DexieLike, options?: WebRepoOptions): PersistentDocRepo { - const now = () => options?.now?.() ?? Date.now(); - - const boards = () => database.table("boards"); - const pages = () => database.table("pages"); - const shapes = () => database.table("shapes"); - const bindings = () => database.table("bindings"); - const meta = () => database.table("meta"); - - async function listBoards(): Promise { - return boards().orderBy("updatedAt").reverse().toArray(); - } - - async function createBoard(name: string): Promise { - const boardId = createId("board"); - const timestamp = now(); - const page = PageOps.create("Page 1"); - const pageRow: PageRow = { ...page, boardId, updatedAt: timestamp }; - - await database.transaction("rw", boards(), pages(), meta(), async () => { - await boards().add({ id: boardId, name: name || DEFAULT_BOARD_NAME, createdAt: timestamp, updatedAt: timestamp }); - await pages().add(pageRow); - await meta().put({ key: pageOrderKey(boardId), value: [page.id] }); - await meta().put({ key: shapeOrderKey(boardId), value: { [page.id]: [...page.shapeIds] } }); - }); - - return boardId; - } - - async function renameBoard(boardId: string, name: string): Promise { - await boards().update(boardId, { name, updatedAt: now() }); - } - - async function deleteBoard(boardId: string): Promise { - await database.transaction("rw", [boards(), pages(), shapes(), bindings(), meta()], async () => { - const pageKeys = (await pages().where("boardId").equals(boardId).toArray()).map((row) => - [row.boardId, row.id] as [string, string] - ); - const shapeKeys = (await shapes().where("boardId").equals(boardId).toArray()).map((row) => - [row.boardId, row.id] as [string, string] - ); - const bindingKeys = (await bindings().where("boardId").equals(boardId).toArray()).map((row) => - [row.boardId, row.id] as [string, string] - ); - - await boards().delete(boardId); - if (pageKeys.length > 0) await pages().bulkDelete(pageKeys); - if (shapeKeys.length > 0) await shapes().bulkDelete(shapeKeys); - if (bindingKeys.length > 0) await bindings().bulkDelete(bindingKeys); - await meta().delete(pageOrderKey(boardId)); - await meta().delete(shapeOrderKey(boardId)); - }); - } - - async function loadDoc(boardId: string): Promise { - const pageRows = await pages().where("boardId").equals(boardId).toArray(); - const [shapeRows, bindingRows, order] = await Promise.all([ - shapes().where("boardId").equals(boardId).toArray(), - bindings().where("boardId").equals(boardId).toArray(), - loadOrder(boardId, pageRows), - ]); - - const docPages: Record = {}; - for (const row of pageRows) { - docPages[row.id] = clonePageRow(row); - } - - const docShapes: Record = {}; - for (const row of shapeRows) { - docShapes[row.id] = cloneShapeRow(row); - } - - const docBindings: Record = {}; - for (const row of bindingRows) { - docBindings[row.id] = cloneBindingRow(row); - } - - return { pages: docPages, shapes: docShapes, bindings: docBindings, order }; - } - - async function loadOrder(boardId: string, fallbackPages: PageRow[]): Promise { - const pageOrderRow = await meta().get(pageOrderKey(boardId)); - const shapeOrderRow = await meta().get(shapeOrderKey(boardId)); - const fallbackPageIds = fallbackPages.map((row) => row.id); - const fallbackShapeOrder = shapeOrderFromPageRows(fallbackPages); - - return { - pageIds: (pageOrderRow?.value as string[] | undefined) ?? fallbackPageIds, - shapeOrder: (shapeOrderRow?.value as Record | undefined) ?? fallbackShapeOrder, - }; - } - - async function applyDocPatch(boardId: string, patch: DocPatch): Promise { - const timestamp = now(); - - await database.transaction("rw", [boards(), pages(), shapes(), bindings(), meta()], async () => { - const pageDeleteKeys = patch.deletes?.pageIds?.map((id) => [boardId, id] as [string, string]) ?? []; - const shapeDeleteKeys = patch.deletes?.shapeIds?.map((id) => [boardId, id] as [string, string]) ?? []; - const bindingDeleteKeys = patch.deletes?.bindingIds?.map((id) => [boardId, id] as [string, string]) ?? []; - - if (pageDeleteKeys.length > 0) await pages().bulkDelete(pageDeleteKeys); - if (shapeDeleteKeys.length > 0) await shapes().bulkDelete(shapeDeleteKeys); - if (bindingDeleteKeys.length > 0) await bindings().bulkDelete(bindingDeleteKeys); - - const upsertPages = - patch.upserts?.pages?.map((page) => ({ ...PageOps.clone(page), boardId, updatedAt: timestamp })) ?? []; - const upsertShapes = - patch.upserts?.shapes?.map((shape) => ({ ...ShapeOps.clone(shape), boardId, updatedAt: timestamp })) ?? []; - const upsertBindings = - patch.upserts?.bindings?.map((binding) => ({ ...BindingOps.clone(binding), boardId, updatedAt: timestamp })) - ?? []; - - if (upsertPages.length > 0) await pages().bulkPut(upsertPages); - if (upsertShapes.length > 0) await shapes().bulkPut(upsertShapes); - if (upsertBindings.length > 0) await bindings().bulkPut(upsertBindings); - - if (patch.order?.pageIds) { - await meta().put({ key: pageOrderKey(boardId), value: [...patch.order.pageIds] }); - } - - if (patch.order?.shapeOrder) { - await meta().put({ key: shapeOrderKey(boardId), value: patch.order.shapeOrder }); - } - - await boards().update(boardId, { updatedAt: timestamp }); - }); - } - - async function exportBoard(boardId: string): Promise { - const board = await boards().get(boardId); - if (!board) { - throw new Error(`Board ${boardId} not found`); - } - - const { pages, shapes, bindings, order } = await loadDoc(boardId); - const doc: Document = { pages, shapes, bindings }; - return { board, doc, order }; - } - - async function importBoard(snapshot: BoardExport): Promise { - const boardId = snapshot.board.id ?? createId("board"); - const timestamp = now(); - const board: BoardMeta = { - id: boardId, - name: snapshot.board.name || DEFAULT_BOARD_NAME, - createdAt: snapshot.board.createdAt ?? timestamp, - updatedAt: timestamp, - }; - - await database.transaction("rw", [boards(), pages(), shapes(), bindings(), meta()], async () => { - await boards().put(board); - - const pageRows = Object.values(snapshot.doc.pages).map((page) => ({ - ...PageOps.clone(page), - boardId, - updatedAt: timestamp, - })); - const shapeRows = Object.values(snapshot.doc.shapes).map((shape) => ({ - ...ShapeOps.clone(shape), - boardId, - updatedAt: timestamp, - })); - const bindingRows = Object.values(snapshot.doc.bindings).map((binding) => ({ - ...BindingOps.clone(binding), - boardId, - updatedAt: timestamp, - })); - - if (pageRows.length > 0) await pages().bulkPut(pageRows); - if (shapeRows.length > 0) await shapes().bulkPut(shapeRows); - if (bindingRows.length > 0) await bindings().bulkPut(bindingRows); - - const order = snapshot.order ?? deriveDocOrderFromDocument(snapshot.doc); - await meta().put({ key: pageOrderKey(boardId), value: order.pageIds }); - await meta().put({ key: shapeOrderKey(boardId), value: order.shapeOrder ?? {} }); - }); - - return boardId; - } - - 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, - }; +export function createDexieDocRepo( + database: DexieLike, + options?: WebRepoOptions +): PersistentDocRepo { + const now = () => options?.now?.() ?? Date.now(); + + const boards = () => database.table('boards'); + const pages = () => database.table('pages'); + const shapes = () => database.table('shapes'); + const bindings = () => database.table('bindings'); + const meta = () => database.table('meta'); + + async function listBoards(): Promise { + return boards().orderBy('updatedAt').reverse().toArray(); + } + + async function createBoard(name: string): Promise { + const boardId = createId('board'); + const timestamp = now(); + const page = PageOps.create('Page 1'); + const pageRow: PageRow = { ...page, boardId, updatedAt: timestamp }; + + await database.transaction('rw', boards(), pages(), meta(), async () => { + await boards().add({ + id: boardId, + name: name || DEFAULT_BOARD_NAME, + createdAt: timestamp, + updatedAt: timestamp + }); + await pages().add(pageRow); + await meta().put({ key: pageOrderKey(boardId), value: [page.id] }); + await meta().put({ + key: shapeOrderKey(boardId), + value: { [page.id]: [...page.shapeIds] } + }); + }); + + return boardId; + } + + async function renameBoard(boardId: string, name: string): Promise { + await boards().update(boardId, { name, updatedAt: now() }); + } + + async function deleteBoard(boardId: string): Promise { + await database.transaction( + 'rw', + [boards(), pages(), shapes(), bindings(), meta()], + async () => { + const pageKeys = (await pages().where('boardId').equals(boardId).toArray()).map( + (row) => [row.boardId, row.id] as [string, string] + ); + const shapeKeys = (await shapes().where('boardId').equals(boardId).toArray()).map( + (row) => [row.boardId, row.id] as [string, string] + ); + const bindingKeys = ( + await bindings().where('boardId').equals(boardId).toArray() + ).map((row) => [row.boardId, row.id] as [string, string]); + + await boards().delete(boardId); + if (pageKeys.length > 0) await pages().bulkDelete(pageKeys); + if (shapeKeys.length > 0) await shapes().bulkDelete(shapeKeys); + if (bindingKeys.length > 0) await bindings().bulkDelete(bindingKeys); + await meta().delete(pageOrderKey(boardId)); + await meta().delete(shapeOrderKey(boardId)); + await meta().delete(layersKey(boardId)); + } + ); + } + + async function loadDoc(boardId: string): Promise { + const pageRows = await pages().where('boardId').equals(boardId).toArray(); + const [shapeRows, bindingRows, order] = await Promise.all([ + shapes().where('boardId').equals(boardId).toArray(), + bindings().where('boardId').equals(boardId).toArray(), + loadOrder(boardId, pageRows) + ]); + + const docPages: Record = {}; + for (const row of pageRows) { + docPages[row.id] = clonePageRow(row); + } + + const docShapes: Record = {}; + for (const row of shapeRows) { + docShapes[row.id] = cloneShapeRow(row); + } + + const docBindings: Record = {}; + for (const row of bindingRows) { + docBindings[row.id] = cloneBindingRow(row); + } + + return { + pages: docPages, + layers: order.layers, + shapes: docShapes, + bindings: docBindings, + order + }; + } + + async function loadOrder(boardId: string, fallbackPages: PageRow[]): Promise { + const pageOrderRow = await meta().get(pageOrderKey(boardId)); + const shapeOrderRow = await meta().get(shapeOrderKey(boardId)); + const layersRow = await meta().get(layersKey(boardId)); + const fallbackPageIds = fallbackPages.map((row) => row.id); + const fallbackShapeOrder = shapeOrderFromPageRows(fallbackPages); + + return { + pageIds: (pageOrderRow?.value as string[] | undefined) ?? fallbackPageIds, + shapeOrder: + (shapeOrderRow?.value as Record | undefined) ?? + fallbackShapeOrder, + layers: layersRow?.value as Record | undefined + }; + } + + async function applyDocPatch(boardId: string, patch: DocPatch): Promise { + const timestamp = now(); + + await database.transaction( + 'rw', + [boards(), pages(), shapes(), bindings(), meta()], + async () => { + const pageDeleteKeys = + patch.deletes?.pageIds?.map((id) => [boardId, id] as [string, string]) ?? []; + const shapeDeleteKeys = + patch.deletes?.shapeIds?.map((id) => [boardId, id] as [string, string]) ?? []; + const bindingDeleteKeys = + patch.deletes?.bindingIds?.map((id) => [boardId, id] as [string, string]) ?? + []; + + if (pageDeleteKeys.length > 0) await pages().bulkDelete(pageDeleteKeys); + if (shapeDeleteKeys.length > 0) await shapes().bulkDelete(shapeDeleteKeys); + if (bindingDeleteKeys.length > 0) await bindings().bulkDelete(bindingDeleteKeys); + + const upsertPages = + patch.upserts?.pages?.map((page) => ({ + ...PageOps.clone(page), + boardId, + updatedAt: timestamp + })) ?? []; + const upsertShapes = + patch.upserts?.shapes?.map((shape) => ({ + ...ShapeOps.clone(shape), + boardId, + updatedAt: timestamp + })) ?? []; + const upsertBindings = + patch.upserts?.bindings?.map((binding) => ({ + ...BindingOps.clone(binding), + boardId, + updatedAt: timestamp + })) ?? []; + + if (upsertPages.length > 0) await pages().bulkPut(upsertPages); + if (upsertShapes.length > 0) await shapes().bulkPut(upsertShapes); + if (upsertBindings.length > 0) await bindings().bulkPut(upsertBindings); + + if (patch.order?.pageIds) { + await meta().put({ + key: pageOrderKey(boardId), + value: [...patch.order.pageIds] + }); + } + + if (patch.order?.shapeOrder) { + await meta().put({ + key: shapeOrderKey(boardId), + value: patch.order.shapeOrder + }); + } + if (patch.order?.layers) { + await meta().put({ key: layersKey(boardId), value: patch.order.layers }); + } + + await boards().update(boardId, { updatedAt: timestamp }); + } + ); + } + + async function exportBoard(boardId: string): Promise { + const board = await boards().get(boardId); + if (!board) { + throw new Error(`Board ${boardId} not found`); + } + + const { pages, layers, shapes, bindings, order } = await loadDoc(boardId); + const doc: Document = { pages, ...(layers ? { layers } : {}), shapes, bindings }; + return { board, doc, order }; + } + + async function importBoard(snapshot: BoardExport): Promise { + const boardId = snapshot.board.id ?? createId('board'); + const timestamp = now(); + const board: BoardMeta = { + id: boardId, + name: snapshot.board.name || DEFAULT_BOARD_NAME, + createdAt: snapshot.board.createdAt ?? timestamp, + updatedAt: timestamp + }; + + await database.transaction( + 'rw', + [boards(), pages(), shapes(), bindings(), meta()], + async () => { + await boards().put(board); + + const pageRows = Object.values(snapshot.doc.pages).map((page) => ({ + ...PageOps.clone(page), + boardId, + updatedAt: timestamp + })); + const shapeRows = Object.values(snapshot.doc.shapes).map((shape) => ({ + ...ShapeOps.clone(shape), + boardId, + updatedAt: timestamp + })); + const bindingRows = Object.values(snapshot.doc.bindings).map((binding) => ({ + ...BindingOps.clone(binding), + boardId, + updatedAt: timestamp + })); + + if (pageRows.length > 0) await pages().bulkPut(pageRows); + if (shapeRows.length > 0) await shapes().bulkPut(shapeRows); + if (bindingRows.length > 0) await bindings().bulkPut(bindingRows); + + const order = snapshot.order ?? deriveDocOrderFromDocument(snapshot.doc); + await meta().put({ key: pageOrderKey(boardId), value: order.pageIds }); + await meta().put({ key: shapeOrderKey(boardId), value: order.shapeOrder ?? {} }); + const importedLayers = snapshot.doc.layers ?? order.layers; + if (importedLayers && Object.keys(importedLayers).length > 0) { + await meta().put({ key: layersKey(boardId), value: importedLayers }); + } + } + ); + + return boardId; + } + + 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 + }; } /** * Batch doc patches and flush them with a debounce to cut down on Dexie writes. */ -export function createPersistenceSink(repo: PersistentDocRepo, options?: PersistenceSinkOptions): PersistenceSink { - const debounceMs = options?.debounceMs ?? 200; - let pendingBoardId: string | null = null; - let pendingPatch: DocPatch | null = null; - let timer: ReturnType | null = null; - let inflight: Promise | null = null; - - const scheduleFlush = () => { - if (timer) { - clearTimeout(timer); - } - timer = setTimeout(() => { - timer = null; - void flush(); - }, debounceMs); - }; - - const resetPending = () => { - pendingBoardId = null; - pendingPatch = null; - if (timer) { - clearTimeout(timer); - timer = null; - } - }; - - async function flush(): Promise { - if (inflight) { - await inflight; - return; - } - - if (!pendingBoardId || !pendingPatch || isPatchEmpty(pendingPatch)) { - resetPending(); - return; - } - - const boardId = pendingBoardId; - const patch = pendingPatch; - resetPending(); - - inflight = repo.applyDocPatch(boardId, patch).finally(() => { - inflight = null; - }); - - await inflight; - } - - function enqueueDocPatch(boardId: string, patch: DocPatch): void { - if (!boardId) { - throw new Error("boardId is required to persist edits"); - } - - if (pendingBoardId && pendingBoardId !== boardId) { - void flush(); - } - - pendingBoardId = boardId; - pendingPatch = clonePatch(patch); - if (!isPatchEmpty(pendingPatch)) { - scheduleFlush(); - } - } - - return { enqueueDocPatch, flush }; +export function createPersistenceSink( + repo: PersistentDocRepo, + options?: PersistenceSinkOptions +): PersistenceSink { + const debounceMs = options?.debounceMs ?? 200; + let pendingBoardId: string | null = null; + let pendingPatch: DocPatch | null = null; + let timer: ReturnType | null = null; + let inflight: Promise | null = null; + + const scheduleFlush = () => { + if (timer) { + clearTimeout(timer); + } + timer = setTimeout(() => { + timer = null; + void flush(); + }, debounceMs); + }; + + const resetPending = () => { + pendingBoardId = null; + pendingPatch = null; + if (timer) { + clearTimeout(timer); + timer = null; + } + }; + + async function flush(): Promise { + if (inflight) { + await inflight; + return; + } + + if (!pendingBoardId || !pendingPatch || isPatchEmpty(pendingPatch)) { + resetPending(); + return; + } + + const boardId = pendingBoardId; + const patch = pendingPatch; + resetPending(); + + inflight = repo.applyDocPatch(boardId, patch).finally(() => { + inflight = null; + }); + + await inflight; + } + + function enqueueDocPatch(boardId: string, patch: DocPatch): void { + if (!boardId) { + throw new Error('boardId is required to persist edits'); + } + + if (pendingBoardId && pendingBoardId !== boardId) { + void flush(); + } + + pendingBoardId = boardId; + pendingPatch = clonePatch(patch); + if (!isPatchEmpty(pendingPatch)) { + scheduleFlush(); + } + } + + return { enqueueDocPatch, flush }; } function clonePageRow(row: PageRow): PageRecord { - const { boardId: _boardId, updatedAt: _updatedAt, ...rest } = row; - return PageOps.clone(rest); + const { boardId: _boardId, updatedAt: _updatedAt, ...rest } = row; + return PageOps.clone(rest); } function cloneShapeRow(row: ShapeRow): ShapeRecord { - const { boardId: _boardId, updatedAt: _updatedAt, ...rest } = row; - return ShapeOps.clone(rest as ShapeRecord); + const { boardId: _boardId, updatedAt: _updatedAt, ...rest } = row; + return ShapeOps.clone(rest as ShapeRecord); } function cloneBindingRow(row: BindingRow): BindingRecord { - const { boardId: _boardId, updatedAt: _updatedAt, ...rest } = row; - return BindingOps.clone(rest); + const { boardId: _boardId, updatedAt: _updatedAt, ...rest } = row; + return BindingOps.clone(rest); } function deriveDocOrderFromDocument(doc: Document): DocOrder { - return { pageIds: Object.keys(doc.pages), shapeOrder: shapeOrderFromPagesRecords(doc.pages) }; + return { + pageIds: Object.keys(doc.pages), + shapeOrder: shapeOrderFromPagesRecords(doc.pages), + layers: doc.layers + }; } function shapeOrderFromPagesRecords(pages: Record): Record { - return Object.fromEntries(Object.values(pages).map((page) => [page.id, [...page.shapeIds]])); + return Object.fromEntries(Object.values(pages).map((page) => [page.id, [...page.shapeIds]])); } function shapeOrderFromPageRows(rows: PageRow[]): Record { - return Object.fromEntries(rows.map((row) => [row.id, [...row.shapeIds]])); + return Object.fromEntries(rows.map((row) => [row.id, [...row.shapeIds]])); } function clonePatch(patch: DocPatch): DocPatch { - const cloned: DocPatch = {}; - - if (patch.upserts) { - cloned.upserts = {}; - if (patch.upserts.pages) cloned.upserts.pages = patch.upserts.pages.map((page) => PageOps.clone(page)); - if (patch.upserts.shapes) cloned.upserts.shapes = patch.upserts.shapes.map((shape) => ShapeOps.clone(shape)); - if (patch.upserts.bindings) { - cloned.upserts.bindings = patch.upserts.bindings.map((binding) => BindingOps.clone(binding)); - } - if (!cloned.upserts.pages && !cloned.upserts.shapes && !cloned.upserts.bindings) { - delete cloned.upserts; - } - } - - if (patch.deletes) { - cloned.deletes = {}; - if (patch.deletes.pageIds) cloned.deletes.pageIds = [...patch.deletes.pageIds]; - if (patch.deletes.shapeIds) cloned.deletes.shapeIds = [...patch.deletes.shapeIds]; - if (patch.deletes.bindingIds) cloned.deletes.bindingIds = [...patch.deletes.bindingIds]; - if (!cloned.deletes.pageIds?.length && !cloned.deletes.shapeIds?.length && !cloned.deletes.bindingIds?.length) { - delete cloned.deletes; - } - } - - if (patch.order) { - const pageIds = patch.order.pageIds ? [...patch.order.pageIds] : undefined; - const shapeOrder = cloneShapeOrderMap(patch.order.shapeOrder); - if (pageIds || shapeOrder) { - cloned.order = {}; - if (pageIds) { - cloned.order.pageIds = pageIds; - } - if (shapeOrder) { - cloned.order.shapeOrder = shapeOrder; - } - } - } - - return cloned; + const cloned: DocPatch = {}; + + if (patch.upserts) { + cloned.upserts = {}; + if (patch.upserts.pages) + cloned.upserts.pages = patch.upserts.pages.map((page) => PageOps.clone(page)); + if (patch.upserts.shapes) + cloned.upserts.shapes = patch.upserts.shapes.map((shape) => ShapeOps.clone(shape)); + if (patch.upserts.bindings) { + cloned.upserts.bindings = patch.upserts.bindings.map((binding) => + BindingOps.clone(binding) + ); + } + if (!cloned.upserts.pages && !cloned.upserts.shapes && !cloned.upserts.bindings) { + delete cloned.upserts; + } + } + + if (patch.deletes) { + cloned.deletes = {}; + if (patch.deletes.pageIds) cloned.deletes.pageIds = [...patch.deletes.pageIds]; + if (patch.deletes.shapeIds) cloned.deletes.shapeIds = [...patch.deletes.shapeIds]; + if (patch.deletes.bindingIds) cloned.deletes.bindingIds = [...patch.deletes.bindingIds]; + if ( + !cloned.deletes.pageIds?.length && + !cloned.deletes.shapeIds?.length && + !cloned.deletes.bindingIds?.length + ) { + delete cloned.deletes; + } + } + + if (patch.order) { + const pageIds = patch.order.pageIds ? [...patch.order.pageIds] : undefined; + const shapeOrder = cloneShapeOrderMap(patch.order.shapeOrder); + const layers = patch.order.layers + ? Object.fromEntries( + Object.entries(patch.order.layers).map(([id, layer]) => [ + id, + LayerOps.clone(layer) + ]) + ) + : undefined; + if (pageIds || shapeOrder || layers) { + cloned.order = {}; + if (pageIds) { + cloned.order.pageIds = pageIds; + } + if (shapeOrder) { + cloned.order.shapeOrder = shapeOrder; + } + if (layers) cloned.order.layers = layers; + } + } + + return cloned; } -function cloneShapeOrderMap(shapeOrder?: Record): Record | undefined { - if (!shapeOrder) { - return undefined; - } +function cloneShapeOrderMap( + shapeOrder?: Record +): Record | undefined { + if (!shapeOrder) { + return undefined; + } - return Object.fromEntries(Object.entries(shapeOrder).map(([pageId, shapeIds]) => [pageId, [...shapeIds]])); + return Object.fromEntries( + Object.entries(shapeOrder).map(([pageId, shapeIds]) => [pageId, [...shapeIds]]) + ); } function isPatchEmpty(patch: DocPatch): boolean { - const hasUpserts = Boolean(patch.upserts?.pages?.length) - || Boolean(patch.upserts?.shapes?.length) - || Boolean(patch.upserts?.bindings?.length); - - const hasDeletes = Boolean(patch.deletes?.pageIds?.length) - || Boolean(patch.deletes?.shapeIds?.length) - || Boolean(patch.deletes?.bindingIds?.length); - - const hasOrder = Boolean(patch.order?.pageIds?.length) - || Boolean(patch.order?.shapeOrder && Object.keys(patch.order.shapeOrder).length > 0); - - return !(hasUpserts || hasDeletes || hasOrder); + const hasUpserts = + Boolean(patch.upserts?.pages?.length) || + Boolean(patch.upserts?.shapes?.length) || + Boolean(patch.upserts?.bindings?.length); + + const hasDeletes = + Boolean(patch.deletes?.pageIds?.length) || + Boolean(patch.deletes?.shapeIds?.length) || + Boolean(patch.deletes?.bindingIds?.length); + + const hasOrder = + Boolean(patch.order?.pageIds?.length) || + Boolean(patch.order?.shapeOrder && Object.keys(patch.order.shapeOrder).length > 0) || + Boolean(patch.order?.layers && Object.keys(patch.order.layers).length > 0); + + return !(hasUpserts || hasDeletes || hasOrder); } /** * Fetch board statistics for a given board. */ export async function getBoardStats(database: DexieLike, boardId: string): Promise { - const pages = database.table("pages"); - const shapes = database.table("shapes"); - const bindings = database.table("bindings"); - const boards = database.table("boards"); - - const [pageCount, shapeCount, bindingCount, board] = await Promise.all([ - pages.where("boardId").equals(boardId).count(), - shapes.where("boardId").equals(boardId).count(), - bindings.where("boardId").equals(boardId).count(), - boards.get(boardId), - ]); - - const allRows = await Promise.all([ - pages.where("boardId").equals(boardId).toArray(), - shapes.where("boardId").equals(boardId).toArray(), - bindings.where("boardId").equals(boardId).toArray(), - ]); - - const docSizeBytes = JSON.stringify({ pages: allRows[0], shapes: allRows[1], bindings: allRows[2] }).length; - - return BoardStatsOps.create({ - pageCount, - shapeCount, - bindingCount, - docSizeBytes, - lastUpdated: board?.updatedAt ?? 0, - }); + const pages = database.table('pages'); + const shapes = database.table('shapes'); + const bindings = database.table('bindings'); + const boards = database.table('boards'); + + const [pageCount, shapeCount, bindingCount, board] = await Promise.all([ + pages.where('boardId').equals(boardId).count(), + shapes.where('boardId').equals(boardId).count(), + bindings.where('boardId').equals(boardId).count(), + boards.get(boardId) + ]); + + const allRows = await Promise.all([ + pages.where('boardId').equals(boardId).toArray(), + shapes.where('boardId').equals(boardId).toArray(), + bindings.where('boardId').equals(boardId).toArray() + ]); + + const docSizeBytes = JSON.stringify({ + pages: allRows[0], + shapes: allRows[1], + bindings: allRows[2] + }).length; + + return BoardStatsOps.create({ + pageCount, + shapeCount, + bindingCount, + docSizeBytes, + lastUpdated: board?.updatedAt ?? 0 + }); } /** * Fetch schema information from the database. */ export async function getSchemaInfo(database: Dexie): Promise { - return { declaredVersion: database.verno, installedVersion: database.verno }; + return { declaredVersion: database.verno, installedVersion: database.verno }; } /** * Fetch applied migrations from the migrations table. */ export async function getAppliedMigrations(database: DexieLike): Promise { - const migrations = database.table("migrations"); - return migrations.orderBy("appliedAt").toArray(); + const migrations = database.table('migrations'); + return migrations.orderBy('appliedAt').toArray(); } /** * Fetch complete inspector data for a board including stats, schema, and migrations. */ export async function getBoardInspectorData( - database: Dexie, - boardId: string, - knownMigrationIds: string[], + database: Dexie, + boardId: string, + knownMigrationIds: string[] ): Promise { - const [stats, schema, migrations] = await Promise.all([ - getBoardStats(database, boardId), - getSchemaInfo(database), - getAppliedMigrations(database), - ]); - - const pendingMigrations = getPendingMigrations(knownMigrationIds, migrations); - - return { storageType: "IndexedDB (Dexie)", stats, schema, migrations, pendingMigrations }; + const [stats, schema, migrations] = await Promise.all([ + getBoardStats(database, boardId), + getSchemaInfo(database), + getAppliedMigrations(database) + ]); + + const pendingMigrations = getPendingMigrations(knownMigrationIds, migrations); + return { storageType: 'IndexedDB (Dexie)', stats, schema, migrations, pendingMigrations }; } diff --git a/apps/web/src/lib/tests/markdown-editor.test.ts b/apps/web/src/lib/tests/markdown-editor.test.ts index 9552720..e0be0dc 100644 --- a/apps/web/src/lib/tests/markdown-editor.test.ts +++ b/apps/web/src/lib/tests/markdown-editor.test.ts @@ -1,397 +1,465 @@ -import { EditorState, PageRecord, ShapeRecord, Store } from "@inkfinite/core"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { MarkdownEditorController } from "$editor/canvas/controllers/markdown-controller.svelte"; - -describe("MarkdownEditorController", () => { - let store: Store; - let controller: MarkdownEditorController; - const mockRefreshCursor = vi.fn(); - const mockGetViewport = () => ({ width: 1024, height: 768 }); - - beforeEach(() => { - store = new Store(); - mockRefreshCursor.mockClear(); - controller = new MarkdownEditorController(store, mockGetViewport, mockRefreshCursor); - }); - - describe("start", () => { - it("should start editing a markdown shape", () => { - const page = PageRecord.create("Test Page", "page1"); - const shape = ShapeRecord.createMarkdown("page1", 100, 200, { - md: "# Hello World", - w: 300, - h: 200, - fontSize: 16, - fontFamily: "sans-serif", - color: "#000", - }, "shape1"); - - page.shapeIds = ["shape1"]; - store.setState((state) => ({ - ...state, - doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, - ui: { ...state.ui, currentPageId: "page1" }, - })); - - controller.start("shape1"); - - expect(controller.isEditing).toBe(true); - expect(controller.current).toEqual({ shapeId: "shape1", value: "# Hello World" }); - expect(mockRefreshCursor).toHaveBeenCalled(); - }); - - it("should not start editing if shape is not markdown", () => { - const page = PageRecord.create("Test Page", "page1"); - const shape = ShapeRecord.createRect("page1", 100, 200, { - w: 100, - h: 50, - fill: "#fff", - stroke: "#000", - radius: 0, - }, "shape1"); - - page.shapeIds = ["shape1"]; - store.setState((state) => ({ - ...state, - doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, - })); - - controller.start("shape1"); - - expect(controller.isEditing).toBe(false); - expect(controller.current).toBeNull(); - }); - - it("should not start editing if shape does not exist", () => { - controller.start("nonexistent"); - - expect(controller.isEditing).toBe(false); - expect(controller.current).toBeNull(); - }); - }); - - describe("getLayout", () => { - it("should return null when not editing", () => { - expect(controller.getLayout()).toBeNull(); - }); - - it("should compute layout when editing", () => { - const page = PageRecord.create("Test Page", "page1"); - const shape = ShapeRecord.createMarkdown("page1", 100, 200, { - md: "# Test", - w: 300, - h: 200, - fontSize: 16, - fontFamily: "sans-serif", - color: "#000", - }, "shape1"); - - page.shapeIds = ["shape1"]; - store.setState((state) => ({ - ...state, - doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, - ui: { ...state.ui, currentPageId: "page1" }, - camera: { ...state.camera, x: 0, y: 0, zoom: 1 }, - })); - - controller.start("shape1"); - const layout = controller.getLayout(); - - expect(layout).toBeTruthy(); - expect(layout?.width).toBe(300); - expect(layout?.height).toBe(200); - expect(layout?.fontSize).toBe(16); - }); - - it("should handle auto-computed height", () => { - const page = PageRecord.create("Test Page", "page1"); - const shape = ShapeRecord.createMarkdown("page1", 100, 200, { - md: "# Test", - w: 300, - fontSize: 16, - fontFamily: "sans-serif", - color: "#000", - }, "shape1"); - - page.shapeIds = ["shape1"]; - store.setState((state) => ({ - ...state, - doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, - })); - - controller.start("shape1"); - const layout = controller.getLayout(); - - expect(layout).toBeTruthy(); - expect(layout?.height).toBe(160); - }); - }); - - describe("handleInput", () => { - it("should update current value on input", () => { - const page = PageRecord.create("Test Page", "page1"); - const shape = ShapeRecord.createMarkdown("page1", 100, 200, { - md: "# Hello", - w: 300, - h: 200, - fontSize: 16, - fontFamily: "sans-serif", - color: "#000", - }, "shape1"); - - page.shapeIds = ["shape1"]; - store.setState((state) => ({ - ...state, - doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, - })); - - controller.start("shape1"); - - const mockEvent = { currentTarget: { value: "# Hello World" } as HTMLTextAreaElement } as unknown as Event; - - controller.handleInput(mockEvent); - - expect(controller.current?.value).toBe("# Hello World"); - }); - - it("should do nothing if not editing", () => { - const mockEvent = { currentTarget: { value: "test" } as HTMLTextAreaElement } as unknown as Event; - - controller.handleInput(mockEvent); - - expect(controller.current).toBeNull(); - }); - }); - - describe("handleKeyDown", () => { - beforeEach(() => { - const page = PageRecord.create("Test Page", "page1"); - const shape = ShapeRecord.createMarkdown("page1", 100, 200, { - md: "# Test", - w: 300, - h: 200, - fontSize: 16, - fontFamily: "sans-serif", - color: "#000", - }, "shape1"); - - page.shapeIds = ["shape1"]; - store.setState((state) => ({ - ...state, - doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, - })); - - controller.start("shape1"); - }); - - it("should insert spaces on Tab key", () => { - const mockTextarea = { selectionStart: 6, selectionEnd: 6, value: "# Test" } as HTMLTextAreaElement; - - const mockEvent = { - key: "Tab", - preventDefault: vi.fn(), - currentTarget: mockTextarea, - } as unknown as KeyboardEvent; - - controller.handleKeyDown(mockEvent); - - expect(mockEvent.preventDefault).toHaveBeenCalled(); - expect(controller.current?.value).toBe("# Test "); - }); - - it("should replace selection with spaces on Tab", () => { - controller.current!.value = "# Test Content"; - - const mockTextarea = { selectionStart: 2, selectionEnd: 6, value: "# Test Content" } as HTMLTextAreaElement; - - const mockEvent = { - key: "Tab", - preventDefault: vi.fn(), - currentTarget: mockTextarea, - } as unknown as KeyboardEvent; - - controller.handleKeyDown(mockEvent); - - expect(mockEvent.preventDefault).toHaveBeenCalled(); - expect(controller.current?.value).toBe("# Content"); - }); - - it("should cancel on Escape key", () => { - const mockEvent = { key: "Escape", preventDefault: vi.fn() } as unknown as KeyboardEvent; - - controller.handleKeyDown(mockEvent); - - expect(mockEvent.preventDefault).toHaveBeenCalled(); - expect(controller.isEditing).toBe(false); - expect(mockRefreshCursor).toHaveBeenCalled(); - }); - - it("should commit on Cmd+Enter", () => { - controller.current!.value = "# Updated"; - - const mockEvent = { - key: "Enter", - metaKey: true, - ctrlKey: false, - preventDefault: vi.fn(), - } as unknown as KeyboardEvent; - - controller.handleKeyDown(mockEvent); - - expect(mockEvent.preventDefault).toHaveBeenCalled(); - expect(controller.isEditing).toBe(false); - - const updatedShape = store.getState().doc.shapes["shape1"]; - expect(updatedShape).toBeTruthy(); - if (updatedShape?.type === "markdown") { - expect(updatedShape.props.md).toBe("# Updated"); - } - }); - - it("should commit on Ctrl+Enter", () => { - controller.current!.value = "# Updated"; - - const mockEvent = { - key: "Enter", - metaKey: false, - ctrlKey: true, - preventDefault: vi.fn(), - } as unknown as KeyboardEvent; - - controller.handleKeyDown(mockEvent); - - expect(mockEvent.preventDefault).toHaveBeenCalled(); - expect(controller.isEditing).toBe(false); - - const updatedShape = store.getState().doc.shapes["shape1"]; - if (updatedShape?.type === "markdown") { - expect(updatedShape.props.md).toBe("# Updated"); - } - }); - }); - - describe("commit", () => { - it("should update markdown content and create history entry", () => { - const page = PageRecord.create("Test Page", "page1"); - const shape = ShapeRecord.createMarkdown("page1", 100, 200, { - md: "# Original", - w: 300, - h: 200, - fontSize: 16, - fontFamily: "sans-serif", - color: "#000", - }, "shape1"); - - page.shapeIds = ["shape1"]; - store.setState((state) => ({ - ...state, - doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, - })); - - controller.start("shape1"); - controller.current!.value = "# Updated Content"; - controller.commit(); - - expect(controller.isEditing).toBe(false); - expect(mockRefreshCursor).toHaveBeenCalled(); - - const updatedShape = store.getState().doc.shapes["shape1"]; - expect(updatedShape).toBeTruthy(); - if (updatedShape?.type === "markdown") { - expect(updatedShape.props.md).toBe("# Updated Content"); - } - }); - - it("should not update if value is unchanged", () => { - const page = PageRecord.create("Test Page", "page1"); - const shape = ShapeRecord.createMarkdown("page1", 100, 200, { - md: "# Original", - w: 300, - h: 200, - fontSize: 16, - fontFamily: "sans-serif", - color: "#000", - }, "shape1"); - - page.shapeIds = ["shape1"]; - const initialState = EditorState.create(); - initialState.doc = { ...initialState.doc, pages: { page1: page }, shapes: { shape1: shape } }; - store.setState(() => initialState); - - controller.start("shape1"); - controller.commit(); - - const finalState = store.getState(); - expect(finalState).toEqual(initialState); - }); - - it("should do nothing if not editing", () => { - const initialState = store.getState(); - controller.commit(); - expect(store.getState()).toBe(initialState); - }); - }); - - describe("cancel", () => { - it("should stop editing without saving", () => { - const page = PageRecord.create("Test Page", "page1"); - const shape = ShapeRecord.createMarkdown("page1", 100, 200, { - md: "# Original", - w: 300, - h: 200, - fontSize: 16, - fontFamily: "sans-serif", - color: "#000", - }, "shape1"); - - page.shapeIds = ["shape1"]; - store.setState((state) => ({ - ...state, - doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, - })); - - controller.start("shape1"); - controller.current!.value = "# Modified"; - controller.cancel(); - - expect(controller.isEditing).toBe(false); - expect(mockRefreshCursor).toHaveBeenCalled(); - - const originalShape = store.getState().doc.shapes["shape1"]; - if (originalShape?.type === "markdown") { - expect(originalShape.props.md).toBe("# Original"); - } - }); - }); - - describe("handleBlur", () => { - it("should commit on blur", () => { - const page = PageRecord.create("Test Page", "page1"); - const shape = ShapeRecord.createMarkdown("page1", 100, 200, { - md: "# Original", - w: 300, - h: 200, - fontSize: 16, - fontFamily: "sans-serif", - color: "#000", - }, "shape1"); - - page.shapeIds = ["shape1"]; - store.setState((state) => ({ - ...state, - doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, - })); - - controller.start("shape1"); - controller.current!.value = "# Updated on Blur"; - controller.handleBlur(); - - expect(controller.isEditing).toBe(false); - - const updatedShape = store.getState().doc.shapes["shape1"]; - if (updatedShape?.type === "markdown") { - expect(updatedShape.props.md).toBe("# Updated on Blur"); - } - }); - }); +import { EditorState, PageRecord, ShapeRecord, Store } from '@inkfinite/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MarkdownEditorController } from '$editor/canvas/controllers/markdown-controller.svelte'; + +describe('MarkdownEditorController', () => { + let store: Store; + let controller: MarkdownEditorController; + const mockRefreshCursor = vi.fn(); + const mockGetViewport = () => ({ width: 1024, height: 768 }); + + beforeEach(() => { + store = new Store(); + mockRefreshCursor.mockClear(); + controller = new MarkdownEditorController(store, mockGetViewport, mockRefreshCursor); + }); + + describe('start', () => { + it('should start editing a markdown shape', () => { + const page = PageRecord.create('Test Page', 'page1'); + const shape = ShapeRecord.createMarkdown( + 'page1', + 100, + 200, + { + md: '# Hello World', + w: 300, + h: 200, + fontSize: 16, + fontFamily: 'sans-serif', + color: '#000' + }, + 'shape1' + ); + + page.shapeIds = ['shape1']; + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, + ui: { ...state.ui, currentPageId: 'page1' } + })); + + controller.start('shape1'); + + expect(controller.isEditing).toBe(true); + expect(controller.current).toEqual({ shapeId: 'shape1', value: '# Hello World' }); + expect(mockRefreshCursor).toHaveBeenCalled(); + }); + + it('should not start editing if shape is not markdown', () => { + const page = PageRecord.create('Test Page', 'page1'); + const shape = ShapeRecord.createRect( + 'page1', + 100, + 200, + { w: 100, h: 50, fill: '#fff', stroke: '#000', radius: 0 }, + 'shape1' + ); + + page.shapeIds = ['shape1']; + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } } + })); + + controller.start('shape1'); + + expect(controller.isEditing).toBe(false); + expect(controller.current).toBeNull(); + }); + + it('should not start editing if shape does not exist', () => { + controller.start('nonexistent'); + + expect(controller.isEditing).toBe(false); + expect(controller.current).toBeNull(); + }); + }); + + describe('getLayout', () => { + it('should return null when not editing', () => { + expect(controller.getLayout()).toBeNull(); + }); + + it('should compute layout when editing', () => { + const page = PageRecord.create('Test Page', 'page1'); + const shape = ShapeRecord.createMarkdown( + 'page1', + 100, + 200, + { + md: '# Test', + w: 300, + h: 200, + fontSize: 16, + fontFamily: 'sans-serif', + color: '#000' + }, + 'shape1' + ); + + page.shapeIds = ['shape1']; + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } }, + ui: { ...state.ui, currentPageId: 'page1' }, + camera: { ...state.camera, x: 0, y: 0, zoom: 1 } + })); + + controller.start('shape1'); + const layout = controller.getLayout(); + + expect(layout).toBeTruthy(); + expect(layout?.width).toBe(300); + expect(layout?.height).toBe(200); + expect(layout?.fontSize).toBe(16); + }); + + it('should handle auto-computed height', () => { + const page = PageRecord.create('Test Page', 'page1'); + const shape = ShapeRecord.createMarkdown( + 'page1', + 100, + 200, + { md: '# Test', w: 300, fontSize: 16, fontFamily: 'sans-serif', color: '#000' }, + 'shape1' + ); + + page.shapeIds = ['shape1']; + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } } + })); + + controller.start('shape1'); + const layout = controller.getLayout(); + + expect(layout).toBeTruthy(); + expect(layout?.height).toBe(160); + }); + }); + + describe('handleInput', () => { + it('should update current value on input', () => { + const page = PageRecord.create('Test Page', 'page1'); + const shape = ShapeRecord.createMarkdown( + 'page1', + 100, + 200, + { + md: '# Hello', + w: 300, + h: 200, + fontSize: 16, + fontFamily: 'sans-serif', + color: '#000' + }, + 'shape1' + ); + + page.shapeIds = ['shape1']; + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } } + })); + + controller.start('shape1'); + + const mockEvent = { + currentTarget: { value: '# Hello World' } as HTMLTextAreaElement + } as unknown as Event; + + controller.handleInput(mockEvent); + + expect(controller.current?.value).toBe('# Hello World'); + }); + + it('should do nothing if not editing', () => { + const mockEvent = { + currentTarget: { value: 'test' } as HTMLTextAreaElement + } as unknown as Event; + + controller.handleInput(mockEvent); + + expect(controller.current).toBeNull(); + }); + }); + + describe('handleKeyDown', () => { + beforeEach(() => { + const page = PageRecord.create('Test Page', 'page1'); + const shape = ShapeRecord.createMarkdown( + 'page1', + 100, + 200, + { + md: '# Test', + w: 300, + h: 200, + fontSize: 16, + fontFamily: 'sans-serif', + color: '#000' + }, + 'shape1' + ); + + page.shapeIds = ['shape1']; + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } } + })); + + controller.start('shape1'); + }); + + it('should insert spaces on Tab key', () => { + const mockTextarea = { + selectionStart: 6, + selectionEnd: 6, + value: '# Test' + } as HTMLTextAreaElement; + + const mockEvent = { + key: 'Tab', + preventDefault: vi.fn(), + currentTarget: mockTextarea + } as unknown as KeyboardEvent; + + controller.handleKeyDown(mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(controller.current?.value).toBe('# Test '); + }); + + it('should replace selection with spaces on Tab', () => { + controller.current!.value = '# Test Content'; + + const mockTextarea = { + selectionStart: 2, + selectionEnd: 6, + value: '# Test Content' + } as HTMLTextAreaElement; + + const mockEvent = { + key: 'Tab', + preventDefault: vi.fn(), + currentTarget: mockTextarea + } as unknown as KeyboardEvent; + + controller.handleKeyDown(mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(controller.current?.value).toBe('# Content'); + }); + + it('should cancel on Escape key', () => { + const mockEvent = { + key: 'Escape', + preventDefault: vi.fn() + } as unknown as KeyboardEvent; + + controller.handleKeyDown(mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(controller.isEditing).toBe(false); + expect(mockRefreshCursor).toHaveBeenCalled(); + }); + + it('should commit on Cmd+Enter', () => { + controller.current!.value = '# Updated'; + + const mockEvent = { + key: 'Enter', + metaKey: true, + ctrlKey: false, + preventDefault: vi.fn() + } as unknown as KeyboardEvent; + + controller.handleKeyDown(mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(controller.isEditing).toBe(false); + + const updatedShape = store.getState().doc.shapes['shape1']; + expect(updatedShape).toBeTruthy(); + if (updatedShape?.type === 'markdown') { + expect(updatedShape.props.md).toBe('# Updated'); + } + }); + + it('should commit on Ctrl+Enter', () => { + controller.current!.value = '# Updated'; + + const mockEvent = { + key: 'Enter', + metaKey: false, + ctrlKey: true, + preventDefault: vi.fn() + } as unknown as KeyboardEvent; + + controller.handleKeyDown(mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(controller.isEditing).toBe(false); + + const updatedShape = store.getState().doc.shapes['shape1']; + if (updatedShape?.type === 'markdown') { + expect(updatedShape.props.md).toBe('# Updated'); + } + }); + }); + + describe('commit', () => { + it('should update markdown content and create history entry', () => { + const page = PageRecord.create('Test Page', 'page1'); + const shape = ShapeRecord.createMarkdown( + 'page1', + 100, + 200, + { + md: '# Original', + w: 300, + h: 200, + fontSize: 16, + fontFamily: 'sans-serif', + color: '#000' + }, + 'shape1' + ); + + page.shapeIds = ['shape1']; + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } } + })); + + controller.start('shape1'); + controller.current!.value = '# Updated Content'; + controller.commit(); + + expect(controller.isEditing).toBe(false); + expect(mockRefreshCursor).toHaveBeenCalled(); + + const updatedShape = store.getState().doc.shapes['shape1']; + expect(updatedShape).toBeTruthy(); + if (updatedShape?.type === 'markdown') { + expect(updatedShape.props.md).toBe('# Updated Content'); + } + }); + + it('should not update if value is unchanged', () => { + const page = PageRecord.create('Test Page', 'page1'); + const shape = ShapeRecord.createMarkdown( + 'page1', + 100, + 200, + { + md: '# Original', + w: 300, + h: 200, + fontSize: 16, + fontFamily: 'sans-serif', + color: '#000' + }, + 'shape1' + ); + + page.shapeIds = ['shape1']; + const initialState = EditorState.create(); + initialState.doc = { + ...initialState.doc, + pages: { page1: page }, + shapes: { shape1: shape } + }; + store.setState(() => initialState); + const normalizedInitialState = store.getState(); + + controller.start('shape1'); + controller.commit(); + + const finalState = store.getState(); + expect(finalState).toEqual(normalizedInitialState); + }); + + it('should do nothing if not editing', () => { + const initialState = store.getState(); + controller.commit(); + expect(store.getState()).toBe(initialState); + }); + }); + + describe('cancel', () => { + it('should stop editing without saving', () => { + const page = PageRecord.create('Test Page', 'page1'); + const shape = ShapeRecord.createMarkdown( + 'page1', + 100, + 200, + { + md: '# Original', + w: 300, + h: 200, + fontSize: 16, + fontFamily: 'sans-serif', + color: '#000' + }, + 'shape1' + ); + + page.shapeIds = ['shape1']; + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } } + })); + + controller.start('shape1'); + controller.current!.value = '# Modified'; + controller.cancel(); + + expect(controller.isEditing).toBe(false); + expect(mockRefreshCursor).toHaveBeenCalled(); + + const originalShape = store.getState().doc.shapes['shape1']; + if (originalShape?.type === 'markdown') { + expect(originalShape.props.md).toBe('# Original'); + } + }); + }); + + describe('handleBlur', () => { + it('should commit on blur', () => { + const page = PageRecord.create('Test Page', 'page1'); + const shape = ShapeRecord.createMarkdown( + 'page1', + 100, + 200, + { + md: '# Original', + w: 300, + h: 200, + fontSize: 16, + fontFamily: 'sans-serif', + color: '#000' + }, + 'shape1' + ); + + page.shapeIds = ['shape1']; + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { page1: page }, shapes: { shape1: shape } } + })); + + controller.start('shape1'); + controller.current!.value = '# Updated on Blur'; + controller.handleBlur(); + + expect(controller.isEditing).toBe(false); + + const updatedShape = store.getState().doc.shapes['shape1']; + if (updatedShape?.type === 'markdown') { + expect(updatedShape.props.md).toBe('# Updated on Blur'); + } + }); + }); }); diff --git a/apps/web/src/lib/tests/runtime.integration.test.ts b/apps/web/src/lib/tests/runtime.integration.test.ts index 4344400..689df8c 100644 --- a/apps/web/src/lib/tests/runtime.integration.test.ts +++ b/apps/web/src/lib/tests/runtime.integration.test.ts @@ -1,99 +1,109 @@ +import { Action, EditorState, SnapshotCommand, Store, type Tool } from '@inkfinite/core'; import { - Action, - EditorState, - SnapshotCommand, - Store, - type Tool, -} from "@inkfinite/core"; -import { EditorRuntime, type RuntimeTransactionDraft, type SelectionTool } from "@inkfinite/runtime"; -import { describe, expect, it } from "vitest"; + EditorRuntime, + type RuntimeTransactionDraft, + type SelectionTool +} from '@inkfinite/runtime'; +import { describe, expect, it } from 'vitest'; const modifiers = { ctrl: false, shift: false, alt: false, meta: false }; const leftDown = { left: true, middle: false, right: false }; const buttonsUp = { left: false, middle: false, right: false }; class DragTool implements Tool { - readonly id = "select" as const; - private origin: { x: number; y: number } | null = null; + readonly id = 'select' as const; + private origin: { x: number; y: number } | null = null; - onEnter(state: EditorState) { return state; } - onExit(state: EditorState) { return state; } + onEnter(state: EditorState) { + return state; + } + onExit(state: EditorState) { + return state; + } - onAction(state: EditorState, action: import("@inkfinite/core").Action): EditorState { - if (action.type === "pointer-down") { - this.origin = action.world; - return state; - } - if (action.type !== "pointer-move" || !this.origin) return state; - const shape = state.doc.shapes["shape:1"]; - if (!shape) return state; - return { - ...state, - doc: { - ...state.doc, - shapes: { - ...state.doc.shapes, - [shape.id]: { - ...shape, - x: action.world.x - this.origin.x, - y: action.world.y - this.origin.y, - }, - }, - }, - }; - } + onAction(state: EditorState, action: import('@inkfinite/core').Action): EditorState { + if (action.type === 'pointer-down') { + this.origin = action.world; + return state; + } + if (action.type !== 'pointer-move' || !this.origin) return state; + const shape = state.doc.shapes['shape:1']; + if (!shape) return state; + return { + ...state, + doc: { + ...state.doc, + shapes: { + ...state.doc.shapes, + [shape.id]: { + ...shape, + x: action.world.x - this.origin.x, + y: action.world.y - this.origin.y + } + } + } + }; + } } -describe("editor runtime Rust commit boundary", () => { - it("keeps drag movement local, applies one committed patch, redraws, and restores the original on undo", () => { - const initial = EditorState.create(); - initial.doc.pages["page:1"] = { id: "page:1", name: "Page 1", shapeIds: ["shape:1"] }; - initial.doc.shapes["shape:1"] = { - id: "shape:1", - type: "rect", - pageId: "page:1", - x: 0, - y: 0, - rot: 0, - props: { w: 20, h: 20, fill: "#000", stroke: "#000", radius: 0 }, - }; - initial.ui.currentPageId = "page:1"; - initial.ui.selectionIds = ["shape:1"]; - const originalDocument = structuredClone(initial.doc); - const store = new Store(initial); - const tool = new DragTool(); - const drafts: RuntimeTransactionDraft[] = []; - let redraws = 0; - store.subscribe(() => redraws++); +describe('editor runtime Rust commit boundary', () => { + it('keeps drag movement local, applies one committed patch, redraws, and restores the original on undo', () => { + const initial = EditorState.create(); + initial.doc.pages['page:1'] = { id: 'page:1', name: 'Page 1', shapeIds: ['shape:1'] }; + initial.doc.shapes['shape:1'] = { + id: 'shape:1', + type: 'rect', + pageId: 'page:1', + x: 0, + y: 0, + rot: 0, + props: { w: 20, h: 20, fill: '#000', stroke: '#000', radius: 0 } + }; + initial.ui.currentPageId = 'page:1'; + initial.ui.selectionIds = ['shape:1']; + const store = new Store(initial); + const originalDocument = structuredClone(store.getState().doc); + const tool = new DragTool(); + const drafts: RuntimeTransactionDraft[] = []; + let redraws = 0; + store.subscribe(() => redraws++); - const runtime = new EditorRuntime({ - store, - tools: new Map([[tool.id, tool]]), - selectionTool: Object.assign(tool, { - getHandleAtPoint: () => null, - }) satisfies SelectionTool, - getSnapSettings: () => ({ snapEnabled: false, gridEnabled: false, gridSize: 25 }), - onTransactionDraft: (draft) => { - drafts.push(draft); - // The fake Rust boundary accepts the draft and returns its materialized - // document as the patch applied by the frontend adapter. - store.setState(() => draft.before); - store.executeCommand(new SnapshotCommand(draft.name, draft.kind, draft.before, draft.after)); - }, - }); + const runtime = new EditorRuntime({ + store, + tools: new Map([[tool.id, tool]]), + selectionTool: Object.assign(tool, { + getHandleAtPoint: () => null + }) satisfies SelectionTool, + getSnapSettings: () => ({ snapEnabled: false, gridEnabled: false, gridSize: 25 }), + onTransactionDraft: (draft) => { + drafts.push(draft); + // The fake Rust boundary accepts the draft and returns its materialized + // document as the patch applied by the frontend adapter. + store.setState(() => draft.before); + store.executeCommand( + new SnapshotCommand(draft.name, draft.kind, draft.before, draft.after) + ); + } + }); - runtime.handleAction(Action.pointerDown({ x: 0, y: 0 }, { x: 0, y: 0 }, 0, leftDown, modifiers)); - runtime.handleAction(Action.pointerMove({ x: 10, y: 8 }, { x: 10, y: 8 }, leftDown, modifiers)); + runtime.handleAction( + Action.pointerDown({ x: 0, y: 0 }, { x: 0, y: 0 }, 0, leftDown, modifiers) + ); + runtime.handleAction( + Action.pointerMove({ x: 10, y: 8 }, { x: 10, y: 8 }, leftDown, modifiers) + ); - expect(drafts).toHaveLength(0); - expect(store.getState().doc.shapes["shape:1"]?.x).toBe(10); + expect(drafts).toHaveLength(0); + expect(store.getState().doc.shapes['shape:1']?.x).toBe(10); - runtime.handleAction(Action.pointerUp({ x: 10, y: 8 }, { x: 10, y: 8 }, 0, buttonsUp, modifiers)); + runtime.handleAction( + Action.pointerUp({ x: 10, y: 8 }, { x: 10, y: 8 }, 0, buttonsUp, modifiers) + ); - expect(drafts).toHaveLength(1); - expect(store.getState().doc.shapes["shape:1"]?.y).toBe(8); - expect(redraws).toBeGreaterThan(1); - expect(store.undo()).toBe(true); - expect(store.getState().doc).toEqual(originalDocument); - }); + expect(drafts).toHaveLength(1); + expect(store.getState().doc.shapes['shape:1']?.y).toBe(8); + expect(redraws).toBeGreaterThan(1); + expect(store.undo()).toBe(true); + expect(store.getState().doc).toEqual(originalDocument); + }); }); diff --git a/crates/inkfinite-core/src/engine/diff.rs b/crates/inkfinite-core/src/engine/diff.rs new file mode 100644 index 0000000..fb4edd5 --- /dev/null +++ b/crates/inkfinite-core/src/engine/diff.rs @@ -0,0 +1,148 @@ +use super::geometry::{union, world_shape_bounds}; +use super::hierarchy::{containing_layer, descendant_ids_for_layer}; +use super::query::record_id_order; +use super::{AffectedRegion, BTreeMap, BTreeSet, Bounds, Document, DocumentPatch, PageId, RecordId, ShapeId}; + +pub fn diff_documents(before: &Document, after: &Document) -> (DocumentPatch, Vec) { + let mut created = Vec::new(); + let mut changed = Vec::new(); + let mut deleted = Vec::new(); + diff_map( + &before.pages, + &after.pages, + RecordId::Page, + &mut created, + &mut changed, + &mut deleted, + ); + diff_map( + &before.layers, + &after.layers, + RecordId::Layer, + &mut created, + &mut changed, + &mut deleted, + ); + diff_map( + &before.shapes, + &after.shapes, + RecordId::Shape, + &mut created, + &mut changed, + &mut deleted, + ); + diff_map( + &before.bindings, + &after.bindings, + RecordId::Binding, + &mut created, + &mut changed, + &mut deleted, + ); + diff_map( + &before.assets, + &after.assets, + RecordId::Asset, + &mut created, + &mut changed, + &mut deleted, + ); + let mut affected = created + .iter() + .chain(&changed) + .chain(&deleted) + .cloned() + .collect::>(); + affected.sort_by(record_id_order); + (DocumentPatch { created, changed, deleted }, affected) +} + +pub fn diff_map( + before: &BTreeMap, after: &BTreeMap, wrap: F, created: &mut Vec, changed: &mut Vec, + deleted: &mut Vec, +) where + K: Ord + Clone, + V: PartialEq, + F: Fn(K) -> RecordId, +{ + for (id, value) in after { + match before.get(id) { + None => created.push(wrap(id.clone())), + Some(old) if old != value => changed.push(wrap(id.clone())), + Some(_) => {} + } + } + for id in before.keys() { + if !after.contains_key(id) { + deleted.push(wrap(id.clone())); + } + } +} + +pub fn affected_regions(before: &Document, after: &Document, ids: &[RecordId]) -> Vec { + let mut regions: BTreeMap = BTreeMap::new(); + for id in ids { + let mut shape_ids = visual_shape_ids(before, id); + shape_ids.extend(visual_shape_ids(after, id)); + for shape_id in shape_ids { + for document in [before, after] { + let Some(shape) = document.shapes.get(&shape_id) else { + continue; + }; + let Some(page_id) = containing_layer(document, shape).map(|layer| layer.page_id.clone()) else { + continue; + }; + let bounds = world_shape_bounds(document, &shape_id); + regions + .entry(page_id) + .and_modify(|current| *current = union(*current, bounds)) + .or_insert(bounds); + } + } + } + regions + .into_iter() + .map(|(page_id, bounds)| AffectedRegion { page_id, bounds }) + .collect() +} + +pub fn visual_shape_ids(document: &Document, id: &RecordId) -> BTreeSet { + match id { + RecordId::Shape(shape_id) => document + .shapes + .contains_key(shape_id) + .then(|| shape_id.clone()) + .into_iter() + .collect(), + RecordId::Layer(layer_id) => descendant_ids_for_layer(document, layer_id).collect(), + RecordId::Page(page_id) => document + .pages + .get(page_id) + .into_iter() + .flat_map(|page| &page.layer_ids) + .flat_map(|layer_id| descendant_ids_for_layer(document, layer_id)) + .collect(), + RecordId::Binding(binding_id) => document + .bindings + .get(binding_id) + .into_iter() + .flat_map(|binding| [binding.source_shape_id.clone(), binding.target_shape_id.clone()]) + .collect(), + RecordId::Asset(_) => BTreeSet::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn page_rename_is_reported_as_one_changed_record() { + let before = crate::engine::tests::document(); + let mut after = before.clone(); + after.pages.get_mut(&PageId::from("page:one")).unwrap().name = "Renamed".into(); + let (patch, affected) = diff_documents(&before, &after); + assert_eq!(affected, vec![RecordId::Page(PageId::from("page:one"))]); + assert_eq!(patch.changed, affected); + } +} diff --git a/crates/inkfinite-core/src/engine/error.rs b/crates/inkfinite-core/src/engine/error.rs new file mode 100644 index 0000000..f5ba562 --- /dev/null +++ b/crates/inkfinite-core/src/engine/error.rs @@ -0,0 +1,43 @@ +use super::{ActorId, CrdtError, Error}; + +/// Recoverable rejection from transaction, merge, validation, or query processing. +#[derive(Debug, Error)] +pub enum EngineError { + /// The CRDT adapter could not complete an operation. + #[error(transparent)] + Crdt(#[from] CrdtError), + /// The transaction's serialized contract is structurally invalid. + #[error("schema validation failed: {0}")] + Schema(String), + /// The caller inspected different causal heads than the current document. + #[error("stale document heads")] + StaleHeads, + /// A record-version or existence precondition failed. + #[error("precondition failed: {0}")] + Precondition(String), + /// The actor is not allowed to perform the operation. + #[error("permission denied: {0}")] + Permission(String), + /// Applying the operation would violate the document model. + #[error("document invariant failed: {0}")] + Invariant(String), + /// No eligible actor-scoped history entry exists. + #[error("no {action} history exists for actor {actor_id}")] + EmptyHistory { + /// Requested history action. + action: &'static str, + /// Actor whose history was inspected. + actor_id: ActorId, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn errors_retain_actionable_context() { + let error = EngineError::Precondition("layer layer:one is missing".into()); + assert_eq!(error.to_string(), "precondition failed: layer layer:one is missing"); + } +} diff --git a/crates/inkfinite-core/src/engine/geometry.rs b/crates/inkfinite-core/src/engine/geometry.rs new file mode 100644 index 0000000..bc193d6 --- /dev/null +++ b/crates/inkfinite-core/src/engine/geometry.rs @@ -0,0 +1,102 @@ +use super::{Bounds, Document, EngineError, ShapeId, ShapeParent, ShapeRecord}; + +pub fn local_shape_bounds(shape: &ShapeRecord) -> Bounds { + let width = numeric_property(shape, "width").unwrap_or(0.0).abs(); + let height = numeric_property(shape, "height").unwrap_or(0.0).abs(); + transformed_bounds(width, height, shape.transform) +} + +pub fn world_shape_bounds(document: &Document, shape_id: &ShapeId) -> Bounds { + let Some(shape) = document.shapes.get(shape_id) else { + return Bounds { x: 0.0, y: 0.0, width: 0.0, height: 0.0 }; + }; + let mut bounds = local_shape_bounds(shape); + let mut parent = shape.parent.clone(); + while let ShapeParent::Shape(parent_id) = parent { + let Some(parent_shape) = document.shapes.get(&parent_id) else { + break; + }; + bounds.x += parent_shape.transform.translation.x; + bounds.y += parent_shape.transform.translation.y; + parent = parent_shape.parent.clone(); + } + bounds +} + +pub fn transformed_bounds(width: f64, height: f64, transform: crate::Transform) -> Bounds { + let cos = transform.rotation.cos(); + let sin = transform.rotation.sin(); + let points = [(0.0, 0.0), (width, 0.0), (0.0, height), (width, height)].map(|(x, y)| { + let x = x * transform.scale_x; + let y = y * transform.scale_y; + ( + transform.translation.x + x * cos - y * sin, + transform.translation.y + x * sin + y * cos, + ) + }); + let min_x = points.iter().map(|p| p.0).fold(f64::INFINITY, f64::min); + let max_x = points.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max); + let min_y = points.iter().map(|p| p.1).fold(f64::INFINITY, f64::min); + let max_y = points.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max); + Bounds { x: min_x, y: min_y, width: max_x - min_x, height: max_y - min_y } +} + +pub fn numeric_property(shape: &ShapeRecord, name: &str) -> Option { + shape + .properties + .get(name) + .and_then(serde_json::Value::as_f64) + .filter(|value| value.is_finite()) +} + +pub fn count_as_f64(count: usize) -> Result { + let count = u32::try_from(count).map_err(|_| EngineError::Invariant("layout selection is too large".into()))?; + Ok(f64::from(count)) +} + +// FIXME: make these instance methods on Bounds +pub fn center_x(bounds: &Bounds) -> f64 { + bounds.x + bounds.width / 2.0 +} + +pub fn center_y(bounds: &Bounds) -> f64 { + bounds.y + bounds.height / 2.0 +} + +pub fn right(bounds: &Bounds) -> f64 { + bounds.x + bounds.width +} + +pub fn bottom(bounds: &Bounds) -> f64 { + bounds.y + bounds.height +} + +pub fn intersects(left: &Bounds, right_bounds: &Bounds) -> bool { + left.x <= right(right_bounds) + && right(left) >= right_bounds.x + && left.y <= bottom(right_bounds) + && bottom(left) >= right_bounds.y +} + +pub fn union(left: Bounds, right_bounds: Bounds) -> Bounds { + let x = left.x.min(right_bounds.x); + let y = left.y.min(right_bounds.y); + Bounds { + x, + y, + width: right(&left).max(right(&right_bounds)) - x, + height: bottom(&left).max(bottom(&right_bounds)) - y, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn union_contains_both_input_regions() { + let left = Bounds { x: 0.0, y: 2.0, width: 4.0, height: 3.0 }; + let right = Bounds { x: 3.0, y: 0.0, width: 5.0, height: 4.0 }; + assert_eq!(union(left, right), Bounds { x: 0.0, y: 0.0, width: 8.0, height: 5.0 }); + } +} diff --git a/crates/inkfinite-core/src/engine/hierarchy.rs b/crates/inkfinite-core/src/engine/hierarchy.rs new file mode 100644 index 0000000..d662d72 --- /dev/null +++ b/crates/inkfinite-core/src/engine/hierarchy.rs @@ -0,0 +1,360 @@ +use super::{ + AssetId, BTreeSet, BindingId, ChangeHash, Document, EngineError, LayerId, Operation, PageId, RecordId, + RecordVersion, ShapeId, ShapeParent, ShapeRecord, SiblingAnchor, Warning, +}; + +pub fn ensure_absent(exists: bool, name: &str, id: &Id) -> Result<(), EngineError> { + if exists { Err(EngineError::Precondition(format!("{name} {id} already exists"))) } else { Ok(()) } +} + +pub fn ensure_version_one(version: RecordVersion, context: &str) -> Result<(), EngineError> { + if version == RecordVersion(1) { + Ok(()) + } else { + Err(EngineError::Schema(format!("{context} must start at record version 1"))) + } +} + +pub fn next_version(version: RecordVersion) -> Result { + version + .0 + .checked_add(1) + .map(RecordVersion) + .ok_or_else(|| EngineError::Invariant("record version overflow".into())) +} + +pub fn check_version(actual: RecordVersion, expected: Option, name: &str) -> Result<(), EngineError> { + if expected.is_some_and(|value| value != actual) { + Err(EngineError::Precondition(format!("{name} version is stale"))) + } else { + Ok(()) + } +} + +pub fn page<'a>( + document: &'a Document, id: &PageId, expected: Option, +) -> Result<&'a crate::PageRecord, EngineError> { + let value = document + .pages + .get(id) + .ok_or_else(|| EngineError::Precondition(format!("page {id} is missing")))?; + check_version(value.version, expected, "page")?; + Ok(value) +} + +pub fn page_mut<'a>( + document: &'a mut Document, id: &PageId, expected: Option, +) -> Result<&'a mut crate::PageRecord, EngineError> { + let value = document + .pages + .get_mut(id) + .ok_or_else(|| EngineError::Precondition(format!("page {id} is missing")))?; + check_version(value.version, expected, "page")?; + Ok(value) +} + +pub fn layer<'a>( + document: &'a Document, id: &LayerId, expected: Option, +) -> Result<&'a crate::LayerRecord, EngineError> { + let value = document + .layers + .get(id) + .ok_or_else(|| EngineError::Precondition(format!("layer {id} is missing")))?; + check_version(value.version, expected, "layer")?; + Ok(value) +} + +pub fn layer_mut<'a>( + document: &'a mut Document, id: &LayerId, expected: Option, +) -> Result<&'a mut crate::LayerRecord, EngineError> { + let value = document + .layers + .get_mut(id) + .ok_or_else(|| EngineError::Precondition(format!("layer {id} is missing")))?; + check_version(value.version, expected, "layer")?; + Ok(value) +} + +pub fn shape<'a>( + document: &'a Document, id: &ShapeId, expected: Option, +) -> Result<&'a ShapeRecord, EngineError> { + let value = document + .shapes + .get(id) + .ok_or_else(|| EngineError::Precondition(format!("shape {id} is missing")))?; + check_version(value.version, expected, "shape")?; + Ok(value) +} + +pub fn shape_mut<'a>( + document: &'a mut Document, id: &ShapeId, expected: Option, +) -> Result<&'a mut ShapeRecord, EngineError> { + let value = document + .shapes + .get_mut(id) + .ok_or_else(|| EngineError::Precondition(format!("shape {id} is missing")))?; + check_version(value.version, expected, "shape")?; + Ok(value) +} + +pub fn binding<'a>( + document: &'a Document, id: &BindingId, expected: Option, +) -> Result<&'a crate::BindingRecord, EngineError> { + let value = document + .bindings + .get(id) + .ok_or_else(|| EngineError::Precondition(format!("binding {id} is missing")))?; + check_version(value.version, expected, "binding")?; + Ok(value) +} + +pub fn asset<'a>( + document: &'a Document, id: &AssetId, expected: Option, +) -> Result<&'a crate::AssetRecord, EngineError> { + let value = document + .assets + .get(id) + .ok_or_else(|| EngineError::Precondition(format!("asset {id} is missing")))?; + check_version(value.version, expected, "asset")?; + Ok(value) +} + +pub fn asset_mut<'a>( + document: &'a mut Document, id: &AssetId, expected: Option, +) -> Result<&'a mut crate::AssetRecord, EngineError> { + let value = document + .assets + .get_mut(id) + .ok_or_else(|| EngineError::Precondition(format!("asset {id} is missing")))?; + check_version(value.version, expected, "asset")?; + Ok(value) +} + +pub fn insert_anchored( + items: &mut Vec, id: Id, anchor: &SiblingAnchor, +) -> Result<(), EngineError> { + if items.contains(&id) { + return Err(EngineError::Precondition(format!("ordered item {id} already exists"))); + } + let index = anchor_index(items, anchor)?; + items.insert(index, id); + Ok(()) +} + +pub fn move_anchored( + items: &mut Vec, id: &Id, anchor: &SiblingAnchor, +) -> Result<(), EngineError> { + let position = items + .iter() + .position(|item| item == id) + .ok_or_else(|| EngineError::Invariant(format!("ordered item {id} is missing")))?; + let item = items.remove(position); + let index = anchor_index(items, anchor)?; + items.insert(index, item); + Ok(()) +} + +pub fn anchor_index( + items: &[Id], anchor: &SiblingAnchor, +) -> Result { + match anchor { + SiblingAnchor::First => Ok(0), + SiblingAnchor::Last => Ok(items.len()), + SiblingAnchor::Before(id) => items + .iter() + .position(|item| item == id) + .ok_or_else(|| EngineError::Precondition(format!("anchor sibling {id} is missing"))), + SiblingAnchor::After(id) => items + .iter() + .position(|item| item == id) + .map(|index| index + 1) + .ok_or_else(|| EngineError::Precondition(format!("anchor sibling {id} is missing"))), + } +} + +pub fn anchor_for(items: &[Id], id: &Id) -> Result, EngineError> { + let index = items + .iter() + .position(|item| item == id) + .ok_or_else(|| EngineError::Invariant(format!("ordered item {id} is missing")))?; + Ok(if index == 0 { SiblingAnchor::First } else { SiblingAnchor::After(items[index - 1].clone()) }) +} + +pub fn shape_siblings<'a>(document: &'a Document, parent: &ShapeParent) -> Result<&'a Vec, EngineError> { + match parent { + ShapeParent::Layer(id) => document + .layers + .get(id) + .map(|layer| &layer.shape_ids) + .ok_or_else(|| EngineError::Precondition(format!("parent layer {id} is missing"))), + ShapeParent::Shape(id) => document + .shapes + .get(id) + .map(|shape| &shape.child_ids) + .ok_or_else(|| EngineError::Precondition(format!("parent shape {id} is missing"))), + } +} + +pub fn insert_shape_child( + document: &mut Document, parent: &ShapeParent, id: ShapeId, anchor: &SiblingAnchor, +) -> Result<(), EngineError> { + match parent { + ShapeParent::Layer(parent_id) => { + let layer = document + .layers + .get_mut(parent_id) + .ok_or_else(|| EngineError::Precondition(format!("parent layer {parent_id} is missing")))?; + insert_anchored(&mut layer.shape_ids, id, anchor)?; + layer.version = next_version(layer.version)?; + } + ShapeParent::Shape(parent_id) => { + let shape = document + .shapes + .get_mut(parent_id) + .ok_or_else(|| EngineError::Precondition(format!("parent shape {parent_id} is missing")))?; + insert_anchored(&mut shape.child_ids, id, anchor)?; + shape.version = next_version(shape.version)?; + } + } + Ok(()) +} + +pub fn remove_shape_child(document: &mut Document, parent: &ShapeParent, id: &ShapeId) -> Result<(), EngineError> { + match parent { + ShapeParent::Layer(parent_id) => { + let layer = document + .layers + .get_mut(parent_id) + .ok_or_else(|| EngineError::Invariant(format!("parent layer {parent_id} is missing")))?; + layer.shape_ids.retain(|child| child != id); + layer.version = next_version(layer.version)?; + } + ShapeParent::Shape(parent_id) => { + let shape = document + .shapes + .get_mut(parent_id) + .ok_or_else(|| EngineError::Invariant(format!("parent shape {parent_id} is missing")))?; + shape.child_ids.retain(|child| child != id); + shape.version = next_version(shape.version)?; + } + } + Ok(()) +} + +pub fn containing_layer<'a>(document: &'a Document, shape: &ShapeRecord) -> Option<&'a crate::LayerRecord> { + let mut parent = shape.parent.clone(); + loop { + match parent { + ShapeParent::Layer(id) => return document.layers.get(&id), + ShapeParent::Shape(id) => parent = document.shapes.get(&id)?.parent.clone(), + } + } +} + +pub fn is_descendant(document: &Document, shape_id: &ShapeId, parent: &ShapeParent) -> bool { + let ShapeParent::Shape(mut current) = parent.clone() else { + return false; + }; + loop { + if ¤t == shape_id { + return true; + } + let Some(shape) = document.shapes.get(¤t) else { + return false; + }; + match &shape.parent { + ShapeParent::Shape(next) => current = next.clone(), + ShapeParent::Layer(_) => return false, + } + } +} + +pub fn descendant_ids_for_layer<'a>( + document: &'a Document, layer_id: &'a LayerId, +) -> impl Iterator + 'a { + document + .layers + .get(layer_id) + .into_iter() + .flat_map(|layer| layer.shape_ids.iter()) + .flat_map(|id| std::iter::once(id.clone()).chain(descendant_ids_for_shape(document, id))) +} + +pub fn descendant_ids_for_shape<'a>( + document: &'a Document, shape_id: &'a ShapeId, +) -> Box + 'a> { + Box::new( + document + .shapes + .get(shape_id) + .into_iter() + .flat_map(|shape| shape.child_ids.iter()) + .flat_map(|id| std::iter::once(id.clone()).chain(descendant_ids_for_shape(document, id))), + ) +} + +pub fn bindings_touching(document: &Document, shapes: &BTreeSet) -> Vec { + document + .bindings + .values() + .filter(|binding| shapes.contains(&binding.source_shape_id) || shapes.contains(&binding.target_shape_id)) + .map(|binding| binding.id.clone()) + .collect() +} + +pub fn asset_is_referenced(document: &Document, asset_id: &AssetId) -> bool { + document.shapes.values().any(|shape| { + shape + .properties + .values() + .any(|value| value.as_str() == Some(asset_id.as_str())) + }) +} + +pub fn operation_shape_ids(operation: &Operation) -> Vec { + match operation { + Operation::PatchShape { shape_id, .. } + | Operation::ReparentShape { shape_id, .. } + | Operation::DeleteShape { shape_id, .. } => vec![shape_id.clone()], + Operation::CreateBinding { binding } => vec![binding.source_shape_id.clone(), binding.target_shape_id.clone()], + Operation::AlignShapes { shape_ids, .. } | Operation::DistributeShapes { shape_ids, .. } => shape_ids.clone(), + _ => Vec::new(), + } +} + +pub fn operation_layer_id(operation: &Operation) -> Option { + match operation { + Operation::PatchLayer { layer_id, .. } + | Operation::ReorderLayer { layer_id, .. } + | Operation::DeleteLayer { layer_id, .. } => Some(layer_id.clone()), + Operation::CreateShape { shape, .. } => match &shape.parent { + ShapeParent::Layer(id) => Some(id.clone()), + ShapeParent::Shape(_) => None, + }, + Operation::ReparentShape { parent: ShapeParent::Layer(id), .. } => Some(id.clone()), + _ => None, + } +} + +pub fn canonical_heads(heads: &[ChangeHash]) -> BTreeSet { + heads.iter().cloned().collect() +} + +pub fn warning(code: &str, message: String, record_ids: Vec) -> Warning { + Warning { code: code.into(), message, record_ids } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sibling_anchors_resolve_without_numeric_positions_in_the_contract() { + let ids = vec![ShapeId::from("a"), ShapeId::from("b")]; + assert_eq!( + anchor_index(&ids, &SiblingAnchor::Before(ShapeId::from("b"))).unwrap(), + 1 + ); + assert_eq!(anchor_index(&ids, &SiblingAnchor::Last).unwrap(), 2); + } +} diff --git a/crates/inkfinite-core/src/engine/history.rs b/crates/inkfinite-core/src/engine/history.rs new file mode 100644 index 0000000..6f185a5 --- /dev/null +++ b/crates/inkfinite-core/src/engine/history.rs @@ -0,0 +1,502 @@ +use super::{ + AssetId, BTreeMap, BTreeSet, BindingId, Document, EngineError, LayerId, Operation, PageId, ShapeId, ShapePatch, + ShapeProperties, ShapeRecord, +}; + +#[derive(Clone)] +pub struct HistoryEntry { + pub operations: Vec, + pub expected: ExpectedRecords, +} + +#[derive(Clone, Default)] +pub struct ExpectedRecords { + pages: BTreeMap, + layers: BTreeMap, + shapes: BTreeMap, + bindings: BTreeMap, + assets: BTreeMap, +} + +pub fn refresh_inverse_preconditions(operations: &mut [Operation], document: &Document) { + for operation in operations { + match operation { + Operation::RenamePage { page_id, expected_version, .. } + | Operation::DeletePage { page_id, expected_version } => { + *expected_version = document.pages.get(page_id).map(|record| record.version); + } + Operation::PatchLayer { layer_id, expected_version, .. } + | Operation::ReorderLayer { layer_id, expected_version, .. } + | Operation::DeleteLayer { layer_id, expected_version, .. } => { + *expected_version = document.layers.get(layer_id).map(|record| record.version); + } + Operation::PatchShape { shape_id, expected_version, .. } + | Operation::ReparentShape { shape_id, expected_version, .. } + | Operation::DeleteShape { shape_id, expected_version } => { + *expected_version = document.shapes.get(shape_id).map(|record| record.version); + } + Operation::DeleteBinding { binding_id, expected_version } => { + *expected_version = document.bindings.get(binding_id).map(|record| record.version); + } + Operation::PatchAsset { asset_id, expected_version, .. } + | Operation::DeleteAsset { asset_id, expected_version } => { + *expected_version = document.assets.get(asset_id).map(|record| record.version); + } + Operation::AlignShapes { shape_ids, expected_versions, .. } + | Operation::DistributeShapes { shape_ids, expected_versions, .. } => { + expected_versions.clear(); + expected_versions.extend(shape_ids.iter().filter_map(|shape_id| { + document + .shapes + .get(shape_id) + .map(|record| (shape_id.clone(), record.version)) + })); + } + Operation::CreatePage { .. } + | Operation::CreateLayer { .. } + | Operation::CreateShape { .. } + | Operation::CreateBinding { .. } + | Operation::CreateAsset { .. } => {} + } + } +} + +pub fn capture_expected_records(operations: &[Operation], document: &Document) -> ExpectedRecords { + let mut expected = ExpectedRecords::default(); + for operation in operations { + match operation { + Operation::RenamePage { page_id, .. } | Operation::DeletePage { page_id, .. } => { + if let Some(record) = document.pages.get(page_id) { + expected.pages.insert(page_id.clone(), record.clone()); + } + } + Operation::PatchLayer { layer_id, .. } + | Operation::ReorderLayer { layer_id, .. } + | Operation::DeleteLayer { layer_id, .. } => { + if let Some(record) = document.layers.get(layer_id) { + expected.layers.insert(layer_id.clone(), record.clone()); + } + } + Operation::PatchShape { shape_id, .. } + | Operation::ReparentShape { shape_id, .. } + | Operation::DeleteShape { shape_id, .. } => { + if let Some(record) = document.shapes.get(shape_id) { + expected.shapes.insert(shape_id.clone(), record.clone()); + } + } + Operation::DeleteBinding { binding_id, .. } => { + if let Some(record) = document.bindings.get(binding_id) { + expected.bindings.insert(binding_id.clone(), record.clone()); + } + } + Operation::PatchAsset { asset_id, .. } | Operation::DeleteAsset { asset_id, .. } => { + if let Some(record) = document.assets.get(asset_id) { + expected.assets.insert(asset_id.clone(), record.clone()); + } + } + Operation::AlignShapes { shape_ids, .. } | Operation::DistributeShapes { shape_ids, .. } => { + for shape_id in shape_ids { + if let Some(record) = document.shapes.get(shape_id) { + expected.shapes.insert(shape_id.clone(), record.clone()); + } + } + } + Operation::CreatePage { .. } + | Operation::CreateLayer { .. } + | Operation::CreateShape { .. } + | Operation::CreateBinding { .. } + | Operation::CreateAsset { .. } => {} + } + } + expected +} + +#[allow(clippy::too_many_lines)] +pub fn prepare_compensation(entry: &HistoryEntry, current: &Document) -> Result, EngineError> { + let mut operations = entry.operations.clone(); + for operation in &mut operations { + match operation { + Operation::RenamePage { page_id, name, expected_version } => { + let expected = entry + .expected + .pages + .get(page_id) + .ok_or_else(|| history_conflict(format!("page {page_id} no longer has the expected state")))?; + let current = current + .pages + .get(page_id) + .ok_or_else(|| history_conflict(format!("page {page_id} was removed concurrently")))?; + *name = merge_history_value(name, &expected.name, ¤t.name, "page name")?; + *expected_version = None; + } + Operation::PatchLayer { layer_id, patch, expected_version } => { + let expected = + entry.expected.layers.get(layer_id).ok_or_else(|| { + history_conflict(format!("layer {layer_id} no longer has the expected state")) + })?; + let current = current + .layers + .get(layer_id) + .ok_or_else(|| history_conflict(format!("layer {layer_id} was removed concurrently")))?; + if let Some(before) = &patch.name { + patch.name = Some(merge_history_value( + before, + &expected.name, + ¤t.name, + "layer name", + )?); + } + if let Some(before) = patch.visible { + patch.visible = Some(merge_history_value( + &before, + &expected.visible, + ¤t.visible, + "layer visibility", + )?); + } + if let Some(before) = patch.locked { + patch.locked = Some(merge_history_value( + &before, + &expected.locked, + ¤t.locked, + "layer lock", + )?); + } + if let Some(before) = patch.opacity { + patch.opacity = Some(merge_history_value( + &before, + &expected.opacity, + ¤t.opacity, + "layer opacity", + )?); + } + *expected_version = None; + } + Operation::PatchShape { shape_id, patch, expected_version } => { + let expected = + entry.expected.shapes.get(shape_id).ok_or_else(|| { + history_conflict(format!("shape {shape_id} no longer has the expected state")) + })?; + let current = current + .shapes + .get(shape_id) + .ok_or_else(|| history_conflict(format!("shape {shape_id} was removed concurrently")))?; + merge_shape_compensation(patch, expected, current)?; + *expected_version = None; + } + Operation::ReparentShape { shape_id, parent, expected_version, .. } => { + let expected = + entry.expected.shapes.get(shape_id).ok_or_else(|| { + history_conflict(format!("shape {shape_id} no longer has the expected state")) + })?; + let current = current + .shapes + .get(shape_id) + .ok_or_else(|| history_conflict(format!("shape {shape_id} was removed concurrently")))?; + *parent = merge_history_value(parent, &expected.parent, ¤t.parent, "shape parent")?; + *expected_version = None; + } + Operation::PatchAsset { asset_id, patch, expected_version } => { + let expected = + entry.expected.assets.get(asset_id).ok_or_else(|| { + history_conflict(format!("asset {asset_id} no longer has the expected state")) + })?; + let current = current + .assets + .get(asset_id) + .ok_or_else(|| history_conflict(format!("asset {asset_id} was removed concurrently")))?; + if let Some(before) = &patch.name { + patch.name = Some(merge_history_value( + before, + &expected.name, + ¤t.name, + "asset name", + )?); + } + if let Some(before) = &patch.provenance_source { + patch.provenance_source = Some(merge_history_value( + before, + &expected.provenance.source, + ¤t.provenance.source, + "asset provenance source", + )?); + } + *expected_version = None; + } + Operation::DeletePage { page_id, expected_version } => { + guard_existing_record( + entry.expected.pages.get(page_id), + current.pages.get(page_id), + "page", + page_id, + )?; + *expected_version = None; + } + Operation::DeleteLayer { layer_id, expected_version, .. } + | Operation::ReorderLayer { layer_id, expected_version, .. } => { + guard_existing_record( + entry.expected.layers.get(layer_id), + current.layers.get(layer_id), + "layer", + layer_id, + )?; + *expected_version = None; + } + Operation::DeleteShape { shape_id, expected_version } => { + guard_existing_record( + entry.expected.shapes.get(shape_id), + current.shapes.get(shape_id), + "shape", + shape_id, + )?; + *expected_version = None; + } + Operation::DeleteBinding { binding_id, expected_version } => { + guard_existing_record( + entry.expected.bindings.get(binding_id), + current.bindings.get(binding_id), + "binding", + binding_id, + )?; + *expected_version = None; + } + Operation::DeleteAsset { asset_id, expected_version } => { + guard_existing_record( + entry.expected.assets.get(asset_id), + current.assets.get(asset_id), + "asset", + asset_id, + )?; + *expected_version = None; + } + Operation::CreatePage { page, .. } => { + guard_absent_record(&entry.expected.pages, ¤t.pages, &page.id, "page")?; + } + Operation::CreateLayer { layer, .. } => { + guard_absent_record(&entry.expected.layers, ¤t.layers, &layer.id, "layer")?; + } + Operation::CreateShape { shape, .. } => { + guard_absent_record(&entry.expected.shapes, ¤t.shapes, &shape.id, "shape")?; + } + Operation::CreateBinding { binding } => { + guard_absent_record(&entry.expected.bindings, ¤t.bindings, &binding.id, "binding")?; + } + Operation::CreateAsset { asset } => { + guard_absent_record(&entry.expected.assets, ¤t.assets, &asset.id, "asset")?; + } + Operation::AlignShapes { .. } | Operation::DistributeShapes { .. } => { + return Err(history_conflict( + "history contains an unsupported aggregate layout operation", + )); + } + } + } + Ok(operations) +} + +#[allow(clippy::too_many_lines)] +pub fn merge_shape_compensation( + patch: &mut ShapePatch, expected: &ShapeRecord, current: &ShapeRecord, +) -> Result<(), EngineError> { + if let Some(before) = patch.transform { + patch.transform = Some(crate::Transform { + translation: crate::Vec2 { + x: merge_history_value( + &before.translation.x, + &expected.transform.translation.x, + ¤t.transform.translation.x, + "shape translation x", + )?, + y: merge_history_value( + &before.translation.y, + &expected.transform.translation.y, + ¤t.transform.translation.y, + "shape translation y", + )?, + }, + rotation: merge_history_value( + &before.rotation, + &expected.transform.rotation, + ¤t.transform.rotation, + "shape rotation", + )?, + scale_x: merge_history_value( + &before.scale_x, + &expected.transform.scale_x, + ¤t.transform.scale_x, + "shape horizontal scale", + )?, + scale_y: merge_history_value( + &before.scale_y, + &expected.transform.scale_y, + ¤t.transform.scale_y, + "shape vertical scale", + )?, + }); + } + if let Some(before) = &patch.properties { + patch.properties = Some(merge_history_map( + before, + &expected.properties, + ¤t.properties, + "shape property", + )?); + } + if let Some(before) = &patch.metadata { + let mut merged = current.metadata.clone(); + merged.name = merge_history_value( + &before.name, + &expected.metadata.name, + ¤t.metadata.name, + "shape name", + )?; + merged.role = merge_history_value( + &before.role, + &expected.metadata.role, + ¤t.metadata.role, + "shape role", + )?; + merged.description = merge_history_value( + &before.description, + &expected.metadata.description, + ¤t.metadata.description, + "shape description", + )?; + merged.tags = merge_history_value( + &before.tags, + &expected.metadata.tags, + ¤t.metadata.tags, + "shape tags", + )?; + merged.locked = merge_history_value( + &before.locked, + &expected.metadata.locked, + ¤t.metadata.locked, + "shape lock", + )?; + merged.agent_editable = merge_history_value( + &before.agent_editable, + &expected.metadata.agent_editable, + ¤t.metadata.agent_editable, + "shape agent permission", + )?; + merged.provenance = merge_history_value( + &before.provenance, + &expected.metadata.provenance, + ¤t.metadata.provenance, + "shape provenance", + )?; + patch.metadata = Some(merged); + } + if let Some(before) = patch.style { + patch.style = Some(crate::ShapeStyle { + opacity: merge_history_value( + &before.opacity, + &expected.style.opacity, + ¤t.style.opacity, + "shape opacity", + )?, + fill_opacity: merge_history_value( + &before.fill_opacity, + &expected.style.fill_opacity, + ¤t.style.fill_opacity, + "shape fill opacity", + )?, + stroke_opacity: merge_history_value( + &before.stroke_opacity, + &expected.style.stroke_opacity, + ¤t.style.stroke_opacity, + "shape stroke opacity", + )?, + }); + } + if let Some(before) = &patch.layout { + patch.layout = Some(merge_history_value( + before, + &expected.layout, + ¤t.layout, + "shape layout", + )?); + } + Ok(()) +} + +pub fn merge_history_map( + before: &ShapeProperties, expected: &ShapeProperties, current: &ShapeProperties, label: &str, +) -> Result { + let keys: BTreeSet<_> = before + .keys() + .chain(expected.keys()) + .chain(current.keys()) + .cloned() + .collect(); + let mut merged = current.clone(); + for key in keys { + if before.get(&key) == expected.get(&key) { + continue; + } + if current.get(&key) != expected.get(&key) { + return Err(history_conflict(format!("{label} {key} changed concurrently"))); + } + if let Some(value) = before.get(&key) { + merged.insert(key, value.clone()); + } else { + merged.remove(&key); + } + } + Ok(merged) +} + +pub fn merge_history_value( + before: &T, expected: &T, current: &T, label: &str, +) -> Result { + if before == expected { + return Ok(current.clone()); + } + if current == expected { + return Ok(before.clone()); + } + Err(history_conflict(format!("{label} changed concurrently"))) +} + +pub fn guard_existing_record( + expected: Option<&T>, current: Option<&T>, kind: &str, id: &Id, +) -> Result<(), EngineError> { + if expected.is_some() && expected == current { + Ok(()) + } else { + Err(history_conflict(format!("{kind} {id} changed concurrently"))) + } +} + +pub fn guard_absent_record( + expected: &BTreeMap, current: &BTreeMap, id: &Id, kind: &str, +) -> Result<(), EngineError> { + if !expected.contains_key(id) && !current.contains_key(id) { + Ok(()) + } else { + Err(history_conflict(format!("{kind} {id} was recreated concurrently"))) + } +} + +pub fn history_conflict(message: impl Into) -> EngineError { + EngineError::Precondition(format!("history conflict: {}", message.into())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compensation_keeps_an_unrelated_current_value() { + assert_eq!(merge_history_value(&1, &1, &2, "value").unwrap(), 2); + } + + #[test] + fn compensation_rejects_two_edits_to_the_same_value() { + assert!(matches!( + merge_history_value(&1, &2, &3, "value"), + Err(EngineError::Precondition(message)) if message.contains("history conflict") + )); + } +} diff --git a/crates/inkfinite-core/src/engine/mod.rs b/crates/inkfinite-core/src/engine/mod.rs index df481f5..b20a3a9 100644 --- a/crates/inkfinite-core/src/engine/mod.rs +++ b/crates/inkfinite-core/src/engine/mod.rs @@ -20,51 +20,27 @@ pub use crate::DocumentSnapshot; pub use crate::crdt::CrdtDocument; pub use crate::proto::{CommitResult, TransactionDraft}; -/// Recoverable rejection from transaction, merge, validation, or query processing. -#[derive(Debug, Error)] -pub enum EngineError { - /// The CRDT adapter could not complete an operation. - #[error(transparent)] - Crdt(#[from] CrdtError), - /// The transaction's serialized contract is structurally invalid. - #[error("schema validation failed: {0}")] - Schema(String), - /// The caller inspected different causal heads than the current document. - #[error("stale document heads")] - StaleHeads, - /// A record-version or existence precondition failed. - #[error("precondition failed: {0}")] - Precondition(String), - /// The actor is not allowed to perform the operation. - #[error("permission denied: {0}")] - Permission(String), - /// Applying the operation would violate the document model. - #[error("document invariant failed: {0}")] - Invariant(String), - /// No eligible actor-scoped history entry exists. - #[error("no {action} history exists for actor {actor_id}")] - EmptyHistory { - /// Requested history action. - action: &'static str, - /// Actor whose history was inspected. - actor_id: ActorId, - }, -} - -#[derive(Clone)] -struct HistoryEntry { - operations: Vec, - expected: ExpectedRecords, -} - -#[derive(Clone, Default)] -struct ExpectedRecords { - pages: BTreeMap, - layers: BTreeMap, - shapes: BTreeMap, - bindings: BTreeMap, - assets: BTreeMap, -} +mod diff; +mod error; +mod geometry; +mod hierarchy; +mod history; +mod operations; +mod policy; +mod query; +mod repair; +mod validation; + +pub use error::EngineError; +pub use repair::repair_document; +pub use validation::validate_document; + +use diff::{affected_regions, diff_documents}; +use hierarchy::{canonical_heads, warning}; +use history::{HistoryEntry, capture_expected_records, prepare_compensation, refresh_inverse_preconditions}; +use operations::apply_operation; +use policy::{validate_permissions, validate_transaction_schema}; +use query::query_document; /// Rust-owned document state plus actor-scoped undo and redo metadata. pub struct TransactionEngine { @@ -355,2221 +331,5 @@ impl TransactionEngine { } } -fn validate_transaction_schema(transaction: &TransactionDraft) -> Result<(), EngineError> { - if transaction.id.0.trim().is_empty() { - return Err(EngineError::Schema("transaction ID is empty".into())); - } - if transaction.actor_id.as_str().trim().is_empty() { - return Err(EngineError::Schema("actor ID is empty".into())); - } - if transaction.description.trim().is_empty() { - return Err(EngineError::Schema("description is empty".into())); - } - if transaction.operations.is_empty() { - return Err(EngineError::Schema("operations are empty".into())); - } - Ok(()) -} - -fn validate_permissions(document: &Document, operation: &Operation, origin: &Origin) -> Result<(), EngineError> { - let mut shape_ids = operation_shape_ids(operation); - match operation { - Operation::DeletePage { page_id, .. } => { - if let Some(page) = document.pages.get(page_id) { - shape_ids.extend( - page.layer_ids - .iter() - .flat_map(|layer_id| descendant_ids_for_layer(document, layer_id)), - ); - } - } - Operation::DeleteLayer { layer_id, .. } => { - shape_ids.extend(descendant_ids_for_layer(document, layer_id)); - } - Operation::DeleteShape { shape_id, .. } => { - shape_ids.extend(descendant_ids_for_shape(document, shape_id)); - } - _ => {} - } - shape_ids.sort(); - shape_ids.dedup(); - for shape_id in shape_ids { - let Some(shape) = document.shapes.get(&shape_id) else { - continue; - }; - if shape.metadata.locked { - return Err(EngineError::Permission(format!("shape {shape_id} is locked"))); - } - if matches!(origin, Origin::Agent) && !shape.metadata.agent_editable { - return Err(EngineError::Permission(format!( - "shape {shape_id} is not agent-editable" - ))); - } - if let Some(layer) = containing_layer(document, shape) - && layer.locked - { - return Err(EngineError::Permission(format!("layer {} is locked", layer.id))); - } - } - if let Some(layer_id) = operation_layer_id(operation) - && document.layers.get(&layer_id).is_some_and(|layer| layer.locked) - { - return Err(EngineError::Permission(format!("layer {layer_id} is locked"))); - } - Ok(()) -} - -fn refresh_inverse_preconditions(operations: &mut [Operation], document: &Document) { - for operation in operations { - match operation { - Operation::RenamePage { page_id, expected_version, .. } - | Operation::DeletePage { page_id, expected_version } => { - *expected_version = document.pages.get(page_id).map(|record| record.version); - } - Operation::PatchLayer { layer_id, expected_version, .. } - | Operation::ReorderLayer { layer_id, expected_version, .. } - | Operation::DeleteLayer { layer_id, expected_version, .. } => { - *expected_version = document.layers.get(layer_id).map(|record| record.version); - } - Operation::PatchShape { shape_id, expected_version, .. } - | Operation::ReparentShape { shape_id, expected_version, .. } - | Operation::DeleteShape { shape_id, expected_version } => { - *expected_version = document.shapes.get(shape_id).map(|record| record.version); - } - Operation::DeleteBinding { binding_id, expected_version } => { - *expected_version = document.bindings.get(binding_id).map(|record| record.version); - } - Operation::PatchAsset { asset_id, expected_version, .. } - | Operation::DeleteAsset { asset_id, expected_version } => { - *expected_version = document.assets.get(asset_id).map(|record| record.version); - } - Operation::AlignShapes { shape_ids, expected_versions, .. } - | Operation::DistributeShapes { shape_ids, expected_versions, .. } => { - expected_versions.clear(); - expected_versions.extend(shape_ids.iter().filter_map(|shape_id| { - document - .shapes - .get(shape_id) - .map(|record| (shape_id.clone(), record.version)) - })); - } - Operation::CreatePage { .. } - | Operation::CreateLayer { .. } - | Operation::CreateShape { .. } - | Operation::CreateBinding { .. } - | Operation::CreateAsset { .. } => {} - } - } -} - -fn capture_expected_records(operations: &[Operation], document: &Document) -> ExpectedRecords { - let mut expected = ExpectedRecords::default(); - for operation in operations { - match operation { - Operation::RenamePage { page_id, .. } | Operation::DeletePage { page_id, .. } => { - if let Some(record) = document.pages.get(page_id) { - expected.pages.insert(page_id.clone(), record.clone()); - } - } - Operation::PatchLayer { layer_id, .. } - | Operation::ReorderLayer { layer_id, .. } - | Operation::DeleteLayer { layer_id, .. } => { - if let Some(record) = document.layers.get(layer_id) { - expected.layers.insert(layer_id.clone(), record.clone()); - } - } - Operation::PatchShape { shape_id, .. } - | Operation::ReparentShape { shape_id, .. } - | Operation::DeleteShape { shape_id, .. } => { - if let Some(record) = document.shapes.get(shape_id) { - expected.shapes.insert(shape_id.clone(), record.clone()); - } - } - Operation::DeleteBinding { binding_id, .. } => { - if let Some(record) = document.bindings.get(binding_id) { - expected.bindings.insert(binding_id.clone(), record.clone()); - } - } - Operation::PatchAsset { asset_id, .. } | Operation::DeleteAsset { asset_id, .. } => { - if let Some(record) = document.assets.get(asset_id) { - expected.assets.insert(asset_id.clone(), record.clone()); - } - } - Operation::AlignShapes { shape_ids, .. } | Operation::DistributeShapes { shape_ids, .. } => { - for shape_id in shape_ids { - if let Some(record) = document.shapes.get(shape_id) { - expected.shapes.insert(shape_id.clone(), record.clone()); - } - } - } - Operation::CreatePage { .. } - | Operation::CreateLayer { .. } - | Operation::CreateShape { .. } - | Operation::CreateBinding { .. } - | Operation::CreateAsset { .. } => {} - } - } - expected -} - -#[allow(clippy::too_many_lines)] -fn prepare_compensation(entry: &HistoryEntry, current: &Document) -> Result, EngineError> { - let mut operations = entry.operations.clone(); - for operation in &mut operations { - match operation { - Operation::RenamePage { page_id, name, expected_version } => { - let expected = entry - .expected - .pages - .get(page_id) - .ok_or_else(|| history_conflict(format!("page {page_id} no longer has the expected state")))?; - let current = current - .pages - .get(page_id) - .ok_or_else(|| history_conflict(format!("page {page_id} was removed concurrently")))?; - *name = merge_history_value(name, &expected.name, ¤t.name, "page name")?; - *expected_version = None; - } - Operation::PatchLayer { layer_id, patch, expected_version } => { - let expected = - entry.expected.layers.get(layer_id).ok_or_else(|| { - history_conflict(format!("layer {layer_id} no longer has the expected state")) - })?; - let current = current - .layers - .get(layer_id) - .ok_or_else(|| history_conflict(format!("layer {layer_id} was removed concurrently")))?; - if let Some(before) = &patch.name { - patch.name = Some(merge_history_value( - before, - &expected.name, - ¤t.name, - "layer name", - )?); - } - if let Some(before) = patch.visible { - patch.visible = Some(merge_history_value( - &before, - &expected.visible, - ¤t.visible, - "layer visibility", - )?); - } - if let Some(before) = patch.locked { - patch.locked = Some(merge_history_value( - &before, - &expected.locked, - ¤t.locked, - "layer lock", - )?); - } - if let Some(before) = patch.opacity { - patch.opacity = Some(merge_history_value( - &before, - &expected.opacity, - ¤t.opacity, - "layer opacity", - )?); - } - *expected_version = None; - } - Operation::PatchShape { shape_id, patch, expected_version } => { - let expected = - entry.expected.shapes.get(shape_id).ok_or_else(|| { - history_conflict(format!("shape {shape_id} no longer has the expected state")) - })?; - let current = current - .shapes - .get(shape_id) - .ok_or_else(|| history_conflict(format!("shape {shape_id} was removed concurrently")))?; - merge_shape_compensation(patch, expected, current)?; - *expected_version = None; - } - Operation::ReparentShape { shape_id, parent, expected_version, .. } => { - let expected = - entry.expected.shapes.get(shape_id).ok_or_else(|| { - history_conflict(format!("shape {shape_id} no longer has the expected state")) - })?; - let current = current - .shapes - .get(shape_id) - .ok_or_else(|| history_conflict(format!("shape {shape_id} was removed concurrently")))?; - *parent = merge_history_value(parent, &expected.parent, ¤t.parent, "shape parent")?; - *expected_version = None; - } - Operation::PatchAsset { asset_id, patch, expected_version } => { - let expected = - entry.expected.assets.get(asset_id).ok_or_else(|| { - history_conflict(format!("asset {asset_id} no longer has the expected state")) - })?; - let current = current - .assets - .get(asset_id) - .ok_or_else(|| history_conflict(format!("asset {asset_id} was removed concurrently")))?; - if let Some(before) = &patch.name { - patch.name = Some(merge_history_value( - before, - &expected.name, - ¤t.name, - "asset name", - )?); - } - if let Some(before) = &patch.provenance_source { - patch.provenance_source = Some(merge_history_value( - before, - &expected.provenance.source, - ¤t.provenance.source, - "asset provenance source", - )?); - } - *expected_version = None; - } - Operation::DeletePage { page_id, expected_version } => { - guard_existing_record( - entry.expected.pages.get(page_id), - current.pages.get(page_id), - "page", - page_id, - )?; - *expected_version = None; - } - Operation::DeleteLayer { layer_id, expected_version, .. } - | Operation::ReorderLayer { layer_id, expected_version, .. } => { - guard_existing_record( - entry.expected.layers.get(layer_id), - current.layers.get(layer_id), - "layer", - layer_id, - )?; - *expected_version = None; - } - Operation::DeleteShape { shape_id, expected_version } => { - guard_existing_record( - entry.expected.shapes.get(shape_id), - current.shapes.get(shape_id), - "shape", - shape_id, - )?; - *expected_version = None; - } - Operation::DeleteBinding { binding_id, expected_version } => { - guard_existing_record( - entry.expected.bindings.get(binding_id), - current.bindings.get(binding_id), - "binding", - binding_id, - )?; - *expected_version = None; - } - Operation::DeleteAsset { asset_id, expected_version } => { - guard_existing_record( - entry.expected.assets.get(asset_id), - current.assets.get(asset_id), - "asset", - asset_id, - )?; - *expected_version = None; - } - Operation::CreatePage { page, .. } => { - guard_absent_record(&entry.expected.pages, ¤t.pages, &page.id, "page")?; - } - Operation::CreateLayer { layer, .. } => { - guard_absent_record(&entry.expected.layers, ¤t.layers, &layer.id, "layer")?; - } - Operation::CreateShape { shape, .. } => { - guard_absent_record(&entry.expected.shapes, ¤t.shapes, &shape.id, "shape")?; - } - Operation::CreateBinding { binding } => { - guard_absent_record(&entry.expected.bindings, ¤t.bindings, &binding.id, "binding")?; - } - Operation::CreateAsset { asset } => { - guard_absent_record(&entry.expected.assets, ¤t.assets, &asset.id, "asset")?; - } - Operation::AlignShapes { .. } | Operation::DistributeShapes { .. } => { - return Err(history_conflict( - "history contains an unsupported aggregate layout operation", - )); - } - } - } - Ok(operations) -} - -#[allow(clippy::too_many_lines)] -fn merge_shape_compensation( - patch: &mut ShapePatch, expected: &ShapeRecord, current: &ShapeRecord, -) -> Result<(), EngineError> { - if let Some(before) = patch.transform { - patch.transform = Some(crate::Transform { - translation: crate::Vec2 { - x: merge_history_value( - &before.translation.x, - &expected.transform.translation.x, - ¤t.transform.translation.x, - "shape translation x", - )?, - y: merge_history_value( - &before.translation.y, - &expected.transform.translation.y, - ¤t.transform.translation.y, - "shape translation y", - )?, - }, - rotation: merge_history_value( - &before.rotation, - &expected.transform.rotation, - ¤t.transform.rotation, - "shape rotation", - )?, - scale_x: merge_history_value( - &before.scale_x, - &expected.transform.scale_x, - ¤t.transform.scale_x, - "shape horizontal scale", - )?, - scale_y: merge_history_value( - &before.scale_y, - &expected.transform.scale_y, - ¤t.transform.scale_y, - "shape vertical scale", - )?, - }); - } - if let Some(before) = &patch.properties { - patch.properties = Some(merge_history_map( - before, - &expected.properties, - ¤t.properties, - "shape property", - )?); - } - if let Some(before) = &patch.metadata { - let mut merged = current.metadata.clone(); - merged.name = merge_history_value( - &before.name, - &expected.metadata.name, - ¤t.metadata.name, - "shape name", - )?; - merged.role = merge_history_value( - &before.role, - &expected.metadata.role, - ¤t.metadata.role, - "shape role", - )?; - merged.description = merge_history_value( - &before.description, - &expected.metadata.description, - ¤t.metadata.description, - "shape description", - )?; - merged.tags = merge_history_value( - &before.tags, - &expected.metadata.tags, - ¤t.metadata.tags, - "shape tags", - )?; - merged.locked = merge_history_value( - &before.locked, - &expected.metadata.locked, - ¤t.metadata.locked, - "shape lock", - )?; - merged.agent_editable = merge_history_value( - &before.agent_editable, - &expected.metadata.agent_editable, - ¤t.metadata.agent_editable, - "shape agent permission", - )?; - merged.provenance = merge_history_value( - &before.provenance, - &expected.metadata.provenance, - ¤t.metadata.provenance, - "shape provenance", - )?; - patch.metadata = Some(merged); - } - if let Some(before) = patch.style { - patch.style = Some(crate::ShapeStyle { - opacity: merge_history_value( - &before.opacity, - &expected.style.opacity, - ¤t.style.opacity, - "shape opacity", - )?, - fill_opacity: merge_history_value( - &before.fill_opacity, - &expected.style.fill_opacity, - ¤t.style.fill_opacity, - "shape fill opacity", - )?, - stroke_opacity: merge_history_value( - &before.stroke_opacity, - &expected.style.stroke_opacity, - ¤t.style.stroke_opacity, - "shape stroke opacity", - )?, - }); - } - if let Some(before) = &patch.layout { - patch.layout = Some(merge_history_value( - before, - &expected.layout, - ¤t.layout, - "shape layout", - )?); - } - Ok(()) -} - -fn merge_history_map( - before: &ShapeProperties, expected: &ShapeProperties, current: &ShapeProperties, label: &str, -) -> Result { - let keys: BTreeSet<_> = before - .keys() - .chain(expected.keys()) - .chain(current.keys()) - .cloned() - .collect(); - let mut merged = current.clone(); - for key in keys { - if before.get(&key) == expected.get(&key) { - continue; - } - if current.get(&key) != expected.get(&key) { - return Err(history_conflict(format!("{label} {key} changed concurrently"))); - } - if let Some(value) = before.get(&key) { - merged.insert(key, value.clone()); - } else { - merged.remove(&key); - } - } - Ok(merged) -} - -fn merge_history_value( - before: &T, expected: &T, current: &T, label: &str, -) -> Result { - if before == expected { - return Ok(current.clone()); - } - if current == expected { - return Ok(before.clone()); - } - Err(history_conflict(format!("{label} changed concurrently"))) -} - -fn guard_existing_record( - expected: Option<&T>, current: Option<&T>, kind: &str, id: &Id, -) -> Result<(), EngineError> { - if expected.is_some() && expected == current { - Ok(()) - } else { - Err(history_conflict(format!("{kind} {id} changed concurrently"))) - } -} - -fn guard_absent_record( - expected: &BTreeMap, current: &BTreeMap, id: &Id, kind: &str, -) -> Result<(), EngineError> { - if !expected.contains_key(id) && !current.contains_key(id) { - Ok(()) - } else { - Err(history_conflict(format!("{kind} {id} was recreated concurrently"))) - } -} - -fn history_conflict(message: impl Into) -> EngineError { - EngineError::Precondition(format!("history conflict: {}", message.into())) -} - -#[allow(clippy::too_many_lines)] -fn apply_operation(document: &mut Document, operation: &Operation) -> Result, EngineError> { - match operation { - Operation::CreatePage { page, anchor } => { - ensure_absent(document.pages.contains_key(&page.id), "page", &page.id)?; - ensure_version_one(page.version, "new page")?; - if !page.layer_ids.is_empty() { - return Err(EngineError::Schema( - "new page layer_ids must be empty; create layers separately".into(), - )); - } - insert_anchored(&mut document.page_ids, page.id.clone(), anchor)?; - document.pages.insert(page.id.clone(), page.clone()); - Ok(vec![Operation::DeletePage { - page_id: page.id.clone(), - expected_version: Some(page.version), - }]) - } - Operation::RenamePage { page_id, name, expected_version } => { - if name.trim().is_empty() { - return Err(EngineError::Schema("page name is empty".into())); - } - let page = page_mut(document, page_id, *expected_version)?; - let old = page.name.clone(); - page.name.clone_from(name); - page.version = next_version(page.version)?; - Ok(vec![Operation::RenamePage { - page_id: page_id.clone(), - name: old, - expected_version: Some(page.version), - }]) - } - Operation::DeletePage { page_id, expected_version } => delete_page(document, page_id, *expected_version), - Operation::CreateLayer { layer, anchor } => { - ensure_absent(document.layers.contains_key(&layer.id), "layer", &layer.id)?; - ensure_version_one(layer.version, "new layer")?; - if !layer.shape_ids.is_empty() { - return Err(EngineError::Schema( - "new layer shape_ids must be empty; create or reparent shapes separately".into(), - )); - } - let page = page_mut(document, &layer.page_id, None)?; - insert_anchored(&mut page.layer_ids, layer.id.clone(), anchor)?; - page.version = next_version(page.version)?; - document.layers.insert(layer.id.clone(), layer.clone()); - Ok(vec![Operation::DeleteLayer { - layer_id: layer.id.clone(), - contents: LayerContentsDisposition::Delete, - expected_version: Some(layer.version), - }]) - } - Operation::PatchLayer { layer_id, patch, expected_version } => { - patch_layer(document, layer_id, patch, *expected_version) - } - Operation::ReorderLayer { layer_id, anchor, expected_version } => { - reorder_layer(document, layer_id, anchor, *expected_version) - } - Operation::DeleteLayer { layer_id, contents, expected_version } => { - delete_layer(document, layer_id, contents, *expected_version) - } - Operation::CreateShape { shape, anchor } => { - ensure_absent(document.shapes.contains_key(&shape.id), "shape", &shape.id)?; - ensure_version_one(shape.version, "new shape")?; - if !shape.child_ids.is_empty() { - return Err(EngineError::Schema( - "new shape child_ids must be empty; create children separately".into(), - )); - } - insert_shape_child(document, &shape.parent, shape.id.clone(), anchor)?; - document.shapes.insert(shape.id.clone(), shape.clone()); - Ok(vec![Operation::DeleteShape { - shape_id: shape.id.clone(), - expected_version: Some(shape.version), - }]) - } - Operation::PatchShape { shape_id, patch, expected_version } => { - patch_shape(document, shape_id, patch, *expected_version) - } - Operation::ReparentShape { shape_id, parent, anchor, expected_version } => { - reparent_shape(document, shape_id, parent, anchor, *expected_version) - } - Operation::DeleteShape { shape_id, expected_version } => delete_shape(document, shape_id, *expected_version), - Operation::CreateBinding { binding } => { - ensure_absent(document.bindings.contains_key(&binding.id), "binding", &binding.id)?; - ensure_version_one(binding.version, "new binding")?; - ensure_binding_endpoints(document, binding)?; - document.bindings.insert(binding.id.clone(), binding.clone()); - Ok(vec![Operation::DeleteBinding { - binding_id: binding.id.clone(), - expected_version: Some(binding.version), - }]) - } - Operation::DeleteBinding { binding_id, expected_version } => { - let binding = crate::BindingRecord { - version: RecordVersion(1), - ..binding(document, binding_id, *expected_version)?.clone() - }; - document.bindings.remove(binding_id); - Ok(vec![Operation::CreateBinding { binding }]) - } - Operation::CreateAsset { asset } => { - ensure_absent(document.assets.contains_key(&asset.id), "asset", &asset.id)?; - ensure_version_one(asset.version, "new asset")?; - document.assets.insert(asset.id.clone(), asset.clone()); - Ok(vec![Operation::DeleteAsset { - asset_id: asset.id.clone(), - expected_version: Some(asset.version), - }]) - } - Operation::PatchAsset { asset_id, patch, expected_version } => { - patch_asset(document, asset_id, patch, *expected_version) - } - Operation::DeleteAsset { asset_id, expected_version } => { - let asset = crate::AssetRecord { - version: RecordVersion(1), - ..asset(document, asset_id, *expected_version)?.clone() - }; - if asset_is_referenced(document, asset_id) { - return Err(EngineError::Invariant(format!("asset {asset_id} is still referenced"))); - } - document.assets.remove(asset_id); - Ok(vec![Operation::CreateAsset { asset }]) - } - Operation::AlignShapes { shape_ids, alignment, expected_versions } => { - align_shapes(document, shape_ids, *alignment, expected_versions) - } - Operation::DistributeShapes { shape_ids, axis, expected_versions } => { - distribute_shapes(document, shape_ids, *axis, expected_versions) - } - } -} - -fn patch_layer( - document: &mut Document, layer_id: &LayerId, patch: &LayerPatch, expected: Option, -) -> Result, EngineError> { - let layer = layer_mut(document, layer_id, expected)?; - let inverse = LayerPatch { - name: patch.name.as_ref().map(|_| layer.name.clone()), - visible: patch.visible.map(|_| layer.visible), - locked: patch.locked.map(|_| layer.locked), - opacity: patch.opacity.map(|_| layer.opacity), - }; - if let Some(value) = &patch.name { - if value.trim().is_empty() { - return Err(EngineError::Schema("layer name is empty".into())); - } - layer.name.clone_from(value); - } - if let Some(value) = patch.visible { - layer.visible = value; - } - if let Some(value) = patch.locked { - layer.locked = value; - } - if let Some(value) = patch.opacity { - layer.opacity = value; - } - layer.version = next_version(layer.version)?; - Ok(vec![Operation::PatchLayer { - layer_id: layer_id.clone(), - patch: inverse, - expected_version: Some(layer.version), - }]) -} - -fn patch_shape( - document: &mut Document, shape_id: &ShapeId, patch: &ShapePatch, expected: Option, -) -> Result, EngineError> { - let shape = shape_mut(document, shape_id, expected)?; - let inverse = ShapePatch { - transform: patch.transform.map(|_| shape.transform), - properties: patch.properties.as_ref().map(|_| shape.properties.clone()), - metadata: patch.metadata.as_ref().map(|_| shape.metadata.clone()), - style: patch.style.map(|_| shape.style), - layout: patch.layout.as_ref().map(|_| shape.layout.clone()), - }; - if let Some(value) = patch.transform { - shape.transform = value; - } - if let Some(value) = &patch.properties { - shape.properties.clone_from(value); - } - if let Some(value) = &patch.metadata { - shape.metadata.clone_from(value); - } - if let Some(value) = patch.style { - shape.style = value; - } - if let Some(value) = &patch.layout { - shape.layout.clone_from(value); - } - shape.version = next_version(shape.version)?; - Ok(vec![Operation::PatchShape { - shape_id: shape_id.clone(), - patch: inverse, - expected_version: Some(shape.version), - }]) -} - -fn patch_asset( - document: &mut Document, asset_id: &AssetId, patch: &AssetPatch, expected: Option, -) -> Result, EngineError> { - let asset = asset_mut(document, asset_id, expected)?; - let inverse = AssetPatch { - name: patch.name.as_ref().map(|_| asset.name.clone()), - provenance_source: patch - .provenance_source - .as_ref() - .map(|_| asset.provenance.source.clone()), - }; - if let Some(value) = &patch.name { - if value.trim().is_empty() { - return Err(EngineError::Schema("asset name is empty".into())); - } - asset.name.clone_from(value); - } - if let Some(value) = &patch.provenance_source { - asset.provenance.source.clone_from(value); - } - asset.version = next_version(asset.version)?; - Ok(vec![Operation::PatchAsset { - asset_id: asset_id.clone(), - patch: inverse, - expected_version: Some(asset.version), - }]) -} - -fn reorder_layer( - document: &mut Document, layer_id: &LayerId, anchor: &SiblingAnchor, expected: Option, -) -> Result, EngineError> { - let layer = layer(document, layer_id, expected)?.clone(); - let page = document - .pages - .get_mut(&layer.page_id) - .ok_or_else(|| EngineError::Invariant(format!("missing page {}", layer.page_id)))?; - let old_anchor = anchor_for(&page.layer_ids, layer_id)?; - move_anchored(&mut page.layer_ids, layer_id, anchor)?; - page.version = next_version(page.version)?; - let layer = document - .layers - .get_mut(layer_id) - .ok_or_else(|| EngineError::Invariant(format!("layer {layer_id} disappeared during reorder")))?; - layer.version = next_version(layer.version)?; - Ok(vec![Operation::ReorderLayer { - layer_id: layer_id.clone(), - anchor: old_anchor, - expected_version: Some(layer.version), - }]) -} - -fn reparent_shape( - document: &mut Document, shape_id: &ShapeId, parent: &ShapeParent, anchor: &SiblingAnchor, - expected: Option, -) -> Result, EngineError> { - let shape = shape(document, shape_id, expected)?.clone(); - if parent == &ShapeParent::Shape(shape_id.clone()) || is_descendant(document, shape_id, parent) { - return Err(EngineError::Invariant(format!( - "reparenting {shape_id} would create a cycle" - ))); - } - let old_siblings = shape_siblings(document, &shape.parent)?; - let old_anchor = anchor_for(old_siblings, shape_id)?; - remove_shape_child(document, &shape.parent, shape_id)?; - insert_shape_child(document, parent, shape_id.clone(), anchor)?; - let changed = document - .shapes - .get_mut(shape_id) - .ok_or_else(|| EngineError::Invariant(format!("shape {shape_id} disappeared during reparent")))?; - changed.parent = parent.clone(); - changed.version = next_version(changed.version)?; - Ok(vec![Operation::ReparentShape { - shape_id: shape_id.clone(), - parent: shape.parent, - anchor: old_anchor, - expected_version: Some(changed.version), - }]) -} - -fn delete_page( - document: &mut Document, page_id: &PageId, expected: Option, -) -> Result, EngineError> { - let page = page(document, page_id, expected)?.clone(); - let anchor = anchor_for(&document.page_ids, page_id)?; - let layer_ids = page.layer_ids.clone(); - let shape_ids: BTreeSet<_> = layer_ids - .iter() - .flat_map(|layer_id| descendant_ids_for_layer(document, layer_id)) - .collect(); - let mut inverse = vec![Operation::CreatePage { - page: crate::PageRecord { layer_ids: Vec::new(), version: RecordVersion(1), ..page.clone() }, - anchor, - }]; - for layer_id in &layer_ids { - let mut layer = document - .layers - .get(layer_id) - .cloned() - .ok_or_else(|| EngineError::Invariant(format!("page {page_id} owns missing layer {layer_id}")))?; - layer.shape_ids.clear(); - layer.version = RecordVersion(1); - inverse.push(Operation::CreateLayer { layer, anchor: SiblingAnchor::Last }); - } - append_shape_restoration(document, &shape_ids, &mut inverse); - append_binding_restoration(document, &shape_ids, &mut inverse); - document.page_ids.retain(|id| id != page_id); - for binding_id in bindings_touching(document, &shape_ids) { - document.bindings.remove(&binding_id); - } - for shape_id in &shape_ids { - document.shapes.remove(shape_id); - } - for layer_id in layer_ids { - document.layers.remove(&layer_id); - } - document.pages.remove(page_id); - Ok(inverse) -} - -fn delete_layer( - document: &mut Document, layer_id: &LayerId, contents: &LayerContentsDisposition, expected: Option, -) -> Result, EngineError> { - let layer = layer(document, layer_id, expected)?.clone(); - let page = document - .pages - .get(&layer.page_id) - .ok_or_else(|| EngineError::Invariant(format!("layer {layer_id} owns missing page {}", layer.page_id)))?; - let anchor = anchor_for(&page.layer_ids, layer_id)?; - let shape_ids: BTreeSet<_> = descendant_ids_for_layer(document, layer_id).collect(); - match contents { - LayerContentsDisposition::MoveTo(destination) => { - if destination == layer_id { - return Err(EngineError::Precondition( - "layer contents destination is the deleted layer".into(), - )); - } - let destination_layer = document - .layers - .get(destination) - .ok_or_else(|| EngineError::Precondition(format!("destination layer {destination} is missing")))?; - if destination_layer.page_id != layer.page_id { - return Err(EngineError::Invariant( - "layer contents must stay on the same page".into(), - )); - } - let root_ids = layer.shape_ids.clone(); - let mut inverse = vec![Operation::CreateLayer { - layer: crate::LayerRecord { shape_ids: Vec::new(), version: RecordVersion(1), ..layer.clone() }, - anchor, - }]; - for shape_id in &root_ids { - inverse.push(Operation::ReparentShape { - shape_id: shape_id.clone(), - parent: ShapeParent::Layer(layer_id.clone()), - anchor: SiblingAnchor::Last, - expected_version: None, - }); - } - for shape_id in root_ids { - insert_shape_child( - document, - &ShapeParent::Layer(destination.clone()), - shape_id.clone(), - &SiblingAnchor::Last, - )?; - let shape = document - .shapes - .get_mut(&shape_id) - .ok_or_else(|| EngineError::Invariant(format!("missing root shape {shape_id}")))?; - shape.parent = ShapeParent::Layer(destination.clone()); - shape.version = next_version(shape.version)?; - } - remove_layer_record(document, &layer)?; - Ok(inverse) - } - LayerContentsDisposition::Delete => { - let mut inverse = vec![Operation::CreateLayer { - layer: crate::LayerRecord { shape_ids: Vec::new(), version: RecordVersion(1), ..layer.clone() }, - anchor, - }]; - append_shape_restoration(document, &shape_ids, &mut inverse); - append_binding_restoration(document, &shape_ids, &mut inverse); - for binding_id in bindings_touching(document, &shape_ids) { - document.bindings.remove(&binding_id); - } - for shape_id in shape_ids { - document.shapes.remove(&shape_id); - } - remove_layer_record(document, &layer)?; - Ok(inverse) - } - } -} - -fn remove_layer_record(document: &mut Document, layer: &crate::LayerRecord) -> Result<(), EngineError> { - let page = document - .pages - .get_mut(&layer.page_id) - .ok_or_else(|| EngineError::Invariant(format!("missing page {}", layer.page_id)))?; - page.layer_ids.retain(|id| id != &layer.id); - page.version = next_version(page.version)?; - document.layers.remove(&layer.id); - Ok(()) -} - -fn delete_shape( - document: &mut Document, shape_id: &ShapeId, expected: Option, -) -> Result, EngineError> { - let root = shape(document, shape_id, expected)?.clone(); - let shape_ids: BTreeSet<_> = std::iter::once(shape_id.clone()) - .chain(descendant_ids_for_shape(document, shape_id)) - .collect(); - let mut inverse = Vec::new(); - append_shape_restoration(document, &shape_ids, &mut inverse); - append_binding_restoration(document, &shape_ids, &mut inverse); - remove_shape_child(document, &root.parent, shape_id)?; - for binding_id in bindings_touching(document, &shape_ids) { - document.bindings.remove(&binding_id); - } - for id in shape_ids { - document.shapes.remove(&id); - } - Ok(inverse) -} - -fn append_shape_restoration(document: &Document, shape_ids: &BTreeSet, operations: &mut Vec) { - let mut remaining = shape_ids.clone(); - while !remaining.is_empty() { - let ready: Vec<_> = remaining - .iter() - .filter(|id| { - document.shapes.get(*id).is_some_and(|shape| match &shape.parent { - ShapeParent::Layer(_) => true, - ShapeParent::Shape(parent_id) => !remaining.contains(parent_id), - }) - }) - .cloned() - .collect(); - if ready.is_empty() { - break; - } - for id in ready { - if let Some(shape) = document.shapes.get(&id) { - let mut shape = shape.clone(); - shape.child_ids.clear(); - shape.version = RecordVersion(1); - operations.push(Operation::CreateShape { shape, anchor: SiblingAnchor::Last }); - } - remaining.remove(&id); - } - } -} - -fn append_binding_restoration(document: &Document, shape_ids: &BTreeSet, operations: &mut Vec) { - for binding in document.bindings.values() { - if shape_ids.contains(&binding.source_shape_id) || shape_ids.contains(&binding.target_shape_id) { - operations.push(Operation::CreateBinding { - binding: crate::BindingRecord { version: RecordVersion(1), ..binding.clone() }, - }); - } - } -} - -fn align_shapes( - document: &mut Document, shape_ids: &[ShapeId], alignment: ShapeAlignment, - expected_versions: &BTreeMap, -) -> Result, EngineError> { - require_distinct_shapes(document, shape_ids, 2, expected_versions)?; - require_common_parent(document, shape_ids)?; - let bounds: Vec<_> = shape_ids - .iter() - .map(|id| { - document - .shapes - .get(id) - .map(local_shape_bounds) - .ok_or_else(|| EngineError::Precondition(format!("shape {id} is missing"))) - }) - .collect::>()?; - let target = match alignment { - ShapeAlignment::Left => bounds.iter().map(|bounds| bounds.x).fold(f64::INFINITY, f64::min), - ShapeAlignment::Center => bounds.iter().map(center_x).sum::() / count_as_f64(bounds.len())?, - ShapeAlignment::Right => bounds.iter().map(right).fold(f64::NEG_INFINITY, f64::max), - ShapeAlignment::Top => bounds.iter().map(|bounds| bounds.y).fold(f64::INFINITY, f64::min), - ShapeAlignment::Middle => bounds.iter().map(center_y).sum::() / count_as_f64(bounds.len())?, - ShapeAlignment::Bottom => bounds.iter().map(bottom).fold(f64::NEG_INFINITY, f64::max), - }; - let deltas = shape_ids - .iter() - .zip(&bounds) - .map(|(id, bounds)| { - let delta = match alignment { - ShapeAlignment::Left => (target - bounds.x, 0.0), - ShapeAlignment::Center => (target - center_x(bounds), 0.0), - ShapeAlignment::Right => (target - right(bounds), 0.0), - ShapeAlignment::Top => (0.0, target - bounds.y), - ShapeAlignment::Middle => (0.0, target - center_y(bounds)), - ShapeAlignment::Bottom => (0.0, target - bottom(bounds)), - }; - (id.clone(), delta) - }) - .collect(); - apply_layout_translations(document, shape_ids, &deltas) -} - -fn distribute_shapes( - document: &mut Document, shape_ids: &[ShapeId], axis: LayoutAxis, - expected_versions: &BTreeMap, -) -> Result, EngineError> { - require_distinct_shapes(document, shape_ids, 3, expected_versions)?; - require_common_parent(document, shape_ids)?; - let mut ordered: Vec<_> = shape_ids - .iter() - .map(|id| { - document - .shapes - .get(id) - .map(|shape| (id.clone(), local_shape_bounds(shape))) - .ok_or_else(|| EngineError::Precondition(format!("shape {id} is missing"))) - }) - .collect::>()?; - ordered.sort_by(|left, right| { - let left_position = match axis { - LayoutAxis::Horizontal => left.1.x, - LayoutAxis::Vertical => left.1.y, - }; - let right_position = match axis { - LayoutAxis::Horizontal => right.1.x, - LayoutAxis::Vertical => right.1.y, - }; - left_position - .total_cmp(&right_position) - .then_with(|| left.0.cmp(&right.0)) - }); - let first = ordered - .first() - .ok_or_else(|| EngineError::Schema("distribution selection is empty".into()))?; - let last = ordered - .last() - .ok_or_else(|| EngineError::Schema("distribution selection is empty".into()))?; - let start = match axis { - LayoutAxis::Horizontal => first.1.x, - LayoutAxis::Vertical => first.1.y, - }; - let end = match axis { - LayoutAxis::Horizontal => right(&last.1), - LayoutAxis::Vertical => bottom(&last.1), - }; - let total_size: f64 = ordered - .iter() - .map(|(_, bounds)| match axis { - LayoutAxis::Horizontal => bounds.width, - LayoutAxis::Vertical => bounds.height, - }) - .sum(); - let gap = (end - start - total_size) / count_as_f64(ordered.len() - 1)?; - let mut cursor = start; - let mut deltas = BTreeMap::new(); - for (id, bounds) in &ordered { - let position = match axis { - LayoutAxis::Horizontal => bounds.x, - LayoutAxis::Vertical => bounds.y, - }; - let delta = cursor - position; - deltas.insert( - id.clone(), - match axis { - LayoutAxis::Horizontal => (delta, 0.0), - LayoutAxis::Vertical => (0.0, delta), - }, - ); - cursor += match axis { - LayoutAxis::Horizontal => bounds.width, - LayoutAxis::Vertical => bounds.height, - } + gap; - } - apply_layout_translations(document, shape_ids, &deltas) -} - -fn apply_layout_translations( - document: &mut Document, shape_ids: &[ShapeId], deltas: &BTreeMap, -) -> Result, EngineError> { - let mut inverse = Vec::new(); - for shape_id in shape_ids { - let shape = document - .shapes - .get_mut(shape_id) - .ok_or_else(|| EngineError::Precondition(format!("shape {shape_id} is missing")))?; - let old_transform = shape.transform; - let (x, y) = deltas - .get(shape_id) - .copied() - .ok_or_else(|| EngineError::Invariant(format!("shape {shape_id} has no layout delta")))?; - shape.transform.translation.x += x; - shape.transform.translation.y += y; - shape.version = next_version(shape.version)?; - inverse.push(Operation::PatchShape { - shape_id: shape_id.clone(), - patch: ShapePatch { transform: Some(old_transform), ..ShapePatch::default() }, - expected_version: Some(shape.version), - }); - } - Ok(inverse) -} - -fn require_distinct_shapes( - document: &Document, shape_ids: &[ShapeId], minimum: usize, expected_versions: &BTreeMap, -) -> Result<(), EngineError> { - let unique: BTreeSet<_> = shape_ids.iter().collect(); - if unique.len() != shape_ids.len() || shape_ids.len() < minimum { - return Err(EngineError::Schema(format!( - "layout operation requires at least {minimum} distinct shapes" - ))); - } - for shape_id in shape_ids { - shape(document, shape_id, expected_versions.get(shape_id).copied())?; - } - Ok(()) -} - -fn require_common_parent(document: &Document, shape_ids: &[ShapeId]) -> Result<(), EngineError> { - let first = &document - .shapes - .get( - shape_ids - .first() - .ok_or_else(|| EngineError::Schema("layout operation selection is empty".into()))?, - ) - .ok_or_else(|| EngineError::Precondition("layout shape is missing".into()))? - .parent; - for id in shape_ids.iter().skip(1) { - let shape = document - .shapes - .get(id) - .ok_or_else(|| EngineError::Precondition(format!("shape {id} is missing")))?; - if &shape.parent != first { - return Err(EngineError::Invariant( - "alignment and distribution require a common parent".into(), - )); - } - } - Ok(()) -} - -/// Validates normalized document ownership, references, geometry, and layout. -/// -/// # Errors -/// -/// Returns [`EngineError::Invariant`] or [`EngineError::Schema`] with the first -/// invalid ownership, reference, geometry, or layout condition. -pub fn validate_document(document: &Document) -> Result<(), EngineError> { - if document.pages.is_empty() || document.page_ids.is_empty() { - return Err(EngineError::Invariant("document must contain at least one page".into())); - } - ensure_unique_and_complete(&document.page_ids, document.pages.keys().cloned(), "page")?; - let mut listed_layers = BTreeSet::new(); - for page in document.pages.values() { - if page.name.trim().is_empty() || page.layer_ids.is_empty() { - return Err(EngineError::Invariant(format!( - "page {} needs a name and at least one layer", - page.id - ))); - } - for layer_id in &page.layer_ids { - if !listed_layers.insert(layer_id.clone()) { - return Err(EngineError::Invariant(format!( - "layer {layer_id} is listed more than once" - ))); - } - let layer = document.layers.get(layer_id).ok_or_else(|| { - EngineError::Invariant(format!("page {} refers to missing layer {layer_id}", page.id)) - })?; - if layer.page_id != page.id { - return Err(EngineError::Invariant(format!( - "layer {layer_id} has inconsistent page ownership" - ))); - } - } - } - if listed_layers.len() != document.layers.len() { - return Err(EngineError::Invariant("one or more layers are unlisted".into())); - } - let mut listed_shapes = BTreeSet::new(); - for layer in document.layers.values() { - if layer.name.trim().is_empty() { - return Err(EngineError::Invariant(format!("layer {} has an empty name", layer.id))); - } - for shape_id in &layer.shape_ids { - validate_child( - document, - &mut listed_shapes, - shape_id, - &ShapeParent::Layer(layer.id.clone()), - )?; - } - } - for shape in document.shapes.values() { - validate_shape_schema(shape)?; - for child_id in &shape.child_ids { - validate_child( - document, - &mut listed_shapes, - child_id, - &ShapeParent::Shape(shape.id.clone()), - )?; - } - ensure_acyclic(document, &shape.id)?; - } - if listed_shapes.len() != document.shapes.len() { - return Err(EngineError::Invariant("one or more shapes are unlisted".into())); - } - for binding in document.bindings.values() { - ensure_binding_endpoints(document, binding)?; - } - Ok(()) -} - -fn validate_shape_schema(shape: &ShapeRecord) -> Result<(), EngineError> { - crate::validate_shape_properties(shape.kind.as_str(), &shape.properties) - .map_err(|error| EngineError::Schema(format!("shape {}: {error}", shape.id)))?; - if shape.kind.as_str() != crate::CONTAINER_KIND && (!shape.child_ids.is_empty() || shape.layout.is_some()) { - return Err(EngineError::Schema(format!( - "non-container shape {} owns children or layout", - shape.id - ))); - } - let transform = shape.transform; - if ![ - transform.translation.x, - transform.translation.y, - transform.rotation, - transform.scale_x, - transform.scale_y, - ] - .into_iter() - .all(f64::is_finite) - || transform.scale_x == 0.0 - || transform.scale_y == 0.0 - { - return Err(EngineError::Schema(format!( - "shape {} has an invalid transform", - shape.id - ))); - } - if let Some(layout) = &shape.layout { - match layout { - ContainerLayout::Free => {} - ContainerLayout::Stack { gap, padding, .. } => { - validate_layout_numbers(shape, *gap, padding)?; - } - ContainerLayout::Grid { columns, column_gap, row_gap, padding, .. } => { - if *columns == 0 { - return Err(EngineError::Schema(format!("shape {} grid has no columns", shape.id))); - } - validate_layout_numbers(shape, *column_gap, padding)?; - if !row_gap.is_finite() || *row_gap < 0.0 { - return Err(EngineError::Schema(format!("shape {} has invalid row gap", shape.id))); - } - } - } - } - Ok(()) -} - -fn validate_layout_numbers(shape: &ShapeRecord, gap: f64, padding: &crate::Insets) -> Result<(), EngineError> { - if ![gap, padding.top, padding.right, padding.bottom, padding.left] - .into_iter() - .all(|value| value.is_finite() && value >= 0.0) - { - return Err(EngineError::Schema(format!( - "shape {} has invalid layout spacing", - shape.id - ))); - } - Ok(()) -} - -/// Repairs merge-created hierarchy damage using stable IDs and sorted order. -/// -/// # Errors -/// -/// Returns an error when the document has no page or when deterministic repair -/// cannot produce a valid normalized document. -#[allow(clippy::too_many_lines)] -pub fn repair_document(document: &mut Document) -> Result, EngineError> { - let original = document.clone(); - let mut warnings = Vec::new(); - if document.pages.is_empty() { - return Err(EngineError::Invariant("cannot repair a document with no pages".into())); - } - document.page_ids.retain(|id| document.pages.contains_key(id)); - document.page_ids.sort(); - document.page_ids.dedup(); - for page_id in document.pages.keys() { - if !document.page_ids.contains(page_id) { - document.page_ids.push(page_id.clone()); - } - } - document.page_ids.sort(); - - let page_ids: Vec<_> = document.page_ids.clone(); - for page_id in page_ids { - let page = document - .pages - .get(&page_id) - .ok_or_else(|| EngineError::Invariant(format!("page {page_id} disappeared")))?; - let valid_layers: Vec<_> = page - .layer_ids - .iter() - .filter(|layer_id| { - document - .layers - .get(*layer_id) - .is_some_and(|layer| layer.page_id == page_id) - }) - .cloned() - .collect(); - let mut layers = valid_layers; - layers.sort(); - layers.dedup(); - if layers.is_empty() { - let layer_id = LayerId::new(format!("layer:recovered:{}", page_id.as_str())); - document - .layers - .entry(layer_id.clone()) - .or_insert_with(|| crate::LayerRecord { - id: layer_id.clone(), - page_id: page_id.clone(), - name: "Recovered".into(), - shape_ids: Vec::new(), - visible: true, - locked: false, - opacity: crate::Opacity::OPAQUE, - version: RecordVersion(1), - }); - layers.push(layer_id.clone()); - warnings.push(warning( - "recovered_layer", - format!("created {layer_id}"), - vec![RecordId::Layer(layer_id)], - )); - } - let page = document - .pages - .get_mut(&page_id) - .ok_or_else(|| EngineError::Invariant(format!("page {page_id} disappeared")))?; - page.layer_ids = layers; - } - let owned_layers: BTreeSet<_> = document - .pages - .values() - .flat_map(|page| page.layer_ids.iter().cloned()) - .collect(); - document.layers.retain(|id, _| owned_layers.contains(id)); - let fallback = document - .pages - .values() - .flat_map(|page| page.layer_ids.iter()) - .min() - .cloned() - .ok_or_else(|| EngineError::Invariant("repair produced no recovery layer".into()))?; - - let valid_shapes: BTreeSet<_> = document.shapes.keys().cloned().collect(); - for shape in document.shapes.values_mut() { - let valid_parent = match &shape.parent { - ShapeParent::Layer(id) => document.layers.contains_key(id), - ShapeParent::Shape(id) => valid_shapes.contains(id) && id != &shape.id, - }; - if !valid_parent { - shape.parent = ShapeParent::Layer(fallback.clone()); - warnings.push(warning( - "recovered_parent", - format!("moved {} to {fallback}", shape.id), - vec![RecordId::Shape(shape.id.clone())], - )); - } - shape.child_ids.clear(); - } - for layer in document.layers.values_mut() { - layer.shape_ids.clear(); - } - break_parent_cycles(document, &fallback, &mut warnings); - let parents: Vec<_> = document - .shapes - .values() - .map(|shape| (shape.id.clone(), shape.parent.clone())) - .collect(); - for (shape_id, parent) in parents { - match parent { - ShapeParent::Layer(layer_id) => { - let layer = document - .layers - .get_mut(&layer_id) - .ok_or_else(|| EngineError::Invariant(format!("repair lost parent layer {layer_id}")))?; - layer.shape_ids.push(shape_id); - } - ShapeParent::Shape(parent_id) => { - let shape = document - .shapes - .get_mut(&parent_id) - .ok_or_else(|| EngineError::Invariant(format!("repair lost parent shape {parent_id}")))?; - shape.child_ids.push(shape_id); - } - } - } - for layer in document.layers.values_mut() { - layer.shape_ids.sort(); - layer.shape_ids.dedup(); - } - for shape in document.shapes.values_mut() { - shape.child_ids.sort(); - shape.child_ids.dedup(); - } - let before_bindings = document.bindings.len(); - document.bindings.retain(|_, binding| { - valid_shapes.contains(&binding.source_shape_id) && valid_shapes.contains(&binding.target_shape_id) - }); - if document.bindings.len() != before_bindings { - warnings.push(warning( - "removed_dangling_binding", - "removed bindings with missing endpoints".into(), - Vec::new(), - )); - } - let changed_before_versions = document != &original; - for (id, page) in &mut document.pages { - if let Some(before) = original.pages.get(id) - && page != before - { - page.version = next_version(before.version)?; - } - } - for (id, layer) in &mut document.layers { - if let Some(before) = original.layers.get(id) - && layer != before - { - layer.version = next_version(before.version)?; - } - } - for (id, shape) in &mut document.shapes { - if let Some(before) = original.shapes.get(id) - && shape != before - { - shape.version = next_version(before.version)?; - } - } - if changed_before_versions && warnings.is_empty() { - warnings.push(warning( - "normalized_hierarchy", - "normalized hierarchy after merge".into(), - Vec::new(), - )); - } - validate_document(document)?; - Ok(warnings) -} - -fn break_parent_cycles(document: &mut Document, fallback: &LayerId, warnings: &mut Vec) { - let shape_ids: Vec<_> = document.shapes.keys().cloned().collect(); - for start in shape_ids { - let mut path = Vec::new(); - let mut current = start.clone(); - while let Some(shape) = document.shapes.get(¤t) { - if let Some(position) = path.iter().position(|id| id == ¤t) { - let cycle = &path[position..]; - if let Some(chosen) = cycle.iter().max().cloned() { - if let Some(shape) = document.shapes.get_mut(&chosen) { - shape.parent = ShapeParent::Layer(fallback.clone()); - } - warnings.push(warning( - "recovered_cycle", - format!("moved {chosen} to {fallback}"), - vec![RecordId::Shape(chosen)], - )); - } - break; - } - path.push(current.clone()); - match &shape.parent { - ShapeParent::Shape(parent) => current = parent.clone(), - ShapeParent::Layer(_) => break, - } - } - } -} - -#[allow(clippy::too_many_lines)] -fn query_document(snapshot: &DocumentSnapshot, query: &Query) -> QueryResult { - let document = &snapshot.document; - let mut records = Vec::new(); - let mut bounds = BTreeMap::new(); - for page in document.pages.values() { - if matches_common(query, page.id.as_str(), Some(&page.name)) - && query.role.is_none() - && query.tag.is_none() - && query.shape_kind.is_none() - && query.layer_id.is_none() - && query.parent_id.is_none() - && query.bounds.is_none() - { - records.push(RecordId::Page(page.id.clone())); - } - } - for layer in document.layers.values() { - if matches_common(query, layer.id.as_str(), Some(&layer.name)) - && query.role.is_none() - && query.tag.is_none() - && query.shape_kind.is_none() - && query.page_id.as_ref().is_none_or(|id| id == &layer.page_id) - && query.layer_id.as_ref().is_none_or(|id| id == &layer.id) - && query.parent_id.is_none() - && query.bounds.is_none() - { - records.push(RecordId::Layer(layer.id.clone())); - } - } - for shape in document.shapes.values() { - let shape_bounds = world_shape_bounds(document, &shape.id); - let layer = containing_layer(document, shape).map(|layer| layer.id.clone()); - let page = layer - .as_ref() - .and_then(|id| document.layers.get(id)) - .map(|layer| layer.page_id.clone()); - let parent = match &shape.parent { - ShapeParent::Layer(id) => id.as_str(), - ShapeParent::Shape(id) => id.as_str(), - }; - let matches = matches_common(query, shape.id.as_str(), shape.metadata.name.as_ref()) - && query - .role - .as_ref() - .is_none_or(|role| shape.metadata.role.as_ref() == Some(role)) - && query.tag.as_ref().is_none_or(|tag| shape.metadata.tags.contains(tag)) - && query.shape_kind.as_ref().is_none_or(|kind| shape.kind.as_str() == kind) - && query.page_id.as_ref().is_none_or(|id| page.as_ref() == Some(id)) - && query.layer_id.as_ref().is_none_or(|id| layer.as_ref() == Some(id)) - && query.parent_id.as_ref().is_none_or(|id| parent == id) - && query - .bounds - .as_ref() - .is_none_or(|filter| intersects(&shape_bounds, filter)); - if matches { - records.push(RecordId::Shape(shape.id.clone())); - bounds.insert(shape.id.clone(), shape_bounds); - } - } - for binding in document.bindings.values() { - if matches_common(query, binding.id.as_str(), None) - && query.role.is_none() - && query.tag.is_none() - && query.shape_kind.is_none() - && query.page_id.is_none() - && query.layer_id.is_none() - && query.parent_id.is_none() - && query.bounds.is_none() - { - records.push(RecordId::Binding(binding.id.clone())); - } - } - for asset in document.assets.values() { - if matches_common(query, asset.id.as_str(), Some(&asset.name)) - && query.role.is_none() - && query.tag.is_none() - && query.shape_kind.is_none() - && query.page_id.is_none() - && query.layer_id.is_none() - && query.parent_id.is_none() - && query.bounds.is_none() - { - records.push(RecordId::Asset(asset.id.clone())); - } - } - records.sort_by(record_id_order); - QueryResult { heads: snapshot.heads.clone(), records, bounds } -} - -fn matches_common(query: &Query, id: &str, name: Option<&String>) -> bool { - query.id.as_ref().is_none_or(|expected| expected == id) - && query - .name - .as_ref() - .is_none_or(|expected| name.is_some_and(|name| name == expected)) -} - -fn record_id_order(left: &RecordId, right: &RecordId) -> Ordering { - record_sort_key(left).cmp(&record_sort_key(right)) -} - -fn record_sort_key(record: &RecordId) -> (u8, &str) { - match record { - RecordId::Page(id) => (0, id.as_str()), - RecordId::Layer(id) => (1, id.as_str()), - RecordId::Shape(id) => (2, id.as_str()), - RecordId::Binding(id) => (3, id.as_str()), - RecordId::Asset(id) => (4, id.as_str()), - } -} - -fn diff_documents(before: &Document, after: &Document) -> (DocumentPatch, Vec) { - let mut created = Vec::new(); - let mut changed = Vec::new(); - let mut deleted = Vec::new(); - diff_map( - &before.pages, - &after.pages, - RecordId::Page, - &mut created, - &mut changed, - &mut deleted, - ); - diff_map( - &before.layers, - &after.layers, - RecordId::Layer, - &mut created, - &mut changed, - &mut deleted, - ); - diff_map( - &before.shapes, - &after.shapes, - RecordId::Shape, - &mut created, - &mut changed, - &mut deleted, - ); - diff_map( - &before.bindings, - &after.bindings, - RecordId::Binding, - &mut created, - &mut changed, - &mut deleted, - ); - diff_map( - &before.assets, - &after.assets, - RecordId::Asset, - &mut created, - &mut changed, - &mut deleted, - ); - let mut affected = created - .iter() - .chain(&changed) - .chain(&deleted) - .cloned() - .collect::>(); - affected.sort_by(record_id_order); - (DocumentPatch { created, changed, deleted }, affected) -} - -fn diff_map( - before: &BTreeMap, after: &BTreeMap, wrap: F, created: &mut Vec, changed: &mut Vec, - deleted: &mut Vec, -) where - K: Ord + Clone, - V: PartialEq, - F: Fn(K) -> RecordId, -{ - for (id, value) in after { - match before.get(id) { - None => created.push(wrap(id.clone())), - Some(old) if old != value => changed.push(wrap(id.clone())), - Some(_) => {} - } - } - for id in before.keys() { - if !after.contains_key(id) { - deleted.push(wrap(id.clone())); - } - } -} - -fn affected_regions(before: &Document, after: &Document, ids: &[RecordId]) -> Vec { - let mut regions: BTreeMap = BTreeMap::new(); - for id in ids { - let mut shape_ids = visual_shape_ids(before, id); - shape_ids.extend(visual_shape_ids(after, id)); - for shape_id in shape_ids { - for document in [before, after] { - let Some(shape) = document.shapes.get(&shape_id) else { - continue; - }; - let Some(page_id) = containing_layer(document, shape).map(|layer| layer.page_id.clone()) else { - continue; - }; - let bounds = world_shape_bounds(document, &shape_id); - regions - .entry(page_id) - .and_modify(|current| *current = union(*current, bounds)) - .or_insert(bounds); - } - } - } - regions - .into_iter() - .map(|(page_id, bounds)| AffectedRegion { page_id, bounds }) - .collect() -} - -fn visual_shape_ids(document: &Document, id: &RecordId) -> BTreeSet { - match id { - RecordId::Shape(shape_id) => document - .shapes - .contains_key(shape_id) - .then(|| shape_id.clone()) - .into_iter() - .collect(), - RecordId::Layer(layer_id) => descendant_ids_for_layer(document, layer_id).collect(), - RecordId::Page(page_id) => document - .pages - .get(page_id) - .into_iter() - .flat_map(|page| &page.layer_ids) - .flat_map(|layer_id| descendant_ids_for_layer(document, layer_id)) - .collect(), - RecordId::Binding(binding_id) => document - .bindings - .get(binding_id) - .into_iter() - .flat_map(|binding| [binding.source_shape_id.clone(), binding.target_shape_id.clone()]) - .collect(), - RecordId::Asset(_) => BTreeSet::new(), - } -} - -fn local_shape_bounds(shape: &ShapeRecord) -> Bounds { - let width = numeric_property(shape, "width").unwrap_or(0.0).abs(); - let height = numeric_property(shape, "height").unwrap_or(0.0).abs(); - transformed_bounds(width, height, shape.transform) -} - -fn world_shape_bounds(document: &Document, shape_id: &ShapeId) -> Bounds { - let Some(shape) = document.shapes.get(shape_id) else { - return Bounds { x: 0.0, y: 0.0, width: 0.0, height: 0.0 }; - }; - let mut bounds = local_shape_bounds(shape); - let mut parent = shape.parent.clone(); - while let ShapeParent::Shape(parent_id) = parent { - let Some(parent_shape) = document.shapes.get(&parent_id) else { - break; - }; - bounds.x += parent_shape.transform.translation.x; - bounds.y += parent_shape.transform.translation.y; - parent = parent_shape.parent.clone(); - } - bounds -} - -fn transformed_bounds(width: f64, height: f64, transform: crate::Transform) -> Bounds { - let cos = transform.rotation.cos(); - let sin = transform.rotation.sin(); - let points = [(0.0, 0.0), (width, 0.0), (0.0, height), (width, height)].map(|(x, y)| { - let x = x * transform.scale_x; - let y = y * transform.scale_y; - ( - transform.translation.x + x * cos - y * sin, - transform.translation.y + x * sin + y * cos, - ) - }); - let min_x = points.iter().map(|p| p.0).fold(f64::INFINITY, f64::min); - let max_x = points.iter().map(|p| p.0).fold(f64::NEG_INFINITY, f64::max); - let min_y = points.iter().map(|p| p.1).fold(f64::INFINITY, f64::min); - let max_y = points.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max); - Bounds { x: min_x, y: min_y, width: max_x - min_x, height: max_y - min_y } -} - -fn numeric_property(shape: &ShapeRecord, name: &str) -> Option { - shape - .properties - .get(name) - .and_then(serde_json::Value::as_f64) - .filter(|value| value.is_finite()) -} -fn count_as_f64(count: usize) -> Result { - let count = u32::try_from(count).map_err(|_| EngineError::Invariant("layout selection is too large".into()))?; - Ok(f64::from(count)) -} -fn center_x(bounds: &Bounds) -> f64 { - bounds.x + bounds.width / 2.0 -} -fn center_y(bounds: &Bounds) -> f64 { - bounds.y + bounds.height / 2.0 -} -fn right(bounds: &Bounds) -> f64 { - bounds.x + bounds.width -} -fn bottom(bounds: &Bounds) -> f64 { - bounds.y + bounds.height -} -fn intersects(left: &Bounds, right_bounds: &Bounds) -> bool { - left.x <= right(right_bounds) - && right(left) >= right_bounds.x - && left.y <= bottom(right_bounds) - && bottom(left) >= right_bounds.y -} -fn union(left: Bounds, right_bounds: Bounds) -> Bounds { - let x = left.x.min(right_bounds.x); - let y = left.y.min(right_bounds.y); - Bounds { - x, - y, - width: right(&left).max(right(&right_bounds)) - x, - height: bottom(&left).max(bottom(&right_bounds)) - y, - } -} - -fn ensure_unique_and_complete<'a, I>(listed: &[I::Item], keys: I, name: &str) -> Result<(), EngineError> -where - I: Iterator, - I::Item: Ord + Clone + std::fmt::Display + 'a, -{ - let listed_set: BTreeSet<_> = listed.iter().cloned().collect(); - if listed_set.len() != listed.len() { - return Err(EngineError::Invariant(format!("duplicate {name} ordering entry"))); - } - let keys_set: BTreeSet<_> = keys.collect(); - if listed_set != keys_set { - return Err(EngineError::Invariant(format!( - "{name} ordering does not match records" - ))); - } - Ok(()) -} - -fn validate_child( - document: &Document, seen: &mut BTreeSet, child_id: &ShapeId, expected_parent: &ShapeParent, -) -> Result<(), EngineError> { - if !seen.insert(child_id.clone()) { - return Err(EngineError::Invariant(format!( - "shape {child_id} is listed more than once" - ))); - } - let child = document - .shapes - .get(child_id) - .ok_or_else(|| EngineError::Invariant(format!("missing child shape {child_id}")))?; - if &child.parent != expected_parent { - return Err(EngineError::Invariant(format!( - "shape {child_id} has inconsistent parent" - ))); - } - Ok(()) -} - -fn ensure_acyclic(document: &Document, start: &ShapeId) -> Result<(), EngineError> { - let mut seen = BTreeSet::new(); - let mut current = start.clone(); - while let Some(shape) = document.shapes.get(¤t) { - if !seen.insert(current.clone()) { - return Err(EngineError::Invariant(format!( - "shape hierarchy contains a cycle at {current}" - ))); - } - match &shape.parent { - ShapeParent::Shape(parent) => current = parent.clone(), - ShapeParent::Layer(_) => return Ok(()), - } - } - Ok(()) -} - -fn ensure_binding_endpoints(document: &Document, binding: &crate::BindingRecord) -> Result<(), EngineError> { - if !document.shapes.contains_key(&binding.source_shape_id) - || !document.shapes.contains_key(&binding.target_shape_id) - { - return Err(EngineError::Invariant(format!( - "binding {} has a missing endpoint", - binding.id - ))); - } - Ok(()) -} - -fn ensure_absent(exists: bool, name: &str, id: &Id) -> Result<(), EngineError> { - if exists { Err(EngineError::Precondition(format!("{name} {id} already exists"))) } else { Ok(()) } -} -fn ensure_version_one(version: RecordVersion, context: &str) -> Result<(), EngineError> { - if version == RecordVersion(1) { - Ok(()) - } else { - Err(EngineError::Schema(format!("{context} must start at record version 1"))) - } -} -fn next_version(version: RecordVersion) -> Result { - version - .0 - .checked_add(1) - .map(RecordVersion) - .ok_or_else(|| EngineError::Invariant("record version overflow".into())) -} -fn check_version(actual: RecordVersion, expected: Option, name: &str) -> Result<(), EngineError> { - if expected.is_some_and(|value| value != actual) { - Err(EngineError::Precondition(format!("{name} version is stale"))) - } else { - Ok(()) - } -} - -fn page<'a>( - document: &'a Document, id: &PageId, expected: Option, -) -> Result<&'a crate::PageRecord, EngineError> { - let value = document - .pages - .get(id) - .ok_or_else(|| EngineError::Precondition(format!("page {id} is missing")))?; - check_version(value.version, expected, "page")?; - Ok(value) -} -fn page_mut<'a>( - document: &'a mut Document, id: &PageId, expected: Option, -) -> Result<&'a mut crate::PageRecord, EngineError> { - let value = document - .pages - .get_mut(id) - .ok_or_else(|| EngineError::Precondition(format!("page {id} is missing")))?; - check_version(value.version, expected, "page")?; - Ok(value) -} -fn layer<'a>( - document: &'a Document, id: &LayerId, expected: Option, -) -> Result<&'a crate::LayerRecord, EngineError> { - let value = document - .layers - .get(id) - .ok_or_else(|| EngineError::Precondition(format!("layer {id} is missing")))?; - check_version(value.version, expected, "layer")?; - Ok(value) -} -fn layer_mut<'a>( - document: &'a mut Document, id: &LayerId, expected: Option, -) -> Result<&'a mut crate::LayerRecord, EngineError> { - let value = document - .layers - .get_mut(id) - .ok_or_else(|| EngineError::Precondition(format!("layer {id} is missing")))?; - check_version(value.version, expected, "layer")?; - Ok(value) -} -fn shape<'a>( - document: &'a Document, id: &ShapeId, expected: Option, -) -> Result<&'a ShapeRecord, EngineError> { - let value = document - .shapes - .get(id) - .ok_or_else(|| EngineError::Precondition(format!("shape {id} is missing")))?; - check_version(value.version, expected, "shape")?; - Ok(value) -} -fn shape_mut<'a>( - document: &'a mut Document, id: &ShapeId, expected: Option, -) -> Result<&'a mut ShapeRecord, EngineError> { - let value = document - .shapes - .get_mut(id) - .ok_or_else(|| EngineError::Precondition(format!("shape {id} is missing")))?; - check_version(value.version, expected, "shape")?; - Ok(value) -} -fn binding<'a>( - document: &'a Document, id: &BindingId, expected: Option, -) -> Result<&'a crate::BindingRecord, EngineError> { - let value = document - .bindings - .get(id) - .ok_or_else(|| EngineError::Precondition(format!("binding {id} is missing")))?; - check_version(value.version, expected, "binding")?; - Ok(value) -} -fn asset<'a>( - document: &'a Document, id: &AssetId, expected: Option, -) -> Result<&'a crate::AssetRecord, EngineError> { - let value = document - .assets - .get(id) - .ok_or_else(|| EngineError::Precondition(format!("asset {id} is missing")))?; - check_version(value.version, expected, "asset")?; - Ok(value) -} -fn asset_mut<'a>( - document: &'a mut Document, id: &AssetId, expected: Option, -) -> Result<&'a mut crate::AssetRecord, EngineError> { - let value = document - .assets - .get_mut(id) - .ok_or_else(|| EngineError::Precondition(format!("asset {id} is missing")))?; - check_version(value.version, expected, "asset")?; - Ok(value) -} - -fn insert_anchored( - items: &mut Vec, id: Id, anchor: &SiblingAnchor, -) -> Result<(), EngineError> { - if items.contains(&id) { - return Err(EngineError::Precondition(format!("ordered item {id} already exists"))); - } - let index = anchor_index(items, anchor)?; - items.insert(index, id); - Ok(()) -} -fn move_anchored( - items: &mut Vec, id: &Id, anchor: &SiblingAnchor, -) -> Result<(), EngineError> { - let position = items - .iter() - .position(|item| item == id) - .ok_or_else(|| EngineError::Invariant(format!("ordered item {id} is missing")))?; - let item = items.remove(position); - let index = anchor_index(items, anchor)?; - items.insert(index, item); - Ok(()) -} -fn anchor_index(items: &[Id], anchor: &SiblingAnchor) -> Result { - match anchor { - SiblingAnchor::First => Ok(0), - SiblingAnchor::Last => Ok(items.len()), - SiblingAnchor::Before(id) => items - .iter() - .position(|item| item == id) - .ok_or_else(|| EngineError::Precondition(format!("anchor sibling {id} is missing"))), - SiblingAnchor::After(id) => items - .iter() - .position(|item| item == id) - .map(|index| index + 1) - .ok_or_else(|| EngineError::Precondition(format!("anchor sibling {id} is missing"))), - } -} -fn anchor_for(items: &[Id], id: &Id) -> Result, EngineError> { - let index = items - .iter() - .position(|item| item == id) - .ok_or_else(|| EngineError::Invariant(format!("ordered item {id} is missing")))?; - Ok(if index == 0 { SiblingAnchor::First } else { SiblingAnchor::After(items[index - 1].clone()) }) -} - -fn shape_siblings<'a>(document: &'a Document, parent: &ShapeParent) -> Result<&'a Vec, EngineError> { - match parent { - ShapeParent::Layer(id) => document - .layers - .get(id) - .map(|layer| &layer.shape_ids) - .ok_or_else(|| EngineError::Precondition(format!("parent layer {id} is missing"))), - ShapeParent::Shape(id) => document - .shapes - .get(id) - .map(|shape| &shape.child_ids) - .ok_or_else(|| EngineError::Precondition(format!("parent shape {id} is missing"))), - } -} -fn insert_shape_child( - document: &mut Document, parent: &ShapeParent, id: ShapeId, anchor: &SiblingAnchor, -) -> Result<(), EngineError> { - match parent { - ShapeParent::Layer(parent_id) => { - let layer = document - .layers - .get_mut(parent_id) - .ok_or_else(|| EngineError::Precondition(format!("parent layer {parent_id} is missing")))?; - insert_anchored(&mut layer.shape_ids, id, anchor)?; - layer.version = next_version(layer.version)?; - } - ShapeParent::Shape(parent_id) => { - let shape = document - .shapes - .get_mut(parent_id) - .ok_or_else(|| EngineError::Precondition(format!("parent shape {parent_id} is missing")))?; - insert_anchored(&mut shape.child_ids, id, anchor)?; - shape.version = next_version(shape.version)?; - } - } - Ok(()) -} -fn remove_shape_child(document: &mut Document, parent: &ShapeParent, id: &ShapeId) -> Result<(), EngineError> { - match parent { - ShapeParent::Layer(parent_id) => { - let layer = document - .layers - .get_mut(parent_id) - .ok_or_else(|| EngineError::Invariant(format!("parent layer {parent_id} is missing")))?; - layer.shape_ids.retain(|child| child != id); - layer.version = next_version(layer.version)?; - } - ShapeParent::Shape(parent_id) => { - let shape = document - .shapes - .get_mut(parent_id) - .ok_or_else(|| EngineError::Invariant(format!("parent shape {parent_id} is missing")))?; - shape.child_ids.retain(|child| child != id); - shape.version = next_version(shape.version)?; - } - } - Ok(()) -} - -fn containing_layer<'a>(document: &'a Document, shape: &ShapeRecord) -> Option<&'a crate::LayerRecord> { - let mut parent = shape.parent.clone(); - loop { - match parent { - ShapeParent::Layer(id) => return document.layers.get(&id), - ShapeParent::Shape(id) => parent = document.shapes.get(&id)?.parent.clone(), - } - } -} -fn is_descendant(document: &Document, shape_id: &ShapeId, parent: &ShapeParent) -> bool { - let ShapeParent::Shape(mut current) = parent.clone() else { - return false; - }; - loop { - if ¤t == shape_id { - return true; - } - let Some(shape) = document.shapes.get(¤t) else { - return false; - }; - match &shape.parent { - ShapeParent::Shape(next) => current = next.clone(), - ShapeParent::Layer(_) => return false, - } - } -} -fn descendant_ids_for_layer<'a>(document: &'a Document, layer_id: &'a LayerId) -> impl Iterator + 'a { - document - .layers - .get(layer_id) - .into_iter() - .flat_map(|layer| layer.shape_ids.iter()) - .flat_map(|id| std::iter::once(id.clone()).chain(descendant_ids_for_shape(document, id))) -} -fn descendant_ids_for_shape<'a>( - document: &'a Document, shape_id: &'a ShapeId, -) -> Box + 'a> { - Box::new( - document - .shapes - .get(shape_id) - .into_iter() - .flat_map(|shape| shape.child_ids.iter()) - .flat_map(|id| std::iter::once(id.clone()).chain(descendant_ids_for_shape(document, id))), - ) -} -fn bindings_touching(document: &Document, shapes: &BTreeSet) -> Vec { - document - .bindings - .values() - .filter(|binding| shapes.contains(&binding.source_shape_id) || shapes.contains(&binding.target_shape_id)) - .map(|binding| binding.id.clone()) - .collect() -} -fn asset_is_referenced(document: &Document, asset_id: &AssetId) -> bool { - document.shapes.values().any(|shape| { - shape - .properties - .values() - .any(|value| value.as_str() == Some(asset_id.as_str())) - }) -} - -fn operation_shape_ids(operation: &Operation) -> Vec { - match operation { - Operation::PatchShape { shape_id, .. } - | Operation::ReparentShape { shape_id, .. } - | Operation::DeleteShape { shape_id, .. } => vec![shape_id.clone()], - Operation::CreateBinding { binding } => vec![binding.source_shape_id.clone(), binding.target_shape_id.clone()], - Operation::AlignShapes { shape_ids, .. } | Operation::DistributeShapes { shape_ids, .. } => shape_ids.clone(), - _ => Vec::new(), - } -} -fn operation_layer_id(operation: &Operation) -> Option { - match operation { - Operation::PatchLayer { layer_id, .. } - | Operation::ReorderLayer { layer_id, .. } - | Operation::DeleteLayer { layer_id, .. } => Some(layer_id.clone()), - Operation::CreateShape { shape, .. } => match &shape.parent { - ShapeParent::Layer(id) => Some(id.clone()), - ShapeParent::Shape(_) => None, - }, - Operation::ReparentShape { parent: ShapeParent::Layer(id), .. } => Some(id.clone()), - _ => None, - } -} -fn canonical_heads(heads: &[ChangeHash]) -> BTreeSet { - heads.iter().cloned().collect() -} -fn warning(code: &str, message: String, record_ids: Vec) -> Warning { - Warning { code: code.into(), message, record_ids } -} - #[cfg(test)] mod tests; diff --git a/crates/inkfinite-core/src/engine/operations.rs b/crates/inkfinite-core/src/engine/operations.rs new file mode 100644 index 0000000..1caea2d --- /dev/null +++ b/crates/inkfinite-core/src/engine/operations.rs @@ -0,0 +1,679 @@ +use super::geometry::{bottom, center_x, center_y, count_as_f64, local_shape_bounds, right}; +use super::hierarchy::{ + anchor_for, asset, asset_is_referenced, asset_mut, binding, bindings_touching, descendant_ids_for_layer, + descendant_ids_for_shape, ensure_absent, ensure_version_one, insert_anchored, insert_shape_child, is_descendant, + layer, layer_mut, move_anchored, next_version, page, page_mut, remove_shape_child, shape, shape_mut, + shape_siblings, +}; +use super::validation::ensure_binding_endpoints; +use super::{ + AssetId, AssetPatch, BTreeMap, BTreeSet, Document, EngineError, LayerContentsDisposition, LayerId, LayerPatch, + LayoutAxis, Operation, PageId, RecordVersion, ShapeAlignment, ShapeId, ShapeParent, ShapePatch, SiblingAnchor, +}; + +#[allow(clippy::too_many_lines)] +pub fn apply_operation(document: &mut Document, operation: &Operation) -> Result, EngineError> { + match operation { + Operation::CreatePage { page, anchor } => { + ensure_absent(document.pages.contains_key(&page.id), "page", &page.id)?; + ensure_version_one(page.version, "new page")?; + if !page.layer_ids.is_empty() { + return Err(EngineError::Schema( + "new page layer_ids must be empty; create layers separately".into(), + )); + } + insert_anchored(&mut document.page_ids, page.id.clone(), anchor)?; + document.pages.insert(page.id.clone(), page.clone()); + Ok(vec![Operation::DeletePage { + page_id: page.id.clone(), + expected_version: Some(page.version), + }]) + } + Operation::RenamePage { page_id, name, expected_version } => { + if name.trim().is_empty() { + return Err(EngineError::Schema("page name is empty".into())); + } + let page = page_mut(document, page_id, *expected_version)?; + let old = page.name.clone(); + page.name.clone_from(name); + page.version = next_version(page.version)?; + Ok(vec![Operation::RenamePage { + page_id: page_id.clone(), + name: old, + expected_version: Some(page.version), + }]) + } + Operation::DeletePage { page_id, expected_version } => delete_page(document, page_id, *expected_version), + Operation::CreateLayer { layer, anchor } => { + ensure_absent(document.layers.contains_key(&layer.id), "layer", &layer.id)?; + ensure_version_one(layer.version, "new layer")?; + if !layer.shape_ids.is_empty() { + return Err(EngineError::Schema( + "new layer shape_ids must be empty; create or reparent shapes separately".into(), + )); + } + let page = page_mut(document, &layer.page_id, None)?; + insert_anchored(&mut page.layer_ids, layer.id.clone(), anchor)?; + page.version = next_version(page.version)?; + document.layers.insert(layer.id.clone(), layer.clone()); + Ok(vec![Operation::DeleteLayer { + layer_id: layer.id.clone(), + contents: LayerContentsDisposition::Delete, + expected_version: Some(layer.version), + }]) + } + Operation::PatchLayer { layer_id, patch, expected_version } => { + patch_layer(document, layer_id, patch, *expected_version) + } + Operation::ReorderLayer { layer_id, anchor, expected_version } => { + reorder_layer(document, layer_id, anchor, *expected_version) + } + Operation::DeleteLayer { layer_id, contents, expected_version } => { + delete_layer(document, layer_id, contents, *expected_version) + } + Operation::CreateShape { shape, anchor } => { + ensure_absent(document.shapes.contains_key(&shape.id), "shape", &shape.id)?; + ensure_version_one(shape.version, "new shape")?; + if !shape.child_ids.is_empty() { + return Err(EngineError::Schema( + "new shape child_ids must be empty; create children separately".into(), + )); + } + insert_shape_child(document, &shape.parent, shape.id.clone(), anchor)?; + document.shapes.insert(shape.id.clone(), shape.clone()); + Ok(vec![Operation::DeleteShape { + shape_id: shape.id.clone(), + expected_version: Some(shape.version), + }]) + } + Operation::PatchShape { shape_id, patch, expected_version } => { + patch_shape(document, shape_id, patch, *expected_version) + } + Operation::ReparentShape { shape_id, parent, anchor, expected_version } => { + reparent_shape(document, shape_id, parent, anchor, *expected_version) + } + Operation::DeleteShape { shape_id, expected_version } => delete_shape(document, shape_id, *expected_version), + Operation::CreateBinding { binding } => { + ensure_absent(document.bindings.contains_key(&binding.id), "binding", &binding.id)?; + ensure_version_one(binding.version, "new binding")?; + ensure_binding_endpoints(document, binding)?; + document.bindings.insert(binding.id.clone(), binding.clone()); + Ok(vec![Operation::DeleteBinding { + binding_id: binding.id.clone(), + expected_version: Some(binding.version), + }]) + } + Operation::DeleteBinding { binding_id, expected_version } => { + let binding = crate::BindingRecord { + version: RecordVersion(1), + ..binding(document, binding_id, *expected_version)?.clone() + }; + document.bindings.remove(binding_id); + Ok(vec![Operation::CreateBinding { binding }]) + } + Operation::CreateAsset { asset } => { + ensure_absent(document.assets.contains_key(&asset.id), "asset", &asset.id)?; + ensure_version_one(asset.version, "new asset")?; + document.assets.insert(asset.id.clone(), asset.clone()); + Ok(vec![Operation::DeleteAsset { + asset_id: asset.id.clone(), + expected_version: Some(asset.version), + }]) + } + Operation::PatchAsset { asset_id, patch, expected_version } => { + patch_asset(document, asset_id, patch, *expected_version) + } + Operation::DeleteAsset { asset_id, expected_version } => { + let asset = crate::AssetRecord { + version: RecordVersion(1), + ..asset(document, asset_id, *expected_version)?.clone() + }; + if asset_is_referenced(document, asset_id) { + return Err(EngineError::Invariant(format!("asset {asset_id} is still referenced"))); + } + document.assets.remove(asset_id); + Ok(vec![Operation::CreateAsset { asset }]) + } + Operation::AlignShapes { shape_ids, alignment, expected_versions } => { + align_shapes(document, shape_ids, *alignment, expected_versions) + } + Operation::DistributeShapes { shape_ids, axis, expected_versions } => { + distribute_shapes(document, shape_ids, *axis, expected_versions) + } + } +} + +pub fn patch_layer( + document: &mut Document, layer_id: &LayerId, patch: &LayerPatch, expected: Option, +) -> Result, EngineError> { + let layer = layer_mut(document, layer_id, expected)?; + let inverse = LayerPatch { + name: patch.name.as_ref().map(|_| layer.name.clone()), + visible: patch.visible.map(|_| layer.visible), + locked: patch.locked.map(|_| layer.locked), + opacity: patch.opacity.map(|_| layer.opacity), + }; + if let Some(value) = &patch.name { + if value.trim().is_empty() { + return Err(EngineError::Schema("layer name is empty".into())); + } + layer.name.clone_from(value); + } + if let Some(value) = patch.visible { + layer.visible = value; + } + if let Some(value) = patch.locked { + layer.locked = value; + } + if let Some(value) = patch.opacity { + layer.opacity = value; + } + layer.version = next_version(layer.version)?; + Ok(vec![Operation::PatchLayer { + layer_id: layer_id.clone(), + patch: inverse, + expected_version: Some(layer.version), + }]) +} + +pub fn patch_shape( + document: &mut Document, shape_id: &ShapeId, patch: &ShapePatch, expected: Option, +) -> Result, EngineError> { + let shape = shape_mut(document, shape_id, expected)?; + let inverse = ShapePatch { + transform: patch.transform.map(|_| shape.transform), + properties: patch.properties.as_ref().map(|_| shape.properties.clone()), + metadata: patch.metadata.as_ref().map(|_| shape.metadata.clone()), + style: patch.style.map(|_| shape.style), + layout: patch.layout.as_ref().map(|_| shape.layout.clone()), + }; + if let Some(value) = patch.transform { + shape.transform = value; + } + if let Some(value) = &patch.properties { + shape.properties.clone_from(value); + } + if let Some(value) = &patch.metadata { + shape.metadata.clone_from(value); + } + if let Some(value) = patch.style { + shape.style = value; + } + if let Some(value) = &patch.layout { + shape.layout.clone_from(value); + } + shape.version = next_version(shape.version)?; + Ok(vec![Operation::PatchShape { + shape_id: shape_id.clone(), + patch: inverse, + expected_version: Some(shape.version), + }]) +} + +pub fn patch_asset( + document: &mut Document, asset_id: &AssetId, patch: &AssetPatch, expected: Option, +) -> Result, EngineError> { + let asset = asset_mut(document, asset_id, expected)?; + let inverse = AssetPatch { + name: patch.name.as_ref().map(|_| asset.name.clone()), + provenance_source: patch + .provenance_source + .as_ref() + .map(|_| asset.provenance.source.clone()), + }; + if let Some(value) = &patch.name { + if value.trim().is_empty() { + return Err(EngineError::Schema("asset name is empty".into())); + } + asset.name.clone_from(value); + } + if let Some(value) = &patch.provenance_source { + asset.provenance.source.clone_from(value); + } + asset.version = next_version(asset.version)?; + Ok(vec![Operation::PatchAsset { + asset_id: asset_id.clone(), + patch: inverse, + expected_version: Some(asset.version), + }]) +} + +pub fn reorder_layer( + document: &mut Document, layer_id: &LayerId, anchor: &SiblingAnchor, expected: Option, +) -> Result, EngineError> { + let layer = layer(document, layer_id, expected)?.clone(); + let page = document + .pages + .get_mut(&layer.page_id) + .ok_or_else(|| EngineError::Invariant(format!("missing page {}", layer.page_id)))?; + let old_anchor = anchor_for(&page.layer_ids, layer_id)?; + move_anchored(&mut page.layer_ids, layer_id, anchor)?; + page.version = next_version(page.version)?; + let layer = document + .layers + .get_mut(layer_id) + .ok_or_else(|| EngineError::Invariant(format!("layer {layer_id} disappeared during reorder")))?; + layer.version = next_version(layer.version)?; + Ok(vec![Operation::ReorderLayer { + layer_id: layer_id.clone(), + anchor: old_anchor, + expected_version: Some(layer.version), + }]) +} + +pub fn reparent_shape( + document: &mut Document, shape_id: &ShapeId, parent: &ShapeParent, anchor: &SiblingAnchor, + expected: Option, +) -> Result, EngineError> { + let shape = shape(document, shape_id, expected)?.clone(); + if parent == &ShapeParent::Shape(shape_id.clone()) || is_descendant(document, shape_id, parent) { + return Err(EngineError::Invariant(format!( + "reparenting {shape_id} would create a cycle" + ))); + } + let old_siblings = shape_siblings(document, &shape.parent)?; + let old_anchor = anchor_for(old_siblings, shape_id)?; + remove_shape_child(document, &shape.parent, shape_id)?; + insert_shape_child(document, parent, shape_id.clone(), anchor)?; + let changed = document + .shapes + .get_mut(shape_id) + .ok_or_else(|| EngineError::Invariant(format!("shape {shape_id} disappeared during reparent")))?; + changed.parent = parent.clone(); + changed.version = next_version(changed.version)?; + Ok(vec![Operation::ReparentShape { + shape_id: shape_id.clone(), + parent: shape.parent, + anchor: old_anchor, + expected_version: Some(changed.version), + }]) +} + +pub fn delete_page( + document: &mut Document, page_id: &PageId, expected: Option, +) -> Result, EngineError> { + let page = page(document, page_id, expected)?.clone(); + let anchor = anchor_for(&document.page_ids, page_id)?; + let layer_ids = page.layer_ids.clone(); + let shape_ids: BTreeSet<_> = layer_ids + .iter() + .flat_map(|layer_id| descendant_ids_for_layer(document, layer_id)) + .collect(); + let mut inverse = vec![Operation::CreatePage { + page: crate::PageRecord { layer_ids: Vec::new(), version: RecordVersion(1), ..page.clone() }, + anchor, + }]; + for layer_id in &layer_ids { + let mut layer = document + .layers + .get(layer_id) + .cloned() + .ok_or_else(|| EngineError::Invariant(format!("page {page_id} owns missing layer {layer_id}")))?; + layer.shape_ids.clear(); + layer.version = RecordVersion(1); + inverse.push(Operation::CreateLayer { layer, anchor: SiblingAnchor::Last }); + } + append_shape_restoration(document, &shape_ids, &mut inverse); + append_binding_restoration(document, &shape_ids, &mut inverse); + document.page_ids.retain(|id| id != page_id); + for binding_id in bindings_touching(document, &shape_ids) { + document.bindings.remove(&binding_id); + } + for shape_id in &shape_ids { + document.shapes.remove(shape_id); + } + for layer_id in layer_ids { + document.layers.remove(&layer_id); + } + document.pages.remove(page_id); + Ok(inverse) +} + +pub fn delete_layer( + document: &mut Document, layer_id: &LayerId, contents: &LayerContentsDisposition, expected: Option, +) -> Result, EngineError> { + let layer = layer(document, layer_id, expected)?.clone(); + let page = document + .pages + .get(&layer.page_id) + .ok_or_else(|| EngineError::Invariant(format!("layer {layer_id} owns missing page {}", layer.page_id)))?; + let anchor = anchor_for(&page.layer_ids, layer_id)?; + let shape_ids: BTreeSet<_> = descendant_ids_for_layer(document, layer_id).collect(); + match contents { + LayerContentsDisposition::MoveTo(destination) => { + if destination == layer_id { + return Err(EngineError::Precondition( + "layer contents destination is the deleted layer".into(), + )); + } + let destination_layer = document + .layers + .get(destination) + .ok_or_else(|| EngineError::Precondition(format!("destination layer {destination} is missing")))?; + if destination_layer.page_id != layer.page_id { + return Err(EngineError::Invariant( + "layer contents must stay on the same page".into(), + )); + } + let root_ids = layer.shape_ids.clone(); + let mut inverse = vec![Operation::CreateLayer { + layer: crate::LayerRecord { shape_ids: Vec::new(), version: RecordVersion(1), ..layer.clone() }, + anchor, + }]; + for shape_id in &root_ids { + inverse.push(Operation::ReparentShape { + shape_id: shape_id.clone(), + parent: ShapeParent::Layer(layer_id.clone()), + anchor: SiblingAnchor::Last, + expected_version: None, + }); + } + for shape_id in root_ids { + insert_shape_child( + document, + &ShapeParent::Layer(destination.clone()), + shape_id.clone(), + &SiblingAnchor::Last, + )?; + let shape = document + .shapes + .get_mut(&shape_id) + .ok_or_else(|| EngineError::Invariant(format!("missing root shape {shape_id}")))?; + shape.parent = ShapeParent::Layer(destination.clone()); + shape.version = next_version(shape.version)?; + } + remove_layer_record(document, &layer)?; + Ok(inverse) + } + LayerContentsDisposition::Delete => { + let mut inverse = vec![Operation::CreateLayer { + layer: crate::LayerRecord { shape_ids: Vec::new(), version: RecordVersion(1), ..layer.clone() }, + anchor, + }]; + append_shape_restoration(document, &shape_ids, &mut inverse); + append_binding_restoration(document, &shape_ids, &mut inverse); + for binding_id in bindings_touching(document, &shape_ids) { + document.bindings.remove(&binding_id); + } + for shape_id in shape_ids { + document.shapes.remove(&shape_id); + } + remove_layer_record(document, &layer)?; + Ok(inverse) + } + } +} + +pub fn remove_layer_record(document: &mut Document, layer: &crate::LayerRecord) -> Result<(), EngineError> { + let page = document + .pages + .get_mut(&layer.page_id) + .ok_or_else(|| EngineError::Invariant(format!("missing page {}", layer.page_id)))?; + page.layer_ids.retain(|id| id != &layer.id); + page.version = next_version(page.version)?; + document.layers.remove(&layer.id); + Ok(()) +} + +pub fn delete_shape( + document: &mut Document, shape_id: &ShapeId, expected: Option, +) -> Result, EngineError> { + let root = shape(document, shape_id, expected)?.clone(); + let shape_ids: BTreeSet<_> = std::iter::once(shape_id.clone()) + .chain(descendant_ids_for_shape(document, shape_id)) + .collect(); + let mut inverse = Vec::new(); + append_shape_restoration(document, &shape_ids, &mut inverse); + append_binding_restoration(document, &shape_ids, &mut inverse); + remove_shape_child(document, &root.parent, shape_id)?; + for binding_id in bindings_touching(document, &shape_ids) { + document.bindings.remove(&binding_id); + } + for id in shape_ids { + document.shapes.remove(&id); + } + Ok(inverse) +} + +pub fn append_shape_restoration(document: &Document, shape_ids: &BTreeSet, operations: &mut Vec) { + let mut remaining = shape_ids.clone(); + while !remaining.is_empty() { + let ready: Vec<_> = remaining + .iter() + .filter(|id| { + document.shapes.get(*id).is_some_and(|shape| match &shape.parent { + ShapeParent::Layer(_) => true, + ShapeParent::Shape(parent_id) => !remaining.contains(parent_id), + }) + }) + .cloned() + .collect(); + if ready.is_empty() { + break; + } + for id in ready { + if let Some(shape) = document.shapes.get(&id) { + let mut shape = shape.clone(); + shape.child_ids.clear(); + shape.version = RecordVersion(1); + operations.push(Operation::CreateShape { shape, anchor: SiblingAnchor::Last }); + } + remaining.remove(&id); + } + } +} + +pub fn append_binding_restoration(document: &Document, shape_ids: &BTreeSet, operations: &mut Vec) { + for binding in document.bindings.values() { + if shape_ids.contains(&binding.source_shape_id) || shape_ids.contains(&binding.target_shape_id) { + operations.push(Operation::CreateBinding { + binding: crate::BindingRecord { version: RecordVersion(1), ..binding.clone() }, + }); + } + } +} + +pub fn align_shapes( + document: &mut Document, shape_ids: &[ShapeId], alignment: ShapeAlignment, + expected_versions: &BTreeMap, +) -> Result, EngineError> { + require_distinct_shapes(document, shape_ids, 2, expected_versions)?; + require_common_parent(document, shape_ids)?; + let bounds: Vec<_> = shape_ids + .iter() + .map(|id| { + document + .shapes + .get(id) + .map(local_shape_bounds) + .ok_or_else(|| EngineError::Precondition(format!("shape {id} is missing"))) + }) + .collect::>()?; + let target = match alignment { + ShapeAlignment::Left => bounds.iter().map(|bounds| bounds.x).fold(f64::INFINITY, f64::min), + ShapeAlignment::Center => bounds.iter().map(center_x).sum::() / count_as_f64(bounds.len())?, + ShapeAlignment::Right => bounds.iter().map(right).fold(f64::NEG_INFINITY, f64::max), + ShapeAlignment::Top => bounds.iter().map(|bounds| bounds.y).fold(f64::INFINITY, f64::min), + ShapeAlignment::Middle => bounds.iter().map(center_y).sum::() / count_as_f64(bounds.len())?, + ShapeAlignment::Bottom => bounds.iter().map(bottom).fold(f64::NEG_INFINITY, f64::max), + }; + let deltas = shape_ids + .iter() + .zip(&bounds) + .map(|(id, bounds)| { + let delta = match alignment { + ShapeAlignment::Left => (target - bounds.x, 0.0), + ShapeAlignment::Center => (target - center_x(bounds), 0.0), + ShapeAlignment::Right => (target - right(bounds), 0.0), + ShapeAlignment::Top => (0.0, target - bounds.y), + ShapeAlignment::Middle => (0.0, target - center_y(bounds)), + ShapeAlignment::Bottom => (0.0, target - bottom(bounds)), + }; + (id.clone(), delta) + }) + .collect(); + apply_layout_translations(document, shape_ids, &deltas) +} + +pub fn distribute_shapes( + document: &mut Document, shape_ids: &[ShapeId], axis: LayoutAxis, + expected_versions: &BTreeMap, +) -> Result, EngineError> { + require_distinct_shapes(document, shape_ids, 3, expected_versions)?; + require_common_parent(document, shape_ids)?; + let mut ordered: Vec<_> = shape_ids + .iter() + .map(|id| { + document + .shapes + .get(id) + .map(|shape| (id.clone(), local_shape_bounds(shape))) + .ok_or_else(|| EngineError::Precondition(format!("shape {id} is missing"))) + }) + .collect::>()?; + ordered.sort_by(|left, right| { + let left_position = match axis { + LayoutAxis::Horizontal => left.1.x, + LayoutAxis::Vertical => left.1.y, + }; + let right_position = match axis { + LayoutAxis::Horizontal => right.1.x, + LayoutAxis::Vertical => right.1.y, + }; + left_position + .total_cmp(&right_position) + .then_with(|| left.0.cmp(&right.0)) + }); + let first = ordered + .first() + .ok_or_else(|| EngineError::Schema("distribution selection is empty".into()))?; + let last = ordered + .last() + .ok_or_else(|| EngineError::Schema("distribution selection is empty".into()))?; + let start = match axis { + LayoutAxis::Horizontal => first.1.x, + LayoutAxis::Vertical => first.1.y, + }; + let end = match axis { + LayoutAxis::Horizontal => right(&last.1), + LayoutAxis::Vertical => bottom(&last.1), + }; + let total_size: f64 = ordered + .iter() + .map(|(_, bounds)| match axis { + LayoutAxis::Horizontal => bounds.width, + LayoutAxis::Vertical => bounds.height, + }) + .sum(); + let gap = (end - start - total_size) / count_as_f64(ordered.len() - 1)?; + let mut cursor = start; + let mut deltas = BTreeMap::new(); + for (id, bounds) in &ordered { + let position = match axis { + LayoutAxis::Horizontal => bounds.x, + LayoutAxis::Vertical => bounds.y, + }; + let delta = cursor - position; + deltas.insert( + id.clone(), + match axis { + LayoutAxis::Horizontal => (delta, 0.0), + LayoutAxis::Vertical => (0.0, delta), + }, + ); + cursor += match axis { + LayoutAxis::Horizontal => bounds.width, + LayoutAxis::Vertical => bounds.height, + } + gap; + } + apply_layout_translations(document, shape_ids, &deltas) +} + +pub fn apply_layout_translations( + document: &mut Document, shape_ids: &[ShapeId], deltas: &BTreeMap, +) -> Result, EngineError> { + let mut inverse = Vec::new(); + for shape_id in shape_ids { + let shape = document + .shapes + .get_mut(shape_id) + .ok_or_else(|| EngineError::Precondition(format!("shape {shape_id} is missing")))?; + let old_transform = shape.transform; + let (x, y) = deltas + .get(shape_id) + .copied() + .ok_or_else(|| EngineError::Invariant(format!("shape {shape_id} has no layout delta")))?; + shape.transform.translation.x += x; + shape.transform.translation.y += y; + shape.version = next_version(shape.version)?; + inverse.push(Operation::PatchShape { + shape_id: shape_id.clone(), + patch: ShapePatch { transform: Some(old_transform), ..ShapePatch::default() }, + expected_version: Some(shape.version), + }); + } + Ok(inverse) +} + +pub fn require_distinct_shapes( + document: &Document, shape_ids: &[ShapeId], minimum: usize, expected_versions: &BTreeMap, +) -> Result<(), EngineError> { + let unique: BTreeSet<_> = shape_ids.iter().collect(); + if unique.len() != shape_ids.len() || shape_ids.len() < minimum { + return Err(EngineError::Schema(format!( + "layout operation requires at least {minimum} distinct shapes" + ))); + } + for shape_id in shape_ids { + shape(document, shape_id, expected_versions.get(shape_id).copied())?; + } + Ok(()) +} + +pub fn require_common_parent(document: &Document, shape_ids: &[ShapeId]) -> Result<(), EngineError> { + let first = &document + .shapes + .get( + shape_ids + .first() + .ok_or_else(|| EngineError::Schema("layout operation selection is empty".into()))?, + ) + .ok_or_else(|| EngineError::Precondition("layout shape is missing".into()))? + .parent; + for id in shape_ids.iter().skip(1) { + let shape = document + .shapes + .get(id) + .ok_or_else(|| EngineError::Precondition(format!("shape {id} is missing")))?; + if &shape.parent != first { + return Err(EngineError::Invariant( + "alignment and distribution require a common parent".into(), + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rename_page_returns_the_inverse_operation() { + let mut document = crate::engine::tests::document(); + let inverse = apply_operation( + &mut document, + &Operation::RenamePage { + page_id: PageId::from("page:one"), + name: "Renamed".into(), + expected_version: Some(RecordVersion(1)), + }, + ) + .unwrap(); + assert_eq!(document.pages[&PageId::from("page:one")].name, "Renamed"); + assert!(matches!( + &inverse[0], + Operation::RenamePage { name, .. } if name == "Page" + )); + } +} diff --git a/crates/inkfinite-core/src/engine/policy.rs b/crates/inkfinite-core/src/engine/policy.rs new file mode 100644 index 0000000..b23fa3c --- /dev/null +++ b/crates/inkfinite-core/src/engine/policy.rs @@ -0,0 +1,106 @@ +use super::hierarchy::{ + containing_layer, descendant_ids_for_layer, descendant_ids_for_shape, operation_layer_id, operation_shape_ids, +}; +use super::{Document, EngineError, Operation, Origin, TransactionDraft}; + +pub fn validate_transaction_schema(transaction: &TransactionDraft) -> Result<(), EngineError> { + if transaction.id.0.trim().is_empty() { + return Err(EngineError::Schema("transaction ID is empty".into())); + } + if transaction.actor_id.as_str().trim().is_empty() { + return Err(EngineError::Schema("actor ID is empty".into())); + } + if transaction.description.trim().is_empty() { + return Err(EngineError::Schema("description is empty".into())); + } + if transaction.operations.is_empty() { + return Err(EngineError::Schema("operations are empty".into())); + } + Ok(()) +} + +pub fn validate_permissions(document: &Document, operation: &Operation, origin: &Origin) -> Result<(), EngineError> { + let mut shape_ids = operation_shape_ids(operation); + match operation { + Operation::DeletePage { page_id, .. } => { + if let Some(page) = document.pages.get(page_id) { + shape_ids.extend( + page.layer_ids + .iter() + .flat_map(|layer_id| descendant_ids_for_layer(document, layer_id)), + ); + } + } + Operation::DeleteLayer { layer_id, .. } => { + shape_ids.extend(descendant_ids_for_layer(document, layer_id)); + } + Operation::DeleteShape { shape_id, .. } => { + shape_ids.extend(descendant_ids_for_shape(document, shape_id)); + } + _ => {} + } + shape_ids.sort(); + shape_ids.dedup(); + for shape_id in shape_ids { + let Some(shape) = document.shapes.get(&shape_id) else { + continue; + }; + if shape.metadata.locked { + return Err(EngineError::Permission(format!("shape {shape_id} is locked"))); + } + if matches!(origin, Origin::Agent) && !shape.metadata.agent_editable { + return Err(EngineError::Permission(format!( + "shape {shape_id} is not agent-editable" + ))); + } + if matches!(origin, Origin::Agent) && containing_layer(document, shape).is_some_and(|layer| !layer.visible) { + return Err(EngineError::Permission(format!( + "shape {shape_id} is hidden from agents" + ))); + } + if let Some(layer) = containing_layer(document, shape) + && layer.locked + { + return Err(EngineError::Permission(format!("layer {} is locked", layer.id))); + } + } + if let Some(layer_id) = operation_layer_id(operation) + && document.layers.get(&layer_id).is_some_and(|layer| layer.locked) + { + let unlock_only = matches!(operation, + Operation::PatchLayer { patch, .. } + if patch.locked == Some(false) + && patch.name.is_none() + && patch.visible.is_none() + && patch.opacity.is_none() + ); + if !unlock_only { + return Err(EngineError::Permission(format!("layer {layer_id} is locked"))); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ActorId; + use crate::proto::TransactionId; + + #[test] + fn an_empty_transaction_is_rejected_by_schema_policy() { + let transaction = TransactionDraft { + id: TransactionId("transaction".into()), + actor_id: ActorId::from("actor"), + origin: Origin::Human, + base_heads: Vec::new(), + description: "empty".into(), + operations: Vec::new(), + timestamp: crate::Timestamp(0), + }; + assert!(matches!( + validate_transaction_schema(&transaction), + Err(EngineError::Schema(message)) if message == "operations are empty" + )); + } +} diff --git a/crates/inkfinite-core/src/engine/query.rs b/crates/inkfinite-core/src/engine/query.rs new file mode 100644 index 0000000..ffaa654 --- /dev/null +++ b/crates/inkfinite-core/src/engine/query.rs @@ -0,0 +1,135 @@ +use super::geometry::{intersects, world_shape_bounds}; +use super::hierarchy::containing_layer; +use super::{BTreeMap, DocumentSnapshot, Ordering, Query, QueryResult, RecordId, ShapeParent}; + +#[allow(clippy::too_many_lines)] +pub fn query_document(snapshot: &DocumentSnapshot, query: &Query) -> QueryResult { + let document = &snapshot.document; + let mut records = Vec::new(); + let mut bounds = BTreeMap::new(); + for page in document.pages.values() { + if matches_common(query, page.id.as_str(), Some(&page.name)) + && query.role.is_none() + && query.tag.is_none() + && query.shape_kind.is_none() + && query.layer_id.is_none() + && query.parent_id.is_none() + && query.bounds.is_none() + { + records.push(RecordId::Page(page.id.clone())); + } + } + for layer in document.layers.values() { + if matches_common(query, layer.id.as_str(), Some(&layer.name)) + && query.role.is_none() + && query.tag.is_none() + && query.shape_kind.is_none() + && query.page_id.as_ref().is_none_or(|id| id == &layer.page_id) + && query.layer_id.as_ref().is_none_or(|id| id == &layer.id) + && query.parent_id.is_none() + && query.bounds.is_none() + { + records.push(RecordId::Layer(layer.id.clone())); + } + } + for shape in document.shapes.values() { + let containing_layer = containing_layer(document, shape); + if containing_layer.is_some_and(|layer| !layer.visible) { + continue; + } + let shape_bounds = world_shape_bounds(document, &shape.id); + let layer = containing_layer.map(|layer| layer.id.clone()); + let page = layer + .as_ref() + .and_then(|id| document.layers.get(id)) + .map(|layer| layer.page_id.clone()); + let parent = match &shape.parent { + ShapeParent::Layer(id) => id.as_str(), + ShapeParent::Shape(id) => id.as_str(), + }; + let matches = matches_common(query, shape.id.as_str(), shape.metadata.name.as_ref()) + && query + .role + .as_ref() + .is_none_or(|role| shape.metadata.role.as_ref() == Some(role)) + && query.tag.as_ref().is_none_or(|tag| shape.metadata.tags.contains(tag)) + && query.shape_kind.as_ref().is_none_or(|kind| shape.kind.as_str() == kind) + && query.page_id.as_ref().is_none_or(|id| page.as_ref() == Some(id)) + && query.layer_id.as_ref().is_none_or(|id| layer.as_ref() == Some(id)) + && query.parent_id.as_ref().is_none_or(|id| parent == id) + && query + .bounds + .as_ref() + .is_none_or(|filter| intersects(&shape_bounds, filter)); + if matches { + records.push(RecordId::Shape(shape.id.clone())); + bounds.insert(shape.id.clone(), shape_bounds); + } + } + for binding in document.bindings.values() { + if matches_common(query, binding.id.as_str(), None) + && query.role.is_none() + && query.tag.is_none() + && query.shape_kind.is_none() + && query.page_id.is_none() + && query.layer_id.is_none() + && query.parent_id.is_none() + && query.bounds.is_none() + { + records.push(RecordId::Binding(binding.id.clone())); + } + } + for asset in document.assets.values() { + if matches_common(query, asset.id.as_str(), Some(&asset.name)) + && query.role.is_none() + && query.tag.is_none() + && query.shape_kind.is_none() + && query.page_id.is_none() + && query.layer_id.is_none() + && query.parent_id.is_none() + && query.bounds.is_none() + { + records.push(RecordId::Asset(asset.id.clone())); + } + } + records.sort_by(record_id_order); + QueryResult { heads: snapshot.heads.clone(), records, bounds } +} + +pub fn matches_common(query: &Query, id: &str, name: Option<&String>) -> bool { + query.id.as_ref().is_none_or(|expected| expected == id) + && query + .name + .as_ref() + .is_none_or(|expected| name.is_some_and(|name| name == expected)) +} + +pub fn record_id_order(left: &RecordId, right: &RecordId) -> Ordering { + record_sort_key(left).cmp(&record_sort_key(right)) +} + +pub fn record_sort_key(record: &RecordId) -> (u8, &str) { + match record { + RecordId::Page(id) => (0, id.as_str()), + RecordId::Layer(id) => (1, id.as_str()), + RecordId::Shape(id) => (2, id.as_str()), + RecordId::Binding(id) => (3, id.as_str()), + RecordId::Asset(id) => (4, id.as_str()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{PageId, ShapeId}; + + #[test] + fn record_order_is_stable_across_record_kinds() { + let mut records = [ + RecordId::Shape(ShapeId::from("shape")), + RecordId::Page(PageId::from("page")), + ]; + records.sort_by(record_id_order); + assert!(matches!(records[0], RecordId::Page(_))); + } +} diff --git a/crates/inkfinite-core/src/engine/repair.rs b/crates/inkfinite-core/src/engine/repair.rs new file mode 100644 index 0000000..09c9a4c --- /dev/null +++ b/crates/inkfinite-core/src/engine/repair.rs @@ -0,0 +1,235 @@ +use super::hierarchy::{next_version, warning}; +use super::{ + BTreeSet, Document, EngineError, LayerId, RecordId, RecordVersion, ShapeParent, Warning, validate_document, +}; + +/// Repairs deterministic hierarchy damage after concurrent CRDT merges. +/// +/// # Errors +/// +/// Returns an invariant error when the document has no page to own recovered +/// records or when a repaired record version overflows. +#[allow(clippy::too_many_lines)] +pub fn repair_document(document: &mut Document) -> Result, EngineError> { + let original = document.clone(); + let mut warnings = Vec::new(); + if document.pages.is_empty() { + return Err(EngineError::Invariant("cannot repair a document with no pages".into())); + } + document.page_ids.retain(|id| document.pages.contains_key(id)); + document.page_ids.sort(); + document.page_ids.dedup(); + for page_id in document.pages.keys() { + if !document.page_ids.contains(page_id) { + document.page_ids.push(page_id.clone()); + } + } + document.page_ids.sort(); + + let page_ids: Vec<_> = document.page_ids.clone(); + for page_id in page_ids { + let page = document + .pages + .get(&page_id) + .ok_or_else(|| EngineError::Invariant(format!("page {page_id} disappeared")))?; + let valid_layers: Vec<_> = page + .layer_ids + .iter() + .filter(|layer_id| { + document + .layers + .get(*layer_id) + .is_some_and(|layer| layer.page_id == page_id) + }) + .cloned() + .collect(); + let mut layers = valid_layers; + layers.sort(); + layers.dedup(); + if layers.is_empty() { + let layer_id = LayerId::new(format!("layer:recovered:{}", page_id.as_str())); + document + .layers + .entry(layer_id.clone()) + .or_insert_with(|| crate::LayerRecord { + id: layer_id.clone(), + page_id: page_id.clone(), + name: "Recovered".into(), + shape_ids: Vec::new(), + visible: true, + locked: false, + opacity: crate::Opacity::OPAQUE, + version: RecordVersion(1), + }); + layers.push(layer_id.clone()); + warnings.push(warning( + "recovered_layer", + format!("created {layer_id}"), + vec![RecordId::Layer(layer_id)], + )); + } + let page = document + .pages + .get_mut(&page_id) + .ok_or_else(|| EngineError::Invariant(format!("page {page_id} disappeared")))?; + page.layer_ids = layers; + } + let owned_layers: BTreeSet<_> = document + .pages + .values() + .flat_map(|page| page.layer_ids.iter().cloned()) + .collect(); + document.layers.retain(|id, _| owned_layers.contains(id)); + let fallback = document + .pages + .values() + .flat_map(|page| page.layer_ids.iter()) + .min() + .cloned() + .ok_or_else(|| EngineError::Invariant("repair produced no recovery layer".into()))?; + + let valid_shapes: BTreeSet<_> = document.shapes.keys().cloned().collect(); + for shape in document.shapes.values_mut() { + let valid_parent = match &shape.parent { + ShapeParent::Layer(id) => document.layers.contains_key(id), + ShapeParent::Shape(id) => valid_shapes.contains(id) && id != &shape.id, + }; + if !valid_parent { + shape.parent = ShapeParent::Layer(fallback.clone()); + warnings.push(warning( + "recovered_parent", + format!("moved {} to {fallback}", shape.id), + vec![RecordId::Shape(shape.id.clone())], + )); + } + shape.child_ids.clear(); + } + for layer in document.layers.values_mut() { + layer.shape_ids.clear(); + } + break_parent_cycles(document, &fallback, &mut warnings); + let parents: Vec<_> = document + .shapes + .values() + .map(|shape| (shape.id.clone(), shape.parent.clone())) + .collect(); + for (shape_id, parent) in parents { + match parent { + ShapeParent::Layer(layer_id) => { + let layer = document + .layers + .get_mut(&layer_id) + .ok_or_else(|| EngineError::Invariant(format!("repair lost parent layer {layer_id}")))?; + layer.shape_ids.push(shape_id); + } + ShapeParent::Shape(parent_id) => { + let shape = document + .shapes + .get_mut(&parent_id) + .ok_or_else(|| EngineError::Invariant(format!("repair lost parent shape {parent_id}")))?; + shape.child_ids.push(shape_id); + } + } + } + for layer in document.layers.values_mut() { + layer.shape_ids.sort(); + layer.shape_ids.dedup(); + } + for shape in document.shapes.values_mut() { + shape.child_ids.sort(); + shape.child_ids.dedup(); + } + let before_bindings = document.bindings.len(); + document.bindings.retain(|_, binding| { + valid_shapes.contains(&binding.source_shape_id) && valid_shapes.contains(&binding.target_shape_id) + }); + if document.bindings.len() != before_bindings { + warnings.push(warning( + "removed_dangling_binding", + "removed bindings with missing endpoints".into(), + Vec::new(), + )); + } + let changed_before_versions = document != &original; + for (id, page) in &mut document.pages { + if let Some(before) = original.pages.get(id) + && page != before + { + page.version = next_version(before.version)?; + } + } + for (id, layer) in &mut document.layers { + if let Some(before) = original.layers.get(id) + && layer != before + { + layer.version = next_version(before.version)?; + } + } + for (id, shape) in &mut document.shapes { + if let Some(before) = original.shapes.get(id) + && shape != before + { + shape.version = next_version(before.version)?; + } + } + if changed_before_versions && warnings.is_empty() { + warnings.push(warning( + "normalized_hierarchy", + "normalized hierarchy after merge".into(), + Vec::new(), + )); + } + validate_document(document)?; + Ok(warnings) +} + +pub fn break_parent_cycles(document: &mut Document, fallback: &LayerId, warnings: &mut Vec) { + let shape_ids: Vec<_> = document.shapes.keys().cloned().collect(); + for start in shape_ids { + let mut path = Vec::new(); + let mut current = start.clone(); + while let Some(shape) = document.shapes.get(¤t) { + if let Some(position) = path.iter().position(|id| id == ¤t) { + let cycle = &path[position..]; + if let Some(chosen) = cycle.iter().max().cloned() { + if let Some(shape) = document.shapes.get_mut(&chosen) { + shape.parent = ShapeParent::Layer(fallback.clone()); + } + warnings.push(warning( + "recovered_cycle", + format!("moved {chosen} to {fallback}"), + vec![RecordId::Shape(chosen)], + )); + } + break; + } + path.push(current.clone()); + match &shape.parent { + ShapeParent::Shape(parent) => current = parent.clone(), + ShapeParent::Layer(_) => break, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::PageId; + + #[test] + fn hierarchy_repair_is_idempotent() { + let mut document = crate::engine::tests::document(); + document + .pages + .get_mut(&PageId::from("page:one")) + .unwrap() + .layer_ids + .clear(); + document.layers.clear(); + repair_document(&mut document).unwrap(); + let repaired = document.clone(); + assert!(repair_document(&mut document).unwrap().is_empty()); + assert_eq!(document, repaired); + } +} diff --git a/crates/inkfinite-core/src/engine/tests.rs b/crates/inkfinite-core/src/engine/tests.rs index a0624ea..650a0fe 100644 --- a/crates/inkfinite-core/src/engine/tests.rs +++ b/crates/inkfinite-core/src/engine/tests.rs @@ -50,7 +50,7 @@ fn shape(id: &str, x: f64) -> ShapeRecord { } } -fn document() -> Document { +pub fn document() -> Document { let page_id = PageId::from("page:one"); let layer_id = LayerId::from("layer:one"); let shape_a = shape("shape:a", 0.0); @@ -308,6 +308,37 @@ fn layer_visual_changes_return_regions_and_deletes_honor_locked_descendants() { }], ); assert_eq!(engine.commit(hide).unwrap().affected_regions.len(), 1); + assert!( + engine + .query(&Query { shape_kind: Some("rect".into()), ..Query::default() }) + .unwrap() + .records + .is_empty(), + "agent-facing queries must not expose shapes in hidden layers" + ); + let mut hidden_agent_edit = transaction( + &mut engine, + "actor:agent", + "edit hidden shape", + vec![Operation::PatchShape { + shape_id: ShapeId::from("shape:a"), + patch: ShapePatch { + transform: Some(Transform { + translation: Vec2 { x: 5.0, y: 20.0 }, + rotation: 0.0, + scale_x: 1.0, + scale_y: 1.0, + }), + ..ShapePatch::default() + }, + expected_version: Some(RecordVersion(1)), + }], + ); + hidden_agent_edit.origin = Origin::Agent; + assert!(matches!( + engine.commit(hidden_agent_edit), + Err(EngineError::Permission(_)) + )); let mut locked_document = document(); locked_document @@ -338,6 +369,30 @@ fn layer_visual_changes_return_regions_and_deletes_honor_locked_descendants() { )); } +#[test] +fn a_locked_layer_can_be_unlocked_but_not_changed_in_the_same_operation() { + let mut document = document(); + document.layers.get_mut(&LayerId::from("layer:one")).unwrap().locked = true; + let mut engine = TransactionEngine::create( + DocumentId::from("document:locked-layer"), + ActorId::from("actor:a"), + document, + ) + .unwrap(); + let unlock = transaction( + &mut engine, + "actor:a", + "unlock layer", + vec![Operation::PatchLayer { + layer_id: LayerId::from("layer:one"), + patch: LayerPatch { locked: Some(false), ..LayerPatch::default() }, + expected_version: Some(RecordVersion(1)), + }], + ); + engine.commit(unlock).unwrap(); + assert!(!engine.snapshot().unwrap().document.layers[&LayerId::from("layer:one")].locked); +} + #[test] fn permissions_preconditions_and_final_invariants_reject_without_mutation() { let mut engine = engine(); diff --git a/crates/inkfinite-core/src/engine/validation.rs b/crates/inkfinite-core/src/engine/validation.rs new file mode 100644 index 0000000..56baa8f --- /dev/null +++ b/crates/inkfinite-core/src/engine/validation.rs @@ -0,0 +1,226 @@ +use super::{BTreeSet, ContainerLayout, Document, EngineError, ShapeId, ShapeParent, ShapeRecord}; + +/// Validates normalized document ownership, references, geometry, and layout. +/// +/// # Errors +/// +/// Returns [`EngineError::Invariant`] or [`EngineError::Schema`] with the first +/// invalid ownership, reference, geometry, or layout condition. +pub fn validate_document(document: &Document) -> Result<(), EngineError> { + if document.pages.is_empty() || document.page_ids.is_empty() { + return Err(EngineError::Invariant("document must contain at least one page".into())); + } + ensure_unique_and_complete(&document.page_ids, document.pages.keys().cloned(), "page")?; + let mut listed_layers = BTreeSet::new(); + for page in document.pages.values() { + if page.name.trim().is_empty() || page.layer_ids.is_empty() { + return Err(EngineError::Invariant(format!( + "page {} needs a name and at least one layer", + page.id + ))); + } + for layer_id in &page.layer_ids { + if !listed_layers.insert(layer_id.clone()) { + return Err(EngineError::Invariant(format!( + "layer {layer_id} is listed more than once" + ))); + } + let layer = document.layers.get(layer_id).ok_or_else(|| { + EngineError::Invariant(format!("page {} refers to missing layer {layer_id}", page.id)) + })?; + if layer.page_id != page.id { + return Err(EngineError::Invariant(format!( + "layer {layer_id} has inconsistent page ownership" + ))); + } + } + } + if listed_layers.len() != document.layers.len() { + return Err(EngineError::Invariant("one or more layers are unlisted".into())); + } + let mut listed_shapes = BTreeSet::new(); + for layer in document.layers.values() { + if layer.name.trim().is_empty() { + return Err(EngineError::Invariant(format!("layer {} has an empty name", layer.id))); + } + for shape_id in &layer.shape_ids { + validate_child( + document, + &mut listed_shapes, + shape_id, + &ShapeParent::Layer(layer.id.clone()), + )?; + } + } + for shape in document.shapes.values() { + validate_shape_schema(shape)?; + for child_id in &shape.child_ids { + validate_child( + document, + &mut listed_shapes, + child_id, + &ShapeParent::Shape(shape.id.clone()), + )?; + } + ensure_acyclic(document, &shape.id)?; + } + if listed_shapes.len() != document.shapes.len() { + return Err(EngineError::Invariant("one or more shapes are unlisted".into())); + } + for binding in document.bindings.values() { + ensure_binding_endpoints(document, binding)?; + } + Ok(()) +} + +pub fn validate_shape_schema(shape: &ShapeRecord) -> Result<(), EngineError> { + crate::validate_shape_properties(shape.kind.as_str(), &shape.properties) + .map_err(|error| EngineError::Schema(format!("shape {}: {error}", shape.id)))?; + if shape.kind.as_str() != crate::CONTAINER_KIND && (!shape.child_ids.is_empty() || shape.layout.is_some()) { + return Err(EngineError::Schema(format!( + "non-container shape {} owns children or layout", + shape.id + ))); + } + let transform = shape.transform; + if ![ + transform.translation.x, + transform.translation.y, + transform.rotation, + transform.scale_x, + transform.scale_y, + ] + .into_iter() + .all(f64::is_finite) + || transform.scale_x == 0.0 + || transform.scale_y == 0.0 + { + return Err(EngineError::Schema(format!( + "shape {} has an invalid transform", + shape.id + ))); + } + if let Some(layout) = &shape.layout { + match layout { + ContainerLayout::Free => {} + ContainerLayout::Stack { gap, padding, .. } => { + validate_layout_numbers(shape, *gap, padding)?; + } + ContainerLayout::Grid { columns, column_gap, row_gap, padding, .. } => { + if *columns == 0 { + return Err(EngineError::Schema(format!("shape {} grid has no columns", shape.id))); + } + validate_layout_numbers(shape, *column_gap, padding)?; + if !row_gap.is_finite() || *row_gap < 0.0 { + return Err(EngineError::Schema(format!("shape {} has invalid row gap", shape.id))); + } + } + } + } + Ok(()) +} + +pub fn validate_layout_numbers(shape: &ShapeRecord, gap: f64, padding: &crate::Insets) -> Result<(), EngineError> { + if ![gap, padding.top, padding.right, padding.bottom, padding.left] + .into_iter() + .all(|value| value.is_finite() && value >= 0.0) + { + return Err(EngineError::Schema(format!( + "shape {} has invalid layout spacing", + shape.id + ))); + } + Ok(()) +} + +/// Repairs merge-created hierarchy damage using stable IDs and sorted order. +/// +/// # Errors +/// +/// Returns an error when the document has no page or when deterministic repair +/// cannot produce a valid normalized document. +#[allow(clippy::too_many_lines)] +pub fn ensure_unique_and_complete<'a, I>(listed: &[I::Item], keys: I, name: &str) -> Result<(), EngineError> +where + I: Iterator, + I::Item: Ord + Clone + std::fmt::Display + 'a, +{ + let listed_set: BTreeSet<_> = listed.iter().cloned().collect(); + if listed_set.len() != listed.len() { + return Err(EngineError::Invariant(format!("duplicate {name} ordering entry"))); + } + let keys_set: BTreeSet<_> = keys.collect(); + if listed_set != keys_set { + return Err(EngineError::Invariant(format!( + "{name} ordering does not match records" + ))); + } + Ok(()) +} + +pub fn validate_child( + document: &Document, seen: &mut BTreeSet, child_id: &ShapeId, expected_parent: &ShapeParent, +) -> Result<(), EngineError> { + if !seen.insert(child_id.clone()) { + return Err(EngineError::Invariant(format!( + "shape {child_id} is listed more than once" + ))); + } + let child = document + .shapes + .get(child_id) + .ok_or_else(|| EngineError::Invariant(format!("missing child shape {child_id}")))?; + if &child.parent != expected_parent { + return Err(EngineError::Invariant(format!( + "shape {child_id} has inconsistent parent" + ))); + } + Ok(()) +} + +pub fn ensure_acyclic(document: &Document, start: &ShapeId) -> Result<(), EngineError> { + let mut seen = BTreeSet::new(); + let mut current = start.clone(); + while let Some(shape) = document.shapes.get(¤t) { + if !seen.insert(current.clone()) { + return Err(EngineError::Invariant(format!( + "shape hierarchy contains a cycle at {current}" + ))); + } + match &shape.parent { + ShapeParent::Shape(parent) => current = parent.clone(), + ShapeParent::Layer(_) => return Ok(()), + } + } + Ok(()) +} + +pub fn ensure_binding_endpoints(document: &Document, binding: &crate::BindingRecord) -> Result<(), EngineError> { + if !document.shapes.contains_key(&binding.source_shape_id) + || !document.shapes.contains_key(&binding.target_shape_id) + { + return Err(EngineError::Invariant(format!( + "binding {} has a missing endpoint", + binding.id + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::PageId; + + #[test] + fn a_page_without_a_layer_is_invalid() { + let mut document = crate::engine::tests::document(); + document + .pages + .get_mut(&PageId::from("page:one")) + .unwrap() + .layer_ids + .clear(); + assert!(matches!(validate_document(&document), Err(EngineError::Invariant(_)))); + } +} diff --git a/packages/core/src/geom.ts b/packages/core/src/geom.ts index f3d99e6..15223c2 100644 --- a/packages/core/src/geom.ts +++ b/packages/core/src/geom.ts @@ -14,7 +14,7 @@ import type { TextShape } from './model'; import type { EditorState } from './reactivity'; -import { getShapesOnCurrentPage } from './reactivity'; +import { getInteractiveShapesOnCurrentPage } from './reactivity'; const strokeOutlineCache = new WeakMap(); @@ -483,7 +483,7 @@ function worldToLocal(p: Vec2, shapeX: number, shapeY: number, shapeRot: number) * @returns Shape ID of the topmost shape under the point, or null if no hit */ export function hitTestPoint(state: EditorState, worldPoint: Vec2, tolerance = 5): string | null { - const shapes = getShapesOnCurrentPage(state); + const shapes = getInteractiveShapesOnCurrentPage(state); for (let index = shapes.length - 1; index >= 0; index--) { const shape = shapes[index]; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f39b0b0..3c29b58 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,17 +1,18 @@ -export * from "./actions"; -export * from "./camera"; -export * from "./cursor"; -export * from "./export"; -export * from "./geom"; -export * from "./history"; -export * from "./math"; -export * from "./model"; -export * from "./persistence/desktop"; -export * from "./persistence/document"; -export * from "./persistence/repo"; -export * from "./persistence/stats"; -export * from "./reactivity"; -export * as stencils from "./stencils"; -export * from "./tools"; -export * from "./ui/filebrowser"; -export * from "./ui/statusbar"; +export * from './actions'; +export * from './camera'; +export * from './cursor'; +export * from './export'; +export * from './geom'; +export * from './history'; +export * from './layers'; +export * from './math'; +export * from './model'; +export * from './persistence/desktop'; +export * from './persistence/document'; +export * from './persistence/repo'; +export * from './persistence/stats'; +export * from './reactivity'; +export * as stencils from './stencils'; +export * from './tools'; +export * from './ui/filebrowser'; +export * from './ui/statusbar'; diff --git a/packages/core/src/layers.ts b/packages/core/src/layers.ts new file mode 100644 index 0000000..373a402 --- /dev/null +++ b/packages/core/src/layers.ts @@ -0,0 +1,115 @@ +import type { EditorState } from './reactivity'; +import { createId, type LayerRecord } from './model'; + +/** Required handling for shapes when deleting a non-empty layer. */ +export type LayerDeleteDisposition = { kind: 'move'; destinationLayerId: string } | { kind: 'delete' }; + +/** Creates and activates a layer at the front of the current page. */ +export function createLayer(state: EditorState, name = 'Layer'): EditorState { + const pageId = state.ui.currentPageId; + if (!pageId) return state; + const page = state.doc.pages[pageId]; + if (!page) return state; + const layer: LayerRecord = { + id: createId('layer'), + pageId, + name: name.trim() || 'Layer', + shapeIds: [], + visible: true, + locked: false, + opacity: 1 + }; + return { + ...state, + doc: { + ...state.doc, + pages: { ...state.doc.pages, [pageId]: { ...page, layerIds: [...(page.layerIds ?? []), layer.id] } }, + layers: { ...(state.doc.layers ?? {}), [layer.id]: layer } + }, + ui: { ...state.ui, activeLayerId: layer.id, selectionIds: [] } + }; +} + +/** Changes mutable layer presentation fields. */ +export function patchLayer( + state: EditorState, + layerId: string, + patch: Partial> +): EditorState { + const layer = state.doc.layers?.[layerId]; + if (!layer) return state; + const name = patch.name === undefined ? layer.name : patch.name.trim(); + if (!name) return state; + const opacity = patch.opacity === undefined ? layer.opacity : Math.max(0, Math.min(1, patch.opacity)); + const nextLayer = { ...layer, ...patch, name, opacity }; + const hiddenOrLocked = !nextLayer.visible || nextLayer.locked; + const selectionIds = hiddenOrLocked + ? state.ui.selectionIds.filter((id) => !nextLayer.shapeIds.includes(id)) + : state.ui.selectionIds; + return { + ...state, + doc: { ...state.doc, layers: { ...state.doc.layers, [layerId]: nextLayer } }, + ui: { ...state.ui, selectionIds } + }; +} + +/** Moves a layer by one position in its page's draw order. */ +export function moveLayer(state: EditorState, layerId: string, direction: 'forward' | 'backward'): EditorState { + const layer = state.doc.layers?.[layerId]; + const page = layer ? state.doc.pages[layer.pageId] : undefined; + if (!layer || !page?.layerIds) return state; + const layerIds = [...page.layerIds]; + const index = layerIds.indexOf(layerId); + const destination = index + (direction === 'forward' ? 1 : -1); + if (index < 0 || destination < 0 || destination >= layerIds.length) return state; + [layerIds[index], layerIds[destination]] = [layerIds[destination], layerIds[index]]; + const shapeIds = layerIds.flatMap((id) => state.doc.layers?.[id]?.shapeIds ?? []); + return { + ...state, + doc: { ...state.doc, pages: { ...state.doc.pages, [page.id]: { ...page, layerIds, shapeIds } } } + }; +} + +/** Deletes a layer after the caller explicitly handles any contained shapes. */ +export function deleteLayer(state: EditorState, layerId: string, disposition?: LayerDeleteDisposition): EditorState { + const layer = state.doc.layers?.[layerId]; + const page = layer ? state.doc.pages[layer.pageId] : undefined; + if (!layer || !page?.layerIds || page.layerIds.length === 1) return state; + if (layer.shapeIds.length > 0 && !disposition) return state; + + const layers = { ...(state.doc.layers ?? {}) }; + const shapes = { ...state.doc.shapes }; + const bindings = { ...state.doc.bindings }; + if (disposition?.kind === 'move') { + const destination = layers[disposition.destinationLayerId]; + if (!destination || destination.pageId !== page.id || destination.id === layerId || destination.locked) + return state; + layers[destination.id] = { ...destination, shapeIds: [...destination.shapeIds, ...layer.shapeIds] }; + for (const shapeId of layer.shapeIds) shapes[shapeId] = { ...shapes[shapeId], layerId: destination.id }; + } else if (disposition?.kind === 'delete') { + const deletedIds = new Set(layer.shapeIds); + for (const shapeId of deletedIds) delete shapes[shapeId]; + for (const [bindingId, binding] of Object.entries(bindings)) { + if (deletedIds.has(binding.fromShapeId) || deletedIds.has(binding.toShapeId)) delete bindings[bindingId]; + } + } + delete layers[layerId]; + const layerIds = page.layerIds.filter((id) => id !== layerId); + const shapeIds = layerIds.flatMap((id) => layers[id]?.shapeIds ?? []); + const selectionIds = state.ui.selectionIds.filter((id) => Boolean(shapes[id])); + return { + ...state, + doc: { + ...state.doc, + layers, + shapes, + bindings, + pages: { ...state.doc.pages, [page.id]: { ...page, layerIds, shapeIds } } + }, + ui: { + ...state.ui, + activeLayerId: state.ui.activeLayerId === layerId ? layerIds.at(-1) : state.ui.activeLayerId, + selectionIds + } + }; +} diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index a10f6b0..f541e7f 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -1,31 +1,66 @@ -import { v4 } from "uuid"; -import type { Vec2 } from "./math"; +import { v4 } from 'uuid'; +import type { Vec2 } from './math'; /** * Generate a unique ID with an optional prefix * @param prefix - Optional prefix for the ID (e.g., 'shape', 'page', 'binding') * @returns A unique ID string (UUID v4 format with prefix) */ export function createId(prefix?: string): string { - const id = v4(); - return prefix ? `${prefix}:${id}` : id; + const id = v4(); + return prefix ? `${prefix}:${id}` : id; } -export type PageRecord = { id: string; name: string; shapeIds: string[] }; +export type PageRecord = { + id: string; + name: string; + /** Flat compatibility order. Layer order and each layer's shape order are authoritative. */ + shapeIds: string[]; + /** Layer IDs in back-to-front order. Older documents omit this field. */ + layerIds?: string[]; +}; export const PageRecord = { - /** - * Create a new page record - */ - create(name: string, id?: string): PageRecord { - return { id: id ?? createId("page"), name, shapeIds: [] }; - }, - - /** - * Clone a page record - */ - clone(page: PageRecord): PageRecord { - return { id: page.id, name: page.name, shapeIds: [...page.shapeIds] }; - }, + /** + * Create a new page record + */ + create(name: string, id?: string): PageRecord { + return { id: id ?? createId('page'), name, shapeIds: [], layerIds: [] }; + }, + + /** + * Clone a page record + */ + clone(page: PageRecord): PageRecord { + return { + id: page.id, + name: page.name, + shapeIds: [...page.shapeIds], + ...(page.layerIds ? { layerIds: [...page.layerIds] } : {}) + }; + } +}; + +/** Ordered visual layer owned by one page. */ +export type LayerRecord = { + id: string; + pageId: string; + name: string; + shapeIds: string[]; + visible: boolean; + locked: boolean; + opacity: number; +}; + +export const LayerRecord = { + /** Creates an empty, visible layer. */ + create(pageId: string, name = 'Layer', id?: string): LayerRecord { + return { id: id ?? createId('layer'), pageId, name, shapeIds: [], visible: true, locked: false, opacity: 1 }; + }, + + /** Clones a layer without sharing its child-order array. */ + clone(layer: LayerRecord): LayerRecord { + return { ...layer, shapeIds: [...layer.shapeIds] }; + } }; export type RectProps = { w: number; h: number; fill: string; stroke: string; radius: number }; @@ -35,7 +70,7 @@ export type LineProps = { a: Vec2; b: Vec2; stroke: string; width: number }; /** * Arrow endpoint binding metadata */ -export type ArrowEndpoint = { kind: "free" | "bound"; bindingId?: string }; +export type ArrowEndpoint = { kind: 'free' | 'bound'; bindingId?: string }; /** * Arrow style configuration @@ -45,24 +80,24 @@ export type ArrowStyle = { stroke: string; width: number; headStart?: boolean; h /** * Arrow routing configuration */ -export type ArrowRouting = { kind: "straight" | "orthogonal"; cornerRadius?: number }; +export type ArrowRouting = { kind: 'straight' | 'orthogonal'; cornerRadius?: number }; /** * Arrow label configuration */ -export type ArrowLabel = { text: string; align: "center" | "start" | "end"; offset: number }; +export type ArrowLabel = { text: string; align: 'center' | 'start' | 'end'; offset: number }; /** * Arrow properties using modern format * Modern format: { points, start, end, style, routing?, label? } */ export type ArrowProps = { - points: Vec2[]; - start: ArrowEndpoint; - end: ArrowEndpoint; - style: ArrowStyle; - routing?: ArrowRouting; - label?: ArrowLabel; + points: Vec2[]; + start: ArrowEndpoint; + end: ArrowEndpoint; + style: ArrowStyle; + routing?: ArrowRouting; + label?: ArrowLabel; }; export type TextProps = { text: string; fontSize: number; fontFamily: string; color: string; w?: number }; @@ -75,14 +110,14 @@ export type TextProps = { text: string; fontSize: number; fontFamily: string; co * - style: font and color settings */ export type MarkdownProps = { - md: string; - w: number; - h?: number; - fontSize: number; - fontFamily: string; - color: string; - bg?: string; - border?: string; + md: string; + w: number; + h?: number; + fontSize: number; + fontFamily: string; + color: string; + bg?: string; + border?: string; }; /** @@ -96,11 +131,11 @@ export type StrokePoint = [number, number, number?]; * Maps to perfect-freehand options */ export type BrushConfig = { - size: number; - thinning: number; - smoothing: number; - streamline: number; - simulatePressure: boolean; + size: number; + thinning: number; + smoothing: number; + streamline: number; + simulatePressure: boolean; }; /** @@ -115,183 +150,270 @@ export type StrokeStyle = { color: string; opacity: number }; */ export type StrokeProps = { points: StrokePoint[]; style: StrokeStyle; brush: BrushConfig }; -export type ShapeType = "rect" | "ellipse" | "line" | "arrow" | "text" | "stroke" | "markdown"; +export type ShapeType = 'rect' | 'ellipse' | 'line' | 'arrow' | 'text' | 'stroke' | 'markdown'; export type BaseShape = { - id: string; - type: ShapeType; - pageId: string; - x: number; - y: number; - rot: number; - groupId?: string; + id: string; + type: ShapeType; + pageId: string; + x: number; + y: number; + rot: number; + groupId?: string; + /** Owning layer. Older documents are assigned to their page's default layer on load. */ + layerId?: string; }; -export type RectShape = BaseShape & { type: "rect"; props: RectProps }; -export type EllipseShape = BaseShape & { type: "ellipse"; props: EllipseProps }; -export type LineShape = BaseShape & { type: "line"; props: LineProps }; -export type ArrowShape = BaseShape & { type: "arrow"; props: ArrowProps }; -export type TextShape = BaseShape & { type: "text"; props: TextProps }; -export type StrokeShape = BaseShape & { type: "stroke"; props: StrokeProps }; -export type MarkdownShape = BaseShape & { type: "markdown"; props: MarkdownProps }; +export type RectShape = BaseShape & { type: 'rect'; props: RectProps }; +export type EllipseShape = BaseShape & { type: 'ellipse'; props: EllipseProps }; +export type LineShape = BaseShape & { type: 'line'; props: LineProps }; +export type ArrowShape = BaseShape & { type: 'arrow'; props: ArrowProps }; +export type TextShape = BaseShape & { type: 'text'; props: TextProps }; +export type StrokeShape = BaseShape & { type: 'stroke'; props: StrokeProps }; +export type MarkdownShape = BaseShape & { type: 'markdown'; props: MarkdownProps }; export type ShapeRecord = RectShape | EllipseShape | LineShape | ArrowShape | TextShape | StrokeShape | MarkdownShape; export const ShapeRecord = { - /** - * Create a rectangle shape - */ - createRect(pageId: string, x: number, y: number, properties: RectProps, id?: string): RectShape { - return { id: id ?? createId("shape"), type: "rect", pageId, x, y, rot: 0, props: properties }; - }, - - /** - * Create an ellipse shape - */ - createEllipse(pageId: string, x: number, y: number, properties: EllipseProps, id?: string): EllipseShape { - return { id: id ?? createId("shape"), type: "ellipse", pageId, x, y, rot: 0, props: properties }; - }, - - /** - * Create a line shape - */ - createLine(pageId: string, x: number, y: number, properties: LineProps, id?: string): LineShape { - return { id: id ?? createId("shape"), type: "line", pageId, x, y, rot: 0, props: properties }; - }, - - /** - * Create an arrow shape - */ - createArrow(pageId: string, x: number, y: number, properties: ArrowProps, id?: string): ArrowShape { - return { id: id ?? createId("shape"), type: "arrow", pageId, x, y, rot: 0, props: properties }; - }, - - /** - * Create a text shape - */ - createText(pageId: string, x: number, y: number, properties: TextProps, id?: string): TextShape { - return { id: id ?? createId("shape"), type: "text", pageId, x, y, rot: 0, props: properties }; - }, - - /** - * Create a stroke shape - */ - createStroke(pageId: string, x: number, y: number, properties: StrokeProps, id?: string): StrokeShape { - return { id: id ?? createId("shape"), type: "stroke", pageId, x, y, rot: 0, props: properties }; - }, - - /** - * Create a markdown block shape - */ - createMarkdown(pageId: string, x: number, y: number, properties: MarkdownProps, id?: string): MarkdownShape { - return { id: id ?? createId("shape"), type: "markdown", pageId, x, y, rot: 0, props: properties }; - }, - - /** - * Clone a shape record - */ - clone(shape: ShapeRecord): ShapeRecord { - if (shape.type === "stroke") { - return { - ...shape, - props: { - ...shape.props, - points: shape.props.points.map((p) => [...p] as StrokePoint), - style: { ...shape.props.style }, - brush: { ...shape.props.brush }, - }, - }; - } - if (shape.type === "arrow") { - return { - ...shape, - props: { - points: shape.props.points.map((p) => ({ ...p })), - start: { ...shape.props.start }, - end: { ...shape.props.end }, - style: { ...shape.props.style, dash: shape.props.style.dash ? [...shape.props.style.dash] : undefined }, - routing: shape.props.routing ? { ...shape.props.routing } : undefined, - label: shape.props.label ? { ...shape.props.label } : undefined, - }, - }; - } - if (shape.type === "markdown") { - return { ...shape, props: { ...shape.props } }; - } - return { ...shape, props: { ...shape.props } } as ShapeRecord; - }, + /** + * Create a rectangle shape + */ + createRect(pageId: string, x: number, y: number, properties: RectProps, id?: string): RectShape { + return { id: id ?? createId('shape'), type: 'rect', pageId, x, y, rot: 0, props: properties }; + }, + + /** + * Create an ellipse shape + */ + createEllipse(pageId: string, x: number, y: number, properties: EllipseProps, id?: string): EllipseShape { + return { id: id ?? createId('shape'), type: 'ellipse', pageId, x, y, rot: 0, props: properties }; + }, + + /** + * Create a line shape + */ + createLine(pageId: string, x: number, y: number, properties: LineProps, id?: string): LineShape { + return { id: id ?? createId('shape'), type: 'line', pageId, x, y, rot: 0, props: properties }; + }, + + /** + * Create an arrow shape + */ + createArrow(pageId: string, x: number, y: number, properties: ArrowProps, id?: string): ArrowShape { + return { id: id ?? createId('shape'), type: 'arrow', pageId, x, y, rot: 0, props: properties }; + }, + + /** + * Create a text shape + */ + createText(pageId: string, x: number, y: number, properties: TextProps, id?: string): TextShape { + return { id: id ?? createId('shape'), type: 'text', pageId, x, y, rot: 0, props: properties }; + }, + + /** + * Create a stroke shape + */ + createStroke(pageId: string, x: number, y: number, properties: StrokeProps, id?: string): StrokeShape { + return { id: id ?? createId('shape'), type: 'stroke', pageId, x, y, rot: 0, props: properties }; + }, + + /** + * Create a markdown block shape + */ + createMarkdown(pageId: string, x: number, y: number, properties: MarkdownProps, id?: string): MarkdownShape { + return { id: id ?? createId('shape'), type: 'markdown', pageId, x, y, rot: 0, props: properties }; + }, + + /** + * Clone a shape record + */ + clone(shape: ShapeRecord): ShapeRecord { + if (shape.type === 'stroke') { + return { + ...shape, + props: { + ...shape.props, + points: shape.props.points.map((p) => [...p] as StrokePoint), + style: { ...shape.props.style }, + brush: { ...shape.props.brush } + } + }; + } + if (shape.type === 'arrow') { + if (!Array.isArray(shape.props.points)) { + return { ...shape, props: { ...shape.props } } as ArrowShape; + } + return { + ...shape, + props: { + points: shape.props.points.map((p) => ({ ...p })), + start: { ...shape.props.start }, + end: { ...shape.props.end }, + style: { + ...shape.props.style, + dash: shape.props.style.dash ? [...shape.props.style.dash] : undefined + }, + routing: shape.props.routing ? { ...shape.props.routing } : undefined, + label: shape.props.label ? { ...shape.props.label } : undefined + } + }; + } + if (shape.type === 'markdown') { + return { ...shape, props: { ...shape.props } }; + } + return { ...shape, props: { ...shape.props } } as ShapeRecord; + } }; -export type BindingType = "arrow-end"; -export type BindingHandle = "start" | "end"; +export type BindingType = 'arrow-end'; +export type BindingHandle = 'start' | 'end'; /** * Binding anchor configuration * - center: bind to shape center * - edge: bind to shape edge with normalized coordinates (nx, ny in [-1, 1]) */ -export type BindingAnchor = { kind: "center" } | { kind: "edge"; nx: number; ny: number }; +export type BindingAnchor = { kind: 'center' } | { kind: 'edge'; nx: number; ny: number }; export type BindingRecord = { - id: string; - type: BindingType; - fromShapeId: string; - toShapeId: string; - handle: BindingHandle; - anchor: BindingAnchor; + id: string; + type: BindingType; + fromShapeId: string; + toShapeId: string; + handle: BindingHandle; + anchor: BindingAnchor; }; export const BindingRecord = { - /** - * Create a binding record for arrow endpoints - */ - create( - fromShapeId: string, - toShapeId: string, - handle: BindingHandle, - anchor?: BindingAnchor, - id?: string, - ): BindingRecord { - if (!anchor) { - anchor = { kind: "center" }; - } - return { id: id ?? createId("binding"), type: "arrow-end", fromShapeId, toShapeId, handle, anchor }; - }, - - /** - * Clone a binding record - */ - clone(binding: BindingRecord): BindingRecord { - return { ...binding, anchor: binding.anchor.kind === "edge" ? { ...binding.anchor } : { kind: "center" } }; - }, + /** + * Create a binding record for arrow endpoints + */ + create( + fromShapeId: string, + toShapeId: string, + handle: BindingHandle, + anchor?: BindingAnchor, + id?: string + ): BindingRecord { + if (!anchor) { + anchor = { kind: 'center' }; + } + return { id: id ?? createId('binding'), type: 'arrow-end', fromShapeId, toShapeId, handle, anchor }; + }, + + /** + * Clone a binding record + */ + clone(binding: BindingRecord): BindingRecord { + return { ...binding, anchor: binding.anchor.kind === 'edge' ? { ...binding.anchor } : { kind: 'center' } }; + } }; export type Document = { - pages: Record; - shapes: Record; - bindings: Record; + pages: Record; + /** Layers indexed by stable ID. Older persisted documents omit this field. */ + layers?: Record; + shapes: Record; + bindings: Record; }; export const Document = { - /** - * Create an empty document - */ - create(): Document { - return { pages: {}, shapes: {}, bindings: {} }; - }, - - /** - * Clone a document - */ - clone(document: Document): Document { - return { - pages: Object.fromEntries(Object.entries(document.pages).map(([id, page]) => [id, PageRecord.clone(page)])), - shapes: Object.fromEntries(Object.entries(document.shapes).map(([id, shape]) => [id, ShapeRecord.clone(shape)])), - bindings: Object.fromEntries( - Object.entries(document.bindings).map(([id, binding]) => [id, BindingRecord.clone(binding)]), - ), - }; - }, + /** + * Create an empty document + */ + create(): Document { + return { pages: {}, layers: {}, shapes: {}, bindings: {} }; + }, + + /** + * Clone a document + */ + clone(document: Document): Document { + return { + pages: Object.fromEntries(Object.entries(document.pages).map(([id, page]) => [id, PageRecord.clone(page)])), + ...(document.layers + ? { + layers: Object.fromEntries( + Object.entries(document.layers).map(([id, layer]) => [id, LayerRecord.clone(layer)]) + ) + } + : {}), + shapes: Object.fromEntries( + Object.entries(document.shapes).map(([id, shape]) => [id, ShapeRecord.clone(shape)]) + ), + bindings: Object.fromEntries( + Object.entries(document.bindings).map(([id, binding]) => [id, BindingRecord.clone(binding)]) + ) + }; + } }; +/** + * Backfills the layer structure used by the v2 editor. + * + * The migration is deterministic and idempotent. Existing flat shape order is + * preserved exactly in a stable default layer, while already-layered documents + * retain their layer and child order. + */ +export function withDocumentLayers(document: Document): Document { + const pages = Object.fromEntries(Object.entries(document.pages).map(([id, page]) => [id, PageRecord.clone(page)])); + const layers = Object.fromEntries( + Object.entries(document.layers ?? {}).map(([id, layer]) => [id, LayerRecord.clone(layer)]) + ); + const shapes = Object.fromEntries( + Object.entries(document.shapes).map(([id, shape]) => [id, ShapeRecord.clone(shape)]) + ); + + for (const page of Object.values(pages)) { + const ownedLayerIds = (page.layerIds ?? []).filter((id) => layers[id]?.pageId === page.id); + const layerIds = ownedLayerIds.length > 0 ? ownedLayerIds : [`layer:${page.id}:default`]; + if (ownedLayerIds.length === 0) { + const id = layerIds[0]; + layers[id] = { + id, + pageId: page.id, + name: 'Default', + shapeIds: [...page.shapeIds], + visible: true, + locked: false, + opacity: 1 + }; + } + + const fallbackId = layerIds[0]; + const ordered = page.shapeIds.filter((id) => shapes[id]?.pageId === page.id); + const seen = new Set(); + for (const layerId of layerIds) { + const layer = layers[layerId]; + layer.opacity = Math.max(0, Math.min(1, Number.isFinite(layer.opacity) ? layer.opacity : 1)); + layer.shapeIds = layer.shapeIds.filter((shapeId) => { + const shape = shapes[shapeId]; + if (!shape || shape.pageId !== page.id || seen.has(shapeId)) return false; + seen.add(shapeId); + shape.layerId = layerId; + return true; + }); + } + for (const shapeId of ordered) { + if (seen.has(shapeId)) continue; + const requestedLayerId = shapes[shapeId].layerId; + const destinationId = + requestedLayerId && layerIds.includes(requestedLayerId) ? requestedLayerId : fallbackId; + layers[destinationId].shapeIds.push(shapeId); + shapes[shapeId].layerId = destinationId; + seen.add(shapeId); + } + for (const shape of Object.values(shapes)) { + if (shape.pageId !== page.id || seen.has(shape.id)) continue; + layers[fallbackId].shapeIds.push(shape.id); + shape.layerId = fallbackId; + seen.add(shape.id); + } + page.layerIds = layerIds; + page.shapeIds = layerIds.flatMap((id) => layers[id].shapeIds); + } + + return { ...document, pages, layers, shapes }; +} + export type ValidationResult = { ok: true } | { ok: false; errors: string[] }; /** @@ -300,156 +422,156 @@ export type ValidationResult = { ok: true } | { ok: false; errors: string[] }; * @returns ValidationResult with ok status and any errors found */ export function validateDoc(document: Document): ValidationResult { - const errors: string[] = []; - - if (Object.keys(document.pages).length === 0 && Object.keys(document.shapes).length > 0) { - errors.push("Document has shapes but no pages"); - } - - for (const [shapeId, shape] of Object.entries(document.shapes)) { - if (shape.id !== shapeId) { - errors.push(`Shape key '${shapeId}' does not match shape.id '${shape.id}'`); - } - - if (!document.pages[shape.pageId]) { - errors.push(`Shape '${shapeId}' references non-existent page '${shape.pageId}'`); - } - - const page = document.pages[shape.pageId]; - if (page && !page.shapeIds.includes(shapeId)) { - errors.push(`Shape '${shapeId}' not listed in page '${shape.pageId}' shapeIds`); - } - - switch (shape.type) { - case "rect": { - if (shape.props.w < 0) errors.push(`Rect shape '${shapeId}' has negative width`); - if (shape.props.h < 0) errors.push(`Rect shape '${shapeId}' has negative height`); - if (shape.props.radius < 0) errors.push(`Rect shape '${shapeId}' has negative radius`); - - break; - } - case "ellipse": { - if (shape.props.w < 0) errors.push(`Ellipse shape '${shapeId}' has negative width`); - if (shape.props.h < 0) errors.push(`Ellipse shape '${shapeId}' has negative height`); - - break; - } - case "line": { - if (shape.props.width < 0) errors.push(`Line shape '${shapeId}' has negative width`); - - break; - } - case "arrow": { - const props = shape.props; - - if (!props.points || props.points.length < 2) { - errors.push(`Arrow shape '${shapeId}' points array must have at least 2 points`); - } - if (!props.style) { - errors.push(`Arrow shape '${shapeId}' missing style`); - } else if (props.style.width < 0) { - errors.push(`Arrow shape '${shapeId}' has negative width in style`); - } - if (props.routing) { - if (props.routing.cornerRadius !== undefined && props.routing.cornerRadius < 0) { - errors.push(`Arrow shape '${shapeId}' has negative cornerRadius`); - } - } - if (props.label) { - if (!["center", "start", "end"].includes(props.label.align)) { - errors.push(`Arrow shape '${shapeId}' has invalid label alignment`); - } - } - - break; - } - case "text": { - if (shape.props.fontSize <= 0) errors.push(`Text shape '${shapeId}' has invalid fontSize`); - if (shape.props.w !== undefined && shape.props.w < 0) { - errors.push(`Text shape '${shapeId}' has negative width`); - } - - break; - } - case "stroke": { - if (shape.props.points.length < 2) { - errors.push(`Stroke shape '${shapeId}' has fewer than 2 points`); - } - if (shape.props.brush.size <= 0) { - errors.push(`Stroke shape '${shapeId}' has invalid brush size`); - } - if (shape.props.style.opacity < 0 || shape.props.style.opacity > 1) { - errors.push(`Stroke shape '${shapeId}' has invalid opacity`); - } - - break; - } - case "markdown": { - if (shape.props.fontSize <= 0) { - errors.push(`Markdown shape '${shapeId}' has invalid fontSize`); - } - if (shape.props.w <= 0) { - errors.push(`Markdown shape '${shapeId}' has invalid width`); - } - if (shape.props.h !== undefined && shape.props.h <= 0) { - errors.push(`Markdown shape '${shapeId}' has invalid height`); - } - - break; - } - } - } - - for (const [pageId, page] of Object.entries(document.pages)) { - if (page.id !== pageId) { - errors.push(`Page key '${pageId}' does not match page.id '${page.id}'`); - } - - for (const shapeId of page.shapeIds) { - if (!document.shapes[shapeId]) { - errors.push(`Page '${pageId}' references non-existent shape '${shapeId}'`); - } - } - - const uniqueIds = new Set(page.shapeIds); - if (uniqueIds.size !== page.shapeIds.length) { - errors.push(`Page '${pageId}' has duplicate shape IDs`); - } - } - - for (const [bindingId, binding] of Object.entries(document.bindings)) { - if (binding.id !== bindingId) { - errors.push(`Binding key '${bindingId}' does not match binding.id '${binding.id}'`); - } - - const fromShape = document.shapes[binding.fromShapeId]; - if (!fromShape) { - errors.push(`Binding '${bindingId}' references non-existent fromShape '${binding.fromShapeId}'`); - } else if (fromShape.type !== "arrow") { - errors.push(`Binding '${bindingId}' fromShape '${binding.fromShapeId}' is not an arrow`); - } - - if (!document.shapes[binding.toShapeId]) { - errors.push(`Binding '${bindingId}' references non-existent toShape '${binding.toShapeId}'`); - } - - if (binding.handle !== "start" && binding.handle !== "end") { - errors.push(`Binding '${bindingId}' has invalid handle '${binding.handle}'`); - } - - if (binding.anchor.kind === "edge") { - if (binding.anchor.nx < -1 || binding.anchor.nx > 1) { - errors.push(`Binding '${bindingId}' has invalid nx '${binding.anchor.nx}' (must be in [-1, 1])`); - } - if (binding.anchor.ny < -1 || binding.anchor.ny > 1) { - errors.push(`Binding '${bindingId}' has invalid ny '${binding.anchor.ny}' (must be in [-1, 1])`); - } - } - } - - if (errors.length > 0) { - return { ok: false, errors }; - } - - return { ok: true }; + const errors: string[] = []; + + if (Object.keys(document.pages).length === 0 && Object.keys(document.shapes).length > 0) { + errors.push('Document has shapes but no pages'); + } + + for (const [shapeId, shape] of Object.entries(document.shapes)) { + if (shape.id !== shapeId) { + errors.push(`Shape key '${shapeId}' does not match shape.id '${shape.id}'`); + } + + if (!document.pages[shape.pageId]) { + errors.push(`Shape '${shapeId}' references non-existent page '${shape.pageId}'`); + } + + const page = document.pages[shape.pageId]; + if (page && !page.shapeIds.includes(shapeId)) { + errors.push(`Shape '${shapeId}' not listed in page '${shape.pageId}' shapeIds`); + } + + switch (shape.type) { + case 'rect': { + if (shape.props.w < 0) errors.push(`Rect shape '${shapeId}' has negative width`); + if (shape.props.h < 0) errors.push(`Rect shape '${shapeId}' has negative height`); + if (shape.props.radius < 0) errors.push(`Rect shape '${shapeId}' has negative radius`); + + break; + } + case 'ellipse': { + if (shape.props.w < 0) errors.push(`Ellipse shape '${shapeId}' has negative width`); + if (shape.props.h < 0) errors.push(`Ellipse shape '${shapeId}' has negative height`); + + break; + } + case 'line': { + if (shape.props.width < 0) errors.push(`Line shape '${shapeId}' has negative width`); + + break; + } + case 'arrow': { + const props = shape.props; + + if (!props.points || props.points.length < 2) { + errors.push(`Arrow shape '${shapeId}' points array must have at least 2 points`); + } + if (!props.style) { + errors.push(`Arrow shape '${shapeId}' missing style`); + } else if (props.style.width < 0) { + errors.push(`Arrow shape '${shapeId}' has negative width in style`); + } + if (props.routing) { + if (props.routing.cornerRadius !== undefined && props.routing.cornerRadius < 0) { + errors.push(`Arrow shape '${shapeId}' has negative cornerRadius`); + } + } + if (props.label) { + if (!['center', 'start', 'end'].includes(props.label.align)) { + errors.push(`Arrow shape '${shapeId}' has invalid label alignment`); + } + } + + break; + } + case 'text': { + if (shape.props.fontSize <= 0) errors.push(`Text shape '${shapeId}' has invalid fontSize`); + if (shape.props.w !== undefined && shape.props.w < 0) { + errors.push(`Text shape '${shapeId}' has negative width`); + } + + break; + } + case 'stroke': { + if (shape.props.points.length < 2) { + errors.push(`Stroke shape '${shapeId}' has fewer than 2 points`); + } + if (shape.props.brush.size <= 0) { + errors.push(`Stroke shape '${shapeId}' has invalid brush size`); + } + if (shape.props.style.opacity < 0 || shape.props.style.opacity > 1) { + errors.push(`Stroke shape '${shapeId}' has invalid opacity`); + } + + break; + } + case 'markdown': { + if (shape.props.fontSize <= 0) { + errors.push(`Markdown shape '${shapeId}' has invalid fontSize`); + } + if (shape.props.w <= 0) { + errors.push(`Markdown shape '${shapeId}' has invalid width`); + } + if (shape.props.h !== undefined && shape.props.h <= 0) { + errors.push(`Markdown shape '${shapeId}' has invalid height`); + } + + break; + } + } + } + + for (const [pageId, page] of Object.entries(document.pages)) { + if (page.id !== pageId) { + errors.push(`Page key '${pageId}' does not match page.id '${page.id}'`); + } + + for (const shapeId of page.shapeIds) { + if (!document.shapes[shapeId]) { + errors.push(`Page '${pageId}' references non-existent shape '${shapeId}'`); + } + } + + const uniqueIds = new Set(page.shapeIds); + if (uniqueIds.size !== page.shapeIds.length) { + errors.push(`Page '${pageId}' has duplicate shape IDs`); + } + } + + for (const [bindingId, binding] of Object.entries(document.bindings)) { + if (binding.id !== bindingId) { + errors.push(`Binding key '${bindingId}' does not match binding.id '${binding.id}'`); + } + + const fromShape = document.shapes[binding.fromShapeId]; + if (!fromShape) { + errors.push(`Binding '${bindingId}' references non-existent fromShape '${binding.fromShapeId}'`); + } else if (fromShape.type !== 'arrow') { + errors.push(`Binding '${bindingId}' fromShape '${binding.fromShapeId}' is not an arrow`); + } + + if (!document.shapes[binding.toShapeId]) { + errors.push(`Binding '${bindingId}' references non-existent toShape '${binding.toShapeId}'`); + } + + if (binding.handle !== 'start' && binding.handle !== 'end') { + errors.push(`Binding '${bindingId}' has invalid handle '${binding.handle}'`); + } + + if (binding.anchor.kind === 'edge') { + if (binding.anchor.nx < -1 || binding.anchor.nx > 1) { + errors.push(`Binding '${bindingId}' has invalid nx '${binding.anchor.nx}' (must be in [-1, 1])`); + } + if (binding.anchor.ny < -1 || binding.anchor.ny > 1) { + errors.push(`Binding '${bindingId}' has invalid ny '${binding.anchor.ny}' (must be in [-1, 1])`); + } + } + } + + if (errors.length > 0) { + return { ok: false, errors }; + } + + return { ok: true }; } diff --git a/packages/core/src/persistence/desktop.ts b/packages/core/src/persistence/desktop.ts index 8dabeaf..a500e4f 100644 --- a/packages/core/src/persistence/desktop.ts +++ b/packages/core/src/persistence/desktop.ts @@ -1,6 +1,6 @@ -import type { BindingRecord, Document, PageRecord, ShapeRecord } from "../model"; -import type { DocOrder, LoadedDoc } from "./document"; -import type { BoardMeta } from "./repo"; +import type { BindingRecord, Document, PageRecord, ShapeRecord } from '../model'; +import type { DocOrder, LoadedDoc } from './document'; +import type { BoardMeta } from './repo'; /** * Desktop file representation - combines board metadata with document content @@ -25,97 +25,103 @@ export type DirectoryEntry = { path: string; name: string; isDir: boolean }; * metadata, and workspace navigation needed by the shared frontend. */ export interface DesktopFileOps { - /** - * Show open dialog and return selected file path - */ - showOpenDialog(): Promise; - - /** - * Show save dialog and return selected file path - */ - showSaveDialog(defaultName?: string): Promise; - - /** - * Get recent files list - */ - getRecentFiles(): Promise; - - /** - * Add a file to recent files list - */ - addRecentFile(handle: FileHandle): Promise; - - /** - * Remove a file from recent files list - */ - removeRecentFile(path: string): Promise; - - /** - * Clear all recent files - */ - clearRecentFiles(): Promise; - - /** - * Get current workspace directory - */ - getWorkspaceDir(): Promise; - - /** - * Set workspace directory - */ - setWorkspaceDir(path: string | null): Promise; - - /** - * Show directory picker and set as workspace - */ - pickWorkspaceDir(): Promise; - - /** - * Read directory contents (filtered by pattern) - */ - readDirectory(directory: string, pattern?: string): Promise; - - /** - * Rename a file on disk - */ - renameFile(oldPath: string, newPath: string): Promise; - - /** - * Delete a file from disk - */ - deleteFile(path: string): Promise; + /** + * Show open dialog and return selected file path + */ + showOpenDialog(): Promise; + + /** + * Show save dialog and return selected file path + */ + showSaveDialog(defaultName?: string): Promise; + + /** + * Get recent files list + */ + getRecentFiles(): Promise; + + /** + * Add a file to recent files list + */ + addRecentFile(handle: FileHandle): Promise; + + /** + * Remove a file from recent files list + */ + removeRecentFile(path: string): Promise; + + /** + * Clear all recent files + */ + clearRecentFiles(): Promise; + + /** + * Get current workspace directory + */ + getWorkspaceDir(): Promise; + + /** + * Set workspace directory + */ + setWorkspaceDir(path: string | null): Promise; + + /** + * Show directory picker and set as workspace + */ + pickWorkspaceDir(): Promise; + + /** + * Read directory contents (filtered by pattern) + */ + readDirectory(directory: string, pattern?: string): Promise; + + /** + * Rename a file on disk + */ + renameFile(oldPath: string, newPath: string): Promise; + + /** + * Delete a file from disk + */ + deleteFile(path: string): Promise; } /** * Create a loaded document from desktop file data */ export function loadedDocFromFileData(data: DesktopFileData): LoadedDoc { - return { pages: data.doc.pages, shapes: data.doc.shapes, bindings: data.doc.bindings, order: data.order }; + return { + pages: data.doc.pages, + layers: data.doc.layers ?? data.order.layers, + shapes: data.doc.shapes, + bindings: data.doc.bindings, + order: data.order + }; } /** * Create file data from document parts */ export function createFileData( - board: BoardMeta, - pages: Record, - shapes: Record, - bindings: Record, - order: DocOrder, + board: BoardMeta, + pages: Record, + shapes: Record, + bindings: Record, + order: DocOrder ): DesktopFileData { - return { board, doc: { pages, shapes, bindings }, order }; + return { board, doc: { pages, shapes, bindings }, order }; } export function parseDesktopFile(content: string): DesktopFileData { - const data = JSON.parse(content) as DesktopFileData; + const data = JSON.parse(content) as DesktopFileData; - if (!data.board || !data.doc || !data.order) { - throw new Error("Invalid file format: missing required fields"); - } + if (!data.board || !data.doc || !data.order) { + throw new Error('Invalid file format: missing required fields'); + } - return data; + return data; } export function serializeDesktopFile(data: DesktopFileData): string { - return JSON.stringify(data, null, 2); + return JSON.stringify(data, null, 2); } diff --git a/packages/core/src/persistence/document.ts b/packages/core/src/persistence/document.ts index 78a9167..e61804a 100644 --- a/packages/core/src/persistence/document.ts +++ b/packages/core/src/persistence/document.ts @@ -1,34 +1,38 @@ import { - type BindingRecord, - BindingRecord as BindingOps, - type Document, - type PageRecord, - PageRecord as PageOps, - type ShapeRecord, - ShapeRecord as ShapeOps, -} from "../model"; -import type { BoardMeta, DocRepo } from "./repo"; + type BindingRecord, + BindingRecord as BindingOps, + type Document, + type LayerRecord, + type PageRecord, + PageRecord as PageOps, + type ShapeRecord, + ShapeRecord as ShapeOps +} from '../model'; +import type { BoardMeta, DocRepo } from './repo'; /** Persisted page and shape ordering for a document. */ export type DocOrder = { - pageIds: string[]; - /** Optional per-page shape order overrides. */ - shapeOrder?: Record; + pageIds: string[]; + /** Optional per-page shape order overrides. */ + shapeOrder?: Record; + /** Complete layer records, stored with ordering metadata by legacy adapters. */ + layers?: Record; }; /** Incremental document changes accepted by persistent repositories. */ export type DocPatch = { - upserts?: { pages?: PageRecord[]; shapes?: ShapeRecord[]; bindings?: BindingRecord[] }; - deletes?: { pageIds?: string[]; shapeIds?: string[]; bindingIds?: string[] }; - order?: Partial; + upserts?: { pages?: PageRecord[]; shapes?: ShapeRecord[]; bindings?: BindingRecord[] }; + deletes?: { pageIds?: string[]; shapeIds?: string[]; bindingIds?: string[] }; + order?: Partial; }; /** A complete document loaded from persistence. */ export type LoadedDoc = { - pages: Record; - shapes: Record; - bindings: Record; - order: DocOrder; + pages: Record; + layers?: Record; + shapes: Record; + bindings: Record; + order: DocOrder; }; /** Portable board snapshot used by import and export flows. */ @@ -39,10 +43,10 @@ export type PersistenceSink = { enqueueDocPatch(boardId: string, patch: DocPatch /** Platform-neutral document repository implemented by each application adapter. */ export interface PersistentDocRepo extends DocRepo { - loadDoc(boardId: string): Promise; - applyDocPatch(boardId: string, patch: DocPatch): Promise; - exportBoard(boardId: string): Promise; - importBoard(snapshot: BoardExport): Promise; + loadDoc(boardId: string): Promise; + applyDocPatch(boardId: string, patch: DocPatch): Promise; + exportBoard(boardId: string): Promise; + importBoard(snapshot: BoardExport): Promise; } /** @@ -53,38 +57,41 @@ export interface PersistentDocRepo extends DocRepo { * patch atomically. */ export function diffDoc(before: Document, after: Document): DocPatch { - const patch: DocPatch = {}; - const deletedPages = difference(Object.keys(before.pages), Object.keys(after.pages)); - const deletedShapes = difference(Object.keys(before.shapes), Object.keys(after.shapes)); - const deletedBindings = difference(Object.keys(before.bindings), Object.keys(after.bindings)); + const patch: DocPatch = {}; + const deletedPages = difference(Object.keys(before.pages), Object.keys(after.pages)); + const deletedShapes = difference(Object.keys(before.shapes), Object.keys(after.shapes)); + const deletedBindings = difference(Object.keys(before.bindings), Object.keys(after.bindings)); - if (deletedPages.length > 0 || deletedShapes.length > 0 || deletedBindings.length > 0) { - patch.deletes = {}; - if (deletedPages.length > 0) patch.deletes.pageIds = deletedPages; - if (deletedShapes.length > 0) patch.deletes.shapeIds = deletedShapes; - if (deletedBindings.length > 0) patch.deletes.bindingIds = deletedBindings; - } + if (deletedPages.length > 0 || deletedShapes.length > 0 || deletedBindings.length > 0) { + patch.deletes = {}; + if (deletedPages.length > 0) patch.deletes.pageIds = deletedPages; + if (deletedShapes.length > 0) patch.deletes.shapeIds = deletedShapes; + if (deletedBindings.length > 0) patch.deletes.bindingIds = deletedBindings; + } - const pageUpserts = Object.values(after.pages).map((page) => PageOps.clone(page)); - const shapeUpserts = Object.values(after.shapes).map((shape) => ShapeOps.clone(shape)); - const bindingUpserts = Object.values(after.bindings).map((binding) => BindingOps.clone(binding)); + const pageUpserts = Object.values(after.pages).map((page) => PageOps.clone(page)); + const shapeUpserts = Object.values(after.shapes).map((shape) => ShapeOps.clone(shape)); + const bindingUpserts = Object.values(after.bindings).map((binding) => BindingOps.clone(binding)); - if (pageUpserts.length > 0 || shapeUpserts.length > 0 || bindingUpserts.length > 0) { - patch.upserts = {}; - if (pageUpserts.length > 0) patch.upserts.pages = pageUpserts; - if (shapeUpserts.length > 0) patch.upserts.shapes = shapeUpserts; - if (bindingUpserts.length > 0) patch.upserts.bindings = bindingUpserts; - } + if (pageUpserts.length > 0 || shapeUpserts.length > 0 || bindingUpserts.length > 0) { + patch.upserts = {}; + if (pageUpserts.length > 0) patch.upserts.pages = pageUpserts; + if (shapeUpserts.length > 0) patch.upserts.shapes = shapeUpserts; + if (bindingUpserts.length > 0) patch.upserts.bindings = bindingUpserts; + } - patch.order = { - pageIds: Object.keys(after.pages), - shapeOrder: Object.fromEntries(Object.values(after.pages).map((page) => [page.id, [...page.shapeIds]])), - }; + patch.order = { + pageIds: Object.keys(after.pages), + shapeOrder: Object.fromEntries(Object.values(after.pages).map((page) => [page.id, [...page.shapeIds]])), + layers: Object.fromEntries( + Object.entries(after.layers ?? {}).map(([id, layer]) => [id, { ...layer, shapeIds: [...layer.shapeIds] }]) + ) + }; - return patch; + return patch; } function difference(before: string[], after: string[]): string[] { - const afterSet = new Set(after); - return before.filter((id) => !afterSet.has(id)); + const afterSet = new Set(after); + return before.filter((id) => !afterSet.has(id)); } diff --git a/packages/core/src/reactivity.ts b/packages/core/src/reactivity.ts index 2a85d1e..a0d0f0f 100644 --- a/packages/core/src/reactivity.ts +++ b/packages/core/src/reactivity.ts @@ -1,57 +1,60 @@ -import { BehaviorSubject, type Subscription } from "rxjs"; -import type { Camera } from "./camera"; -import { Camera as CameraOps } from "./camera"; +import { BehaviorSubject, type Subscription } from 'rxjs'; +import type { Camera } from './camera'; +import { Camera as CameraOps } from './camera'; import { - type Command, - History, - type HistoryAppliedEvent, - type HistoryEntry, - type HistoryOperation, - type HistoryState, -} from "./history"; -import type { Document, PageRecord, ShapeRecord } from "./model"; -import { Document as DocumentOps } from "./model"; + type Command, + History, + type HistoryAppliedEvent, + type HistoryEntry, + type HistoryOperation, + type HistoryState +} from './history'; +import type { Document, LayerRecord, PageRecord, ShapeRecord } from './model'; +import { Document as DocumentOps, withDocumentLayers } from './model'; -export type ToolId = "select" | "rect" | "ellipse" | "line" | "arrow" | "text" | "pen" | "markdown"; +export type ToolId = 'select' | 'rect' | 'ellipse' | 'line' | 'arrow' | 'text' | 'pen' | 'markdown'; -export type BindingPreview = { arrowId: string; targetShapeId: string; handle: "start" | "end" }; +export type BindingPreview = { arrowId: string; targetShapeId: string; handle: 'start' | 'end' }; export type UIState = { - currentPageId: string | null; - selectionIds: string[]; - toolId: ToolId; - bindingPreview?: BindingPreview; + currentPageId: string | null; + /** Active destination for newly created shapes on the current page. */ + activeLayerId?: string | null; + selectionIds: string[]; + toolId: ToolId; + bindingPreview?: BindingPreview; }; export type EditorState = { doc: Document; ui: UIState; camera: Camera }; export const EditorState = { - /** - * Create initial editor state - */ - create(): EditorState { - return { - doc: DocumentOps.create(), - ui: { currentPageId: null, selectionIds: [], toolId: "select" }, - camera: CameraOps.create(), - }; - }, - - /** - * Clone editor state - */ - clone(state: EditorState): EditorState { - return { - doc: DocumentOps.clone(state.doc), - ui: { - currentPageId: state.ui.currentPageId, - selectionIds: [...state.ui.selectionIds], - toolId: state.ui.toolId, - bindingPreview: state.ui.bindingPreview ? { ...state.ui.bindingPreview } : undefined, - }, - camera: CameraOps.clone(state.camera), - }; - }, + /** + * Create initial editor state + */ + create(): EditorState { + return { + doc: DocumentOps.create(), + ui: { currentPageId: null, activeLayerId: null, selectionIds: [], toolId: 'select' }, + camera: CameraOps.create() + }; + }, + + /** + * Clone editor state + */ + clone(state: EditorState): EditorState { + return { + doc: DocumentOps.clone(state.doc), + ui: { + currentPageId: state.ui.currentPageId, + activeLayerId: state.ui.activeLayerId, + selectionIds: [...state.ui.selectionIds], + toolId: state.ui.toolId, + bindingPreview: state.ui.bindingPreview ? { ...state.ui.bindingPreview } : undefined + }, + camera: CameraOps.clone(state.camera) + }; + } }; export type StateUpdater = (state: EditorState) => EditorState; @@ -69,175 +72,175 @@ export type StoreOptions = { onHistoryEvent?: (event: HistoryAppliedEvent) => vo * - Undo/redo history support */ export class Store { - private readonly state$: BehaviorSubject; - private history: HistoryState; - private readonly historyListener?: (event: HistoryAppliedEvent) => void; - - constructor(initialState?: EditorState, options?: StoreOptions) { - this.state$ = new BehaviorSubject(initialState ?? EditorState.create()); - this.history = History.create(); - this.historyListener = options?.onHistoryEvent; - } - - /** - * Get the current state snapshot - */ - getState(): EditorState { - return this.state$.value; - } - - /** - * Update the state using an updater function - * - * The updater receives the current state and returns a new state. - * Invariants are enforced after the update. - * - * Note: This bypasses history. Use executeCommand() for undoable changes. - * - * @param updater - Function that transforms current state to new state - */ - setState(updater: StateUpdater): void { - const currentState = this.state$.value; - const newState = updater(currentState); - const repairedState = enforceInvariants(newState); - this.state$.next(repairedState); - } - - /** - * Execute a command and add it to history - * - * This is the preferred way to make undoable changes to the state. - * - * @param command - Command to execute - */ - executeCommand(command: Command): void { - const currentState = this.state$.value; - const [newHistory, newState] = History.execute(this.history, currentState, command); - this.history = newHistory; - const repairedState = enforceInvariants(newState); - this.state$.next(repairedState); - const entry = this.history.undoStack.at(-1); - if (entry) { - this.emitHistoryEvent("do", entry, currentState, repairedState); - } - } - - /** - * Undo the last command - * - * @returns True if undo was successful, false if nothing to undo - */ - undo(): boolean { - const currentState = this.state$.value; - const entry = this.history.undoStack.at(-1); - const result = History.undo(this.history, currentState); - - if (!result) { - return false; - } - - const [newHistory, newState] = result; - this.history = newHistory; - const repairedState = enforceInvariants(newState); - this.state$.next(repairedState); - if (entry) { - this.emitHistoryEvent("undo", entry, currentState, repairedState); - } - return true; - } - - /** - * Redo the last undone command - * - * @returns True if redo was successful, false if nothing to redo - */ - redo(): boolean { - const currentState = this.state$.value; - const entry = this.history.redoStack.at(-1); - const result = History.redo(this.history, currentState); - - if (!result) { - return false; - } - - const [newHistory, newState] = result; - this.history = newHistory; - const repairedState = enforceInvariants(newState); - this.state$.next(repairedState); - if (entry) { - this.emitHistoryEvent("redo", entry, currentState, repairedState); - } - return true; - } - - /** - * Check if undo is available - */ - canUndo(): boolean { - return History.canUndo(this.history); - } - - /** - * Check if redo is available - */ - canRedo(): boolean { - return History.canRedo(this.history); - } - - /** - * Get the history state (for debugging/UI) - */ - getHistory(): HistoryState { - return this.history; - } - - /** - * Clear all history - */ - clearHistory(): void { - this.history = History.clear(); - } - - /** - * Subscribe to state changes - * - * The listener is called immediately with the current state, - * and then on every state change. - * - * @param listener - Function called with new state - * @returns Unsubscribe function - */ - subscribe(listener: StateListener): () => void { - const subscription: Subscription = this.state$.subscribe(listener); - return () => subscription.unsubscribe(); - } - - /** - * Get the underlying RxJS observable - */ - getObservable() { - return this.state$.asObservable(); - } - - private emitHistoryEvent( - op: HistoryOperation, - entry: HistoryEntry, - beforeState: EditorState, - afterState: EditorState, - ): void { - if (!this.historyListener) { - return; - } - - this.historyListener({ - op, - commandId: entry.timestamp, - command: entry.command, - kind: entry.command.kind, - beforeState, - afterState, - }); - } + private readonly state$: BehaviorSubject; + private history: HistoryState; + private readonly historyListener?: (event: HistoryAppliedEvent) => void; + + constructor(initialState?: EditorState, options?: StoreOptions) { + this.state$ = new BehaviorSubject(enforceInvariants(initialState ?? EditorState.create())); + this.history = History.create(); + this.historyListener = options?.onHistoryEvent; + } + + /** + * Get the current state snapshot + */ + getState(): EditorState { + return this.state$.value; + } + + /** + * Update the state using an updater function + * + * The updater receives the current state and returns a new state. + * Invariants are enforced after the update. + * + * Note: This bypasses history. Use executeCommand() for undoable changes. + * + * @param updater - Function that transforms current state to new state + */ + setState(updater: StateUpdater): void { + const currentState = this.state$.value; + const newState = updater(currentState); + const repairedState = enforceInvariants(newState); + this.state$.next(repairedState); + } + + /** + * Execute a command and add it to history + * + * This is the preferred way to make undoable changes to the state. + * + * @param command - Command to execute + */ + executeCommand(command: Command): void { + const currentState = this.state$.value; + const [newHistory, newState] = History.execute(this.history, currentState, command); + this.history = newHistory; + const repairedState = enforceInvariants(newState); + this.state$.next(repairedState); + const entry = this.history.undoStack.at(-1); + if (entry) { + this.emitHistoryEvent('do', entry, currentState, repairedState); + } + } + + /** + * Undo the last command + * + * @returns True if undo was successful, false if nothing to undo + */ + undo(): boolean { + const currentState = this.state$.value; + const entry = this.history.undoStack.at(-1); + const result = History.undo(this.history, currentState); + + if (!result) { + return false; + } + + const [newHistory, newState] = result; + this.history = newHistory; + const repairedState = enforceInvariants(newState); + this.state$.next(repairedState); + if (entry) { + this.emitHistoryEvent('undo', entry, currentState, repairedState); + } + return true; + } + + /** + * Redo the last undone command + * + * @returns True if redo was successful, false if nothing to redo + */ + redo(): boolean { + const currentState = this.state$.value; + const entry = this.history.redoStack.at(-1); + const result = History.redo(this.history, currentState); + + if (!result) { + return false; + } + + const [newHistory, newState] = result; + this.history = newHistory; + const repairedState = enforceInvariants(newState); + this.state$.next(repairedState); + if (entry) { + this.emitHistoryEvent('redo', entry, currentState, repairedState); + } + return true; + } + + /** + * Check if undo is available + */ + canUndo(): boolean { + return History.canUndo(this.history); + } + + /** + * Check if redo is available + */ + canRedo(): boolean { + return History.canRedo(this.history); + } + + /** + * Get the history state (for debugging/UI) + */ + getHistory(): HistoryState { + return this.history; + } + + /** + * Clear all history + */ + clearHistory(): void { + this.history = History.clear(); + } + + /** + * Subscribe to state changes + * + * The listener is called immediately with the current state, + * and then on every state change. + * + * @param listener - Function called with new state + * @returns Unsubscribe function + */ + subscribe(listener: StateListener): () => void { + const subscription: Subscription = this.state$.subscribe(listener); + return () => subscription.unsubscribe(); + } + + /** + * Get the underlying RxJS observable + */ + getObservable() { + return this.state$.asObservable(); + } + + private emitHistoryEvent( + op: HistoryOperation, + entry: HistoryEntry, + beforeState: EditorState, + afterState: EditorState + ): void { + if (!this.historyListener) { + return; + } + + this.historyListener({ + op, + commandId: entry.timestamp, + command: entry.command, + kind: entry.command.kind, + beforeState, + afterState + }); + } } /** @@ -252,44 +255,46 @@ export class Store { * @returns Repaired state */ function enforceInvariants(state: EditorState): EditorState { - const pages = Object.keys(state.doc.pages); - const shapes = state.doc.shapes; - - let currentPageId = state.ui.currentPageId; - if (currentPageId !== null && !state.doc.pages[currentPageId]) { - currentPageId = pages.length > 0 ? pages[0] : null; - } - - let selectionIds = state.ui.selectionIds; - if (currentPageId === null) { - selectionIds = []; - } else { - const currentPage = state.doc.pages[currentPageId]; - const validShapeIds = new Set(currentPage?.shapeIds); - - selectionIds = selectionIds.filter((id) => { - return shapes[id] && validShapeIds.has(id); - }); - } - - if (currentPageId === state.ui.currentPageId && arraysEqual(selectionIds, state.ui.selectionIds)) { - return state; - } - - return { ...state, ui: { ...state.ui, currentPageId, selectionIds } }; -} - -/** - * Check if two arrays are equal - */ -function arraysEqual(a: string[], b: string[]): boolean { - if (a.length !== b.length) return false; - let index = 0; - for (const item of a) { - if (item !== b[index]) return false; - index++; - } - return true; + const activeLayerId = state.ui.activeLayerId; + const taggedDocument = activeLayerId + ? { + ...state.doc, + shapes: Object.fromEntries( + Object.entries(state.doc.shapes).map(([id, shape]) => [ + id, + shape.layerId ? shape : { ...shape, layerId: activeLayerId } + ]) + ) + } + : state.doc; + const doc = withDocumentLayers(taggedDocument); + const pages = Object.keys(doc.pages); + const shapes = doc.shapes; + + let currentPageId = state.ui.currentPageId; + if (currentPageId !== null && !doc.pages[currentPageId]) { + currentPageId = pages.length > 0 ? pages[0] : null; + } + + let selectionIds = state.ui.selectionIds; + if (currentPageId === null) { + selectionIds = []; + } else { + const currentPage = doc.pages[currentPageId]; + const validShapeIds = new Set(getInteractiveShapeIds(doc, currentPage)); + + selectionIds = selectionIds.filter((id) => { + return shapes[id] && validShapeIds.has(id); + }); + } + + const currentPage = currentPageId ? doc.pages[currentPageId] : undefined; + const layerIds = currentPage?.layerIds ?? []; + const nextActiveLayerId = layerIds.includes(state.ui.activeLayerId ?? '') + ? state.ui.activeLayerId + : (layerIds.find((id) => !doc.layers?.[id]?.locked && doc.layers?.[id]?.visible) ?? layerIds[0] ?? null); + + return { ...state, doc, ui: { ...state.ui, currentPageId, activeLayerId: nextActiveLayerId, selectionIds } }; } /** @@ -299,10 +304,10 @@ function arraysEqual(a: string[], b: string[]): boolean { * @returns Current page or null if no page is selected */ export function getCurrentPage(state: EditorState): PageRecord | null { - if (state.ui.currentPageId === null) { - return null; - } - return state.doc.pages[state.ui.currentPageId] ?? null; + if (state.ui.currentPageId === null) { + return null; + } + return state.doc.pages[state.ui.currentPageId] ?? null; } /** @@ -312,14 +317,56 @@ export function getCurrentPage(state: EditorState): PageRecord | null { * @returns Array of shapes on current page (empty if no page selected) */ export function getShapesOnCurrentPage(state: EditorState): ShapeRecord[] { - const currentPage = getCurrentPage(state); - if (!currentPage) { - return []; - } - - return currentPage.shapeIds.map((id) => state.doc.shapes[id]).filter((shape): shape is ShapeRecord => - shape !== undefined - ); + const currentPage = getCurrentPage(state); + if (!currentPage) { + return []; + } + + const layers = state.doc.layers; + if (!layers || !currentPage.layerIds?.length) { + return currentPage.shapeIds + .map((id) => state.doc.shapes[id]) + .filter((shape): shape is ShapeRecord => shape !== undefined); + } + return currentPage.layerIds.flatMap((layerId) => { + const layer = layers[layerId]; + if (!layer?.visible) return []; + return layer.shapeIds + .map((id) => state.doc.shapes[id]) + .filter((shape): shape is ShapeRecord => shape !== undefined); + }); +} + +/** Returns visible, unlocked shapes in draw order for hit testing and selection. */ +export function getInteractiveShapesOnCurrentPage(state: EditorState): ShapeRecord[] { + const currentPage = getCurrentPage(state); + if (!currentPage) return []; + const layers = state.doc.layers; + if (!layers || !currentPage.layerIds?.length) return getShapesOnCurrentPage(state); + return currentPage.layerIds.flatMap((layerId) => { + const layer = layers[layerId]; + if (!layer?.visible || layer.locked) return []; + return layer.shapeIds + .map((id) => state.doc.shapes[id]) + .filter((shape): shape is ShapeRecord => shape !== undefined); + }); +} + +/** Returns the current page's layers in back-to-front order. */ +export function getLayersOnCurrentPage(state: EditorState): LayerRecord[] { + const page = getCurrentPage(state); + if (!page || !state.doc.layers) return []; + return (page.layerIds ?? []) + .map((id) => state.doc.layers?.[id]) + .filter((layer): layer is LayerRecord => Boolean(layer)); +} + +function getInteractiveShapeIds(document: Document, page: PageRecord): string[] { + if (!document.layers || !page.layerIds?.length) return page.shapeIds; + return page.layerIds.flatMap((id) => { + const layer = document.layers?.[id]; + return layer?.visible && !layer.locked ? layer.shapeIds : []; + }); } /** @@ -329,9 +376,9 @@ export function getShapesOnCurrentPage(state: EditorState): ShapeRecord[] { * @returns Array of selected shapes (empty if no selection) */ export function getSelectedShapes(state: EditorState): ShapeRecord[] { - return state.ui.selectionIds.map((id) => state.doc.shapes[id]).filter((shape): shape is ShapeRecord => - shape !== undefined - ); + return state.ui.selectionIds + .map((id) => state.doc.shapes[id]) + .filter((shape): shape is ShapeRecord => shape !== undefined); } /** @@ -342,7 +389,7 @@ export function getSelectedShapes(state: EditorState): ShapeRecord[] { * @returns True if shape is selected */ export function isShapeSelected(state: EditorState, shapeId: string): boolean { - return state.ui.selectionIds.includes(shapeId); + return state.ui.selectionIds.includes(shapeId); } /** @@ -352,7 +399,7 @@ export function isShapeSelected(state: EditorState, shapeId: string): boolean { * @returns Array of all pages */ export function getAllPages(state: EditorState): PageRecord[] { - return Object.values(state.doc.pages); + return Object.values(state.doc.pages); } /** @@ -363,5 +410,5 @@ export function getAllPages(state: EditorState): PageRecord[] { * @returns Shape or undefined if not found */ export function getShape(state: EditorState, shapeId: string): ShapeRecord | undefined { - return state.doc.shapes[shapeId]; + return state.doc.shapes[shapeId]; } diff --git a/packages/core/src/tools/select.ts b/packages/core/src/tools/select.ts index d2761ed..cb1dd40 100644 --- a/packages/core/src/tools/select.ts +++ b/packages/core/src/tools/select.ts @@ -1,48 +1,48 @@ -import type { Action } from "../actions"; +import type { Action } from '../actions'; import { - computeNormalizedAnchor, - computePolylineLength, - getPointAtDistance, - hitTestPoint, - resolveArrowEndpoints, - shapeBounds, -} from "../geom"; -import { Box2, type Vec2, Vec2 as Vec2Ops } from "../math"; -import { BindingRecord, ShapeRecord } from "../model"; -import { EditorState, getCurrentPage, type ToolId } from "../reactivity"; -import type { Tool } from "./base"; + computeNormalizedAnchor, + computePolylineLength, + getPointAtDistance, + hitTestPoint, + resolveArrowEndpoints, + shapeBounds +} from '../geom'; +import { Box2, type Vec2, Vec2 as Vec2Ops } from '../math'; +import { BindingRecord, ShapeRecord } from '../model'; +import { EditorState, getCurrentPage, getInteractiveShapesOnCurrentPage, type ToolId } from '../reactivity'; +import type { Tool } from './base'; /** * Internal state for the select tool */ type SelectToolState = { - /** Whether we're currently dragging selected shapes */ - isDragging: boolean; - /** World coordinates where drag started */ - dragStartWorld: Vec2 | null; - /** Initial positions of shapes being dragged (shape id -> {x, y}) */ - initialShapePositions: Map; - /** Marquee selection start point in world coordinates */ - marqueeStart: Vec2 | null; - /** Marquee selection end point in world coordinates */ - marqueeEnd: Vec2 | null; - /** Active resize/rotate handle identifier */ - activeHandle: HandleKind | null; - /** Shape being manipulated by handle */ - handleShapeId: string | null; - /** Bounds snapshot at the time handle drag started */ - handleStartBounds: Box2 | null; - /** Initial shapes snapshot for handle drags */ - handleInitialShapes: Map; - /** Rotation pivot in world coordinates */ - rotationCenter: Vec2 | null; - /** Starting angle for rotation handle */ - rotationStartAngle: number | null; + /** Whether we're currently dragging selected shapes */ + isDragging: boolean; + /** World coordinates where drag started */ + dragStartWorld: Vec2 | null; + /** Initial positions of shapes being dragged (shape id -> {x, y}) */ + initialShapePositions: Map; + /** Marquee selection start point in world coordinates */ + marqueeStart: Vec2 | null; + /** Marquee selection end point in world coordinates */ + marqueeEnd: Vec2 | null; + /** Active resize/rotate handle identifier */ + activeHandle: HandleKind | null; + /** Shape being manipulated by handle */ + handleShapeId: string | null; + /** Bounds snapshot at the time handle drag started */ + handleStartBounds: Box2 | null; + /** Initial shapes snapshot for handle drags */ + handleInitialShapes: Map; + /** Rotation pivot in world coordinates */ + rotationCenter: Vec2 | null; + /** Starting angle for rotation handle */ + rotationStartAngle: number | null; }; -type RectHandle = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w"; +type RectHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'; -type HandleKind = RectHandle | "rotate" | "line-start" | "line-end" | `arrow-point-${number}` | "arrow-label"; +type HandleKind = RectHandle | 'rotate' | 'line-start' | 'line-end' | `arrow-point-${number}` | 'arrow-label'; const HANDLE_HIT_RADIUS = 10; const ROTATE_HANDLE_OFFSET = 40; @@ -60,940 +60,960 @@ const MIN_RESIZE_SIZE = 5; * - Delete/Backspace to remove selected shapes */ export class SelectTool implements Tool { - readonly id: ToolId = "select"; - private toolState: SelectToolState; - private readonly marqueeListener?: (bounds: Box2 | null) => void; - - constructor(onMarqueeChange?: (bounds: Box2 | null) => void) { - this.marqueeListener = onMarqueeChange; - this.toolState = { - isDragging: false, - dragStartWorld: null, - initialShapePositions: new Map(), - marqueeStart: null, - marqueeEnd: null, - activeHandle: null, - handleShapeId: null, - handleStartBounds: null, - handleInitialShapes: new Map(), - rotationCenter: null, - rotationStartAngle: null, - }; - } - - onEnter(state: EditorState): EditorState { - this.resetToolState(); - return state; - } - - onExit(state: EditorState): EditorState { - this.resetToolState(); - return state; - } - - onAction(state: EditorState, action: Action): EditorState { - switch (action.type) { - case "pointer-down": { - return this.handlePointerDown(state, action); - } - case "pointer-move": { - return this.handlePointerMove(state, action); - } - case "pointer-up": { - return this.handlePointerUp(state, action); - } - case "key-down": { - return this.handleKeyDown(state, action); - } - default: { - return state; - } - } - } - - /** - * Handle pointer down - select shapes or start marquee - */ - private handlePointerDown(state: EditorState, action: Action): EditorState { - if (action.type !== "pointer-down") return state; - - if (action.modifiers.alt && state.ui.selectionIds.length === 1) { - const shapeId = state.ui.selectionIds[0]; - const shape = state.doc.shapes[shapeId]; - if (shape?.type === "arrow") { - const result = this.tryAddPointToArrowSegment(state, shape, action.world); - if (result) { - return result; - } - } - } - - const handleHit = this.hitTestHandle(state, action.world); - if (handleHit) { - return this.beginHandleDrag(state, handleHit.shape, handleHit.handle, action.world); - } - - const hitShapeId = hitTestPoint(state, action.world); - - return hitShapeId ? this.handleShapeClick(state, hitShapeId, action) : this.handleEmptyClick(state, action); - } - - private hitTestHandle(state: EditorState, point: Vec2): { handle: HandleKind; shape: ShapeRecord } | null { - if (state.ui.selectionIds.length !== 1) { - return null; - } - const shapeId = state.ui.selectionIds[0]; - const shape = state.doc.shapes[shapeId]; - if (!shape) { - return null; - } - const handles = this.getHandlePositions(state, shape); - for (const handle of handles) { - if (Vec2Ops.dist(point, handle.position) <= HANDLE_HIT_RADIUS) { - return { handle: handle.id, shape }; - } - } - return null; - } - - private beginHandleDrag(state: EditorState, shape: ShapeRecord, handle: HandleKind, point: Vec2): EditorState { - this.toolState.activeHandle = handle; - this.toolState.handleShapeId = shape.id; - this.toolState.handleStartBounds = shapeBounds(shape); - this.toolState.handleInitialShapes.clear(); - this.toolState.handleInitialShapes.set(shape.id, ShapeRecord.clone(shape)); - this.toolState.isDragging = false; - this.toolState.dragStartWorld = point; - const bounds = this.toolState.handleStartBounds; - this.toolState.rotationCenter = bounds - ? { x: (bounds.min.x + bounds.max.x) / 2, y: (bounds.min.y + bounds.max.y) / 2 } - : null; - this.toolState.rotationStartAngle = this.toolState.rotationCenter - ? Math.atan2(point.y - this.toolState.rotationCenter.y, point.x - this.toolState.rotationCenter.x) - : null; - return state; - } - - /** - * Handle clicking on a shape - */ - private handleShapeClick(state: EditorState, shapeId: string, action: Action): EditorState { - if (action.type !== "pointer-down") return state; - - const clickedShape = state.doc.shapes[shapeId]; - if (!clickedShape) return state; - - const isShiftHeld = action.modifiers.shift; - - let idsToInteractWith: string[] = [shapeId]; - if (clickedShape.groupId) { - idsToInteractWith = Object.values(state.doc.shapes).filter((s) => s.groupId === clickedShape.groupId).map((s) => - s.id - ); - } - - const isAnySelected = idsToInteractWith.some(id => state.ui.selectionIds.includes(id)); - - let newSelectionIds: string[]; - - if (isShiftHeld) { - if (isAnySelected) { - newSelectionIds = state.ui.selectionIds.filter((id) => !idsToInteractWith.includes(id)); - } else { - newSelectionIds = [...state.ui.selectionIds, ...idsToInteractWith]; - } - } else { - if (isAnySelected && !isShiftHeld) { - newSelectionIds = state.ui.selectionIds; - } else { - newSelectionIds = idsToInteractWith; - } - } - - if (isShiftHeld) { - const shouldSelect = !isAnySelected; - if (shouldSelect) { - newSelectionIds = [...new Set([...state.ui.selectionIds, ...idsToInteractWith])]; - } else { - newSelectionIds = state.ui.selectionIds.filter(id => !idsToInteractWith.includes(id)); - } - } else { - if (isAnySelected) { - newSelectionIds = state.ui.selectionIds; - } else { - newSelectionIds = idsToInteractWith; - } - } - - this.toolState.isDragging = true; - this.toolState.dragStartWorld = action.world; - this.toolState.initialShapePositions.clear(); - - for (const id of newSelectionIds) { - const shape = state.doc.shapes[id]; - if (shape) { - this.toolState.initialShapePositions.set(id, { x: shape.x, y: shape.y }); - } - } - - return { ...state, ui: { ...state.ui, selectionIds: newSelectionIds } }; - } - - /** - * Handle clicking on empty canvas - clear selection or start marquee - */ - private handleEmptyClick(state: EditorState, action: Action): EditorState { - if (action.type !== "pointer-down") return state; - - const isShiftHeld = action.modifiers.shift; - - if (!isShiftHeld) { - this.toolState.marqueeStart = action.world; - this.toolState.marqueeEnd = action.world; - this.notifyMarqueeChange(); - - return { ...state, ui: { ...state.ui, selectionIds: [] } }; - } - - return state; - } - - /** - * Handle pointer move - drag shapes or update marquee - */ - private handlePointerMove(state: EditorState, action: Action): EditorState { - if (action.type !== "pointer-move") return state; - - if (this.toolState.activeHandle && this.toolState.handleShapeId) { - return this.handleHandleDrag(state, action); - } - - if (this.toolState.isDragging && this.toolState.dragStartWorld) { - return this.handleDragMove(state, action); - } else if (this.toolState.marqueeStart) { - return this.handleMarqueeMove(state, action); - } - - return state; - } - - private handleHandleDrag(state: EditorState, action: Action): EditorState { - if (action.type !== "pointer-move" || !this.toolState.handleShapeId || !this.toolState.activeHandle) { - return state; - } - const shapeId = this.toolState.handleShapeId; - const currentShape = state.doc.shapes[shapeId]; - const initialShape = this.toolState.handleInitialShapes.get(shapeId); - if (!currentShape || !initialShape) { - return state; - } - - let updated: ShapeRecord | null = null; - if (this.toolState.activeHandle === "rotate") { - updated = this.rotateShape(initialShape, action.world); - } else if (this.toolState.activeHandle === "arrow-label") { - updated = this.adjustArrowLabel(initialShape, action.world); - } else if ( - this.toolState.activeHandle === "line-start" - || this.toolState.activeHandle === "line-end" - || this.toolState.activeHandle.startsWith("arrow-point-") - ) { - updated = this.resizeLineShape(initialShape, action.world, this.toolState.activeHandle); - } else if (this.toolState.handleStartBounds) { - updated = this.resizeRectLikeShape( - initialShape, - this.toolState.handleStartBounds, - action.world, - this.toolState.activeHandle, - ); - } - - if (!updated) { - return state; - } - - let newState = { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [shapeId]: updated } } }; - - if ( - currentShape.type === "arrow" - && (this.toolState.activeHandle === "line-start" || this.toolState.activeHandle === "line-end") - ) { - const handle = this.toolState.activeHandle === "line-start" ? "start" : "end"; - - const stateWithoutArrow = { - ...newState, - doc: { - ...newState.doc, - shapes: Object.fromEntries(Object.entries(newState.doc.shapes).filter(([id]) => id !== shapeId)), - }, - }; - - const hitShapeId = hitTestPoint(stateWithoutArrow, action.world); - - if (hitShapeId) { - newState = { - ...newState, - ui: { ...newState.ui, bindingPreview: { arrowId: shapeId, targetShapeId: hitShapeId, handle } }, - }; - } else { - newState = { ...newState, ui: { ...newState.ui, bindingPreview: undefined } }; - } - } - - return newState; - } - - /** - * Handle dragging selected shapes - */ - private handleDragMove(state: EditorState, action: Action): EditorState { - if (action.type !== "pointer-move" || !this.toolState.dragStartWorld) return state; - - const delta = Vec2Ops.sub(action.world, this.toolState.dragStartWorld); - - const newShapes = { ...state.doc.shapes }; - - for (const [shapeId, initialPos] of this.toolState.initialShapePositions) { - const shape = newShapes[shapeId]; - if (shape) { - newShapes[shapeId] = { ...shape, x: initialPos.x + delta.x, y: initialPos.y + delta.y }; - } - } - - return { ...state, doc: { ...state.doc, shapes: newShapes } }; - } - - /** - * Handle updating marquee selection - */ - private handleMarqueeMove(state: EditorState, action: Action): EditorState { - if (action.type !== "pointer-move") return state; - - this.toolState.marqueeEnd = action.world; - this.notifyMarqueeChange(); - - return state; - } - - /** - * Handle pointer up - end drag or complete marquee selection - */ - private handlePointerUp(state: EditorState, action: Action): EditorState { - if (action.type !== "pointer-up") return state; - - let newState = state; - - if (this.toolState.marqueeStart && this.toolState.marqueeEnd) { - newState = this.completeMarqueeSelection(state); - } - - if (this.toolState.isDragging && !this.toolState.activeHandle) { - newState = this.removeBindingsForMovedArrows(newState); - } - - if ( - this.toolState.handleShapeId - && (this.toolState.activeHandle === "line-start" || this.toolState.activeHandle === "line-end") - ) { - newState = this.updateArrowBindings(newState, this.toolState.handleShapeId, action.world); - } - - this.toolState.activeHandle = null; - this.toolState.handleShapeId = null; - this.toolState.handleStartBounds = null; - this.toolState.handleInitialShapes.clear(); - this.toolState.rotationCenter = null; - this.toolState.rotationStartAngle = null; - this.toolState.isDragging = false; - this.toolState.dragStartWorld = null; - this.toolState.initialShapePositions.clear(); - this.toolState.marqueeStart = null; - this.toolState.marqueeEnd = null; - this.notifyMarqueeChange(); - - if (newState.ui.bindingPreview) { - newState = { ...newState, ui: { ...newState.ui, bindingPreview: undefined } }; - } - - return newState; - } - - /** - * Complete marquee selection - select shapes whose bounds intersect the marquee - */ - private completeMarqueeSelection(state: EditorState): EditorState { - if (!this.toolState.marqueeStart || !this.toolState.marqueeEnd) return state; - - const marqueeBox = Box2.fromPoints([this.toolState.marqueeStart, this.toolState.marqueeEnd]); - const currentPage = getCurrentPage(state); - - if (!currentPage) return state; - - const selectedIds: string[] = []; - - for (const shapeId of currentPage.shapeIds) { - const shape = state.doc.shapes[shapeId]; - if (shape) { - const bounds = shapeBounds(shape); - if (Box2.intersectsBox(marqueeBox, bounds)) { - selectedIds.push(shapeId); - } - } - } - - return { ...state, ui: { ...state.ui, selectionIds: selectedIds } }; - } - - /** - * Handle keyboard input - Escape to clear selection, Delete to remove shapes - */ - private handleKeyDown(state: EditorState, action: Action): EditorState { - if (action.type !== "key-down") return state; - - if (action.key === "Escape") { - return { ...state, ui: { ...state.ui, selectionIds: [] } }; - } - - if (action.key === "Delete" || action.key === "Backspace") { - if ( - this.toolState.activeHandle - && typeof this.toolState.activeHandle === "string" - && this.toolState.activeHandle.startsWith("arrow-point-") - && this.toolState.handleShapeId - ) { - return this.removeArrowPoint(state, this.toolState.handleShapeId, this.toolState.activeHandle); - } - - return this.deleteSelectedShapes(state); - } - - return state; - } - - /** - * Delete all selected shapes - */ - private deleteSelectedShapes(state: EditorState): EditorState { - const shapesToDelete = new Set(state.ui.selectionIds); - - if (shapesToDelete.size === 0) return state; - - const newShapes = { ...state.doc.shapes }; - const newBindings = { ...state.doc.bindings }; - const newPages = { ...state.doc.pages }; - - for (const shapeId of shapesToDelete) { - delete newShapes[shapeId]; - } - - for (const [bindingId, binding] of Object.entries(newBindings)) { - if (shapesToDelete.has(binding.fromShapeId) || shapesToDelete.has(binding.toShapeId)) { - delete newBindings[bindingId]; - } - } - - for (const [pageId, page] of Object.entries(newPages)) { - const filteredShapeIds = page.shapeIds.filter((id) => !shapesToDelete.has(id)); - if (filteredShapeIds.length !== page.shapeIds.length) { - newPages[pageId] = { ...page, shapeIds: filteredShapeIds }; - } - } - - return { - ...state, - doc: { ...state.doc, shapes: newShapes, bindings: newBindings, pages: newPages }, - ui: { ...state.ui, selectionIds: [] }, - }; - } - - /** - * Reset internal tool state - */ - private resetToolState(): void { - this.toolState = { - isDragging: false, - dragStartWorld: null, - initialShapePositions: new Map(), - marqueeStart: null, - marqueeEnd: null, - activeHandle: null, - handleShapeId: null, - handleStartBounds: null, - handleInitialShapes: new Map(), - rotationCenter: null, - rotationStartAngle: null, - }; - this.notifyMarqueeChange(); - } - - /** - * Get current marquee bounds (for rendering) - */ - getMarqueeBounds(): Box2 | null { - if (!this.toolState.marqueeStart || !this.toolState.marqueeEnd) return null; - return Box2.fromPoints([this.toolState.marqueeStart, this.toolState.marqueeEnd]); - } - - private notifyMarqueeChange(): void { - if (this.marqueeListener) { - this.marqueeListener(this.getMarqueeBounds()); - } - } - - getHandleAtPoint(state: EditorState, point: Vec2): HandleKind | null { - const hit = this.hitTestHandle(state, point); - return hit?.handle ?? null; - } - - getActiveHandle(): HandleKind | null { - return this.toolState.activeHandle; - } - - private getHandlePositions(state: EditorState, shape: ShapeRecord): Array<{ id: HandleKind; position: Vec2 }> { - const handles: Array<{ id: HandleKind; position: Vec2 }> = []; - if (shape.type === "rect" || shape.type === "ellipse" || shape.type === "text") { - const bounds = shapeBounds(shape); - const minX = bounds.min.x; - const maxX = bounds.max.x; - const minY = bounds.min.y; - const maxY = bounds.max.y; - const centerX = (minX + maxX) / 2; - const centerY = (minY + maxY) / 2; - handles.push( - { id: "nw", position: { x: minX, y: minY } }, - { id: "n", position: { x: centerX, y: minY } }, - { id: "ne", position: { x: maxX, y: minY } }, - { id: "e", position: { x: maxX, y: centerY } }, - { id: "se", position: { x: maxX, y: maxY } }, - { id: "s", position: { x: centerX, y: maxY } }, - { id: "sw", position: { x: minX, y: maxY } }, - { id: "w", position: { x: minX, y: centerY } }, - { id: "rotate", position: { x: centerX, y: minY - ROTATE_HANDLE_OFFSET } }, - ); - } else if (shape.type === "line") { - const start = this.localToWorld(shape, shape.props.a); - const end = this.localToWorld(shape, shape.props.b); - handles.push({ id: "line-start", position: start }, { id: "line-end", position: end }); - } else if (shape.type === "arrow") { - const resolved = resolveArrowEndpoints(state, shape.id); - if (resolved && shape.props.points && shape.props.points.length >= 2) { - handles.push({ id: "line-start", position: resolved.a }); - - for (let i = 1; i < shape.props.points.length - 1; i++) { - const point = shape.props.points[i]; - const worldPos = this.localToWorld(shape, point); - handles.push({ id: `arrow-point-${i}` as HandleKind, position: worldPos }); - } - - handles.push({ id: "line-end", position: resolved.b }); - - if (shape.props.label) { - const polylineLength = computePolylineLength(shape.props.points); - const align = shape.props.label.align ?? "center"; - const offset = shape.props.label.offset ?? 0; - - let distance: number; - if (align === "center") { - distance = polylineLength / 2 + offset; - } else if (align === "start") { - distance = offset; - } else { - distance = polylineLength - offset; - } - - distance = Math.max(0, Math.min(distance, polylineLength)); - const labelPos = getPointAtDistance(shape.props.points, distance); - const worldLabelPos = this.localToWorld(shape, labelPos); - handles.push({ id: "arrow-label", position: worldLabelPos }); - } - } - } - return handles; - } - - private resizeRectLikeShape( - initial: ShapeRecord, - bounds: Box2, - pointer: Vec2, - handle: HandleKind, - ): ShapeRecord | null { - if ( - initial.type !== "rect" && initial.type !== "ellipse" && initial.type !== "text" && initial.type !== "markdown" - ) { - return null; - } - let minX = bounds.min.x; - let maxX = bounds.max.x; - let minY = bounds.min.y; - let maxY = bounds.max.y; - - const clampX = (value: number) => Math.min(Math.max(value, -1e6), 1e6); - const clampY = (value: number) => Math.min(Math.max(value, -1e6), 1e6); - - switch (handle) { - case "nw": { - minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); - minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); - break; - } - case "n": { - minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); - break; - } - case "ne": { - maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); - minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); - break; - } - case "e": { - maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); - break; - } - case "se": { - maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); - maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); - break; - } - case "s": { - maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); - break; - } - case "sw": { - minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); - maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); - break; - } - case "w": { - minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); - break; - } - } - - const width = Math.max(maxX - minX, MIN_RESIZE_SIZE); - const height = Math.max(maxY - minY, MIN_RESIZE_SIZE); - - if (initial.type === "text") { - return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width } }; - } - - if (initial.type === "markdown") { - return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width, h: height } }; - } - - // @ts-expect-error union mismatch - return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width, h: height } }; - } - - private adjustArrowLabel(initial: ShapeRecord, pointer: Vec2): ShapeRecord | null { - if (initial.type !== "arrow" || !initial.props.points || initial.props.points.length < 2 || !initial.props.label) { - return null; - } - - const localPointer = this.worldToLocal(initial, pointer); - const points = initial.props.points; - const polylineLength = computePolylineLength(points); - - let closestDistance = 0; - let minDistToLine = Number.POSITIVE_INFINITY; - - for (let i = 0; i < points.length - 1; i++) { - const a = points[i]; - const b = points[i + 1]; - const segmentLength = Vec2Ops.dist(a, b); - - const ab = Vec2Ops.sub(b, a); - const ap = Vec2Ops.sub(localPointer, a); - const t = Math.max(0, Math.min(1, Vec2Ops.dot(ap, ab) / Vec2Ops.dot(ab, ab))); - const projection = Vec2Ops.add(a, Vec2Ops.mulScalar(ab, t)); - const distToLine = Vec2Ops.dist(localPointer, projection); - - if (distToLine < minDistToLine) { - minDistToLine = distToLine; - let distanceToSegmentStart = 0; - for (let j = 0; j < i; j++) { - distanceToSegmentStart += Vec2Ops.dist(points[j], points[j + 1]); - } - closestDistance = distanceToSegmentStart + t * segmentLength; - } - } - - const align = initial.props.label.align ?? "center"; - let newOffset: number; - - if (align === "center") { - newOffset = closestDistance - polylineLength / 2; - } else if (align === "start") { - newOffset = closestDistance; - } else { - newOffset = polylineLength - closestDistance; - } - - return { ...initial, props: { ...initial.props, label: { ...initial.props.label, offset: newOffset } } }; - } - - private resizeLineShape(initial: ShapeRecord, pointer: Vec2, handle: HandleKind): ShapeRecord | null { - if (initial.type !== "line" && initial.type !== "arrow") { - return null; - } - - if (initial.type === "arrow" && typeof handle === "string" && handle.startsWith("arrow-point-")) { - const pointIndex = Number.parseInt(handle.replace("arrow-point-", ""), 10); - if (!initial.props.points || pointIndex < 1 || pointIndex >= initial.props.points.length - 1) { - return null; - } - - const newPoints = initial.props.points.map((p, i) => { - if (i === pointIndex) { - return { x: pointer.x - initial.x, y: pointer.y - initial.y }; - } - return p; - }); - - const newProps = { ...initial.props, points: newPoints }; - return { ...initial, props: newProps }; - } - - if (handle !== "line-start" && handle !== "line-end") { - return null; - } - - let startPoint: Vec2, endPoint: Vec2; - - if (initial.type === "line") { - startPoint = initial.props.a; - endPoint = initial.props.b; - } else { - if (!initial.props.points || initial.props.points.length < 2) { - return null; - } - startPoint = initial.props.points[0]; - endPoint = initial.props.points[initial.props.points.length - 1]; - } - - const startWorld = this.localToWorld(initial, startPoint); - const endWorld = this.localToWorld(initial, endPoint); - const newStart = handle === "line-start" ? pointer : startWorld; - const newEnd = handle === "line-end" ? pointer : endWorld; - - if (initial.type === "line") { - const newProps = { - ...initial.props, - a: { x: 0, y: 0 }, - b: { x: newEnd.x - newStart.x, y: newEnd.y - newStart.y }, - }; - return { ...initial, x: newStart.x, y: newStart.y, props: newProps }; - } else { - const newPoints = initial.props.points.map((p, i) => { - if (i === 0) { - return { x: 0, y: 0 }; - } else if (i === initial.props.points.length - 1) { - return { x: newEnd.x - newStart.x, y: newEnd.y - newStart.y }; - } else { - const worldPos = this.localToWorld(initial, p); - return { x: worldPos.x - newStart.x, y: worldPos.y - newStart.y }; - } - }); - - const newProps = { ...initial.props, points: newPoints }; - return { ...initial, x: newStart.x, y: newStart.y, props: newProps }; - } - } - - private rotateShape(initial: ShapeRecord, pointer: Vec2): ShapeRecord | null { - if (!this.toolState.rotationCenter || this.toolState.rotationStartAngle === null) { - return null; - } - if ( - initial.type !== "rect" && initial.type !== "ellipse" && initial.type !== "text" && initial.type !== "markdown" - ) { - return null; - } - const currentAngle = Math.atan2( - pointer.y - this.toolState.rotationCenter.y, - pointer.x - this.toolState.rotationCenter.x, - ); - const delta = currentAngle - this.toolState.rotationStartAngle; - return { ...initial, rot: initial.rot + delta }; - } - - private localToWorld(shape: ShapeRecord, point: Vec2): Vec2 { - if (shape.rot === 0) { - return { x: shape.x + point.x, y: shape.y + point.y }; - } - const cos = Math.cos(shape.rot); - const sin = Math.sin(shape.rot); - return { x: shape.x + point.x * cos - point.y * sin, y: shape.y + point.x * sin + point.y * cos }; - } - - private worldToLocal(shape: ShapeRecord, point: Vec2): Vec2 { - if (shape.rot === 0) { - return { x: point.x - shape.x, y: point.y - shape.y }; - } - const dx = point.x - shape.x; - const dy = point.y - shape.y; - const cos = Math.cos(-shape.rot); - const sin = Math.sin(-shape.rot); - return { x: dx * cos - dy * sin, y: dx * sin + dy * cos }; - } - - /** - * Remove an intermediate point from an arrow - */ - private removeArrowPoint(state: EditorState, arrowId: string, handle: HandleKind): EditorState { - const arrow = state.doc.shapes[arrowId]; - if (!arrow || arrow.type !== "arrow" || !arrow.props.points) { - return state; - } - - const pointIndex = Number.parseInt((handle as string).replace("arrow-point-", ""), 10); - if (Number.isNaN(pointIndex) || pointIndex < 1 || pointIndex >= arrow.props.points.length - 1) { - return state; - } - - const newPoints = arrow.props.points.filter((_, i) => i !== pointIndex); - - if (newPoints.length < 2) { - return state; - } - - const updatedArrow = { ...arrow, props: { ...arrow.props, points: newPoints } }; - - this.resetToolState(); - - return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [arrowId]: updatedArrow } } }; - } - - /** - * Try to add a point to an arrow segment at the clicked location - * Returns updated state if successful, null otherwise - */ - private tryAddPointToArrowSegment(state: EditorState, arrow: ShapeRecord, clickWorld: Vec2): EditorState | null { - if (arrow.type !== "arrow" || !arrow.props.points || arrow.props.points.length < 2) { - return null; - } - - const clickLocal = { x: clickWorld.x - arrow.x, y: clickWorld.y - arrow.y }; - const tolerance = 10; - - for (let i = 0; i < arrow.props.points.length - 1; i++) { - const a = arrow.props.points[i]; - const b = arrow.props.points[i + 1]; - - const ab = Vec2Ops.sub(b, a); - const ap = Vec2Ops.sub(clickLocal, a); - const abLengthSq = Vec2Ops.lenSq(ab); - - if (abLengthSq === 0) continue; - - const t = Math.max(0, Math.min(1, Vec2Ops.dot(ap, ab) / abLengthSq)); - const projection = Vec2Ops.add(a, Vec2Ops.mulScalar(ab, t)); - const distance = Vec2Ops.dist(clickLocal, projection); - - if (distance <= tolerance) { - const newPoints = [...arrow.props.points.slice(0, i + 1), clickLocal, ...arrow.props.points.slice(i + 1)]; - - const updatedArrow = { ...arrow, props: { ...arrow.props, points: newPoints } }; - return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [arrow.id]: updatedArrow } } }; - } - } - - return null; - } - - /** - * Remove bindings for arrows that were moved with the select tool - * - * When an arrow is moved (not just its endpoints), its bindings should be removed - * to prevent the endpoints from snapping back to the old binding positions. - */ - private removeBindingsForMovedArrows(state: EditorState): EditorState { - const movedArrowIds = Array.from(this.toolState.initialShapePositions.keys()).filter((shapeId) => { - const shape = state.doc.shapes[shapeId]; - return shape && shape.type === "arrow"; - }); - - if (movedArrowIds.length === 0) { - return state; - } - - const newBindings = { ...state.doc.bindings }; - const newShapes = { ...state.doc.shapes }; - let bindingsRemoved = false; - - for (const arrowId of movedArrowIds) { - const arrow = newShapes[arrowId]; - if (!arrow || arrow.type !== "arrow") continue; - - for (const [bindingId, binding] of Object.entries(newBindings)) { - if (binding.fromShapeId === arrowId) { - delete newBindings[bindingId]; - bindingsRemoved = true; - - console.log("[Arrow Movement Fix] Removing binding", { - arrowId, - bindingId, - handle: binding.handle, - targetShapeId: binding.toShapeId, - }); - } - } - - if (bindingsRemoved) { - newShapes[arrowId] = { ...arrow, props: { ...arrow.props, start: { kind: "free" }, end: { kind: "free" } } }; - } - } - - if (!bindingsRemoved) { - return state; - } - - return { ...state, doc: { ...state.doc, shapes: newShapes, bindings: newBindings } }; - } - - /** - * Update arrow bindings when an endpoint is dragged - * - * Creates or updates bindings for arrow endpoints based on hit testing. - * If the endpoint is over a shape, creates/updates an edge anchor binding. - * If the endpoint is not over a shape, removes any existing binding. - */ - private updateArrowBindings(state: EditorState, arrowId: string, endpointWorld: Vec2): EditorState { - const arrow = state.doc.shapes[arrowId]; - if (!arrow || arrow.type !== "arrow") return state; - - const handle = this.toolState.activeHandle === "line-start" ? "start" : "end"; - - const stateWithoutArrow = { - ...state, - doc: { - ...state.doc, - shapes: Object.fromEntries(Object.entries(state.doc.shapes).filter(([id]) => id !== arrowId)), - }, - }; - - const hitShapeId = hitTestPoint(stateWithoutArrow, endpointWorld); - - const newBindings = { ...state.doc.bindings }; - - for (const [bindingId, binding] of Object.entries(newBindings)) { - if (binding.fromShapeId === arrowId && binding.handle === handle) { - delete newBindings[bindingId]; - } - } - - if (hitShapeId) { - const targetShape = state.doc.shapes[hitShapeId]; - if (targetShape) { - const anchor = computeNormalizedAnchor(endpointWorld, targetShape); - const binding = BindingRecord.create(arrowId, hitShapeId, handle, { - kind: "edge", - nx: anchor.nx, - ny: anchor.ny, - }); - newBindings[binding.id] = binding; - } - } - - return { ...state, doc: { ...state.doc, bindings: newBindings } }; - } + readonly id: ToolId = 'select'; + private toolState: SelectToolState; + private readonly marqueeListener?: (bounds: Box2 | null) => void; + + constructor(onMarqueeChange?: (bounds: Box2 | null) => void) { + this.marqueeListener = onMarqueeChange; + this.toolState = { + isDragging: false, + dragStartWorld: null, + initialShapePositions: new Map(), + marqueeStart: null, + marqueeEnd: null, + activeHandle: null, + handleShapeId: null, + handleStartBounds: null, + handleInitialShapes: new Map(), + rotationCenter: null, + rotationStartAngle: null + }; + } + + onEnter(state: EditorState): EditorState { + this.resetToolState(); + return state; + } + + onExit(state: EditorState): EditorState { + this.resetToolState(); + return state; + } + + onAction(state: EditorState, action: Action): EditorState { + switch (action.type) { + case 'pointer-down': { + return this.handlePointerDown(state, action); + } + case 'pointer-move': { + return this.handlePointerMove(state, action); + } + case 'pointer-up': { + return this.handlePointerUp(state, action); + } + case 'key-down': { + return this.handleKeyDown(state, action); + } + default: { + return state; + } + } + } + + /** + * Handle pointer down - select shapes or start marquee + */ + private handlePointerDown(state: EditorState, action: Action): EditorState { + if (action.type !== 'pointer-down') return state; + + if (action.modifiers.alt && state.ui.selectionIds.length === 1) { + const shapeId = state.ui.selectionIds[0]; + const shape = state.doc.shapes[shapeId]; + if (shape?.type === 'arrow') { + const result = this.tryAddPointToArrowSegment(state, shape, action.world); + if (result) { + return result; + } + } + } + + const handleHit = this.hitTestHandle(state, action.world); + if (handleHit) { + return this.beginHandleDrag(state, handleHit.shape, handleHit.handle, action.world); + } + + const hitShapeId = hitTestPoint(state, action.world); + + return hitShapeId ? this.handleShapeClick(state, hitShapeId, action) : this.handleEmptyClick(state, action); + } + + private hitTestHandle(state: EditorState, point: Vec2): { handle: HandleKind; shape: ShapeRecord } | null { + if (state.ui.selectionIds.length !== 1) { + return null; + } + const shapeId = state.ui.selectionIds[0]; + const shape = state.doc.shapes[shapeId]; + if (!shape) { + return null; + } + const handles = this.getHandlePositions(state, shape); + for (const handle of handles) { + if (Vec2Ops.dist(point, handle.position) <= HANDLE_HIT_RADIUS) { + return { handle: handle.id, shape }; + } + } + return null; + } + + private beginHandleDrag(state: EditorState, shape: ShapeRecord, handle: HandleKind, point: Vec2): EditorState { + this.toolState.activeHandle = handle; + this.toolState.handleShapeId = shape.id; + this.toolState.handleStartBounds = shapeBounds(shape); + this.toolState.handleInitialShapes.clear(); + this.toolState.handleInitialShapes.set(shape.id, ShapeRecord.clone(shape)); + this.toolState.isDragging = false; + this.toolState.dragStartWorld = point; + const bounds = this.toolState.handleStartBounds; + this.toolState.rotationCenter = bounds + ? { x: (bounds.min.x + bounds.max.x) / 2, y: (bounds.min.y + bounds.max.y) / 2 } + : null; + this.toolState.rotationStartAngle = this.toolState.rotationCenter + ? Math.atan2(point.y - this.toolState.rotationCenter.y, point.x - this.toolState.rotationCenter.x) + : null; + return state; + } + + /** + * Handle clicking on a shape + */ + private handleShapeClick(state: EditorState, shapeId: string, action: Action): EditorState { + if (action.type !== 'pointer-down') return state; + + const clickedShape = state.doc.shapes[shapeId]; + if (!clickedShape) return state; + + const isShiftHeld = action.modifiers.shift; + + let idsToInteractWith: string[] = [shapeId]; + if (clickedShape.groupId) { + idsToInteractWith = Object.values(state.doc.shapes) + .filter((s) => s.groupId === clickedShape.groupId) + .map((s) => s.id); + } + + const isAnySelected = idsToInteractWith.some((id) => state.ui.selectionIds.includes(id)); + + let newSelectionIds: string[]; + + if (isShiftHeld) { + if (isAnySelected) { + newSelectionIds = state.ui.selectionIds.filter((id) => !idsToInteractWith.includes(id)); + } else { + newSelectionIds = [...state.ui.selectionIds, ...idsToInteractWith]; + } + } else { + if (isAnySelected && !isShiftHeld) { + newSelectionIds = state.ui.selectionIds; + } else { + newSelectionIds = idsToInteractWith; + } + } + + if (isShiftHeld) { + const shouldSelect = !isAnySelected; + if (shouldSelect) { + newSelectionIds = [...new Set([...state.ui.selectionIds, ...idsToInteractWith])]; + } else { + newSelectionIds = state.ui.selectionIds.filter((id) => !idsToInteractWith.includes(id)); + } + } else { + if (isAnySelected) { + newSelectionIds = state.ui.selectionIds; + } else { + newSelectionIds = idsToInteractWith; + } + } + + this.toolState.isDragging = true; + this.toolState.dragStartWorld = action.world; + this.toolState.initialShapePositions.clear(); + + for (const id of newSelectionIds) { + const shape = state.doc.shapes[id]; + if (shape) { + this.toolState.initialShapePositions.set(id, { x: shape.x, y: shape.y }); + } + } + + return { ...state, ui: { ...state.ui, selectionIds: newSelectionIds } }; + } + + /** + * Handle clicking on empty canvas - clear selection or start marquee + */ + private handleEmptyClick(state: EditorState, action: Action): EditorState { + if (action.type !== 'pointer-down') return state; + + const isShiftHeld = action.modifiers.shift; + + if (!isShiftHeld) { + this.toolState.marqueeStart = action.world; + this.toolState.marqueeEnd = action.world; + this.notifyMarqueeChange(); + + return { ...state, ui: { ...state.ui, selectionIds: [] } }; + } + + return state; + } + + /** + * Handle pointer move - drag shapes or update marquee + */ + private handlePointerMove(state: EditorState, action: Action): EditorState { + if (action.type !== 'pointer-move') return state; + + if (this.toolState.activeHandle && this.toolState.handleShapeId) { + return this.handleHandleDrag(state, action); + } + + if (this.toolState.isDragging && this.toolState.dragStartWorld) { + return this.handleDragMove(state, action); + } else if (this.toolState.marqueeStart) { + return this.handleMarqueeMove(state, action); + } + + return state; + } + + private handleHandleDrag(state: EditorState, action: Action): EditorState { + if (action.type !== 'pointer-move' || !this.toolState.handleShapeId || !this.toolState.activeHandle) { + return state; + } + const shapeId = this.toolState.handleShapeId; + const currentShape = state.doc.shapes[shapeId]; + const initialShape = this.toolState.handleInitialShapes.get(shapeId); + if (!currentShape || !initialShape) { + return state; + } + + let updated: ShapeRecord | null = null; + if (this.toolState.activeHandle === 'rotate') { + updated = this.rotateShape(initialShape, action.world); + } else if (this.toolState.activeHandle === 'arrow-label') { + updated = this.adjustArrowLabel(initialShape, action.world); + } else if ( + this.toolState.activeHandle === 'line-start' || + this.toolState.activeHandle === 'line-end' || + this.toolState.activeHandle.startsWith('arrow-point-') + ) { + updated = this.resizeLineShape(initialShape, action.world, this.toolState.activeHandle); + } else if (this.toolState.handleStartBounds) { + updated = this.resizeRectLikeShape( + initialShape, + this.toolState.handleStartBounds, + action.world, + this.toolState.activeHandle + ); + } + + if (!updated) { + return state; + } + + let newState = { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [shapeId]: updated } } }; + + if ( + currentShape.type === 'arrow' && + (this.toolState.activeHandle === 'line-start' || this.toolState.activeHandle === 'line-end') + ) { + const handle = this.toolState.activeHandle === 'line-start' ? 'start' : 'end'; + + const stateWithoutArrow = { + ...newState, + doc: { + ...newState.doc, + shapes: Object.fromEntries(Object.entries(newState.doc.shapes).filter(([id]) => id !== shapeId)) + } + }; + + const hitShapeId = hitTestPoint(stateWithoutArrow, action.world); + + if (hitShapeId) { + newState = { + ...newState, + ui: { ...newState.ui, bindingPreview: { arrowId: shapeId, targetShapeId: hitShapeId, handle } } + }; + } else { + newState = { ...newState, ui: { ...newState.ui, bindingPreview: undefined } }; + } + } + + return newState; + } + + /** + * Handle dragging selected shapes + */ + private handleDragMove(state: EditorState, action: Action): EditorState { + if (action.type !== 'pointer-move' || !this.toolState.dragStartWorld) return state; + + const delta = Vec2Ops.sub(action.world, this.toolState.dragStartWorld); + + const newShapes = { ...state.doc.shapes }; + + for (const [shapeId, initialPos] of this.toolState.initialShapePositions) { + const shape = newShapes[shapeId]; + if (shape) { + newShapes[shapeId] = { ...shape, x: initialPos.x + delta.x, y: initialPos.y + delta.y }; + } + } + + return { ...state, doc: { ...state.doc, shapes: newShapes } }; + } + + /** + * Handle updating marquee selection + */ + private handleMarqueeMove(state: EditorState, action: Action): EditorState { + if (action.type !== 'pointer-move') return state; + + this.toolState.marqueeEnd = action.world; + this.notifyMarqueeChange(); + + return state; + } + + /** + * Handle pointer up - end drag or complete marquee selection + */ + private handlePointerUp(state: EditorState, action: Action): EditorState { + if (action.type !== 'pointer-up') return state; + + let newState = state; + + if (this.toolState.marqueeStart && this.toolState.marqueeEnd) { + newState = this.completeMarqueeSelection(state); + } + + if (this.toolState.isDragging && !this.toolState.activeHandle) { + newState = this.removeBindingsForMovedArrows(newState); + } + + if ( + this.toolState.handleShapeId && + (this.toolState.activeHandle === 'line-start' || this.toolState.activeHandle === 'line-end') + ) { + newState = this.updateArrowBindings(newState, this.toolState.handleShapeId, action.world); + } + + this.toolState.activeHandle = null; + this.toolState.handleShapeId = null; + this.toolState.handleStartBounds = null; + this.toolState.handleInitialShapes.clear(); + this.toolState.rotationCenter = null; + this.toolState.rotationStartAngle = null; + this.toolState.isDragging = false; + this.toolState.dragStartWorld = null; + this.toolState.initialShapePositions.clear(); + this.toolState.marqueeStart = null; + this.toolState.marqueeEnd = null; + this.notifyMarqueeChange(); + + if (newState.ui.bindingPreview) { + newState = { ...newState, ui: { ...newState.ui, bindingPreview: undefined } }; + } + + return newState; + } + + /** + * Complete marquee selection - select shapes whose bounds intersect the marquee + */ + private completeMarqueeSelection(state: EditorState): EditorState { + if (!this.toolState.marqueeStart || !this.toolState.marqueeEnd) return state; + + const marqueeBox = Box2.fromPoints([this.toolState.marqueeStart, this.toolState.marqueeEnd]); + const currentPage = getCurrentPage(state); + + if (!currentPage) return state; + + const selectedIds: string[] = []; + + const interactiveIds = new Set(getInteractiveShapesOnCurrentPage(state).map((shape) => shape.id)); + for (const shapeId of currentPage.shapeIds) { + if (!interactiveIds.has(shapeId)) continue; + const shape = state.doc.shapes[shapeId]; + if (shape) { + const bounds = shapeBounds(shape); + if (Box2.intersectsBox(marqueeBox, bounds)) { + selectedIds.push(shapeId); + } + } + } + + return { ...state, ui: { ...state.ui, selectionIds: selectedIds } }; + } + + /** + * Handle keyboard input - Escape to clear selection, Delete to remove shapes + */ + private handleKeyDown(state: EditorState, action: Action): EditorState { + if (action.type !== 'key-down') return state; + + if (action.key === 'Escape') { + return { ...state, ui: { ...state.ui, selectionIds: [] } }; + } + + if (action.key === 'Delete' || action.key === 'Backspace') { + if ( + this.toolState.activeHandle && + typeof this.toolState.activeHandle === 'string' && + this.toolState.activeHandle.startsWith('arrow-point-') && + this.toolState.handleShapeId + ) { + return this.removeArrowPoint(state, this.toolState.handleShapeId, this.toolState.activeHandle); + } + + return this.deleteSelectedShapes(state); + } + + return state; + } + + /** + * Delete all selected shapes + */ + private deleteSelectedShapes(state: EditorState): EditorState { + const shapesToDelete = new Set(state.ui.selectionIds); + + if (shapesToDelete.size === 0) return state; + + const newShapes = { ...state.doc.shapes }; + const newBindings = { ...state.doc.bindings }; + const newPages = { ...state.doc.pages }; + + for (const shapeId of shapesToDelete) { + delete newShapes[shapeId]; + } + + for (const [bindingId, binding] of Object.entries(newBindings)) { + if (shapesToDelete.has(binding.fromShapeId) || shapesToDelete.has(binding.toShapeId)) { + delete newBindings[bindingId]; + } + } + + for (const [pageId, page] of Object.entries(newPages)) { + const filteredShapeIds = page.shapeIds.filter((id) => !shapesToDelete.has(id)); + if (filteredShapeIds.length !== page.shapeIds.length) { + newPages[pageId] = { ...page, shapeIds: filteredShapeIds }; + } + } + + return { + ...state, + doc: { ...state.doc, shapes: newShapes, bindings: newBindings, pages: newPages }, + ui: { ...state.ui, selectionIds: [] } + }; + } + + /** + * Reset internal tool state + */ + private resetToolState(): void { + this.toolState = { + isDragging: false, + dragStartWorld: null, + initialShapePositions: new Map(), + marqueeStart: null, + marqueeEnd: null, + activeHandle: null, + handleShapeId: null, + handleStartBounds: null, + handleInitialShapes: new Map(), + rotationCenter: null, + rotationStartAngle: null + }; + this.notifyMarqueeChange(); + } + + /** + * Get current marquee bounds (for rendering) + */ + getMarqueeBounds(): Box2 | null { + if (!this.toolState.marqueeStart || !this.toolState.marqueeEnd) return null; + return Box2.fromPoints([this.toolState.marqueeStart, this.toolState.marqueeEnd]); + } + + private notifyMarqueeChange(): void { + if (this.marqueeListener) { + this.marqueeListener(this.getMarqueeBounds()); + } + } + + getHandleAtPoint(state: EditorState, point: Vec2): HandleKind | null { + const hit = this.hitTestHandle(state, point); + return hit?.handle ?? null; + } + + getActiveHandle(): HandleKind | null { + return this.toolState.activeHandle; + } + + private getHandlePositions(state: EditorState, shape: ShapeRecord): Array<{ id: HandleKind; position: Vec2 }> { + const handles: Array<{ id: HandleKind; position: Vec2 }> = []; + if (shape.type === 'rect' || shape.type === 'ellipse' || shape.type === 'text') { + const bounds = shapeBounds(shape); + const minX = bounds.min.x; + const maxX = bounds.max.x; + const minY = bounds.min.y; + const maxY = bounds.max.y; + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + handles.push( + { id: 'nw', position: { x: minX, y: minY } }, + { id: 'n', position: { x: centerX, y: minY } }, + { id: 'ne', position: { x: maxX, y: minY } }, + { id: 'e', position: { x: maxX, y: centerY } }, + { id: 'se', position: { x: maxX, y: maxY } }, + { id: 's', position: { x: centerX, y: maxY } }, + { id: 'sw', position: { x: minX, y: maxY } }, + { id: 'w', position: { x: minX, y: centerY } }, + { id: 'rotate', position: { x: centerX, y: minY - ROTATE_HANDLE_OFFSET } } + ); + } else if (shape.type === 'line') { + const start = this.localToWorld(shape, shape.props.a); + const end = this.localToWorld(shape, shape.props.b); + handles.push({ id: 'line-start', position: start }, { id: 'line-end', position: end }); + } else if (shape.type === 'arrow') { + const resolved = resolveArrowEndpoints(state, shape.id); + if (resolved && shape.props.points && shape.props.points.length >= 2) { + handles.push({ id: 'line-start', position: resolved.a }); + + for (let i = 1; i < shape.props.points.length - 1; i++) { + const point = shape.props.points[i]; + const worldPos = this.localToWorld(shape, point); + handles.push({ id: `arrow-point-${i}` as HandleKind, position: worldPos }); + } + + handles.push({ id: 'line-end', position: resolved.b }); + + if (shape.props.label) { + const polylineLength = computePolylineLength(shape.props.points); + const align = shape.props.label.align ?? 'center'; + const offset = shape.props.label.offset ?? 0; + + let distance: number; + if (align === 'center') { + distance = polylineLength / 2 + offset; + } else if (align === 'start') { + distance = offset; + } else { + distance = polylineLength - offset; + } + + distance = Math.max(0, Math.min(distance, polylineLength)); + const labelPos = getPointAtDistance(shape.props.points, distance); + const worldLabelPos = this.localToWorld(shape, labelPos); + handles.push({ id: 'arrow-label', position: worldLabelPos }); + } + } + } + return handles; + } + + private resizeRectLikeShape( + initial: ShapeRecord, + bounds: Box2, + pointer: Vec2, + handle: HandleKind + ): ShapeRecord | null { + if ( + initial.type !== 'rect' && + initial.type !== 'ellipse' && + initial.type !== 'text' && + initial.type !== 'markdown' + ) { + return null; + } + let minX = bounds.min.x; + let maxX = bounds.max.x; + let minY = bounds.min.y; + let maxY = bounds.max.y; + + const clampX = (value: number) => Math.min(Math.max(value, -1e6), 1e6); + const clampY = (value: number) => Math.min(Math.max(value, -1e6), 1e6); + + switch (handle) { + case 'nw': { + minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); + minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); + break; + } + case 'n': { + minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); + break; + } + case 'ne': { + maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); + minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); + break; + } + case 'e': { + maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); + break; + } + case 'se': { + maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); + maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); + break; + } + case 's': { + maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); + break; + } + case 'sw': { + minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); + maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); + break; + } + case 'w': { + minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); + break; + } + } + + const width = Math.max(maxX - minX, MIN_RESIZE_SIZE); + const height = Math.max(maxY - minY, MIN_RESIZE_SIZE); + + if (initial.type === 'text') { + return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width } }; + } + + if (initial.type === 'markdown') { + return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width, h: height } }; + } + + // @ts-expect-error union mismatch + return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width, h: height } }; + } + + private adjustArrowLabel(initial: ShapeRecord, pointer: Vec2): ShapeRecord | null { + if ( + initial.type !== 'arrow' || + !initial.props.points || + initial.props.points.length < 2 || + !initial.props.label + ) { + return null; + } + + const localPointer = this.worldToLocal(initial, pointer); + const points = initial.props.points; + const polylineLength = computePolylineLength(points); + + let closestDistance = 0; + let minDistToLine = Number.POSITIVE_INFINITY; + + for (let i = 0; i < points.length - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + const segmentLength = Vec2Ops.dist(a, b); + + const ab = Vec2Ops.sub(b, a); + const ap = Vec2Ops.sub(localPointer, a); + const t = Math.max(0, Math.min(1, Vec2Ops.dot(ap, ab) / Vec2Ops.dot(ab, ab))); + const projection = Vec2Ops.add(a, Vec2Ops.mulScalar(ab, t)); + const distToLine = Vec2Ops.dist(localPointer, projection); + + if (distToLine < minDistToLine) { + minDistToLine = distToLine; + let distanceToSegmentStart = 0; + for (let j = 0; j < i; j++) { + distanceToSegmentStart += Vec2Ops.dist(points[j], points[j + 1]); + } + closestDistance = distanceToSegmentStart + t * segmentLength; + } + } + + const align = initial.props.label.align ?? 'center'; + let newOffset: number; + + if (align === 'center') { + newOffset = closestDistance - polylineLength / 2; + } else if (align === 'start') { + newOffset = closestDistance; + } else { + newOffset = polylineLength - closestDistance; + } + + return { ...initial, props: { ...initial.props, label: { ...initial.props.label, offset: newOffset } } }; + } + + private resizeLineShape(initial: ShapeRecord, pointer: Vec2, handle: HandleKind): ShapeRecord | null { + if (initial.type !== 'line' && initial.type !== 'arrow') { + return null; + } + + if (initial.type === 'arrow' && typeof handle === 'string' && handle.startsWith('arrow-point-')) { + const pointIndex = Number.parseInt(handle.replace('arrow-point-', ''), 10); + if (!initial.props.points || pointIndex < 1 || pointIndex >= initial.props.points.length - 1) { + return null; + } + + const newPoints = initial.props.points.map((p, i) => { + if (i === pointIndex) { + return { x: pointer.x - initial.x, y: pointer.y - initial.y }; + } + return p; + }); + + const newProps = { ...initial.props, points: newPoints }; + return { ...initial, props: newProps }; + } + + if (handle !== 'line-start' && handle !== 'line-end') { + return null; + } + + let startPoint: Vec2, endPoint: Vec2; + + if (initial.type === 'line') { + startPoint = initial.props.a; + endPoint = initial.props.b; + } else { + if (!initial.props.points || initial.props.points.length < 2) { + return null; + } + startPoint = initial.props.points[0]; + endPoint = initial.props.points[initial.props.points.length - 1]; + } + + const startWorld = this.localToWorld(initial, startPoint); + const endWorld = this.localToWorld(initial, endPoint); + const newStart = handle === 'line-start' ? pointer : startWorld; + const newEnd = handle === 'line-end' ? pointer : endWorld; + + if (initial.type === 'line') { + const newProps = { + ...initial.props, + a: { x: 0, y: 0 }, + b: { x: newEnd.x - newStart.x, y: newEnd.y - newStart.y } + }; + return { ...initial, x: newStart.x, y: newStart.y, props: newProps }; + } else { + const newPoints = initial.props.points.map((p, i) => { + if (i === 0) { + return { x: 0, y: 0 }; + } else if (i === initial.props.points.length - 1) { + return { x: newEnd.x - newStart.x, y: newEnd.y - newStart.y }; + } else { + const worldPos = this.localToWorld(initial, p); + return { x: worldPos.x - newStart.x, y: worldPos.y - newStart.y }; + } + }); + + const newProps = { ...initial.props, points: newPoints }; + return { ...initial, x: newStart.x, y: newStart.y, props: newProps }; + } + } + + private rotateShape(initial: ShapeRecord, pointer: Vec2): ShapeRecord | null { + if (!this.toolState.rotationCenter || this.toolState.rotationStartAngle === null) { + return null; + } + if ( + initial.type !== 'rect' && + initial.type !== 'ellipse' && + initial.type !== 'text' && + initial.type !== 'markdown' + ) { + return null; + } + const currentAngle = Math.atan2( + pointer.y - this.toolState.rotationCenter.y, + pointer.x - this.toolState.rotationCenter.x + ); + const delta = currentAngle - this.toolState.rotationStartAngle; + return { ...initial, rot: initial.rot + delta }; + } + + private localToWorld(shape: ShapeRecord, point: Vec2): Vec2 { + if (shape.rot === 0) { + return { x: shape.x + point.x, y: shape.y + point.y }; + } + const cos = Math.cos(shape.rot); + const sin = Math.sin(shape.rot); + return { x: shape.x + point.x * cos - point.y * sin, y: shape.y + point.x * sin + point.y * cos }; + } + + private worldToLocal(shape: ShapeRecord, point: Vec2): Vec2 { + if (shape.rot === 0) { + return { x: point.x - shape.x, y: point.y - shape.y }; + } + const dx = point.x - shape.x; + const dy = point.y - shape.y; + const cos = Math.cos(-shape.rot); + const sin = Math.sin(-shape.rot); + return { x: dx * cos - dy * sin, y: dx * sin + dy * cos }; + } + + /** + * Remove an intermediate point from an arrow + */ + private removeArrowPoint(state: EditorState, arrowId: string, handle: HandleKind): EditorState { + const arrow = state.doc.shapes[arrowId]; + if (!arrow || arrow.type !== 'arrow' || !arrow.props.points) { + return state; + } + + const pointIndex = Number.parseInt((handle as string).replace('arrow-point-', ''), 10); + if (Number.isNaN(pointIndex) || pointIndex < 1 || pointIndex >= arrow.props.points.length - 1) { + return state; + } + + const newPoints = arrow.props.points.filter((_, i) => i !== pointIndex); + + if (newPoints.length < 2) { + return state; + } + + const updatedArrow = { ...arrow, props: { ...arrow.props, points: newPoints } }; + + this.resetToolState(); + + return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [arrowId]: updatedArrow } } }; + } + + /** + * Try to add a point to an arrow segment at the clicked location + * Returns updated state if successful, null otherwise + */ + private tryAddPointToArrowSegment(state: EditorState, arrow: ShapeRecord, clickWorld: Vec2): EditorState | null { + if (arrow.type !== 'arrow' || !arrow.props.points || arrow.props.points.length < 2) { + return null; + } + + const clickLocal = { x: clickWorld.x - arrow.x, y: clickWorld.y - arrow.y }; + const tolerance = 10; + + for (let i = 0; i < arrow.props.points.length - 1; i++) { + const a = arrow.props.points[i]; + const b = arrow.props.points[i + 1]; + + const ab = Vec2Ops.sub(b, a); + const ap = Vec2Ops.sub(clickLocal, a); + const abLengthSq = Vec2Ops.lenSq(ab); + + if (abLengthSq === 0) continue; + + const t = Math.max(0, Math.min(1, Vec2Ops.dot(ap, ab) / abLengthSq)); + const projection = Vec2Ops.add(a, Vec2Ops.mulScalar(ab, t)); + const distance = Vec2Ops.dist(clickLocal, projection); + + if (distance <= tolerance) { + const newPoints = [ + ...arrow.props.points.slice(0, i + 1), + clickLocal, + ...arrow.props.points.slice(i + 1) + ]; + + const updatedArrow = { ...arrow, props: { ...arrow.props, points: newPoints } }; + return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [arrow.id]: updatedArrow } } }; + } + } + + return null; + } + + /** + * Remove bindings for arrows that were moved with the select tool + * + * When an arrow is moved (not just its endpoints), its bindings should be removed + * to prevent the endpoints from snapping back to the old binding positions. + */ + private removeBindingsForMovedArrows(state: EditorState): EditorState { + const movedArrowIds = Array.from(this.toolState.initialShapePositions.keys()).filter((shapeId) => { + const shape = state.doc.shapes[shapeId]; + return shape && shape.type === 'arrow'; + }); + + if (movedArrowIds.length === 0) { + return state; + } + + const newBindings = { ...state.doc.bindings }; + const newShapes = { ...state.doc.shapes }; + let bindingsRemoved = false; + + for (const arrowId of movedArrowIds) { + const arrow = newShapes[arrowId]; + if (!arrow || arrow.type !== 'arrow') continue; + + for (const [bindingId, binding] of Object.entries(newBindings)) { + if (binding.fromShapeId === arrowId) { + delete newBindings[bindingId]; + bindingsRemoved = true; + + console.log('[Arrow Movement Fix] Removing binding', { + arrowId, + bindingId, + handle: binding.handle, + targetShapeId: binding.toShapeId + }); + } + } + + if (bindingsRemoved) { + newShapes[arrowId] = { + ...arrow, + props: { ...arrow.props, start: { kind: 'free' }, end: { kind: 'free' } } + }; + } + } + + if (!bindingsRemoved) { + return state; + } + + return { ...state, doc: { ...state.doc, shapes: newShapes, bindings: newBindings } }; + } + + /** + * Update arrow bindings when an endpoint is dragged + * + * Creates or updates bindings for arrow endpoints based on hit testing. + * If the endpoint is over a shape, creates/updates an edge anchor binding. + * If the endpoint is not over a shape, removes any existing binding. + */ + private updateArrowBindings(state: EditorState, arrowId: string, endpointWorld: Vec2): EditorState { + const arrow = state.doc.shapes[arrowId]; + if (!arrow || arrow.type !== 'arrow') return state; + + const handle = this.toolState.activeHandle === 'line-start' ? 'start' : 'end'; + + const stateWithoutArrow = { + ...state, + doc: { + ...state.doc, + shapes: Object.fromEntries(Object.entries(state.doc.shapes).filter(([id]) => id !== arrowId)) + } + }; + + const hitShapeId = hitTestPoint(stateWithoutArrow, endpointWorld); + + const newBindings = { ...state.doc.bindings }; + + for (const [bindingId, binding] of Object.entries(newBindings)) { + if (binding.fromShapeId === arrowId && binding.handle === handle) { + delete newBindings[bindingId]; + } + } + + if (hitShapeId) { + const targetShape = state.doc.shapes[hitShapeId]; + if (targetShape) { + const anchor = computeNormalizedAnchor(endpointWorld, targetShape); + const binding = BindingRecord.create(arrowId, hitShapeId, handle, { + kind: 'edge', + nx: anchor.nx, + ny: anchor.ny + }); + newBindings[binding.id] = binding; + } + } + + return { ...state, doc: { ...state.doc, bindings: newBindings } }; + } } diff --git a/packages/core/tests/layers.test.ts b/packages/core/tests/layers.test.ts new file mode 100644 index 0000000..383f986 --- /dev/null +++ b/packages/core/tests/layers.test.ts @@ -0,0 +1,87 @@ +import { + createLayer, + deleteLayer, + EditorState, + getInteractiveShapesOnCurrentPage, + moveLayer, + patchLayer, + ShapeRecord, + Store, + withDocumentLayers +} from '../src'; +import { describe, expect, it } from 'vitest'; + +function layeredState() { + const state = EditorState.create(); + state.doc.pages.page = { id: 'page', name: 'Page', shapeIds: ['back', 'front'] }; + state.doc.shapes.back = ShapeRecord.createRect( + 'page', + 0, + 0, + { w: 10, h: 10, fill: '#000', stroke: '#000', radius: 0 }, + 'back' + ); + state.doc.shapes.front = ShapeRecord.createRect( + 'page', + 20, + 0, + { w: 10, h: 10, fill: '#000', stroke: '#000', radius: 0 }, + 'front' + ); + state.ui.currentPageId = 'page'; + return new Store(state).getState(); +} + +describe('layers', () => { + it('backfills one stable default layer without changing order and is idempotent', () => { + const migrated = layeredState().doc; + const layerId = migrated.pages.page.layerIds?.[0]; + expect(layerId).toBe('layer:page:default'); + expect(migrated.layers?.[layerId!].shapeIds).toEqual(['back', 'front']); + expect(withDocumentLayers(migrated)).toEqual(migrated); + }); + + it('places new shapes on the active layer and excludes hidden or locked layers from interaction', () => { + const original = layeredState(); + const withSecond = createLayer(original, 'Foreground'); + const activeLayerId = withSecond.ui.activeLayerId!; + const shape = ShapeRecord.createRect( + 'page', + 40, + 0, + { w: 10, h: 10, fill: '#000', stroke: '#000', radius: 0 }, + 'new' + ); + const store = new Store({ + ...withSecond, + doc: { + ...withSecond.doc, + pages: { + ...withSecond.doc.pages, + page: { ...withSecond.doc.pages.page, shapeIds: [...withSecond.doc.pages.page.shapeIds, shape.id] } + }, + shapes: { ...withSecond.doc.shapes, [shape.id]: shape } + } + }); + expect(store.getState().doc.shapes.new.layerId).toBe(activeLayerId); + const hidden = patchLayer(store.getState(), activeLayerId, { visible: false }); + expect(getInteractiveShapesOnCurrentPage(hidden).map(({ id }) => id)).toEqual(['back', 'front']); + const locked = patchLayer(store.getState(), activeLayerId, { locked: true }); + expect(getInteractiveShapesOnCurrentPage(locked).map(({ id }) => id)).toEqual(['back', 'front']); + }); + + it('reorders layers and requires explicit handling before deleting non-empty content', () => { + const original = layeredState(); + const created = createLayer(original, 'Foreground'); + const foreground = created.ui.activeLayerId!; + expect(moveLayer(created, foreground, 'backward').doc.pages.page.layerIds?.[0]).toBe(foreground); + expect(deleteLayer(original, original.ui.activeLayerId!)).toBe(original); + expect(deleteLayer(created, original.ui.activeLayerId!)).toBe(created); + const deleted = deleteLayer(created, original.ui.activeLayerId!, { + kind: 'move', + destinationLayerId: foreground + }); + expect(deleted.doc.pages.page.layerIds).toEqual([foreground]); + expect(deleted.doc.layers?.[foreground].shapeIds).toEqual(['back', 'front']); + }); +}); diff --git a/packages/renderer/src/index.ts b/packages/renderer/src/index.ts index 0eec2b1..b1ba48d 100644 --- a/packages/renderer/src/index.ts +++ b/packages/renderer/src/index.ts @@ -18,6 +18,7 @@ import { computeOrthogonalPath, computePolylineLength, getPointAtDistance, + getLayersOnCurrentPage, getStrokeOutline, getShapesOnCurrentPage, resolveArrowEndpoints, @@ -225,9 +226,24 @@ function drawScene( const shapes = getShapesOnCurrentPage(state); const visibleBounds = getExpandedViewportBounds(state.camera, viewport); - for (const shape of shapes) { - if (!isShapeVisible(state, shape, visibleBounds)) continue; - drawShape(context, state, shape, theme, textLayoutCache, textMetricCache, markdownLayoutCache); + const layers = getLayersOnCurrentPage(state); + if (layers.length === 0) { + for (const shape of shapes) { + if (!isShapeVisible(state, shape, visibleBounds)) continue; + drawShape(context, state, shape, theme, textLayoutCache, textMetricCache, markdownLayoutCache); + } + } else { + for (const layer of layers) { + if (!layer.visible) continue; + context.save(); + context.globalAlpha *= layer.opacity; + for (const shapeId of layer.shapeIds) { + const shape = state.doc.shapes[shapeId]; + if (!shape || !isShapeVisible(state, shape, visibleBounds)) continue; + drawShape(context, state, shape, theme, textLayoutCache, textMetricCache, markdownLayoutCache); + } + context.restore(); + } } drawSelection(context, state, shapes, handleState); diff --git a/packages/renderer/tests/index.test.ts b/packages/renderer/tests/index.test.ts index d086d72..1893c14 100644 --- a/packages/renderer/tests/index.test.ts +++ b/packages/renderer/tests/index.test.ts @@ -104,6 +104,85 @@ describe('Renderer', () => { }); describe('rendering', () => { + it('renders visible layers in order with isolated opacity and skips hidden layers', () => { + const scheduledFrames: FrameRequestCallback[] = []; + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + scheduledFrames.push(callback); + return scheduledFrames.length; + }); + let alpha = 1; + const alphaWrites: number[] = []; + Object.defineProperty(context, 'globalAlpha', { + configurable: true, + get: () => alpha, + set: (value: number) => { + alpha = value; + alphaWrites.push(value); + } + }); + const page = PageRecord.create('Page', 'page'); + const visible = ShapeRecord.createRect( + 'page', + 0, + 0, + { w: 10, h: 10, fill: '#fff', stroke: '#000', radius: 0 }, + 'visible' + ); + const hidden = ShapeRecord.createRect( + 'page', + 20, + 0, + { w: 10, h: 10, fill: '#fff', stroke: '#000', radius: 0 }, + 'hidden' + ); + const store = new Store(); + store.setState((state) => ({ + ...state, + doc: { + pages: { + page: { + ...page, + shapeIds: [visible.id, hidden.id], + layerIds: ['visible-layer', 'hidden-layer'] + } + }, + layers: { + 'visible-layer': { + id: 'visible-layer', + pageId: 'page', + name: 'Visible', + shapeIds: [visible.id], + visible: true, + locked: false, + opacity: 0.4 + }, + 'hidden-layer': { + id: 'hidden-layer', + pageId: 'page', + name: 'Hidden', + shapeIds: [hidden.id], + visible: false, + locked: false, + opacity: 1 + } + }, + shapes: { + visible: { ...visible, layerId: 'visible-layer' }, + hidden: { ...hidden, layerId: 'hidden-layer' } + }, + bindings: {} + }, + ui: { ...state.ui, currentPageId: page.id } + })); + + const renderer = createRenderer(canvas, store); + scheduledFrames.shift()?.(0); + expect(alphaWrites).toContain(0.4); + expect(context.fill).toHaveBeenCalledTimes(1); + expect(context.save).toHaveBeenCalledTimes(vi.mocked(context.restore).mock.calls.length); + renderer.dispose(); + }); + it('keeps backing dimensions stable until CSS size or DPR changes', () => { const scheduledFrames: FrameRequestCallback[] = []; Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: 2 }); diff --git a/packages/ui/src/lib/editor/canvas/Canvas.svelte b/packages/ui/src/lib/editor/canvas/Canvas.svelte index a3e96d4..cfa9540 100644 --- a/packages/ui/src/lib/editor/canvas/Canvas.svelte +++ b/packages/ui/src/lib/editor/canvas/Canvas.svelte @@ -4,6 +4,7 @@ import Toolbar from '../components/Toolbar.svelte'; import FileBrowser from '../filebrowser/FileBrowser.svelte'; import StencilPalette from '../components/StencilPalette.svelte'; + import LayerPanel from '../components/LayerPanel.svelte'; import { createCanvasController } from './canvas-store.svelte'; import { draggingStencil, endDrag } from '../dnd.svelte'; import type { EditorPlatformAdapter } from '../platform'; @@ -131,6 +132,7 @@ bind:this={canvasEl} ondblclick={c.handleCanvasDoubleClick} onpointerleave={c.handlePointerLeave}> + {#if textEditorCurrent} {@const layout = c.textEditor.getLayout()} {#if layout} 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 eebda7c..5d772df 100644 --- a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts @@ -16,7 +16,7 @@ import { CursorStore, diffDoc, EllipseTool, - getShapesOnCurrentPage, + getInteractiveShapesOnCurrentPage, LineTool, MarkdownTool, PenTool, @@ -165,7 +165,12 @@ export function createCanvasController( 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 }, + doc: { + pages: doc.pages, + layers: doc.layers ?? doc.order.layers, + shapes: doc.shapes, + bindings: doc.bindings + }, ui: { ...state.ui, currentPageId: firstPageId, selectionIds: [] } })); } @@ -281,6 +286,16 @@ export function createCanvasController( runtime.handleAction(action); } + function commitLayerState(name: string, nextState: import('@inkfinite/core').EditorState) { + const state = store.getState(); + runtime.commit( + state, + nextState, + name, + Action.keyDown(name, name, { ctrl: false, shift: false, alt: false, meta: false }) + ); + } + function handleCanvasDoubleClick(event: MouseEvent) { if (!canvas) { return; @@ -289,7 +304,7 @@ export function createCanvasController( const screen = { x: event.clientX - rect.left, y: event.clientY - rect.top }; const world = Camera.screenToWorld(store.getState().camera, screen, getViewport()); - const shapes = getShapesOnCurrentPage(store.getState()); + const shapes = getInteractiveShapesOnCurrentPage(store.getState()); for (let index = shapes.length - 1; index >= 0; index--) { const shape = shapes[index]; if (shape.type === 'text') { @@ -472,6 +487,7 @@ export function createCanvasController( setCanvasRef, marqueeRect: () => marqueeRect, insertStencil, + commitLayerState, get stencilPaletteOpen() { return stencilPaletteOpen; }, diff --git a/packages/ui/src/lib/editor/components/LayerPanel.svelte b/packages/ui/src/lib/editor/components/LayerPanel.svelte new file mode 100644 index 0000000..fc52aa6 --- /dev/null +++ b/packages/ui/src/lib/editor/components/LayerPanel.svelte @@ -0,0 +1,245 @@ + + + + + diff --git a/packages/ui/src/lib/editor/components/LayerPanel.svelte.test.ts b/packages/ui/src/lib/editor/components/LayerPanel.svelte.test.ts new file mode 100644 index 0000000..34dbf95 --- /dev/null +++ b/packages/ui/src/lib/editor/components/LayerPanel.svelte.test.ts @@ -0,0 +1,51 @@ +import { EditorState, ShapeRecord, Store } from '@inkfinite/core'; +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; + +import LayerPanel from './LayerPanel.svelte'; + +function editorStore() { + const state = EditorState.create(); + state.doc.pages.page = { id: 'page', name: 'Page', shapeIds: ['shape'] }; + state.doc.shapes.shape = ShapeRecord.createRect( + 'page', + 0, + 0, + { w: 10, h: 10, fill: '#fff', stroke: '#000', radius: 0 }, + 'shape' + ); + state.ui.currentPageId = 'page'; + return new Store(state); +} + +describe('LayerPanel', () => { + it('provides accessible controls for the complete layer lifecycle', async () => { + const store = editorStore(); + const screen = render(LayerPanel, { + store, + onCommit: (_name, next) => store.setState(() => next) + }); + + await expect + .element(screen.getByRole('complementary', { name: 'Layers' })) + .toBeInTheDocument(); + await expect + .element(screen.getByRole('button', { name: 'Delete Default' })) + .toBeDisabled(); + await screen.getByRole('button', { name: 'Add layer' }).click(); + await expect.element(screen.getByRole('button', { name: 'Delete Default' })).toBeEnabled(); + await screen.getByRole('button', { name: 'Hide Layer' }).click(); + expect( + Object.values(store.getState().doc.layers ?? {}).find( + (layer) => layer.name === 'Layer' + )?.visible + ).toBe(false); + await screen.getByRole('button', { name: 'Show Layer' }).click(); + await screen.getByRole('button', { name: 'Lock Layer' }).click(); + expect( + Object.values(store.getState().doc.layers ?? {}).find( + (layer) => layer.name === 'Layer' + )?.locked + ).toBe(true); + }); +}); -- 2.51.2