diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c8eff5..82bf39b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,12 +68,10 @@ and device-pixel-ratio changes. - Document model collapsed to a single native model, removing the predecessor/current split. -- Desktop ordinary editor updates now use Rust reconciliation instead of - deleting and recreating the native scene; full mirror replacement remains - only for structural page and layer changes not yet covered by semantic patches. +- Desktop editor updates now use the shared TypeScript patch builder and Rust + reconciliation for page, layer, and shape changes - 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. + bytes. WASM request and response payloads are generated from Rust. ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index 72c4199..b7a66be 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -116,12 +116,10 @@ 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. -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. +Desktop reconciliation uses the same TypeScript before-and-after patch +builder as the web editor. Page, layer, and shape changes become semantic +patches, then Rust reconciles and commits them through the native transaction +engine. The WASM request and response payloads now use generated TypeScript types, and native editor types are reused where the representations match. The shared worker diff --git a/TODO.md b/TODO.md index 7da40a5..1515774 100644 --- a/TODO.md +++ b/TODO.md @@ -78,10 +78,10 @@ interaction state, previews, hit testing, Canvas rendering, and browser APIs. #### Shared reconciliation -- [ ] 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 +- [x] Use one TypeScript editor-patch builder across web and desktop +- [x] Reconcile desktop page and layer changes through Rust +- [x] Retire the desktop whole-document mirror fallback +- [x] Reuse generated native geometry and transform types in the editor model #### Worker verification and cleanup diff --git a/apps/desktop/src/lib/persistence/desktop-session.test.ts b/apps/desktop/src/lib/persistence/desktop-session.test.ts index aa33656..8968562 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.test.ts @@ -1,5 +1,5 @@ import { PageRecord, ShapeRecord, type BoardExport, type DesktopFileOps, type FileHandle } from '@inkfinite/core'; -import type { ChangeHash, DocumentSnapshot, Proposal, TransactionDraft } from '@inkfinite/bindings'; +import type { ChangeHash, DocumentSnapshot, Proposal, ShapeProperties, TransactionDraft } from '@inkfinite/bindings'; import { beforeEach, describe, expect, it } from 'vitest'; import { createDesktopSessionRepo } from '$lib/persistence/desktop-session'; import type { @@ -47,6 +47,7 @@ function createFakeSessionApi() { const files = new Map(); const sessions = new Map(); const agentContexts: Array[0]> = []; + const editorPatchBatches: Array[0]['patches']> = []; let sessionNumber = 0; let headNumber = 0; @@ -160,6 +161,87 @@ function createFakeSessionApi() { return { commit: commitResult(args.transaction, next), status: statusFor(args.session_id, session) }; }, + async reconcileEditorPatches( + args: Parameters[0] + ): Promise { + editorPatchBatches.push(structuredClone(args.patches)); + const session = sessions.get(args.session_id); + if (!session) throw new Error('Missing fake session'); + const next = structuredClone(session.status.snapshot); + for (const patch of args.patches) { + if (patch.type === 'rename_page') { + const page = next.document.pages[patch.page_id]; + if (page) page.name = patch.name; + } else if (patch.type === 'create_shape') { + const properties = structuredClone(patch.shape.properties) as Record; + if ('w' in properties) { + properties.width = properties.w; + delete properties.w; + } + if ('h' in properties) { + properties.height = properties.h; + delete properties.h; + } + const scaleX = Math.hypot(patch.transform.a, patch.transform.b); + const scaleY = + scaleX > Number.EPSILON + ? (patch.transform.a * patch.transform.d - patch.transform.b * patch.transform.c) / scaleX + : 1; + next.document.shapes[patch.shape.id] = { + id: patch.shape.id, + kind: patch.shape.kind, + parent: patch.parent, + transform: { + translation: { x: patch.transform.e, y: patch.transform.f }, + rotation: Math.atan2(patch.transform.b, patch.transform.a), + scale_x: scaleX, + scale_y: scaleY + }, + child_ids: [], + layout: patch.shape.layout, + properties: properties as ShapeProperties, + metadata: patch.shape.metadata ?? { + name: null, + role: null, + description: null, + tags: [], + locked: false, + agent_editable: true, + provenance: { actor_id: 'actor:desktop', origin: 'human', timestamp: 0, source: null } + }, + style: patch.shape.style, + version: 1 + }; + if (patch.parent.kind === 'layer') { + next.document.layers[patch.parent.id]?.shape_ids.push(patch.shape.id); + } else { + next.document.shapes[patch.parent.id]?.child_ids.push(patch.shape.id); + } + } else if (patch.type === 'create_binding') { + next.document.bindings[patch.binding.id] = structuredClone(patch.binding); + } + } + next.heads = [`head:${++headNumber}`]; + session.undo.push(structuredClone(session.status.snapshot)); + session.redo = []; + session.status = { ...session.status, snapshot: next, dirty: true, can_undo: true, can_redo: false }; + return { + commit: commitResult( + { + id: 'transaction:editor', + actor_id: 'actor:desktop', + origin: 'human', + base_heads: session.status.snapshot.heads, + description: 'Editor patches', + operations: [], + timestamp: Date.now() + }, + next + ), + status: statusFor(args.session_id, session) + }; + }, + async importSvg(_args: Parameters[0]) { throw new Error('SVG import is not part of this fake session'); }, @@ -309,7 +391,7 @@ function createFakeSessionApi() { } } satisfies SessionApi; - return { api, files, draftPath, agentContexts }; + return { api, files, draftPath, agentContexts, editorPatchBatches }; } function createFakeFileOps() { @@ -438,6 +520,36 @@ describe('Rust-backed desktop session repository', () => { expect(reopened.pages[pageId].name).toBe('Renamed'); }); + it('routes layer changes through the Rust reconciliation command', async () => { + const repo = createDesktopSessionRepo(fileOps.ops, { api: session.api }); + const opened = await repo.openDraft(); + const pageId = opened.doc.order.pageIds[0]; + const existingLayerId = opened.doc.pages[pageId].layerIds?.[0]; + expect(existingLayerId).toBeDefined(); + const newLayer = { + id: 'layer:desktop:new', + pageId, + name: 'New layer', + shapeIds: [], + visible: true, + locked: false, + opacity: 1 + }; + + await repo.applyDocPatch(opened.boardId, { + upserts: { + pages: [{ ...opened.doc.pages[pageId], layerIds: [existingLayerId!, newLayer.id], shapeIds: [] }] + }, + order: { layers: { [existingLayerId!]: opened.doc.layers![existingLayerId!]!, [newLayer.id]: newLayer } } + }); + + expect(session.editorPatchBatches.at(-1)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'create_layer', layer: expect.objectContaining({ id: newLayer.id }) }) + ]) + ); + }); + it('publishes editor context only after a desktop session is open', async () => { const repo = createDesktopSessionRepo(fileOps.ops, { api: session.api }); await repo.updateAgentContext({ diff --git a/apps/desktop/src/lib/persistence/desktop-session.ts b/apps/desktop/src/lib/persistence/desktop-session.ts index e30f053..ec5017a 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.ts @@ -1,5 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; +import { createEditorReconciliationRequest, createId } from '@inkfinite/core'; import type { BoardExport, BoardMeta, @@ -15,25 +16,18 @@ import type { PersistentDocRepo, ShapeRecord as EditorShapeRecord } from '@inkfinite/core'; -import { createId } from '@inkfinite/core'; import type { - BindingRecord as SnapshotBindingRecord, ChangeHash, CommitResult, - ContainerLayout, DocumentSnapshot, + JsonValue, Query, QueryResult, - Provenance, Proposal, - ShapeProperties, ShapeRecord, - ShapeStyle, - TransactionDraft, - Transform, - JsonValue + TransactionDraft } from '@inkfinite/bindings'; -import type { EditorPatch, EditorProjection, EditorTransform } from '@inkfinite/bindings/editor'; +import type { EditorPatch, EditorProjection } from '@inkfinite/bindings/editor'; const ACTOR_ID = 'actor:desktop'; @@ -135,7 +129,7 @@ export interface SessionApi { occluded_regions: Array<{ x: number; y: number; width: number; height: number }>; }): Promise; commit(args: { session_id: string; transaction: TransactionDraft }): Promise; - reconcileEditorPatches?(args: { session_id: string; patches: EditorPatch[] }): Promise; + reconcileEditorPatches(args: { session_id: string; patches: EditorPatch[] }): Promise; importSvg(args: { session_id: string; path: string; @@ -610,30 +604,18 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: await ensureBoardLoaded(boardId); if (!currentStatus || !currentDoc) throw new Error('No board loaded'); const nextDoc = applyPatch(currentDoc, patch); - const editorPatches = editorPatchesForDocuments(currentDoc, nextDoc); - if (editorPatches && editorPatches.length === 0) return; - - let committed: SessionCommit; - if (editorPatches && api.reconcileEditorPatches) { - committed = await api.reconcileEditorPatches({ - session_id: currentStatus.session_id, - patches: editorPatches - }); - } else { - const target = documentFromLoadedDoc(nextDoc, currentStatus.snapshot, ACTOR_ID); - const operations = operationsForMirror(currentStatus.snapshot, target); - if (operations.length === 0) return; - const transaction: TransactionDraft = { - id: createId('transaction'), - actor_id: ACTOR_ID, - origin: 'human', - base_heads: currentStatus.snapshot.heads, - description: 'Update desktop document mirror', - operations, - timestamp: Date.now() - }; - committed = await api.commit({ session_id: currentStatus.session_id, transaction }); - } + const request = createEditorReconciliationRequest(currentDoc, nextDoc, { + actor_id: ACTOR_ID, + origin: 'human', + transaction_id: createId('transaction'), + description: 'Update desktop document', + timestamp: Date.now() + }); + if (request.patches.length === 0) return; + const committed = await api.reconcileEditorPatches({ + session_id: currentStatus.session_id, + patches: request.patches + }); updateStatus(committed.status); if (currentProposal) { notifyProposal({ @@ -1160,7 +1142,16 @@ function applyPatch(doc: LoadedDoc, patch: DocPatch): LoadedDoc { for (const id of patch.deletes?.pageIds ?? []) delete next.pages[id]; for (const id of patch.deletes?.shapeIds ?? []) delete next.shapes[id]; for (const id of patch.deletes?.bindingIds ?? []) delete next.bindings[id]; - for (const page of patch.upserts?.pages ?? []) next.pages[page.id] = page; + for (const page of patch.upserts?.pages ?? []) { + const previous = next.pages[page.id]; + next.pages[page.id] = previous + ? { + ...previous, + ...page, + ...(page.layerIds === undefined && previous.layerIds ? { layerIds: [...previous.layerIds] } : {}) + } + : page; + } for (const shape of patch.upserts?.shapes ?? []) next.shapes[shape.id] = shape; for (const binding of patch.upserts?.bindings ?? []) next.bindings[binding.id] = binding; if (patch.order?.pageIds) next.order.pageIds = [...patch.order.pageIds]; @@ -1226,367 +1217,6 @@ function rebaseImportedDocument(snapshot: BoardExport, destination: LoadedDoc): }; } -function editorPatchesForDocuments(before: LoadedDoc, after: LoadedDoc): EditorPatch[] | null { - const beforePageIds = Object.keys(before.pages).sort(); - const afterPageIds = Object.keys(after.pages).sort(); - const beforeLayerIds = Object.keys(before.layers ?? {}).sort(); - const afterLayerIds = Object.keys(after.layers ?? {}).sort(); - // Page/layer creation and deletion still use the complete document adapter - // until their semantic operations are needed by the editor. - if (JSON.stringify(beforePageIds) !== JSON.stringify(afterPageIds)) return null; - if (JSON.stringify(beforeLayerIds) !== JSON.stringify(afterLayerIds)) return null; - - const patches: EditorPatch[] = []; - for (const pageId of after.order.pageIds) { - const previous = before.pages[pageId]; - const next = after.pages[pageId]; - if (previous && next && previous.name !== next.name) { - patches.push({ type: 'rename_page', page_id: pageId, name: next.name }); - } - } - for (const layerId of after.order.layers ? Object.keys(after.order.layers) : Object.keys(after.layers ?? {})) { - const previous = before.layers?.[layerId]; - const next = after.layers?.[layerId]; - if (!previous || !next) continue; - if ( - previous.name !== next.name || - previous.visible !== next.visible || - previous.locked !== next.locked || - previous.opacity !== next.opacity - ) { - patches.push({ - type: 'patch_layer', - layer_id: layerId, - patch: { - name: previous.name === next.name ? null : next.name, - visible: previous.visible === next.visible ? null : next.visible, - locked: previous.locked === next.locked ? null : next.locked, - opacity: previous.opacity === next.opacity ? null : next.opacity - } - }); - } - } - - for (const shapeId of Object.keys(before.shapes)) { - const previous = before.shapes[shapeId]; - const next = after.shapes[shapeId]; - if (!next) { - patches.push({ type: 'delete_shape', shape_id: shapeId }); - continue; - } - const transformChanged = previous.x !== next.x || previous.y !== next.y || previous.rot !== next.rot; - const parentChanged = previous.groupId !== next.groupId || previous.layerId !== next.layerId; - const orderChanged = - JSON.stringify(siblingAnchorForShape(before, previous)) !== - JSON.stringify(siblingAnchorForShape(after, next)); - const propertiesChanged = !jsonEqual(previous.props, next.props); - const styleChanged = - (previous.opacity ?? 1) !== (next.opacity ?? 1) || - (previous.fillOpacity ?? null) !== (next.fillOpacity ?? null) || - (previous.strokeOpacity ?? null) !== (next.strokeOpacity ?? null); - if (transformChanged || parentChanged || orderChanged || propertiesChanged || styleChanged) { - patches.push({ - type: 'shape', - shape_id: shapeId, - transform: transformChanged || parentChanged ? affineForEditorShape(next) : null, - properties: propertiesChanged ? (structuredClone(next.props) as ShapeProperties) : null, - metadata: null, - style: styleChanged - ? { - opacity: next.opacity ?? 1, - fill_opacity: next.fillOpacity ?? null, - stroke_opacity: next.strokeOpacity ?? null - } - : null, - parent: parentChanged ? editorParent(next) : null, - anchor: orderChanged ? siblingAnchorForShape(after, next) : null - }); - } - } - for (const shape of Object.values(after.shapes)) { - if (before.shapes[shape.id]) continue; - patches.push({ - type: 'create_shape', - shape: { - id: shape.id, - kind: shape.type, - properties: structuredClone(shape.props) as ShapeProperties, - metadata: null, - style: { - opacity: shape.opacity ?? 1, - fill_opacity: shape.fillOpacity ?? null, - stroke_opacity: shape.strokeOpacity ?? null - }, - layout: null - }, - parent: editorParent(shape), - transform: affineForEditorShape(shape), - anchor: siblingAnchorForShape(after, shape) - }); - } - for (const bindingId of Object.keys(before.bindings)) { - if (!after.bindings[bindingId]) patches.push({ type: 'delete_binding', binding_id: bindingId }); - } - for (const binding of Object.values(after.bindings)) { - if (before.bindings[binding.id]) continue; - patches.push({ - type: 'create_binding', - binding: { - id: binding.id, - kind: binding.type, - source_shape_id: binding.fromShapeId, - target_shape_id: binding.toShapeId, - source_handle: binding.handle, - anchor: - binding.anchor.kind === 'center' - ? { kind: 'center' } - : { kind: 'edge', x: binding.anchor.nx, y: binding.anchor.ny }, - version: 1 - } - }); - } - return patches; -} - -function editorParent(shape: EditorShapeRecord): { kind: 'layer'; id: string } | { kind: 'shape'; id: string } { - return shape.groupId ? { kind: 'shape', id: shape.groupId } : { kind: 'layer', id: shape.layerId ?? '' }; -} - -function affineForEditorShape(shape: EditorShapeRecord): EditorTransform { - const projected = shape.editorTransform; - if (projected) { - const projectedRotation = Math.atan2(projected.b, projected.a); - if (Math.abs(projectedRotation - shape.rot) <= 1e-9) { - return { ...projected, e: shape.x, f: shape.y }; - } - const scaleX = Math.hypot(projected.a, projected.b); - const scaleY = scaleX > Number.EPSILON ? (projected.a * projected.d - projected.b * projected.c) / scaleX : 1; - const cos = Math.cos(shape.rot); - const sin = Math.sin(shape.rot); - return { a: cos * scaleX, b: sin * scaleX, c: -sin * scaleY, d: cos * scaleY, e: shape.x, f: shape.y }; - } - const cos = Math.cos(shape.rot); - const sin = Math.sin(shape.rot); - return { a: cos, b: sin, c: -sin, d: cos, e: shape.x, f: shape.y }; -} - -function siblingAnchorForShape( - doc: LoadedDoc, - shape: EditorShapeRecord -): { position: 'last' } | { position: 'before'; sibling_id: string } { - const layer = shape.layerId ? doc.layers?.[shape.layerId] : undefined; - const siblings = layer?.shapeIds.filter((id) => doc.shapes[id]?.groupId === shape.groupId) ?? []; - const nextId = siblings[siblings.indexOf(shape.id) + 1]; - return nextId ? { position: 'before', sibling_id: nextId } : { position: 'last' }; -} - -function jsonEqual(left: unknown, right: unknown): boolean { - return JSON.stringify(left) === JSON.stringify(right); -} - -function documentFromLoadedDoc(doc: LoadedDoc, current: DocumentSnapshot, actor: string): DocumentSnapshot { - const pages = structuredClone(current.document.pages); - const layers = structuredClone(current.document.layers); - const shapes: Record = {}; - const groupChildren = new Map(); - const shapePages = new Map(); - - for (const pageId of doc.order.pageIds) { - const page = doc.pages[pageId]; - const currentPage = pages[pageId]; - if (!page || !currentPage) { - throw new Error(`Desktop mirror cannot update unknown page ${pageId}`); - } - const layerIds = page.layerIds?.length ? page.layerIds : currentPage.layer_ids; - for (const layerId of layerIds) { - const editorLayer = doc.layers?.[layerId]; - const currentLayer = current.document.layers[layerId]; - if (!editorLayer && !currentLayer) continue; - const layerShapeIds = - editorLayer?.shapeIds ?? page.shapeIds.filter((id) => doc.shapes[id]?.layerId === layerId); - const roots: string[] = []; - for (const shapeId of layerShapeIds) { - const shape = doc.shapes[shapeId]; - if (!shape) continue; - if (shape.groupId) { - const children = groupChildren.get(shape.groupId) ?? []; - children.push(shape.id); - groupChildren.set(shape.groupId, children); - if (!roots.includes(shape.groupId)) roots.push(shape.groupId); - } else { - roots.push(shape.id); - } - shapePages.set(shape.id, { pageId, layerId }); - } - layers[layerId] = { - id: layerId, - page_id: pageId, - name: editorLayer?.name ?? currentLayer?.name ?? 'Layer', - shape_ids: roots, - visible: editorLayer?.visible ?? currentLayer?.visible ?? true, - locked: editorLayer?.locked ?? currentLayer?.locked ?? false, - opacity: editorLayer?.opacity ?? currentLayer?.opacity ?? 1, - version: currentLayer?.version ?? 1 - }; - } - pages[pageId] = { ...currentPage, name: page.name, layer_ids: [...layerIds] }; - } - const retainedLayerIds = new Set(Object.values(pages).flatMap((page) => page.layer_ids)); - for (const layerId of Object.keys(layers)) { - if (!retainedLayerIds.has(layerId)) delete layers[layerId]; - } - - for (const shape of Object.values(doc.shapes)) { - const location = shapePages.get(shape.id); - if (!location) continue; - const existing = current.document.shapes[shape.id]; - shapes[shape.id] = shapeFromEditor(shape, location, existing, actor); - } - for (const [groupId, childIds] of groupChildren) { - const location = shapePages.get(childIds[0]); - if (!location) continue; - const existing = current.document.shapes[groupId]; - shapes[groupId] = { - id: groupId, - kind: 'container', - parent: { kind: 'layer', id: location.layerId }, - transform: identityTransform(), - child_ids: childIds, - layout: { kind: 'free' } satisfies ContainerLayout, - properties: {}, - metadata: existing?.metadata ?? defaultMetadata(actor), - style: existing?.style ?? defaultStyle(), - version: existing?.version ?? 1 - }; - } - - const bindings: Record = {}; - for (const binding of Object.values(doc.bindings)) { - bindings[binding.id] = { - id: binding.id, - kind: binding.type, - source_shape_id: binding.fromShapeId, - target_shape_id: binding.toShapeId, - source_handle: binding.handle, - anchor: - binding.anchor.kind === 'center' - ? { kind: 'center' } - : { kind: 'edge', x: binding.anchor.nx, y: binding.anchor.ny }, - version: current.document.bindings[binding.id]?.version ?? 1 - }; - } - - return { - ...current, - document: { ...current.document, page_ids: [...doc.order.pageIds], pages, layers, shapes, bindings } - }; -} - -function shapeFromEditor( - shape: EditorShapeRecord, - location: { pageId: string; layerId: string }, - existing: ShapeRecord | undefined, - actor: string -): ShapeRecord { - const properties = structuredClone(shape.props) as Record; - if ('w' in properties) { - properties.width = properties.w; - delete properties.w; - } - if ('h' in properties) { - properties.height = properties.h; - delete properties.h; - } - const strokeStyle = shape.type === 'stroke' ? shape.props.style : undefined; - const strokeOpacity = - shape.strokeOpacity ?? - (strokeStyle && typeof strokeStyle.opacity === 'number' ? strokeStyle.opacity : undefined) ?? - existing?.style.stroke_opacity ?? - null; - return { - id: shape.id, - kind: shape.type, - parent: shape.groupId ? { kind: 'shape', id: shape.groupId } : { kind: 'layer', id: location.layerId }, - transform: { - translation: { x: shape.x, y: shape.y }, - rotation: shape.rot, - scale_x: existing?.transform.scale_x ?? 1, - scale_y: existing?.transform.scale_y ?? 1 - } satisfies Transform, - child_ids: [], - layout: null, - properties: properties as ShapeProperties, - metadata: { - ...(existing?.metadata ?? defaultMetadata(actor)), - agent_editable: shape.agentEditable ?? existing?.metadata.agent_editable ?? true - }, - style: { - opacity: shape.opacity ?? existing?.style.opacity ?? 1, - fill_opacity: shape.fillOpacity ?? existing?.style.fill_opacity ?? null, - stroke_opacity: strokeOpacity - }, - version: existing?.version ?? 1 - }; -} - -function operationsForMirror(current: DocumentSnapshot, target: DocumentSnapshot): TransactionDraft['operations'] { - const operations: TransactionDraft['operations'] = []; - for (const binding of Object.values(current.document.bindings)) { - operations.push({ type: 'delete_binding', binding_id: binding.id, expected_version: binding.version }); - } - for (const page of Object.values(current.document.pages)) { - if (target.document.pages[page.id]?.name !== page.name) { - operations.push({ - type: 'rename_page', - page_id: page.id, - name: target.document.pages[page.id]?.name ?? page.name, - expected_version: page.version - }); - } - } - const currentRoots = Object.values(current.document.layers).flatMap((layer) => layer.shape_ids); - for (const shapeId of currentRoots) { - const shape = current.document.shapes[shapeId]; - if (shape) operations.push({ type: 'delete_shape', shape_id: shape.id, expected_version: shape.version }); - } - const created = new Set(); - const createShape = (shapeId: string) => { - if (created.has(shapeId)) return; - const shape = target.document.shapes[shapeId]; - if (!shape) return; - if (shape.parent.kind === 'shape') createShape(shape.parent.id); - operations.push({ type: 'create_shape', shape, anchor: { position: 'last' } }); - created.add(shapeId); - }; - for (const layer of Object.values(target.document.layers)) { - for (const shapeId of layer.shape_ids) createShape(shapeId); - } - for (const binding of Object.values(target.document.bindings)) { - operations.push({ type: 'create_binding', binding }); - } - return operations; -} - -function defaultMetadata(actor: string) { - return { - name: null, - role: null, - description: null, - tags: [], - locked: false, - agent_editable: true, - provenance: { actor_id: actor, origin: 'human', timestamp: Date.now(), source: null } satisfies Provenance - }; -} - -function defaultStyle(): ShapeStyle { - return { opacity: 1, fill_opacity: null, stroke_opacity: null }; -} - -function identityTransform(): Transform { - return { translation: { x: 0, y: 0 }, rotation: 0, scale_x: 1, scale_y: 1 }; -} - function fileName(path: string): string { return path.split(/[\\/]/).pop() || 'Untitled.inkfinite'; } diff --git a/apps/desktop/src/lib/persistence/desktop-workspace.test.ts b/apps/desktop/src/lib/persistence/desktop-workspace.test.ts index 1e7d687..ae59687 100644 --- a/apps/desktop/src/lib/persistence/desktop-workspace.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-workspace.test.ts @@ -67,6 +67,9 @@ describe('desktop workspace adapter', () => { commit: async () => { throw new Error('not used'); }, + reconcileEditorPatches: async () => { + throw new Error('not used'); + }, importSvg: async () => { throw new Error('not used'); }, diff --git a/crates/inkfinite-core/src/editor.rs b/crates/inkfinite-core/src/editor.rs index 8dffb48..ad782f9 100644 --- a/crates/inkfinite-core/src/editor.rs +++ b/crates/inkfinite-core/src/editor.rs @@ -6,7 +6,7 @@ //! world-space transforms, and editor patches are converted back into minimal //! native operations. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -481,6 +481,9 @@ pub fn reconcile_editor_patches( ) -> Result { let document = &snapshot.document; let mut operations = Vec::new(); + let mut created_layers = BTreeSet::new(); + let mut touched_layers = BTreeSet::new(); + let mut touched_pages = BTreeSet::new(); let default_metadata = default_metadata(&request); for patch in request.patches { @@ -491,15 +494,23 @@ pub fn reconcile_editor_patches( .pages .get(&page_id) .ok_or_else(|| EditorReconciliationError::UnknownPage(page_id.clone()))?; - operations.push(Operation::DeletePage { page_id, expected_version: Some(page.version) }); + let expected_version = (!touched_pages.contains(&page_id)).then_some(page.version); + operations.push(Operation::DeletePage { page_id, expected_version }); + } + EditorPatch::CreateLayer { layer, anchor } => { + created_layers.insert(layer.id.clone()); + touched_pages.insert(layer.page_id.clone()); + operations.push(Operation::CreateLayer { layer, anchor }); } - EditorPatch::CreateLayer { layer, anchor } => operations.push(Operation::CreateLayer { layer, anchor }), EditorPatch::DeleteLayer { layer_id, contents } => { let layer = document .layers .get(&layer_id) .ok_or_else(|| EditorReconciliationError::UnknownLayer(layer_id.clone()))?; - operations.push(Operation::DeleteLayer { layer_id, contents, expected_version: Some(layer.version) }); + let expected_version = (!touched_layers.contains(&layer_id)).then_some(layer.version); + touched_pages.insert(layer.page_id.clone()); + operations.push(Operation::DeleteLayer { layer_id: layer_id.clone(), contents, expected_version }); + touched_layers.insert(layer_id); } EditorPatch::Shape { shape_id, transform, properties, metadata, style, parent, anchor } => { reconcile_shape( @@ -511,11 +522,12 @@ pub fn reconcile_editor_patches( style, parent.as_ref(), anchor, + &created_layers, &mut operations, )?; } EditorPatch::CreateShape { shape, parent, transform, anchor } => { - let local_transform = local_transform(document, &shape.id, &parent, transform)?; + let local_transform = local_transform(document, &shape.id, &parent, transform, &created_layers)?; let metadata = shape.metadata.unwrap_or_else(|| default_metadata.clone()); let native_shape = ShapeRecord { id: shape.id, @@ -544,7 +556,8 @@ pub fn reconcile_editor_patches( .get(&page_id) .ok_or_else(|| EditorReconciliationError::UnknownPage(page_id.clone()))?; if page.name != name { - operations.push(Operation::RenamePage { page_id, name, expected_version: Some(page.version) }); + let expected_version = (!touched_pages.contains(&page_id)).then_some(page.version); + operations.push(Operation::RenamePage { page_id, name, expected_version }); } } EditorPatch::PatchLayer { layer_id, patch } => { @@ -553,7 +566,9 @@ pub fn reconcile_editor_patches( .get(&layer_id) .ok_or_else(|| EditorReconciliationError::UnknownLayer(layer_id.clone()))?; if layer_patch_changes(layer, &patch) { - operations.push(Operation::PatchLayer { layer_id, patch, expected_version: Some(layer.version) }); + let expected_version = (!touched_layers.contains(&layer_id)).then_some(layer.version); + operations.push(Operation::PatchLayer { layer_id: layer_id.clone(), patch, expected_version }); + touched_layers.insert(layer_id); } } EditorPatch::ReorderLayer { layer_id, anchor } => { @@ -561,7 +576,10 @@ pub fn reconcile_editor_patches( .layers .get(&layer_id) .ok_or_else(|| EditorReconciliationError::UnknownLayer(layer_id.clone()))?; - operations.push(Operation::ReorderLayer { layer_id, anchor, expected_version: Some(layer.version) }); + let expected_version = (!touched_layers.contains(&layer_id)).then_some(layer.version); + touched_pages.insert(layer.page_id.clone()); + operations.push(Operation::ReorderLayer { layer_id: layer_id.clone(), anchor, expected_version }); + touched_layers.insert(layer_id); } EditorPatch::CreateBinding { binding } => operations.push(Operation::CreateBinding { binding }), EditorPatch::DeleteBinding { binding_id } => { @@ -590,7 +608,7 @@ pub fn reconcile_editor_patches( fn reconcile_shape( document: &Document, shape_id: ShapeId, transform: Option, properties: Option, metadata: Option, style: Option, parent: Option<&ShapeParent>, - anchor: Option>, operations: &mut Vec, + anchor: Option>, created_layers: &BTreeSet, operations: &mut Vec, ) -> Result<(), EditorReconciliationError> { let shape = document .shapes @@ -602,7 +620,7 @@ fn reconcile_shape( let mut shape_patch = NativeShapePatch::default(); if let Some(world) = transform { - let local = local_transform(document, &shape_id, &target_parent, world)?; + let local = local_transform(document, &shape_id, &target_parent, world, created_layers)?; let current_world = world_transform(document, shape); if !same_affine(current_world, world.into()) || parent_changed { shape_patch.transform = Some(local); @@ -652,10 +670,11 @@ fn reconcile_shape( fn local_transform( document: &Document, shape_id: &ShapeId, parent: &ShapeParent, world: EditorTransform, + created_layers: &BTreeSet, ) -> Result { let parent_world = match parent { ShapeParent::Layer(layer_id) => { - if !document.layers.contains_key(layer_id) { + if !document.layers.contains_key(layer_id) && !created_layers.contains(layer_id) { return Err(EditorReconciliationError::UnknownLayer(layer_id.clone())); } Affine::IDENTITY @@ -923,6 +942,106 @@ mod tests { assert!(transaction.operations.is_empty()); } + #[test] + fn reconciliation_accepts_shapes_in_new_layers() { + let snapshot = nested_snapshot(); + let page_id = snapshot.document.page_ids[0].clone(); + let layer_id = LayerId::from("layer:new"); + let shape_id = ShapeId::from("shape:new"); + let layer = LayerRecord { + id: layer_id.clone(), + page_id, + name: "New layer".into(), + shape_ids: Vec::new(), + visible: true, + locked: false, + opacity: Opacity::OPAQUE, + version: RecordVersion(1), + }; + let transaction = reconcile_editor_patches( + &snapshot, + request(vec![ + EditorPatch::CreateLayer { layer, anchor: SiblingAnchor::Last }, + EditorPatch::CreateShape { + shape: EditorShapeDraft { + id: shape_id, + kind: ShapeKind::from("rect"), + properties: BTreeMap::from([ + ("width".into(), Value::from(20.0)), + ("height".into(), Value::from(10.0)), + ]), + metadata: None, + style: style(), + layout: None, + }, + parent: ShapeParent::Layer(layer_id), + transform: EditorTransform { a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 25.0, f: 30.0 }, + anchor: SiblingAnchor::Last, + }, + ]), + ) + .expect("a new layer can receive a shape in the same editor change"); + + assert_eq!(transaction.operations.len(), 2); + assert!(matches!(transaction.operations[0], Operation::CreateLayer { .. })); + assert!(matches!(transaction.operations[1], Operation::CreateShape { .. })); + } + + #[test] + fn reconciliation_allows_layer_patch_and_reorder_in_one_change() { + let mut snapshot = nested_snapshot(); + let page_id = snapshot.document.page_ids[0].clone(); + let layer_id = LayerId::from("layer:second"); + snapshot + .document + .pages + .get_mut(&page_id) + .unwrap() + .layer_ids + .push(layer_id.clone()); + snapshot.document.layers.insert( + layer_id.clone(), + LayerRecord { + id: layer_id.clone(), + page_id: page_id.clone(), + name: "Second".into(), + shape_ids: Vec::new(), + visible: true, + locked: false, + opacity: Opacity::OPAQUE, + version: RecordVersion(1), + }, + ); + + let transaction = reconcile_editor_patches( + &snapshot, + request(vec![ + EditorPatch::PatchLayer { + layer_id: layer_id.clone(), + patch: LayerPatch { name: Some("Renamed".into()), visible: None, locked: None, opacity: None }, + }, + EditorPatch::ReorderLayer { layer_id: layer_id.clone(), anchor: SiblingAnchor::First }, + EditorPatch::RenamePage { page_id, name: "Renamed page".into() }, + ]), + ) + .expect("layer fields and order should reconcile together"); + + assert_eq!(transaction.operations.len(), 3); + let Operation::PatchLayer { expected_version, .. } = &transaction.operations[0] else { + panic!("expected a layer patch") + }; + assert_eq!(*expected_version, Some(RecordVersion(1))); + let Operation::ReorderLayer { layer_id: reordered, expected_version, .. } = &transaction.operations[1] else { + panic!("expected a layer reorder") + }; + assert_eq!(reordered, &layer_id); + assert_eq!(*expected_version, None); + let Operation::RenamePage { expected_version, .. } = &transaction.operations[2] else { + panic!("expected a page rename") + }; + assert_eq!(*expected_version, None); + } + #[test] fn projection_preserves_bindings_and_order() { let mut snapshot = nested_snapshot(); diff --git a/packages/core/src/math.ts b/packages/core/src/math.ts index bd4a70a..30527ac 100644 --- a/packages/core/src/math.ts +++ b/packages/core/src/math.ts @@ -1,4 +1,7 @@ -export type Vec2 = { x: number; y: number }; +import type { Vec2 as NativeVec2 } from '@inkfinite/bindings/model'; + +/** Two-dimensional point shared with the native document model. */ +export type Vec2 = NativeVec2; /** Constrain a number to an inclusive range. */ export function clamp(value: number, minimum: number, maximum: number): number { diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 5b940d0..74efdc4 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -1,3 +1,10 @@ +import type { EditorTransform as GeneratedEditorTransform } from '@inkfinite/bindings/editor'; +import type { + PathFillRule as NativePathFillRule, + PathGeometry as NativePathGeometry, + PathSegment as NativePathSegment, + PathSubpath as NativePathSubpath +} from '@inkfinite/bindings/model'; import { v4 } from 'uuid'; import type { Vec2 } from './math'; /** @@ -68,20 +75,16 @@ export type EllipseProps = { w: number; h: number; fill: string; stroke: string export type LineProps = { a: Vec2; b: Vec2; stroke: string; width: number }; /** Fill rule for compound native paths. */ -export type PathFillRule = 'nonzero' | 'evenodd'; +export type PathFillRule = NativePathFillRule; /** A normalized native path segment. */ -export type PathSegment = - | { type: 'move'; to: Vec2 } - | { type: 'line'; to: Vec2 } - | { type: 'quadratic'; control: Vec2; to: Vec2 } - | { type: 'cubic'; control_1: Vec2; control_2: Vec2; to: Vec2 }; +export type PathSegment = NativePathSegment; /** One native path subpath. */ -export type PathSubpath = { segments: PathSegment[]; closed: boolean }; +export type PathSubpath = NativePathSubpath; /** Native path geometry and its compound fill rule. */ -export type PathGeometry = { subpaths: PathSubpath[]; fill_rule: PathFillRule }; +export type PathGeometry = NativePathGeometry; /** Native path painting properties stored alongside its geometry. */ export type PathProps = PathGeometry & { fill?: string; stroke?: string; stroke_width?: number }; @@ -171,8 +174,8 @@ export type StrokeProps = { points: StrokePoint[]; style: StrokeStyle; brush: Br export type ShapeType = 'rect' | 'ellipse' | 'line' | 'arrow' | 'text' | 'stroke' | 'path' | 'markdown'; -/** Full world transform retained when a native projection contains scale or shear. */ -export type EditorTransform = { a: number; b: number; c: number; d: number; e: number; f: number }; +/** Full projected transform shared with the Rust editor projection. */ +export type EditorTransform = GeneratedEditorTransform; export type BaseShape = { id: string; diff --git a/packages/core/src/persistence/canonical.ts b/packages/core/src/persistence/canonical.ts index 57726c3..2d7ea61 100644 --- a/packages/core/src/persistence/canonical.ts +++ b/packages/core/src/persistence/canonical.ts @@ -33,10 +33,9 @@ export type CanonicalSnapshotOptions = { documentId: string; heads?: readonly st * Converts the browser editor document into the JSON shape consumed by Rust * and the browser WASM renderer. * - * The editor model is intentionally flat today. Each shape therefore becomes - * a root child of its owning layer; its local transform preserves the editor's - * position and rotation. The native hierarchy can replace this adapter when - * browser projection moves into Rust. + * Legacy flat editor documents are normalized into root children of their + * owning layers. Active browser sessions use the Rust projection and do not + * rebuild this hierarchy in TypeScript. */ export function toCanonicalDocumentSnapshot( input: BoardExport | Document, diff --git a/packages/core/tests/canonical.test.ts b/packages/core/tests/canonical.test.ts index f578398..9b435ba 100644 --- a/packages/core/tests/canonical.test.ts +++ b/packages/core/tests/canonical.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { createEditorReconciliationRequest, toCanonicalDocumentSnapshot } from '../src/persistence/canonical'; -import { PageRecord, ShapeRecord, type Document } from '../src/model'; +import { LayerRecord, PageRecord, ShapeRecord, type Document } from '../src/model'; describe('toCanonicalDocumentSnapshot', () => { it('projects browser shapes into the canonical renderer input', () => { @@ -62,4 +62,93 @@ describe('toCanonicalDocumentSnapshot', () => { transform: { e: 30, f: 45 } }); }); + + it('reconciles page and layer structure as semantic patches', () => { + const page = PageRecord.create('Page 1', 'page:one'); + const back = LayerRecord.create(page.id, 'Back', 'layer:back'); + page.layerIds = [back.id]; + const front = LayerRecord.create(page.id, 'Front', 'layer:front'); + const nextPage = PageRecord.create('Page 2', 'page:two'); + const nextLayer = LayerRecord.create(nextPage.id, 'Default', 'layer:two'); + const before: Document = { pages: { [page.id]: page }, layers: { [back.id]: back }, shapes: {}, bindings: {} }; + const after: Document = { + pages: { + [page.id]: { ...page, layerIds: [front.id, back.id] }, + [nextPage.id]: { ...nextPage, layerIds: [nextLayer.id] } + }, + layers: { [front.id]: front, [back.id]: { ...back, name: 'Renamed back' }, [nextLayer.id]: nextLayer }, + shapes: {}, + bindings: {} + }; + + const request = createEditorReconciliationRequest(before, after, { + actor_id: 'browser', + origin: 'human', + transaction_id: 'transaction:structure', + description: 'Update structure', + timestamp: 1 + }); + + expect(request.patches.map((patch) => patch.type)).toEqual([ + 'create_page', + 'create_layer', + 'create_layer', + 'patch_layer', + 'reorder_layer' + ]); + expect(request.patches).toContainEqual( + expect.objectContaining({ type: 'create_page', page: expect.objectContaining({ id: 'page:two' }) }) + ); + expect(request.patches).toContainEqual( + expect.objectContaining({ type: 'reorder_layer', layer_id: 'layer:back' }) + ); + }); + + it('reconciles a layer deletion with a native move disposition', () => { + const page = PageRecord.create('Page 1', 'page:one'); + const source = LayerRecord.create(page.id, 'Source', 'layer:source'); + const destination = LayerRecord.create(page.id, 'Destination', 'layer:destination'); + const shape = ShapeRecord.createRect( + page.id, + 10, + 20, + { w: 40, h: 20, fill: 'red', stroke: 'none', radius: 4 }, + 'shape:moved' + ); + shape.layerId = source.id; + page.layerIds = [source.id, destination.id]; + page.shapeIds = [shape.id]; + source.shapeIds = [shape.id]; + const before: Document = { + pages: { [page.id]: page }, + layers: { [source.id]: source, [destination.id]: destination }, + shapes: { [shape.id]: shape }, + bindings: {} + }; + const after: Document = { + pages: { [page.id]: { ...page, layerIds: [destination.id], shapeIds: [shape.id] } }, + layers: { [destination.id]: { ...destination, shapeIds: [shape.id] } }, + shapes: { [shape.id]: { ...shape, layerId: destination.id } }, + bindings: {} + }; + + const request = createEditorReconciliationRequest(before, after, { + actor_id: 'browser', + origin: 'human', + transaction_id: 'transaction:move-layer', + description: 'Delete source layer', + timestamp: 1 + }); + + expect(request.patches).toContainEqual( + expect.objectContaining({ type: 'reorder_layer', layer_id: destination.id }) + ); + expect(request.patches).toContainEqual( + expect.objectContaining({ + type: 'delete_layer', + layer_id: source.id, + contents: { kind: 'move_to', destination_layer_id: destination.id } + }) + ); + }); });