From 170dbd2fbe87afbc31d936227089a26463e6af99 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Thu, 20 Aug 2026 20:55:03 +0000 Subject: [PATCH] refactor: mv browser docs into rs sessions --- CHANGELOG.md | 12 ++++++------ ROADMAP.md | 91 +++++++++++++++++++++++++++++++++++++++++++++---------------------------------------------- TODO.md | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------- packages/bindings/package.json | 3 ++- crates/inkfinite-core/src/lib.rs | 1 + crates/inkfinite-core/src/wasm.rs | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ crates/inkfinite-wasm/src/lib.rs | 383 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- packages/bindings/src/index.ts | 1 + packages/bindings/src/wasm.ts | 281 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/core/src/interchange.ts | 39 +++------------------------------------ packages/core/src/model.ts | 27 --------------------------- packages/wasm/src/index.ts | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------- crates/inkfinite-cli/src/bin/generate-bindings.rs | 28 +++++++++++++++++++++++++++- packages/core/src/interchange/svg.ts | 365 ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- packages/core/src/persistence/canonical.ts | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- packages/core/src/persistence/document.ts | 12 ++++++++---- apps/web/src/content/docs/internals.md | 10 +++++----- apps/web/src/lib/persistence/dexie.ts | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------- apps/web/src/lib/persistence/repository.ts | 41 ++++++++++++++++++++--------------------- apps/web/src/lib/persistence/svg-import.test.ts | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------ apps/web/src/lib/persistence/svg-import.ts | 47 ++++++++++++++++++++++++++++++++++++++++++++--- apps/web/src/lib/persistence/svg-import.worker.ts | 23 +++++++++++++++++++++++ packages/ui/src/lib/editor/platform.ts | 25 +++++++++++++++++++------ packages/ui/src/lib/editor/svg-import.svelte.test.ts | 89 ----------------------------------------------------------------------------------------- apps/web/src/content/docs/internals/svg-import.md | 35 +++++++++++++++++++---------------- packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts | 41 ++++++++++++++++++++++++++++++++--------- 26 file(s) changed, 1271 insertion(s)(+), 939 deletion(s)(-) diff --git a/CHANGELOG.md b/CHANGELOG.md --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,13 +53,10 @@ valid/invalid geometry fixtures. - SVG imports committed as one validated transaction from the desktop file menu, browser file and drop entry points, and the CLI. -- Browser SVG imports use the Rust importer through a lazy WASM facade and - reusable worker. The normalized result retains groups, composed transforms, - styles, fill rules, source assets, embedded assets, warnings, and structured - failures before one IndexedDB board import. +- Browser SVG imports use the Rust document session through a lazy WASM facade + and reusable worker. - Browser SVG exports project the current board into the canonical snapshot, - render it through the Rust SVG renderer in the shared worker, and preserve - warnings. + render it through the Rust SVG renderer in the shared worker, and preserve warnings. ### Changed @@ -74,6 +71,9 @@ - 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. +- Browser canonical state now caches the Rust editor projection with Automerge + bytes. The browser SVG projector and imported-group metadata were removed, and + WASM request and response payloads are generated from Rust. ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md --- a/ROADMAP.md +++ b/ROADMAP.md @@ -92,58 +92,57 @@ ### WASM -Use WebAssembly to bring Inkfinite's canonical Rust semantics to the browser, -not to replace the browser editor. Rust should own document meaning while -TypeScript continues to own immediate interaction and browser APIs. +WebAssembly brings Inkfinite's Rust document engine to the browser. A persistent +worker session opens, mutates, undoes, redoes, and saves the canonical Automerge +document used by native entry points. IndexedDB stores canonical bytes rather +than maintaining another shape graph. SVG parsing and export, committed +geometry, editor projection, and editor reconciliation call the Rust +implementations. -The web app currently persists a TypeScript document graph in Dexie, while the -desktop app and CLI use the Rust transaction engine and Automerge document. -Desktop projection also flattens native hierarchy without composing every -ancestor transform, and its reverse mirror can delete and recreate the scene for -an ordinary edit. Shared TypeScript types do not prevent these execution paths -from diverging. +The browser still has a TypeScript editor model for low-latency interaction and +browser APIs. Pointer movement, hover, selection, camera state, drag previews, +snap guides, hit testing, Canvas rendering, PNG export, and DOM/Svelte work do +not cross the WASM interface. TypeScript creates semantic editor patches; Rust +reconciles them into validated parent-relative transactions. Rust owns committed +path geometry, exact bounds, hierarchy transforms, reparenting, topology, arc +conversion, validation, and final freehand normalization. -The first step is a thin `inkfinite-wasm` facade. SVG import establishes the -build, packaging, worker, and binding path. Deterministic SVG export should use -the Rust renderer through the same facade so browser, desktop, and CLI output -share hierarchy, bounds, and serialization behavior. Canvas rendering and PNG -export remain in TypeScript. +Canonical browser documents load their editor state from the Rust projection +returned with each session response. The projection is cached with canonical +IndexedDB state so reopening a board does not rebuild the editor view from a +second hierarchy representation. Browser SVG imports pass bytes to the worker; +the Rust session parses the source, builds the shared SVG transaction, commits it, +and returns the updated snapshot, projection, assets, and diagnostics. SVG group +compatibility metadata and the TypeScript SVG projector are no longer part of the +browser document model. -The second step moves native-to-editor projection and editor-to-native -reconciliation into Rust. Projection must compose hierarchy transforms into a -consistent editor view. Editor commits should describe semantic changes such as -moving, resizing, restyling, reparenting, or changing path geometry. Rust should -translate those changes into minimal validated transactions instead of replacing -the scene. Native and WASM callers should exercise the same projection and -reconciliation fixtures. +Desktop reconciliation has a related fallback. Page or layer creation and +deletion can still trigger the whole-document mirror even though Rust supports +semantic structural patches. Web and desktop should use one TypeScript +before-and-after patch builder, then let Rust reconcile and commit those patches. +The mirror fallback can be removed once all supported structural edits use that +path. -The third step makes the browser document session stateful in WASM. It should -open, mutate, undo, redo, and save the same canonical Automerge document used by -native entry points. IndexedDB remains the browser storage adapter, but stores -canonical document bytes rather than acting as a second shape graph. Existing -browser documents need an explicit migration before the Dexie graph can stop -being the source of truth. +The WASM request and response payloads now use generated TypeScript types, and +native editor types are reused where the representations match. The shared worker +and client still need names that describe their document-engine role rather than +SVG import. Standalone projection or reconciliation exports can be removed when +the stateful session covers their use cases. -The JS/WASM boundary should carry coarse operations and batches. Strings, -objects, and arrays may require conversion or copying, so pointer movement, -hover, selection, camera state, drag previews, snap guides, Canvas rendering, -and DOM/Svelte work remain in TypeScript. A persistent WASM session avoids -serializing the complete document for each operation. +Root verification should build and type-check the WASM package and run a browser +smoke test against the compiled module and real worker. The test should create a +document, commit a transaction, save and reopen its bytes, project editor state, +exercise undo and redo, import a representative SVG, render SVG, and verify the +canonical state after each operation. Remove fallback implementations only after +this path is covered. -Rust remains authoritative for committed path geometry, exact bounds, -transforms, reparenting, topology, arc conversion, validation, and final -freehand normalization. TypeScript may keep fast previews, cached bounds, hit -testing, and selection geometry for responsive interaction. Shared fixtures -must keep those previews acceptably aligned with committed results. Batched WASM -geometry queries should be considered only when profiling shows a need. - -Excalidraw and JSON Canvas conversion can remain TypeScript-only while they are -browser conveniences. Move them into Rust when desktop, CLI, or MCP also need -them. TypeScript history likewise continues to own ephemeral editor state; -durable document undo and redo belong to the Rust session. - -This sequencing leads to one document engine across browser, desktop, CLI, and -future MCP access without placing the frame-by-frame editor loop behind WASM. +This cleanup does not move the frame-by-frame editor loop into WASM. TypeScript +keeps responsive previews, cached bounds, hit testing, selection geometry, and +ephemeral history. Shared fixtures compare those approximations with committed +Rust results. Legacy Dexie migration may still build an initial canonical +snapshot in TypeScript, and Excalidraw and JSON Canvas conversion can remain in +TypeScript until another native entry point needs them. Add batched WASM geometry +queries only when profiling identifies a browser geometry bottleneck. ### Vector editing diff --git a/TODO.md b/TODO.md --- a/TODO.md +++ b/TODO.md @@ -61,39 +61,36 @@ ## WASM -### Browser facade +Inkfinite now runs the canonical Rust document engine in a browser worker. The browser stores +Automerge bytes, commits validated transactions, uses Rust projection and reconciliation APIs, +and shares SVG and committed-geometry behavior with native callers. TypeScript continues to own +interaction state, previews, hit testing, Canvas rendering, and browser APIs. -- [x] Establish the `inkfinite-wasm` build and TypeScript bindings -- [x] Expose deterministic Rust SVG rendering to the browser -- [x] Route browser SVG export through Rust -- [x] Keep Canvas and PNG rendering in TypeScript +### Engine integration closure -### Editor projection and reconciliation +#### Canonical browser flow -- [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 +- [x] Return the Rust editor projection with browser document session state +- [x] Hydrate canonical browser documents from the Rust projection +- [x] Commit browser SVG imports through the Rust session and SVG transaction builder +- [x] Remove the TypeScript SVG projector and imported-group compatibility metadata +- [x] Generate TypeScript types for WASM request and response payloads -### Browser document engine +#### Shared reconciliation -- [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 +- [ ] Use one TypeScript editor-patch builder across web and desktop +- [ ] Reconcile desktop page and layer changes through Rust +- [ ] Retire the desktop whole-document mirror fallback +- [ ] Reuse generated native geometry and transform types in the editor model -### Canonical geometry +#### Worker verification and cleanup -- [x] Commit path geometry through Rust validation -- [x] Commit freehand strokes through Rust normalization -- [x] Keep gesture previews and hit testing in TypeScript -- [x] Add committed-geometry parity fixtures +- [ ] Rename the shared SVG worker and client around their document-engine role +- [ ] Add browser tests against the compiled WASM module and real worker +- [ ] Cover create, mutate, save, reopen, project, undo, redo, SVG import, and SVG render +- [ ] Run WASM build, type checking, and browser smoke tests from root verification +- [ ] Remove unused TypeScript validation and generated bounds helpers after the browser tests pass +- [ ] Remove unused standalone WASM APIs after migrating their consumers ## Vector Editing @@ -404,3 +401,30 @@ - [ ] Revisit skill organization after SVG and MCP workflows stabilize - [ ] Consider separate drawing, wireframing, SVG, and MCP skills + +### Color Scheme/"Vibe" Shift + +| Mode | Background | Accent | +| --------- | ----------------------- | -------------------- | +| **Light** | `#E0E0FE` soft lavender | `#EB4999` vivid pink | +| **Dark** | `#292E6E` deep indigo | `#EB4999` vivid pink | + +The palette is based on Skeleton's **Modern** theme: `surface-50` / `surface-950` for the light and dark backgrounds, with `primary-500` as the shared brand accent. + +```css +/* Light */ +--bg: #e0e0fe; +--surface: #c7c8fb; +--text: #292e6e; +--muted: #575ad9; +--accent: #eb4999; +--link: #c92f7d; + +/* Dark */ +--bg: #292e6e; +--surface: #353988; +--text: #e0e0fe; +--muted: #aeb0f6; +--accent: #eb4999; +--link: #f06bad; +``` diff --git a/packages/bindings/package.json b/packages/bindings/package.json --- a/packages/bindings/package.json +++ b/packages/bindings/package.json @@ -8,7 +8,8 @@ "./model": "./src/model.ts", "./editor": "./src/editor.ts", "./transaction": "./src/transaction.ts", - "./svg-import": "./src/svg-import.ts" + "./svg-import": "./src/svg-import.ts", + "./wasm": "./src/wasm.ts" }, "types": "./dist/index.d.ts", "scripts": { diff --git a/crates/inkfinite-core/src/lib.rs b/crates/inkfinite-core/src/lib.rs --- a/crates/inkfinite-core/src/lib.rs +++ b/crates/inkfinite-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod svg_import; pub mod svg_transaction; pub mod sync; +pub mod wasm; use std::collections::BTreeMap; use std::fmt; diff --git a/crates/inkfinite-core/src/wasm.rs b/crates/inkfinite-core/src/wasm.rs new file mode 100644 --- /dev/null +++ b/crates/inkfinite-core/src/wasm.rs @@ -0,0 +1,205 @@ +//! JSON contracts exchanged by the browser document-engine adapter. +//! +//! These types are generated into `@inkfinite/bindings` so the worker and the +//! Rust/WASM boundary use the same request and response shapes. + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::editor::EditorProjection; +use crate::proto::{Bounds, CommitResult, TransactionDraft}; +use crate::svg_import::{SvgImport, SvgImportWarning}; +use crate::{AssetId, DocumentSnapshot, ShapeId}; + +/// A structured failure returned by a browser document-engine operation. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +pub struct WasmDocumentSessionFailure { + /// Machine-readable failure category. + pub code: String, + /// Human-readable failure detail. + pub message: String, +} + +/// State returned after opening or changing a browser document session. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +pub struct WasmDocumentSessionState { + /// Current canonical materialized snapshot. + pub snapshot: DocumentSnapshot, + /// Current Rust-owned editor projection. + pub editor_projection: EditorProjection, + /// 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, +} + +/// Result of a stateful browser document mutation. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case", tag = "status")] +pub enum WasmDocumentMutationResponse { + /// 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: WasmDocumentSessionFailure, + }, +} + +/// Options accepted by the deterministic browser SVG renderer. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, TS)] +#[serde(default)] +pub struct WasmSvgRenderOptions { + /// Page to render, or the first page when omitted. + pub page_id: Option, + /// Layers to include. + pub layer_ids: Vec, + /// Shapes to include. + pub selection: Vec, + /// Optional clipping region. + pub region: Option, + /// Font families available to the browser renderer. + pub available_font_families: Vec, + /// Asset IDs available to the browser renderer. + pub available_asset_ids: Vec, +} + +/// A normalized SVG import failure. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +pub struct WasmSvgImportFailure { + /// Machine-readable failure category. + pub code: String, + /// Human-readable failure detail. + pub message: String, +} + +/// Result of parsing SVG bytes without mutating a document. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case", tag = "status")] +pub enum WasmSvgImportResponse { + /// Normalized import tree and retained assets. + Success { + /// The normalized Rust import tree. + import: Box, + /// Number of embedded image nodes omitted by the current shape registry. + omitted_image_count: usize, + }, + /// The source could not be imported. + Error { + /// Import failure details. + error: WasmSvgImportFailure, + }, +} + +/// Result of importing SVG bytes into a browser document session. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case", tag = "status")] +pub enum WasmSvgImportCommitResponse { + /// Imported content committed as one native transaction. + Success { + /// Session state after the import. + state: Box, + /// Non-fatal parser warnings. + warnings: Vec, + /// Number of embedded image nodes omitted by the current shape registry. + omitted_image_count: usize, + /// Native shape IDs created by the transaction. + shape_ids: Vec, + /// Retained source asset ID. + source_asset_id: AssetId, + }, + /// The import was rejected without changing the session. + Error { + /// Import failure details. + error: WasmDocumentSessionFailure, + }, +} + +/// A deterministic SVG render warning. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +pub struct WasmSvgRenderWarning { + /// Machine-readable warning category. + pub code: String, + /// Human-readable warning detail. + pub message: String, +} + +/// A deterministic SVG render failure. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +pub struct WasmSvgRenderFailure { + /// Machine-readable failure category. + pub code: String, + /// Human-readable failure detail. + pub message: String, +} + +/// Result of rendering a canonical browser snapshot. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case", tag = "status")] +pub enum WasmSvgRenderResponse { + /// Deterministic SVG and non-fatal resource warnings. + Success { + /// Complete SVG markup. + svg: String, + /// Renderer warnings. + warnings: Vec, + }, + /// The snapshot or render request was invalid. + Error { + /// Render failure details. + error: WasmSvgRenderFailure, + }, +} + +/// An editor projection failure. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +pub struct WasmEditorProjectionFailure { + /// Machine-readable failure category. + pub code: String, + /// Human-readable failure detail. + pub message: String, +} + +/// Result of projecting a canonical browser snapshot. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case", tag = "status")] +pub enum WasmEditorProjectionResponse { + /// Rust-owned flat editor projection. + Success { + /// Projected editor records and order. + projection: EditorProjection, + }, + /// The snapshot could not be decoded or projected. + Error { + /// Projection failure details. + error: WasmEditorProjectionFailure, + }, +} + +/// An editor reconciliation failure. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +pub struct WasmEditorReconciliationFailure { + /// Machine-readable failure category. + pub code: String, + /// Human-readable failure detail. + pub message: String, +} + +/// Result of translating semantic editor patches into a native transaction. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case", tag = "status")] +pub enum WasmEditorReconciliationResponse { + /// Native transaction draft ready for the document session. + Success { + /// Transaction draft using the supplied causal heads. + transaction: TransactionDraft, + }, + /// The patches could not be translated. + Error { + /// Reconciliation failure details. + error: WasmEditorReconciliationFailure, + }, +} diff --git a/crates/inkfinite-wasm/src/lib.rs b/crates/inkfinite-wasm/src/lib.rs --- a/crates/inkfinite-wasm/src/lib.rs +++ b/crates/inkfinite-wasm/src/lib.rs @@ -8,98 +8,31 @@ use std::collections::BTreeSet; use inkfinite_core::editor::{ - EditorProjection, EditorReconciliationError, EditorReconciliationRequest, project_editor as project_native, + EditorReconciliationError, EditorReconciliationRequest, project_editor as project_native, reconcile_editor_patches as reconcile_native, }; use inkfinite_core::engine::{EngineError, TransactionEngine}; -use inkfinite_core::proto::{Bounds, CommitResult, TransactionDraft}; +use inkfinite_core::proto::{TransactionDraft, TransactionId}; 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::{ - ActorId, AssetId, DocumentId, DocumentSnapshot, INKFINITE_FORMAT_ID, INKFINITE_FORMAT_VERSION, LayerId, PageId, - ShapeId, +use inkfinite_core::svg_import::{SvgImportError, SvgImportNode, import_svg as parse_svg}; +use inkfinite_core::svg_transaction::{SvgImportTransactionOptions, build_svg_import_transaction}; +use inkfinite_core::wasm::{ + WasmDocumentMutationResponse as DocumentMutationResponse, WasmDocumentSessionFailure as DocumentSessionFailure, + WasmDocumentSessionState as DocumentSessionState, WasmEditorProjectionFailure as EditorProjectionFailure, + WasmEditorProjectionResponse as EditorProjectionResponse, + WasmEditorReconciliationFailure as EditorReconciliationFailure, + WasmEditorReconciliationResponse as EditorReconciliationResponse, + WasmSvgImportCommitResponse as SvgImportCommitResponse, WasmSvgImportFailure as SvgImportFailure, + WasmSvgImportResponse as SvgImportResponse, WasmSvgRenderFailure as SvgRenderFailure, + WasmSvgRenderOptions as SvgRenderOptionsInput, WasmSvgRenderResponse as SvgRenderResponse, + WasmSvgRenderWarning as SvgRenderWarningResponse, }; -use serde::{Deserialize, Serialize}; +use inkfinite_core::{ + ActorId, AssetId, DocumentId, DocumentSnapshot, INKFINITE_FORMAT_ID, INKFINITE_FORMAT_VERSION, LayerId, Origin, + PageId, ShapeId, Timestamp, +}; +use serde::Serialize; use wasm_bindgen::prelude::*; - -/// The result envelope exchanged between the SVG worker and the browser. -#[derive(Debug, Serialize)] -#[serde(tag = "status", rename_all = "snake_case")] -pub enum SvgImportResponse { - /// A normalized import and the image nodes that the current browser model omits. - Success { - /// The normalized Rust import tree. - import: Box, - /// Number of embedded image nodes in the tree. - omitted_image_count: usize, - }, - /// A structured failure that did not mutate a document. - Error { - /// Import failure details. - error: SvgImportFailure, - }, -} - -/// A stable error crossing the WASM boundary. -#[derive(Debug, Serialize)] -pub struct SvgImportFailure { - /// Machine-readable failure category. - pub code: &'static str, - /// Human-readable failure detail. - pub message: String, -} - -/// The result envelope exchanged for deterministic browser SVG rendering. -#[derive(Debug, Serialize)] -#[serde(tag = "status", rename_all = "snake_case")] -pub enum SvgRenderResponse { - /// The rendered SVG and any non-fatal resource warnings. - Success { - /// Complete deterministic SVG markup. - svg: String, - /// Warnings emitted while resolving render resources. - warnings: Vec, - }, - /// The snapshot or render request could not be processed. - Error { - /// Render failure details. - error: SvgRenderFailure, - }, -} - -/// A stable render error crossing the WASM boundary. -#[derive(Debug, Serialize)] -pub struct SvgRenderFailure { - /// Machine-readable failure category. - pub code: &'static str, - /// Human-readable failure detail. - 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. /// @@ -121,6 +54,7 @@ .snapshot() .map_err(|error| JsValue::from_str(&error.to_string()))?; serialize_result(&DocumentSessionState { + editor_projection: project_native(&snapshot), can_undo: self.engine.can_undo(&self.actor_id), can_redo: self.engine.can_redo(&self.actor_id), snapshot, @@ -147,7 +81,7 @@ if transaction.actor_id != self.actor_id { return serialize_response(DocumentMutationResponse::Error { error: DocumentSessionFailure { - code: "actor_mismatch", + code: "actor_mismatch".into(), message: "transaction actor does not belong to this session".into(), }, }); @@ -171,7 +105,7 @@ if request.actor_id != self.actor_id { return serialize_response(DocumentMutationResponse::Error { error: DocumentSessionFailure { - code: "actor_mismatch", + code: "actor_mismatch".into(), message: "editor request actor does not belong to this session".into(), }, }); @@ -184,14 +118,14 @@ Ok(transaction) => transaction, Err(error) => { return serialize_response(DocumentMutationResponse::Error { - error: DocumentSessionFailure { code: "editor_reconciliation", message: error.to_string() }, + error: DocumentSessionFailure { code: "editor_reconciliation".into(), message: error.to_string() }, }); } }; if transaction.operations.is_empty() { return serialize_response(DocumentMutationResponse::Error { error: DocumentSessionFailure { - code: "no_changes", + code: "no_changes".into(), message: "editor patches contain no document changes".into(), }, }); @@ -200,6 +134,116 @@ Ok(commit) => serialize_response(DocumentMutationResponse::Success { commit }), Err(error) => serialize_response(DocumentMutationResponse::Error { error: engine_failure(&error) }), } + } + + /// Imports and commits one SVG as a single native transaction. + pub fn import_svg_document( + &mut self, source: &[u8], source_name: &str, page_id: &str, layer_id: &str, timestamp: f64, + ) -> String { + let import = match parse_svg(source) { + Ok(import) => import, + Err(error) => { + let error = failure(&error); + return serialize_response(SvgImportCommitResponse::Error { + error: DocumentSessionFailure { code: error.code, message: error.message }, + }); + } + }; + let snapshot = match self.engine.snapshot() { + Ok(snapshot) => snapshot, + Err(error) => { + return serialize_response(SvgImportCommitResponse::Error { error: engine_failure(&error) }); + } + }; + let page_id = if page_id.trim().is_empty() { + match snapshot.document.page_ids.first() { + Some(page_id) => page_id.clone(), + None => { + return serialize_response(SvgImportCommitResponse::Error { + error: DocumentSessionFailure { + code: "missing_page".into(), + message: "document has no page for SVG import".into(), + }, + }); + } + } + } else { + PageId::from(page_id) + }; + let page = match snapshot.document.pages.get(&page_id) { + Some(page) => page, + None => { + return serialize_response(SvgImportCommitResponse::Error { + error: DocumentSessionFailure { + code: "missing_page".into(), + message: format!("SVG import page {page_id} does not exist"), + }, + }); + } + }; + let layer_id = if layer_id.trim().is_empty() { + match page.layer_ids.first() { + Some(layer_id) => layer_id.clone(), + None => { + return serialize_response(SvgImportCommitResponse::Error { + error: DocumentSessionFailure { + code: "missing_layer".into(), + message: format!("SVG import page {page_id} has no layer"), + }, + }); + } + } + } else { + LayerId::from(layer_id) + }; + let source_name = (!source_name.trim().is_empty()).then(|| source_name.to_owned()); + let transaction = match build_svg_import_transaction( + &snapshot, + &import, + SvgImportTransactionOptions { + actor_id: self.actor_id.clone(), + origin: Origin::Human, + page_id, + layer_id, + transaction_id: TransactionId(format!( + "transaction:svg-import:{}", + import.source_asset.digest.replace(':', "-") + )), + description: source_name + .as_deref() + .map(|name| format!("Import SVG {name}")) + .unwrap_or_else(|| "Import SVG".into()), + source_name, + timestamp: Timestamp(timestamp as i64), + }, + ) { + Ok(transaction) => transaction, + Err(error) => { + return serialize_response(SvgImportCommitResponse::Error { + error: DocumentSessionFailure { code: "svg_import".into(), message: error.to_string() }, + }); + } + }; + let shape_ids = transaction.shape_ids; + let asset_id = import.source_asset.id.clone(); + let omitted_image_count = transaction.omitted_image_count; + let warnings = import.warnings; + if let Err(error) = self.engine.commit(transaction.transaction) { + return serialize_response(SvgImportCommitResponse::Error { error: engine_failure(&error) }); + } + let state = match self.session_state() { + Ok(state) => state, + Err(error) => { + return serialize_response(SvgImportCommitResponse::Error { error: engine_failure(&error) }); + } + }; + serialize_response(SvgImportCommitResponse::Success { + state: Box::new(state), + warnings, + omitted_image_count, + shape_ids, + source_asset_id: asset_id, + }) } /// Compensates the latest transaction committed by this session actor. @@ -229,108 +273,30 @@ pub fn can_redo(&self) -> bool { self.engine.can_redo(&self.actor_id) } + + fn session_state(&mut self) -> Result { + let snapshot = self.engine.snapshot()?; + Ok(DocumentSessionState { + editor_projection: project_native(&snapshot), + can_undo: self.engine.can_undo(&self.actor_id), + can_redo: self.engine.can_redo(&self.actor_id), + snapshot, + }) + } } -/// 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")] -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 { - /// Machine-readable warning category. - pub code: &'static str, - /// Human-readable warning detail. - pub message: String, -} - -#[derive(Debug, Default, Deserialize)] -struct SvgRenderOptionsInput { - page_id: Option, - #[serde(default)] - layer_ids: Vec, - #[serde(default)] - selection: Vec, - region: Option, - #[serde(default)] - available_font_families: Vec, - #[serde(default)] - available_asset_ids: Vec, -} - -impl From for SvgRenderOptions { - fn from(input: SvgRenderOptionsInput) -> Self { - Self { - page_id: input.page_id.map(PageId::from), - layer_ids: input.layer_ids.into_iter().map(LayerId::from).collect::>(), - selection: input.selection.into_iter().map(ShapeId::from).collect::>(), - region: input.region, - available_font_families: input.available_font_families.into_iter().collect::>(), - available_asset_ids: input - .available_asset_ids - .into_iter() - .map(AssetId::from) - .collect::>(), - } +fn render_options(input: SvgRenderOptionsInput) -> SvgRenderOptions { + SvgRenderOptions { + page_id: input.page_id.map(PageId::from), + layer_ids: input.layer_ids.into_iter().map(LayerId::from).collect::>(), + selection: input.selection.into_iter().map(ShapeId::from).collect::>(), + region: input.region, + available_font_families: input.available_font_families.into_iter().collect::>(), + available_asset_ids: input + .available_asset_ids + .into_iter() + .map(AssetId::from) + .collect::>(), } } @@ -409,7 +375,7 @@ Ok(snapshot) => snapshot, Err(error) => { return serialize_response(EditorProjectionResponse::Error { - error: EditorProjectionFailure { code: "invalid_snapshot", message: error.to_string() }, + error: EditorProjectionFailure { code: "invalid_snapshot".into(), message: error.to_string() }, }); } }; @@ -429,7 +395,7 @@ Ok(snapshot) => snapshot, Err(error) => { return serialize_response(EditorReconciliationResponse::Error { - error: EditorReconciliationFailure { code: "invalid_snapshot", message: error.to_string() }, + error: EditorReconciliationFailure { code: "invalid_snapshot".into(), message: error.to_string() }, }); } }; @@ -437,7 +403,7 @@ Ok(request) => request, Err(error) => { return serialize_response(EditorReconciliationResponse::Error { - error: EditorReconciliationFailure { code: "invalid_request", message: error.to_string() }, + error: EditorReconciliationFailure { code: "invalid_request".into(), message: error.to_string() }, }); } }; @@ -460,16 +426,16 @@ Ok(snapshot) => snapshot, Err(error) => { return serialize_response(SvgRenderResponse::Error { - error: SvgRenderFailure { code: "invalid_snapshot", message: error.to_string() }, + error: SvgRenderFailure { code: "invalid_snapshot".into(), message: error.to_string() }, }); } }; let options_source = if options_json.trim().is_empty() { "{}" } else { options_json }; let options = match serde_json::from_str::(options_source) { - Ok(options) => options.into(), + Ok(options) => render_options(options), Err(error) => { return serialize_response(SvgRenderResponse::Error { - error: SvgRenderFailure { code: "invalid_options", message: error.to_string() }, + error: SvgRenderFailure { code: "invalid_options".into(), message: error.to_string() }, }); } }; @@ -488,7 +454,7 @@ } fn invalid_json_failure(code: &'static str, error: &serde_json::Error) -> DocumentSessionFailure { - DocumentSessionFailure { code, message: error.to_string() } + DocumentSessionFailure { code: code.into(), message: error.to_string() } } fn engine_failure(error: &EngineError) -> DocumentSessionFailure { @@ -508,7 +474,7 @@ } } }; - DocumentSessionFailure { code, message: error.to_string() } + DocumentSessionFailure { code: code.into(), message: error.to_string() } } fn serialize_response(response: T) -> String { @@ -530,7 +496,7 @@ EditorReconciliationError::SingularParent { .. } => "singular_parent", EditorReconciliationError::UnsupportedShear { .. } => "unsupported_shear", }; - EditorReconciliationFailure { code, message: error.to_string() } + EditorReconciliationFailure { code: code.into(), message: error.to_string() } } fn render_failure(error: &SvgRenderError) -> SvgRenderFailure { @@ -539,7 +505,7 @@ SvgRenderError::InvalidRegion => "invalid_region", SvgRenderError::InvalidShapeProperties { .. } => "invalid_shape_properties", }; - SvgRenderFailure { code, message: error.to_string() } + SvgRenderFailure { code: code.into(), message: error.to_string() } } fn render_warning(warning: &SvgRenderWarning) -> SvgRenderWarningResponse { @@ -548,7 +514,7 @@ SvgRenderWarning::MissingAsset { .. } => "missing_asset", SvgRenderWarning::UnresolvedExternalAsset { .. } => "unresolved_external_asset", }; - SvgRenderWarningResponse { code, message: warning.to_string() } + SvgRenderWarningResponse { code: code.into(), message: warning.to_string() } } fn count_images(group: &inkfinite_core::svg_import::SvgGroup) -> usize { @@ -574,7 +540,7 @@ SvgImportError::UnsupportedTransform { .. } => "unsupported_transform", SvgImportError::InvalidImage { .. } => "invalid_image", }; - SvgImportFailure { code, message: error.to_string() } + SvgImportFailure { code: code.into(), message: error.to_string() } } fn escape_json_string(value: &str) -> String { @@ -795,6 +761,7 @@ assert_eq!(response["status"], "success"); let state: Value = serde_json::from_str(&session.state_json().expect("state should serialize")).expect("state JSON"); + assert!(state["editor_projection"].is_object()); assert!(state["snapshot"]["document"]["shapes"]["shape:rect"].is_object()); assert!(state["snapshot"]["document"]["shapes"]["shape:path"].is_object()); assert_eq!( @@ -813,5 +780,17 @@ let redo: Value = serde_json::from_str(&session.redo()).expect("redo response"); assert_eq!(redo["status"], "success"); assert!(session.can_undo()); + + let imported: Value = serde_json::from_str(&session.import_svg_document( + br#""#, + "icon.svg", + "page:one", + "layer:one", + 1.0, + )) + .expect("SVG import response"); + assert_eq!(imported["status"], "success"); + assert_eq!(imported["shape_ids"].as_array().map(Vec::len), Some(3)); + assert!(imported["state"]["editor_projection"]["shapes"].is_object()); } } diff --git a/packages/bindings/src/index.ts b/packages/bindings/src/index.ts --- a/packages/bindings/src/index.ts +++ b/packages/bindings/src/index.ts @@ -7,3 +7,4 @@ export * from './svg-import.js'; export * from './editor.js'; export * from './transaction.js'; +export * from './wasm.js'; diff --git a/packages/bindings/src/wasm.ts b/packages/bindings/src/wasm.ts new file mode 100644 --- /dev/null +++ b/packages/bindings/src/wasm.ts @@ -0,0 +1,281 @@ +// 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 { Bounds, CommitResult, TransactionDraft } from './transaction.js'; +import type { AssetId, DocumentSnapshot, ShapeId } from './model.js'; +import type { EditorProjection } from './editor.js'; +import type { SvgImport, SvgImportWarning } from './svg-import.js'; + +/** + * A structured failure returned by a browser document-engine operation. + */ +export type WasmDocumentSessionFailure = { + /** + * Machine-readable failure category. + */ + code: string; + /** + * Human-readable failure detail. + */ + message: string; +}; + +/** + * State returned after opening or changing a browser document session. + */ +export type WasmDocumentSessionState = { + /** + * Current canonical materialized snapshot. + */ + snapshot: DocumentSnapshot; + /** + * Current Rust-owned editor projection. + */ + editor_projection: EditorProjection; + /** + * Whether the session actor can compensate its latest transaction. + */ + can_undo: boolean; + /** + * Whether the session actor can reapply its latest compensated transaction. + */ + can_redo: boolean; +}; + +/** + * Result of a stateful browser document mutation. + */ +export type WasmDocumentMutationResponse = + | { + status: 'success'; + /** + * Materialized commit metadata. + */ + commit: CommitResult; + } + | { + status: 'error'; + /** + * Mutation failure details. + */ + error: WasmDocumentSessionFailure; + }; + +/** + * Options accepted by the deterministic browser SVG renderer. + */ +export type WasmSvgRenderOptions = { + /** + * Page to render, or the first page when omitted. + */ + page_id: string | null; + /** + * Layers to include. + */ + layer_ids: Array; + /** + * Shapes to include. + */ + selection: Array; + /** + * Optional clipping region. + */ + region: Bounds | null; + /** + * Font families available to the browser renderer. + */ + available_font_families: Array; + /** + * Asset IDs available to the browser renderer. + */ + available_asset_ids: Array; +}; + +/** + * A normalized SVG import failure. + */ +export type WasmSvgImportFailure = { + /** + * Machine-readable failure category. + */ + code: string; + /** + * Human-readable failure detail. + */ + message: string; +}; + +/** + * Result of parsing SVG bytes without mutating a document. + */ +export type WasmSvgImportResponse = + | { + status: 'success'; + /** + * The normalized Rust import tree. + */ + import: SvgImport; + /** + * Number of embedded image nodes omitted by the current shape registry. + */ + omitted_image_count: number; + } + | { + status: 'error'; + /** + * Import failure details. + */ + error: WasmSvgImportFailure; + }; + +/** + * Result of importing SVG bytes into a browser document session. + */ +export type WasmSvgImportCommitResponse = + | { + status: 'success'; + /** + * Session state after the import. + */ + state: WasmDocumentSessionState; + /** + * Non-fatal parser warnings. + */ + warnings: Array; + /** + * Number of embedded image nodes omitted by the current shape registry. + */ + omitted_image_count: number; + /** + * Native shape IDs created by the transaction. + */ + shape_ids: Array; + /** + * Retained source asset ID. + */ + source_asset_id: AssetId; + } + | { + status: 'error'; + /** + * Import failure details. + */ + error: WasmDocumentSessionFailure; + }; + +/** + * A deterministic SVG render warning. + */ +export type WasmSvgRenderWarning = { + /** + * Machine-readable warning category. + */ + code: string; + /** + * Human-readable warning detail. + */ + message: string; +}; + +/** + * A deterministic SVG render failure. + */ +export type WasmSvgRenderFailure = { + /** + * Machine-readable failure category. + */ + code: string; + /** + * Human-readable failure detail. + */ + message: string; +}; + +/** + * Result of rendering a canonical browser snapshot. + */ +export type WasmSvgRenderResponse = + | { + status: 'success'; + /** + * Complete SVG markup. + */ + svg: string; + /** + * Renderer warnings. + */ + warnings: Array; + } + | { + status: 'error'; + /** + * Render failure details. + */ + error: WasmSvgRenderFailure; + }; + +/** + * An editor projection failure. + */ +export type WasmEditorProjectionFailure = { + /** + * Machine-readable failure category. + */ + code: string; + /** + * Human-readable failure detail. + */ + message: string; +}; + +/** + * Result of projecting a canonical browser snapshot. + */ +export type WasmEditorProjectionResponse = + | { + status: 'success'; + /** + * Projected editor records and order. + */ + projection: EditorProjection; + } + | { + status: 'error'; + /** + * Projection failure details. + */ + error: WasmEditorProjectionFailure; + }; + +/** + * An editor reconciliation failure. + */ +export type WasmEditorReconciliationFailure = { + /** + * Machine-readable failure category. + */ + code: string; + /** + * Human-readable failure detail. + */ + message: string; +}; + +/** + * Result of translating semantic editor patches into a native transaction. + */ +export type WasmEditorReconciliationResponse = + | { + status: 'success'; + /** + * Transaction draft using the supplied causal heads. + */ + transaction: TransactionDraft; + } + | { + status: 'error'; + /** + * Reconciliation failure details. + */ + error: WasmEditorReconciliationFailure; + }; diff --git a/packages/core/src/interchange.ts b/packages/core/src/interchange.ts --- a/packages/core/src/interchange.ts +++ b/packages/core/src/interchange.ts @@ -1,16 +1,13 @@ import type { BoardExport } from './persistence/document'; import { exportExcalidraw, importExcalidraw } from './interchange/excalidraw'; import { exportJsonCanvas, importJsonCanvas } from './interchange/json-canvas'; -import { projectSvgImport, type SvgImportResult } from './interchange/svg'; -export { projectSvgImport } from './interchange/svg'; -export type { SvgImportResult } from './interchange/svg'; import { object } from './interchange/shared'; /** External editable document formats supported by Inkfinite. */ export type InterchangeFormat = 'excalidraw' | 'json-canvas'; /** Formats accepted by the import boundary. */ -export type InterchangeImportFormat = InterchangeFormat | 'svg'; +export type InterchangeImportFormat = InterchangeFormat; /** A non-fatal loss or compatibility decision made during conversion. */ export type InterchangeWarning = { code: string; message: string; count: number }; @@ -50,20 +47,10 @@ const MAX_IMPORT_BYTES = 16 * 1024 * 1024; -/** Returns whether a filename or text prefix identifies an SVG source. */ -export function isSvgImport(contents: string | Uint8Array, fileName: string): boolean { - if (fileName.toLowerCase().endsWith('.svg')) return true; - if (typeof contents !== 'string') return false; - return contents.trimStart().startsWith(' MAX_IMPORT_BYTES) { throw new Error('The selected file is larger than the 16 MB import limit.'); - } - if (isSvgImport(contents, fileName)) { - throw new Error('SVG imports must use the asynchronous browser importer.'); } let value: unknown; try { @@ -81,28 +68,8 @@ throw new Error('Choose an Excalidraw (.excalidraw) or Obsidian Canvas (.canvas) file.'); } -/** Imports SVG bytes through the caller-owned shared Rust/WASM importer. */ -export async function importInterchangeAsync( - contents: string | Uint8Array, - fileName: string, - importSvg: (source: Uint8Array) => Promise -): Promise { - if (byteLength(contents) > MAX_IMPORT_BYTES) { - throw new Error('The selected file is larger than the 16 MB import limit.'); - } - if (isSvgImport(contents, fileName)) { - const source = typeof contents === 'string' ? new TextEncoder().encode(contents) : contents; - const response = await importSvg(source); - return projectSvgImport(response, fileName); - } - if (typeof contents !== 'string') { - throw new Error('Non-SVG interchange imports require text content.'); - } - return importInterchange(contents, fileName); -} - -function byteLength(contents: string | Uint8Array) { - return typeof contents === 'string' ? new TextEncoder().encode(contents).byteLength : contents.byteLength; +function byteLength(contents: string) { + return new TextEncoder().encode(contents).byteLength; } /** Exports one Inkfinite page to a supported editable canvas format. */ diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -373,16 +373,6 @@ } }; -/** A retained group imported from an external document. */ -export type ImportedGroup = { - id: string; - sourceId?: string; - parentId?: string; - transform: { translation: Vec2; rotation: number; scale_x: number; scale_y: number }; - style: { opacity: number; fill_opacity: number | null; stroke_opacity: number | null }; - properties: Record; -}; - /** A retained binary asset imported from an external document. */ export type ImportedAsset = { id: string; name: string; mediaType: string; digest: string; bytes: number[] }; @@ -392,8 +382,6 @@ layers?: Record; /** Binary assets retained by interchange imports. */ assets?: Record; - /** SVG group hierarchy retained by interchange imports. */ - svgGroups?: Record; shapes: Record; bindings: Record; }; @@ -425,21 +413,6 @@ Object.entries(document.assets).map(([id, asset]) => [ id, { ...asset, bytes: [...asset.bytes] } - ]) - ) - } - : {}), - ...(document.svgGroups - ? { - svgGroups: Object.fromEntries( - Object.entries(document.svgGroups).map(([id, group]) => [ - id, - { - ...group, - transform: { ...group.transform, translation: { ...group.transform.translation } }, - style: { ...group.style }, - properties: { ...group.properties } - } ]) ) } diff --git a/packages/wasm/src/index.ts b/packages/wasm/src/index.ts --- a/packages/wasm/src/index.ts +++ b/packages/wasm/src/index.ts @@ -1,7 +1,20 @@ import type { DocumentSnapshot } from '@inkfinite/bindings/model'; import type { EditorProjection, EditorReconciliationRequest } from '@inkfinite/bindings/editor'; import type { CommitResult, TransactionDraft } from '@inkfinite/bindings/transaction'; -import type { SvgImport } from '@inkfinite/bindings/svg-import'; +import type { + WasmDocumentMutationResponse, + WasmDocumentSessionFailure, + WasmDocumentSessionState, + WasmEditorProjectionResponse, + WasmEditorReconciliationResponse, + WasmSvgImportCommitResponse, + WasmSvgImportFailure, + WasmSvgImportResponse, + WasmSvgRenderFailure, + WasmSvgRenderOptions, + WasmSvgRenderResponse, + WasmSvgRenderWarning +} from '@inkfinite/bindings/wasm'; export type { DocumentSnapshot } from '@inkfinite/bindings/model'; export type { EditorPatch, @@ -11,51 +24,60 @@ } from '@inkfinite/bindings/editor'; export type { TransactionDraft, CommitResult } from '@inkfinite/bindings/transaction'; export type { SvgImport, SvgImportWarning } from '@inkfinite/bindings/svg-import'; +export type { + WasmDocumentMutationResponse, + WasmDocumentSessionFailure, + WasmDocumentSessionState, + WasmEditorProjectionResponse, + WasmEditorReconciliationResponse, + WasmSvgImportCommitResponse, + WasmSvgImportFailure, + WasmSvgImportResponse, + WasmSvgRenderFailure, + WasmSvgRenderOptions, + WasmSvgRenderResponse, + WasmSvgRenderWarning +} from '@inkfinite/bindings/wasm'; /** Structured error returned when Rust rejects an SVG. */ -export type SvgImportFailure = { code: string; message: string }; - -/** JSON envelope emitted by the `inkfinite-wasm` crate. */ -export type SvgImportResponse = - | { status: 'success'; import: SvgImport; omitted_image_count: number } - | { status: 'error'; error: SvgImportFailure }; - -/** Options accepted by the Rust SVG renderer. Empty selections include the whole page. */ -export type SvgRenderOptions = { - page_id?: string; - layer_ids?: string[]; - selection?: string[]; - region?: { x: number; y: number; width: number; height: number }; - available_font_families?: string[]; - available_asset_ids?: string[]; -}; - +export type SvgImportFailure = WasmSvgImportFailure; +/** JSON envelope emitted by the `inkfinite-wasm` SVG importer. */ +export type SvgImportResponse = WasmSvgImportResponse; +/** JSON envelope emitted after an SVG is committed to a document session. */ +export type SvgImportCommitResponse = WasmSvgImportCommitResponse; +/** Options accepted by the Rust SVG renderer. */ +export type SvgRenderOptions = Partial; /** Structured warning emitted by deterministic Rust SVG rendering. */ -export type SvgRenderWarning = { code: string; message: string }; - +export type SvgRenderWarning = WasmSvgRenderWarning; /** Structured failure returned when Rust cannot render a snapshot. */ -export type SvgRenderFailure = { code: string; message: string }; - +export type SvgRenderFailure = WasmSvgRenderFailure; /** JSON envelope emitted by the `inkfinite-wasm` SVG renderer. */ -export type SvgRenderResponse = - | { status: 'success'; svg: string; warnings: SvgRenderWarning[] } - | { status: 'error'; error: SvgRenderFailure }; - +export type SvgRenderResponse = WasmSvgRenderResponse; /** Response envelope returned by Rust editor projection. */ -export type EditorProjectionResponse = - | { status: 'success'; projection: EditorProjection } - | { status: 'error'; error: { code: string; message: string } }; - +export type EditorProjectionResponse = WasmEditorProjectionResponse; /** Response envelope returned by Rust editor reconciliation. */ -export type EditorReconciliationResponse = - | { status: 'success'; transaction: TransactionDraft } - | { status: 'error'; error: { code: string; message: string } }; +export type EditorReconciliationResponse = WasmEditorReconciliationResponse; +/** Stable failure returned by a stateful browser document session. */ +export type DocumentSessionFailure = WasmDocumentSessionFailure; +/** Successful mutation envelope returned by the Rust document engine. */ +export type DocumentMutationResponse = WasmDocumentMutationResponse; +/** Snapshot, projection, and history capabilities owned by one Rust session. */ +export type DocumentSessionState = WasmDocumentSessionState; + +type SvgImportCommitSuccess = Extract; interface GeneratedDocumentSession { state_json(): string; save(): Uint8Array; apply_transaction(transactionJson: string): string; apply_editor_patches(requestJson: string): string; + import_svg_document( + source: Uint8Array, + sourceName: string, + pageId: string, + layerId: string, + timestamp: number + ): string; undo(): string; redo(): string; can_undo(): boolean; @@ -72,17 +94,6 @@ 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 { @@ -118,6 +129,23 @@ /** Reconciles semantic editor patches and commits the resulting transaction. */ applyEditorPatches(request: EditorReconciliationRequest): CommitResult { return mutation(this.session.apply_editor_patches(JSON.stringify(request))); + } + + /** Parses and commits an SVG through the shared Rust transaction builder. */ + importSvg( + source: Uint8Array, + sourceName = '', + pageId = '', + layerId = '', + timestamp = Date.now() + ): SvgImportCommitSuccess { + const response = JSON.parse( + this.session.import_svg_document(source, sourceName, pageId, layerId, timestamp) + ) as SvgImportCommitResponse; + if (response.status === 'error') { + throw new DocumentSessionError(response.error.code, response.error.message); + } + return response; } /** Compensates the latest transaction for this session actor. */ diff --git a/crates/inkfinite-cli/src/bin/generate-bindings.rs b/crates/inkfinite-cli/src/bin/generate-bindings.rs --- a/crates/inkfinite-cli/src/bin/generate-bindings.rs +++ b/crates/inkfinite-cli/src/bin/generate-bindings.rs @@ -10,6 +10,7 @@ use inkfinite_core::editor::*; use inkfinite_core::proto::*; use inkfinite_core::svg_import::*; +use inkfinite_core::wasm::*; use inkfinite_core::*; use schemars::JsonSchema; use serde_json::{Value, json}; @@ -147,6 +148,7 @@ svg_import_bindings(), ); artifacts.insert(PathBuf::from("packages/bindings/src/editor.ts"), editor_bindings()); + artifacts.insert(PathBuf::from("packages/bindings/src/wasm.ts"), wasm_bindings()); artifacts.insert(PathBuf::from("packages/bindings/src/index.ts"), index_bindings()); Ok(artifacts) } @@ -316,9 +318,33 @@ output } +fn wasm_bindings() -> String { + let config = ts_config(); + let mut output = GENERATED_TS_HEADER.to_owned(); + output.push_str("import type { Bounds, CommitResult, TransactionDraft } from './transaction.js';\n"); + output.push_str("import type { AssetId, DocumentSnapshot, ShapeId } from './model.js';\n"); + output.push_str("import type { EditorProjection } from './editor.js';\n"); + output.push_str("import type { SvgImport, SvgImportWarning } from './svg-import.js';\n\n"); + append_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_clean_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_clean_declaration::(&mut output, &config); + append_clean_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); + append_clean_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_clean_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 './editor.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';\nexport * from './wasm.js';\n" ) } diff --git a/packages/core/src/interchange/svg.ts b/packages/core/src/interchange/svg.ts deleted file mode 100644 --- a/packages/core/src/interchange/svg.ts +++ /dev/null @@ -1,365 +0,0 @@ -import type { SvgAsset, SvgGroup, SvgImport, SvgImportWarning, SvgShape } from '@inkfinite/bindings/svg-import'; -import type { Transform } from '@inkfinite/bindings/model'; -import { - ShapeRecord, - type Document, - type ImportedGroup, - type PathGeometry, - type PathSegment, - type ShapeRecord as Shape -} from '../model'; -import type { InterchangeImport } from '../interchange'; -import { addShape, blankSnapshot, WarningCollector } from './shared'; - -/** A source accepted by the browser WASM importer. */ -export type SvgImportSource = string | Uint8Array; - -/** Imports the normalized result produced by Rust into the browser document model. */ -export function projectSvgImport(imported: SvgImportResult, fileName: string): InterchangeImport { - const { snapshot, pageId, layerId } = blankSnapshot(fileName); - const warnings = new WarningCollector(); - const assets = [imported.source_asset, ...imported.assets]; - snapshot.doc.assets = Object.fromEntries(assets.map((asset) => [asset.id, assetRecord(asset)])); - - const context: ProjectionContext = { - pageId, - layerId, - document: snapshot.doc, - warnings, - ids: new Set(), - shapeIndex: 0, - groupIndex: 0, - groups: {} - }; - walkGroup(imported.root, IDENTITY, '', 1, context); - snapshot.doc.svgGroups = context.groups; - if (imported.omitted_image_count > 0) { - warnings.add( - 'svg-images-omitted', - 'Embedded image nodes were retained as assets but omitted because image shapes are not available yet.', - imported.omitted_image_count - ); - } - for (const warning of imported.warnings) addSvgWarning(warnings, warning); - - return { format: 'svg', snapshot, warnings: warnings.values() }; -} - -/** Normalized SVG result supplied by the generated Rust/WASM bindings. */ -export type SvgImportResult = SvgImport & { omitted_image_count: number }; - -type Matrix = { a: number; b: number; c: number; d: number; e: number; f: number }; -type Point = { x: number; y: number }; -type ProjectionContext = { - pageId: string; - layerId: string; - document: Document; - warnings: WarningCollector; - ids: Set; - shapeIndex: number; - groupIndex: number; - groups: Record; -}; - -const IDENTITY: Matrix = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; - -function walkGroup( - group: SvgGroup, - parentMatrix: Matrix, - groupPath: string, - parentOpacity: number, - context: ProjectionContext -) { - const matrix = multiply(parentMatrix, transformMatrix(group.transform)); - const opacity = parentOpacity * group.style.opacity; - const groupId = groupPath ? `svg:group:${groupPath}` : 'svg:group:root'; - const parentId = groupPath - ? groupPath.includes('/') - ? `svg:group:${groupPath.slice(0, groupPath.lastIndexOf('/'))}` - : 'svg:group:root' - : undefined; - context.groups[groupId] = { - id: groupId, - ...(group.source_id ? { sourceId: group.source_id } : {}), - ...(parentId && parentId !== groupId ? { parentId } : {}), - transform: group.transform, - style: group.style, - properties: group.properties - }; - for (const [index, node] of group.children.entries()) { - if (node.kind === 'group') { - const childName = node.value.source_id?.trim() || `group-${context.groupIndex++}`; - walkGroup(node.value, matrix, groupPath ? `${groupPath}/${childName}` : childName, opacity, context); - continue; - } - if (node.kind === 'image') continue; - const shape = projectShape(node.value, matrix, opacity, groupPath, context, index); - if (shape) addShape(context.document, context.pageId, context.layerId, shape); - } -} - -function projectShape( - source: SvgShape, - parentMatrix: Matrix, - parentOpacity: number, - groupPath: string, - context: ProjectionContext, - childIndex: number -): Shape | null { - const matrix = multiply(parentMatrix, transformMatrix(source.transform)); - const id = nextId(source.source_id, context); - const fillOpacity = source.style.fill_opacity ?? 1; - const strokeOpacity = source.style.stroke_opacity ?? 1; - const opacity = parentOpacity * source.style.opacity; - const groupId = groupPath ? `svg:group:${groupPath}` : undefined; - const properties = source.properties; - const kind = source.kind; - - switch (kind) { - case 'rect': { - const transform = decompose(matrix); - const shape = ShapeRecord.createRect( - context.pageId, - transform.e, - transform.f, - { - w: numberProperty(properties, 'width'), - h: numberProperty(properties, 'height'), - fill: paintProperty(properties, 'fill'), - stroke: paintProperty(properties, 'stroke'), - radius: numberProperty(properties, 'radius') - }, - id - ); - shape.rot = transform.rotation; - shape.props.w *= Math.abs(transform.scaleX); - shape.props.h *= Math.abs(transform.scaleY); - return styled(shape, opacity, fillOpacity, strokeOpacity, groupId); - } - case 'ellipse': { - const transform = decompose(matrix); - const shape = ShapeRecord.createEllipse( - context.pageId, - transform.e, - transform.f, - { - w: numberProperty(properties, 'width'), - h: numberProperty(properties, 'height'), - fill: paintProperty(properties, 'fill'), - stroke: paintProperty(properties, 'stroke') - }, - id - ); - shape.rot = transform.rotation; - shape.props.w *= Math.abs(transform.scaleX); - shape.props.h *= Math.abs(transform.scaleY); - return styled(shape, opacity, fillOpacity, strokeOpacity, groupId); - } - case 'line': { - const start = apply(matrix, pointProperty(properties, 'a')); - const end = apply(matrix, pointProperty(properties, 'b')); - const shape = ShapeRecord.createLine( - context.pageId, - start.x, - start.y, - { - a: { x: 0, y: 0 }, - b: { x: end.x - start.x, y: end.y - start.y }, - stroke: paintProperty(properties, 'stroke'), - width: numberProperty(properties, 'width') - }, - id - ); - return styled(shape, opacity, 1, strokeOpacity, groupId); - } - case 'path': { - const geometry = transformGeometry(properties as PathGeometry, matrix); - const shape = ShapeRecord.createPath( - context.pageId, - 0, - 0, - { - ...geometry, - fill: paintProperty(properties, 'fill'), - stroke: paintProperty(properties, 'stroke'), - stroke_width: numberProperty(properties, 'stroke_width') - }, - id - ); - return styled(shape, opacity, fillOpacity, strokeOpacity, groupId); - } - case 'text': { - const transform = decompose(matrix); - const shape = ShapeRecord.createText( - context.pageId, - transform.e, - transform.f, - { - text: stringProperty(properties, 'text'), - fontSize: numberProperty(properties, 'font_size') * Math.abs(transform.scaleY), - fontFamily: stringProperty(properties, 'font_family'), - color: paintProperty(properties, 'color') - }, - id - ); - shape.rot = transform.rotation; - return styled(shape, opacity, fillOpacity, strokeOpacity, groupId); - } - default: - context.warnings.add( - 'svg-unsupported-element', - `The Rust importer returned unsupported shape kind '${kind}' at child ${childIndex}.` - ); - return null; - } -} - -function styled( - shape: T, - opacity: number, - fillOpacity: number, - strokeOpacity: number, - groupId?: string -): T { - shape.opacity = opacity; - shape.fillOpacity = fillOpacity; - shape.strokeOpacity = strokeOpacity; - shape.groupId = groupId; - return shape; -} - -function transformGeometry(properties: PathGeometry, matrix: Matrix): PathGeometry { - return { - fill_rule: properties.fill_rule, - subpaths: properties.subpaths.map((subpath) => ({ - closed: subpath.closed, - segments: subpath.segments.map((segment) => transformSegment(segment, matrix)) - })) - }; -} - -function transformSegment(segment: PathSegment, matrix: Matrix): PathSegment { - switch (segment.type) { - case 'move': - case 'line': - return { ...segment, to: apply(matrix, segment.to) }; - case 'quadratic': - return { ...segment, control: apply(matrix, segment.control), to: apply(matrix, segment.to) }; - case 'cubic': - return { - ...segment, - control_1: apply(matrix, segment.control_1), - control_2: apply(matrix, segment.control_2), - to: apply(matrix, segment.to) - }; - } -} - -function assetRecord(asset: SvgAsset) { - return { - id: asset.id, - name: asset.name, - mediaType: asset.media_type, - digest: asset.digest, - bytes: [...asset.bytes] - }; -} - -function addSvgWarning(warnings: WarningCollector, warning: SvgImportWarning) { - switch (warning.kind) { - case 'unsupported_element': - warnings.add( - 'svg-unsupported-element', - `Skipped SVG element <${warning.element}>${warning.source_id ? ` (${warning.source_id})` : ''}: ${warning.reason}` - ); - break; - case 'unsupported_feature': - warnings.add( - `svg-${warning.feature}`, - `Skipped SVG ${warning.feature.replaceAll('_', ' ')} on <${warning.element}>${warning.source_id ? ` (${warning.source_id})` : ''}: ${warning.action.replaceAll('_', ' ')}` - ); - break; - case 'unsupported_paint': - warnings.add( - 'svg-unsupported-paint', - `Skipped SVG ${warning.property} paint on <${warning.element}>: ${warning.value}` - ); - break; - } -} - -function nextId(sourceId: string | null, context: ProjectionContext) { - const base = sourceId?.trim() ? `svg:${sourceId.trim()}` : `svg:shape:${context.shapeIndex++}`; - let id = base; - let suffix = 2; - while (context.ids.has(id)) id = `${base}:${suffix++}`; - context.ids.add(id); - return id; -} - -function transformMatrix(transform: Transform): Matrix { - const cos = Math.cos(transform.rotation); - const sin = Math.sin(transform.rotation); - return { - a: transform.scale_x * cos, - b: transform.scale_x * sin, - c: -transform.scale_y * sin, - d: transform.scale_y * cos, - e: transform.translation.x, - f: transform.translation.y - }; -} - -function multiply(left: Matrix, right: Matrix): Matrix { - return { - a: left.a * right.a + left.c * right.b, - b: left.b * right.a + left.d * right.b, - c: left.a * right.c + left.c * right.d, - d: left.b * right.c + left.d * right.d, - e: left.a * right.e + left.c * right.f + left.e, - f: left.b * right.e + left.d * right.f + left.f - }; -} - -function apply(matrix: Matrix, point: Point): Point { - return { - x: matrix.a * point.x + matrix.c * point.y + matrix.e, - y: matrix.b * point.x + matrix.d * point.y + matrix.f - }; -} - -function decompose(matrix: Matrix) { - const scaleX = Math.hypot(matrix.a, matrix.b); - const determinant = matrix.a * matrix.d - matrix.b * matrix.c; - const scaleY = determinant < 0 ? -Math.hypot(matrix.c, matrix.d) : Math.hypot(matrix.c, matrix.d); - return { - e: matrix.e, - f: matrix.f, - rotation: scaleX > Number.EPSILON ? Math.atan2(matrix.b, matrix.a) : 0, - scaleX, - scaleY - }; -} - -function numberProperty(properties: Record, name: string) { - const value = properties[name]; - return typeof value === 'number' && Number.isFinite(value) ? value : 0; -} - -function stringProperty(properties: Record, name: string) { - return typeof properties[name] === 'string' ? properties[name] : ''; -} - -function paintProperty(properties: Record, name: string) { - const value = stringProperty(properties, name); - return value === 'none' || value === 'transparent' ? '' : value; -} - -function pointProperty(properties: Record, name: string): Point { - const value = properties[name]; - if (typeof value === 'object' && value !== null && 'x' in value && 'y' in value) { - const point = value as { x: unknown; y: unknown }; - if (typeof point.x === 'number' && typeof point.y === 'number') return { x: point.x, y: point.y }; - } - return { x: 0, y: 0 }; -} diff --git a/packages/core/src/persistence/canonical.ts b/packages/core/src/persistence/canonical.ts --- a/packages/core/src/persistence/canonical.ts +++ b/packages/core/src/persistence/canonical.ts @@ -1,4 +1,9 @@ -import type { EditorPatch, EditorReconciliationRequest, EditorTransform } from '@inkfinite/bindings/editor'; +import type { + EditorPatch, + EditorProjection, + EditorReconciliationRequest, + EditorTransform +} from '@inkfinite/bindings/editor'; import type { AssetRecord as NativeAssetRecord, BindingRecord as NativeBindingRecord, @@ -122,11 +127,98 @@ } /** - * Projects a canonical Rust snapshot back into the editor's flat document. + * Hydrates the editor document from the Rust-owned flat projection. * - * The canonical snapshot remains authoritative. This conversion only creates - * the low-latency TypeScript projection used by the existing canvas runtime. + * `snapshot` is supplied only for assets because the editor projection contains + * drawable records and ordering, not binary content. */ +export function fromEditorProjection(projection: EditorProjection, snapshot?: NativeDocumentSnapshot): 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 ShapeRecord['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 === null ? {} : { groupId: shape.group_id }), + layerId: shape.layer_id, + agentEditable: shape.agent_editable, + props: shape.props as ShapeRecord['props'] + } as ShapeRecord; + } + for (const binding of Object.values(projection.bindings)) { + bindings[binding.id] = { + id: binding.id, + type: binding.type as import('../model').BindingType, + fromShapeId: binding.from_shape_id, + toShapeId: binding.to_shape_id, + handle: binding.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] + } + ] + ] + : [] + ) + ); + return { + pages, + layers, + shapes, + bindings, + ...(Object.keys(assets).length > 0 ? { assets } : {}), + order: { + pageIds: [...projection.order.page_ids], + shapeOrder: Object.fromEntries( + Object.entries(projection.order.shape_order).map(([pageId, shapeIds]) => [pageId, [...shapeIds]]) + ), + layers + } + }; +} + export function fromCanonicalDocumentSnapshot(snapshot: NativeDocumentSnapshot): LoadedDoc { const pages: Record = {}; const layers: Record = {}; diff --git a/packages/core/src/persistence/document.ts b/packages/core/src/persistence/document.ts --- a/packages/core/src/persistence/document.ts +++ b/packages/core/src/persistence/document.ts @@ -7,9 +7,9 @@ PageRecord as PageOps, type ShapeRecord, ShapeRecord as ShapeOps, - type ImportedAsset, - type ImportedGroup + type ImportedAsset } from '../model'; +import type { EditorProjection } from '@inkfinite/bindings/editor'; import type { DocumentSnapshot as NativeDocumentSnapshot } from '@inkfinite/bindings/model'; import type { BoardMeta, DocRepo } from './repo'; @@ -36,7 +36,6 @@ shapes: Record; bindings: Record; assets?: Record; - svgGroups?: Record; order: DocOrder; }; @@ -44,7 +43,12 @@ export type BoardExport = { board: BoardMeta; doc: Document; order: DocOrder }; /** Canonical bytes and their materialized cache stored by browser adapters. */ -export type CanonicalDocumentState = { bytes: Uint8Array; snapshot: NativeDocumentSnapshot }; +export type CanonicalDocumentState = { + bytes: Uint8Array; + snapshot: NativeDocumentSnapshot; + /** Rust-owned editor projection cached with canonical state for hydration. */ + projection?: EditorProjection; +}; /** One editor document change handed to a Rust-backed browser persistence adapter. */ export type EditorDocumentChange = { diff --git a/apps/web/src/content/docs/internals.md b/apps/web/src/content/docs/internals.md --- a/apps/web/src/content/docs/internals.md +++ b/apps/web/src/content/docs/internals.md @@ -62,10 +62,10 @@ editor geometry, actions, tools, stencils, interchange helpers, and browser-side persistence utilities. -These are not two canonical file formats. On desktop, the persistence adapter translates between -the generated Rust contracts in `@inkfinite/bindings` and the editor projection. The frontend keeps -the projected state for low-latency interaction; Rust remains authoritative for the native session -and `.inkfinite` file. +These are not two canonical file formats. The desktop and browser persistence adapters translate +between the generated Rust contracts in `@inkfinite/bindings` and the editor projection returned by +Rust sessions. The frontend keeps the projected state for low-latency interaction; Rust remains +authoritative for the native session and `.inkfinite` file. For the durable record structure, see [Documents](/docs/concepts/documents/). The [native path geometry guide](/docs/internals/native-path-geometry/) documents the path representation used by @@ -171,5 +171,5 @@ `ts-rs`. `pnpm bindings:generate` regenerates `@inkfinite/bindings`, while `pnpm bindings:check` verifies that checked-in generated contracts still match Rust. -This generated boundary is used for durable document, transaction, and protocol data. The +This generated boundary is used for document, transaction, protocol, and browser WASM payloads. The hand-written `@inkfinite/core` editor types remain a separate interaction-oriented representation. diff --git a/apps/web/src/lib/persistence/dexie.ts b/apps/web/src/lib/persistence/dexie.ts --- a/apps/web/src/lib/persistence/dexie.ts +++ b/apps/web/src/lib/persistence/dexie.ts @@ -1,6 +1,7 @@ import { createEditorReconciliationRequest, diffDoc, + fromEditorProjection, toCanonicalDocumentSnapshot } from '@inkfinite/core'; import type { @@ -18,7 +19,7 @@ import { liveQuery } from 'dexie'; import { InkfiniteDB } from './database'; import { createDexieDocRepo, createPersistenceSink, getBoardInspectorData } from './repository'; -import { getSharedSvgImportWorker, importSvgInWorker, renderSvgInWorker } from './svg-import'; +import { getSharedSvgImportWorker, renderSvgInWorker } from './svg-import'; import type { BrowserDocumentState } from './svg-import'; import type { PersistenceSinkOptions } from './repository'; import type { LoadedDoc } from '@inkfinite/core'; @@ -57,7 +58,7 @@ let subscription: { unsubscribe(): void } | null = null; let documentWorker: ReturnType | null = null; let documentBoardId: string | null = null; - let documentReady: Promise | null = null; + let documentReady: Promise | null = null; let documentQueue: Promise = Promise.resolve(); let transactionNumber = 0; const canonicalEnabled = @@ -94,15 +95,14 @@ 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 { + function openCanonicalDocument(boardId: string, doc: LoadedDoc): Promise { const worker = ensureDocumentWorker(); - if (!worker || !repo.loadCanonical || !repo.saveCanonical) return Promise.resolve(); + if (!worker || !repo.loadCanonical || !repo.saveCanonical) return Promise.resolve(null); documentBoardId = boardId; return (async () => { const canonical = await repo.loadCanonical!(boardId); @@ -113,16 +113,72 @@ 'browser' ); if (!canonical) await repo.saveCanonical!(boardId, state); + return fromEditorProjection(state.editor_projection, state.snapshot); })(); } - function setActiveDocument(boardId: string, doc: LoadedDoc) { - if (!canonicalEnabled) return; - if (documentBoardId === boardId && documentReady) return; + async function setActiveDocument(boardId: string, doc: LoadedDoc): Promise { + if (!canonicalEnabled) return null; + if (documentBoardId === boardId && documentReady) { + return documentReady.then(async (hydrated) => { + if (!documentWorker) return hydrated; + const state = await documentWorker.documentState(); + return fromEditorProjection(state.editor_projection, state.snapshot); + }); + } documentReady = openCanonicalDocument(boardId, doc).catch((error) => { documentBoardId = null; throw error; }); + return documentReady; + } + + async function commitSvgImport(args: { + boardId: string; + source: Uint8Array; + sourceName: string; + pageId?: string; + layerId?: string; + }) { + if (!canonicalEnabled || !repo.saveCanonical) { + throw new Error('The browser document engine is unavailable.'); + } + const current = await repo.loadDoc(args.boardId); + await setActiveDocument(args.boardId, current); + await documentReady; + const worker = ensureDocumentWorker(); + if (!worker) throw new Error('The browser document worker is unavailable.'); + const state = await worker.importDocumentSvg( + args.source, + args.sourceName, + args.pageId, + args.layerId + ); + await repo.saveCanonical!(args.boardId, { + bytes: state.bytes, + snapshot: state.snapshot, + projection: state.editor_projection + }); + return { + doc: fromEditorProjection(state.editor_projection, state.snapshot), + warnings: state.warnings.map((warning) => ({ + code: + warning.kind === 'unsupported_feature' + ? `svg-${warning.feature}` + : warning.kind === 'unsupported_element' + ? 'svg-unsupported-element' + : 'svg-unsupported-paint', + message: + warning.kind === 'unsupported_feature' + ? `Skipped SVG ${warning.feature.replaceAll('_', ' ')}` + : warning.kind === 'unsupported_element' + ? `Skipped SVG element <${warning.element}>: ${warning.reason}` + : `Skipped SVG ${warning.property} paint: ${warning.value}`, + count: 1 + })), + omittedImageCount: state.omitted_image_count, + shapeIds: [...state.shape_ids] + }; } function enqueueEditorChange(change: EditorDocumentChange) { @@ -141,7 +197,6 @@ 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 } @@ -227,6 +282,7 @@ }); }, setActiveDocument, + commitSvgImport, dispose() { subscription?.unsubscribe(); subscription = null; @@ -325,7 +381,6 @@ return { pickImport: () => pickTextFile('.excalidraw,.canvas,application/json'), pickSvg: pickSvgFile, - importSvg: importSvgInWorker, async exportSvg( snapshot: BoardExport, options: SvgExportOptions = {} diff --git a/apps/web/src/lib/persistence/repository.ts b/apps/web/src/lib/persistence/repository.ts --- a/apps/web/src/lib/persistence/repository.ts +++ b/apps/web/src/lib/persistence/repository.ts @@ -3,6 +3,7 @@ BoardStatsOps, createId, fromCanonicalDocumentSnapshot, + fromEditorProjection, LayerRecord as LayerOps, PageRecord as PageOps, ShapeRecord as ShapeOps @@ -20,7 +21,6 @@ LoadedDoc, LayerRecord, ImportedAsset, - ImportedGroup, PageRecord, PersistenceSink, PersistentDocRepo, @@ -44,6 +44,7 @@ boardId: string; bytes: Uint8Array; snapshot: CanonicalDocumentState['snapshot']; + projection?: CanonicalDocumentState['projection']; updatedAt: Timestamp; }; @@ -63,7 +64,6 @@ const SHAPE_ORDER_META_PREFIX = 'shape-order:'; const LAYERS_META_PREFIX = 'layers:'; const ASSETS_META_PREFIX = 'assets:'; -const SVG_GROUPS_META_PREFIX = 'svg-groups:'; const pageOrderKey = (boardId: string) => `${PAGE_ORDER_META_PREFIX}${boardId}`; @@ -72,8 +72,6 @@ const layersKey = (boardId: string) => `${LAYERS_META_PREFIX}${boardId}`; const assetsKey = (boardId: string) => `${ASSETS_META_PREFIX}${boardId}`; - -const svgGroupsKey = (boardId: string) => `${SVG_GROUPS_META_PREFIX}${boardId}`; /** * Create a Dexie-backed persistent DocRepo used by the web app. @@ -146,7 +144,7 @@ await meta().delete(shapeOrderKey(boardId)); await meta().delete(layersKey(boardId)); await meta().delete(assetsKey(boardId)); - await meta().delete(svgGroupsKey(boardId)); + await meta().delete(`svg-groups:${boardId}`); await canonical().delete(boardId); } ); @@ -154,7 +152,13 @@ async function loadCanonical(boardId: string) { const row = await canonical().get(boardId); - return row ? { bytes: new Uint8Array(row.bytes), snapshot: row.snapshot } : null; + return row + ? { + bytes: new Uint8Array(row.bytes), + snapshot: row.snapshot, + projection: row.projection + } + : null; } async function saveCanonical(boardId: string, state: CanonicalDocumentState): Promise { @@ -167,6 +171,7 @@ boardId, bytes: new Uint8Array(state.bytes), snapshot: state.snapshot, + projection: state.projection, updatedAt: timestamp }); const pageKeys = (await pages().where('boardId').equals(boardId).toArray()).map( @@ -185,7 +190,7 @@ await meta().delete(shapeOrderKey(boardId)); await meta().delete(layersKey(boardId)); await meta().delete(assetsKey(boardId)); - await meta().delete(svgGroupsKey(boardId)); + await meta().delete(`svg-groups:${boardId}`); await boards().update(boardId, { updatedAt: timestamp }); } ); @@ -193,14 +198,17 @@ async function loadDoc(boardId: string): Promise { const canonicalRow = await canonical().get(boardId); - if (canonicalRow) return fromCanonicalDocumentSnapshot(canonicalRow.snapshot); + if (canonicalRow) { + return canonicalRow.projection + ? fromEditorProjection(canonicalRow.projection, canonicalRow.snapshot) + : fromCanonicalDocumentSnapshot(canonicalRow.snapshot); + } const pageRows = await pages().where('boardId').equals(boardId).toArray(); - const [shapeRows, bindingRows, order, assetsRow, svgGroupsRow] = await Promise.all([ + const [shapeRows, bindingRows, order, assetsRow] = await Promise.all([ shapes().where('boardId').equals(boardId).toArray(), bindings().where('boardId').equals(boardId).toArray(), loadOrder(boardId, pageRows), - meta().get(assetsKey(boardId)), - meta().get(svgGroupsKey(boardId)) + meta().get(assetsKey(boardId)) ]); const docPages: Record = {}; @@ -224,7 +232,6 @@ shapes: docShapes, bindings: docBindings, assets: assetsRow?.value as Record | undefined, - svgGroups: svgGroupsRow?.value as Record | undefined, order }; } @@ -315,13 +322,11 @@ throw new Error(`Board ${boardId} not found`); } - const { pages, layers, shapes, bindings, assets, svgGroups, order } = - await loadDoc(boardId); + const { pages, layers, shapes, bindings, assets, order } = await loadDoc(boardId); const doc: Document = { pages, ...(layers ? { layers } : {}), ...(assets ? { assets } : {}), - ...(svgGroups ? { svgGroups } : {}), shapes, bindings }; @@ -374,12 +379,6 @@ } if (snapshot.doc.assets && Object.keys(snapshot.doc.assets).length > 0) { await meta().put({ key: assetsKey(boardId), value: snapshot.doc.assets }); - } - if (snapshot.doc.svgGroups && Object.keys(snapshot.doc.svgGroups).length > 0) { - await meta().put({ - key: svgGroupsKey(boardId), - value: snapshot.doc.svgGroups - }); } } ); diff --git a/apps/web/src/lib/persistence/svg-import.test.ts b/apps/web/src/lib/persistence/svg-import.test.ts --- a/apps/web/src/lib/persistence/svg-import.test.ts +++ b/apps/web/src/lib/persistence/svg-import.test.ts @@ -6,6 +6,15 @@ type FakeMessage = | { type: 'import'; id: number; source: ArrayBuffer } + | { + type: 'import_document_svg'; + id: number; + source: ArrayBuffer; + sourceName: string; + pageId: string; + layerId: string; + timestamp: number; + } | { type: 'project'; id: number; snapshot: unknown } | { type: 'reconcile'; id: number; snapshot: unknown; request: unknown } | { type: 'render'; id: number; snapshot: unknown; options: unknown }; @@ -44,34 +53,52 @@ omitted_image_count: 0 } } - : message.type === 'render' + : message.type === 'import_document_svg' ? { id: message.id, response: { - status: 'success', - svg: '', - warnings: [] + snapshot: {}, + editor_projection: {}, + can_undo: false, + can_redo: false, + bytes: new Uint8Array(), + warnings: [], + omitted_image_count: 0, + shape_ids: [], + source_asset_id: 'asset:source' } } - : message.type === 'project' + : message.type === 'render' ? { id: message.id, response: { - pages: {}, - layers: {}, - shapes: {}, - bindings: {}, - order: { - page_ids: [], - shape_order: {}, - layers: {} - } + status: 'success', + svg: '', + warnings: [] } } - : { - id: message.id, - response: { id: 'transaction:one', operations: [] } - } + : 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: [] + } + } }) ) ); @@ -102,6 +129,27 @@ expect(source).toEqual(new Uint8Array([60, 115, 118, 103])); client.dispose(); expect(worker.terminated).toBe(true); + }); + + it('routes document SVG imports through the worker-owned session', async () => { + const worker = new FakeWorker(); + const client = new SvgImportWorkerClient(worker as unknown as Worker); + + const state = await client.importDocumentSvg( + new Uint8Array([60, 115, 118, 103]), + 'icon.svg', + 'page:one', + 'layer:one' + ); + + expect(worker.lastMessage).toMatchObject({ + type: 'import_document_svg', + sourceName: 'icon.svg', + pageId: 'page:one', + layerId: 'layer:one' + }); + expect(state.source_asset_id).toBe('asset:source'); + client.dispose(); }); it('routes projection and reconciliation requests through the same worker', async () => { diff --git a/apps/web/src/lib/persistence/svg-import.ts b/apps/web/src/lib/persistence/svg-import.ts --- a/apps/web/src/lib/persistence/svg-import.ts +++ b/apps/web/src/lib/persistence/svg-import.ts @@ -12,9 +12,12 @@ SvgRenderOptions, SvgRenderResponse, TransactionDraft, - DocumentSessionState + DocumentSessionState, + SvgImport, + SvgImportCommitResponse } from '@inkfinite/wasm'; -import type { SvgImportResult } from '@inkfinite/core'; + +export type SvgImportResult = SvgImport & { omitted_image_count: number }; /** A structured parser failure returned by the SVG worker. */ export class SvgImportWorkerError extends Error { @@ -31,6 +34,14 @@ | { type: 'import'; source: ArrayBuffer } | { type: 'open_document'; source: ArrayBuffer; actorId: string } | { type: 'create_document'; snapshot: DocumentSnapshot; actorId: string } + | { + type: 'import_document_svg'; + source: ArrayBuffer; + sourceName: string; + pageId: string; + layerId: string; + timestamp: number; + } | { type: 'document_state' } | { type: 'apply_transaction'; transaction: TransactionDraft } | { type: 'apply_editor_patches'; request: EditorReconciliationRequest } @@ -50,19 +61,27 @@ | SvgImportResponse | SvgRenderResponse | BrowserDocumentState + | BrowserSvgImportState | EditorProjection | TransactionDraft; } | { id: number; error: string }; export type BrowserDocumentState = DocumentSessionState & { bytes: Uint8Array }; +type SvgImportCommitSuccess = Extract; +export type BrowserSvgImportState = BrowserDocumentState & + Pick< + SvgImportCommitSuccess, + 'warnings' | 'omitted_image_count' | 'shape_ids' | 'source_asset_id' + >; type WorkerResponseValue = | SvgImportResponse | SvgRenderResponse | EditorProjection | TransactionDraft - | BrowserDocumentState; + | BrowserDocumentState + | BrowserSvgImportState; /** A worker boundary that keeps Rust parsing, rendering, and document state off the UI thread. */ export class SvgImportWorkerClient { @@ -89,6 +108,28 @@ throw new SvgImportWorkerError(response.error.code, response.error.message); } return { ...response.import, omitted_image_count: response.omitted_image_count }; + } + + /** Parses and commits SVG bytes through the worker-owned Rust document session. */ + importDocumentSvg( + source: Uint8Array, + sourceName: string, + pageId = '', + layerId = '', + timestamp = Date.now() + ): Promise { + const transferable = source.slice(); + return this.request( + { + type: 'import_document_svg', + source: transferable.buffer, + sourceName, + pageId, + layerId, + timestamp + }, + [transferable.buffer] + ); } /** Opens canonical bytes in the worker-owned Rust document session. */ diff --git a/apps/web/src/lib/persistence/svg-import.worker.ts b/apps/web/src/lib/persistence/svg-import.worker.ts --- a/apps/web/src/lib/persistence/svg-import.worker.ts +++ b/apps/web/src/lib/persistence/svg-import.worker.ts @@ -18,6 +18,15 @@ | { 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: 'import_document_svg'; + id: number; + source: ArrayBuffer; + sourceName: string; + pageId: string; + layerId: string; + timestamp: number; + } | { type: 'document_state'; id: number } | { type: 'apply_transaction'; id: number; transaction: TransactionDraft } | { type: 'apply_editor_patches'; id: number; request: EditorReconciliationRequest } @@ -58,6 +67,20 @@ event.data.actorId ); response = documentState(); + break; + } + case 'import_document_svg': { + if (!documentSession) { + throw new Error('No browser document is open in the Rust worker.'); + } + const imported = documentSession.importSvg( + new Uint8Array(event.data.source), + event.data.sourceName, + event.data.pageId, + event.data.layerId, + event.data.timestamp + ); + response = { ...imported, ...imported.state, bytes: documentSession.save() }; break; } case 'document_state': diff --git a/packages/ui/src/lib/editor/platform.ts b/packages/ui/src/lib/editor/platform.ts --- a/packages/ui/src/lib/editor/platform.ts +++ b/packages/ui/src/lib/editor/platform.ts @@ -7,8 +7,7 @@ SvgExportOptions, PersistenceSink, PersistenceStatus, - PersistentDocRepo, - SvgImportResult + PersistentDocRepo } from '@inkfinite/core'; import type { StatusStore } from './status'; @@ -80,8 +79,6 @@ pickImport(): Promise; /** Picks SVG bytes for the browser import path. */ pickSvg?(): Promise; - /** Parses SVG bytes through the application's shared Rust/WASM worker. */ - importSvg?(source: Uint8Array): Promise; /** Renders a browser document through the application's shared Rust/WASM worker. */ exportSvg?(snapshot: BoardExport, options?: SvgExportOptions): Promise; saveExport(file: InterchangeExport | SvgExport, defaultStem: string): Promise; @@ -144,8 +141,24 @@ 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; + /** Supplies the current editor document to a Rust-backed browser session and returns its projection. */ + setActiveDocument?: ( + boardId: string, + doc: import('@inkfinite/core').LoadedDoc + ) => Promise; + /** Commits browser SVG bytes through the active Rust document session. */ + commitSvgImport?: (args: { + boardId: string; + source: Uint8Array; + sourceName: string; + pageId?: string; + layerId?: string; + }) => Promise<{ + doc: import('@inkfinite/core').LoadedDoc; + warnings: Array<{ code: string; message: string; count: number }>; + omittedImageCount: number; + shapeIds: string[]; + }>; subscribeFileMenu?: (listener: (action: NativeFileMenuAction) => void) => () => void; dispose?: () => void; }; diff --git a/packages/ui/src/lib/editor/svg-import.svelte.test.ts b/packages/ui/src/lib/editor/svg-import.svelte.test.ts deleted file mode 100644 --- a/packages/ui/src/lib/editor/svg-import.svelte.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { projectSvgImport, type SvgImportResult } from '@inkfinite/core'; -import { describe, expect, it } from 'vitest'; - -const style = { opacity: 1, fill_opacity: 1, stroke_opacity: 1 }; - -function svgImport(): SvgImportResult { - return { - view_box: { x: 0, y: 0, width: 100, height: 80 }, - root: { - source_id: null, - transform: { translation: { x: 0, y: 0 }, rotation: 0, scale_x: 1, scale_y: 1 }, - style, - properties: { width: 100, height: 80 }, - children: [ - { - kind: 'group', - value: { - source_id: 'translated', - transform: { - translation: { x: 10, y: 20 }, - rotation: 0, - scale_x: 1, - scale_y: 1 - }, - style, - properties: { width: 24, height: 35 }, - children: [ - { - kind: 'shape', - value: { - source_id: 'box', - kind: 'rect', - transform: { - translation: { x: 4, y: 5 }, - rotation: 0, - scale_x: 1, - scale_y: 1 - }, - properties: { - width: 20, - height: 30, - radius: 0, - fill: '#123456', - stroke: 'none' - }, - style - } - } - ] - } - } - ] - }, - source_asset: { - id: 'asset:source', - name: 'source-icon.svg', - media_type: 'image/svg+xml', - digest: 'sha256:test', - bytes: [60, 115, 118, 103] - }, - assets: [], - warnings: [], - omitted_image_count: 0 - }; -} - -describe('browser SVG projection', () => { - it('maps the shared normalized tree while retaining group identity and source assets', () => { - const imported = projectSvgImport(svgImport(), 'icon.svg'); - const shape = Object.values(imported.snapshot.doc.shapes)[0]; - - expect(shape).toMatchObject({ - type: 'rect', - x: 14, - y: 25, - groupId: 'svg:group:translated', - props: { w: 20, h: 30, fill: '#123456' } - }); - expect(imported.snapshot.doc.assets?.['asset:source']).toMatchObject({ - mediaType: 'image/svg+xml', - bytes: [60, 115, 118, 103] - }); - expect(imported.snapshot.doc.svgGroups?.['svg:group:translated']).toMatchObject({ - parentId: 'svg:group:root', - transform: { translation: { x: 10, y: 20 } } - }); - expect(imported.warnings).toEqual([]); - }); -}); diff --git a/apps/web/src/content/docs/internals/svg-import.md b/apps/web/src/content/docs/internals/svg-import.md --- a/apps/web/src/content/docs/internals/svg-import.md +++ b/apps/web/src/content/docs/internals/svg-import.md @@ -137,14 +137,15 @@ the imported subtree movable as one object. Desktop imports use the native Tauri dialog plugin to select a path, then Rust -reads, parses, and commits the file through the active session. The browser adapter exposes an Import menu with editable-document, SVG file, -and pasted SVG code/markup options. SVG file bytes and pasted markup cross one -reusable worker, which lazy-loads the Rust WASM facade and returns the normalized -result. The browser projects that result into its local document model, retains -source and embedded assets, and persists the imported board in one IndexedDB -operation. File selection, drag-and-drop, and pasted markup use this path. The -web build runs `scripts/build-wasm.mjs` before Vite packages the worker; it -requires the matching `wasm-bindgen` CLI. The CLI accepts +reads, parses, and commits the file through the active session. The browser adapter +exposes an Import menu with editable-document, SVG file, and pasted SVG code or +markup options. SVG file bytes and pasted markup cross one reusable worker. The +Rust document session parses the bytes, builds the shared SVG transaction, commits +one history entry, and returns the new canonical snapshot and editor projection. +The browser caches that projection with the canonical IndexedDB bytes and uses it +to hydrate the editor. File selection, drag-and-drop, and pasted markup use this +path. The web build runs `scripts/build-wasm.mjs` before Vite packages the worker; +it requires the matching `wasm-bindgen` CLI. The CLI accepts `inkfinite import svg FILE --input ARTWORK.svg` and can validate the transaction with `--dry-run` before saving. @@ -157,15 +158,17 @@ ## Browser WASM facade -`@inkfinite/wasm` exposes the Rust importer and deterministic SVG renderer. The -browser loads the generated module once inside the shared SVG worker. Import -requests transfer their byte buffer; render requests send one canonical snapshot -and one render-options object across the worker boundary. +`@inkfinite/wasm` exposes the Rust document session, importer, and deterministic +SVG renderer. The browser loads the generated module once inside the shared worker. +Import requests transfer their byte buffer to the session; render requests send one +canonical snapshot and one render-options object across the worker boundary. Rust +response envelopes and renderer options use the generated types in +`@inkfinite/bindings/wasm`. -The web adapter projects its editor document into the canonical snapshot before -export. Page and selection filters become Rust renderer options, and renderer -warnings become export conversion notes. Canvas drawing and PNG capture stay in -TypeScript, where they can use the browser's frame loop and canvas APIs. +The web adapter uses the canonical session snapshot before export. Page and +selection filters become Rust renderer options, and renderer warnings become +export conversion notes. Canvas drawing and PNG capture stay in TypeScript, where +they can use the browser's frame loop and canvas APIs. ## Input safety and failures diff --git a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts --- a/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts +++ b/packages/ui/src/lib/editor/canvas/canvas-store.svelte.ts @@ -21,7 +21,6 @@ LayerRecord, exportInterchange, importInterchange, - importInterchangeAsync, MarkdownTool, PageRecord, PenTool, @@ -283,7 +282,7 @@ platformSession?.setActiveBoard?.(boardId); } - function applyLoadedDoc(doc: LoadedDoc, fitDrawing = false) { + function applyLoadedDoc(doc: LoadedDoc, fitDrawing = false, syncCanonical = true) { const firstPageId = doc.order.pageIds[0] ?? Object.keys(doc.pages)[0] ?? null; store.setState((state) => ({ ...state, @@ -291,13 +290,25 @@ pages: doc.pages, layers: doc.layers ?? doc.order.layers, ...(doc.assets ? { assets: doc.assets } : {}), - ...(doc.svgGroups ? { svgGroups: doc.svgGroups } : {}), shapes: doc.shapes, bindings: doc.bindings }, ui: { ...state.ui, currentPageId: firstPageId, selectionIds: [] } })); - if (activeBoardId) platformSession?.setActiveDocument?.(activeBoardId, doc); + if (activeBoardId && syncCanonical) { + const boardId = activeBoardId; + const hydration = platformSession?.setActiveDocument?.(boardId, doc); + if (hydration) { + void hydration + .then((hydrated) => { + if (hydrated && activeBoardId === boardId) + applyLoadedDoc(hydrated, false, false); + }) + .catch((error) => + console.error('Failed to hydrate the Rust document projection', error) + ); + } + } if (fitDrawing) { camera.fitAll(); } @@ -486,9 +497,9 @@ name: string; contents: string | Uint8Array; }) { - if (!repo || !sink) return; - const importSvg = platformSession?.interchange?.importSvg; - if (!importSvg) throw new Error('The browser SVG importer is not available.'); + if (!repo || !sink || !platformSession?.commitSvgImport) { + throw new Error('The browser document engine is not available.'); + } const size = typeof source.contents === 'string' ? new TextEncoder().encode(source.contents).byteLength @@ -496,12 +507,24 @@ if (size > 16 * 1024 * 1024) { throw new Error('The SVG source is larger than the 16 MB import limit.'); } - const imported = await importInterchangeAsync(source.contents, source.name, importSvg); + const bytes = + typeof source.contents === 'string' + ? new TextEncoder().encode(source.contents) + : source.contents; await sink.flush(); - const boardId = await repo.importBoard(imported.snapshot); + const boardName = source.name.replace(/\.[^.]+$/, '').trim() || 'Untitled Board'; + const boardId = await repo.createBoard(boardName); const doc = await repo.loadDoc(boardId); setActiveBoardId(boardId); applyLoadedDoc(doc, true); + const imported = await platformSession.commitSvgImport({ + boardId, + source: bytes, + sourceName: source.name, + pageId: doc.order.pageIds[0], + layerId: doc.pages[doc.order.pageIds[0] ?? '']?.layerIds?.[0] + }); + applyLoadedDoc(imported.doc, true); interchangeNotice = { title: 'SVG import complete', message: `${source.name} is now an Inkfinite document.`, -- tangled.sh