docs(canvas): explain registry contracts and editor publication flow
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* 纯编辑器与单 Root 发布协调器:命令先在私有 draft 上规划,再统一发布 document/history/operations/runtime。
|
||||
* 不在这里执行网络或 IndexedDB;engine 通过同步准备钩子装配 ledger 等事实,调度器在发布后异步处理。
|
||||
* 修改入口共用 entered 防重入和 disposed 生命周期检查;协调提案另用 rootRevision 拒绝过期计划。
|
||||
*/
|
||||
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';
|
||||
@@ -14,35 +19,48 @@ 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;
|
||||
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. */
|
||||
/** engine 安装的同步 reducer,将核心提交变成下一份 runtime;抛错拒绝整次发布。 */
|
||||
reduceRuntime?: (previous: Runtime, next: CoreSnapshot, commit: Commit) => Runtime;
|
||||
/** Engine may attach transport semantics without changing identity, order or document changes. */
|
||||
/** engine 可附加任务应用语义,但不得修改意图身份、序列、变化或参与者。 */
|
||||
prepareIntent?: (intent: OperationIntent, runtime: Runtime) => OperationIntent;
|
||||
/** Pure coordinator hook for all publication paths; may replace runtime only or reject. */
|
||||
/** 所有发布路径的最后准备钩子;只能返回 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 按确切快照身份保存查询,既拒绝错误快照,也不永久保留已废弃文档。
|
||||
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>();
|
||||
@@ -50,10 +68,12 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
const limit = options.historyLimit ?? 100;
|
||||
if (!Number.isSafeInteger(limit) || limit < 1) throw fault('invalid_history_limit');
|
||||
|
||||
/** 命令→候选文档/changes 的纯规划;不创建历史、不发送请求,也不改当前 state。 */
|
||||
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 }];
|
||||
@@ -62,22 +82,26 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
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]);
|
||||
@@ -90,10 +114,12 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
if(changes.length)changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
|
||||
} 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)));
|
||||
@@ -103,6 +129,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
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]);
|
||||
@@ -115,6 +142,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
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]);
|
||||
// 混合几何 patch 按 move→resize→rotate 分发,各 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'))],
|
||||
@@ -125,43 +153,53 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
changes = output;
|
||||
}
|
||||
}
|
||||
// 创建可先放入临时 draft 容器,整批结束统一物化,减少大量创建时的重复表复制。
|
||||
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。 */
|
||||
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) {
|
||||
// 收集旧/新 owner、父 canvas 和端点 owner,形成跨图版本/claim 的完整参与者范围。
|
||||
const canvasIds = new Set<Id>();
|
||||
for (const change of changes) {
|
||||
if (change.kind === 'canvas') {
|
||||
@@ -191,8 +229,10 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
}
|
||||
}
|
||||
}
|
||||
// 意图记录这次历史变化,恢复可重建栈元数据;不是从队列是否为空猜测历史已完成。
|
||||
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)));
|
||||
@@ -201,6 +241,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
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');
|
||||
// 查询更新也在赋值之前准备;订阅者不会看到新文档搭配旧索引的短暂组合。
|
||||
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;
|
||||
@@ -208,12 +249,14 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
return Object.freeze({ status: 'applied', revision: state.revision, operationIds, ...(historyEntryId ? { historyEntryId } : {}) });
|
||||
}
|
||||
|
||||
/** 同步事务:多条 dispatch 共享 draft,任一命令失败使整批拒绝;回调结束后 tx 关闭。 */
|
||||
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 记住第一次失败,后续命令不再继续推进 draft。 */
|
||||
dispatch(command: Command): Availability {
|
||||
if (!active) throw fault('transaction_closed');
|
||||
if (failed) return { enabled: false, ...reject(failed) };
|
||||
@@ -222,19 +265,23 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
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);
|
||||
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) {
|
||||
@@ -245,6 +292,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
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);
|
||||
}
|
||||
// 容量淘汰移除最旧 entry;资源 pin 的后续扫描以更新后的 history 为准。
|
||||
while (undo.length > limit) entries.delete(undo.shift()!);
|
||||
history = Object.freeze({ undo: Object.freeze(undo), redo: Object.freeze([]), entries: readMap(entries) });
|
||||
}
|
||||
@@ -252,13 +300,16 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
} 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、不发布;提交时还要重新校验当前状态。 */
|
||||
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;
|
||||
@@ -273,6 +324,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
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)))})));
|
||||
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) });
|
||||
@@ -280,8 +332,10 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
} 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/目标的计划,并修正原创建历史,不额外造用户项。 */
|
||||
resolveCreationSize(effect: CreationSizeEffect, expectedRootRevision: number): Result {
|
||||
try { assertOpen(); } catch (error) { return reject(error); }
|
||||
entered = true;
|
||||
@@ -298,8 +352,10 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
} 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;
|
||||
@@ -317,6 +373,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
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;
|
||||
@@ -326,6 +384,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
const next = createDocument(document.projectId, [...document.canvases.values()], [...document.elements.values()], document.tombstones);
|
||||
validateDocument(next, options.registry);
|
||||
let nextSequence=sequence;
|
||||
// 恢复替换必须让两栈精确覆盖 entry,entry 的 operation ID 必须存在,序列保持单调。
|
||||
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');
|
||||
@@ -342,10 +401,13 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
} 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(); },
|
||||
});
|
||||
}
|
||||
/** 比较规范化表,先做身份/大小快判;无净变化的事务不增加 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;
|
||||
|
||||
Reference in New Issue
Block a user