diff --git a/src/lib/canvas-core/document.ts b/src/lib/canvas-core/document.ts new file mode 100644 index 0000000..35d1a6a --- /dev/null +++ b/src/lib/canvas-core/document.ts @@ -0,0 +1,103 @@ +import type { CanvasRecord, Change, Document, ElementRecord, Geometry, Id, Registry, Target } from './types.ts'; +import { clone, equal, fault, freeze, readMap, validJson } from './values.ts'; + +export function createDocument(projectId: Id, canvases: readonly CanvasRecord[], elements: readonly ElementRecord[] = [], tombstones: ReadonlyMap = new Map()): Document { + for (const element of elements) if (!validJson(element.data)) throw fault('invalid_element', [element.id]); + if (!projectId || new Set(canvases.map(item => item.id)).size !== canvases.length || new Set(elements.map(item => item.id)).size !== elements.length) throw fault('duplicate_identity'); + return Object.freeze({ projectId, canvases: readMap(new Map(canvases.map(item => [item.id, freeze(clone(item))]))), elements: readMap(new Map(elements.map(item => [item.id, freeze(clone(item))]))), tombstones: readMap(tombstones) }); +} +export function geometryValid(value: Geometry): boolean { + return [value.x, value.y, value.width, value.height, value.rotation].every(Number.isFinite) && value.width > 0 && value.height > 0 && value.rotation >= 0 && value.rotation < 360; +} +export function targetElement(document: Document, target: Target): ElementRecord { + const element = document.elements.get(target.id); + if (!element) throw fault('element_missing', [target.id]); + if (element.incarnation !== target.incarnation) throw fault('stale_incarnation', [target.id]); + return element; +} +export function ancestors(document: Document, canvasId: Id): Id[] { + const result: Id[] = [], visited = new Set(); + let next: Id | undefined = canvasId; + while (next) { + if (visited.has(next)) throw fault('canvas_cycle', [next]); + visited.add(next); result.push(next); + const canvas: CanvasRecord | undefined = document.canvases.get(next); + if (!canvas) throw fault('canvas_not_loaded', [next]); + next = canvas.parent?.canvasId; + } + return result; +} +export function commonCanvas(document: Document, left: Id, right: Id): Id { + const parents = new Set(ancestors(document, left)); + const result = ancestors(document, right).find(id => parents.has(id)); + if (!result) throw fault('no_common_canvas', [left, right]); + return result; +} +export function validateDocument(document: Document, registry: Registry): void { + if (!document.canvases.size) throw fault('missing_root'); + for (const [id, incarnation] of document.tombstones) if (!id || !Number.isSafeInteger(incarnation) || incarnation < 1) throw fault('invalid_tombstone', [id]); + const entries = new Set(); + let roots = 0; + for (const canvas of document.canvases.values()) { + if (!canvas.id) throw fault('invalid_canvas'); + ancestors(document, canvas.id); + if (!canvas.parent) { roots++; continue; } + const entry = document.elements.get(canvas.parent.entryElementId); + if (!entry || entry.structure !== 'node' || entry.ownerCanvasId !== canvas.parent.canvasId || entries.has(entry.id)) throw fault('invalid_canvas_entry', [canvas.id]); + entries.add(entry.id); + } + if (roots !== 1) throw fault('invalid_roots'); + for (const element of document.elements.values()) { + if (!element.id || !document.canvases.has(element.ownerCanvasId) || !Number.isSafeInteger(element.incarnation) || element.incarnation < 1 || !Number.isSafeInteger(element.dataVersion) || element.dataVersion < 1 || !validJson(element.data)) throw fault('invalid_element', [element.id]); + const dead = document.tombstones.get(element.id); + if (dead !== undefined && element.incarnation <= dead) throw fault('stale_incarnation', [element.id]); + const definition = registry.components.get(element.componentId); + if (registry.resolve(element) === 'known' && (!definition || definition.structure !== element.structure || !definition.validate(element.data))) throw fault('invalid_component_data', [element.id]); + if (element.structure === 'node') { + if (!geometryValid(element.geometry)) throw fault('invalid_geometry', [element.id]); + } else { + const source = document.elements.get(element.source.elementId), target = document.elements.get(element.target.elementId); + if (!source || !target || source.structure !== 'node' || target.structure !== 'node' || !validJson(element.path)) throw fault('invalid_endpoint', [element.id]); + if (commonCanvas(document, source.ownerCanvasId, target.ownerCanvasId) !== element.ownerCanvasId) throw fault('invalid_connection_owner', [element.id]); + } + } +} +export function applyChanges(document: Document, changes: readonly Change[], conditional = true): Document { + const elements = new Map(document.elements), canvases = new Map(document.canvases), tombstones = new Map(document.tombstones); + for (const change of changes) { + if (change.kind === 'field') { + if (!change.field.startsWith('data.') && !['geometry', 'ownerCanvasId', 'source', 'target', 'path'].includes(change.field)) throw fault('invalid_field'); + const element = elements.get(change.target.id); + if (!element || element.incarnation !== change.target.incarnation) throw fault('stale_incarnation', [change.target.id]); + const dataKey = change.field.startsWith('data.') ? change.field.slice(5) : null; + const prior = dataKey !== null ? element.data[dataKey] ?? null : (element as unknown as Record)[change.field]; + if (conditional && (dataKey !== null && Object.hasOwn(element.data, dataKey) !== (change.beforePresent ?? true) || !equal(prior, change.before))) throw fault('field_conflict', [element.id]); + if (dataKey !== null) { + if (!dataKey || ['__proto__', 'prototype', 'constructor'].includes(dataKey)) throw fault('invalid_field'); + const data = { ...element.data }; + if (change.afterPresent === false) delete data[dataKey]; else data[dataKey] = clone(change.after); + elements.set(element.id, freeze({ ...element, data })); + } else elements.set(element.id, freeze({ ...element, [change.field]: clone(change.after) }) as ElementRecord); + } else if (change.kind === 'element') { + if (conditional && !equal(elements.get(change.id) || null, change.before)) throw fault('element_conflict', [change.id]); + if (change.after) { + if (change.after.id !== change.id) throw fault('identity_mismatch', [change.id]); + elements.set(change.id, freeze(clone(change.after))); + } else { + const old = elements.get(change.id); + if (old) tombstones.set(change.id, Math.max(tombstones.get(change.id) || 0, old.incarnation)); + elements.delete(change.id); + } + } else if (change.kind === 'canvas') { + if (conditional && !equal(canvases.get(change.id) || null, change.before)) throw fault('canvas_conflict', [change.id]); + if (change.after) { + if (change.after.id !== change.id) throw fault('identity_mismatch', [change.id]); + canvases.set(change.id, freeze(clone(change.after))); + } else canvases.delete(change.id); + } else throw fault('invalid_change'); + } + return Object.freeze({ projectId: document.projectId, elements: readMap(elements), canvases: readMap(canvases), tombstones: readMap(tombstones) }); +} +export function invert(changes: readonly Change[]): readonly Change[] { + return changes.slice().reverse().map(change => freeze({ ...change, before: change.after, after: change.before, ...(change.kind === 'field' ? { beforePresent: change.afterPresent, afterPresent: change.beforePresent } : {}) }) as Change); +} diff --git a/src/lib/canvas-core/editor.ts b/src/lib/canvas-core/editor.ts new file mode 100644 index 0000000..c4a28e4 --- /dev/null +++ b/src/lib/canvas-core/editor.ts @@ -0,0 +1,268 @@ +import type { Applied, Availability, Capability, Change, Command, Commit, CoreSnapshot, Document, ElementRecord, Geometry, HistoryEntry, HistoryState, Id, OperationIntent, Origin, Registry, Result, Snapshot } from './types.ts'; +import { applyChanges, commonCanvas, createDocument, invert, targetElement, validateDocument } from './document.ts'; +import { clone, equal, fault, freeze, readMap, reject } from './values.ts'; + +export type EditorOptions = { + registry: Registry; document: Document; createId?: () => Id; historyLimit?: number; + policy?: (command: Command, document: Document) => Availability; + traversalPolicy?: (entry: HistoryEntry, direction: 'undo' | 'redo') => Availability; + onListenerError?: (error: unknown) => void; + allowRawChanges?: boolean; + recovery?: CoreSnapshot & { readonly rootRevision: number }; + runtime?: Runtime; + /** Installed by the engine. Pure preparation: an error rejects the entire commit. */ + reduceRuntime?: (previous: Runtime, next: CoreSnapshot, commit: Commit) => Runtime; +}; +export type Transaction = { dispatch(command: Command): Availability; getDocument(): Document }; +export type Editor = ReturnType; +const isThenable = (value: unknown) => !!value && (typeof value === 'object' || typeof value === 'function') && typeof (value as { then?: unknown }).then === 'function'; + +export function createEditor(options: EditorOptions) { + const source = options.recovery?.document ?? options.document; + const document = createDocument(source.projectId, [...source.canvases.values()], [...source.elements.values()], source.tombstones); + validateDocument(document, options.registry); + let state: Snapshot = Object.freeze({ revision: options.recovery?.revision ?? 0, rootRevision: options.recovery?.rootRevision ?? 0, document, history: options.recovery?.history ?? Object.freeze({ undo: Object.freeze([]), redo: Object.freeze([]), entries: readMap(new Map()) }), operations: options.recovery?.operations ?? readMap(new Map()), runtime: options.runtime as Runtime }); + let entered = false, disposed = false, sequence = 0; + for (const intent of state.operations.values()) sequence = Math.max(sequence, intent.sequence); + const listeners = new Set<(commit: Commit, snapshot: Snapshot) => void>(); + const createId = options.createId || (() => globalThis.crypto.randomUUID()); + const limit = options.historyLimit ?? 100; + if (!Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_history_limit'); + + function plan(document: Document, command: Command): { document: Document; changes: readonly Change[] } { + const permission = options.policy?.(command, document); + if (permission && !permission.enabled) throw fault(permission.code, permission.targets); + let changes: readonly Change[]; + if (command.type === 'canvas.create') { + if (document.canvases.has(command.canvas.id)) throw fault('duplicate_identity', [command.canvas.id]); + changes = [{ kind: 'canvas', id: command.canvas.id, before: null, after: command.canvas }]; + } else if (command.type === 'canvas.reparent') { + const canvas = document.canvases.get(command.canvasId); + if (!canvas) throw fault('canvas_not_loaded', [command.canvasId]); + changes = equal(canvas.parent, command.parent) ? [] : [{ kind: 'canvas', id: canvas.id, before: canvas, after: { ...canvas, parent: command.parent } }]; + } else if (command.type === 'element.create') { + const element = command.element; + if (document.elements.has(element.id) || document.tombstones.has(element.id)) throw fault('duplicate_identity', [element.id]); + if (options.registry.resolve(element) !== 'known') throw fault('unsupported_component', [element.id]); + changes = [{ kind: 'element', id: element.id, before: null, after: element }]; + } else if (command.type === 'changes.apply') { + if (!options.allowRawChanges) throw fault('raw_changes_not_allowed'); + changes = command.changes; + } else { + let element = targetElement(document, command.target); + if (options.registry.resolve(element) !== 'known') throw fault('unsupported_component', [element.id]); + if (command.type === 'element.remove') { + changes = [{ kind: 'element', id: element.id, before: element, after: null }]; + for (const edge of document.elements.values()) if (edge.structure === 'connection' && (edge.source.elementId === element.id || edge.target.elementId === element.id)) changes = [...changes, { kind: 'element', id: edge.id, before: edge, after: null }]; + } else if (command.type === 'element.move') { + if (!options.registry.supports(element, 'geometry.move')) throw fault('unsupported_capability', [element.id]); + changes = [{ kind: 'field', target: command.target, field: 'ownerCanvasId', before: element.ownerCanvasId, after: command.canvasId }]; + if (command.geometry && element.structure === 'node') changes = [...changes, { kind: 'field', target: command.target, field: 'geometry', before: element.geometry, after: command.geometry }]; + for (const canvas of document.canvases.values()) if (canvas.parent?.entryElementId === element.id) changes = [...changes, { kind: 'canvas', id: canvas.id, before: canvas, after: { ...canvas, parent: { ...canvas.parent, canvasId: command.canvasId } } }]; + const moved = applyChanges(document, changes); + for (const edge of moved.elements.values()) if (edge.structure === 'connection') { + const source = moved.elements.get(edge.source.elementId), target = moved.elements.get(edge.target.elementId); + if (!source || !target) throw fault('invalid_endpoint', [edge.id]); + const owner = commonCanvas(moved, source.ownerCanvasId, target.ownerCanvasId); + if (owner !== edge.ownerCanvasId) changes = [...changes, { kind: 'field', target: { id: edge.id, incarnation: edge.incarnation }, field: 'ownerCanvasId', before: edge.ownerCanvasId, after: owner }]; + } + } else { + const output: Change[] = []; + const run = (capability: Capability, patch: unknown) => { + const handler = options.registry.handlers.get(element.componentId)?.get(capability); + if (!handler) throw fault('unsupported_capability', [element.id, capability]); + const generated = handler({ element, input: freeze({ patch: clone(patch) }) }); + // A handler is scoped to this target; it cannot write another object or domain. + for (const change of generated) if (change.target.id !== element.id || change.target.incarnation !== element.incarnation || (capability === 'content.edit' ? !change.field.startsWith('data.') : change.field !== 'geometry')) throw fault('invalid_handler_change', [element.id]); + output.push(...generated); + element = targetElement(applyChanges(document, output), command.target); + }; + if (command.type === 'element.data') run('content.edit', command.patch); + else { + if (element.structure !== 'node' || Object.keys(command.patch).some(key => !['x', 'y', 'width', 'height', 'rotation'].includes(key))) throw fault('invalid_geometry_patch', [element.id]); + const parts: [Capability, Partial][] = [ + ['geometry.move', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'x' || key === 'y'))], + ['geometry.resize', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'width' || key === 'height'))], + ['geometry.rotate', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'rotation'))], + ]; + for (const [capability, patch] of parts) if (Object.keys(patch).length) run(capability, patch); + } + changes = output; + } + } + const next = applyChanges(document, changes); + return { document: next, changes: freeze(clone(changes)) }; + } + + function assertOpen() { + if (disposed) throw fault('editor_disposed'); + if (entered) throw fault('reentrant_dispatch'); + } + function emit(commit: Commit) { + for (const listener of [...listeners]) try { listener(commit, state); } catch (error) { try { options.onListenerError?.(error); } catch { /* Observers cannot roll back a committed state. */ } } + } + function uniqueId(used: Set): Id { + const id = createId(); + if (!id || used.has(id) || state.operations.has(id) || state.history.entries.has(id)) throw fault('duplicate_operation_id'); + used.add(id); return id; + } + function publish(document: Document, changes: readonly Change[], origin: Origin, history: HistoryState, historyEntryId?: Id): Applied { + const used = new Set(); + for (const id of history.entries.keys()) used.add(id); + // Canonical remote/ACK/replay updates must never feed back into the outbox. + const outbound = !['remote', 'ack', 'replay'].includes(origin); + const operationIds = Object.freeze(outbound ? [uniqueId(used)] : []); + const operations = new Map(state.operations); + if (outbound) { + const canvasIds = new Set(); + for (const change of changes) { + if (change.kind === 'canvas') { + canvasIds.add(change.id); + if (change.before?.parent) canvasIds.add(change.before.parent.canvasId); + if (change.after?.parent) canvasIds.add(change.after.parent.canvasId); + } else if (change.kind === 'element') { + if (change.before) canvasIds.add(change.before.ownerCanvasId); + if (change.after) canvasIds.add(change.after.ownerCanvasId); + } else { + const before = state.document.elements.get(change.target.id), after = document.elements.get(change.target.id); + if (before) canvasIds.add(before.ownerCanvasId); + if (after) canvasIds.add(after.ownerCanvasId); + if (change.field === 'ownerCanvasId') { canvasIds.add(change.before as Id); canvasIds.add(change.after as Id); } + } + } + const intent: OperationIntent = freeze({ id: operationIds[0], operationType: 'document.change', operationSchemaVersion: 1, sequence: sequence + 1, origin, canvasIds: [...canvasIds].sort(), changes, dependencies: [] }); + operations.set(intent.id, intent); + } + if (historyEntryId) { + const entries = new Map(history.entries), entry = entries.get(historyEntryId)!; + entries.set(entry.id, freeze({ ...entry, operationIds: [...entry.operationIds, ...operationIds] })); + history = Object.freeze({ ...history, entries: readMap(entries) }); + } + const next: CoreSnapshot = Object.freeze({ revision: state.revision + 1, document, history, operations: readMap(operations) }); + const commit = freeze({ revision: next.revision, origin, changes, operationIds }); + const runtime = options.reduceRuntime ? options.reduceRuntime(state.runtime, next, commit) : state.runtime; + if (isThenable(runtime)) throw fault('async_transaction'); + state = Object.freeze({ ...next, rootRevision: state.rootRevision + 1, runtime }); + if (outbound) sequence++; + emit(commit); + return Object.freeze({ status: 'applied', revision: state.revision, operationIds, ...(historyEntryId ? { historyEntryId } : {}) }); + } + + function transaction(callback: (transaction: Transaction) => unknown, settings: { label?: string; groupId?: Id; history?: boolean; origin?: Origin } = {}): Result { + try { assertOpen(); } catch (error) { return reject(error); } + entered = true; + let active = true, document = state.document, failed: unknown; + const changes: Change[] = []; + const tx: Transaction = Object.freeze({ + dispatch(command: Command): Availability { + if (!active) throw fault('transaction_closed'); + if (failed) return { enabled: false, ...reject(failed) }; + try { + const result = plan(document, command); document = result.document; changes.push(...result.changes); + return { enabled: true }; + } catch (error) { failed = error; const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; } + }, + getDocument() { if (!active) throw fault('transaction_closed'); return document; }, + }); + try { + const result = callback(tx); + active = false; + if (isThenable(result)) { Promise.resolve(result).catch(() => {}); throw fault('async_transaction'); } + if (failed) throw failed; + validateDocument(document, options.registry); + if (!changes.length || documentsEqual(document, state.document)) return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) }); + let history = state.history, historyEntryId: Id | undefined; + if (settings.history !== false && (!settings.origin || settings.origin === 'user')) { + const entries = new Map(history.entries), undo = [...history.undo]; + for (const id of history.redo) entries.delete(id); + const prior = undo.length ? entries.get(undo[undo.length - 1]) : undefined; + if (settings.groupId && prior?.groupId === settings.groupId) { + historyEntryId = prior.id; + entries.set(prior.id, freeze({ ...prior, changes: [...prior.changes, ...changes] })); + } else { + historyEntryId = uniqueId(new Set()); + entries.set(historyEntryId, freeze({ id: historyEntryId, label: settings.label || 'Edit', changes: [...changes], operationIds: [], ...(settings.groupId ? { groupId: settings.groupId } : {}) })); + undo.push(historyEntryId); + } + while (undo.length > limit) entries.delete(undo.shift()!); + history = Object.freeze({ undo: Object.freeze(undo), redo: Object.freeze([]), entries: readMap(entries) }); + } + return publish(document, freeze([...changes]), settings.origin || 'user', history, historyEntryId); + } catch (error) { active = false; return reject(error); } + finally { entered = false; } + } + function dispatch(command: Command, settings?: Parameters[1]): Result { + return transaction(tx => { tx.dispatch(command); }, settings); + } + function can(command: Command): Availability { + try { if (disposed) throw fault('editor_disposed'); const result = plan(state.document, command); validateDocument(result.document, options.registry); return { enabled: true }; } + catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; } + } + function traverse(direction: 'undo' | 'redo'): Result { + try { assertOpen(); } catch (error) { return reject(error); } + entered = true; + try { + const source = state.history[direction], id = source[source.length - 1]; + if (!id) throw fault('history_empty'); + const entry = state.history.entries.get(id)!; + const allowed = options.traversalPolicy?.(entry, direction); + if (allowed && !allowed.enabled) throw fault(allowed.code, allowed.targets); + let changes = direction === 'undo' ? invert(entry.changes) : entry.changes; + const generations = new Map(); + changes = changes.map(change => { + if (change.kind === 'element' && !change.before && change.after) { + const dead = state.document.tombstones.get(change.id); + if (dead !== undefined && change.after.incarnation <= dead) { generations.set(change.id, dead + 1); return freeze({ ...change, after: { ...change.after, incarnation: dead + 1 } }); } + } + return change; + }).map(change => change.kind === 'field' && generations.has(change.target.id) ? freeze({ ...change, target: { ...change.target, incarnation: generations.get(change.target.id)! } }) : change); + const document = applyChanges(state.document, changes); + validateDocument(document, options.registry); + const entries = new Map(state.history.entries); + entries.set(id, freeze({ ...entry, changes: direction === 'undo' ? invert(changes) : changes })); + const history: HistoryState = Object.freeze({ undo: Object.freeze(direction === 'undo' ? state.history.undo.slice(0, -1) : [...state.history.undo, id]), redo: Object.freeze(direction === 'redo' ? state.history.redo.slice(0, -1) : [...state.history.redo, id]), entries: readMap(entries) }); + return publish(document, changes, direction, history, id); + } catch (error) { return reject(error); } + finally { entered = false; } + } + return Object.freeze({ getSnapshot: () => state, registry: options.registry, dispatch, transaction, can, + subscribe(listener: (commit: Commit, snapshot: Snapshot) => void) { if (disposed) throw fault('editor_disposed'); listeners.add(listener); return () => { listeners.delete(listener); }; }, + /** Engine-only metadata commit, sharing the document/history publication boundary. */ + updateRuntime(reduce: (runtime: Runtime) => Runtime, expectedRootRevision = state.rootRevision): Result { + try { assertOpen(); } catch (error) { return reject(error); } + entered = true; + try { + if (expectedRootRevision !== state.rootRevision) throw fault('stale_plan'); + const runtime = reduce(state.runtime); + if (isThenable(runtime)) throw fault('async_transaction'); + if (runtime !== state.runtime) { + state = Object.freeze({ ...state, rootRevision: state.rootRevision + 1, runtime }); + emit(freeze({ revision: state.revision, origin: 'ack', changes: [], operationIds: [] })); + } + return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) }); + } catch (error) { return reject(error); } + finally { entered = false; } + }, + /** Engine-only canonical proposal: validation and runtime state publish as one root. */ + reconcile(document: Document, runtime: Runtime, expectedRootRevision: number): Result { + try { assertOpen(); } catch (error) { return reject(error); } + entered = true; + try { + if (expectedRootRevision !== state.rootRevision) throw fault('stale_plan'); + if (document.projectId !== state.document.projectId) throw fault('project_mismatch'); + const next = createDocument(document.projectId, [...document.canvases.values()], [...document.elements.values()], document.tombstones); + validateDocument(next, options.registry); + const changed = !documentsEqual(next, state.document); + state = Object.freeze({ ...state, document: changed ? next : state.document, runtime, revision: state.revision + Number(changed), rootRevision: state.rootRevision + 1 }); + emit(freeze({ revision: state.revision, origin: 'ack', changes: [], operationIds: [] })); + return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) }); + } catch (error) { return reject(error); } + finally { entered = false; } + }, + history: Object.freeze({ undo: () => traverse('undo'), redo: () => traverse('redo'), canUndo: () => !!state.history.undo.length, canRedo: () => !!state.history.redo.length }), + dispose() { disposed = true; listeners.clear(); }, + }); +} +function documentsEqual(a: Document, b: Document): boolean { + return equal([...a.canvases], [...b.canvases]) && equal([...a.elements], [...b.elements]) && equal([...a.tombstones], [...b.tombstones]); +} diff --git a/src/lib/canvas-core/features.ts b/src/lib/canvas-core/features.ts new file mode 100644 index 0000000..7ab21bd --- /dev/null +++ b/src/lib/canvas-core/features.ts @@ -0,0 +1,51 @@ +import type { CapabilityHandler, ComponentDefinition, Data, FieldChange, Geometry } from './types.ts'; +import { defineComponent, defineFeature } from './registry.ts'; +import { equal, fault, validJson } from './values.ts'; + +const geometryHandler = (allowed: readonly (keyof Geometry)[], constrain?: (geometry: Geometry) => Geometry): CapabilityHandler => ({ element, input }) => { + if (element.structure !== 'node') throw fault('unsupported_capability', [element.id]); + const patch = input.patch as Partial; + if (!patch || Object.values(patch).some(value => typeof value !== 'number' || !Number.isFinite(value)) || Object.keys(patch).some(key => !allowed.includes(key as keyof Geometry))) throw fault('invalid_geometry_patch', [element.id]); + const after = constrain ? constrain({ ...element.geometry, ...patch }) : { ...element.geometry, ...patch }; + return equal(element.geometry, after) ? [] : [{ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: 'geometry', before: element.geometry, after }]; +}; +export const movable = () => defineFeature('geometry.move', { 'geometry.move': geometryHandler(['x', 'y']) }); +export const resizable = (inputOptions: { min?: number; max?: number; aspect?: boolean } = {}) => { + const options = Object.freeze({ ...inputOptions }); + if (options.min !== undefined && (!Number.isFinite(options.min) || options.min <= 0) || options.max !== undefined && (!Number.isFinite(options.max) || options.max < (options.min ?? 1))) throw fault('invalid_resize_constraint'); + return defineFeature('geometry.resize', { + 'geometry.resize': ({ element, input }) => { + if (element.structure !== 'node') throw fault('unsupported_capability', [element.id]); + const patch = input.patch as Partial; + return geometryHandler(['width', 'height'], geometry => { + const min = options.min ?? 1, max = options.max ?? 100000; + let width = Math.min(max, Math.max(min, geometry.width)), height = Math.min(max, Math.max(min, geometry.height)); + if (options.aspect) { + const ratio = element.geometry.width / element.geometry.height; + if (patch.width !== undefined) height = width / ratio; else width = height * ratio; + if (width < min || height < min || width > max || height > max) throw fault('aspect_constraint', [element.id]); + } + return { ...geometry, width, height }; + })({ element, input }); + }, + }); +}; +export const rotatable = () => defineFeature('geometry.rotate', { 'geometry.rotate': geometryHandler(['rotation']) }); +export const editable = () => defineFeature('content.edit', { + 'content.edit': ({ element, input }) => { + const patch = input.patch as Data; + if (!patch || !validJson(patch) || Array.isArray(patch)) throw fault('invalid_data_patch', [element.id]); + return Object.entries(patch).flatMap(([key, after]): FieldChange[] => { + const before = element.data[key] ?? null; + return Object.hasOwn(element.data, key) && equal(before, after) ? [] : [{ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: `data.${key}`, before, after, beforePresent: Object.hasOwn(element.data, key), afterPresent: true }]; + }); + }, +}); +const textData = (data: Data) => validJson(data) && typeof data.title === 'string' && typeof data.text === 'string'; +export function builtinComponents(): readonly ComponentDefinition[] { + return ['text', 'image', 'script', 'plain-text', 'plain-image', 'icon', 'frame'].map(id => defineComponent({ + id, dataVersion: 1, structure: 'node', validate: textData, + features: [movable(), resizable(), ...(id === 'frame' ? [] : [rotatable()]), editable()], + resourceFields: ['image', 'plain-image'].includes(id) ? ['asset'] : [], + })).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: data => validJson(data), features: [editable()] })]); +} diff --git a/src/lib/canvas-core/gestures.ts b/src/lib/canvas-core/gestures.ts new file mode 100644 index 0000000..632e98c --- /dev/null +++ b/src/lib/canvas-core/gestures.ts @@ -0,0 +1,120 @@ +import type { Editor } from './editor.ts'; +import type { Availability, Capability, ElementRecord, Geometry, Id, Result, Target } from './types.ts'; +import { geometryValid, targetElement } from './document.ts'; +import { equal, fault, freeze, readMap, readSet, reject } from './values.ts'; + +export type ViewState = { readonly id: Id; readonly selection: ReturnType>; readonly viewport: { readonly x: number; readonly y: number; readonly zoom: number }; readonly tool: 'select' | 'pan'; readonly modes: ReadonlyMap }; +export type GestureState = { readonly viewId: Id; readonly targets: readonly Target[]; readonly preview: ReadonlyMap; readonly phase: 'active' | 'committing' }; +export type SessionSnapshot = { readonly views: ReadonlyMap; readonly gesture: GestureState | null; readonly lastCancelReason: string | null }; + +/** Transient interaction state belongs to views; no pointer event enters the ledger. */ +export function createSessions(editor: Editor) { + let snapshot: SessionSnapshot = Object.freeze({ views: readMap(new Map()), gesture: null, lastCancelReason: null }); + let baseline = new Map(), disposed = false; + const listeners = new Set<() => void>(); + const notify = () => { for (const listener of [...listeners]) try { listener(); } catch { /* A renderer cannot invalidate model commits. */ } }; + function setView(view: ViewState) { const views = new Map(snapshot.views); views.set(view.id, view); snapshot = Object.freeze({ ...snapshot, views: readMap(views) }); notify(); } + function cancel(reason = 'canceled') { + if (!snapshot.gesture) return; + baseline.clear(); snapshot = Object.freeze({ ...snapshot, gesture: null, lastCancelReason: reason }); notify(); + } + const detachEditor = editor.subscribe(() => { + const document = editor.getSnapshot().document; + const views = new Map(snapshot.views); let changed = false; + for (const [id, view] of views) { + const selection = [...view.selection].filter(target => document.elements.has(target)), modes = new Map([...view.modes].filter(([target]) => document.elements.has(target))); + if (selection.length !== view.selection.size || modes.size !== view.modes.size) { views.set(id, freeze({ ...view, selection: readSet(selection), modes: readMap(modes) })); changed = true; } + } + if (changed) { snapshot = Object.freeze({ ...snapshot, views: readMap(views) }); notify(); } + if (snapshot.gesture?.phase !== 'active') return; + for (const target of snapshot.gesture.targets) { + const before = baseline.get(target.id), current = document.elements.get(target.id); + if (!current || current.structure !== 'node' || before?.structure !== 'node' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || !equal(current.geometry, before.geometry)) { cancel('target_changed'); return; } + if (!editor.can({ type: 'element.geometry', target, patch: {} }).enabled) { cancel('permission_changed'); return; } + } + }); + function view(id: Id) { const found = snapshot.views.get(id); if (!found || disposed) throw fault('view_detached', [id]); return found; } + function update(viewId: Id, target: Target, patch: Partial): Availability { + try { + view(viewId); + if (snapshot.gesture?.viewId !== viewId || !snapshot.gesture.targets.some(value => value.id === target.id && value.incarnation === target.incarnation)) throw fault('gesture_not_owned', [target.id]); + const original = targetElement(editor.getSnapshot().document, target); + if (original.structure !== 'node') throw fault('unsupported_capability', [target.id]); + if (Object.keys(patch).some(key => !['x', 'y', 'width', 'height', 'rotation'].includes(key))) throw fault('invalid_geometry_patch', [target.id]); + let element: ElementRecord = freeze({ ...original, geometry: snapshot.gesture.preview.get(target.id) || original.geometry }); + const parts: [Capability, Partial][] = [ + ['geometry.move', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'x' || key === 'y'))], + ['geometry.resize', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'width' || key === 'height'))], + ['geometry.rotate', Object.fromEntries(Object.entries(patch).filter(([key]) => key === 'rotation'))], + ]; + for (const [capability, value] of parts) if (Object.keys(value).length) { + if (!editor.registry.supports(element, capability)) throw fault('unsupported_capability', [target.id]); + const changes = editor.registry.handlers.get(element.componentId)!.get(capability)!({ element, input: freeze({ patch: value }) }); + for (const change of changes) { + if (change.field !== 'geometry' || change.target.id !== target.id || change.target.incarnation !== target.incarnation) throw fault('invalid_handler_change', [target.id]); + const geometry = change.after as Geometry; + if (!geometryValid(geometry)) throw fault('invalid_geometry', [target.id]); + element = freeze({ ...element, geometry }); + } + } + const preview = new Map(snapshot.gesture.preview); preview.set(target.id, (element as ElementRecord & { structure: 'node' }).geometry); + snapshot = Object.freeze({ ...snapshot, gesture: freeze({ ...snapshot.gesture, preview: readMap(preview) }) }); notify(); + return { enabled: true }; + } catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; } + } + return Object.freeze({ + getSnapshot: () => snapshot, + attach(id: Id) { + if (disposed || snapshot.views.has(id)) throw fault('duplicate_view', [id]); + setView(freeze({ id, selection: readSet([]), viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select', modes: readMap(new Map()) })); + return () => { if (snapshot.gesture?.viewId === id) cancel('view_detached'); const views = new Map(snapshot.views); views.delete(id); snapshot = Object.freeze({ ...snapshot, views: readMap(views) }); notify(); }; + }, + select(id: Id, ids: readonly Id[]) { + const current = view(id), document = editor.getSnapshot().document; + for (const target of ids) if (!document.elements.has(target)) throw fault('element_missing', [target]); + setView(freeze({ ...current, selection: readSet(ids) })); + }, + viewport(id: Id, viewport: ViewState['viewport']) { + if (![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom <= 0) throw fault('invalid_viewport'); + setView(freeze({ ...view(id), viewport: { ...viewport } })); + }, + tool(id: Id, tool: ViewState['tool']) { setView(freeze({ ...view(id), tool })); }, + mode(id: Id, target: Id, mode: string | null) { const current = view(id), modes = new Map(current.modes); if (mode) modes.set(target, mode); else modes.delete(target); setView(freeze({ ...current, modes: readMap(modes) })); }, + begin(viewId: Id, targets: readonly Target[]): Availability { + try { + view(viewId); + if (snapshot.gesture || !targets.length || new Set(targets.map(target => target.id)).size !== targets.length) throw fault('gesture_busy'); + baseline = new Map(targets.map(target => { const element = targetElement(editor.getSnapshot().document, target); if (element.structure !== 'node' || editor.registry.resolve(element) !== 'known') throw fault('unsupported_component', [target.id]); return [target.id, element]; })); + for (const target of targets) { const allowed = editor.can({ type: 'element.geometry', target, patch: {} }); if (!allowed.enabled) throw fault(allowed.code, allowed.targets); } + snapshot = Object.freeze({ ...snapshot, lastCancelReason: null, gesture: freeze({ viewId, targets: targets.map(target => ({ ...target })), preview: readMap(new Map()), phase: 'active' as const }) }); notify(); + return { enabled: true }; + } catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; } + }, + update, + commit(viewId: Id): Result { + try { + view(viewId); + const gesture = snapshot.gesture; + if (!gesture || gesture.viewId !== viewId) throw fault('gesture_not_owned'); + snapshot = Object.freeze({ ...snapshot, gesture: freeze({ ...gesture, phase: 'committing' as const }) }); + const result = editor.transaction(tx => { + for (const [id, geometry] of gesture.preview) { + const original = baseline.get(id); + if (!original || original.structure !== 'node') throw fault('target_changed', [id]); + const patch = Object.fromEntries(Object.entries(geometry).filter(([key, value]) => !equal(original.geometry[key as keyof Geometry], value))); + tx.dispatch({ type: 'element.geometry', target: { id, incarnation: original.incarnation }, patch }); + } + }, { label: 'Transform' }); + cancel(result.status === 'rejected' ? result.code : 'committed'); + return result; + } catch (error) { cancel('commit_failed'); return reject(error); } + }, + cancel, + effectiveGeometry(id: Id): Geometry | undefined { + const element = editor.getSnapshot().document.elements.get(id); + return snapshot.gesture?.preview.get(id) || (element?.structure === 'node' ? element.geometry : undefined); + }, + subscribe(listener: () => void) { if (disposed) throw fault('sessions_disposed'); listeners.add(listener); return () => { listeners.delete(listener); }; }, + dispose() { cancel('disposed'); disposed = true; detachEditor(); snapshot = Object.freeze({ ...snapshot, views: readMap(new Map()) }); notify(); listeners.clear(); }, + }); +} diff --git a/src/lib/canvas-core/index.ts b/src/lib/canvas-core/index.ts new file mode 100644 index 0000000..1c42352 --- /dev/null +++ b/src/lib/canvas-core/index.ts @@ -0,0 +1,7 @@ +export * from './types.ts'; +export * from './values.ts'; +export * from './document.ts'; +export * from './registry.ts'; +export * from './features.ts'; +export * from './editor.ts'; +export * from './gestures.ts'; diff --git a/src/lib/canvas-core/registry.ts b/src/lib/canvas-core/registry.ts new file mode 100644 index 0000000..cdf5d6d --- /dev/null +++ b/src/lib/canvas-core/registry.ts @@ -0,0 +1,51 @@ +import type { Capability, CapabilityHandler, ComponentDefinition, Data, ElementRecord, Feature, Registry, ReadonlyValueSet } from './types.ts'; +import { clone, fault, freeze, readMap, readSet, validJson } from './values.ts'; + +export function defineFeature(id: string, handlers: Partial>): Feature { + if (!id || !Object.values(handlers).length || Object.values(handlers).some(value => typeof value !== 'function')) throw fault('invalid_feature'); + return freeze({ id, handlers: { ...handlers } }); +} +export function defineComponent(definition: ComponentDefinition): ComponentDefinition { + if (!definition.id || !Number.isSafeInteger(definition.dataVersion) || definition.dataVersion < 1 || typeof definition.validate !== 'function') throw fault('invalid_component'); + return freeze({ ...definition, features: definition.features.map(feature => defineFeature(feature.id, feature.handlers)), migrations: definition.migrations ? { ...definition.migrations } : undefined, resourceFields: [...definition.resourceFields || []] }); +} +export function createRegistry(definitions: readonly ComponentDefinition[]): Registry { + const components = new Map(), capabilities = new Map>(), handlers = new Map>(); + for (const input of definitions) { + const definition = defineComponent(input); + if (components.has(definition.id)) throw fault('duplicate_component', [definition.id]); + const found = new Map(), features = new Set(); + for (const feature of definition.features) { + if (features.has(feature.id)) throw fault('duplicate_feature', [feature.id]); + features.add(feature.id); + for (const [key, handler] of Object.entries(feature.handlers)) { + const capability = key as Capability; + if (!handler) throw fault('invalid_feature'); + if (found.has(capability)) throw fault('duplicate_capability', [definition.id, capability]); + found.set(capability, handler); + } + } + components.set(definition.id, definition); capabilities.set(definition.id, readSet(found.keys())); handlers.set(definition.id, readMap(found)); + } + const resolve: Registry['resolve'] = element => { + const definition = components.get(element.componentId); + if (!definition) return 'unknown-component'; + if (definition.dataVersion !== element.dataVersion) return 'unsupported-version'; + return 'known'; + }; + return Object.freeze({ components: readMap(components), capabilities: readMap(capabilities), handlers: readMap(handlers), resolve, + supports: (element: ElementRecord, capability: Capability) => resolve(element) === 'known' && !!capabilities.get(element.componentId)?.has(capability) }); +} +export function migrateElement(element: ElementRecord, registry: Registry): ElementRecord { + const definition = registry.components.get(element.componentId); + if (!definition || element.dataVersion > definition.dataVersion) return element; + let data: Data = freeze(clone(element.data)), version = element.dataVersion; + while (version < definition.dataVersion) { + const migrate = definition.migrations?.[version]; + if (!migrate) throw fault('missing_migration', [element.id]); + data = freeze(clone(migrate(data))); version++; + if (!validJson(data)) throw fault('invalid_migration', [element.id]); + } + if (!definition.validate(data)) throw fault('invalid_component_data', [element.id]); + return freeze({ ...element, dataVersion: version, data }); +} diff --git a/src/lib/canvas-core/types.ts b/src/lib/canvas-core/types.ts new file mode 100644 index 0000000..a5db355 --- /dev/null +++ b/src/lib/canvas-core/types.ts @@ -0,0 +1,47 @@ +/** Canonical, serializable contracts. No renderer, browser, or service types. */ +export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; +export type Data = { readonly [key: string]: Json }; +export type Id = string; +export type Point = { readonly x: number; readonly y: number }; +export type Geometry = Point & { readonly width: number; readonly height: number; readonly rotation: number }; +export type CanvasRecord = { readonly id: Id; readonly parent: null | { readonly canvasId: Id; readonly entryElementId: Id } }; +export type Target = { readonly id: Id; readonly incarnation: number }; +export type Endpoint = { readonly elementId: Id; readonly portId?: string }; +export type ElementBase = Target & { readonly ownerCanvasId: Id; readonly componentId: string; readonly dataVersion: number; readonly data: Data }; +export type ElementRecord = ElementBase & ( + | { readonly structure: 'node'; readonly geometry: Geometry } + | { readonly structure: 'connection'; readonly source: Endpoint; readonly target: Endpoint; readonly path: Json } +); +export type Document = { readonly projectId: Id; readonly canvases: ReadonlyMap; readonly elements: ReadonlyMap; readonly tombstones: ReadonlyMap }; +export type Field = `data.${string}` | 'geometry' | 'ownerCanvasId' | 'source' | 'target' | 'path'; +export type FieldChange = { readonly kind: 'field'; readonly target: Target; readonly field: Field; readonly before: Json; readonly after: Json; readonly beforePresent?: boolean; readonly afterPresent?: boolean }; +export type Change = FieldChange + | { readonly kind: 'element'; readonly id: Id; readonly before: ElementRecord | null; readonly after: ElementRecord | null } + | { readonly kind: 'canvas'; readonly id: Id; readonly before: CanvasRecord | null; readonly after: CanvasRecord | null }; +export type Command = + | { readonly type: 'element.create'; readonly element: ElementRecord } + | { readonly type: 'element.remove'; readonly target: Target } + | { readonly type: 'element.data'; readonly target: Target; readonly patch: Data } + | { readonly type: 'element.geometry'; readonly target: Target; readonly patch: Partial } + | { readonly type: 'element.move'; readonly target: Target; readonly canvasId: Id; readonly geometry?: Geometry } + | { readonly type: 'canvas.create'; readonly canvas: CanvasRecord } + | { readonly type: 'canvas.reparent'; readonly canvasId: Id; readonly parent: CanvasRecord['parent'] } + | { readonly type: 'changes.apply'; readonly changes: readonly Change[] }; +export type Origin = 'user' | 'undo' | 'redo' | 'remote' | 'ack' | 'replay' | 'asset-resolution' | 'migration'; +export type Rejected = { readonly status: 'rejected'; readonly code: string; readonly targets: readonly Id[] }; +export type Applied = { readonly status: 'applied'; readonly revision: number; readonly operationIds: readonly Id[]; readonly historyEntryId?: Id }; +export type Result = Applied | Rejected; +export type HistoryEntry = { readonly id: Id; readonly label: string; readonly changes: readonly Change[]; readonly operationIds: readonly Id[]; readonly groupId?: Id }; +export type HistoryState = { readonly undo: readonly Id[]; readonly redo: readonly Id[]; readonly entries: ReadonlyMap }; +export type OperationIntent = { readonly id: Id; readonly operationType: 'document.change'; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] }; +export type Commit = { readonly revision: number; readonly origin: Origin; readonly changes: readonly Change[]; readonly operationIds: readonly Id[] }; +export type CoreSnapshot = { readonly revision: number; readonly document: Document; readonly history: HistoryState; readonly operations: ReadonlyMap }; +/** One publication boundary. Runtime metadata changes do not dirty the document revision. */ +export type Snapshot = CoreSnapshot & { readonly rootRevision: number; readonly runtime: Runtime }; +export type Availability = { readonly enabled: true } | { readonly enabled: false; readonly code: string; readonly targets: readonly Id[] }; +export type ReadonlyValueSet = Pick, 'size' | 'has' | 'entries' | 'keys' | 'values' | typeof Symbol.iterator> & { forEach(callback: (value: T, value2: T, set: ReadonlyValueSet) => void): void }; +export type Capability = 'geometry.move' | 'geometry.resize' | 'geometry.rotate' | 'content.edit' | 'connection.edit'; +export type CapabilityHandler = (context: { readonly element: ElementRecord; readonly input: Readonly> }) => readonly FieldChange[]; +export type Feature = { readonly id: string; readonly handlers: Readonly>> }; +export type ComponentDefinition = { readonly id: string; readonly dataVersion: number; readonly structure: ElementRecord['structure']; readonly features: readonly Feature[]; readonly validate: (data: Data) => boolean; readonly migrations?: Readonly Data>>; readonly resourceFields?: readonly string[] }; +export type Registry = { readonly components: ReadonlyMap; readonly capabilities: ReadonlyMap>; readonly handlers: ReadonlyMap>; readonly resolve: (element: ElementRecord) => 'known' | 'unknown-component' | 'unsupported-version'; readonly supports: (element: ElementRecord, capability: Capability) => boolean }; diff --git a/src/lib/canvas-core/values.ts b/src/lib/canvas-core/values.ts new file mode 100644 index 0000000..3a42c6a --- /dev/null +++ b/src/lib/canvas-core/values.ts @@ -0,0 +1,52 @@ +import type { Id, Json, ReadonlyValueSet } from './types.ts'; + +export function fault(code: string, targets: readonly Id[] = []): Error & { code: string; targets: readonly Id[] } { + return Object.assign(new Error(code), { code, targets }); +} +export function reject(error: unknown) { + const value = error as { code?: string; targets?: readonly string[] }; + return Object.freeze({ status: 'rejected' as const, code: value?.code || 'invalid_command', targets: value?.targets || [] }); +} +export function freeze(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value)) freeze(child); + Object.freeze(value); + } + return value; +} +export function clone(value: T): T { + if (value === undefined) return value; + return JSON.parse(JSON.stringify(value)) as T; +} +export function equal(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b)) return false; + const left = Object.keys(a), right = Object.keys(b); + return left.length === right.length && left.every(key => Object.prototype.hasOwnProperty.call(b, key) && equal((a as Record)[key], (b as Record)[key])); +} +/** A capability-safe read view: unlike TS ReadonlyMap, no .set is exposed at runtime. */ +export function readMap(input: ReadonlyMap): ReadonlyMap { + const map = new Map(input); + const view: ReadonlyMap = { + get size() { return map.size; }, get: key => map.get(key), has: key => map.has(key), + entries: () => map.entries(), keys: () => map.keys(), values: () => map.values(), + [Symbol.iterator]: () => map[Symbol.iterator](), + forEach: (callback, thisArg) => map.forEach((value, key) => callback.call(thisArg, value, key, view)), + }; + return Object.freeze(view); +} +export function readSet(input: Iterable): ReadonlyValueSet { + const set = new Set(input); + const view: ReadonlyValueSet = { + get size() { return set.size; }, has: key => set.has(key), entries: () => set.entries(), keys: () => set.keys(), values: () => set.values(), + [Symbol.iterator]: () => set[Symbol.iterator](), forEach: callback => set.forEach(value => callback(value, value, view)), + }; + return Object.freeze(view); +} +export function validJson(value: unknown, depth = 0): value is Json { + if (depth > 64) return false; + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true; + if (typeof value === 'number') return Number.isFinite(value); + if (Array.isArray(value)) return value.every(item => validJson(item, depth + 1)); + return !!value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype && Object.entries(value).every(([key, child]) => !['__proto__', 'prototype', 'constructor'].includes(key) && validJson(child, depth + 1)); +} diff --git a/tests/headless/core.test.mjs b/tests/headless/core.test.mjs new file mode 100644 index 0000000..9361b45 --- /dev/null +++ b/tests/headless/core.test.mjs @@ -0,0 +1,207 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createDocument, createEditor, createRegistry, builtinComponents, defineComponent, defineFeature, editable, movable, resizable, rotatable, migrateElement, commonCanvas } from '../../src/lib/canvas-core/index.ts'; + +const geometry = { x: 10, y: 20, width: 100, height: 80, rotation: 0 }; +const node = (id, ownerCanvasId = 'root', componentId = 'text') => ({ id, incarnation: 1, ownerCanvasId, componentId, dataVersion: 1, structure: 'node', geometry, data: { title: id, text: '' } }); +const target = id => ({ id, incarnation: 1 }); +function setup(elements = [node('a'), node('b')], options = {}) { + let next = 0; + return createEditor({ document: createDocument('p', [{ id: 'root', parent: null }], elements), registry: createRegistry(builtinComponents()), createId: () => `op-${++next}`, ...options }); +} + +test('runtime preparation shares the document publication boundary; metadata has a separate root revision', () => { + const editor = setup(undefined, { runtime: Object.freeze({ ids: [] }), reduceRuntime(previous, next, commit) { + if (next.document.elements.get('a').data.title === 'reject-ledger') throw new Error('ledger_failed'); + return Object.freeze({ ids: Object.freeze([...previous.ids, ...commit.operationIds]) }); + } }); + const initial = editor.getSnapshot(); + assert.equal(editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'reject-ledger' } }).status, 'rejected'); + assert.strictEqual(editor.getSnapshot(), initial); + const result = editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'committed' } }); + assert.deepEqual(editor.getSnapshot().runtime.ids, result.operationIds); + assert.equal(editor.getSnapshot().rootRevision, 1); assert.equal(editor.getSnapshot().revision, 1); + const old = editor.getSnapshot(); + assert.equal(editor.updateRuntime(value => ({ ...value, ready: true }), old.rootRevision).status, 'applied'); + assert.equal(editor.getSnapshot().rootRevision, 2); assert.equal(editor.getSnapshot().revision, 1); + assert.equal(editor.updateRuntime(value => value, old.rootRevision).code, 'stale_plan'); + editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'canonical' } }, { origin: 'ack' }); + assert.equal(editor.getSnapshot().operations.size, 1); assert.equal(editor.getSnapshot().history.undo.length, 1); +}); + +test('a rejected second command publishes no document, history, operation or notification', () => { + const editor = setup(), before = editor.getSnapshot(); let notifications = 0; + editor.subscribe(() => notifications++); + const result = editor.transaction(tx => { + tx.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'changed' } }); + tx.dispatch({ type: 'element.geometry', target: target('missing'), patch: { x: 42 } }); + }); + assert.equal(result.status, 'rejected'); assert.strictEqual(editor.getSnapshot(), before); assert.equal(notifications, 0); +}); + +test('can is pure and command handlers come from the component capability table', () => { + let ids = 0; + const frame = node('a', 'root', 'frame'), editor = setup([frame], { createId: () => String(++ids) }); + const before = editor.getSnapshot(); + assert.equal(editor.can({ type: 'element.geometry', target: target('a'), patch: { rotation: 30 } }).enabled, false); + assert.equal(editor.can({ type: 'element.geometry', target: target('a'), patch: { x: 30 } }).enabled, true); + assert.equal(ids, 0); assert.strictEqual(editor.getSnapshot(), before); +}); + +test('absent and explicit null are distinct history values; view selection follows element deletion', async () => { + const editor = setup(); + editor.dispatch({ type: 'element.data', target: target('a'), patch: { optional: null } }); + assert.equal(Object.hasOwn(editor.getSnapshot().document.elements.get('a').data, 'optional'), true); + editor.history.undo(); assert.equal(Object.hasOwn(editor.getSnapshot().document.elements.get('a').data, 'optional'), false); + editor.history.redo(); assert.equal(editor.getSnapshot().document.elements.get('a').data.optional, null); + const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts'); + const sessions = createSessions(editor); sessions.attach('view'); sessions.select('view', ['a']); + editor.dispatch({ type: 'element.remove', target: target('a') }); + assert.equal(sessions.getSnapshot().views.get('view').selection.size, 0); sessions.dispose(); + assert.equal(sessions.getSnapshot().views.size, 0); +}); + +test('permission changes cancel geometry previews and prevent new gestures', async () => { + let permitted = true; + const editor = setup(undefined, { policy: () => permitted ? { enabled: true } : { enabled: false, code: 'forbidden', targets: ['a'] } }); + const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts'); + const sessions = createSessions(editor); sessions.attach('view'); + assert.equal(sessions.begin('view', [target('a')]).enabled, true); + sessions.update('view', target('a'), { x: 100 }); permitted = false; + editor.updateRuntime(() => ({ permissionEpoch: 2 })); + assert.equal(sessions.getSnapshot().gesture, null); assert.equal(sessions.getSnapshot().lastCancelReason, 'permission_changed'); + assert.equal(sessions.begin('view', [target('a')]).code, 'forbidden'); sessions.dispose(); +}); + +test('several commands are one history unit; data undo preserves a later resource acknowledgment', () => { + const editor = setup(); + editor.transaction(tx => { + tx.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'changed' } }); + tx.dispatch({ type: 'element.geometry', target: target('a'), patch: { x: 30 } }); + }); + editor.dispatch({ type: 'element.data', target: target('a'), patch: { asset: 'stored-asset' } }, { history: false, origin: 'asset-resolution' }); + assert.equal(editor.getSnapshot().history.undo.length, 1); + assert.equal(editor.history.undo().status, 'applied'); + const value = editor.getSnapshot().document.elements.get('a'); + assert.equal(value.data.title, 'a'); assert.equal(value.geometry.x, 10); assert.equal(value.data.asset, 'stored-asset'); + assert.equal(editor.history.redo().status, 'applied'); + assert.equal(editor.getSnapshot().document.elements.get('a').data.asset, 'stored-asset'); +}); + +test('same-field conflict does not pop history or overwrite remote edits', () => { + const editor = setup(); + editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'local' } }); + editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'remote' } }, { history: false, origin: 'remote' }); + const before = editor.getSnapshot(); + assert.equal(editor.history.undo().status, 'rejected'); assert.strictEqual(editor.getSnapshot(), before); +}); + +test('delete undo restores a new incarnation and the original connections; stale targets remain invalid', () => { + const edge = { id: 'e', incarnation: 1, ownerCanvasId: 'root', componentId: 'connection', dataVersion: 1, data: {}, structure: 'connection', source: { elementId: 'a' }, target: { elementId: 'b' }, path: null }; + const editor = setup([node('a'), node('b'), edge]); + assert.equal(editor.dispatch({ type: 'element.remove', target: target('a') }).status, 'applied'); + assert.equal(editor.getSnapshot().document.elements.has('e'), false); + assert.equal(editor.history.undo().status, 'applied'); + assert.equal(editor.getSnapshot().document.elements.get('a').incarnation, 2); + assert.equal(editor.getSnapshot().document.elements.has('e'), true); + assert.equal(editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'late result' } }).status, 'rejected'); + assert.equal(editor.history.redo().status, 'applied'); + assert.equal(editor.history.undo().status, 'applied'); + assert.equal(editor.getSnapshot().document.elements.get('a').incarnation, 3); +}); + +test('new user edit clears redo, bounded history evicts old entries, no-op creates no operation', () => { + const editor = setup(undefined, { historyLimit: 2 }); + for (const title of ['one', 'two', 'three']) editor.dispatch({ type: 'element.data', target: target('a'), patch: { title } }); + assert.equal(editor.getSnapshot().history.undo.length, 2); assert.equal(editor.getSnapshot().history.entries.size, 2); + editor.history.undo(); + editor.dispatch({ type: 'element.data', target: target('b'), patch: { title: 'new branch' } }); + assert.equal(editor.getSnapshot().history.redo.length, 0); + const before = editor.getSnapshot(); + editor.dispatch({ type: 'element.data', target: target('b'), patch: { title: 'new branch' } }); + assert.strictEqual(editor.getSnapshot(), before); +}); + +test('async transactions reject, close their draft, and never publish the asynchronous tail', async () => { + const editor = setup(), before = editor.getSnapshot(); let captured; + const result = editor.transaction(async tx => { + captured = tx; + tx.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'before await' } }); + await Promise.resolve(); + tx.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'after await' } }); + }); + assert.equal(result.status, 'rejected'); await Promise.resolve(); await Promise.resolve(); + assert.strictEqual(editor.getSnapshot(), before); + assert.throws(() => captured.dispatch({ type: 'element.remove', target: target('a') }), /transaction_closed/); +}); + +test('unknown and future components survive known-object editing but cannot use uninstalled behavior', () => { + const unknown = { ...node('future', 'root', 'future/audio'), dataVersion: 99, data: { novel: ['keep', { every: 'field' }] } }; + const editor = setup([node('a'), unknown]); + editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'edited' } }); + assert.deepEqual(editor.getSnapshot().document.elements.get('future'), unknown); + assert.equal(editor.dispatch({ type: 'element.data', target: target('future'), patch: { title: 'overwrite' } }).status, 'rejected'); +}); + +test('capability conflicts are configuration errors; custom resize handlers can use a different constraint', () => { + assert.throws(() => createRegistry([defineComponent({ id: 'bad', dataVersion: 1, structure: 'node', validate: () => true, features: [movable(), movable()] })]), /duplicate_feature/); + const registry = createRegistry([defineComponent({ id: 'ratio', dataVersion: 1, structure: 'node', validate: () => true, features: [resizable({ aspect: true })] })]); + const editor = setup([{ ...node('a', 'root', 'ratio'), geometry: { ...geometry, width: 200, height: 100 } }], { registry }); + assert.equal(editor.dispatch({ type: 'element.geometry', target: target('a'), patch: { width: 400 } }).status, 'applied'); + assert.equal(editor.getSnapshot().document.elements.get('a').geometry.height, 200); +}); + +test('migration validates a staged copy and retains stable identity and ownership', () => { + const registry = createRegistry([defineComponent({ id: 'text', dataVersion: 2, structure: 'node', features: [editable()], validate: data => typeof data.heading === 'string', migrations: { 1: data => ({ heading: data.title }) } })]); + const original = node('a'); + const upgraded = migrateElement(original, registry); + assert.equal(upgraded.id, original.id); assert.equal(upgraded.ownerCanvasId, original.ownerCanvasId); assert.equal(upgraded.dataVersion, 2); + assert.equal(original.dataVersion, 1); +}); + +test('cross-child connection belongs to the common ancestor; cyclic canvas ownership rejects atomically', () => { + const elements = [node('f'), node('g'), node('a', 'child'), node('b', 'sibling')]; + const canvases = [{ id: 'root', parent: null }, { id: 'child', parent: { canvasId: 'root', entryElementId: 'f' } }, { id: 'sibling', parent: { canvasId: 'root', entryElementId: 'g' } }]; + const registry = createRegistry(builtinComponents()); + const document = createDocument('p', canvases, elements); + assert.equal(commonCanvas(document, 'child', 'sibling'), 'root'); + const editor = setup(elements, { registry, document }); const before = editor.getSnapshot(); + assert.equal(editor.dispatch({ type: 'canvas.reparent', canvasId: 'root', parent: { canvasId: 'child', entryElementId: 'a' } }).status, 'rejected'); + assert.strictEqual(editor.getSnapshot(), before); +}); + +test('snapshot collections expose no mutation methods and observer failures do not roll back commits', () => { + const editor = setup(); + assert.equal(editor.getSnapshot().document.elements.set, undefined); + editor.subscribe(() => { throw new Error('observer'); }); + assert.equal(editor.dispatch({ type: 'element.data', target: target('a'), patch: { title: 'committed' } }).status, 'applied'); + assert.equal(editor.getSnapshot().document.elements.get('a').data.title, 'committed'); +}); + +test('gesture preview is transient, cancel preserves an upload ACK, commit creates one history entry', async () => { + const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts'); + const editor = setup(), sessions = createSessions(editor), detach = sessions.attach('view'); + const initial = editor.getSnapshot(); + assert.equal(sessions.begin('view', [target('a')]).enabled, true); + sessions.update('view', target('a'), { x: 30 }); sessions.update('view', target('a'), { x: 60 }); + assert.strictEqual(editor.getSnapshot(), initial); assert.equal(sessions.effectiveGeometry('a').x, 60); + editor.dispatch({ type: 'element.data', target: target('a'), patch: { asset: 'stored' } }, { origin: 'asset-resolution', history: false }); + assert.equal(sessions.getSnapshot().gesture.phase, 'active'); + sessions.cancel('escape'); assert.equal(sessions.effectiveGeometry('a').x, 10); + assert.equal(editor.getSnapshot().document.elements.get('a').data.asset, 'stored'); + sessions.begin('view', [target('a')]); sessions.update('view', target('a'), { x: 90 }); + assert.equal(sessions.commit('view').status, 'applied'); assert.equal(editor.getSnapshot().history.undo.length, 1); + assert.equal(editor.getSnapshot().document.elements.get('a').geometry.x, 90); + detach(); sessions.dispose(); +}); + +test('geometry conflict and view detach cancel only that gesture, without deleting model content', async () => { + const { createSessions } = await import('../../src/lib/canvas-core/gestures.ts'); + const editor = setup(), sessions = createSessions(editor), detach = sessions.attach('view'); + sessions.begin('view', [target('a')]); sessions.update('view', target('a'), { x: 30 }); + editor.dispatch({ type: 'element.geometry', target: target('a'), patch: { x: 70 } }, { origin: 'remote', history: false }); + assert.equal(sessions.getSnapshot().gesture, null); assert.equal(sessions.effectiveGeometry('a').x, 70); + sessions.begin('view', [target('a')]); sessions.update('view', target('a'), { x: 100 }); detach(); + assert.equal(sessions.getSnapshot().gesture, null); assert.equal(editor.getSnapshot().document.elements.get('a').geometry.x, 70); + sessions.dispose(); +});