Add renderer-independent canvas model, transactions and view sessions

This commit is contained in:
2026-11-07 22:00:41 +08:00
parent 6ff7c3064b
commit 7b4d4c0ad3
9 changed files with 906 additions and 0 deletions
+103
View File
@@ -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<Id, number> = 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<Id>();
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<Id>();
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<string, unknown>)[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);
}
+268
View File
@@ -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<Runtime = undefined> = {
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<typeof createEditor>;
const isThenable = (value: unknown) => !!value && (typeof value === 'object' || typeof value === 'function') && typeof (value as { then?: unknown }).then === 'function';
export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime>) {
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<Runtime> = 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<Id, HistoryEntry>()) }), operations: options.recovery?.operations ?? readMap(new Map<Id, OperationIntent>()), 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<Runtime>) => 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>][] = [
['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>): 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<Id>();
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<Id>();
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<typeof transaction>[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<Id, number>();
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<Runtime>) => 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]);
}
+51
View File
@@ -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<Geometry>;
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<Geometry>;
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()] })]);
}
+120
View File
@@ -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<typeof readSet<Id>>; readonly viewport: { readonly x: number; readonly y: number; readonly zoom: number }; readonly tool: 'select' | 'pan'; readonly modes: ReadonlyMap<Id, string> };
export type GestureState = { readonly viewId: Id; readonly targets: readonly Target[]; readonly preview: ReadonlyMap<Id, Geometry>; readonly phase: 'active' | 'committing' };
export type SessionSnapshot = { readonly views: ReadonlyMap<Id, ViewState>; 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<Id, ViewState>()), gesture: null, lastCancelReason: null });
let baseline = new Map<Id, ElementRecord>(), 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<Geometry>): 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>][] = [
['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<Id>([]), viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select', modes: readMap(new Map<Id, string>()) }));
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<Id, Geometry>()), 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<Id, ViewState>()) }); notify(); listeners.clear(); },
});
}
+7
View File
@@ -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';
+51
View File
@@ -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<Record<Capability, CapabilityHandler>>): 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<string, ComponentDefinition>(), capabilities = new Map<string, ReadonlyValueSet<Capability>>(), handlers = new Map<string, ReadonlyMap<Capability, CapabilityHandler>>();
for (const input of definitions) {
const definition = defineComponent(input);
if (components.has(definition.id)) throw fault('duplicate_component', [definition.id]);
const found = new Map<Capability, CapabilityHandler>(), features = new Set<string>();
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 });
}
+47
View File
@@ -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<Id, CanvasRecord>; readonly elements: ReadonlyMap<Id, ElementRecord>; readonly tombstones: ReadonlyMap<Id, number> };
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<Geometry> }
| { 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<Id, HistoryEntry> };
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<Id, OperationIntent> };
/** One publication boundary. Runtime metadata changes do not dirty the document revision. */
export type Snapshot<Runtime = unknown> = 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<T> = Pick<ReadonlySet<T>, 'size' | 'has' | 'entries' | 'keys' | 'values' | typeof Symbol.iterator> & { forEach(callback: (value: T, value2: T, set: ReadonlyValueSet<T>) => 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<Record<string, unknown>> }) => readonly FieldChange[];
export type Feature = { readonly id: string; readonly handlers: Readonly<Partial<Record<Capability, CapabilityHandler>>> };
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<Record<number, (data: Data) => Data>>; readonly resourceFields?: readonly string[] };
export type Registry = { readonly components: ReadonlyMap<string, ComponentDefinition>; readonly capabilities: ReadonlyMap<string, ReadonlyValueSet<Capability>>; readonly handlers: ReadonlyMap<string, ReadonlyMap<Capability, CapabilityHandler>>; readonly resolve: (element: ElementRecord) => 'known' | 'unknown-component' | 'unsupported-version'; readonly supports: (element: ElementRecord, capability: Capability) => boolean };
+52
View File
@@ -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<T>(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<T>(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<string, unknown>)[key], (b as Record<string, unknown>)[key]));
}
/** A capability-safe read view: unlike TS ReadonlyMap, no .set is exposed at runtime. */
export function readMap<K, V>(input: ReadonlyMap<K, V>): ReadonlyMap<K, V> {
const map = new Map(input);
const view: ReadonlyMap<K, V> = {
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<T>(input: Iterable<T>): ReadonlyValueSet<T> {
const set = new Set(input);
const view: ReadonlyValueSet<T> = {
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));
}
+207
View File
@@ -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();
});