Files
Endoleap/src/lib/canvas-core/editor.ts
T

440 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 纯编辑器与单 Root 发布协调器:命令先在私有 draft 上规划,再统一发布 document/history/operations/runtime。
* 不在这里执行网络或 IndexedDB;engine 通过同步准备钩子装配 ledger 等事实,调度器在发布后异步处理。
* 命令/协调发布入口共用 entered 防重入和 disposed 检查;协调提案另用 rootRevision 拒绝过期计划。
* dispose/subscribe 和只读查询有各自生命周期规则,entered 是同步重入闸门,不是跨线程或网络锁。
*/
import {dataArray,dataRecord} from './data-containers.ts';
import {stageCreation,materializeCreations} from './creation-draft.ts';
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 {createDocumentQueries,advanceDocumentQueries,withQueryCoverage,type QueryCoverage,type QueryBudget} from './queries.ts';
import { canvasTransform, convertGeometry } from './coordinates.ts';
import { unitScale, withUnitScale } from './units.ts';
import { removalChanges, repairConnectionOwners } from './structure.ts';
import {planDissolveCanvas} from './dissolve.ts';
import { planHistoryChanges } from './history.ts';
import { describeHistoryTransition } from './history-transition.ts';
import { planCreationSize, validateCreationSizeEffect } from './creation-size-plan.ts';
import type { CreationSizeEffect } from './types.ts';
/** 宿主装配合同:registry/document 是输入,policy 决定准入,runtime 钩子参与同一次准备。
* 准备钩子必须同步且无外部写入;任一抛错都应让候选 Root 整体被丢弃。 */
export type EditorOptions<Runtime = undefined> = {
registry: Registry; document: Document; queryBudget?:QueryBudget; createId?: () => Id; historyLimit?: number;
/** 将加载覆盖范围绑定到查询,区分未加载与确实不存在。 */
queryCoverage?: (snapshot:Snapshot<Runtime>)=>QueryCoverage;
/** 每条命令规划前都检查;can 的结果不能代替真正提交时的再次检查。 */
policy?: (command: Command, document: Document) => Availability;
/** 撤销/重做独立准入钩子,runtime 可据依赖、未知请求或权限阻止栈移动。 */
traversalPolicy?: (entry: HistoryEntry, direction: 'undo' | 'redo') => Availability;
/** listener 抛错后的诊断回调;诊断本身失败也不能回滚已经发布的状态。 */
onListenerError?: (error: unknown) => void;
/** 明确开放 changes.apply 给可信宿主;仍执行 policy 和发布前文档检查。 */
allowRawChanges?: boolean;
/** 恢复层预先校验的核心快照;初始化直接沿用其中 history/operations,不在此完整解码恢复 DTO。 */
recovery?: CoreSnapshot & { readonly rootRevision: number };
/** 与初始核心快照配套的宿主 runtime;其内部不可变性由宿主负责。 */
runtime?: Runtime;
/** engine 安装的同步 reducer,将核心提交变成下一份 runtime;抛错拒绝整次发布。 */
reduceRuntime?: (previous: Runtime, next: CoreSnapshot, commit: Commit) => Runtime;
/** engine 可附加任务应用语义,但不得修改意图身份、序列、变化或参与者。 */
prepareIntent?: (intent: OperationIntent, runtime: Runtime) => OperationIntent;
/** 所有发布路径的最后准备钩子;只能返回 runtime 或拒绝,不能另发一次嵌套提交。 */
prepareRuntime?: (previous: Snapshot<Runtime>, next: Snapshot<Runtime>) => Runtime;
/** runtime 可给出更大的持久序列,但不得倒退或复用已发布的操作次序。 */
nextOperationSequence?: (runtime: Runtime, fallback: number) => number;
};
/** 事务内接口:dispatch 推进私有 draft,getDocument 只在同步回调尚未结束时有效。 */
export type Transaction = { dispatch(command: Command): Availability; getDocument(): Document };
/** 从工厂返回值推导公共 API,避免另一份接口声明与实现漂移。 */
export type Editor = ReturnType<typeof createEditor>;
/** 不只检查 Promise 实例,也识别带 then 的对象,禁止异步结果混进同步发布路径。 */
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 });
// 文档索引与 runtime 覆盖范围同次绑定;同一个 doc 对象的加载事实也可能更新。
const bindCoverage=(snapshot:Snapshot<Runtime>,queries:ReturnType<typeof createDocumentQueries>)=>options.queryCoverage?withQueryCoverage(queries,options.queryCoverage(snapshot)):queries;
let queries=bindCoverage(state,createDocumentQueries(document,options.registry,state.revision,options.queryBudget));
// WeakMap 按 Document 对象身份保存查询,不按整个 Root 身份;同文档的 runtime-only 发布会更新这条关联。
// 旧 Document 尚有外部引用时可查旧索引;未登记的外来 Document 明确拒绝,WeakMap 不独自保活旧文档。
const queriesByDocument=new WeakMap([[document,queries]]);
// entered 覆盖准备、发布和通知,防止 handler/reducer/observer 同步重入造成半份状态。
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>();
// createId 可能在最终发布前被调用;失败的候选不会回收已经向宿主申请过的 ID。
const createId = options.createId || (() => globalThis.crypto.randomUUID());
const limit = options.historyLimit ?? 100;
if (!Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_history_limit');
/** 命令→候选文档/changes 的同步规划,不创建历史、不发送请求,也不替换当前 state。
* policy/handler 由宿主提供,须遵守无外部写入约定;本函数不能回滚插件自己的副作用。 */
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))];
// 解散包含多条成员/引用修改,逐项检查宿主 policy,不能仅获准入口命令就越权移动成员。
} else if(command.type==='canvas.dissolve'){
const dissolved=planDissolveCanvas(document,options.registry,command.canvasId,command.entry);changes=dissolved.changes;
for(const member of dissolved.commands){
const allowed=options.policy?.(member,document);if(allowed&&!allowed.enabled)throw fault(allowed.code,allowed.targets);
}
// 新建禁止复用活 ID 或墓碑 ID;历史恢复换代走另一条受控路径。
} 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 }];
// 原始 changes 能越过常规 handler,必须由宿主明确启用,仍受最终文档校验约束。
} 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]);
// 先定位同 incarnation 的已知组件,之后连线路径/端点分别使用受控字段范围。
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]);
// 端点命令只要求 connection.edit 已安装,直接生成 source/target 变化,不调用该 handler。
} 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))];
// 删除使用结构规划器收集子树/附边,不通过单一内容或几何 handler。
} else if (command.type === 'element.remove') {
changes = removalChanges(document,command.target,queriesByDocument.get(document));
// 跨 owner 移动需要同时处理归属、几何、视觉单位、入口子 canvas 与相关边。
} 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'){
// 默认保持世界空间外观;调用方明确传 geometry 时按它定位,表现比例仍进行换算。
const source=canvasTransform(document,element.ownerCanvasId),destination=canvasTransform(document,command.canvasId),presentation=withUnitScale(element.data.presentation,unitScale(element.data.presentation)*source.scale/destination.scale);
if(!equal(presentation,element.data.presentation))changes=[...changes,{kind:'field',target:command.target,field:'data.presentation',before:element.data.presentation??null,beforePresent:Object.hasOwn(element.data,'presentation'),after:presentation??null,afterPresent:presentation!==undefined}];
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[] = [];
/** 执行一个 capability 并限制输出目标/字段;下一 handler 读取前面已规划的结果。 */
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]);
// 按输入字段拆成 move→resize→rotate;上方检查输出目标与 geometry 字段,具体几何语义仍由 handler 实现。
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;
}
}
// 创建先放入临时 draft 容器,整批结束统一物化;其它修改沿有序 changes 应用,返回的变化另做 JSON 捕获。
const next = command.type==='element.create'||command.type==='canvas.create'?stageCreation(document,changes[0] as Extract<Change,{kind:'canvas'|'element'}>):applyChanges(document, changes);
return { document: next, changes: freeze(clone(changes)) };
}
/** 所有修改入口共用的生命周期/重入闸门;只读 can 使用自己的检查路径。 */
function assertOpen() {
if (disposed) throw fault('editor_disposed');
if (entered) throw fault('reentrant_dispatch');
}
/** 协调器最后的同步准备机会,只能替换 runtime;Promise/抛错均阻止发布。 */
function prepareState(next: Snapshot<Runtime>): Snapshot<Runtime> {
if(!options.prepareRuntime)return next;
const runtime=options.prepareRuntime(state,next);
if(isThenable(runtime))throw fault('async_transaction');
return runtime===next.runtime?next:Object.freeze({...next,runtime});
}
/** 发布后通知 listener 快照;逐个隔离异常,观察者失败不能回滚已提交的 Root。
* 本轮遍历 listener 集合的数组副本,通知期间退订不撤回副本中尚未调用的 listener。 */
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. */ } }
}
/** 为历史/操作分配 ID,检查本次已用集合及已有表,拒绝宿主生成器返回冲突 ID。 */
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;
}
/** 聚合一次候选提交:意图/历史关联→runtime reducer→查询准备→一次替换 state→通知。
* state 赋值前任何错误都不暴露候选文档,ACK/remote/replay 不制造新的出站操作。 */
function publish(document: Document, changes: readonly Change[], origin: Origin, history: HistoryState, historyEntryId?: Id, historyEffect?: CreationSizeEffect): 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.
// 用户编辑、undo/redo 等需要同步;服务回执/远端回放不能再次进入 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');
// 一个逻辑历史项可能对应多次出站操作,关联 ID 追加在同一候选 history 中。
if (historyEntryId) {
const entries = new Map(history.entries), entry = entries.get(historyEntryId)!;
entries.set(entry.id, dataRecord(freeze({ ...entry, operationIds: dataArray(freeze([...entry.operationIds, ...operationIds])) })));
history = Object.freeze({ ...history, entries: readMap(entries) });
}
if (outbound) {
// 按变化种类收集参与者:canvas 自身及父项、元素旧/新 owner、新建边或端点变化涉及的端点 owner。
// 这是出站意图的 canvasIds 来源,不是一次对所有可能插件引用的全图扫描。
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 historyTransition=describeHistoryTransition(state.history,history,state.rootRevision+1,historyEntryId,true);
const intent: OperationIntent = freeze({ id: operationIds[0], operationType: 'document.change', operationSchemaVersion: 1, sequence: nextSequence, origin, canvasIds: [...canvasIds].sort(), changes, dependencies: [],...(historyTransition?{historyTransition}:{}),...(historyEffect?{historyEffect}:{}) });
// engine 可补充显式 run.apply 语义,但不能更改稳定身份、变化、参与者或历史证据。
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','historyTransition','historyEffect'].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)));
}
const next: CoreSnapshot = Object.freeze({ revision: state.revision + 1, document, history, operations: readMap(operations) });
const commit = freeze({ revision: next.revision, origin, changes, operationIds });
// reducer/prepareRuntime/查询构建都在 state 赋值前完成;拒绝只丢弃候选,不能撤回宿主钩子的外部副作用。
const runtime = options.reduceRuntime ? options.reduceRuntime(state.runtime, next, commit) : state.runtime;
if (isThenable(runtime)) throw fault('async_transaction');
// 查询更新也在赋值之前准备;订阅者不会看到新文档搭配旧索引的短暂组合。
const nextState=prepareState(Object.freeze({ ...next, rootRevision: state.rootRevision + 1, runtime })),nextQueries=bindCoverage(nextState,advanceDocumentQueries(queries,document,options.registry,next.revision,changes));
state=nextState;queries=nextQueries;queriesByDocument.set(document,queries);
if (outbound) sequence = nextSequence;
emit(commit);
return Object.freeze({ status: 'applied', revision: state.revision, operationIds, ...(historyEntryId ? { historyEntryId } : {}) });
}
/** 同步事务:多条 dispatch 共享 draft,正常 Error 失败会阻止发布;回调结束后 tx 关闭。
* failed 是 unknown 但当前分支用真值判断;宿主 policy/handler 应抛 Error,不应依赖抛 null/false/0 的拒绝语义。 */
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 记录规划抛错;常规 Error 被 failed 真值分支记住,后续命令停止推进。 */
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 }; }
},
/** 事务内只读候选文档;异步尾部不能继续使用已关闭的 tx。 */
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;
// 全部命令规划结束才物化创建记录并做整图校验,允许同批先创建节点再接边。
document=materializeCreations(document);
validateDocument(document, options.registry);
// 无变化或表内容/迭代顺序与原文档相同,直接报告 applied;不分配历史/操作 ID,也不通知订阅者。
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];
// 新用户编辑产生新分支:先清 redo;仅当前 undo 栈顶 groupId 相同才合并。
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, dataRecord(freeze({ ...prior, changes: dataArray(freeze([...prior.changes, ...changes])) })));
} else {
historyEntryId = uniqueId(new Set());
entries.set(historyEntryId, dataRecord(freeze({ id: historyEntryId, label: settings.label || 'Edit', changes: dataArray(freeze([...changes])), operationIds: dataArray<Id>([]), ...(settings.groupId ? { groupId: settings.groupId } : {}) })));
undo.push(historyEntryId);
}
// 仅从当前 history 淘汰最旧 entry;未解决操作的 historyTransition 等证据仍可能 pin 住旧资源。
while (undo.length > limit) entries.delete(undo.shift()!);
history = Object.freeze({ undo: Object.freeze(undo), redo: Object.freeze([]), entries: readMap(entries) });
}
// history:false 或非 user origin 可不建历史项,但 outbound 由 publish 的 origin 规则独立决定。
return publish(document, freeze([...changes]), settings.origin || 'user', history, historyEntryId);
} catch (error) { active = false; return reject(error); }
finally { entered = false; }
}
/** 单命令是同步 transaction 的便捷封装,遵守相同校验与原子发布边界。 */
function dispatch(command: Command, settings?: Parameters<typeof transaction>[1]): Result {
return transaction(tx => { tx.dispatch(command); }, settings);
}
/** 只规划和校验可用性,不分配操作 ID、不发布;提交时还要重新校验当前状态。
* can 只检查 disposed,不占 entered;宿主提供的 policy/handler 可能仍被调用,不应有外部写入。 */
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 }; }
}
/** 撤销/重做先检查准入和全部 changes,再移动栈并发布补偿意图,失败保留原两栈。 */
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);
// undo 反转变化顺序和值,redo 使用正向变化;私有执行先通过最终文档校验,才构造新的两栈。
const planned=direction==='undo'?invert(entry.changes):entry.changes;
const {document,changes,remap,restored}=planHistoryChanges(state.document,planned,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(restored)for(const [otherId,other]of entries)if(otherId!==id)entries.set(otherId,dataRecord(freeze({...other,changes:dataArray(freeze(other.changes.map(remap)))})));
// 当前 entry 仍存“可重做的正向 changes”;undo 后把实际逆向变化再 invert,保留新恢复代次。
entries.set(id,dataRecord(freeze({...entry,changes:dataArray(freeze(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; }
}
// 公开只读快照与绑定查询;指定未知 document 查查询会拒绝,不隐式混用当前索引。
return Object.freeze({ getSnapshot: () => state,getQueries:(document=state.document)=>{const value=queriesByDocument.get(document);if(!value)throw fault('query_snapshot_unavailable');return value;}, registry: options.registry, dispatch, transaction, can,
/** Coordinator entry: document, amended history and outbound fact share one Root. */
/** 接收异步解码后的尺寸,入口本身同步;匹配 Root/目标后修正原创建历史,不额外造用户项。
* asset-resolution 仍可产生出站 operation,并与原创建 entry 关联,不是完全跳过 history/runtime。 */
resolveCreationSize(effect: CreationSizeEffect, expectedRootRevision: number): Result {
try { assertOpen(); } catch (error) { return reject(error); }
entered = true;
try {
if (expectedRootRevision !== state.rootRevision) throw fault('stale_plan');
validateCreationSizeEffect(effect);
const plan = planCreationSize(state.document, state.history, options.registry, effect.entryId, effect.target, effect.size);
const after = plan.document.elements.get(effect.target.id)!;
if (after.structure !== 'node') throw fault('creation_size_target_changed');
const allowed = options.policy?.({ type: 'element.geometry', target: effect.target, patch: { width: after.geometry.width, height: after.geometry.height } }, state.document);
if (allowed && !allowed.enabled) throw fault(allowed.code, allowed.targets);
if (!plan.changes.length) return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) });
return publish(plan.document, plan.changes, 'asset-resolution', plan.history, effect.entryId, freeze(clone(effect)));
} catch (error) { return reject(error); }
finally { entered = false; }
},
/** 订阅发布事件并返回幂等清理函数;退订不负责停止 engine 的持久任务。 */
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. */
/** 仅 runtime 元数据更新:推进 rootRevision,保留文档 revision,不产生出站 operation。 */
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');
// 相同 runtime 引用视为 no-op,prepareRuntime 不执行;调用方须返回新对象而非原地改旧状态。
if (runtime !== state.runtime) {
const nextState=prepareState(Object.freeze({ ...state, rootRevision: state.rootRevision + 1, runtime })),nextQueries=bindCoverage(nextState,queries);
state=nextState;queries=nextQueries;queriesByDocument.set(state.document,queries);
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. */
/** 接收 ACK/恢复提案并重新捕获校验文档,与 runtime 一起发布;旧 rootRevision 提案明确拒绝。
* 可替换 history/operations,但不允许改写原有操作身份;文档实际变化才推进 revision。 */
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;
// 替换时要求两栈精确覆盖 entries、历史关联的 operation ID 存在,已有操作内容完全相等。
// 新操作的 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');
}
// 即使文档相等,reconcile 仍发布一个新 rootRevision 与 ACK 通知;仅文档变化推进 revision。
const changed = !documentsEqual(next, state.document);
const nextState=prepareState(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 })),nextQueries=bindCoverage(nextState,changed?advanceDocumentQueries(queries,next,options.registry,state.revision+1):queries);
state=nextState;queries=nextQueries;queriesByDocument.set(state.document,queries);
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; }
},
// 两栈的 canUndo/canRedo 仅说明非空;真正遍历时仍可能因 policy 或条件冲突而拒绝。
history: Object.freeze({ undo: () => traverse('undo'), redo: () => traverse('redo'), canUndo: () => !!state.history.undo.length, canRedo: () => !!state.history.redo.length }),
/** 关闭 editor 并释放监听;不在纯内核中取消上传、清数据库或释放宿主资源。 */
dispose() { disposed = true; listeners.clear(); },
});
}
/** 比较表的迭代顺序与内容,先做身份/大小快判;不是忽略插入顺序的 Map 集合等价。
* transaction 用它省去重复发布,reconcile 用它决定是否推进文档 revision。 */
function documentsEqual(a: Document, b: Document): boolean {
if(a===b)return true;
if(a.canvases.size!==b.canvases.size||a.elements.size!==b.elements.size||a.tombstones.size!==b.tombstones.size)return false;
return equal([...a.canvases], [...b.canvases]) && equal([...a.elements], [...b.elements]) && equal([...a.tombstones], [...b.tombstones]);
}