/** * 纯编辑器与单 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 = { registry: Registry; document: Document; queryBudget?:QueryBudget; createId?: () => Id; historyLimit?: number; /** 将加载覆盖范围绑定到查询,区分未加载与确实不存在。 */ queryCoverage?: (snapshot:Snapshot)=>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, next: Snapshot) => 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; /** 不只检查 Promise 实例,也识别带 then 的对象,禁止异步结果混进同步发布路径。 */ const isThenable = (value: unknown) => !!value && (typeof value === 'object' || typeof value === 'function') && typeof (value as { then?: unknown }).then === 'function'; /** 创建一个项目的编辑器实例,捕获并校验初始文档;恢复时沿用历史/意图及其序列。 */ export function createEditor(options: EditorOptions) { const source = options.recovery?.document ?? options.document; const document = createDocument(source.projectId, [...source.canvases.values()], [...source.elements.values()], source.tombstones); validateDocument(document, options.registry); let state: Snapshot = Object.freeze({ revision: options.recovery?.revision ?? 0, rootRevision: options.recovery?.rootRevision ?? 0, document, history: options.recovery?.history ?? Object.freeze({ undo: Object.freeze([]), redo: Object.freeze([]), entries: readMap(new Map()) }), operations: options.recovery?.operations ?? readMap(new Map()), runtime: options.runtime as Runtime }); // 文档索引与 runtime 覆盖范围同次绑定;同一个 doc 对象的加载事实也可能更新。 const bindCoverage=(snapshot:Snapshot,queries:ReturnType)=>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) => 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.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):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): Snapshot { 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 { 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(); 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(); 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)[key],(intent as unknown as Record)[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([]), ...(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[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) => 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 }): 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]); }