From 3a43bc311a1d2b381c88a3594b889fdc13b2d568 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Thu, 20 Aug 2026 14:30:08 -0500 Subject: [PATCH] feat: add browser WASM document sessions --- CHANGELOG.md | 2 + TODO.md | 16 +- apps/web/src/dexie-repository.test.ts | 49 +- apps/web/src/lib/persistence/database.ts | 4 +- apps/web/src/lib/persistence/dexie.ts | 146 +++++- apps/web/src/lib/persistence/repository.ts | 60 ++- apps/web/src/lib/persistence/svg-import.ts | 66 ++- .../src/lib/persistence/svg-import.worker.ts | 62 ++- .../src/bin/generate-bindings.rs | 8 +- crates/inkfinite-core/src/editor.rs | 51 +- crates/inkfinite-wasm/src/lib.rs | 281 ++++++++++- packages/bindings/src/editor.ts | 44 +- packages/bindings/src/transaction.ts | 5 +- packages/core/src/persistence/canonical.ts | 437 +++++++++++++++++- packages/core/src/persistence/document.ts | 25 +- packages/core/tests/canonical.test.ts | 33 +- .../lib/editor/canvas/canvas-store.svelte.ts | 51 +- packages/ui/src/lib/editor/platform.ts | 2 + packages/wasm/src/index.ts | 123 ++++- 19 files changed, 1388 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c688705..d0b2a82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ 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. +- Stateful browser WASM document sessions that open, validate, mutate, undo, redo, and save + canonical Automerge bytes through one worker. #### SVG Interop diff --git a/TODO.md b/TODO.md index def529a..7716c1b 100644 --- a/TODO.md +++ b/TODO.md @@ -79,14 +79,14 @@ Inkfinite now imports SVGs through one validated Rust pipeline across desktop, w ### Browser document engine -- [ ] Add a stateful WASM document session -- [ ] Open and save canonical Automerge document bytes -- [ ] Apply validated Rust transactions in the browser -- [ ] Use Rust undo and redo for durable changes -- [ ] Persist canonical document state to IndexedDB -- [ ] Migrate existing browser documents to canonical state -- [ ] Retire the Dexie shape graph as the browser source of truth -- [ ] Keep ephemeral editor history in TypeScript +- [x] Add a stateful WASM document session +- [x] Open and save canonical Automerge document bytes +- [x] Apply validated Rust transactions in the browser +- [x] Use Rust undo and redo for durable changes +- [x] Persist canonical document state to IndexedDB +- [x] Migrate existing browser documents to canonical state +- [x] Retire the Dexie shape graph as the browser source of truth +- [x] Keep ephemeral editor history in TypeScript ### Canonical geometry diff --git a/apps/web/src/dexie-repository.test.ts b/apps/web/src/dexie-repository.test.ts index cdf99d5..b797dfa 100644 --- a/apps/web/src/dexie-repository.test.ts +++ b/apps/web/src/dexie-repository.test.ts @@ -8,7 +8,8 @@ import { PageRecord, SetSelectionCommand, ShapeRecord, - Store + Store, + type CanonicalDocumentState } from '@inkfinite/core'; import Dexie from 'dexie'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -130,6 +131,52 @@ describe('DocRepo (Dexie)', () => { expect(await database.table('bindings').toArray()).toHaveLength(0); }); + it('canonical state replaces legacy graph rows and round-trips through loadDoc', async () => { + const database = createTestDb(); + const repo = createDexieDocRepo(database); + const boardId = await repo.createBoard('Canonical'); + const snapshot = { + format: 'inkfinite.document', + format_version: 2, + document_id: boardId, + heads: [], + document: { + pages: { + 'page:canonical': { + id: 'page:canonical', + name: 'Canonical page', + layer_ids: ['layer:canonical'], + version: 1 + } + }, + page_ids: ['page:canonical'], + layers: { + 'layer:canonical': { + id: 'layer:canonical', + page_id: 'page:canonical', + name: 'Default', + shape_ids: [], + visible: true, + locked: false, + opacity: 1, + version: 1 + } + }, + shapes: {}, + bindings: {}, + assets: {} + } + } satisfies CanonicalDocumentState['snapshot']; + + await repo.saveCanonical?.(boardId, { bytes: new Uint8Array([1, 2, 3]), snapshot }); + const loaded = await repo.loadDoc(boardId); + + expect(loaded.pages['page:canonical']?.name).toBe('Canonical page'); + expect(await database.pages.count()).toBe(0); + expect(await database.canonical.get(boardId)).toMatchObject({ boardId }); + expect([...((await repo.loadCanonical?.(boardId))?.bytes ?? [])]).toEqual([1, 2, 3]); + }); + it('exportBoard + importBoard round-trip doc + metadata', async () => { const db = createTestDb(); const repo = createDexieDocRepo(db); diff --git a/apps/web/src/lib/persistence/database.ts b/apps/web/src/lib/persistence/database.ts index 8841d8e..86dab4e 100644 --- a/apps/web/src/lib/persistence/database.ts +++ b/apps/web/src/lib/persistence/database.ts @@ -1,6 +1,6 @@ import type { BoardMeta } from '@inkfinite/core'; import Dexie from 'dexie'; -import type { BindingRow, MetaRow, PageRow, ShapeRow } from './repository'; +import type { BindingRow, CanonicalRow, MetaRow, PageRow, ShapeRow } from './repository'; /** Default IndexedDB database name for the static web application. */ export const DB_NAME = 'inkfinite'; @@ -14,6 +14,7 @@ export class InkfiniteDB extends Dexie { shapes!: Dexie.Table; bindings!: Dexie.Table; meta!: Dexie.Table; + canonical!: Dexie.Table; constructor(name = DB_NAME) { super(name); @@ -25,5 +26,6 @@ export class InkfiniteDB extends Dexie { bindings: '[boardId+id], boardId, type, updatedAt', meta: 'key' }); + this.version(3).stores({ canonical: 'boardId, updatedAt' }); } } diff --git a/apps/web/src/lib/persistence/dexie.ts b/apps/web/src/lib/persistence/dexie.ts index c1bb120..8828136 100644 --- a/apps/web/src/lib/persistence/dexie.ts +++ b/apps/web/src/lib/persistence/dexie.ts @@ -1,20 +1,27 @@ import { - toCanonicalDocumentSnapshot, - type BoardExport, - type DocPatch, - type InterchangeExport, - type PersistenceSink, - type PersistentDocRepo, - type SvgExport, - type SvgExportOptions + createEditorReconciliationRequest, + diffDoc, + toCanonicalDocumentSnapshot +} from '@inkfinite/core'; +import type { + BoardExport, + DocPatch, + EditorDocumentChange, + InterchangeExport, + PersistenceSink, + PersistentDocRepo, + SvgExport, + SvgExportOptions } from '@inkfinite/core'; import { createStatusStore } from '@inkfinite/ui/editor'; import type { EditorPlatformAdapter, EditorPlatformSession } from '@inkfinite/ui/editor'; import { liveQuery } from 'dexie'; import { InkfiniteDB } from './database'; import { createDexieDocRepo, createPersistenceSink, getBoardInspectorData } from './repository'; -import { importSvgInWorker, renderSvgInWorker } from './svg-import'; +import { getSharedSvgImportWorker, importSvgInWorker, renderSvgInWorker } from './svg-import'; +import type { BrowserDocumentState } from './svg-import'; import type { PersistenceSinkOptions } from './repository'; +import type { LoadedDoc } from '@inkfinite/core'; type LiveQueryFactory = typeof liveQuery; @@ -48,6 +55,15 @@ export function createDexieSession( const liveQueryFactory = opts.liveQueryFn ?? liveQuery; let activeBoardId: string | null = null; let subscription: { unsubscribe(): void } | null = null; + let documentWorker: ReturnType | null = null; + let documentBoardId: string | null = null; + let documentReady: Promise | null = null; + let documentQueue: Promise = Promise.resolve(); + let transactionNumber = 0; + const canonicalEnabled = + typeof Worker !== 'undefined' && + typeof repo.loadCanonical === 'function' && + typeof repo.saveCanonical === 'function'; function markSaved(timestamp?: number) { status.update((current) => ({ @@ -67,6 +83,99 @@ export function createDexieSession( })); } + function ensureDocumentWorker() { + if (!canonicalEnabled) return null; + documentWorker ??= getSharedSvgImportWorker(); + return documentWorker; + } + + function loadedDocument(doc: LoadedDoc) { + return { + pages: doc.pages, + ...(doc.layers ? { layers: doc.layers } : {}), + ...(doc.assets ? { assets: doc.assets } : {}), + ...(doc.svgGroups ? { svgGroups: doc.svgGroups } : {}), + shapes: doc.shapes, + bindings: doc.bindings + }; + } + + function openCanonicalDocument(boardId: string, doc: LoadedDoc): Promise { + const worker = ensureDocumentWorker(); + if (!worker || !repo.loadCanonical || !repo.saveCanonical) return Promise.resolve(); + documentBoardId = boardId; + return (async () => { + const canonical = await repo.loadCanonical!(boardId); + const state = canonical + ? await worker.openDocument(canonical.bytes, 'browser') + : await worker.createDocument( + toCanonicalDocumentSnapshot(loadedDocument(doc), { documentId: boardId }), + 'browser' + ); + if (!canonical) await repo.saveCanonical!(boardId, state); + })(); + } + + function setActiveDocument(boardId: string, doc: LoadedDoc) { + if (!canonicalEnabled) return; + if (documentBoardId === boardId && documentReady) return; + documentReady = openCanonicalDocument(boardId, doc).catch((error) => { + documentBoardId = null; + throw error; + }); + } + + function enqueueEditorChange(change: EditorDocumentChange) { + if (!canonicalEnabled || !repo.saveCanonical) { + sink.enqueueDocPatch(change.boardId, diffDoc(change.before, change.after)); + return; + } + status.update((current) => ({ + ...current, + pendingWrites: (current.pendingWrites ?? 0) + 1, + state: 'saving', + errorMsg: undefined + })); + const run = async () => { + setActiveDocument(change.boardId, { + pages: change.before.pages, + layers: change.before.layers, + assets: change.before.assets, + svgGroups: change.before.svgGroups, + shapes: change.before.shapes, + bindings: change.before.bindings, + order: { pageIds: Object.keys(change.before.pages), layers: change.before.layers } + }); + await documentReady; + const worker = ensureDocumentWorker(); + if (!worker) throw new Error('The browser document worker is unavailable.'); + let state; + if (change.op === 'undo') { + state = await worker.undoDocument(); + } else if (change.op === 'redo') { + state = await worker.redoDocument(); + } else { + const request = createEditorReconciliationRequest(change.before, change.after, { + actor_id: 'browser', + origin: 'human', + transaction_id: `transaction:browser:${++transactionNumber}`, + description: change.description, + timestamp: Date.now() + }); + if (request.patches.length === 0) return; + state = await worker.applyEditorPatches(request); + } + await repo.saveCanonical!(change.boardId, state); + }; + documentQueue = documentQueue + .catch(() => {}) + .then(run) + .catch((error) => { + markError(error); + throw error; + }); + } + const trackedSink: PersistenceSink = { enqueueDocPatch(boardId, patch) { if (hasPatchChanges(patch)) { @@ -79,8 +188,11 @@ export function createDexieSession( } sink.enqueueDocPatch(boardId, patch); }, + enqueueEditorChange, async flush() { try { + await documentReady; + await documentQueue; await sink.flush(); } catch (error) { markError(error); @@ -93,7 +205,12 @@ export function createDexieSession( repo, sink: trackedSink, status, - interchange: createBrowserInterchangeFiles(), + interchange: createBrowserInterchangeFiles(async (boardId) => { + if (!documentWorker || documentBoardId !== boardId) return null; + await documentReady; + await documentQueue; + return (await documentWorker.documentState()).snapshot; + }), inspectBoard: (boardId) => getBoardInspectorData(database, boardId), setActiveBoard(boardId) { if (activeBoardId === boardId) return; @@ -109,6 +226,7 @@ export function createDexieSession( error: markError }); }, + setActiveDocument, dispose() { subscription?.unsubscribe(); subscription = null; @@ -117,7 +235,9 @@ export function createDexieSession( } /** Creates browser-backed file selection and download operations. */ -export function createBrowserInterchangeFiles() { +export function createBrowserInterchangeFiles( + getCanonicalSnapshot?: (boardId: string) => Promise +) { function pickTextFile(accept: string): Promise<{ name: string; contents: string } | null> { return new Promise((resolve, reject) => { const input = document.createElement('input'); @@ -210,7 +330,9 @@ export function createBrowserInterchangeFiles() { snapshot: BoardExport, options: SvgExportOptions = {} ): Promise { - const canonical = toCanonicalDocumentSnapshot(snapshot); + const canonical = + (await getCanonicalSnapshot?.(snapshot.board.id)) ?? + toCanonicalDocumentSnapshot(snapshot); if (options.selectionOnly && !(options.selectionIds?.length ?? 0)) { canonical.document = { ...canonical.document, diff --git a/apps/web/src/lib/persistence/repository.ts b/apps/web/src/lib/persistence/repository.ts index 6ef2de6..c87aad9 100644 --- a/apps/web/src/lib/persistence/repository.ts +++ b/apps/web/src/lib/persistence/repository.ts @@ -2,6 +2,7 @@ import { BindingRecord as BindingOps, BoardStatsOps, createId, + fromCanonicalDocumentSnapshot, LayerRecord as LayerOps, PageRecord as PageOps, ShapeRecord as ShapeOps @@ -12,6 +13,7 @@ import type { BoardInspectorData, BoardMeta, BoardStats, + CanonicalDocumentState, DocOrder, DocPatch, Document, @@ -37,6 +39,14 @@ export type ShapeRow = ShapeRecord & { boardId: string; updatedAt: Timestamp }; /** IndexedDB row for a binding scoped to its board. */ export type BindingRow = BindingRecord & { boardId: string; updatedAt: Timestamp }; +/** Canonical Rust document bytes and its derived materialized cache. */ +export type CanonicalRow = { + boardId: string; + bytes: Uint8Array; + snapshot: CanonicalDocumentState['snapshot']; + updatedAt: Timestamp; +}; + /** Key-value metadata stored alongside document rows. */ export type MetaRow = { key: string; value: unknown }; @@ -79,6 +89,7 @@ export function createDexieDocRepo( const shapes = () => database.table('shapes'); const bindings = () => database.table('bindings'); const meta = () => database.table('meta'); + const canonical = () => database.table('canonical'); async function listBoards(): Promise { return boards().orderBy('updatedAt').reverse().toArray(); @@ -115,7 +126,7 @@ export function createDexieDocRepo( async function deleteBoard(boardId: string): Promise { await database.transaction( 'rw', - [boards(), pages(), shapes(), bindings(), meta()], + [boards(), pages(), shapes(), bindings(), meta(), canonical()], async () => { const pageKeys = (await pages().where('boardId').equals(boardId).toArray()).map( (row) => [row.boardId, row.id] as [string, string] @@ -136,11 +147,53 @@ export function createDexieDocRepo( await meta().delete(layersKey(boardId)); await meta().delete(assetsKey(boardId)); await meta().delete(svgGroupsKey(boardId)); + await canonical().delete(boardId); + } + ); + } + + async function loadCanonical(boardId: string) { + const row = await canonical().get(boardId); + return row ? { bytes: new Uint8Array(row.bytes), snapshot: row.snapshot } : null; + } + + async function saveCanonical(boardId: string, state: CanonicalDocumentState): Promise { + const timestamp = now(); + await database.transaction( + 'rw', + [boards(), pages(), shapes(), bindings(), meta(), canonical()], + async () => { + await canonical().put({ + boardId, + bytes: new Uint8Array(state.bytes), + snapshot: state.snapshot, + updatedAt: timestamp + }); + const pageKeys = (await pages().where('boardId').equals(boardId).toArray()).map( + (row) => [row.boardId, row.id] as [string, string] + ); + const shapeKeys = (await shapes().where('boardId').equals(boardId).toArray()).map( + (row) => [row.boardId, row.id] as [string, string] + ); + const bindingKeys = ( + await bindings().where('boardId').equals(boardId).toArray() + ).map((row) => [row.boardId, row.id] as [string, string]); + if (pageKeys.length > 0) await pages().bulkDelete(pageKeys); + if (shapeKeys.length > 0) await shapes().bulkDelete(shapeKeys); + if (bindingKeys.length > 0) await bindings().bulkDelete(bindingKeys); + await meta().delete(pageOrderKey(boardId)); + await meta().delete(shapeOrderKey(boardId)); + await meta().delete(layersKey(boardId)); + await meta().delete(assetsKey(boardId)); + await meta().delete(svgGroupsKey(boardId)); + await boards().update(boardId, { updatedAt: timestamp }); } ); } async function loadDoc(boardId: string): Promise { + const canonicalRow = await canonical().get(boardId); + if (canonicalRow) return fromCanonicalDocumentSnapshot(canonicalRow.snapshot); const pageRows = await pages().where('boardId').equals(boardId).toArray(); const [shapeRows, bindingRows, order, assetsRow, svgGroupsRow] = await Promise.all([ shapes().where('boardId').equals(boardId).toArray(), @@ -287,9 +340,10 @@ export function createDexieDocRepo( await database.transaction( 'rw', - [boards(), pages(), shapes(), bindings(), meta()], + [boards(), pages(), shapes(), bindings(), meta(), canonical()], async () => { await boards().put(board); + await canonical().delete(boardId); const pageRows = Object.values(snapshot.doc.pages).map((page) => ({ ...PageOps.clone(page), @@ -346,6 +400,8 @@ export function createDexieDocRepo( openBoard, renameBoard, deleteBoard, + loadCanonical, + saveCanonical, loadDoc, applyDocPatch, exportBoard, diff --git a/apps/web/src/lib/persistence/svg-import.ts b/apps/web/src/lib/persistence/svg-import.ts index af323c5..5895e83 100644 --- a/apps/web/src/lib/persistence/svg-import.ts +++ b/apps/web/src/lib/persistence/svg-import.ts @@ -11,7 +11,8 @@ import type { SvgImportResponse, SvgRenderOptions, SvgRenderResponse, - TransactionDraft + TransactionDraft, + DocumentSessionState } from '@inkfinite/wasm'; import type { SvgImportResult } from '@inkfinite/core'; @@ -28,6 +29,13 @@ export class SvgImportWorkerError extends Error { type WorkerRequestBody = | { type: 'import'; source: ArrayBuffer } + | { type: 'open_document'; source: ArrayBuffer; actorId: string } + | { type: 'create_document'; snapshot: DocumentSnapshot; actorId: string } + | { type: 'document_state' } + | { type: 'apply_transaction'; transaction: TransactionDraft } + | { type: 'apply_editor_patches'; request: EditorReconciliationRequest } + | { type: 'undo_document' } + | { type: 'redo_document' } | { type: 'project'; snapshot: DocumentSnapshot } | { type: 'reconcile'; snapshot: DocumentSnapshot; request: EditorReconciliationRequest } | { type: 'render'; snapshot: DocumentSnapshot; options: SvgRenderOptions }; @@ -36,16 +44,27 @@ type WorkerRequestBody = type WorkerRequest = WorkerRequestBody & { id: number }; type WorkerResponse = - | { id: number; response: SvgImportResponse | SvgRenderResponse } + | { + id: number; + response: + | SvgImportResponse + | SvgRenderResponse + | BrowserDocumentState + | EditorProjection + | TransactionDraft; + } | { id: number; error: string }; +export type BrowserDocumentState = DocumentSessionState & { bytes: Uint8Array }; + type WorkerResponseValue = | SvgImportResponse | SvgRenderResponse | EditorProjection - | TransactionDraft; + | TransactionDraft + | BrowserDocumentState; -/** A worker boundary that keeps SVG decoding, parsing, and rendering off the UI thread. */ +/** A worker boundary that keeps Rust parsing, rendering, and document state off the UI thread. */ export class SvgImportWorkerClient { private nextRequestId = 0; private readonly pending = new Map< @@ -72,6 +91,45 @@ export class SvgImportWorkerClient { return { ...response.import, omitted_image_count: response.omitted_image_count }; } + /** Opens canonical bytes in the worker-owned Rust document session. */ + openDocument(source: Uint8Array, actorId: string): Promise { + const transferable = source.slice(); + return this.request( + { type: 'open_document', source: transferable.buffer, actorId }, + [transferable.buffer] + ); + } + + /** Creates a Rust document session from a migrated materialized snapshot. */ + createDocument(snapshot: DocumentSnapshot, actorId: string): Promise { + return this.request({ type: 'create_document', snapshot, actorId }); + } + + /** Returns the worker session state and fresh canonical bytes. */ + documentState(): Promise { + return this.request({ type: 'document_state' }); + } + + /** Applies one prepared transaction through the worker-owned Rust engine. */ + applyTransaction(transaction: TransactionDraft): Promise { + return this.request({ type: 'apply_transaction', transaction }); + } + + /** Reconciles semantic editor patches through the worker-owned Rust engine. */ + applyEditorPatches(request: EditorReconciliationRequest): Promise { + return this.request({ type: 'apply_editor_patches', request }); + } + + /** Compensates the latest document transaction in Rust. */ + undoDocument(): Promise { + return this.request({ type: 'undo_document' }); + } + + /** Reapplies the latest compensated document transaction in Rust. */ + redoDocument(): Promise { + return this.request({ type: 'redo_document' }); + } + /** Projects one canonical snapshot into the shared flat editor view. */ project(snapshot: DocumentSnapshot): Promise { return this.request({ type: 'project', snapshot }); diff --git a/apps/web/src/lib/persistence/svg-import.worker.ts b/apps/web/src/lib/persistence/svg-import.worker.ts index 52a4c99..2c52612 100644 --- a/apps/web/src/lib/persistence/svg-import.worker.ts +++ b/apps/web/src/lib/persistence/svg-import.worker.ts @@ -3,8 +3,10 @@ import type { DocumentSnapshot, EditorReconciliationRequest, - SvgRenderOptions + SvgRenderOptions, + TransactionDraft } from '@inkfinite/wasm'; +import { WasmDocumentSession } from '@inkfinite/wasm'; import { importSvgInWorkerRuntime, projectEditorInWorkerRuntime, @@ -14,6 +16,13 @@ import { type Request = | { type: 'import'; id: number; source: ArrayBuffer } + | { type: 'open_document'; id: number; source: ArrayBuffer; actorId: string } + | { type: 'create_document'; id: number; snapshot: DocumentSnapshot; actorId: string } + | { type: 'document_state'; id: number } + | { type: 'apply_transaction'; id: number; transaction: TransactionDraft } + | { type: 'apply_editor_patches'; id: number; request: EditorReconciliationRequest } + | { type: 'undo_document'; id: number } + | { type: 'redo_document'; id: number } | { type: 'project'; id: number; snapshot: DocumentSnapshot } | { type: 'reconcile'; @@ -24,10 +33,52 @@ type Request = | { type: 'render'; id: number; snapshot: DocumentSnapshot; options: SvgRenderOptions }; const scope = globalThis as unknown as DedicatedWorkerGlobalScope; +let documentSession: WasmDocumentSession | null = null; + +function documentState() { + if (!documentSession) throw new Error('No browser document is open in the Rust worker.'); + return { ...documentSession.state(), bytes: documentSession.save() }; +} + scope.onmessage = async (event: MessageEvent) => { try { let response: unknown; switch (event.data.type) { + case 'open_document': { + documentSession = await WasmDocumentSession.open( + new Uint8Array(event.data.source), + event.data.actorId + ); + response = documentState(); + break; + } + case 'create_document': { + documentSession = await WasmDocumentSession.create( + event.data.snapshot, + event.data.actorId + ); + response = documentState(); + break; + } + case 'document_state': + case 'apply_transaction': + case 'apply_editor_patches': + case 'undo_document': + case 'redo_document': { + if (!documentSession) { + throw new Error('No browser document is open in the Rust worker.'); + } + if (event.data.type === 'apply_transaction') { + documentSession.applyTransaction(event.data.transaction); + } + if (event.data.type === 'apply_editor_patches') { + documentSession.applyEditorPatches(event.data.request); + } + if (event.data.type === 'undo_document') documentSession.undo(); + if (event.data.type === 'redo_document') documentSession.redo(); + response = documentState(); + break; + } case 'import': { response = await importSvgInWorkerRuntime(new Uint8Array(event.data.source)); break; @@ -48,7 +99,14 @@ scope.onmessage = async (event: MessageEvent) => { break; } } - scope.postMessage({ id: event.data.id, response }); + const transfer = + response && + typeof response === 'object' && + 'bytes' in response && + response.bytes instanceof Uint8Array + ? [response.bytes.buffer] + : []; + scope.postMessage({ id: event.data.id, response }, transfer); } catch (error) { scope.postMessage({ id: event.data.id, diff --git a/crates/inkfinite-cli/src/bin/generate-bindings.rs b/crates/inkfinite-cli/src/bin/generate-bindings.rs index b465006..aacc9eb 100644 --- a/crates/inkfinite-cli/src/bin/generate-bindings.rs +++ b/crates/inkfinite-cli/src/bin/generate-bindings.rs @@ -218,9 +218,9 @@ fn transaction_bindings() -> String { let mut output = GENERATED_TS_HEADER.to_owned(); output.push_str("import type {\n"); output.push_str(" ActorId, AssetId, AssetRecord, BindingId, BindingRecord, ChangeHash, ContainerLayout,\n"); - output.push_str(" DocumentId, DocumentSnapshot, LayerId, LayerRecord, Opacity, Origin, PageId, PageRecord,\n"); + output.push_str(" LayerId, LayerRecord, Opacity, Origin, PageId, PageRecord,\n"); output.push_str(" RecordVersion, SemanticMetadata, ShapeId, ShapeParent, ShapeProperties, ShapeRecord,\n"); - output.push_str(" ShapeStyle, SiblingAnchor, Timestamp, Transform, JsonValue\n"); + output.push_str(" ShapeStyle, SiblingAnchor, Timestamp, Transform\n"); output.push_str("} from \"./model.js\";\n\n"); append_declaration::(&mut output, &config); @@ -300,9 +300,9 @@ 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", + "import type { ActorId, BindingAnchor, BindingId, BindingKind, BindingRecord, ContainerLayout, LayerId, LayerRecord, Opacity, Origin, PageId, PageRecord, SemanticMetadata, ShapeId, ShapeKind, ShapeParent, ShapeProperties, ShapeStyle, SiblingAnchor, Timestamp } from './model.js';\n", ); - output.push_str("import type { LayerPatch, TransactionId } from './transaction.js';\n\n"); + output.push_str("import type { LayerContentsDisposition, LayerPatch, TransactionId } from './transaction.js';\n\n"); append_declaration::(&mut output, &config); append_declaration::(&mut output, &config); append_declaration::(&mut output, &config); diff --git a/crates/inkfinite-core/src/editor.rs b/crates/inkfinite-core/src/editor.rs index 07083d8..8dffb48 100644 --- a/crates/inkfinite-core/src/editor.rs +++ b/crates/inkfinite-core/src/editor.rs @@ -14,11 +14,13 @@ 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::proto::{ + LayerContentsDisposition, 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, + LayerRecord, Opacity, Origin, PageId, PageRecord, Provenance, RecordVersion, SemanticMetadata, ShapeId, ShapeKind, + ShapeParent, ShapeProperties, ShapeRecord, ShapeStyle, SiblingAnchor, Timestamp, Transform, Vec2, }; /// Full affine transform used by the editor projection. @@ -210,6 +212,32 @@ pub enum EditorPatch { /// Replacement sibling placement. anchor: Option>, }, + /// Create a page and its later layer records. + CreatePage { + /// New page record. + page: PageRecord, + /// Placement relative to an existing page. + anchor: SiblingAnchor, + }, + /// Delete a page and all records it owns. + DeletePage { + /// Page to delete. + page_id: PageId, + }, + /// Create an empty layer. + CreateLayer { + /// New layer record. + layer: LayerRecord, + /// Placement relative to an existing layer. + anchor: SiblingAnchor, + }, + /// Delete a layer and handle its children explicitly. + DeleteLayer { + /// Layer to delete. + layer_id: LayerId, + /// Handling for shapes owned by the layer. + contents: LayerContentsDisposition, + }, /// Create one shape from editor-owned semantic fields. CreateShape { /// New shape fields. @@ -457,6 +485,22 @@ pub fn reconcile_editor_patches( for patch in request.patches { match patch { + EditorPatch::CreatePage { page, anchor } => operations.push(Operation::CreatePage { page, anchor }), + EditorPatch::DeletePage { page_id } => { + let page = document + .pages + .get(&page_id) + .ok_or_else(|| EditorReconciliationError::UnknownPage(page_id.clone()))?; + operations.push(Operation::DeletePage { page_id, expected_version: Some(page.version) }); + } + EditorPatch::CreateLayer { layer, anchor } => operations.push(Operation::CreateLayer { layer, anchor }), + EditorPatch::DeleteLayer { layer_id, contents } => { + let layer = document + .layers + .get(&layer_id) + .ok_or_else(|| EditorReconciliationError::UnknownLayer(layer_id.clone()))?; + operations.push(Operation::DeleteLayer { layer_id, contents, expected_version: Some(layer.version) }); + } EditorPatch::Shape { shape_id, transform, properties, metadata, style, parent, anchor } => { reconcile_shape( document, @@ -541,6 +585,7 @@ pub fn reconcile_editor_patches( }) } +// FIXME: this function signature is AWFUL #[allow(clippy::too_many_arguments)] fn reconcile_shape( document: &Document, shape_id: ShapeId, transform: Option, properties: Option, diff --git a/crates/inkfinite-wasm/src/lib.rs b/crates/inkfinite-wasm/src/lib.rs index fd7fca6..2f802ae 100644 --- a/crates/inkfinite-wasm/src/lib.rs +++ b/crates/inkfinite-wasm/src/lib.rs @@ -11,10 +11,14 @@ 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::engine::{EngineError, TransactionEngine}; +use inkfinite_core::proto::{Bounds, CommitResult, TransactionDraft}; 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, DocumentSnapshot, LayerId, PageId, ShapeId}; +use inkfinite_core::{ + ActorId, AssetId, DocumentId, DocumentSnapshot, INKFINITE_FORMAT_ID, INKFINITE_FORMAT_VERSION, LayerId, PageId, + ShapeId, +}; use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; @@ -72,6 +76,173 @@ pub struct SvgRenderFailure { pub message: String, } +/// A browser document session mutation result. +#[derive(Debug, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum DocumentMutationResponse { + /// A validated transaction or history compensation was committed. + Success { + /// Materialized commit metadata. + commit: CommitResult, + }, + /// The mutation was rejected without changing the session. + Error { + /// Mutation failure details. + error: DocumentSessionFailure, + }, +} + +/// A stable document-session error crossing the WASM boundary. +#[derive(Debug, Serialize)] +pub struct DocumentSessionFailure { + /// Machine-readable failure category. + pub code: &'static str, + /// Human-readable failure detail. + pub message: String, +} + +/// A stateful Rust document engine owned by one browser worker. +/// +/// The session keeps Automerge state and actor-scoped history alive between +/// calls. IndexedDB stores the bytes returned by [`Self::save`]; it does not +/// participate in document mutation or validation. +#[wasm_bindgen] +pub struct DocumentSession { + engine: TransactionEngine, + actor_id: ActorId, +} + +#[wasm_bindgen] +impl DocumentSession { + /// Returns the current snapshot and actor-scoped history capabilities. + pub fn state_json(&mut self) -> Result { + let snapshot = self + .engine + .snapshot() + .map_err(|error| JsValue::from_str(&error.to_string()))?; + serialize_result(&DocumentSessionState { + can_undo: self.engine.can_undo(&self.actor_id), + can_redo: self.engine.can_redo(&self.actor_id), + snapshot, + }) + } + + /// Returns the canonical Automerge bytes for IndexedDB persistence. + pub fn save(&mut self) -> Result, JsValue> { + self.engine + .save() + .map_err(|error| JsValue::from_str(&error.to_string())) + } + + /// Applies one validated transaction draft to the session. + pub fn apply_transaction(&mut self, transaction_json: &str) -> String { + let transaction = match serde_json::from_str::(transaction_json) { + Ok(transaction) => transaction, + Err(error) => { + return serialize_response(DocumentMutationResponse::Error { + error: invalid_json_failure("invalid_transaction", &error), + }); + } + }; + if transaction.actor_id != self.actor_id { + return serialize_response(DocumentMutationResponse::Error { + error: DocumentSessionFailure { + code: "actor_mismatch", + message: "transaction actor does not belong to this session".into(), + }, + }); + } + match self.engine.commit(transaction) { + Ok(commit) => serialize_response(DocumentMutationResponse::Success { commit }), + Err(error) => serialize_response(DocumentMutationResponse::Error { error: engine_failure(&error) }), + } + } + + /// Reconciles and commits semantic editor patches against the current state. + pub fn apply_editor_patches(&mut self, request_json: &str) -> String { + let request = match serde_json::from_str::(request_json) { + Ok(request) => request, + Err(error) => { + return serialize_response(DocumentMutationResponse::Error { + error: invalid_json_failure("invalid_request", &error), + }); + } + }; + if request.actor_id != self.actor_id { + return serialize_response(DocumentMutationResponse::Error { + error: DocumentSessionFailure { + code: "actor_mismatch", + message: "editor request actor does not belong to this session".into(), + }, + }); + } + let snapshot = match self.engine.snapshot() { + Ok(snapshot) => snapshot, + Err(error) => return serialize_response(DocumentMutationResponse::Error { error: engine_failure(&error) }), + }; + let transaction = match reconcile_native(&snapshot, request) { + Ok(transaction) => transaction, + Err(error) => { + return serialize_response(DocumentMutationResponse::Error { + error: DocumentSessionFailure { code: "editor_reconciliation", message: error.to_string() }, + }); + } + }; + if transaction.operations.is_empty() { + return serialize_response(DocumentMutationResponse::Error { + error: DocumentSessionFailure { + code: "no_changes", + message: "editor patches contain no document changes".into(), + }, + }); + } + match self.engine.commit(transaction) { + Ok(commit) => serialize_response(DocumentMutationResponse::Success { commit }), + Err(error) => serialize_response(DocumentMutationResponse::Error { error: engine_failure(&error) }), + } + } + + /// Compensates the latest transaction committed by this session actor. + pub fn undo(&mut self) -> String { + match self.engine.undo(&self.actor_id) { + Ok(commit) => serialize_response(DocumentMutationResponse::Success { commit }), + Err(error) => serialize_response(DocumentMutationResponse::Error { error: engine_failure(&error) }), + } + } + + /// Reapplies the latest transaction compensated by this session actor. + pub fn redo(&mut self) -> String { + match self.engine.redo(&self.actor_id) { + Ok(commit) => serialize_response(DocumentMutationResponse::Success { commit }), + Err(error) => serialize_response(DocumentMutationResponse::Error { error: engine_failure(&error) }), + } + } + + /// Reports whether the session actor can undo its latest transaction. + #[must_use] + pub fn can_undo(&self) -> bool { + self.engine.can_undo(&self.actor_id) + } + + /// Reports whether the session actor can redo its latest compensated transaction. + #[must_use] + pub fn can_redo(&self) -> bool { + self.engine.can_redo(&self.actor_id) + } +} + +/// The result of opening or creating a browser document session. +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct DocumentSessionState { + /// Current canonical materialized snapshot. + pub snapshot: DocumentSnapshot, + /// Whether the session actor can compensate its latest transaction. + pub can_undo: bool, + /// Whether the session actor can reapply its latest compensated transaction. + pub can_redo: bool, +} + /// The result envelope exchanged for editor projection. #[derive(Debug, Serialize)] #[serde(tag = "status", rename_all = "snake_case")] @@ -163,6 +334,39 @@ impl From for SvgRenderOptions { } } +/// Creates a stateful browser session from a canonical materialized snapshot. +#[wasm_bindgen] +pub fn create_document(snapshot_json: &str, actor_id: &str) -> Result { + let snapshot = serde_json::from_str::(snapshot_json) + .map_err(|error| JsValue::from_str(&format!("invalid snapshot: {error}")))?; + if snapshot.format.as_str() != INKFINITE_FORMAT_ID || snapshot.format_version != INKFINITE_FORMAT_VERSION { + return Err(JsValue::from_str("unsupported document format")); + } + if actor_id.trim().is_empty() { + return Err(JsValue::from_str("actor id must not be empty")); + } + let actor_id = ActorId::from(actor_id); + let engine = TransactionEngine::create( + DocumentId::from(snapshot.document_id.as_str()), + actor_id.clone(), + snapshot.document, + ) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + Ok(DocumentSession { engine, actor_id }) +} + +/// Opens a stateful browser session from canonical Automerge bytes. +#[wasm_bindgen] +pub fn open_document(bytes: &[u8], actor_id: &str) -> Result { + if actor_id.trim().is_empty() { + return Err(JsValue::from_str("actor id must not be empty")); + } + let actor_id = ActorId::from(actor_id); + let engine = + TransactionEngine::load(bytes, actor_id.clone()).map_err(|error| JsValue::from_str(&error.to_string()))?; + Ok(DocumentSession { engine, actor_id }) +} + /// Imports UTF-8 SVG bytes and returns the serialized response envelope. #[must_use] pub fn import_svg_json(source: &[u8]) -> String { @@ -279,6 +483,34 @@ pub fn render_svg_json(snapshot_json: &str, options_json: &str) -> String { } } +fn serialize_result(value: &T) -> Result { + serde_json::to_string(value).map_err(|error| JsValue::from_str(&error.to_string())) +} + +fn invalid_json_failure(code: &'static str, error: &serde_json::Error) -> DocumentSessionFailure { + DocumentSessionFailure { code, message: error.to_string() } +} + +fn engine_failure(error: &EngineError) -> DocumentSessionFailure { + let code = match error { + EngineError::Crdt(_) => "crdt", + EngineError::Sync(_) => "sync", + EngineError::Schema(_) => "schema", + EngineError::StaleHeads => "stale_heads", + EngineError::Precondition(_) => "precondition", + EngineError::Permission(_) => "permission", + EngineError::Invariant(_) => "invariant", + EngineError::EmptyHistory { action, .. } => { + if *action == "undo" { + "empty_undo" + } else { + "empty_redo" + } + } + }; + DocumentSessionFailure { code, message: error.to_string() } +} + fn serialize_response(response: T) -> String { serde_json::to_string(&response).unwrap_or_else(|error| { format!( @@ -510,4 +742,49 @@ mod tests { ); assert_eq!(reconciled["transaction"]["base_heads"][0], "head:one"); } + + #[test] + fn stateful_session_commits_saves_and_replays_history() { + let snapshot = serde_json::json!({ + "format": "inkfinite.document", + "format_version": 2, + "document_id": "document:session", + "heads": [], + "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": [], "visible": true, "locked": false, "opacity": 1, "version": 1}}, + "shapes": {}, "bindings": {}, "assets": {} + } + }); + let mut session = create_document(&snapshot.to_string(), "browser").expect("session should open"); + let request = serde_json::json!({ + "patches": [{ + "type": "create_shape", + "shape": {"id": "shape:rect", "kind": "rect", "properties": {"w": 20, "h": 10}, "metadata": null, "style": {"opacity": 1, "fill_opacity": null, "stroke_opacity": null}, "layout": null}, + "parent": {"kind": "layer", "id": "layer:one"}, + "transform": {"a": 1, "b": 0, "c": 0, "d": 1, "e": 4, "f": 5}, + "anchor": {"position": "last"} + }], + "actor_id": "browser", "origin": "human", "transaction_id": "transaction:create", "description": "Create rectangle", "timestamp": 1 + }); + let response: Value = + serde_json::from_str(&session.apply_editor_patches(&request.to_string())).expect("commit response"); + assert_eq!(response["status"], "success"); + let state: Value = + serde_json::from_str(&session.state_json().expect("state should serialize")).expect("state JSON"); + assert!(state["snapshot"]["document"]["shapes"]["shape:rect"].is_object()); + assert!(session.can_undo()); + let saved = session.save().expect("session should save"); + let mut reopened = open_document(&saved, "browser").expect("saved bytes should reopen"); + assert!(serde_json::from_str::(&reopened.state_json().expect("reopened state")) + .expect("state JSON")["snapshot"]["document"]["shapes"]["shape:rect"] + .is_object()); + let undo: Value = serde_json::from_str(&session.undo()).expect("undo response"); + assert_eq!(undo["status"], "success"); + assert!(!session.can_undo()); + let redo: Value = serde_json::from_str(&session.redo()).expect("redo response"); + assert_eq!(redo["status"], "success"); + assert!(session.can_undo()); + } } diff --git a/packages/bindings/src/editor.ts b/packages/bindings/src/editor.ts index 2df8a5a..a0ae2c8 100644 --- a/packages/bindings/src/editor.ts +++ b/packages/bindings/src/editor.ts @@ -9,9 +9,11 @@ import type { BindingRecord, ContainerLayout, LayerId, + LayerRecord, Opacity, Origin, PageId, + PageRecord, SemanticMetadata, ShapeId, ShapeKind, @@ -21,7 +23,7 @@ import type { SiblingAnchor, Timestamp } from './model.js'; -import type { LayerPatch, TransactionId } from './transaction.js'; +import type { LayerContentsDisposition, LayerPatch, TransactionId } from './transaction.js'; /** * Full affine transform used by the editor projection. @@ -317,6 +319,46 @@ export type EditorPatch = */ anchor: SiblingAnchor | null; } + | { + type: 'create_page'; + /** + * New page record. + */ + page: PageRecord; + /** + * Placement relative to an existing page. + */ + anchor: SiblingAnchor; + } + | { + type: 'delete_page'; + /** + * Page to delete. + */ + page_id: PageId; + } + | { + type: 'create_layer'; + /** + * New layer record. + */ + layer: LayerRecord; + /** + * Placement relative to an existing layer. + */ + anchor: SiblingAnchor; + } + | { + type: 'delete_layer'; + /** + * Layer to delete. + */ + layer_id: LayerId; + /** + * Handling for shapes owned by the layer. + */ + contents: LayerContentsDisposition; + } | { type: 'create_shape'; /** diff --git a/packages/bindings/src/transaction.ts b/packages/bindings/src/transaction.ts index 56d7f26..9bb3175 100644 --- a/packages/bindings/src/transaction.ts +++ b/packages/bindings/src/transaction.ts @@ -9,8 +9,6 @@ import type { BindingRecord, ChangeHash, ContainerLayout, - DocumentId, - DocumentSnapshot, LayerId, LayerRecord, Opacity, @@ -26,8 +24,7 @@ import type { ShapeStyle, SiblingAnchor, Timestamp, - Transform, - JsonValue + Transform } from './model.js'; /** diff --git a/packages/core/src/persistence/canonical.ts b/packages/core/src/persistence/canonical.ts index 5d00059..5507f77 100644 --- a/packages/core/src/persistence/canonical.ts +++ b/packages/core/src/persistence/canonical.ts @@ -1,3 +1,4 @@ +import type { EditorPatch, EditorReconciliationRequest, EditorTransform } from '@inkfinite/bindings/editor'; import type { AssetRecord as NativeAssetRecord, BindingRecord as NativeBindingRecord, @@ -7,11 +8,13 @@ import type { PageRecord as NativePageRecord, Provenance, SemanticMetadata, + ShapeParent, ShapeRecord as NativeShapeRecord, ShapeStyle, - ShapeProperties + ShapeProperties, + Transform } from '@inkfinite/bindings/model'; -import type { BoardExport, DocOrder } from './document'; +import type { BoardExport, DocOrder, LoadedDoc } from './document'; import { ensureDocumentLayers, type Document, type LayerRecord, type ShapeRecord } from '../model'; const FORMAT_ID = 'inkfinite.document'; @@ -118,6 +121,436 @@ export function toCanonicalDocumentSnapshot( }; } +/** + * Projects a canonical Rust snapshot back into the editor's flat document. + * + * The canonical snapshot remains authoritative. This conversion only creates + * the low-latency TypeScript projection used by the existing canvas runtime. + */ +export function fromCanonicalDocumentSnapshot(snapshot: NativeDocumentSnapshot): LoadedDoc { + const pages: Record = {}; + const layers: Record = {}; + const shapes: Record = {}; + const bindings: Record = {}; + + for (const pageId of snapshot.document.page_ids) { + const page = snapshot.document.pages[pageId]; + if (!page) continue; + pages[page.id] = { id: page.id, name: page.name, shapeIds: [], layerIds: [...page.layer_ids] }; + for (const layerId of page.layer_ids) { + const layer = snapshot.document.layers[layerId]; + if (!layer) continue; + const layerShapeIds: string[] = []; + const walk = (shapeId: string, parent: string | undefined, transform: Affine) => { + const native = snapshot.document.shapes[shapeId]; + if (!native) return; + const world = multiplyAffine(transform, transformFromNative(native.transform)); + if (native.kind !== 'container') { + const shape = editorShape(native, page.id, layer.id, parent, world); + shapes[shape.id] = shape; + layerShapeIds.push(shape.id); + pages[page.id]!.shapeIds.push(shape.id); + } + for (const childId of native.child_ids) + walk(childId, native.kind === 'container' ? native.id : parent, world); + }; + for (const shapeId of layer.shape_ids) walk(shapeId, undefined, identityAffine()); + layers[layer.id] = { + id: layer.id, + pageId: layer.page_id, + name: layer.name, + shapeIds: layerShapeIds, + visible: layer.visible, + locked: layer.locked, + opacity: layer.opacity + }; + } + } + + for (const binding of Object.values(snapshot.document.bindings)) { + bindings[binding.id] = { + id: binding.id, + type: binding.kind as import('../model').BindingType, + fromShapeId: binding.source_shape_id, + toShapeId: binding.target_shape_id, + handle: binding.source_handle as import('../model').BindingHandle, + anchor: + binding.anchor.kind === 'center' + ? { kind: 'center' } + : { kind: 'edge', nx: binding.anchor.x, ny: binding.anchor.y } + }; + } + + const assets = Object.fromEntries( + Object.values(snapshot.document.assets).flatMap((asset) => + asset.source.kind === 'embedded' + ? [ + [ + asset.id, + { + id: asset.id, + name: asset.name, + mediaType: asset.media_type, + digest: asset.digest, + bytes: [...asset.source.bytes] + } + ] + ] + : [] + ) + ); + const order: DocOrder = { + pageIds: [...snapshot.document.page_ids], + shapeOrder: Object.fromEntries(Object.values(pages).map((page) => [page.id, [...page.shapeIds]])), + layers + }; + return { pages, layers, shapes, bindings, ...(Object.keys(assets).length > 0 ? { assets } : {}), order }; +} + +/** Builds one semantic Rust reconciliation request from an editor document change. */ +export function createEditorReconciliationRequest( + before: Document, + after: Document, + options: Omit +): EditorReconciliationRequest { + const previous = ensureDocumentLayers(before); + const next = ensureDocumentLayers(after); + const patches: EditorPatch[] = []; + const previousShapes = previous.shapes; + const nextShapes = next.shapes; + const deletedPages = new Set(Object.keys(previous.pages).filter((id) => !next.pages[id])); + const deletedLayerDestinations = deletedLayerMoves(previous, next); + + for (const page of Object.values(next.pages)) { + if (!previous.pages[page.id]) { + patches.push({ + type: 'create_page', + page: { id: page.id, name: page.name, layer_ids: [], version: 1 }, + anchor: orderAnchorFor(page.id, Object.keys(next.pages)) + }); + } + } + for (const layer of Object.values(next.layers ?? {})) { + if (!previous.layers?.[layer.id]) { + patches.push({ + type: 'create_layer', + layer: nativeLayer(layer), + anchor: orderAnchorFor(layer.id, next.pages[layer.pageId]?.layerIds ?? []) + }); + } + } + + for (const shapeId of Object.keys(previousShapes)) { + if (!nextShapes[shapeId] && !deletedPages.has(previousShapes[shapeId]!.pageId)) { + patches.push({ type: 'delete_shape', shape_id: shapeId }); + } + } + for (const shape of Object.values(nextShapes)) { + const old = previousShapes[shape.id]; + if (!old) { + patches.push({ + type: 'create_shape', + shape: { + id: shape.id, + kind: shape.type, + properties: cloneProperties(shape.props), + metadata: null, + style: shapeStyle(shape), + layout: null + }, + parent: shapeParent(shape, next), + transform: editorTransform(shape), + anchor: { position: 'last' } + }); + continue; + } + const patch = shapePatch(old, shape, previous, next, deletedLayerDestinations.has(old.layerId ?? '')); + if (patch) patches.push(patch); + } + + for (const page of Object.values(next.pages)) { + const old = previous.pages[page.id]; + if (old && old.name !== page.name) patches.push({ type: 'rename_page', page_id: page.id, name: page.name }); + } + for (const layer of Object.values(next.layers ?? {})) { + const old = previous.layers?.[layer.id]; + if (!old) continue; + const patch = { + name: old.name === layer.name ? null : layer.name, + visible: old.visible === layer.visible ? null : layer.visible, + locked: old.locked === layer.locked ? null : layer.locked, + opacity: old.opacity === layer.opacity ? null : clampOpacity(layer.opacity) + }; + if (patch.name !== null || patch.visible !== null || patch.locked !== null || patch.opacity !== null) { + patches.push({ type: 'patch_layer', layer_id: layer.id, patch }); + } + if (old && JSON.stringify(old.pageId) === JSON.stringify(layer.pageId)) { + const beforeIds = previous.pages[layer.pageId]?.layerIds ?? []; + const afterIds = next.pages[layer.pageId]?.layerIds ?? []; + if (JSON.stringify(beforeIds) !== JSON.stringify(afterIds) && beforeIds.includes(layer.id)) { + patches.push({ type: 'reorder_layer', layer_id: layer.id, anchor: orderAnchorFor(layer.id, afterIds) }); + } + } + } + for (const layerId of Object.keys(previous.layers ?? {})) { + const layer = previous.layers![layerId]!; + if (!deletedPages.has(layer.pageId) && !next.layers?.[layerId]) { + const destination = deletedLayerDestinations.get(layerId); + patches.push({ + type: 'delete_layer', + layer_id: layerId, + contents: destination ? { kind: 'move_to', destination_layer_id: destination } : { kind: 'delete' } + }); + } + } + for (const pageId of Object.keys(previous.pages)) { + if (deletedPages.has(pageId)) patches.push({ type: 'delete_page', page_id: pageId }); + } + + for (const binding of Object.values(previous.bindings)) { + if (!next.bindings[binding.id] && next.shapes[binding.fromShapeId] && next.shapes[binding.toShapeId]) { + patches.push({ type: 'delete_binding', binding_id: binding.id }); + } + } + for (const binding of Object.values(next.bindings)) { + const old = previous.bindings[binding.id]; + if (!old) { + patches.push({ type: 'create_binding', binding: nativeBinding(binding) }); + } else if (JSON.stringify(old) !== JSON.stringify(binding)) { + patches.push({ type: 'delete_binding', binding_id: binding.id }); + patches.push({ type: 'create_binding', binding: nativeBinding(binding) }); + } + } + return { ...options, patches }; +} + +type Affine = EditorTransform; + +function identityAffine(): Affine { + return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; +} + +function transformFromNative(transform: Transform): Affine { + const cos = Math.cos(transform.rotation); + const sin = Math.sin(transform.rotation); + return { + a: cos * transform.scale_x, + b: sin * transform.scale_x, + c: -sin * transform.scale_y, + d: cos * transform.scale_y, + e: transform.translation.x, + f: transform.translation.y + }; +} + +function multiplyAffine(parent: Affine, child: Affine): Affine { + return { + a: parent.a * child.a + parent.c * child.b, + b: parent.b * child.a + parent.d * child.b, + c: parent.a * child.c + parent.c * child.d, + d: parent.b * child.c + parent.d * child.d, + e: parent.a * child.e + parent.c * child.f + parent.e, + f: parent.b * child.e + parent.d * child.f + parent.f + }; +} + +function editorShape( + native: NativeShapeRecord, + pageId: string, + layerId: string, + groupId: string | undefined, + transform: Affine +): ShapeRecord { + return { + id: native.id, + type: native.kind as ShapeRecord['type'], + pageId, + x: transform.e, + y: transform.f, + rot: Math.atan2(transform.b, transform.a), + editorTransform: transform, + groupId, + layerId, + opacity: native.style.opacity, + ...(native.style.fill_opacity === null ? {} : { fillOpacity: native.style.fill_opacity }), + ...(native.style.stroke_opacity === null ? {} : { strokeOpacity: native.style.stroke_opacity }), + agentEditable: native.metadata.agent_editable, + props: editorProperties(native.properties) + } as ShapeRecord; +} + +function editorProperties(properties: ShapeProperties): ShapeProperties { + const result = JSON.parse(JSON.stringify(properties)) as ShapeProperties; + if ('width' in result) { + result.w = result.width; + delete result.width; + } + if ('height' in result) { + result.h = result.height; + delete result.height; + } + return result; +} + +function cloneProperties(properties: ShapeRecord['props']): ShapeProperties { + return JSON.parse(JSON.stringify(properties)) as ShapeProperties; +} + +function shapeStyle(shape: ShapeRecord): ShapeStyle { + return { + opacity: clampOpacity(shape.opacity), + fill_opacity: shape.fillOpacity === undefined ? null : clampOpacity(shape.fillOpacity), + stroke_opacity: shape.strokeOpacity === undefined ? null : clampOpacity(shape.strokeOpacity) + }; +} + +function shapeParent(shape: ShapeRecord, document: Document): ShapeParent { + return shape.groupId + ? { kind: 'shape', id: shape.groupId } + : { kind: 'layer', id: shape.layerId ?? findShapeLayer(shape.id, document) }; +} + +function findShapeLayer(shapeId: string, document: Document): string { + for (const layer of Object.values(document.layers ?? {})) { + if (layer.shapeIds.includes(shapeId)) return layer.id; + } + throw new Error(`Shape ${shapeId} has no owning layer`); +} + +function nativeLayer(layer: LayerRecord): NativeLayerRecord { + return { + id: layer.id, + page_id: layer.pageId, + name: layer.name, + shape_ids: [], + visible: layer.visible, + locked: layer.locked, + opacity: clampOpacity(layer.opacity), + version: 1 + }; +} + +function deletedLayerMoves(before: Document, after: Document): Map { + const destinations = new Map(); + for (const layer of Object.values(before.layers ?? {})) { + if (after.layers?.[layer.id]) continue; + const movedShapes = layer.shapeIds + .map((shapeId) => after.shapes[shapeId]?.layerId) + .filter((layerId): layerId is string => Boolean(layerId)); + if (movedShapes.length === layer.shapeIds.length && movedShapes.length > 0 && new Set(movedShapes).size === 1) { + destinations.set(layer.id, movedShapes[0]!); + } + } + return destinations; +} + +function orderAnchorFor(id: string, ids: string[]) { + const index = ids.indexOf(id); + return index <= 0 + ? ({ position: 'first' } as const) + : ({ position: 'after', sibling_id: ids[index - 1]! } as const); +} + +function shapePatch( + before: ShapeRecord, + after: ShapeRecord, + beforeDocument: Document, + afterDocument: Document, + skipDeletedLayerParent: boolean +): Extract | null { + const parentBefore = shapeParent(before, beforeDocument); + const parentAfter = shapeParent(after, afterDocument); + const transformChanged = + JSON.stringify(editorTransform(before, before)) !== JSON.stringify(editorTransform(after, before)); + const propertiesChanged = JSON.stringify(before.props) !== JSON.stringify(after.props); + const styleChanged = JSON.stringify(shapeStyle(before)) !== JSON.stringify(shapeStyle(after)); + const orderChanged = skipDeletedLayerParent + ? false + : siblingOrderChanged(before.id, parentBefore, parentAfter, beforeDocument, afterDocument); + const parentChanged = JSON.stringify(parentBefore) !== JSON.stringify(parentAfter); + if ( + !transformChanged && + !propertiesChanged && + !styleChanged && + (!parentChanged || skipDeletedLayerParent) && + !orderChanged + ) + return null; + return { + type: 'shape', + shape_id: after.id, + transform: transformChanged ? editorTransform(after, before) : null, + properties: propertiesChanged ? cloneProperties(after.props) : null, + metadata: null, + style: styleChanged ? shapeStyle(after) : null, + parent: !parentChanged || skipDeletedLayerParent ? null : parentAfter, + anchor: orderChanged ? orderAnchor(after.id, parentAfter, afterDocument) : null + }; +} + +function editorTransform(shape: ShapeRecord, previous?: ShapeRecord): EditorTransform { + const current = shape.editorTransform ? { ...shape.editorTransform } : transformFromRotation(shape.rot, 1, 1); + if (previous && Math.abs(shape.rot - previous.rot) > 1e-9) { + const previousTransform = previous.editorTransform ?? transformFromRotation(previous.rot, 1, 1); + const scaleX = Math.hypot(previousTransform.a, previousTransform.b); + const determinant = previousTransform.a * previousTransform.d - previousTransform.b * previousTransform.c; + const scaleY = scaleX > Number.EPSILON ? determinant / 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 }; + } + return { ...current, e: shape.x, f: shape.y }; +} + +function transformFromRotation(rotation: number, scaleX: number, scaleY: number): EditorTransform { + const cos = Math.cos(rotation); + const sin = Math.sin(rotation); + return { a: cos * scaleX, b: sin * scaleX, c: -sin * scaleY, d: cos * scaleY, e: 0, f: 0 }; +} + +function siblingOrderChanged( + shapeId: string, + beforeParent: ShapeParent, + afterParent: ShapeParent, + before: Document, + after: Document +): boolean { + if (JSON.stringify(beforeParent) !== JSON.stringify(afterParent)) return true; + const beforeSiblings = siblings(beforeParent, before); + const afterSiblings = siblings(afterParent, after); + return JSON.stringify(beforeSiblings) !== JSON.stringify(afterSiblings) && afterSiblings.includes(shapeId); +} + +function siblings(parent: ShapeParent, document: Document): string[] { + if (parent.kind === 'layer') return document.layers?.[parent.id]?.shapeIds ?? []; + return Object.values(document.shapes) + .filter((shape) => shape.groupId === parent.id) + .map((shape) => shape.id); +} + +function orderAnchor(shapeId: string, parent: ShapeParent, document: Document) { + const ids = siblings(parent, document).filter((id) => id !== shapeId); + const index = siblings(parent, document).indexOf(shapeId); + return index <= 0 + ? { position: 'first' as const } + : { position: 'after' as const, sibling_id: ids[index - 1] ?? ids.at(-1)! }; +} + +function nativeBinding(binding: import('../model').BindingRecord): NativeBindingRecord { + return { + 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 + }; +} + function nativeShape(shape: ShapeRecord, layerId: string): NativeShapeRecord { const metadata: SemanticMetadata = { name: null, diff --git a/packages/core/src/persistence/document.ts b/packages/core/src/persistence/document.ts index ea48041..829701c 100644 --- a/packages/core/src/persistence/document.ts +++ b/packages/core/src/persistence/document.ts @@ -10,6 +10,7 @@ import { type ImportedAsset, type ImportedGroup } from '../model'; +import type { DocumentSnapshot as NativeDocumentSnapshot } from '@inkfinite/bindings/model'; import type { BoardMeta, DocRepo } from './repo'; /** Persisted page and shape ordering for a document. */ @@ -42,11 +43,31 @@ export type LoadedDoc = { /** Portable board snapshot used by import and export flows. */ export type BoardExport = { board: BoardMeta; doc: Document; order: DocOrder }; -/** Receives editor patches and controls when they reach persistence. */ -export type PersistenceSink = { enqueueDocPatch(boardId: string, patch: DocPatch): void; flush(): Promise }; +/** Canonical bytes and their materialized cache stored by browser adapters. */ +export type CanonicalDocumentState = { bytes: Uint8Array; snapshot: NativeDocumentSnapshot }; + +/** One editor document change handed to a Rust-backed browser persistence adapter. */ +export type EditorDocumentChange = { + boardId: string; + before: Document; + after: Document; + op: 'do' | 'undo' | 'redo'; + description: string; +}; + +/** Receives editor changes and controls when they reach persistence. */ +export type PersistenceSink = { + enqueueDocPatch(boardId: string, patch: DocPatch): void; + enqueueEditorChange?(change: EditorDocumentChange): void; + flush(): Promise; +}; /** Platform-neutral document repository implemented by each application adapter. */ export interface PersistentDocRepo extends DocRepo { + /** Loads canonical browser state when the adapter supports the Rust engine. */ + loadCanonical?(boardId: string): Promise; + /** Atomically stores canonical bytes and the derived materialized cache. */ + saveCanonical?(boardId: string, state: CanonicalDocumentState): Promise; loadDoc(boardId: string): Promise; applyDocPatch(boardId: string, patch: DocPatch): Promise; exportBoard(boardId: string): Promise; diff --git a/packages/core/tests/canonical.test.ts b/packages/core/tests/canonical.test.ts index 57a91f1..f578398 100644 --- a/packages/core/tests/canonical.test.ts +++ b/packages/core/tests/canonical.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { toCanonicalDocumentSnapshot } from '../src/persistence/canonical'; -import { PageRecord, ShapeRecord } from '../src/model'; +import { createEditorReconciliationRequest, toCanonicalDocumentSnapshot } from '../src/persistence/canonical'; +import { PageRecord, ShapeRecord, type Document } from '../src/model'; describe('toCanonicalDocumentSnapshot', () => { it('projects browser shapes into the canonical renderer input', () => { @@ -33,4 +33,33 @@ describe('toCanonicalDocumentSnapshot', () => { properties: { w: 40, h: 20, fill: 'red' } }); }); + + it('turns editor moves into semantic Rust reconciliation patches', () => { + const page = PageRecord.create('Page 1', 'page:one'); + const rect = ShapeRecord.createRect( + page.id, + 10, + 20, + { w: 40, h: 20, fill: 'red', stroke: 'none', radius: 4 }, + 'shape:rect' + ); + page.shapeIds.push(rect.id); + const before: Document = { pages: { [page.id]: page }, shapes: { [rect.id]: rect }, bindings: {} }; + const after: Document = { ...before, shapes: { [rect.id]: { ...rect, x: 30, y: 45 } } }; + + const request = createEditorReconciliationRequest(before, after, { + actor_id: 'browser', + origin: 'human', + transaction_id: 'transaction:test', + description: 'Move rectangle', + timestamp: 1 + }); + + expect(request.patches).toHaveLength(1); + expect(request.patches[0]).toMatchObject({ + type: 'shape', + shape_id: 'shape:rect', + transform: { e: 30, f: 45 } + }); + }); }); diff --git a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts index 15965bb..c79296d 100644 --- a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts @@ -138,6 +138,16 @@ export function createCanvasController( if (!activeBoardId || event.kind !== 'doc' || !sink) { return; } + if (sink.enqueueEditorChange) { + sink.enqueueEditorChange({ + boardId: activeBoardId, + before: event.beforeState.doc, + after: event.afterState.doc, + op: event.op, + description: event.command.name + }); + return; + } const patch = diffDoc(event.beforeState.doc, event.afterState.doc); sink.enqueueDocPatch(activeBoardId, patch); } @@ -287,6 +297,7 @@ export function createCanvasController( }, ui: { ...state.ui, currentPageId: firstPageId, selectionIds: [] } })); + if (activeBoardId) platformSession?.setActiveDocument?.(activeBoardId, doc); if (fitDrawing) { camera.fitAll(); } @@ -373,9 +384,9 @@ export function createCanvasController( selectionTool: selectTool, getSnapSettings: () => snapStore.get(), onTransactionDraft: ({ name, kind, before, after }) => { - // Tool movement renders `after` as a local preview. Restore the durable - // mirror before executing the command so history and persistence receive - // the real before/after pair exactly once. + // Tool movement renders `after` as a local preview. We want restore the + // committed mirror before executing the command so history and persistence + // receive the real before/after pair exactly once. store.setState(() => before); store.executeCommand(new SnapshotCommand(name, kind, before, after)); syncHandleState(); @@ -887,6 +898,23 @@ export function createCanvasController( persistenceStatusStore = fallbackStatusStore; }); + function insertStencil(stencil: Stencil, worldPos: { x: number; y: number }) { + const state = store.getState(); + const nextState = stencils.insertStencil(state, stencil, worldPos, snapStore.get()); + + runtime.commit( + state, + nextState, + 'Insert Stencil', + Action.keyDown('InsertStencil', 'InsertStencil', { + ctrl: false, + shift: false, + alt: false, + meta: false + }) + ); + } + return { platform: () => platform, desktop, @@ -944,21 +972,4 @@ export function createCanvasController( stencilPaletteOpen = val; } }; - - function insertStencil(stencil: Stencil, worldPos: { x: number; y: number }) { - const state = store.getState(); - const nextState = stencils.insertStencil(state, stencil, worldPos, snapStore.get()); - - runtime.commit( - state, - nextState, - 'Insert Stencil', - Action.keyDown('InsertStencil', 'InsertStencil', { - ctrl: false, - shift: false, - alt: false, - meta: false - }) - ); - } } diff --git a/packages/ui/src/lib/editor/platform.ts b/packages/ui/src/lib/editor/platform.ts index 869015f..338711b 100644 --- a/packages/ui/src/lib/editor/platform.ts +++ b/packages/ui/src/lib/editor/platform.ts @@ -144,6 +144,8 @@ export type EditorPlatformSession = { desktop?: DesktopDocumentRepo; inspectBoard?: (boardId: string) => Promise; setActiveBoard?: (boardId: string | null) => void; + /** Supplies the current editor projection to a Rust-backed browser session. */ + setActiveDocument?: (boardId: string, doc: import('@inkfinite/core').LoadedDoc) => void; subscribeFileMenu?: (listener: (action: NativeFileMenuAction) => void) => () => void; dispose?: () => void; }; diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts index d4a51d7..0d80f40 100644 --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -1,8 +1,7 @@ import type { DocumentSnapshot } from '@inkfinite/bindings/model'; import type { EditorProjection, EditorReconciliationRequest } from '@inkfinite/bindings/editor'; -import type { TransactionDraft } from '@inkfinite/bindings/transaction'; +import type { CommitResult, TransactionDraft } from '@inkfinite/bindings/transaction'; import type { SvgImport } from '@inkfinite/bindings/svg-import'; - export type { DocumentSnapshot } from '@inkfinite/bindings/model'; export type { EditorPatch, @@ -10,7 +9,7 @@ export type { EditorReconciliationRequest, EditorTransform } from '@inkfinite/bindings/editor'; -export type { TransactionDraft } from '@inkfinite/bindings/transaction'; +export type { TransactionDraft, CommitResult } from '@inkfinite/bindings/transaction'; export type { SvgImport, SvgImportWarning } from '@inkfinite/bindings/svg-import'; /** Structured error returned when Rust rejects an SVG. */ @@ -52,13 +51,111 @@ export type EditorReconciliationResponse = | { status: 'success'; transaction: TransactionDraft } | { status: 'error'; error: { code: string; message: string } }; -type GeneratedWasmModule = { +interface GeneratedDocumentSession { + state_json(): string; + save(): Uint8Array; + apply_transaction(transactionJson: string): string; + apply_editor_patches(requestJson: string): string; + undo(): string; + redo(): string; + can_undo(): boolean; + can_redo(): boolean; + free(): void; +} + +interface 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; -}; + create_document(snapshotJson: string, actorId: string): GeneratedDocumentSession; + open_document(bytes: Uint8Array, actorId: string): GeneratedDocumentSession; +} + +/** Stable failure returned by a stateful browser document session. */ +export type DocumentSessionFailure = { code: string; message: string }; + +/** Successful mutation envelope returned by the Rust document engine. */ +export type DocumentMutationResponse = + | { status: 'success'; commit: CommitResult } + | { status: 'error'; error: DocumentSessionFailure }; + +/** Snapshot and history capabilities owned by one Rust document session. */ +export type DocumentSessionState = { snapshot: DocumentSnapshot; can_undo: boolean; can_redo: boolean }; + +/** A stateful WASM document session. Keep one instance per worker/document. */ +export class WasmDocumentSession { + private constructor(private readonly session: GeneratedDocumentSession) {} + + /** Creates a session from a normalized snapshot, used for new or migrated boards. */ + static async create(snapshot: DocumentSnapshot, actorId: string): Promise { + const module = await loadModule(); + return new WasmDocumentSession(module.create_document(JSON.stringify(snapshot), actorId)); + } + + /** Opens a session from canonical Automerge bytes. */ + static async open(bytes: Uint8Array, actorId: string): Promise { + const module = await loadModule(); + return new WasmDocumentSession(module.open_document(bytes, actorId)); + } + + /** Returns the current snapshot and Rust-owned undo/redo capabilities. */ + state(): DocumentSessionState { + return JSON.parse(this.session.state_json()) as DocumentSessionState; + } + + /** Returns canonical Automerge bytes for persistence. */ + save(): Uint8Array { + return this.session.save(); + } + + /** Applies one transaction through Rust validation and history. */ + applyTransaction(transaction: TransactionDraft): CommitResult { + return mutation(this.session.apply_transaction(JSON.stringify(transaction))); + } + + /** Reconciles semantic editor patches and commits the resulting transaction. */ + applyEditorPatches(request: EditorReconciliationRequest): CommitResult { + return mutation(this.session.apply_editor_patches(JSON.stringify(request))); + } + + /** Compensates the latest transaction for this session actor. */ + undo(): CommitResult { + return mutation(this.session.undo()); + } + + /** Reapplies the latest compensated transaction for this session actor. */ + redo(): CommitResult { + return mutation(this.session.redo()); + } + + /** Reports whether the session actor can undo. */ + canUndo(): boolean { + return this.session.can_undo(); + } + + /** Reports whether the session actor can redo. */ + canRedo(): boolean { + return this.session.can_redo(); + } + + /** Releases the Rust session. */ + dispose(): void { + this.session.free(); + } +} + +/** Error thrown when Rust rejects a document mutation. */ +export class DocumentSessionError extends Error { + constructor( + readonly code: string, + message: string + ) { + super(message); + this.name = 'DocumentSessionError'; + } +} let modulePromise: Promise | null = null; @@ -73,7 +170,9 @@ export async function importSvg(source: Uint8Array): Promise 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); + if (response.status === 'error') { + throw new Error(response.error.message); + } return response.projection; } @@ -86,7 +185,9 @@ export async function reconcileEditorPatches( 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); + if (response.status === 'error') { + throw new Error(response.error.message); + } return response.transaction; } @@ -102,6 +203,14 @@ export async function renderSvg( return response; } +function mutation(serialized: string): CommitResult { + const response = JSON.parse(serialized) as DocumentMutationResponse; + if (response.status === 'error') { + throw new DocumentSessionError(response.error.code, response.error.message); + } + return response.commit; +} + /** Clears the lazy module cache, primarily for worker tests and hot reload. */ export function resetWasmModuleForTests() { modulePromise = null; -- 2.51.2