diff --git a/CHANGELOG.md b/CHANGELOG.md index d381f8d..c688705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ recovery. - Tauri desktop document sessions owned by Rust: create, open, snapshot, commit, undo, redo, save, query, and validate. +- WASM (Rust-owned) editor projection and reconciliation bindings that handle composed + ancestor transforms, semantic editor patches, and minimal parent-relative native transactions. #### SVG Interop @@ -65,6 +67,9 @@ and device-pixel-ratio changes. - Document model collapsed to a single native model, removing the predecessor/current split. +- Desktop ordinary editor updates now use Rust reconciliation instead of + deleting and recreating the native scene; full mirror replacement remains + only for structural page and layer changes not yet covered by semantic patches. ### Fixed diff --git a/TODO.md b/TODO.md index 7614c23..def529a 100644 --- a/TODO.md +++ b/TODO.md @@ -70,12 +70,12 @@ Inkfinite now imports SVGs through one validated Rust pipeline across desktop, w ### Editor projection and reconciliation -- [ ] Move native-to-editor projection into Rust -- [ ] Compose ancestor transforms in editor projections -- [ ] Define semantic editor patches for durable changes -- [ ] Reconcile editor patches into minimal native transactions -- [ ] Stop rebuilding the native scene for ordinary edits -- [ ] Test native and WASM projection parity +- [x] Move native-to-editor projection into Rust +- [x] Compose ancestor transforms in editor projections +- [x] Define semantic editor patches for durable changes +- [x] Reconcile editor patches into minimal native transactions +- [x] Stop rebuilding the native scene for ordinary edits +- [x] Test native and WASM projection parity ### Browser document engine diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 86cff44..da4b730 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -29,6 +29,7 @@ pub fn run() { session::snapshot, session::update_context, session::commit, + session::reconcile_editor_patches, session::import_svg, session::propose, session::accept_proposal, diff --git a/apps/desktop/src-tauri/src/session.rs b/apps/desktop/src-tauri/src/session.rs index aece0f7..3c5f961 100644 --- a/apps/desktop/src-tauri/src/session.rs +++ b/apps/desktop/src-tauri/src/session.rs @@ -4,6 +4,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard}; +use inkfinite_core::editor::EditorPatch; use inkfinite_core::proto::{ AgentAccessMode, DocumentPath, Proposal, ProposalId, ProtocolError, Query, QueryResult, SessionId, TransactionDraft, }; @@ -187,6 +188,16 @@ pub fn commit( .map_err(to_protocol_error) } +/// Reconciles semantic editor changes through the native transaction engine. +#[tauri::command] +pub fn reconcile_editor_patches( + state: State<'_, DesktopState>, session_id: String, patches: Vec, +) -> Result { + lock_service(&state)? + .reconcile_editor_patches(&SessionId(session_id), patches) + .map_err(to_protocol_error) +} + /// Imports an SVG file into the active desktop layer through one transaction. #[tauri::command] pub async fn import_svg(state: State<'_, DesktopState>, session_id: String, path: String) -> Result { diff --git a/apps/desktop/src/lib/persistence/desktop-session.ts b/apps/desktop/src/lib/persistence/desktop-session.ts index 3aa2297..e30f053 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.ts @@ -33,6 +33,7 @@ import type { Transform, JsonValue } from '@inkfinite/bindings'; +import type { EditorPatch, EditorProjection, EditorTransform } from '@inkfinite/bindings/editor'; const ACTOR_ID = 'actor:desktop'; @@ -43,6 +44,8 @@ export type SessionStatus = { actor_id: string; agent_access: 'review' | 'direct'; snapshot: DocumentSnapshot; + /** Rust-owned projection used to materialize the editor mirror. */ + editor_projection?: EditorProjection; dirty: boolean; lock_held: boolean; recovery_available: boolean; @@ -132,6 +135,7 @@ export interface SessionApi { occluded_regions: Array<{ x: number; y: number; width: number; height: number }>; }): Promise; commit(args: { session_id: string; transaction: TransactionDraft }): Promise; + reconcileEditorPatches?(args: { session_id: string; patches: EditorPatch[] }): Promise; importSvg(args: { session_id: string; path: string; @@ -195,6 +199,11 @@ function createSessionApi(): SessionApi { }), commit: (args) => invokeSession('commit', { sessionId: args.session_id, transaction: args.transaction }), + reconcileEditorPatches: (args) => + invokeSession('reconcile_editor_patches', { + sessionId: args.session_id, + patches: args.patches + }), importSvg: (args) => invokeSession>>('import_svg', { sessionId: args.session_id, @@ -422,7 +431,9 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: createdAt: currentBoard?.createdAt ?? Date.now(), updatedAt: Date.now() }; - currentDoc = loadedDocFromSnapshot(status.snapshot); + currentDoc = status.editor_projection + ? loadedDocFromProjection(status.editor_projection) + : loadedDocFromSnapshot(status.snapshot); if (!isDraft) { boardFiles.set(currentBoard.id, currentFile); boardFiles.set(boardIdForPath(status.path), currentFile); @@ -433,7 +444,9 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: const previousPath = currentFile?.path; currentStatus = status; currentFile = { path: status.path, name: fileName(status.path) }; - currentDoc = loadedDocFromSnapshot(status.snapshot); + currentDoc = status.editor_projection + ? loadedDocFromProjection(status.editor_projection) + : loadedDocFromSnapshot(status.snapshot); if (currentBoard && !currentIsDraft) { currentBoard = { ...currentBoard, updatedAt: Date.now() }; boardFiles.set(currentBoard.id, currentFile); @@ -597,20 +610,30 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: 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 }); + const editorPatches = editorPatchesForDocuments(currentDoc, nextDoc); + if (editorPatches && editorPatches.length === 0) return; + + let committed: SessionCommit; + if (editorPatches && api.reconcileEditorPatches) { + committed = await api.reconcileEditorPatches({ + session_id: currentStatus.session_id, + patches: editorPatches + }); + } else { + 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() + }; + committed = await api.commit({ session_id: currentStatus.session_id, transaction }); + } updateStatus(committed.status); if (currentProposal) { notifyProposal({ @@ -951,6 +974,74 @@ async function listDocumentEntries(fileOps: DesktopFileOps, directory: string) { }); } +function loadedDocFromProjection(projection: EditorProjection): LoadedDoc { + const pages: Record = {}; + const layers: Record = {}; + const shapes: Record = {}; + const bindings: Record = {}; + + for (const pageId of projection.order.page_ids) { + const page = projection.pages[pageId]; + if (!page) continue; + pages[page.id] = { id: page.id, name: page.name, shapeIds: [...page.shape_ids], layerIds: [...page.layer_ids] }; + } + for (const layer of Object.values(projection.layers)) { + layers[layer.id] = { + id: layer.id, + pageId: layer.page_id, + name: layer.name, + shapeIds: [...layer.shape_ids], + visible: layer.visible, + locked: layer.locked, + opacity: layer.opacity + }; + } + for (const shape of Object.values(projection.shapes)) { + shapes[shape.id] = { + id: shape.id, + type: shape.type as EditorShapeRecord['type'], + pageId: shape.page_id, + x: shape.x, + y: shape.y, + rot: shape.rot, + editorTransform: shape.transform, + opacity: shape.opacity, + ...(shape.fill_opacity !== null ? { fillOpacity: shape.fill_opacity } : {}), + ...(shape.stroke_opacity !== null ? { strokeOpacity: shape.stroke_opacity } : {}), + ...(shape.group_id ? { groupId: shape.group_id } : {}), + layerId: shape.layer_id, + agentEditable: shape.agent_editable, + props: shape.props as EditorShapeRecord['props'] + } as EditorShapeRecord; + } + for (const binding of Object.values(projection.bindings)) { + bindings[binding.id] = { + id: binding.id, + type: binding.type as 'arrow-end', + fromShapeId: binding.from_shape_id, + toShapeId: binding.to_shape_id, + handle: binding.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: [...projection.order.page_ids], + shapeOrder: Object.fromEntries( + Object.entries(projection.order.shape_order).map(([pageId, shapeIds]) => [pageId, [...shapeIds]]) + ), + layers + } + }; +} + function loadedDocFromSnapshot(snapshot: DocumentSnapshot): LoadedDoc { const pages: Record = {}; const layers: Record = {}; @@ -1135,6 +1226,164 @@ function rebaseImportedDocument(snapshot: BoardExport, destination: LoadedDoc): }; } +function editorPatchesForDocuments(before: LoadedDoc, after: LoadedDoc): EditorPatch[] | null { + const beforePageIds = Object.keys(before.pages).sort(); + const afterPageIds = Object.keys(after.pages).sort(); + const beforeLayerIds = Object.keys(before.layers ?? {}).sort(); + const afterLayerIds = Object.keys(after.layers ?? {}).sort(); + // Page/layer creation and deletion still use the complete document adapter + // until their semantic operations are needed by the editor. + if (JSON.stringify(beforePageIds) !== JSON.stringify(afterPageIds)) return null; + if (JSON.stringify(beforeLayerIds) !== JSON.stringify(afterLayerIds)) return null; + + const patches: EditorPatch[] = []; + for (const pageId of after.order.pageIds) { + const previous = before.pages[pageId]; + const next = after.pages[pageId]; + if (previous && next && previous.name !== next.name) { + patches.push({ type: 'rename_page', page_id: pageId, name: next.name }); + } + } + for (const layerId of after.order.layers ? Object.keys(after.order.layers) : Object.keys(after.layers ?? {})) { + const previous = before.layers?.[layerId]; + const next = after.layers?.[layerId]; + if (!previous || !next) continue; + if ( + previous.name !== next.name || + previous.visible !== next.visible || + previous.locked !== next.locked || + previous.opacity !== next.opacity + ) { + patches.push({ + type: 'patch_layer', + layer_id: layerId, + patch: { + name: previous.name === next.name ? null : next.name, + visible: previous.visible === next.visible ? null : next.visible, + locked: previous.locked === next.locked ? null : next.locked, + opacity: previous.opacity === next.opacity ? null : next.opacity + } + }); + } + } + + for (const shapeId of Object.keys(before.shapes)) { + const previous = before.shapes[shapeId]; + const next = after.shapes[shapeId]; + if (!next) { + patches.push({ type: 'delete_shape', shape_id: shapeId }); + continue; + } + const transformChanged = previous.x !== next.x || previous.y !== next.y || previous.rot !== next.rot; + const parentChanged = previous.groupId !== next.groupId || previous.layerId !== next.layerId; + const orderChanged = + JSON.stringify(siblingAnchorForShape(before, previous)) !== + JSON.stringify(siblingAnchorForShape(after, next)); + const propertiesChanged = !jsonEqual(previous.props, next.props); + const styleChanged = + (previous.opacity ?? 1) !== (next.opacity ?? 1) || + (previous.fillOpacity ?? null) !== (next.fillOpacity ?? null) || + (previous.strokeOpacity ?? null) !== (next.strokeOpacity ?? null); + if (transformChanged || parentChanged || orderChanged || propertiesChanged || styleChanged) { + patches.push({ + type: 'shape', + shape_id: shapeId, + transform: transformChanged || parentChanged ? affineForEditorShape(next) : null, + properties: propertiesChanged ? (structuredClone(next.props) as ShapeProperties) : null, + metadata: null, + style: styleChanged + ? { + opacity: next.opacity ?? 1, + fill_opacity: next.fillOpacity ?? null, + stroke_opacity: next.strokeOpacity ?? null + } + : null, + parent: parentChanged ? editorParent(next) : null, + anchor: orderChanged ? siblingAnchorForShape(after, next) : null + }); + } + } + for (const shape of Object.values(after.shapes)) { + if (before.shapes[shape.id]) continue; + patches.push({ + type: 'create_shape', + shape: { + id: shape.id, + kind: shape.type, + properties: structuredClone(shape.props) as ShapeProperties, + metadata: null, + style: { + opacity: shape.opacity ?? 1, + fill_opacity: shape.fillOpacity ?? null, + stroke_opacity: shape.strokeOpacity ?? null + }, + layout: null + }, + parent: editorParent(shape), + transform: affineForEditorShape(shape), + anchor: siblingAnchorForShape(after, shape) + }); + } + for (const bindingId of Object.keys(before.bindings)) { + if (!after.bindings[bindingId]) patches.push({ type: 'delete_binding', binding_id: bindingId }); + } + for (const binding of Object.values(after.bindings)) { + if (before.bindings[binding.id]) continue; + patches.push({ + type: 'create_binding', + binding: { + 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: 1 + } + }); + } + return patches; +} + +function editorParent(shape: EditorShapeRecord): { kind: 'layer'; id: string } | { kind: 'shape'; id: string } { + return shape.groupId ? { kind: 'shape', id: shape.groupId } : { kind: 'layer', id: shape.layerId ?? '' }; +} + +function affineForEditorShape(shape: EditorShapeRecord): EditorTransform { + const projected = shape.editorTransform; + if (projected) { + const projectedRotation = Math.atan2(projected.b, projected.a); + if (Math.abs(projectedRotation - shape.rot) <= 1e-9) { + return { ...projected, e: shape.x, f: shape.y }; + } + const scaleX = Math.hypot(projected.a, projected.b); + const scaleY = scaleX > Number.EPSILON ? (projected.a * projected.d - projected.b * projected.c) / scaleX : 1; + const cos = Math.cos(shape.rot); + const sin = Math.sin(shape.rot); + return { a: cos * scaleX, b: sin * scaleX, c: -sin * scaleY, d: cos * scaleY, e: shape.x, f: shape.y }; + } + const cos = Math.cos(shape.rot); + const sin = Math.sin(shape.rot); + return { a: cos, b: sin, c: -sin, d: cos, e: shape.x, f: shape.y }; +} + +function siblingAnchorForShape( + doc: LoadedDoc, + shape: EditorShapeRecord +): { position: 'last' } | { position: 'before'; sibling_id: string } { + const layer = shape.layerId ? doc.layers?.[shape.layerId] : undefined; + const siblings = layer?.shapeIds.filter((id) => doc.shapes[id]?.groupId === shape.groupId) ?? []; + const nextId = siblings[siblings.indexOf(shape.id) + 1]; + return nextId ? { position: 'before', sibling_id: nextId } : { position: 'last' }; +} + +function jsonEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + function documentFromLoadedDoc(doc: LoadedDoc, current: DocumentSnapshot, actor: string): DocumentSnapshot { const pages = structuredClone(current.document.pages); const layers = structuredClone(current.document.layers); diff --git a/apps/web/.prettierignore b/apps/web/.prettierignore index 16349ea..7d74fe2 100644 --- a/apps/web/.prettierignore +++ b/apps/web/.prettierignore @@ -7,7 +7,3 @@ bun.lockb # Miscellaneous /static/ - -# TS covered by dprint -**/*.ts -**/*.js diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js index 477ed48..6b79e37 100644 --- a/apps/web/eslint.config.js +++ b/apps/web/eslint.config.js @@ -1,57 +1,55 @@ -import { includeIgnoreFile } from "@eslint/compat"; -import js from "@eslint/js"; -import prettier from "eslint-config-prettier"; -import svelte from "eslint-plugin-svelte"; -import { defineConfig } from "eslint/config"; -import globals from "globals"; -import { fileURLToPath } from "node:url"; -import { dirname } from "node:path"; -import ts from "typescript-eslint"; -import svelteConfig from "./svelte.config.js"; +import { includeIgnoreFile } from '@eslint/compat'; +import js from '@eslint/js'; +import prettier from 'eslint-config-prettier'; +import svelte from 'eslint-plugin-svelte'; +import { defineConfig } from 'eslint/config'; +import globals from 'globals'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import ts from 'typescript-eslint'; +import svelteConfig from './svelte.config.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url)); +const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); export default defineConfig( - includeIgnoreFile(gitignorePath), - js.configs.recommended, - ...ts.configs.recommended, - ...svelte.configs.recommended, - prettier, - ...svelte.configs.prettier, - { - languageOptions: { - globals: { ...globals.browser, ...globals.node }, - parserOptions: { - tsconfigRootDir: __dirname, - }, - }, + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs.recommended, + prettier, + ...svelte.configs.prettier, + { + languageOptions: { + globals: { ...globals.browser, ...globals.node }, + parserOptions: { tsconfigRootDir: __dirname } + }, - rules: { - // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. - // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors - "no-undef": "off", - // Allow unused vars that start with _ - "@typescript-eslint/no-unused-vars": [ - "error", - { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - caughtErrorsIgnorePattern: "^_", - }, - ], - }, - }, - { - files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"], - languageOptions: { - parserOptions: { - projectService: true, - extraFileExtensions: [".svelte"], - parser: ts.parser, - svelteConfig, - tsconfigRootDir: __dirname, - }, - }, - }, + rules: { + // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. + // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors + 'no-undef': 'off', + // Allow unused vars that start with _ + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_' + } + ] + } + }, + { + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: ['.svelte'], + parser: ts.parser, + svelteConfig, + tsconfigRootDir: __dirname + } + } + } ); diff --git a/apps/web/src/dexie-repository.test.ts b/apps/web/src/dexie-repository.test.ts index c23ef03..cdf99d5 100644 --- a/apps/web/src/dexie-repository.test.ts +++ b/apps/web/src/dexie-repository.test.ts @@ -1,247 +1,301 @@ -import "fake-indexeddb/auto"; -import { InkfiniteDB } from "$lib/persistence/database"; -import { createDexieDocRepo, createPersistenceSink } from "$lib/persistence/repository"; +import 'fake-indexeddb/auto'; +import { InkfiniteDB } from '$lib/persistence/database'; +import { createDexieDocRepo, createPersistenceSink } from '$lib/persistence/repository'; import { - CreateShapeCommand, - diffDoc, - Document as DocumentOps, - PageRecord, - SetSelectionCommand, - ShapeRecord, - Store, -} from "@inkfinite/core"; -import Dexie from "dexie"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + CreateShapeCommand, + diffDoc, + Document as DocumentOps, + PageRecord, + SetSelectionCommand, + ShapeRecord, + Store +} from '@inkfinite/core'; +import Dexie from 'dexie'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const openDbs: Dexie[] = []; function createTestDb(name = `inkfinite-test-${Math.random().toString(36).slice(2)}`) { - const database = new InkfiniteDB(name); - openDbs.push(database); - return database; + const database = new InkfiniteDB(name); + openDbs.push(database); + return database; } afterEach(async () => { - await Promise.all(openDbs.map(async (database) => { - try { - await database.delete(); - } catch { - /* No-op */ - } - })); - openDbs.length = 0; - vi.useRealTimers(); + await Promise.all( + openDbs.map(async (database) => { + try { + await database.delete(); + } catch { + /* No-op */ + } + }) + ); + openDbs.length = 0; + vi.useRealTimers(); }); -describe("DocRepo (Dexie)", () => { - it("createBoard seeds default page + order and rename persists", async () => { - const db = createTestDb(); - const repo = createDexieDocRepo(db); - const boardId = await repo.createBoard("Seeded"); - const loaded = await repo.loadDoc(boardId); - - expect(Object.keys(loaded.pages)).toHaveLength(1); - expect(loaded.order.pageIds).toHaveLength(1); - expect(loaded.order.shapeOrder?.[loaded.order.pageIds[0]!]).toEqual([]); - - await repo.renameBoard(boardId, "Renamed"); - const boardRow = await db.table("boards").get(boardId); - expect(boardRow?.name).toBe("Renamed"); - }); - - it("round-trips docs via applyDocPatch + loadDoc", async () => { - const database = createTestDb(); - const repo = createDexieDocRepo(database); - const boardId = await repo.createBoard("Round trip"); - - const page = PageRecord.create("Canvas"); - const rect = ShapeRecord.createRect(page.id, 0, 0, { w: 100, h: 80, fill: "#000", stroke: "#fff", radius: 8 }); - page.shapeIds.push(rect.id); - - const doc = DocumentOps.create(); - doc.pages[page.id] = page; - doc.shapes[rect.id] = rect; - - await repo.applyDocPatch(boardId, diffDoc(DocumentOps.create(), doc)); - - const loaded = await repo.loadDoc(boardId); - expect(loaded.pages[page.id]).toEqual(page); - expect(loaded.shapes[rect.id]).toEqual(rect); - expect(loaded.order.pageIds).toContain(page.id); - }); - - it("applyDocPatch performs a single Dexie transaction", async () => { - const database = createTestDb(); - const repo = createDexieDocRepo(database); - const boardId = await repo.createBoard("Tx board"); - - const page = PageRecord.create("Tx Page"); - const rect = ShapeRecord.createRect(page.id, 10, 10, { w: 50, h: 50, fill: "#ccc", stroke: "#111", radius: 4 }); - page.shapeIds.push(rect.id); - - const doc = DocumentOps.create(); - doc.pages[page.id] = page; - doc.shapes[rect.id] = rect; - - const transactionSpy = vi.spyOn(database, "transaction"); - await repo.applyDocPatch(boardId, diffDoc(DocumentOps.create(), doc)); - expect(transactionSpy).toHaveBeenCalledTimes(1); - }); - - it("deleteBoard removes rows across all tables", async () => { - const database = createTestDb(); - const repo = createDexieDocRepo(database); - const boardId = await repo.createBoard("Delete board"); - - const page = PageRecord.create("Delete Page"); - const rect = ShapeRecord.createRect(page.id, 0, 0, { w: 40, h: 40, fill: "#f00", stroke: "#000", radius: 2 }); - page.shapeIds.push(rect.id); - - const doc = DocumentOps.create(); - doc.pages[page.id] = page; - doc.shapes[rect.id] = rect; - - await repo.applyDocPatch(boardId, diffDoc(DocumentOps.create(), doc)); - await repo.deleteBoard(boardId); - - expect(await database.table("boards").toArray()).toHaveLength(0); - expect(await database.table("pages").toArray()).toHaveLength(0); - expect(await database.table("shapes").toArray()).toHaveLength(0); - expect(await database.table("bindings").toArray()).toHaveLength(0); - }); - - it("exportBoard + importBoard round-trip doc + metadata", async () => { - const db = createTestDb(); - const repo = createDexieDocRepo(db); - const boardId = await repo.createBoard("Source"); - - const page = PageRecord.create("Canvas"); - const rect = ShapeRecord.createRect(page.id, 5, 5, { w: 20, h: 10, fill: "#123", stroke: "#456", radius: 1 }); - page.shapeIds.push(rect.id); - - const doc = DocumentOps.create(); - doc.pages[page.id] = page; - doc.shapes[rect.id] = rect; - - await repo.applyDocPatch(boardId, diffDoc(DocumentOps.create(), doc)); - - const snapshot = await repo.exportBoard(boardId); - const importedId = await repo.importBoard({ - ...snapshot, - board: { ...snapshot.board, id: "board:imported", name: "Imported" }, - }); - - const imported = await repo.loadDoc(importedId); - expect(imported.pages).toEqual(snapshot.doc.pages); - expect(imported.shapes).toEqual(snapshot.doc.shapes); - }); +describe('DocRepo (Dexie)', () => { + it('createBoard seeds default page + order and rename persists', async () => { + const db = createTestDb(); + const repo = createDexieDocRepo(db); + const boardId = await repo.createBoard('Seeded'); + const loaded = await repo.loadDoc(boardId); + + expect(Object.keys(loaded.pages)).toHaveLength(1); + expect(loaded.order.pageIds).toHaveLength(1); + expect(loaded.order.shapeOrder?.[loaded.order.pageIds[0]!]).toEqual([]); + + await repo.renameBoard(boardId, 'Renamed'); + const boardRow = await db.table('boards').get(boardId); + expect(boardRow?.name).toBe('Renamed'); + }); + + it('round-trips docs via applyDocPatch + loadDoc', async () => { + const database = createTestDb(); + const repo = createDexieDocRepo(database); + const boardId = await repo.createBoard('Round trip'); + + const page = PageRecord.create('Canvas'); + const rect = ShapeRecord.createRect(page.id, 0, 0, { + w: 100, + h: 80, + fill: '#000', + stroke: '#fff', + radius: 8 + }); + page.shapeIds.push(rect.id); + + const doc = DocumentOps.create(); + doc.pages[page.id] = page; + doc.shapes[rect.id] = rect; + + await repo.applyDocPatch(boardId, diffDoc(DocumentOps.create(), doc)); + + const loaded = await repo.loadDoc(boardId); + expect(loaded.pages[page.id]).toEqual(page); + expect(loaded.shapes[rect.id]).toEqual(rect); + expect(loaded.order.pageIds).toContain(page.id); + }); + + it('applyDocPatch performs a single Dexie transaction', async () => { + const database = createTestDb(); + const repo = createDexieDocRepo(database); + const boardId = await repo.createBoard('Tx board'); + + const page = PageRecord.create('Tx Page'); + const rect = ShapeRecord.createRect(page.id, 10, 10, { + w: 50, + h: 50, + fill: '#ccc', + stroke: '#111', + radius: 4 + }); + page.shapeIds.push(rect.id); + + const doc = DocumentOps.create(); + doc.pages[page.id] = page; + doc.shapes[rect.id] = rect; + + const transactionSpy = vi.spyOn(database, 'transaction'); + await repo.applyDocPatch(boardId, diffDoc(DocumentOps.create(), doc)); + expect(transactionSpy).toHaveBeenCalledTimes(1); + }); + + it('deleteBoard removes rows across all tables', async () => { + const database = createTestDb(); + const repo = createDexieDocRepo(database); + const boardId = await repo.createBoard('Delete board'); + + const page = PageRecord.create('Delete Page'); + const rect = ShapeRecord.createRect(page.id, 0, 0, { + w: 40, + h: 40, + fill: '#f00', + stroke: '#000', + radius: 2 + }); + page.shapeIds.push(rect.id); + + const doc = DocumentOps.create(); + doc.pages[page.id] = page; + doc.shapes[rect.id] = rect; + + await repo.applyDocPatch(boardId, diffDoc(DocumentOps.create(), doc)); + await repo.deleteBoard(boardId); + + expect(await database.table('boards').toArray()).toHaveLength(0); + expect(await database.table('pages').toArray()).toHaveLength(0); + expect(await database.table('shapes').toArray()).toHaveLength(0); + expect(await database.table('bindings').toArray()).toHaveLength(0); + }); + + it('exportBoard + importBoard round-trip doc + metadata', async () => { + const db = createTestDb(); + const repo = createDexieDocRepo(db); + const boardId = await repo.createBoard('Source'); + + const page = PageRecord.create('Canvas'); + const rect = ShapeRecord.createRect(page.id, 5, 5, { + w: 20, + h: 10, + fill: '#123', + stroke: '#456', + radius: 1 + }); + page.shapeIds.push(rect.id); + + const doc = DocumentOps.create(); + doc.pages[page.id] = page; + doc.shapes[rect.id] = rect; + + await repo.applyDocPatch(boardId, diffDoc(DocumentOps.create(), doc)); + + const snapshot = await repo.exportBoard(boardId); + const importedId = await repo.importBoard({ + ...snapshot, + board: { ...snapshot.board, id: 'board:imported', name: 'Imported' } + }); + + const imported = await repo.loadDoc(importedId); + expect(imported.pages).toEqual(snapshot.doc.pages); + expect(imported.shapes).toEqual(snapshot.doc.shapes); + }); }); -describe("History persistence sink", () => { - let repo: ReturnType; - let boardId: string; - - beforeEach(async () => { - const database = createTestDb(); - repo = createDexieDocRepo(database); - boardId = await repo.createBoard("Persisted"); - }); - - it("doc command triggers exactly one persistence flush", async () => { - const { store, sink, applySpy, pageId } = await createStoreWithSink(repo, boardId); - - const rect = ShapeRecord.createRect(pageId, 0, 0, { w: 10, h: 10, fill: "#222", stroke: "#fff", radius: 0 }); - - store.executeCommand(new CreateShapeCommand(rect, pageId)); - expect(applySpy).toHaveBeenCalledTimes(0); - - await sink.flush(); - - expect(applySpy).toHaveBeenCalledTimes(1); - const loaded = await repo.loadDoc(boardId); - expect(loaded.shapes[rect.id]).toBeDefined(); - }); - - it("undo and redo both persist document changes", async () => { - const { store, sink, applySpy, pageId } = await createStoreWithSink(repo, boardId); - - const rect = ShapeRecord.createRect(pageId, 0, 0, { w: 25, h: 25, fill: "#0f0", stroke: "#090", radius: 0 }); - - store.executeCommand(new CreateShapeCommand(rect, pageId)); - await sink.flush(); - applySpy.mockClear(); - - store.undo(); - await sink.flush(); - expect(applySpy).toHaveBeenCalledTimes(1); - let loaded = await repo.loadDoc(boardId); - expect(loaded.shapes[rect.id]).toBeUndefined(); - - applySpy.mockClear(); - store.redo(); - await sink.flush(); - expect(applySpy).toHaveBeenCalledTimes(1); - loaded = await repo.loadDoc(boardId); - expect(loaded.shapes[rect.id]).toBeDefined(); - }); - - it("ui-only commands never hit persistence", async () => { - const { store, sink, applySpy, pageId } = await createStoreWithSink(repo, boardId); - - const rect = ShapeRecord.createRect(pageId, 0, 0, { w: 15, h: 15, fill: "#aaa", stroke: "#bbb", radius: 0 }); - - store.executeCommand(new CreateShapeCommand(rect, pageId)); - await sink.flush(); - applySpy.mockClear(); - - store.executeCommand(new SetSelectionCommand([], [rect.id])); - await sink.flush(); - - expect(applySpy).toHaveBeenCalledTimes(0); - }); - - it("batches rapid doc commands into one flush", async () => { - const { store, sink, applySpy, pageId } = await createStoreWithSink(repo, boardId); - - for (let i = 0; i < 10; i++) { - const rect = ShapeRecord.createRect(pageId, i * 5, 0, { w: 5, h: 5, fill: "#444", stroke: "#111", radius: 0 }); - store.executeCommand(new CreateShapeCommand(rect, pageId)); - } - - await sink.flush(); - - expect(applySpy).toHaveBeenCalledTimes(1); - const loaded = await repo.loadDoc(boardId); - expect(Object.keys(loaded.shapes).length).toBeGreaterThanOrEqual(10); - }); +describe('History persistence sink', () => { + let repo: ReturnType; + let boardId: string; + + beforeEach(async () => { + const database = createTestDb(); + repo = createDexieDocRepo(database); + boardId = await repo.createBoard('Persisted'); + }); + + it('doc command triggers exactly one persistence flush', async () => { + const { store, sink, applySpy, pageId } = await createStoreWithSink(repo, boardId); + + const rect = ShapeRecord.createRect(pageId, 0, 0, { + w: 10, + h: 10, + fill: '#222', + stroke: '#fff', + radius: 0 + }); + + store.executeCommand(new CreateShapeCommand(rect, pageId)); + expect(applySpy).toHaveBeenCalledTimes(0); + + await sink.flush(); + + expect(applySpy).toHaveBeenCalledTimes(1); + const loaded = await repo.loadDoc(boardId); + expect(loaded.shapes[rect.id]).toBeDefined(); + }); + + it('undo and redo both persist document changes', async () => { + const { store, sink, applySpy, pageId } = await createStoreWithSink(repo, boardId); + + const rect = ShapeRecord.createRect(pageId, 0, 0, { + w: 25, + h: 25, + fill: '#0f0', + stroke: '#090', + radius: 0 + }); + + store.executeCommand(new CreateShapeCommand(rect, pageId)); + await sink.flush(); + applySpy.mockClear(); + + store.undo(); + await sink.flush(); + expect(applySpy).toHaveBeenCalledTimes(1); + let loaded = await repo.loadDoc(boardId); + expect(loaded.shapes[rect.id]).toBeUndefined(); + + applySpy.mockClear(); + store.redo(); + await sink.flush(); + expect(applySpy).toHaveBeenCalledTimes(1); + loaded = await repo.loadDoc(boardId); + expect(loaded.shapes[rect.id]).toBeDefined(); + }); + + it('ui-only commands never hit persistence', async () => { + const { store, sink, applySpy, pageId } = await createStoreWithSink(repo, boardId); + + const rect = ShapeRecord.createRect(pageId, 0, 0, { + w: 15, + h: 15, + fill: '#aaa', + stroke: '#bbb', + radius: 0 + }); + + store.executeCommand(new CreateShapeCommand(rect, pageId)); + await sink.flush(); + applySpy.mockClear(); + + store.executeCommand(new SetSelectionCommand([], [rect.id])); + await sink.flush(); + + expect(applySpy).toHaveBeenCalledTimes(0); + }); + + it('batches rapid doc commands into one flush', async () => { + const { store, sink, applySpy, pageId } = await createStoreWithSink(repo, boardId); + + for (let i = 0; i < 10; i++) { + const rect = ShapeRecord.createRect(pageId, i * 5, 0, { + w: 5, + h: 5, + fill: '#444', + stroke: '#111', + radius: 0 + }); + store.executeCommand(new CreateShapeCommand(rect, pageId)); + } + + await sink.flush(); + + expect(applySpy).toHaveBeenCalledTimes(1); + const loaded = await repo.loadDoc(boardId); + expect(Object.keys(loaded.shapes).length).toBeGreaterThanOrEqual(10); + }); }); async function createStoreWithSink(repo: ReturnType, boardId: string) { - const sink = createPersistenceSink(repo, { debounceMs: 10 }); - const applySpy = vi.spyOn(repo, "applyDocPatch"); - const store = new Store(undefined, { - onHistoryEvent: (event) => { - if (event.kind !== "doc") { - return; - } - const patch = diffDoc(event.beforeState.doc, event.afterState.doc); - sink.enqueueDocPatch(boardId, patch); - }, - }); - - const pageId = await hydrateStoreFromRepo(store, repo, boardId); - return { store, sink, applySpy, pageId }; + const sink = createPersistenceSink(repo, { debounceMs: 10 }); + const applySpy = vi.spyOn(repo, 'applyDocPatch'); + const store = new Store(undefined, { + onHistoryEvent: (event) => { + if (event.kind !== 'doc') { + return; + } + const patch = diffDoc(event.beforeState.doc, event.afterState.doc); + sink.enqueueDocPatch(boardId, patch); + } + }); + + const pageId = await hydrateStoreFromRepo(store, repo, boardId); + return { store, sink, applySpy, pageId }; } -async function hydrateStoreFromRepo(store: Store, repo: ReturnType, boardId: string) { - const loaded = await repo.loadDoc(boardId); - const firstPageId = loaded.order.pageIds[0] ?? Object.keys(loaded.pages)[0]; - - store.setState((state) => ({ - ...state, - doc: { pages: loaded.pages, shapes: loaded.shapes, bindings: loaded.bindings }, - ui: { ...state.ui, currentPageId: firstPageId ?? null }, - })); - - return firstPageId!; +async function hydrateStoreFromRepo( + store: Store, + repo: ReturnType, + boardId: string +) { + const loaded = await repo.loadDoc(boardId); + const firstPageId = loaded.order.pageIds[0] ?? Object.keys(loaded.pages)[0]; + + store.setState((state) => ({ + ...state, + doc: { pages: loaded.pages, shapes: loaded.shapes, bindings: loaded.bindings }, + ui: { ...state.ui, currentPageId: firstPageId ?? null } + })); + + return firstPageId!; } diff --git a/apps/web/src/lib/persistence/database.ts b/apps/web/src/lib/persistence/database.ts index cebf6b8..8841d8e 100644 --- a/apps/web/src/lib/persistence/database.ts +++ b/apps/web/src/lib/persistence/database.ts @@ -1,29 +1,29 @@ -import type { BoardMeta } from "@inkfinite/core"; -import Dexie from "dexie"; -import type { BindingRow, MetaRow, PageRow, ShapeRow } from "./repository"; +import type { BoardMeta } from '@inkfinite/core'; +import Dexie from 'dexie'; +import type { BindingRow, MetaRow, PageRow, ShapeRow } from './repository'; /** Default IndexedDB database name for the static web application. */ -export const DB_NAME = "inkfinite"; +export const DB_NAME = 'inkfinite'; /** * Dexie database owned by the static web application's persistence adapter. */ export class InkfiniteDB extends Dexie { - boards!: Dexie.Table; - pages!: Dexie.Table; - shapes!: Dexie.Table; - bindings!: Dexie.Table; - meta!: Dexie.Table; + boards!: Dexie.Table; + pages!: Dexie.Table; + shapes!: Dexie.Table; + bindings!: Dexie.Table; + meta!: Dexie.Table; - constructor(name = DB_NAME) { - super(name); + constructor(name = DB_NAME) { + super(name); - this.version(2).stores({ - boards: "id, name, createdAt, updatedAt", - pages: "[boardId+id], boardId, updatedAt", - shapes: "[boardId+id], boardId, type, updatedAt", - bindings: "[boardId+id], boardId, type, updatedAt", - meta: "key", - }); - } + this.version(2).stores({ + boards: 'id, name, createdAt, updatedAt', + pages: '[boardId+id], boardId, updatedAt', + shapes: '[boardId+id], boardId, type, updatedAt', + bindings: '[boardId+id], boardId, type, updatedAt', + meta: 'key' + }); + } } diff --git a/apps/web/src/lib/persistence/svg-import.test.ts b/apps/web/src/lib/persistence/svg-import.test.ts index 13d85b2..2219ae8 100644 --- a/apps/web/src/lib/persistence/svg-import.test.ts +++ b/apps/web/src/lib/persistence/svg-import.test.ts @@ -6,6 +6,8 @@ type Listener = (event: { data: unknown; message?: string }) => void; type FakeMessage = | { type: 'import'; id: number; source: ArrayBuffer } + | { type: 'project'; id: number; snapshot: unknown } + | { type: 'reconcile'; id: number; snapshot: unknown; request: unknown } | { type: 'render'; id: number; snapshot: unknown; options: unknown }; class FakeWorker { @@ -42,14 +44,34 @@ class FakeWorker { omitted_image_count: 0 } } - : { - id: message.id, - response: { - status: 'success', - svg: '', - warnings: [] + : message.type === 'render' + ? { + id: message.id, + response: { + status: 'success', + svg: '', + warnings: [] + } } - } + : message.type === 'project' + ? { + id: message.id, + response: { + pages: {}, + layers: {}, + shapes: {}, + bindings: {}, + order: { + page_ids: [], + shape_order: {}, + layers: {} + } + } + } + : { + id: message.id, + response: { id: 'transaction:one', operations: [] } + } }) ) ); @@ -82,6 +104,20 @@ describe('SVG import worker client', () => { expect(worker.terminated).toBe(true); }); + it('routes projection and reconciliation requests through the same worker', async () => { + const worker = new FakeWorker(); + const client = new SvgImportWorkerClient(worker as unknown as Worker); + + const projection = await client.project({} as DocumentSnapshot); + expect(worker.lastMessage).toMatchObject({ type: 'project' }); + expect(projection.order.page_ids).toEqual([]); + + const transaction = await client.reconcile({} as DocumentSnapshot, {} as never); + expect(worker.lastMessage).toMatchObject({ type: 'reconcile' }); + expect(transaction.id).toBe('transaction:one'); + client.dispose(); + }); + it('routes canonical render requests through the same worker', async () => { const worker = new FakeWorker(); const client = new SvgImportWorkerClient(worker as unknown as Worker); diff --git a/apps/web/src/lib/persistence/svg-import.ts b/apps/web/src/lib/persistence/svg-import.ts index a58904b..af323c5 100644 --- a/apps/web/src/lib/persistence/svg-import.ts +++ b/apps/web/src/lib/persistence/svg-import.ts @@ -1,9 +1,17 @@ -import { importSvg as importSvgWasm, renderSvg as renderSvgWasm } from '@inkfinite/wasm'; +import { + importSvg as importSvgWasm, + projectEditor as projectEditorWasm, + reconcileEditorPatches as reconcileEditorPatchesWasm, + renderSvg as renderSvgWasm +} from '@inkfinite/wasm'; import type { DocumentSnapshot, + EditorProjection, + EditorReconciliationRequest, SvgImportResponse, SvgRenderOptions, - SvgRenderResponse + SvgRenderResponse, + TransactionDraft } from '@inkfinite/wasm'; import type { SvgImportResult } from '@inkfinite/core'; @@ -20,6 +28,8 @@ export class SvgImportWorkerError extends Error { type WorkerRequestBody = | { type: 'import'; source: ArrayBuffer } + | { type: 'project'; snapshot: DocumentSnapshot } + | { type: 'reconcile'; snapshot: DocumentSnapshot; request: EditorReconciliationRequest } | { type: 'render'; snapshot: DocumentSnapshot; options: SvgRenderOptions }; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -29,7 +39,11 @@ type WorkerResponse = | { id: number; response: SvgImportResponse | SvgRenderResponse } | { id: number; error: string }; -type WorkerResponseValue = SvgImportResponse | SvgRenderResponse; +type WorkerResponseValue = + | SvgImportResponse + | SvgRenderResponse + | EditorProjection + | TransactionDraft; /** A worker boundary that keeps SVG decoding, parsing, and rendering off the UI thread. */ export class SvgImportWorkerClient { @@ -58,6 +72,19 @@ export class SvgImportWorkerClient { return { ...response.import, omitted_image_count: response.omitted_image_count }; } + /** Projects one canonical snapshot into the shared flat editor view. */ + project(snapshot: DocumentSnapshot): Promise { + return this.request({ type: 'project', snapshot }); + } + + /** Reconciles semantic editor changes into one native transaction draft. */ + reconcile( + snapshot: DocumentSnapshot, + request: EditorReconciliationRequest + ): Promise { + return this.request({ type: 'reconcile', snapshot, request }); + } + /** Renders one canonical snapshot through the Rust SVG renderer. */ render( snapshot: DocumentSnapshot, @@ -132,6 +159,19 @@ export function importSvgInWorker(source: Uint8Array) { return getSharedSvgImportWorker().import(source); } +/** Projects a canonical snapshot through the shared worker. */ +export function projectEditorInWorker(snapshot: DocumentSnapshot) { + return getSharedSvgImportWorker().project(snapshot); +} + +/** Reconciles semantic editor patches through the shared worker. */ +export function reconcileEditorPatchesInWorker( + snapshot: DocumentSnapshot, + request: EditorReconciliationRequest +) { + return getSharedSvgImportWorker().reconcile(snapshot, request); +} + /** Renders a canonical snapshot through the shared worker. */ export function renderSvgInWorker(snapshot: DocumentSnapshot, options: SvgRenderOptions = {}) { return getSharedSvgImportWorker().render(snapshot, options); @@ -142,6 +182,19 @@ export async function importSvgInWorkerRuntime(source: Uint8Array) { return importSvgWasm(source); } +/** Used by the worker entry point to run the generated Rust/WASM bindings. */ +export async function projectEditorInWorkerRuntime(snapshot: DocumentSnapshot) { + return projectEditorWasm(snapshot); +} + +/** Used by the worker entry point to run the generated Rust/WASM bindings. */ +export async function reconcileEditorPatchesInWorkerRuntime( + snapshot: DocumentSnapshot, + request: EditorReconciliationRequest +) { + return reconcileEditorPatchesWasm(snapshot, request); +} + /** Used by the worker entry point to run the generated Rust/WASM bindings. */ export async function renderSvgInWorkerRuntime( snapshot: DocumentSnapshot, diff --git a/apps/web/src/lib/persistence/svg-import.worker.ts b/apps/web/src/lib/persistence/svg-import.worker.ts index d7f8560..52a4c99 100644 --- a/apps/web/src/lib/persistence/svg-import.worker.ts +++ b/apps/web/src/lib/persistence/svg-import.worker.ts @@ -1,25 +1,53 @@ -import { importSvgInWorkerRuntime, renderSvgInWorkerRuntime } from './svg-import'; +/// + +import type { + DocumentSnapshot, + EditorReconciliationRequest, + SvgRenderOptions +} from '@inkfinite/wasm'; +import { + importSvgInWorkerRuntime, + projectEditorInWorkerRuntime, + reconcileEditorPatchesInWorkerRuntime, + renderSvgInWorkerRuntime +} from './svg-import'; type Request = | { type: 'import'; id: number; source: ArrayBuffer } + | { type: 'project'; id: number; snapshot: DocumentSnapshot } | { - type: 'render'; + type: 'reconcile'; id: number; - snapshot: import('@inkfinite/wasm').DocumentSnapshot; - options: import('@inkfinite/wasm').SvgRenderOptions; - }; -type WorkerScope = { - onmessage: ((event: MessageEvent) => void) | null; - postMessage(message: unknown): void; -}; + snapshot: DocumentSnapshot; + request: EditorReconciliationRequest; + } + | { type: 'render'; id: number; snapshot: DocumentSnapshot; options: SvgRenderOptions }; -const scope = globalThis as unknown as WorkerScope; -scope.onmessage = async (event) => { +const scope = globalThis as unknown as DedicatedWorkerGlobalScope; +scope.onmessage = async (event: MessageEvent) => { try { - const response = - event.data.type === 'import' - ? await importSvgInWorkerRuntime(new Uint8Array(event.data.source)) - : await renderSvgInWorkerRuntime(event.data.snapshot, event.data.options); + let response: unknown; + switch (event.data.type) { + case 'import': { + response = await importSvgInWorkerRuntime(new Uint8Array(event.data.source)); + break; + } + case 'project': { + response = await projectEditorInWorkerRuntime(event.data.snapshot); + break; + } + case 'reconcile': { + response = await reconcileEditorPatchesInWorkerRuntime( + event.data.snapshot, + event.data.request + ); + break; + } + case 'render': { + response = await renderSvgInWorkerRuntime(event.data.snapshot, event.data.options); + break; + } + } scope.postMessage({ id: event.data.id, response }); } catch (error) { scope.postMessage({ diff --git a/apps/web/src/lib/tests/StatusBar.accessibility.test.ts b/apps/web/src/lib/tests/StatusBar.accessibility.test.ts index ec2430f..543f747 100644 --- a/apps/web/src/lib/tests/StatusBar.accessibility.test.ts +++ b/apps/web/src/lib/tests/StatusBar.accessibility.test.ts @@ -1,109 +1,120 @@ -import { CursorStore, Store } from "@inkfinite/core"; -import { beforeEach, describe, expect, it } from "vitest"; -import { cleanup, render } from "vitest-browser-svelte"; -import StatusBar from "$editor/components/StatusBar.svelte"; +import { CursorStore, Store } from '@inkfinite/core'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import StatusBar from '$editor/components/StatusBar.svelte'; const createMockStatusStore = () => ({ - get: () => ({ backend: "indexeddb" as const, state: "saved" as const, pendingWrites: 0 }), - subscribe: () => () => {}, - update: () => {}, + get: () => ({ backend: 'indexeddb' as const, state: 'saved' as const, pendingWrites: 0 }), + subscribe: () => () => {}, + update: () => {} }); const createMockSnapStore = () => ({ - get: () => ({ snapEnabled: false, gridEnabled: true, gridSize: 25 }), - subscribe: () => () => {}, - update: () => {}, - set: () => {}, + get: () => ({ snapEnabled: false, gridEnabled: true, gridSize: 25 }), + subscribe: () => () => {}, + update: () => {}, + set: () => {} }); -describe("StatusBar accessibility", () => { - beforeEach(() => { - cleanup(); - }); - - it("should have ARIA labels on snap checkboxes", () => { - const store = new Store(); - const cursor = new CursorStore(); - const persistence = createMockStatusStore(); - const snap = createMockSnapStore(); - - const { container } = render(StatusBar, { store, cursor, persistence, snap }); - - const checkboxes = container.querySelectorAll(".status-bar__toggle input[type=\"checkbox\"]"); - expect(checkboxes.length).toBe(2); - - const mainSnapCheckbox = checkboxes[0]; - expect(mainSnapCheckbox.getAttribute("aria-label")).toBe("Enable main snapping"); - - const gridSnapCheckbox = checkboxes[1]; - expect(gridSnapCheckbox.getAttribute("aria-label")).toBe("Enable grid snapping"); - }); - - it("should have proper checkbox states", () => { - const store = new Store(); - const cursor = new CursorStore(); - const persistence = createMockStatusStore(); - const snap = { - get: () => ({ snapEnabled: true, gridEnabled: false, gridSize: 25 }), - subscribe: () => () => {}, - update: () => {}, - set: () => {}, - }; - - const { container } = render(StatusBar, { store, cursor, persistence, snap }); - - const checkboxes = container.querySelectorAll(".status-bar__toggle input[type=\"checkbox\"]") as NodeListOf< - HTMLInputElement - >; - - expect(checkboxes[0].checked).toBe(true); - expect(checkboxes[1].checked).toBe(false); - }); - - it("should have visible focus states on checkboxes", () => { - const store = new Store(); - const cursor = new CursorStore(); - const persistence = createMockStatusStore(); - const snap = createMockSnapStore(); - - const { container } = render(StatusBar, { store, cursor, persistence, snap }); - - const checkbox = container.querySelector(".status-bar__toggle input[type=\"checkbox\"]") as HTMLInputElement; - checkbox.focus(); - - expect(document.activeElement).toBe(checkbox); - }); - - it("should display error state with proper styling", () => { - const store = new Store(); - const cursor = new CursorStore(); - const persistence = { - get: () => ({ backend: "indexeddb" as const, state: "error" as const, pendingWrites: 0, errorMsg: "Test error" }), - subscribe: () => () => {}, - update: () => {}, - }; - - const snap = createMockSnapStore(); - const { container } = render(StatusBar, { store, cursor, persistence, snap }); - const persistenceValue = container.querySelector(".status-bar__section--persistence .status-bar__value"); - expect(persistenceValue).toBeTruthy(); - expect(persistenceValue?.textContent).toContain("Error"); - expect(persistenceValue?.classList.contains("status-bar__value--error")).toBe(true); - }); - - it("should use semantic HTML structure", () => { - const store = new Store(); - const cursor = new CursorStore(); - const persistence = createMockStatusStore(); - const snap = createMockSnapStore(); - - const { container } = render(StatusBar, { store, cursor, persistence, snap }); - const labels = container.querySelectorAll(".status-bar__toggle"); - expect(labels.length).toBe(2); - - labels.forEach((label) => { - const input = label.querySelector("input[type=\"checkbox\"]"); - expect(input).toBeTruthy(); - }); - }); +describe('StatusBar accessibility', () => { + beforeEach(() => { + cleanup(); + }); + + it('should have ARIA labels on snap checkboxes', () => { + const store = new Store(); + const cursor = new CursorStore(); + const persistence = createMockStatusStore(); + const snap = createMockSnapStore(); + + const { container } = render(StatusBar, { store, cursor, persistence, snap }); + + const checkboxes = container.querySelectorAll( + '.status-bar__toggle input[type="checkbox"]' + ); + expect(checkboxes.length).toBe(2); + + const mainSnapCheckbox = checkboxes[0]; + expect(mainSnapCheckbox.getAttribute('aria-label')).toBe('Enable main snapping'); + + const gridSnapCheckbox = checkboxes[1]; + expect(gridSnapCheckbox.getAttribute('aria-label')).toBe('Enable grid snapping'); + }); + + it('should have proper checkbox states', () => { + const store = new Store(); + const cursor = new CursorStore(); + const persistence = createMockStatusStore(); + const snap = { + get: () => ({ snapEnabled: true, gridEnabled: false, gridSize: 25 }), + subscribe: () => () => {}, + update: () => {}, + set: () => {} + }; + + const { container } = render(StatusBar, { store, cursor, persistence, snap }); + + const checkboxes = container.querySelectorAll( + '.status-bar__toggle input[type="checkbox"]' + ) as NodeListOf; + + expect(checkboxes[0].checked).toBe(true); + expect(checkboxes[1].checked).toBe(false); + }); + + it('should have visible focus states on checkboxes', () => { + const store = new Store(); + const cursor = new CursorStore(); + const persistence = createMockStatusStore(); + const snap = createMockSnapStore(); + + const { container } = render(StatusBar, { store, cursor, persistence, snap }); + + const checkbox = container.querySelector( + '.status-bar__toggle input[type="checkbox"]' + ) as HTMLInputElement; + checkbox.focus(); + + expect(document.activeElement).toBe(checkbox); + }); + + it('should display error state with proper styling', () => { + const store = new Store(); + const cursor = new CursorStore(); + const persistence = { + get: () => ({ + backend: 'indexeddb' as const, + state: 'error' as const, + pendingWrites: 0, + errorMsg: 'Test error' + }), + subscribe: () => () => {}, + update: () => {} + }; + + const snap = createMockSnapStore(); + const { container } = render(StatusBar, { store, cursor, persistence, snap }); + const persistenceValue = container.querySelector( + '.status-bar__section--persistence .status-bar__value' + ); + expect(persistenceValue).toBeTruthy(); + expect(persistenceValue?.textContent).toContain('Error'); + expect(persistenceValue?.classList.contains('status-bar__value--error')).toBe(true); + }); + + it('should use semantic HTML structure', () => { + const store = new Store(); + const cursor = new CursorStore(); + const persistence = createMockStatusStore(); + const snap = createMockSnapStore(); + + const { container } = render(StatusBar, { store, cursor, persistence, snap }); + const labels = container.querySelectorAll('.status-bar__toggle'); + expect(labels.length).toBe(2); + + labels.forEach((label) => { + const input = label.querySelector('input[type="checkbox"]'); + expect(input).toBeTruthy(); + }); + }); }); diff --git a/apps/web/src/lib/tests/Toolbar.accessibility.test.ts b/apps/web/src/lib/tests/Toolbar.accessibility.test.ts index 6e00975..37777d3 100644 --- a/apps/web/src/lib/tests/Toolbar.accessibility.test.ts +++ b/apps/web/src/lib/tests/Toolbar.accessibility.test.ts @@ -9,7 +9,10 @@ function renderToolbar(store: Store) { const target = document.createElement('div'); document.body.appendChild(target); const brushStore = createBrushStore(); - return render(Toolbar, { target, props: { currentTool: 'select', onToolChange: () => {}, store, brushStore } }); + return render(Toolbar, { + target, + props: { currentTool: 'select', onToolChange: () => {}, store, brushStore } + }); } describe('Toolbar accessibility', () => { @@ -48,7 +51,9 @@ describe('Toolbar accessibility', () => { const store = new Store(); const { container } = renderToolbar(store); - const exportButton = container.querySelector('.toolbar__export-button') as HTMLButtonElement; + const exportButton = container.querySelector( + '.toolbar__export-button' + ) as HTMLButtonElement; exportButton.click(); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -87,11 +92,11 @@ describe('Toolbar accessibility', () => { const menu = document.querySelector('[aria-label="Import options"]'); expect(menu?.getAttribute('role')).toBe('menu'); - expect([...menu!.querySelectorAll('[role="menuitem"]')].map((item) => item.textContent?.trim())).toEqual([ - 'Editable document', - 'SVG file', - 'SVG code / markup' - ]); + expect( + [...menu!.querySelectorAll('[role="menuitem"]')].map((item) => + item.textContent?.trim() + ) + ).toEqual(['Editable document', 'SVG file', 'SVG code / markup']); }); it('should have visible focus states on buttons', () => { diff --git a/apps/web/src/lib/tests/components/FileBrowser.svelte.test.ts b/apps/web/src/lib/tests/components/FileBrowser.svelte.test.ts index 10470f9..c2be1a4 100644 --- a/apps/web/src/lib/tests/components/FileBrowser.svelte.test.ts +++ b/apps/web/src/lib/tests/components/FileBrowser.svelte.test.ts @@ -1,288 +1,313 @@ -import FileBrowser from "$editor/filebrowser/FileBrowser.svelte"; -import type { BoardMeta, FileBrowserViewModel } from "@inkfinite/core"; -import { FileBrowserVM } from "@inkfinite/core"; -import { describe, expect, it, vi } from "vitest"; -import { render } from "vitest-browser-svelte"; -import { page } from "vitest/browser"; +import FileBrowser from '$editor/filebrowser/FileBrowser.svelte'; +import type { BoardMeta, FileBrowserViewModel } from '@inkfinite/core'; +import { FileBrowserVM } from '@inkfinite/core'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; const mockRepo = { - listBoards: vi.fn(), - createBoard: vi.fn(), - openBoard: vi.fn(), - renameBoard: vi.fn(), - deleteBoard: vi.fn(), + listBoards: vi.fn(), + createBoard: vi.fn(), + openBoard: vi.fn(), + renameBoard: vi.fn(), + deleteBoard: vi.fn() }; function createMockBoards(): BoardMeta[] { - return [{ id: "board-1", name: "Board 1", createdAt: 1000, updatedAt: 2000 }, { - id: "board-2", - name: "Board 2", - createdAt: 1500, - updatedAt: 2500, - }, { id: "board-3", name: "Test Board", createdAt: 2000, updatedAt: 3000 }]; + return [ + { id: 'board-1', name: 'Board 1', createdAt: 1000, updatedAt: 2000 }, + { id: 'board-2', name: 'Board 2', createdAt: 1500, updatedAt: 2500 }, + { id: 'board-3', name: 'Test Board', createdAt: 2000, updatedAt: 3000 } + ]; } function createMockVM(boards: BoardMeta[]): FileBrowserViewModel { - return FileBrowserVM.create({ repo: mockRepo, boards }); + return FileBrowserVM.create({ repo: mockRepo, boards }); } -describe("FileBrowser", () => { - describe("boards list", () => { - it("should render boards when provided", async () => { - const boards = createMockBoards(); - const vm = createMockVM(boards); +describe('FileBrowser', () => { + describe('boards list', () => { + it('should render boards when provided', async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); - render(FileBrowser, { vm, open: true }); + render(FileBrowser, { vm, open: true }); - await expect.element(page.getByText("Board 1")).toBeVisible(); - await expect.element(page.getByText("Board 2")).toBeVisible(); - await expect.element(page.getByText("Test Board")).toBeVisible(); - }); + await expect.element(page.getByText('Board 1')).toBeVisible(); + await expect.element(page.getByText('Board 2')).toBeVisible(); + await expect.element(page.getByText('Test Board')).toBeVisible(); + }); - it("should show empty state when no boards", async () => { - const vm = createMockVM([]); + it('should show empty state when no boards', async () => { + const vm = createMockVM([]); - render(FileBrowser, { vm, open: true }); + render(FileBrowser, { vm, open: true }); - await expect.element(page.getByText("No boards yet")).toBeVisible(); - }); + await expect.element(page.getByText('No boards yet')).toBeVisible(); + }); - it("should show filtered empty state when query has no matches", async () => { - const boards = createMockBoards(); - const vm = FileBrowserVM.setQuery(createMockVM(boards), "NonExistent"); + it('should show filtered empty state when query has no matches', async () => { + const boards = createMockBoards(); + const vm = FileBrowserVM.setQuery(createMockVM(boards), 'NonExistent'); - render(FileBrowser, { vm, open: true }); + render(FileBrowser, { vm, open: true }); - await expect.element(page.getByText("No boards match your search")).toBeVisible(); - }); - }); + await expect.element(page.getByText('No boards match your search')).toBeVisible(); + }); + }); - describe("search functionality", () => { - it("should have search input", async () => { - const vm = createMockVM(createMockBoards()); + describe('search functionality', () => { + it('should have search input', async () => { + const vm = createMockVM(createMockBoards()); - render(FileBrowser, { vm, open: true }); + render(FileBrowser, { vm, open: true }); - const searchInput = page.getByPlaceholder("Search boards..."); - await expect.element(searchInput).toBeInTheDocument(); - }); + const searchInput = page.getByPlaceholder('Search boards...'); + await expect.element(searchInput).toBeInTheDocument(); + }); - it("should update query on input", async () => { - const boards = createMockBoards(); - const vm = createMockVM(boards); - const onUpdate = vi.fn(); + it('should update query on input', async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + const onUpdate = vi.fn(); - render(FileBrowser, { vm, open: true, onUpdate }); + render(FileBrowser, { vm, open: true, onUpdate }); - const searchInput = page.getByPlaceholder("Search boards..."); - await searchInput.fill("Test"); + const searchInput = page.getByPlaceholder('Search boards...'); + await searchInput.fill('Test'); - await expect.poll(() => searchInput.query()).toHaveValue("Test"); - }); - }); + await expect.poll(() => searchInput.query()).toHaveValue('Test'); + }); + }); - describe("board actions", () => { - it("should show create board button", async () => { - const vm = createMockVM(createMockBoards()); + describe('board actions', () => { + it('should show create board button', async () => { + const vm = createMockVM(createMockBoards()); - render(FileBrowser, { vm, open: true }); + render(FileBrowser, { vm, open: true }); - const createButton = page.getByRole("button", { name: /create new board/i }); - await expect.element(createButton).toBeVisible(); - }); + const createButton = page.getByRole('button', { name: /create new board/i }); + await expect.element(createButton).toBeVisible(); + }); - it("should show create form when new button is clicked", async () => { - const vm = createMockVM(createMockBoards()); + it('should show create form when new button is clicked', async () => { + const vm = createMockVM(createMockBoards()); - render(FileBrowser, { vm, open: true }); + render(FileBrowser, { vm, open: true }); - const newButton = page.getByRole("button", { name: /create new board/i }); - await newButton.click(); + const newButton = page.getByRole('button', { name: /create new board/i }); + await newButton.click(); - await expect.element(page.getByPlaceholder("Board name")).toBeVisible(); - await expect.element(page.getByRole("button", { name: /^create$/i })).toBeVisible(); - }); + await expect.element(page.getByPlaceholder('Board name')).toBeVisible(); + await expect.element(page.getByRole('button', { name: /^create$/i })).toBeVisible(); + }); - it("should have inspect buttons for each board", async () => { - const boards = createMockBoards(); - const vm = createMockVM(boards); + it('should have inspect buttons for each board', async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); - render(FileBrowser, { vm, open: true }); + render(FileBrowser, { vm, open: true }); + + const inspectButtons = page.getByLabelText(/inspect board/i); + await expect.poll(() => inspectButtons.all()).toHaveLength(3); + }); + + it('should have rename buttons for each board', async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + + render(FileBrowser, { vm, open: true }); + + const renameButtons = page.getByLabelText(/rename board/i); + await expect.poll(() => renameButtons.all()).toHaveLength(3); + }); + + it('should have delete buttons for each board', async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + + render(FileBrowser, { vm, open: true }); + + const deleteButtons = page.getByLabelText(/delete board/i); + await expect.poll(() => deleteButtons.all()).toHaveLength(3); + }); + }); + + describe('inspector drawer', () => { + it('should not show inspector initially', async () => { + const vm = createMockVM(createMockBoards()); + + render(FileBrowser, { vm, open: true }); + + await expect.poll(() => document.querySelector('.inspector__title')).toBeNull(); + }); + + it('should show inspector when inspect button is clicked', async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + const fetchInspectorData = vi + .fn() + .mockResolvedValue({ + storageType: 'IndexedDB (Dexie)', + stats: { + pageCount: 2, + shapeCount: 10, + bindingCount: 3, + docSizeBytes: 2048, + lastUpdated: 3000 + }, + schema: { declaredVersion: 2, installedVersion: 2 } + }); + + render(FileBrowser, { vm, open: true, fetchInspectorData }); + + const inspectButtons = page.getByLabelText(/inspect board/i); + const buttons = inspectButtons.all(); + const firstButton = buttons[0]; + await firstButton.click(); + + await expect.element(page.getByText('Board Inspector')).toBeVisible(); + }); + + it('should display board statistics in inspector', async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + const fetchInspectorData = vi + .fn() + .mockResolvedValue({ + storageType: 'IndexedDB (Dexie)', + stats: { + pageCount: 2, + shapeCount: 10, + bindingCount: 3, + docSizeBytes: 2048, + lastUpdated: 3000 + }, + schema: { declaredVersion: 2, installedVersion: 2 } + }); + + render(FileBrowser, { vm, open: true, fetchInspectorData }); + + const inspectButtons = page.getByLabelText(/inspect board/i); + const buttons = inspectButtons.all(); + const firstButton = buttons[0]; + await firstButton.click(); + + await expect.element(page.getByText('Statistics')).toBeVisible(); + + await expect.element(page.getByText('Pages:')).toBeVisible(); + await expect.element(page.getByText('Shapes:')).toBeVisible(); + await expect.element(page.getByText('Bindings:')).toBeVisible(); + }); + + it('should display schema information in inspector', async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + const fetchInspectorData = vi + .fn() + .mockResolvedValue({ + storageType: 'IndexedDB (Dexie)', + stats: { + pageCount: 2, + shapeCount: 10, + bindingCount: 3, + docSizeBytes: 2048, + lastUpdated: 3000 + }, + schema: { declaredVersion: 2, installedVersion: 2 } + }); + + render(FileBrowser, { vm, open: true, fetchInspectorData }); + + const inspectButtons = page.getByLabelText(/inspect board/i); + const buttons = inspectButtons.all(); + const firstButton = buttons[0]; + await firstButton.click(); + + await expect.element(page.getByText('Schema')).toBeVisible(); + await expect.element(page.getByText('Declared Version:')).toBeVisible(); + await expect.element(page.getByText('Installed Version:')).toBeVisible(); + }); + + it('should show error when inspector data fetch fails', async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + const fetchInspectorData = vi.fn().mockRejectedValue(new Error('Failed to fetch')); + + render(FileBrowser, { vm, open: true, fetchInspectorData }); + + const inspectButtons = page.getByLabelText(/inspect board/i); + const buttons = inspectButtons.all(); + const firstButton = buttons[0]; + await firstButton.click(); + + await expect.element(page.getByText('Failed to fetch')).toBeVisible(); + }); + }); + + describe('overlay behavior', () => { + it('should render a close button that closes the browser', async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + const onClose = vi.fn(); + + render(FileBrowser, { vm, open: true, onClose }); + + const closeButton = page.getByLabelText(/close board browser/i); + await closeButton.click(); + + await expect.poll(() => onClose).toHaveBeenCalled(); + }); + + it('should close when clicking the backdrop', async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + const onClose = vi.fn(); + + render(FileBrowser, { vm, open: true, onClose }); + + await expect.poll(() => document.querySelector('.sheet__backdrop')).not.toBeNull(); + + const backdrop = document.querySelector('.sheet__backdrop'); + backdrop?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + await expect.poll(() => onClose).toHaveBeenCalled(); + }); + }); + + describe('callbacks', () => { + it('should call onUpdate when search changes', async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + const onUpdate = vi.fn(); + + render(FileBrowser, { vm, open: true, onUpdate }); + + const searchInput = page.getByPlaceholder('Search boards...'); + await searchInput.fill('Test'); - const inspectButtons = page.getByLabelText(/inspect board/i); - await expect.poll(() => inspectButtons.all()).toHaveLength(3); - }); + const input = document.querySelector( + '[placeholder="Search boards..."]' + ) as HTMLInputElement; + input?.dispatchEvent(new Event('change', { bubbles: true })); + + await expect.poll(() => onUpdate).toHaveBeenCalled(); + }); + + it('should call onClose when board is opened', async () => { + const boards = createMockBoards(); + const vm = createMockVM(boards); + const onClose = vi.fn(); + + mockRepo.openBoard.mockResolvedValue(undefined); + + render(FileBrowser, { vm, open: true, onClose }); + + const boardName = page.getByText('Board 1'); + await boardName.click(); - it("should have rename buttons for each board", async () => { - const boards = createMockBoards(); - const vm = createMockVM(boards); - - render(FileBrowser, { vm, open: true }); - - const renameButtons = page.getByLabelText(/rename board/i); - await expect.poll(() => renameButtons.all()).toHaveLength(3); - }); - - it("should have delete buttons for each board", async () => { - const boards = createMockBoards(); - const vm = createMockVM(boards); - - render(FileBrowser, { vm, open: true }); - - const deleteButtons = page.getByLabelText(/delete board/i); - await expect.poll(() => deleteButtons.all()).toHaveLength(3); - }); - }); - - describe("inspector drawer", () => { - it("should not show inspector initially", async () => { - const vm = createMockVM(createMockBoards()); - - render(FileBrowser, { vm, open: true }); - - await expect.poll(() => document.querySelector(".inspector__title")).toBeNull(); - }); - - it("should show inspector when inspect button is clicked", async () => { - const boards = createMockBoards(); - const vm = createMockVM(boards); - const fetchInspectorData = vi.fn().mockResolvedValue({ - storageType: "IndexedDB (Dexie)", - stats: { pageCount: 2, shapeCount: 10, bindingCount: 3, docSizeBytes: 2048, lastUpdated: 3000 }, - schema: { declaredVersion: 2, installedVersion: 2 }, - }); - - render(FileBrowser, { vm, open: true, fetchInspectorData }); - - const inspectButtons = page.getByLabelText(/inspect board/i); - const buttons = inspectButtons.all(); - const firstButton = buttons[0]; - await firstButton.click(); - - await expect.element(page.getByText("Board Inspector")).toBeVisible(); - }); - - it("should display board statistics in inspector", async () => { - const boards = createMockBoards(); - const vm = createMockVM(boards); - const fetchInspectorData = vi.fn().mockResolvedValue({ - storageType: "IndexedDB (Dexie)", - stats: { pageCount: 2, shapeCount: 10, bindingCount: 3, docSizeBytes: 2048, lastUpdated: 3000 }, - schema: { declaredVersion: 2, installedVersion: 2 }, - }); - - render(FileBrowser, { vm, open: true, fetchInspectorData }); - - const inspectButtons = page.getByLabelText(/inspect board/i); - const buttons = inspectButtons.all(); - const firstButton = buttons[0]; - await firstButton.click(); - - await expect.element(page.getByText("Statistics")).toBeVisible(); - - await expect.element(page.getByText("Pages:")).toBeVisible(); - await expect.element(page.getByText("Shapes:")).toBeVisible(); - await expect.element(page.getByText("Bindings:")).toBeVisible(); - }); - - it("should display schema information in inspector", async () => { - const boards = createMockBoards(); - const vm = createMockVM(boards); - const fetchInspectorData = vi.fn().mockResolvedValue({ - storageType: "IndexedDB (Dexie)", - stats: { pageCount: 2, shapeCount: 10, bindingCount: 3, docSizeBytes: 2048, lastUpdated: 3000 }, - schema: { declaredVersion: 2, installedVersion: 2 }, - }); - - render(FileBrowser, { vm, open: true, fetchInspectorData }); - - const inspectButtons = page.getByLabelText(/inspect board/i); - const buttons = inspectButtons.all(); - const firstButton = buttons[0]; - await firstButton.click(); - - await expect.element(page.getByText("Schema")).toBeVisible(); - await expect.element(page.getByText("Declared Version:")).toBeVisible(); - await expect.element(page.getByText("Installed Version:")).toBeVisible(); - }); - - it("should show error when inspector data fetch fails", async () => { - const boards = createMockBoards(); - const vm = createMockVM(boards); - const fetchInspectorData = vi.fn().mockRejectedValue(new Error("Failed to fetch")); - - render(FileBrowser, { vm, open: true, fetchInspectorData }); - - const inspectButtons = page.getByLabelText(/inspect board/i); - const buttons = inspectButtons.all(); - const firstButton = buttons[0]; - await firstButton.click(); - - await expect.element(page.getByText("Failed to fetch")).toBeVisible(); - }); - }); - - describe("overlay behavior", () => { - it("should render a close button that closes the browser", async () => { - const boards = createMockBoards(); - const vm = createMockVM(boards); - const onClose = vi.fn(); - - render(FileBrowser, { vm, open: true, onClose }); - - const closeButton = page.getByLabelText(/close board browser/i); - await closeButton.click(); - - await expect.poll(() => onClose).toHaveBeenCalled(); - }); - - it("should close when clicking the backdrop", async () => { - const boards = createMockBoards(); - const vm = createMockVM(boards); - const onClose = vi.fn(); - - render(FileBrowser, { vm, open: true, onClose }); - - await expect.poll(() => document.querySelector(".sheet__backdrop")).not.toBeNull(); - - const backdrop = document.querySelector(".sheet__backdrop"); - backdrop?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - - await expect.poll(() => onClose).toHaveBeenCalled(); - }); - }); - - describe("callbacks", () => { - it("should call onUpdate when search changes", async () => { - const boards = createMockBoards(); - const vm = createMockVM(boards); - const onUpdate = vi.fn(); - - render(FileBrowser, { vm, open: true, onUpdate }); - - const searchInput = page.getByPlaceholder("Search boards..."); - await searchInput.fill("Test"); - - const input = document.querySelector("[placeholder=\"Search boards...\"]") as HTMLInputElement; - input?.dispatchEvent(new Event("change", { bubbles: true })); - - await expect.poll(() => onUpdate).toHaveBeenCalled(); - }); - - it("should call onClose when board is opened", async () => { - const boards = createMockBoards(); - const vm = createMockVM(boards); - const onClose = vi.fn(); - - mockRepo.openBoard.mockResolvedValue(undefined); - - render(FileBrowser, { vm, open: true, onClose }); - - const boardName = page.getByText("Board 1"); - await boardName.click(); - - await expect.poll(() => mockRepo.openBoard).toHaveBeenCalledWith("board-1"); - await expect.poll(() => onClose).toHaveBeenCalled(); - }); - }); + await expect.poll(() => mockRepo.openBoard).toHaveBeenCalledWith('board-1'); + await expect.poll(() => onClose).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/web/src/lib/tests/status.test.ts b/apps/web/src/lib/tests/status.test.ts index 868cdec..cd8943d 100644 --- a/apps/web/src/lib/tests/status.test.ts +++ b/apps/web/src/lib/tests/status.test.ts @@ -1,134 +1,142 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { Observable, Observer, Subscription } from "dexie"; -import type { DocPatch, PageRecord, PersistentDocRepo } from "@inkfinite/core"; -import { describe, expect, it, vi } from "vitest"; -import type { InkfiniteDB } from "$lib/persistence/database"; -import { createDexieSession, type DexieAdapterOptions } from "$lib/persistence/dexie"; +import type { Observable, Observer, Subscription } from 'dexie'; +import type { DocPatch, PageRecord, PersistentDocRepo } from '@inkfinite/core'; +import { describe, expect, it, vi } from 'vitest'; +import type { InkfiniteDB } from '$lib/persistence/database'; +import { createDexieSession, type DexieAdapterOptions } from '$lib/persistence/dexie'; function createMockRepo(): PersistentDocRepo { - return { - listBoards: vi.fn(async () => []), - createBoard: vi.fn(async () => "board:mock"), - openBoard: vi.fn(async () => {}), - renameBoard: vi.fn(async () => {}), - deleteBoard: vi.fn(async () => {}), - loadDoc: vi.fn(async () => ({ pages: {}, shapes: {}, bindings: {}, order: { pageIds: [], shapeOrder: {} } })), - applyDocPatch: vi.fn(async () => {}), - exportBoard: vi.fn(async () => ({ - board: { id: "board:mock", name: "", createdAt: 0, updatedAt: 0 }, - doc: { pages: {}, shapes: {}, bindings: {} }, - order: { pageIds: [], shapeOrder: {} }, - })), - importBoard: vi.fn(async () => "board:mock"), - }; + return { + listBoards: vi.fn(async () => []), + createBoard: vi.fn(async () => 'board:mock'), + openBoard: vi.fn(async () => {}), + renameBoard: vi.fn(async () => {}), + deleteBoard: vi.fn(async () => {}), + loadDoc: vi.fn(async () => ({ + pages: {}, + shapes: {}, + bindings: {}, + order: { pageIds: [], shapeOrder: {} } + })), + applyDocPatch: vi.fn(async () => {}), + exportBoard: vi.fn(async () => ({ + board: { id: 'board:mock', name: '', createdAt: 0, updatedAt: 0 }, + doc: { pages: {}, shapes: {}, bindings: {} }, + order: { pageIds: [], shapeOrder: {} } + })), + importBoard: vi.fn(async () => 'board:mock') + }; } type ObserverLike = { next: (value: any) => void; error?: (err: unknown) => void }; function createMockLiveQuery() { - const observers = new Set(); - const factory: NonNullable = () => { - const observable: Observable = { - subscribe(observer?: Observer | ((value: any) => void) | null) { - const normalized: ObserverLike = typeof observer === "function" - ? { next: observer } - : observer - ? { next: observer.next ?? (() => {}), error: observer.error } - : { next: () => {} }; - observers.add(normalized); - const subscription = { - closed: false, - unsubscribe() { - if (subscription.closed) { - return; - } - subscription.closed = true; - observers.delete(normalized); - }, - }; - return subscription as Subscription; - }, - [Symbol.observable]() { - return this; - }, - }; - return observable; - }; + const observers = new Set(); + const factory: NonNullable = () => { + const observable: Observable = { + subscribe(observer?: Observer | ((value: any) => void) | null) { + const normalized: ObserverLike = + typeof observer === 'function' + ? { next: observer } + : observer + ? { next: observer.next ?? (() => {}), error: observer.error } + : { next: () => {} }; + observers.add(normalized); + const subscription = { + closed: false, + unsubscribe() { + if (subscription.closed) { + return; + } + subscription.closed = true; + observers.delete(normalized); + } + }; + return subscription as Subscription; + }, + [Symbol.observable]() { + return this; + } + }; + return observable; + }; - return { - factory, - emit(value: any) { - for (const observer of observers) { - observer.next(value); - } - }, - error(err: unknown) { - for (const observer of observers) { - observer.error?.(err); - } - }, - observerCount() { - return observers.size; - }, - }; + return { + factory, + emit(value: any) { + for (const observer of observers) { + observer.next(value); + } + }, + error(err: unknown) { + for (const observer of observers) { + observer.error?.(err); + } + }, + observerCount() { + return observers.size; + } + }; } -function createStatusTracker( - overrides?: { - repo?: PersistentDocRepo; - options?: Omit; - db?: Partial; - }, -) { - const repo = overrides?.repo ?? createMockRepo(); - const live = overrides?.options?.liveQueryFn ? null : createMockLiveQuery(); - const options: Omit = overrides?.options ?? { liveQueryFn: live?.factory }; - const db = (overrides?.db ?? { boards: { get: vi.fn(async () => undefined) } }) as InkfiniteDB; - const manager = createDexieSession(db, repo, options); - const mock = { repo, live, manager }; - return mock; +function createStatusTracker(overrides?: { + repo?: PersistentDocRepo; + options?: Omit; + db?: Partial; +}) { + const repo = overrides?.repo ?? createMockRepo(); + const live = overrides?.options?.liveQueryFn ? null : createMockLiveQuery(); + const options: Omit = overrides?.options ?? { + liveQueryFn: live?.factory + }; + const db = (overrides?.db ?? { boards: { get: vi.fn(async () => undefined) } }) as InkfiniteDB; + const manager = createDexieSession(db, repo, options); + const mock = { repo, live, manager }; + return mock; } function buildPatch(): DocPatch { - return { upserts: { pages: [{ id: "page:1", name: "Page 1", shapeIds: [] } as PageRecord] } }; + return { upserts: { pages: [{ id: 'page:1', name: 'Page 1', shapeIds: [] } as PageRecord] } }; } -describe("Dexie editor adapter", () => { - it("tracks pending writes and resets when liveQuery emits", () => { - const { live, manager } = createStatusTracker(); - expect(manager.status.get().pendingWrites).toBe(0); - manager.setActiveBoard?.("board:1"); +describe('Dexie editor adapter', () => { + it('tracks pending writes and resets when liveQuery emits', () => { + const { live, manager } = createStatusTracker(); + expect(manager.status.get().pendingWrites).toBe(0); + manager.setActiveBoard?.('board:1'); - manager.sink.enqueueDocPatch("board:1", buildPatch()); - let status = manager.status.get(); - expect(status.state).toBe("saving"); - expect(status.pendingWrites).toBe(1); + manager.sink.enqueueDocPatch('board:1', buildPatch()); + let status = manager.status.get(); + expect(status.state).toBe('saving'); + expect(status.pendingWrites).toBe(1); - live?.emit({ updatedAt: 123 }); - status = manager.status.get(); - expect(status.pendingWrites).toBe(0); - expect(status.state).toBe("saved"); - expect(status.lastSavedAt).toBe(123); - }); + live?.emit({ updatedAt: 123 }); + status = manager.status.get(); + expect(status.pendingWrites).toBe(0); + expect(status.state).toBe('saved'); + expect(status.lastSavedAt).toBe(123); + }); - it("records errors from flush", async () => { - const repo = createMockRepo(); - (repo.applyDocPatch as ReturnType).mockRejectedValueOnce(new Error("failed")); - const { manager } = createStatusTracker({ repo }); - manager.setActiveBoard?.("board:1"); - manager.sink.enqueueDocPatch("board:1", buildPatch()); + it('records errors from flush', async () => { + const repo = createMockRepo(); + (repo.applyDocPatch as ReturnType).mockRejectedValueOnce( + new Error('failed') + ); + const { manager } = createStatusTracker({ repo }); + manager.setActiveBoard?.('board:1'); + manager.sink.enqueueDocPatch('board:1', buildPatch()); - await expect(manager.sink.flush()).rejects.toThrow("failed"); - expect(manager.status.get().state).toBe("error"); - expect(manager.status.get().errorMsg).toBe("failed"); - }); + await expect(manager.sink.flush()).rejects.toThrow('failed'); + expect(manager.status.get().state).toBe('error'); + expect(manager.status.get().errorMsg).toBe('failed'); + }); - it("stops liveQuery when disposed", () => { - const live = createMockLiveQuery(); - const { manager } = createStatusTracker({ options: { liveQueryFn: live.factory } }); - manager.setActiveBoard?.("board:1"); - expect(live.observerCount()).toBe(1); - manager.dispose?.(); - expect(live.observerCount()).toBe(0); - }); + it('stops liveQuery when disposed', () => { + const live = createMockLiveQuery(); + const { manager } = createStatusTracker({ options: { liveQueryFn: live.factory } }); + manager.setActiveBoard?.('board:1'); + expect(live.observerCount()).toBe(1); + manager.dispose?.(); + expect(live.observerCount()).toBe(0); + }); }); diff --git a/apps/web/src/lib/tests/test-platform.ts b/apps/web/src/lib/tests/test-platform.ts index 96207a8..7dabcc0 100644 --- a/apps/web/src/lib/tests/test-platform.ts +++ b/apps/web/src/lib/tests/test-platform.ts @@ -1,20 +1,21 @@ -import { - createStatusStore, - type EditorPlatformAdapter, -} from "@inkfinite/ui/editor"; -import { InkfiniteDB } from "$lib/persistence/database"; -import { createDexieDocRepo, createPersistenceSink } from "$lib/persistence/repository"; +import { createStatusStore, type EditorPlatformAdapter } from '@inkfinite/ui/editor'; +import { InkfiniteDB } from '$lib/persistence/database'; +import { createDexieDocRepo, createPersistenceSink } from '$lib/persistence/repository'; export function createTestPlatformAdapter(): EditorPlatformAdapter { - return { - kind: "web", - async connect() { - const repo = createDexieDocRepo(new InkfiniteDB()); - return { - repo, - sink: createPersistenceSink(repo), - status: createStatusStore({ backend: "indexeddb", state: "saved", pendingWrites: 0 }), - }; - }, - }; + return { + kind: 'web', + async connect() { + const repo = createDexieDocRepo(new InkfiniteDB()); + return { + repo, + sink: createPersistenceSink(repo), + status: createStatusStore({ + backend: 'indexeddb', + state: 'saved', + pendingWrites: 0 + }) + }; + } + }; } diff --git a/apps/web/svelte.config.js b/apps/web/svelte.config.js index 3c1f207..8ba1b21 100644 --- a/apps/web/svelte.config.js +++ b/apps/web/svelte.config.js @@ -8,10 +8,7 @@ const config = { extensions: ['.svelte', '.svx', '.md'], preprocess: [ vitePreprocess(), - mdsvex({ - extensions: ['.svx', '.md'], - rehypePlugins: [extractTableOfContents] - }) + mdsvex({ extensions: ['.svx', '.md'], rehypePlugins: [extractTableOfContents] }) ], kit: { adapter: adapter(), diff --git a/crates/inkfinite-cli/src/bin/generate-bindings.rs b/crates/inkfinite-cli/src/bin/generate-bindings.rs index 27d7d55..b465006 100644 --- a/crates/inkfinite-cli/src/bin/generate-bindings.rs +++ b/crates/inkfinite-cli/src/bin/generate-bindings.rs @@ -7,6 +7,7 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use inkfinite_core::editor::*; use inkfinite_core::proto::*; use inkfinite_core::svg_import::*; use inkfinite_core::*; @@ -145,6 +146,7 @@ fn artifacts() -> Result, Box> { PathBuf::from("packages/bindings/src/svg-import.ts"), svg_import_bindings(), ); + artifacts.insert(PathBuf::from("packages/bindings/src/editor.ts"), editor_bindings()); artifacts.insert(PathBuf::from("packages/bindings/src/index.ts"), index_bindings()); Ok(artifacts) } @@ -294,9 +296,29 @@ fn svg_import_bindings() -> String { output } +fn editor_bindings() -> String { + let config = ts_config(); + let mut output = GENERATED_TS_HEADER.to_owned(); + output.push_str( + "import type { ActorId, BindingAnchor, BindingId, BindingKind, BindingRecord, ContainerLayout, LayerId, Opacity, Origin, PageId, SemanticMetadata, ShapeId, ShapeKind, ShapeParent, ShapeProperties, ShapeStyle, SiblingAnchor, Timestamp } from './model.js';\n", + ); + output.push_str("import type { LayerPatch, TransactionId } from './transaction.js';\n\n"); + append_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_clean_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + output +} + fn index_bindings() -> String { format!( - "{GENERATED_TS_HEADER}export * from './model.js';\nexport * from './protocol.js';\nexport * from './registry.js';\nexport * from './svg-import.js';\nexport * from './transaction.js';\n" + "{GENERATED_TS_HEADER}export * from './model.js';\nexport * from './protocol.js';\nexport * from './registry.js';\nexport * from './svg-import.js';\nexport * from './editor.js';\nexport * from './transaction.js';\n" ) } diff --git a/crates/inkfinite-core/src/editor.rs b/crates/inkfinite-core/src/editor.rs new file mode 100644 index 0000000..07083d8 --- /dev/null +++ b/crates/inkfinite-core/src/editor.rs @@ -0,0 +1,898 @@ +//! Projection and reconciliation between native documents and editor state. +//! +//! The editor works with a flat list of drawable shapes, while the canonical +//! document stores containers and parent-relative transforms. This module is +//! the shared boundary between those representations: projections expose +//! world-space transforms, and editor patches are converted back into minimal +//! native operations. + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use ts_rs::TS; + +use crate::engine::geometry::{Affine, world_transform}; +use crate::proto::{LayerPatch, Operation, ShapePatch as NativeShapePatch, TransactionDraft, TransactionId}; +use crate::{ + ActorId, BindingAnchor, BindingRecord, CONTAINER_KIND, ContainerLayout, Document, DocumentSnapshot, LayerId, + Opacity, Origin, PageId, Provenance, RecordVersion, SemanticMetadata, ShapeId, ShapeKind, ShapeParent, + ShapeProperties, ShapeRecord, ShapeStyle, SiblingAnchor, Timestamp, Transform, Vec2, +}; + +/// Full affine transform used by the editor projection. +/// +/// Unlike the canonical [`Transform`], this representation can retain the +/// result of composing ancestor transforms even when the composition includes +/// non-uniform scale and rotation. +#[derive(Clone, Copy, Debug, JsonSchema, PartialEq, Serialize, Deserialize, TS)] +pub struct EditorTransform { + /// Horizontal scale and rotation component. + pub a: f64, + /// Vertical shear and rotation component. + pub b: f64, + /// Horizontal shear and rotation component. + pub c: f64, + /// Vertical scale and rotation component. + pub d: f64, + /// Horizontal translation. + pub e: f64, + /// Vertical translation. + pub f: f64, +} + +impl From for EditorTransform { + fn from(value: Affine) -> Self { + Self { a: value.a, b: value.b, c: value.c, d: value.d, e: value.e, f: value.f } + } +} + +impl From for Affine { + fn from(value: EditorTransform) -> Self { + Self { a: value.a, b: value.b, c: value.c, d: value.d, e: value.e, f: value.f } + } +} + +/// One drawable shape projected into the editor's flat shape collection. +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize, TS)] +pub struct EditorShape { + /// Stable shape identifier. + pub id: ShapeId, + /// Editor registry key. + #[serde(rename = "type")] + #[ts(rename = "type")] + pub kind: ShapeKind, + /// Page containing the shape. + pub page_id: PageId, + /// Complete native-to-world transform. + pub transform: EditorTransform, + /// Legacy translation fields used by the current editor interaction model. + pub x: f64, + /// Legacy translation field used by the current editor interaction model. + pub y: f64, + /// Legacy rotation field used by the current editor interaction model. + pub rot: f64, + /// Immediate container parent, when the shape is inside a container. + pub group_id: Option, + /// Owning editor layer. + pub layer_id: LayerId, + /// Complete-shape opacity. + pub opacity: Opacity, + /// Optional fill opacity. + pub fill_opacity: Option, + /// Optional stroke opacity. + pub stroke_opacity: Option, + /// Agent editability retained for editor policy surfaces. + pub agent_editable: bool, + /// Kind-specific properties using editor property names. + #[ts(type = "ShapeProperties")] + pub props: ShapeProperties, +} + +/// A new shape supplied by an editor patch. +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize, TS)] +pub struct EditorShapeDraft { + /// Stable shape identifier. + pub id: ShapeId, + /// Native registry key. + pub kind: ShapeKind, + /// Kind-specific properties using editor property names. + #[ts(type = "ShapeProperties")] + pub properties: ShapeProperties, + /// Optional semantic metadata. Missing metadata receives editor defaults. + pub metadata: Option, + /// Common visual style. + pub style: ShapeStyle, + /// Optional container layout. + pub layout: Option, +} + +/// Page represented in the flat editor document. +#[derive(Clone, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize, TS)] +pub struct EditorPage { + /// Stable page identifier. + pub id: PageId, + /// User-visible page name. + pub name: String, + /// Drawable shape IDs in depth-first draw order. + pub shape_ids: Vec, + /// Layer IDs in back-to-front order. + pub layer_ids: Vec, +} + +/// Layer represented in the flat editor document. +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize, TS)] +pub struct EditorLayer { + /// Stable layer identifier. + pub id: LayerId, + /// Owning page identifier. + pub page_id: PageId, + /// User-visible layer name. + pub name: String, + /// Drawable shape IDs in depth-first draw order. + pub shape_ids: Vec, + /// Whether the layer participates in rendering. + pub visible: bool, + /// Whether the layer can be selected or changed. + pub locked: bool, + /// Inherited layer opacity. + pub opacity: Opacity, +} + +/// Binding represented in the editor's binding collection. +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize, TS)] +pub struct EditorBinding { + /// Stable binding identifier. + pub id: crate::BindingId, + /// Editor binding kind. + #[serde(rename = "type")] + #[ts(rename = "type")] + pub kind: crate::BindingKind, + /// Source arrow or connector. + pub from_shape_id: ShapeId, + /// Target shape. + pub to_shape_id: ShapeId, + /// Source handle. + pub handle: String, + /// Target anchor. + pub anchor: BindingAnchor, +} + +/// Ordering information accompanying an editor projection. +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize, TS)] +pub struct EditorOrder { + /// Page IDs in document order. + pub page_ids: Vec, + /// Flattened drawable shape order by page. + pub shape_order: BTreeMap>, + /// Layer records in their projected form. + pub layers: BTreeMap, +} + +/// Native document projected into the editor's flat document shape. +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize, TS)] +pub struct EditorProjection { + /// Projected pages. + pub pages: BTreeMap, + /// Projected layers. + pub layers: BTreeMap, + /// Drawable shapes with composed world transforms. + pub shapes: BTreeMap, + /// Projected bindings. + pub bindings: BTreeMap, + /// Stable ordering metadata. + pub order: EditorOrder, +} + +/// Semantic editor changes that can be reconciled into native operations. +/// +/// Shape transforms are world-space transforms from [`EditorShape::transform`]. +/// The reconciler converts them back to parent-relative canonical transforms. +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case", tag = "type")] +pub enum EditorPatch { + /// Change fields on an existing shape. A supplied transform is world-space. + Shape { + /// Shape to change. + shape_id: ShapeId, + /// Replacement world-space transform. + transform: Option, + /// Replacement editor properties. + #[ts(type = "ShapeProperties | null")] + properties: Option, + /// Replacement semantic metadata. + metadata: Option, + /// Replacement visual style. + style: Option, + /// Replacement parent, when reparenting is part of the edit. + parent: Option, + /// Replacement sibling placement. + anchor: Option>, + }, + /// Create one shape from editor-owned semantic fields. + CreateShape { + /// New shape fields. + shape: EditorShapeDraft, + /// Parent in the native hierarchy. + parent: ShapeParent, + /// World-space transform for the new shape. + transform: EditorTransform, + /// Sibling placement. + anchor: SiblingAnchor, + }, + /// Delete a shape and its descendants. + DeleteShape { + /// Shape to delete. + shape_id: ShapeId, + }, + /// Rename a page. + RenamePage { + /// Page to rename. + page_id: PageId, + /// Replacement page name. + name: String, + }, + /// Change mutable layer fields. + PatchLayer { + /// Layer to change. + layer_id: LayerId, + /// Replacement fields. + patch: LayerPatch, + }, + /// Reorder a layer within its page. + ReorderLayer { + /// Layer to move. + layer_id: LayerId, + /// Replacement placement. + anchor: SiblingAnchor, + }, + /// Create a binding. + CreateBinding { + /// Binding record to create. + binding: BindingRecord, + }, + /// Delete a binding. + DeleteBinding { + /// Binding to delete. + binding_id: crate::BindingId, + }, +} + +/// Request metadata used to turn editor patches into one transaction draft. +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize, TS)] +pub struct EditorReconciliationRequest { + /// Ordered semantic editor changes. + pub patches: Vec, + /// Actor committing the resulting transaction. + pub actor_id: ActorId, + /// Origin retained in transaction history. + pub origin: Origin, + /// Stable transaction identifier. + pub transaction_id: TransactionId, + /// Human-readable history description. + pub description: String, + /// Client timestamp. + pub timestamp: Timestamp, +} + +/// Failure while translating an editor patch into a native transaction. +#[derive(Debug, Error, PartialEq)] +pub enum EditorReconciliationError { + /// The patch referred to a missing shape. + #[error("editor patch refers to unknown shape {0}")] + UnknownShape(ShapeId), + /// The patch referred to a missing page. + #[error("editor patch refers to unknown page {0}")] + UnknownPage(PageId), + /// The patch referred to a missing layer. + #[error("editor patch refers to unknown layer {0}")] + UnknownLayer(LayerId), + /// The patch referred to a missing parent shape. + #[error("editor patch refers to unknown parent shape {0}")] + UnknownParent(ShapeId), + /// The patch referred to a missing binding. + #[error("editor patch refers to unknown binding {0}")] + UnknownBinding(crate::BindingId), + /// A parent transform could not be inverted. + #[error("parent transform for shape {shape_id} is singular")] + SingularParent { + /// Shape whose parent could not be inverted. + shape_id: ShapeId, + }, + /// A world transform cannot be represented by the native transform model. + #[error("world transform for shape {shape_id} contains unsupported shear")] + UnsupportedShear { + /// Shape whose transform could not be decomposed. + shape_id: ShapeId, + }, +} + +/// Projects a canonical document into the shared flat editor representation. +#[must_use] +pub fn project_editor(snapshot: &DocumentSnapshot) -> EditorProjection { + let document = &snapshot.document; + let mut pages = BTreeMap::new(); + let mut layers = BTreeMap::new(); + let mut shapes = BTreeMap::new(); + let mut shape_order = BTreeMap::new(); + + for page_id in &document.page_ids { + let Some(page) = document.pages.get(page_id) else { continue }; + let mut flattened = Vec::new(); + for layer_id in &page.layer_ids { + let Some(layer) = document.layers.get(layer_id) else { continue }; + let mut layer_shapes = Vec::new(); + for shape_id in &layer.shape_ids { + append_projected_shape(document, page, layer, shape_id, None, &mut layer_shapes, &mut shapes); + } + flattened.extend(layer_shapes.iter().cloned()); + layers.insert( + layer.id.clone(), + EditorLayer { + id: layer.id.clone(), + page_id: layer.page_id.clone(), + name: layer.name.clone(), + shape_ids: layer_shapes, + visible: layer.visible, + locked: layer.locked, + opacity: layer.opacity, + }, + ); + } + shape_order.insert(page.id.clone(), flattened.clone()); + pages.insert( + page.id.clone(), + EditorPage { + id: page.id.clone(), + name: page.name.clone(), + shape_ids: flattened, + layer_ids: page.layer_ids.clone(), + }, + ); + } + + let bindings = document + .bindings + .values() + .map(|binding| { + ( + binding.id.clone(), + EditorBinding { + id: binding.id.clone(), + kind: binding.kind.clone(), + from_shape_id: binding.source_shape_id.clone(), + to_shape_id: binding.target_shape_id.clone(), + handle: binding.source_handle.clone(), + anchor: binding.anchor, + }, + ) + }) + .collect(); + + EditorProjection { + pages, + layers: layers.clone(), + shapes, + bindings, + order: EditorOrder { page_ids: document.page_ids.clone(), shape_order, layers }, + } +} + +fn append_projected_shape( + document: &Document, page: &crate::PageRecord, layer: &crate::LayerRecord, shape_id: &ShapeId, + group_id: Option, flattened: &mut Vec, shapes: &mut BTreeMap, +) { + let Some(shape) = document.shapes.get(shape_id) else { return }; + if shape.kind.as_str() != CONTAINER_KIND { + flattened.push(shape.id.clone()); + let world = world_transform(document, shape); + let properties = editor_properties(&shape.properties); + shapes.insert( + shape.id.clone(), + EditorShape { + id: shape.id.clone(), + kind: shape.kind.clone(), + page_id: page.id.clone(), + transform: world.into(), + x: world.e, + y: world.f, + rot: world.b.atan2(world.a), + group_id: group_id.clone(), + layer_id: layer.id.clone(), + opacity: shape.style.opacity, + fill_opacity: shape.style.fill_opacity, + stroke_opacity: shape.style.stroke_opacity, + agent_editable: shape.metadata.agent_editable, + props: properties, + }, + ); + } + let child_group = if shape.kind.as_str() == CONTAINER_KIND { Some(shape.id.clone()) } else { group_id }; + for child_id in &shape.child_ids { + append_projected_shape(document, page, layer, child_id, child_group.clone(), flattened, shapes); + } +} + +/// Converts editor properties back to canonical property names. +#[must_use] +pub fn native_properties(properties: &ShapeProperties) -> ShapeProperties { + let mut result = properties.clone(); + if let Some(width) = result.remove("w") { + result.insert("width".into(), width); + } + if let Some(height) = result.remove("h") { + result.insert("height".into(), height); + } + result +} + +fn editor_properties(properties: &ShapeProperties) -> ShapeProperties { + let mut result = properties.clone(); + if let Some(width) = result.remove("width") { + result.insert("w".into(), width); + } + if let Some(height) = result.remove("height") { + result.insert("h".into(), height); + } + result +} + +/// Reconciles semantic editor changes into one minimal native transaction. +/// +/// The returned draft retains the inspected snapshot heads and uses record +/// versions on the first operation touching each record. The transaction +/// engine remains responsible for final validation and atomic commit. +/// +/// # Errors +/// +/// Returns an error when a patch refers to missing hierarchy records or asks +/// for a world transform that cannot be represented by a native transform. +pub fn reconcile_editor_patches( + snapshot: &DocumentSnapshot, request: EditorReconciliationRequest, +) -> Result { + let document = &snapshot.document; + let mut operations = Vec::new(); + let default_metadata = default_metadata(&request); + + for patch in request.patches { + match patch { + EditorPatch::Shape { shape_id, transform, properties, metadata, style, parent, anchor } => { + reconcile_shape( + document, + shape_id, + transform, + properties, + metadata, + style, + parent.as_ref(), + anchor, + &mut operations, + )?; + } + EditorPatch::CreateShape { shape, parent, transform, anchor } => { + let local_transform = local_transform(document, &shape.id, &parent, transform)?; + let metadata = shape.metadata.unwrap_or_else(|| default_metadata.clone()); + let native_shape = ShapeRecord { + id: shape.id, + kind: shape.kind, + parent, + transform: local_transform, + child_ids: Vec::new(), + layout: shape.layout, + properties: native_properties(&shape.properties), + metadata, + style: shape.style, + version: RecordVersion(1), + }; + operations.push(Operation::CreateShape { shape: native_shape, anchor }); + } + EditorPatch::DeleteShape { shape_id } => { + let shape = document + .shapes + .get(&shape_id) + .ok_or_else(|| EditorReconciliationError::UnknownShape(shape_id.clone()))?; + operations.push(Operation::DeleteShape { shape_id, expected_version: Some(shape.version) }); + } + EditorPatch::RenamePage { page_id, name } => { + let page = document + .pages + .get(&page_id) + .ok_or_else(|| EditorReconciliationError::UnknownPage(page_id.clone()))?; + if page.name != name { + operations.push(Operation::RenamePage { page_id, name, expected_version: Some(page.version) }); + } + } + EditorPatch::PatchLayer { layer_id, patch } => { + let layer = document + .layers + .get(&layer_id) + .ok_or_else(|| EditorReconciliationError::UnknownLayer(layer_id.clone()))?; + if layer_patch_changes(layer, &patch) { + operations.push(Operation::PatchLayer { layer_id, patch, expected_version: Some(layer.version) }); + } + } + EditorPatch::ReorderLayer { layer_id, anchor } => { + let layer = document + .layers + .get(&layer_id) + .ok_or_else(|| EditorReconciliationError::UnknownLayer(layer_id.clone()))?; + operations.push(Operation::ReorderLayer { layer_id, anchor, expected_version: Some(layer.version) }); + } + EditorPatch::CreateBinding { binding } => operations.push(Operation::CreateBinding { binding }), + EditorPatch::DeleteBinding { binding_id } => { + let binding = document + .bindings + .get(&binding_id) + .ok_or_else(|| EditorReconciliationError::UnknownBinding(binding_id.clone()))?; + operations.push(Operation::DeleteBinding { binding_id, expected_version: Some(binding.version) }); + } + } + } + + Ok(TransactionDraft { + id: request.transaction_id, + actor_id: request.actor_id, + origin: request.origin, + base_heads: snapshot.heads.clone(), + description: request.description, + operations, + timestamp: request.timestamp, + }) +} + +#[allow(clippy::too_many_arguments)] +fn reconcile_shape( + document: &Document, shape_id: ShapeId, transform: Option, properties: Option, + metadata: Option, style: Option, parent: Option<&ShapeParent>, + anchor: Option>, operations: &mut Vec, +) -> Result<(), EditorReconciliationError> { + let shape = document + .shapes + .get(&shape_id) + .ok_or_else(|| EditorReconciliationError::UnknownShape(shape_id.clone()))?; + let target_parent = parent.cloned().unwrap_or_else(|| shape.parent.clone()); + let parent_changed = parent.is_some_and(|value| value != &shape.parent); + let reorder = anchor.is_some(); + let mut shape_patch = NativeShapePatch::default(); + + if let Some(world) = transform { + let local = local_transform(document, &shape_id, &target_parent, world)?; + let current_world = world_transform(document, shape); + if !same_affine(current_world, world.into()) || parent_changed { + shape_patch.transform = Some(local); + } + } + if let Some(properties) = properties { + let properties = native_properties(&properties); + if properties != shape.properties { + shape_patch.properties = Some(properties); + } + } + if let Some(metadata) = metadata + && metadata != shape.metadata + { + shape_patch.metadata = Some(metadata); + } + if let Some(style) = style + && style != shape.style + { + shape_patch.style = Some(style); + } + + let mut used_version = false; + if parent_changed || reorder { + operations.push(Operation::ReparentShape { + shape_id: shape_id.clone(), + parent: target_parent, + anchor: anchor.unwrap_or(SiblingAnchor::Last), + expected_version: Some(shape.version), + }); + used_version = true; + } + if shape_patch.transform.is_some() + || shape_patch.properties.is_some() + || shape_patch.metadata.is_some() + || shape_patch.style.is_some() + || shape_patch.layout.is_some() + { + operations.push(Operation::PatchShape { + shape_id, + patch: shape_patch, + expected_version: (!used_version).then_some(shape.version), + }); + } + Ok(()) +} + +fn local_transform( + document: &Document, shape_id: &ShapeId, parent: &ShapeParent, world: EditorTransform, +) -> Result { + let parent_world = match parent { + ShapeParent::Layer(layer_id) => { + if !document.layers.contains_key(layer_id) { + return Err(EditorReconciliationError::UnknownLayer(layer_id.clone())); + } + Affine::IDENTITY + } + ShapeParent::Shape(parent_id) => { + let parent_shape = document + .shapes + .get(parent_id) + .ok_or_else(|| EditorReconciliationError::UnknownParent(parent_id.clone()))?; + world_transform(document, parent_shape) + } + }; + let Some(inverse) = parent_world.inverse() else { + return Err(EditorReconciliationError::SingularParent { shape_id: shape_id.clone() }); + }; + decompose_native_transform(inverse.then(world.into()), shape_id) +} + +fn decompose_native_transform(matrix: Affine, shape_id: &ShapeId) -> Result { + let scale_x = matrix.a.hypot(matrix.b); + if scale_x <= f64::EPSILON { + return Err(EditorReconciliationError::UnsupportedShear { shape_id: shape_id.clone() }); + } + let rotation = matrix.b.atan2(matrix.a); + let scale_y = (matrix.a * matrix.d - matrix.b * matrix.c) / scale_x; + let transform = Transform { translation: Vec2 { x: matrix.e, y: matrix.f }, rotation, scale_x, scale_y }; + let reconstructed = Affine::from_transform(transform); + if !same_affine(reconstructed, matrix) { + return Err(EditorReconciliationError::UnsupportedShear { shape_id: shape_id.clone() }); + } + Ok(transform) +} + +fn same_affine(left: Affine, right: Affine) -> bool { + [ + (left.a, right.a), + (left.b, right.b), + (left.c, right.c), + (left.d, right.d), + (left.e, right.e), + (left.f, right.f), + ] + .into_iter() + .all(|(left, right)| (left - right).abs() <= 1e-9 * (1.0 + left.abs().max(right.abs()))) +} + +fn layer_patch_changes(layer: &crate::LayerRecord, patch: &LayerPatch) -> bool { + patch.name.as_ref().is_some_and(|name| name != &layer.name) + || patch.visible.is_some_and(|visible| visible != layer.visible) + || patch.locked.is_some_and(|locked| locked != layer.locked) + || patch.opacity.is_some_and(|opacity| opacity != layer.opacity) +} + +fn default_metadata(request: &EditorReconciliationRequest) -> SemanticMetadata { + SemanticMetadata { + name: None, + role: None, + description: None, + tags: Vec::new(), + locked: false, + agent_editable: true, + provenance: Provenance { + actor_id: request.actor_id.clone(), + origin: request.origin.clone(), + timestamp: request.timestamp, + source: None, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{BindingId, BindingRecord, ChangeHash, DocumentId, RecordVersion, ShapeParent, blank_document}; + use serde_json::Value; + + fn metadata() -> SemanticMetadata { + SemanticMetadata { + name: None, + role: None, + description: None, + tags: Vec::new(), + locked: false, + agent_editable: true, + provenance: Provenance { + actor_id: ActorId::from("actor:test"), + origin: Origin::Human, + timestamp: Timestamp(0), + source: None, + }, + } + } + + fn style() -> ShapeStyle { + ShapeStyle { opacity: Opacity::OPAQUE, fill_opacity: None, stroke_opacity: None } + } + + fn nested_snapshot() -> DocumentSnapshot { + let document_id = DocumentId::from("document:editor"); + let mut document = blank_document(&document_id, None); + let page_id = document.page_ids[0].clone(); + let layer_id = document.pages[&page_id].layer_ids[0].clone(); + let root_id = ShapeId::from("shape:root"); + let group_id = ShapeId::from("shape:group"); + let child_id = ShapeId::from("shape:child"); + document.layers.get_mut(&layer_id).unwrap().shape_ids = vec![root_id.clone()]; + document.shapes.insert( + root_id.clone(), + ShapeRecord { + id: root_id.clone(), + kind: ShapeKind::from(CONTAINER_KIND), + parent: ShapeParent::Layer(layer_id.clone()), + transform: Transform { + translation: Vec2 { x: 100.0, y: 50.0 }, + rotation: 0.3, + scale_x: 2.0, + scale_y: 2.0, + }, + child_ids: vec![group_id.clone()], + layout: Some(ContainerLayout::Free), + properties: BTreeMap::new(), + metadata: metadata(), + style: style(), + version: RecordVersion(1), + }, + ); + document.shapes.insert( + group_id.clone(), + ShapeRecord { + id: group_id.clone(), + kind: ShapeKind::from(CONTAINER_KIND), + parent: ShapeParent::Shape(root_id), + transform: Transform { + translation: Vec2 { x: 10.0, y: 20.0 }, + rotation: -0.2, + scale_x: 1.0, + scale_y: 1.0, + }, + child_ids: vec![child_id.clone()], + layout: Some(ContainerLayout::Free), + properties: BTreeMap::new(), + metadata: metadata(), + style: style(), + version: RecordVersion(1), + }, + ); + document.shapes.insert( + child_id.clone(), + ShapeRecord { + id: child_id, + kind: ShapeKind::from("rect"), + parent: ShapeParent::Shape(group_id), + transform: Transform { + translation: Vec2 { x: 4.0, y: 8.0 }, + rotation: 0.1, + scale_x: 1.0, + scale_y: 1.0, + }, + child_ids: Vec::new(), + layout: None, + properties: BTreeMap::from([ + ("width".into(), Value::from(20.0)), + ("height".into(), Value::from(10.0)), + ]), + metadata: metadata(), + style: style(), + version: RecordVersion(1), + }, + ); + DocumentSnapshot { + format: crate::FormatId::from(crate::INKFINITE_FORMAT_ID), + format_version: crate::INKFINITE_FORMAT_VERSION, + document_id, + heads: vec![ChangeHash::from("head:one")], + document, + } + } + + fn request(patches: Vec) -> EditorReconciliationRequest { + EditorReconciliationRequest { + patches, + actor_id: ActorId::from("actor:test"), + origin: Origin::Human, + transaction_id: TransactionId("transaction:editor".into()), + description: "Editor change".into(), + timestamp: Timestamp(1), + } + } + + #[test] + fn projection_composes_ancestor_transforms_and_flattens_containers() { + let snapshot = nested_snapshot(); + let projection = project_editor(&snapshot); + let child = &projection.shapes[&ShapeId::from("shape:child")]; + let expected = world_transform( + &snapshot.document, + &snapshot.document.shapes[&ShapeId::from("shape:child")], + ); + let actual: Affine = child.transform.into(); + assert!(same_affine(actual, expected)); + assert_eq!(child.group_id, Some(ShapeId::from("shape:group"))); + assert_eq!(projection.shapes.len(), 1); + assert_eq!(child.props["w"], Value::from(20.0)); + assert!(!child.props.contains_key("width")); + } + + #[test] + fn reconciliation_emits_one_relative_patch_for_a_world_move() { + let snapshot = nested_snapshot(); + let projection = project_editor(&snapshot); + let child_id = ShapeId::from("shape:child"); + let mut moved: Affine = projection.shapes[&child_id].transform.into(); + moved.e += 5.0; + let transaction = reconcile_editor_patches( + &snapshot, + request(vec![EditorPatch::Shape { + shape_id: child_id.clone(), + transform: Some(moved.into()), + properties: None, + metadata: None, + style: None, + parent: None, + anchor: None, + }]), + ) + .expect("world move should reconcile"); + assert_eq!(transaction.base_heads, snapshot.heads); + assert_eq!(transaction.operations.len(), 1); + let Operation::PatchShape { shape_id, patch, expected_version } = &transaction.operations[0] else { + panic!("expected a shape patch") + }; + assert_eq!(shape_id, &child_id); + assert_eq!(expected_version, &Some(RecordVersion(1))); + let local = patch.transform.expect("transform patch"); + let parent = world_transform( + &snapshot.document, + &snapshot.document.shapes[&ShapeId::from("shape:group")], + ); + let expected = parent + .inverse() + .expect("parent is invertible") + .point(Vec2 { x: moved.e, y: moved.f }); + assert!((local.translation.x - expected.x).abs() < 1e-9); + assert!((local.translation.y - expected.y).abs() < 1e-9); + } + + #[test] + fn reconciliation_omits_no_op_changes() { + let snapshot = nested_snapshot(); + let projection = project_editor(&snapshot); + let child = &projection.shapes[&ShapeId::from("shape:child")]; + let transaction = reconcile_editor_patches( + &snapshot, + request(vec![EditorPatch::Shape { + shape_id: child.id.clone(), + transform: Some(child.transform), + properties: Some(child.props.clone()), + metadata: None, + style: Some(style()), + parent: None, + anchor: None, + }]), + ) + .expect("no-op should reconcile"); + assert!(transaction.operations.is_empty()); + } + + #[test] + fn projection_preserves_bindings_and_order() { + let mut snapshot = nested_snapshot(); + let binding = BindingRecord { + id: BindingId::from("binding:one"), + kind: crate::BindingKind::from("arrow-end"), + source_shape_id: ShapeId::from("shape:child"), + target_shape_id: ShapeId::from("shape:child"), + source_handle: "end".into(), + anchor: BindingAnchor::Center, + version: RecordVersion(1), + }; + snapshot.document.bindings.insert(binding.id.clone(), binding); + let projection = project_editor(&snapshot); + assert_eq!(projection.order.page_ids, snapshot.document.page_ids); + assert_eq!(projection.bindings[&BindingId::from("binding:one")].handle, "end"); + } +} diff --git a/crates/inkfinite-core/src/ipc/mod.rs b/crates/inkfinite-core/src/ipc/mod.rs index 53de34b..144429b 100644 --- a/crates/inkfinite-core/src/ipc/mod.rs +++ b/crates/inkfinite-core/src/ipc/mod.rs @@ -667,6 +667,7 @@ pub fn session_protocol_error(error: &SessionError) -> ProtocolError { SessionError::SessionSelectionRequired { open_sessions: 0 } => "app_session_unavailable", SessionError::SessionSelectionRequired { .. } => "session_selection_required", SessionError::InvalidContext(_) => "invalid_context", + SessionError::EditorReconciliation(_) => "editor_reconciliation_error", SessionError::Render(_) => "render_error", SessionError::ActorMismatch { .. } => "actor_mismatch", SessionError::StaleHeads => "stale_heads", diff --git a/crates/inkfinite-core/src/lib.rs b/crates/inkfinite-core/src/lib.rs index 30838a0..901a414 100644 --- a/crates/inkfinite-core/src/lib.rs +++ b/crates/inkfinite-core/src/lib.rs @@ -3,6 +3,7 @@ #![forbid(unsafe_code)] pub mod crdt; +pub mod editor; pub mod engine; pub mod file; pub mod ipc; diff --git a/crates/inkfinite-core/src/session.rs b/crates/inkfinite-core/src/session.rs index acb056e..55a45c7 100644 --- a/crates/inkfinite-core/src/session.rs +++ b/crates/inkfinite-core/src/session.rs @@ -13,6 +13,9 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use thiserror::Error; +use crate::editor::{ + EditorPatch, EditorProjection, EditorReconciliationRequest, project_editor, reconcile_editor_patches, +}; use crate::engine::geometry::world_shape_bounds; use crate::engine::{EngineError, SyncApplyResult, validate_document}; use crate::file::{DocumentFile, FileError}; @@ -69,6 +72,8 @@ pub struct SessionStatus { pub agent_access: AgentAccessMode, /// Current materialized CRDT snapshot. pub snapshot: DocumentSnapshot, + /// Rust-owned flat editor projection of the current snapshot. + pub editor_projection: EditorProjection, /// Whether the current heads differ from the last successful save. pub dirty: bool, /// Whether this service instance still owns the advisory lock. @@ -245,6 +250,9 @@ pub enum SessionError { /// The frontend reported editor context that does not belong to the session document. #[error("invalid editor context: {0}")] InvalidContext(String), + /// A semantic editor patch could not be translated to native operations. + #[error("editor reconciliation failed: {0}")] + EditorReconciliation(String), /// Deterministic SVG rendering rejected the requested live projection. #[error(transparent)] Render(#[from] SvgRenderError), @@ -567,6 +575,36 @@ impl SessionService { Ok(SessionCommit { commit, status }) } + /// Reconciles semantic editor changes through the native transaction engine. + /// + /// The editor supplies world-space transforms and field-level changes. Rust + /// converts those changes to parent-relative operations without rebuilding + /// unrelated scene records. + /// + /// # Errors + /// + /// Returns a typed reconciliation, transaction, or persistence error. + pub fn reconcile_editor_patches( + &mut self, session_id: &SessionId, patches: Vec, + ) -> Result { + let session = self.session_mut(session_id)?; + let snapshot = session.file.snapshot()?; + let timestamp = timestamp_now(); + let request = EditorReconciliationRequest { + patches, + actor_id: session.file.actor_id().clone(), + origin: Origin::Human, + transaction_id: TransactionId(format!("transaction:editor:{}", timestamp.0)), + description: "Update editor document".into(), + timestamp, + }; + let transaction = reconcile_editor_patches(&snapshot, request) + .map_err(|error| SessionError::EditorReconciliation(error.to_string()))?; + let commit = session.file.commit(transaction)?; + let status = session.status(session_id)?; + Ok(SessionCommit { commit, status }) + } + /// Parses and commits one SVG into the session's active layer. /// /// The source is parsed before the session changes. The resulting assets, @@ -1172,6 +1210,7 @@ impl DocumentSession { path: DocumentPath(self.file.path().to_string_lossy().into_owned()), actor_id: self.file.actor_id().clone(), agent_access: self.agent_access, + editor_projection: project_editor(&snapshot), snapshot, dirty, lock_held: true, diff --git a/crates/inkfinite-wasm/src/lib.rs b/crates/inkfinite-wasm/src/lib.rs index 1d9f42c..fd7fca6 100644 --- a/crates/inkfinite-wasm/src/lib.rs +++ b/crates/inkfinite-wasm/src/lib.rs @@ -7,10 +7,14 @@ use std::collections::BTreeSet; +use inkfinite_core::editor::{ + EditorProjection, EditorReconciliationError, EditorReconciliationRequest, project_editor as project_native, + reconcile_editor_patches as reconcile_native, +}; use inkfinite_core::proto::Bounds; use inkfinite_core::render::{SvgRenderError, SvgRenderOptions, SvgRenderWarning, render_svg as render_native_svg}; use inkfinite_core::svg_import::{SvgImport, SvgImportError, SvgImportNode, import_svg as parse_svg}; -use inkfinite_core::{AssetId, LayerId, PageId, ShapeId}; +use inkfinite_core::{AssetId, DocumentSnapshot, LayerId, PageId, ShapeId}; use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; @@ -68,6 +72,57 @@ pub struct SvgRenderFailure { pub message: String, } +/// The result envelope exchanged for editor projection. +#[derive(Debug, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum EditorProjectionResponse { + /// The native snapshot projected into editor records. + Success { + /// Flat editor projection with composed world transforms. + projection: EditorProjection, + }, + /// The snapshot could not be decoded. + Error { + /// Projection failure details. + error: EditorProjectionFailure, + }, +} + +/// The result envelope exchanged for editor reconciliation. +#[derive(Debug, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum EditorReconciliationResponse { + /// Semantic patches translated into one native transaction draft. + Success { + /// Validated-shape transaction draft. The document engine performs + /// final optimistic and invariant validation when it is committed. + transaction: inkfinite_core::proto::TransactionDraft, + }, + /// The patches could not be translated. + Error { + /// Reconciliation failure details. + error: EditorReconciliationFailure, + }, +} + +/// A stable editor projection error crossing the WASM boundary. +#[derive(Debug, Serialize)] +pub struct EditorProjectionFailure { + /// Machine-readable failure category. + pub code: &'static str, + /// Human-readable failure detail. + pub message: String, +} + +/// A stable editor reconciliation error crossing the WASM boundary. +#[derive(Debug, Serialize)] +pub struct EditorReconciliationFailure { + /// Machine-readable failure category. + pub code: &'static str, + /// Human-readable failure detail. + pub message: String, +} + /// A render warning with a stable browser-facing code. #[derive(Debug, Serialize)] pub struct SvgRenderWarningResponse { @@ -143,6 +198,57 @@ pub fn render_svg(snapshot_json: &str, options_json: &str) -> String { render_svg_json(snapshot_json, options_json) } +/// Projects a canonical snapshot without requiring a WASM runtime. +#[must_use] +pub fn project_editor_json(snapshot_json: &str) -> String { + let snapshot = match serde_json::from_str::(snapshot_json) { + Ok(snapshot) => snapshot, + Err(error) => { + return serialize_response(EditorProjectionResponse::Error { + error: EditorProjectionFailure { code: "invalid_snapshot", message: error.to_string() }, + }); + } + }; + serialize_response(EditorProjectionResponse::Success { projection: project_native(&snapshot) }) +} + +/// Projects a canonical document snapshot into the flat editor view. +#[wasm_bindgen] +pub fn project_editor(snapshot_json: &str) -> String { + project_editor_json(snapshot_json) +} + +/// Reconciles semantic editor patches without requiring a WASM runtime. +#[must_use] +pub fn reconcile_editor_patches_json(snapshot_json: &str, request_json: &str) -> String { + let snapshot = match serde_json::from_str::(snapshot_json) { + Ok(snapshot) => snapshot, + Err(error) => { + return serialize_response(EditorReconciliationResponse::Error { + error: EditorReconciliationFailure { code: "invalid_snapshot", message: error.to_string() }, + }); + } + }; + let request = match serde_json::from_str::(request_json) { + Ok(request) => request, + Err(error) => { + return serialize_response(EditorReconciliationResponse::Error { + error: EditorReconciliationFailure { code: "invalid_request", message: error.to_string() }, + }); + } + }; + match reconcile_native(&snapshot, request) { + Ok(transaction) => serialize_response(EditorReconciliationResponse::Success { transaction }), + Err(error) => serialize_response(EditorReconciliationResponse::Error { error: reconciliation_failure(&error) }), + } +} + +/// Reconciles semantic editor patches into one native transaction draft. +#[wasm_bindgen] +pub fn reconcile_editor_patches(snapshot_json: &str, request_json: &str) -> String { + reconcile_editor_patches_json(snapshot_json, request_json) +} + /// Renders a canonical document snapshot without requiring a WASM runtime. #[must_use] pub fn render_svg_json(snapshot_json: &str, options_json: &str) -> String { @@ -182,6 +288,19 @@ fn serialize_response(response: T) -> String { }) } +fn reconciliation_failure(error: &EditorReconciliationError) -> EditorReconciliationFailure { + let code = match error { + EditorReconciliationError::UnknownShape(_) => "unknown_shape", + EditorReconciliationError::UnknownPage(_) => "unknown_page", + EditorReconciliationError::UnknownLayer(_) => "unknown_layer", + EditorReconciliationError::UnknownParent(_) => "unknown_parent", + EditorReconciliationError::UnknownBinding(_) => "unknown_binding", + EditorReconciliationError::SingularParent { .. } => "singular_parent", + EditorReconciliationError::UnsupportedShear { .. } => "unsupported_shear", + }; + EditorReconciliationFailure { code, message: error.to_string() } +} + fn render_failure(error: &SvgRenderError) -> SvgRenderFailure { let code = match error { SvgRenderError::PageNotFound { .. } => "page_not_found", @@ -342,4 +461,53 @@ mod tests { assert_eq!(response["status"], "error"); assert_eq!(response["error"]["code"], "invalid_snapshot"); } + + #[test] + fn projects_and_reconciles_editor_changes() { + let snapshot = serde_json::json!({ + "format": "inkfinite.document", + "format_version": 2, + "document_id": "document:wasm-editor", + "heads": ["head:one"], + "document": { + "pages": {"page:one": {"id": "page:one", "name": "Page 1", "layer_ids": ["layer:one"], "version": 1}}, + "page_ids": ["page:one"], + "layers": {"layer:one": {"id": "layer:one", "page_id": "page:one", "name": "Default", "shape_ids": ["shape:rect"], "visible": true, "locked": false, "opacity": 1, "version": 1}}, + "shapes": {"shape:rect": { + "id": "shape:rect", "kind": "rect", "parent": {"kind": "layer", "id": "layer:one"}, + "transform": {"translation": {"x": 10, "y": 20}, "rotation": 0, "scale_x": 1, "scale_y": 1}, + "child_ids": [], "layout": null, "properties": {"width": 40, "height": 20}, + "metadata": {"name": null, "role": null, "description": null, "tags": [], "locked": false, "agent_editable": true, + "provenance": {"actor_id": "browser", "origin": "human", "timestamp": 0, "source": null}}, + "style": {"opacity": 1, "fill_opacity": null, "stroke_opacity": null}, "version": 1 + }}, + "bindings": {}, "assets": {} + } + }); + let projection: Value = + serde_json::from_str(&project_editor_json(&snapshot.to_string())).expect("projection JSON"); + assert_eq!(projection["status"], "success"); + assert_eq!(projection["projection"]["shapes"]["shape:rect"]["x"], 10.0); + + let request = serde_json::json!({ + "patches": [{ + "type": "shape", "shape_id": "shape:rect", + "transform": {"a": 1, "b": 0, "c": 0, "d": 1, "e": 15, "f": 20}, + "properties": null, "metadata": null, "style": null, "parent": null, "anchor": null + }], + "actor_id": "browser", "origin": "human", "transaction_id": "transaction:editor", + "description": "Move rectangle", "timestamp": 1 + }); + let reconciled: Value = serde_json::from_str(&reconcile_editor_patches_json( + &snapshot.to_string(), + &request.to_string(), + )) + .expect("reconciliation JSON"); + assert_eq!(reconciled["status"], "success"); + assert_eq!( + reconciled["transaction"]["operations"].as_array().map(Vec::len), + Some(1) + ); + assert_eq!(reconciled["transaction"]["base_heads"][0], "head:one"); + } } diff --git a/packages/bindings/package.json b/packages/bindings/package.json index c093a34..253dea7 100644 --- a/packages/bindings/package.json +++ b/packages/bindings/package.json @@ -6,6 +6,8 @@ "exports": { ".": "./dist/index.js", "./model": "./src/model.ts", + "./editor": "./src/editor.ts", + "./transaction": "./src/transaction.ts", "./svg-import": "./src/svg-import.ts" }, "types": "./dist/index.d.ts", diff --git a/packages/bindings/src/editor.ts b/packages/bindings/src/editor.ts new file mode 100644 index 0000000..2df8a5a --- /dev/null +++ b/packages/bindings/src/editor.ts @@ -0,0 +1,422 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// Generated by Inkfinite's binding generator. Run `cargo run -p inkfinite-cli --bin generate-bindings`. + +import type { + ActorId, + BindingAnchor, + BindingId, + BindingKind, + BindingRecord, + ContainerLayout, + LayerId, + Opacity, + Origin, + PageId, + SemanticMetadata, + ShapeId, + ShapeKind, + ShapeParent, + ShapeProperties, + ShapeStyle, + SiblingAnchor, + Timestamp +} from './model.js'; +import type { LayerPatch, TransactionId } from './transaction.js'; + +/** + * Full affine transform used by the editor projection. + * + * Unlike the canonical [`Transform`], this representation can retain the + * result of composing ancestor transforms even when the composition includes + * non-uniform scale and rotation. + */ +export type EditorTransform = { + /** + * Horizontal scale and rotation component. + */ + a: number; + /** + * Vertical shear and rotation component. + */ + b: number; + /** + * Horizontal shear and rotation component. + */ + c: number; + /** + * Vertical scale and rotation component. + */ + d: number; + /** + * Horizontal translation. + */ + e: number; + /** + * Vertical translation. + */ + f: number; +}; + +/** + * One drawable shape projected into the editor's flat shape collection. + */ +export type EditorShape = { + /** + * Stable shape identifier. + */ + id: ShapeId; + /** + * Editor registry key. + */ + type: ShapeKind; + /** + * Page containing the shape. + */ + page_id: PageId; + /** + * Complete native-to-world transform. + */ + transform: EditorTransform; + /** + * Legacy translation fields used by the current editor interaction model. + */ + x: number; + /** + * Legacy translation field used by the current editor interaction model. + */ + y: number; + /** + * Legacy rotation field used by the current editor interaction model. + */ + rot: number; + /** + * Immediate container parent, when the shape is inside a container. + */ + group_id: ShapeId | null; + /** + * Owning editor layer. + */ + layer_id: LayerId; + /** + * Complete-shape opacity. + */ + opacity: Opacity; + /** + * Optional fill opacity. + */ + fill_opacity: Opacity | null; + /** + * Optional stroke opacity. + */ + stroke_opacity: Opacity | null; + /** + * Agent editability retained for editor policy surfaces. + */ + agent_editable: boolean; + /** + * Kind-specific properties using editor property names. + */ + props: ShapeProperties; +}; + +/** + * A new shape supplied by an editor patch. + */ +export type EditorShapeDraft = { + /** + * Stable shape identifier. + */ + id: ShapeId; + /** + * Native registry key. + */ + kind: ShapeKind; + /** + * Kind-specific properties using editor property names. + */ + properties: ShapeProperties; + /** + * Optional semantic metadata. Missing metadata receives editor defaults. + */ + metadata: SemanticMetadata | null; + /** + * Common visual style. + */ + style: ShapeStyle; + /** + * Optional container layout. + */ + layout: ContainerLayout | null; +}; + +/** + * Page represented in the flat editor document. + */ +export type EditorPage = { + /** + * Stable page identifier. + */ + id: PageId; + /** + * User-visible page name. + */ + name: string; + /** + * Drawable shape IDs in depth-first draw order. + */ + shape_ids: Array; + /** + * Layer IDs in back-to-front order. + */ + layer_ids: Array; +}; + +/** + * Layer represented in the flat editor document. + */ +export type EditorLayer = { + /** + * Stable layer identifier. + */ + id: LayerId; + /** + * Owning page identifier. + */ + page_id: PageId; + /** + * User-visible layer name. + */ + name: string; + /** + * Drawable shape IDs in depth-first draw order. + */ + shape_ids: Array; + /** + * Whether the layer participates in rendering. + */ + visible: boolean; + /** + * Whether the layer can be selected or changed. + */ + locked: boolean; + /** + * Inherited layer opacity. + */ + opacity: Opacity; +}; + +/** + * Binding represented in the editor's binding collection. + */ +export type EditorBinding = { + /** + * Stable binding identifier. + */ + id: BindingId; + /** + * Editor binding kind. + */ + type: BindingKind; + /** + * Source arrow or connector. + */ + from_shape_id: ShapeId; + /** + * Target shape. + */ + to_shape_id: ShapeId; + /** + * Source handle. + */ + handle: string; + /** + * Target anchor. + */ + anchor: BindingAnchor; +}; + +/** + * Ordering information accompanying an editor projection. + */ +export type EditorOrder = { + /** + * Page IDs in document order. + */ + page_ids: Array; + /** + * Flattened drawable shape order by page. + */ + shape_order: { [key in PageId]: Array }; + /** + * Layer records in their projected form. + */ + layers: { [key in LayerId]: EditorLayer }; +}; + +/** + * Native document projected into the editor's flat document shape. + */ +export type EditorProjection = { + /** + * Projected pages. + */ + pages: { [key in PageId]: EditorPage }; + /** + * Projected layers. + */ + layers: { [key in LayerId]: EditorLayer }; + /** + * Drawable shapes with composed world transforms. + */ + shapes: { [key in ShapeId]: EditorShape }; + /** + * Projected bindings. + */ + bindings: { [key in BindingId]: EditorBinding }; + /** + * Stable ordering metadata. + */ + order: EditorOrder; +}; + +/** + * Semantic editor changes that can be reconciled into native operations. + * + * Shape transforms are world-space transforms from [`EditorShape::transform`]. + * The reconciler converts them back to parent-relative canonical transforms. + */ +export type EditorPatch = + | { + type: 'shape'; + /** + * Shape to change. + */ + shape_id: ShapeId; + /** + * Replacement world-space transform. + */ + transform: EditorTransform | null; + /** + * Replacement editor properties. + */ + properties: ShapeProperties | null; + /** + * Replacement semantic metadata. + */ + metadata: SemanticMetadata | null; + /** + * Replacement visual style. + */ + style: ShapeStyle | null; + /** + * Replacement parent, when reparenting is part of the edit. + */ + parent: ShapeParent | null; + /** + * Replacement sibling placement. + */ + anchor: SiblingAnchor | null; + } + | { + type: 'create_shape'; + /** + * New shape fields. + */ + shape: EditorShapeDraft; + /** + * Parent in the native hierarchy. + */ + parent: ShapeParent; + /** + * World-space transform for the new shape. + */ + transform: EditorTransform; + /** + * Sibling placement. + */ + anchor: SiblingAnchor; + } + | { + type: 'delete_shape'; + /** + * Shape to delete. + */ + shape_id: ShapeId; + } + | { + type: 'rename_page'; + /** + * Page to rename. + */ + page_id: PageId; + /** + * Replacement page name. + */ + name: string; + } + | { + type: 'patch_layer'; + /** + * Layer to change. + */ + layer_id: LayerId; + /** + * Replacement fields. + */ + patch: LayerPatch; + } + | { + type: 'reorder_layer'; + /** + * Layer to move. + */ + layer_id: LayerId; + /** + * Replacement placement. + */ + anchor: SiblingAnchor; + } + | { + type: 'create_binding'; + /** + * Binding record to create. + */ + binding: BindingRecord; + } + | { + type: 'delete_binding'; + /** + * Binding to delete. + */ + binding_id: BindingId; + }; + +/** + * Request metadata used to turn editor patches into one transaction draft. + */ +export type EditorReconciliationRequest = { + /** + * Ordered semantic editor changes. + */ + patches: Array; + /** + * Actor committing the resulting transaction. + */ + actor_id: ActorId; + /** + * Origin retained in transaction history. + */ + origin: Origin; + /** + * Stable transaction identifier. + */ + transaction_id: TransactionId; + /** + * Human-readable history description. + */ + description: string; + /** + * Client timestamp. + */ + timestamp: Timestamp; +}; diff --git a/packages/bindings/src/index.ts b/packages/bindings/src/index.ts index d2a49dc..afbceb6 100644 --- a/packages/bindings/src/index.ts +++ b/packages/bindings/src/index.ts @@ -5,4 +5,5 @@ export * from './model.js'; export * from './protocol.js'; export * from './registry.js'; export * from './svg-import.js'; +export * from './editor.js'; export * from './transaction.js'; diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 4c9093e..6c9b79d 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -170,6 +170,10 @@ 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' | 'path' | 'markdown'; + +/** Full world transform retained when a native projection contains scale or shear. */ +export type EditorTransform = { a: number; b: number; c: number; d: number; e: number; f: number }; + export type BaseShape = { id: string; type: ShapeType; @@ -177,6 +181,8 @@ export type BaseShape = { x: number; y: number; rot: number; + /** Full projected transform used to preserve native ancestor composition. */ + editorTransform?: EditorTransform; /** Opacity applied to the complete shape; omitted values use `1`. */ opacity?: number; /** Opacity applied only to fills; omitted values use `1`. */ diff --git a/packages/ui/src/lib/editor/components/Toolbar.svelte.test.ts b/packages/ui/src/lib/editor/components/Toolbar.svelte.test.ts index 5574f7f..5978c45 100644 --- a/packages/ui/src/lib/editor/components/Toolbar.svelte.test.ts +++ b/packages/ui/src/lib/editor/components/Toolbar.svelte.test.ts @@ -17,7 +17,9 @@ describe('Editor Toolbar', () => { await screen.getByRole('button', { name: 'Rectangle' }).click(); expect(onToolChange).toHaveBeenCalledWith('rect'); - await expect.element(screen.getByRole('button', { name: 'Zoom level' })).not.toBeInTheDocument(); + await expect + .element(screen.getByRole('button', { name: 'Zoom level' })) + .not.toBeInTheDocument(); }); it('keeps drawing tools in one row when the window narrows', async () => { @@ -43,7 +45,9 @@ describe('Editor Toolbar', () => { const toolbar = screen.getByRole('toolbar', { name: 'Drawing tools' }).element(); const handle = screen.getByRole('button', { name: 'Drag toolbar' }).element(); - handle.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', shiftKey: true, bubbles: true })); + handle.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowLeft', shiftKey: true, bubbles: true }) + ); await vi.waitFor(() => expect(toolbar.style.left).toBe('8px')); }); @@ -65,21 +69,37 @@ describe('Editor Toolbar', () => { }); (screen.getByRole('button', { name: 'Import' }).element() as HTMLButtonElement).click(); - await expect.element(screen.getByRole('menuitem', { name: 'Editable document' })).toBeInTheDocument(); - (screen.getByRole('menuitem', { name: 'Editable document' }).element() as HTMLButtonElement).click(); + await expect + .element(screen.getByRole('menuitem', { name: 'Editable document' })) + .toBeInTheDocument(); + ( + screen + .getByRole('menuitem', { name: 'Editable document' }) + .element() as HTMLButtonElement + ).click(); expect(onImportEditable).toHaveBeenCalledOnce(); (screen.getByRole('button', { name: 'Import' }).element() as HTMLButtonElement).click(); - (screen.getByRole('menuitem', { name: 'SVG file' }).element() as HTMLButtonElement).click(); + ( + screen.getByRole('menuitem', { name: 'SVG file' }).element() as HTMLButtonElement + ).click(); expect(onImportSvg).toHaveBeenCalledOnce(); (screen.getByRole('button', { name: 'Import' }).element() as HTMLButtonElement).click(); - (screen.getByRole('menuitem', { name: 'SVG code / markup' }).element() as HTMLButtonElement).click(); + ( + screen + .getByRole('menuitem', { name: 'SVG code / markup' }) + .element() as HTMLButtonElement + ).click(); expect(onImportSvgMarkup).toHaveBeenCalledOnce(); - (screen.getByRole('button', { name: 'Export drawing' }).element() as HTMLButtonElement).click(); + ( + screen.getByRole('button', { name: 'Export drawing' }).element() as HTMLButtonElement + ).click(); await expect - .element(screen.getByRole('menuitem', { name: 'Export as Excalidraw editable document' })) + .element( + screen.getByRole('menuitem', { name: 'Export as Excalidraw editable document' }) + ) .toBeInTheDocument(); ( screen @@ -88,9 +108,15 @@ describe('Editor Toolbar', () => { ).click(); expect(onExportEditable).toHaveBeenCalledWith('excalidraw'); - (screen.getByRole('button', { name: 'Export drawing' }).element() as HTMLButtonElement).click(); + ( + screen.getByRole('button', { name: 'Export drawing' }).element() as HTMLButtonElement + ).click(); await expect - .element(screen.getByRole('menuitem', { name: 'Export as Obsidian Canvas editable document' })) + .element( + screen.getByRole('menuitem', { + name: 'Export as Obsidian Canvas editable document' + }) + ) .toBeInTheDocument(); ( screen @@ -108,8 +134,14 @@ describe('Editor Toolbar', () => { brushStore: createBrushStore() }); - const penBounds = screen.getByRole('button', { name: 'Pen' }).element().getBoundingClientRect(); - const brushBounds = screen.getByRole('button', { name: 'Brush settings' }).element().getBoundingClientRect(); + const penBounds = screen + .getByRole('button', { name: 'Pen' }) + .element() + .getBoundingClientRect(); + const brushBounds = screen + .getByRole('button', { name: 'Brush settings' }) + .element() + .getBoundingClientRect(); expect(brushBounds.top).toBeGreaterThan(penBounds.bottom); expect(brushBounds.right).toBeCloseTo(penBounds.right, 0); @@ -141,10 +173,14 @@ describe('Editor Toolbar', () => { brushStore: createBrushStore() }); - const fill = screen.getByRole('slider', { name: 'Fill opacity' }).element() as HTMLInputElement; + const fill = screen + .getByRole('slider', { name: 'Fill opacity' }) + .element() as HTMLInputElement; fill.value = '0.4'; fill.dispatchEvent(new Event('change', { bubbles: true })); - const stroke = screen.getByRole('slider', { name: 'Stroke opacity' }).element() as HTMLInputElement; + const stroke = screen + .getByRole('slider', { name: 'Stroke opacity' }) + .element() as HTMLInputElement; stroke.value = '0.65'; stroke.dispatchEvent(new Event('change', { bubbles: true })); diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts index 1b153e9..d4a51d7 100644 --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -1,7 +1,16 @@ import type { DocumentSnapshot } from '@inkfinite/bindings/model'; +import type { EditorProjection, EditorReconciliationRequest } from '@inkfinite/bindings/editor'; +import type { TransactionDraft } from '@inkfinite/bindings/transaction'; import type { SvgImport } from '@inkfinite/bindings/svg-import'; export type { DocumentSnapshot } from '@inkfinite/bindings/model'; +export type { + EditorPatch, + EditorProjection, + EditorReconciliationRequest, + EditorTransform +} from '@inkfinite/bindings/editor'; +export type { TransactionDraft } from '@inkfinite/bindings/transaction'; export type { SvgImport, SvgImportWarning } from '@inkfinite/bindings/svg-import'; /** Structured error returned when Rust rejects an SVG. */ @@ -33,9 +42,21 @@ export type SvgRenderResponse = | { status: 'success'; svg: string; warnings: SvgRenderWarning[] } | { status: 'error'; error: SvgRenderFailure }; +/** Response envelope returned by Rust editor projection. */ +export type EditorProjectionResponse = + | { status: 'success'; projection: EditorProjection } + | { status: 'error'; error: { code: string; message: string } }; + +/** Response envelope returned by Rust editor reconciliation. */ +export type EditorReconciliationResponse = + | { status: 'success'; transaction: TransactionDraft } + | { status: 'error'; error: { code: string; message: string } }; + type GeneratedWasmModule = { default(input?: unknown): Promise; import_svg(source: Uint8Array): string; + project_editor(snapshotJson: string): string; + reconcile_editor_patches(snapshotJson: string, requestJson: string): string; render_svg(snapshotJson: string, optionsJson: string): string; }; @@ -48,6 +69,27 @@ export async function importSvg(source: Uint8Array): Promise return response; } +/** Projects a canonical document snapshot into the shared flat editor view. */ +export async function projectEditor(snapshot: DocumentSnapshot): Promise { + const module = await loadModule(); + const response = JSON.parse(module.project_editor(JSON.stringify(snapshot))) as EditorProjectionResponse; + if (response.status === 'error') throw new Error(response.error.message); + return response.projection; +} + +/** Reconciles semantic editor changes into one native transaction draft. */ +export async function reconcileEditorPatches( + snapshot: DocumentSnapshot, + request: EditorReconciliationRequest +): Promise { + const module = await loadModule(); + const response = JSON.parse( + module.reconcile_editor_patches(JSON.stringify(snapshot), JSON.stringify(request)) + ) as EditorReconciliationResponse; + if (response.status === 'error') throw new Error(response.error.message); + return response.transaction; +} + /** Renders a canonical document snapshot through the Rust SVG renderer. */ export async function renderSvg( snapshot: DocumentSnapshot,