316 lines
24 KiB
TypeScript
316 lines
24 KiB
TypeScript
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, createDocument, invert, targetElement, validateDocument } from './document.ts';
|
|
import { clone, equal, fault, freeze, readMap, reject, validJson } from './values.ts';
|
|
|
|
import { canvasTransform, convertGeometry } from './coordinates.ts';
|
|
import { removalChanges, repairConnectionOwners } from './structure.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;
|
|
/** Engine may attach transport semantics without changing identity, order or document changes. */
|
|
prepareIntent?: (intent: OperationIntent, runtime: Runtime) => OperationIntent;
|
|
nextOperationSequence?: (runtime: Runtime, fallback: number) => number;
|
|
};
|
|
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 } }];
|
|
if(changes.length)changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
|
|
} 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 === 'connection.path') {
|
|
if (element.structure !== 'connection' || !options.registry.supports(element, 'connection.edit')) throw fault('unsupported_capability', [element.id]);
|
|
if (!validJson(command.path)) throw fault('invalid_connection_path', [element.id]);
|
|
changes = options.registry.handlers.get(element.componentId)!.get('connection.edit')!({ element, input: freeze({ patch: { path: clone(command.path) } }) });
|
|
for (const change of changes) if (change.kind !== 'field' || change.field !== 'path' || change.target.id !== element.id || change.target.incarnation !== element.incarnation) throw fault('invalid_handler_change', [element.id]);
|
|
} else if(command.type==='connection.endpoints'){
|
|
if(element.structure!=='connection'||!options.registry.supports(element,'connection.edit'))throw fault('unsupported_capability',[element.id]);
|
|
changes=[];
|
|
for(const [field,endpoint]of [['source',command.source],['target',command.destination]] as const)if(endpoint&&!equal(element[field],endpoint))changes=[...changes,{kind:'field',target:command.target,field,before:element[field],after:clone(endpoint)}];
|
|
if(changes.length)changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
|
|
} else if (command.type === 'element.remove') {
|
|
changes = removalChanges(document,command.target);
|
|
} 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(element.structure==='node'){
|
|
if([...document.canvases.values()].some(canvas=>canvas.parent?.entryElementId===element.id)&&canvasTransform(document,element.ownerCanvasId).scale!==canvasTransform(document,command.canvasId).scale)throw fault('container_scale_conversion_required',[element.id]);
|
|
const geometry=command.geometry??(element.ownerCanvasId===command.canvasId?element.geometry:convertGeometry(element.geometry,canvasTransform(document,element.ownerCanvasId),canvasTransform(document,command.canvasId)));
|
|
if(!equal(geometry,element.geometry))changes=[...changes,{kind:'field',target:command.target,field:'geometry',before:element.geometry,after: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 } } }];
|
|
changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
|
|
} 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);
|
|
const nextSequence = outbound ? options.nextOperationSequence?.(state.runtime, sequence + 1) ?? sequence + 1 : sequence;
|
|
if (!Number.isSafeInteger(nextSequence) || outbound && nextSequence <= sequence) throw fault('invalid_operation_sequence');
|
|
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);
|
|
if (!change.before && change.after?.structure === 'connection') {
|
|
for (const endpoint of [change.after.source, change.after.target]) {
|
|
const element = document.elements.get(endpoint.elementId) ?? state.document.elements.get(endpoint.elementId);
|
|
if (element) canvasIds.add(element.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); }
|
|
if (change.field === 'source' || change.field === 'target') {
|
|
for (const endpoint of [change.before, change.after]) {
|
|
const id = (endpoint as { elementId?: Id })?.elementId;
|
|
const element = id ? document.elements.get(id) ?? state.document.elements.get(id) : undefined;
|
|
if (element) canvasIds.add(element.ownerCanvasId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const intent: OperationIntent = freeze({ id: operationIds[0], operationType: 'document.change', operationSchemaVersion: 1, sequence: nextSequence, origin, canvasIds: [...canvasIds].sort(), changes, dependencies: [] });
|
|
const prepared=options.prepareIntent?.(intent,state.runtime) ?? intent;
|
|
if(prepared.operationType==='run.apply'&&!validJson(prepared.action)||!['document.change','run.apply'].includes(prepared.operationType)||['id','operationSchemaVersion','sequence','origin','canvasIds','changes','dependencies'].some(key=>!equal((prepared as unknown as Record<string,unknown>)[key],(intent as unknown as Record<string,unknown>)[key])))throw fault('invalid_prepared_intent');
|
|
operations.set(intent.id, freeze(clone(prepared)));
|
|
}
|
|
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 = nextSequence;
|
|
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);
|
|
const planned=direction==='undo'?invert(entry.changes):entry.changes;
|
|
const generations=new Map<Id,Map<number,number>>();
|
|
const incarnation=(id:Id,value:number)=>{const mapping=generations.get(id);while(mapping?.has(value))value=mapping.get(value)!;return value;};
|
|
const remap=(change:Change):Change=>{
|
|
if(change.kind==='field'){const next=incarnation(change.target.id,change.target.incarnation);return next===change.target.incarnation?change:freeze({...change,target:{...change.target,incarnation:next}});}
|
|
if(change.kind==='element'){const record=(value:ElementRecord|null)=>value?{...value,incarnation:incarnation(value.id,value.incarnation)}:null;return freeze({...change,before:record(change.before),after:record(change.after)});}
|
|
return change;
|
|
};
|
|
let document=state.document;const changes:Change[]=[];
|
|
for(const original of planned){
|
|
let change=remap(original);
|
|
if(change.kind==='element'&&!change.before&&change.after){const dead=document.tombstones.get(change.id);if(dead!==undefined&&change.after.incarnation<=dead){const mapping=generations.get(change.id)??new Map<number,number>();mapping.set(change.after.incarnation,dead+1);generations.set(change.id,mapping);change=remap(change);}}
|
|
document=applyChanges(document,[change]);changes.push(change);
|
|
}
|
|
validateDocument(document,options.registry);
|
|
const entries=new Map(state.history.entries);
|
|
// Later redo entries belong to the lifecycle restored by this explicit history action.
|
|
// Frozen operations retain their original identities and incarnations.
|
|
if(generations.size)for(const [otherId,other]of entries)if(otherId!==id)entries.set(otherId,freeze({...other,changes:other.changes.map(remap)}));
|
|
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, replacement?: { readonly history: HistoryState; readonly operations: ReadonlyMap<Id,OperationIntent> }): 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);
|
|
let nextSequence=sequence;
|
|
if(replacement){
|
|
const {history,operations}=replacement,ids=[...history.undo,...history.redo];
|
|
if(new Set(ids).size!==ids.length||ids.length!==history.entries.size||ids.some(id=>!history.entries.has(id)))throw fault('invalid_history');
|
|
for(const [id,intent]of state.operations)if(!equal(intent,operations.get(id)))throw fault('operation_identity_conflict',[id]);
|
|
for(const [id,intent]of operations){if(id!==intent.id||!Number.isSafeInteger(intent.sequence)||intent.sequence<1)throw fault('invalid_operation_sequence');nextSequence=Math.max(nextSequence,intent.sequence);}
|
|
for(const [id,entry]of history.entries)if(entry.id!==id||entry.operationIds.some(operationId=>!operations.has(operationId)))throw fault('invalid_history');
|
|
}
|
|
const changed = !documentsEqual(next, state.document);
|
|
state = Object.freeze({ ...state, ...(replacement?{history:replacement.history,operations:replacement.operations}:{}), document: changed ? next : state.document, runtime, revision: state.revision + Number(changed), rootRevision: state.rootRevision + 1 });
|
|
sequence=nextSequence;
|
|
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]);
|
|
}
|