docs(core): clarify editor publication and history traversal contracts

This commit is contained in:
2026-11-12 12:21:31 +08:00
parent db3eb8ec5d
commit 8e3ad8c2a2
4 changed files with 58 additions and 24 deletions
+39 -15
View File
@@ -1,7 +1,8 @@
/**
* 纯编辑器与单 Root 发布协调器:命令先在私有 draft 上规划,再统一发布 document/history/operations/runtime。
* 不在这里执行网络或 IndexedDB;engine 通过同步准备钩子装配 ledger 等事实,调度器在发布后异步处理。
* 修改入口共用 entered 防重入和 disposed 生命周期检查;协调提案另用 rootRevision 拒绝过期计划。
* 命令/协调发布入口共用 entered 防重入和 disposed 检查;协调提案另用 rootRevision 拒绝过期计划。
* dispose/subscribe 和只读查询有各自生命周期规则,entered 是同步重入闸门,不是跨线程或网络锁。
*/
import {dataArray,dataRecord} from './data-containers.ts';
import {stageCreation,materializeCreations} from './creation-draft.ts';
@@ -29,9 +30,13 @@ export type EditorOptions<Runtime = undefined> = {
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;
@@ -58,17 +63,20 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<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 按确切快照身份保存查询,既拒绝错误快照,也不永久保留已废弃文档。
// 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。 */
/** 命令→候选文档/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);
@@ -107,11 +115,13 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
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 与相关边。
@@ -142,7 +152,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 只能处理自己的字段。
// 按输入字段拆成 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'))],
@@ -153,7 +163,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
changes = output;
}
}
// 创建可先放入临时 draft 容器,整批结束统一物化,减少大量创建时的重复表复制。
// 创建先放入临时 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)) };
}
@@ -170,7 +180,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
if(isThenable(runtime))throw fault('async_transaction');
return runtime===next.runtime?next:Object.freeze({...next,runtime});
}
/** 发布后通知 listener 快照;逐个隔离异常,观察者失败不能回滚已提交的 Root。 */
/** 发布后通知 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. */ } }
}
@@ -199,7 +210,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
history = Object.freeze({ ...history, entries: readMap(entries) });
}
if (outbound) {
// 收集旧/新 owner、父 canvas 和端点 owner,形成跨图版本/claim 的完整参与者范围。
// 按变化种类收集参与者:canvas 自身及父项、元素旧/新 owner、新建边或端点变化涉及的端点 owner。
// 这是出站意图的 canvasIds 来源,不是一次对所有可能插件引用的全图扫描。
const canvasIds = new Set<Id>();
for (const change of changes) {
if (change.kind === 'canvas') {
@@ -239,6 +251,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
}
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');
// 查询更新也在赋值之前准备;订阅者不会看到新文档搭配旧索引的短暂组合。
@@ -249,14 +262,15 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
return Object.freeze({ status: 'applied', revision: state.revision, operationIds, ...(historyEntryId ? { historyEntryId } : {}) });
}
/** 同步事务:多条 dispatch 共享 draft,任一命令失败使整批拒绝;回调结束后 tx 关闭。 */
/** 同步事务:多条 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 记住第一次失败,后续命令不再继续推进 draft。 */
/** 私有 dispatch 记录规划抛错;常规 Error 被 failed 真值分支记住,后续命令停止推进。 */
dispatch(command: Command): Availability {
if (!active) throw fault('transaction_closed');
if (failed) return { enabled: false, ...reject(failed) };
@@ -277,6 +291,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
// 全部命令规划结束才物化创建记录并做整图校验,允许同批先创建节点再接边。
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')) {
@@ -292,10 +307,11 @@ 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 为准。
// 仅从当前 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; }
@@ -304,7 +320,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
function dispatch(command: Command, settings?: Parameters<typeof transaction>[1]): Result {
return transaction(tx => { tx.dispatch(command); }, settings);
}
/** 只规划和校验可用性,不分配操作 ID、不发布;提交时还要重新校验当前状态。 */
/** 只规划和校验可用性,不分配操作 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 }; }
@@ -319,6 +336,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
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);
@@ -326,6 +344,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
// 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);
@@ -335,7 +354,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
// 公开只读快照与绑定查询;指定未知 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/目标的计划,并修正原创建历史,不额外造用户项。 */
/** 接收异步解码后的尺寸,入口本身同步;匹配 Root/目标后修正原创建历史,不额外造用户项。
* asset-resolution 仍可产生出站 operation,并与原创建 entry 关联,不是完全跳过 history/runtime。 */
resolveCreationSize(effect: CreationSizeEffect, expectedRootRevision: number): Result {
try { assertOpen(); } catch (error) { return reject(error); }
entered = true;
@@ -363,6 +383,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
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);
@@ -373,7 +394,7 @@ 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 提案明确拒绝。
/** 接收 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); }
@@ -384,7 +405,8 @@ 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 必须存在,序列保持单调。
// 替换时要求两栈精确覆盖 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');
@@ -392,6 +414,7 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
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);
@@ -407,7 +430,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
dispose() { disposed = true; listeners.clear(); },
});
}
/** 比较规范化表,先做身份/大小快判;无净变化的事务不增加 revision 或历史。 */
/** 比较表的迭代顺序与内容,先做身份/大小快判;不是忽略插入顺序的 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;
+11 -6
View File
@@ -18,11 +18,12 @@ export type HistoryTransition={readonly version:1;readonly rootRevision:number;r
// only its flattened changes, never registry-dependent references or callbacks.
const transitionChanges=new WeakMap<HistoryTransition,readonly Change[]>();
const immutableData=new WeakSet<object>();
/** 检查可安全跨持久切点复用的深不可变普通数据,最多 64 层。
/** 检查缓存候选的冻结数据描述符与普通原型,最多递归 64 层;不是单独的 JSON/schema 校验器。
* 冻结的 getter 仍可能返回不同值,稀疏数组也不是完整区间,因此都不允许缓存。 */
function immutablePlainData(value:unknown,depth=0):boolean{
if(depth>64)return false;
if(value===null||typeof value!=='object')return typeof value!=='function';
// 只记住已遍历成功的对象身份;祖先链若太深仍会先被 depth 条件拒绝。
if(immutableData.has(value))return true;
if(!Object.isFrozen(value)||Object.getPrototypeOf(value)!==(Array.isArray(value)?Array.prototype:Object.prototype))return false;
if(Array.isArray(value))for(let i=0;i<value.length;i++)if(!Object.hasOwn(value,i))return false;
@@ -44,7 +45,8 @@ function splice<T>(before:readonly T[],after:readonly T[]):Splice<T>{
}
/** Store changed ranges only. Clearing redo/eviction still retains the removed entry records needed to reverse that transition. */
/** 对比两份 history,记录新增、删除和修改的 entry 及两栈顺序。
* 默认无变化不造记录;recordNoop 用于调用方显式需要记录发布边界的情形。 */
* 默认无变化不造记录;recordNoop 用于调用方显式需要记录发布边界的情形。
* 来源应是不可变 history:两栈和区间内的 Change 沿用引用,最终 freeze 不等于任意外部输入的深复制。 */
export function describeHistoryTransition(before:HistoryState,after:HistoryState,rootRevision:number,entryId?:Id,recordNoop=false):HistoryTransition|undefined{
const entries:EntryPatch[]=[];
for(const id of new Set([...before.entries.keys(),...after.entries.keys()])){const a=before.entries.get(id),b=after.entries.get(id);if(equal(a,b))continue;entries.push({id,before:meta(a),after:meta(b),changes:splice(a?.changes??[],b?.changes??[]),operationIds:splice(a?.operationIds??[],b?.operationIds??[])});}
@@ -52,9 +54,10 @@ export function describeHistoryTransition(before:HistoryState,after:HistoryState
return freeze({version:1,rootRevision,...(entryId?{entryId}:{}),before:{undo:before.undo,redo:before.redo},after:{undo:after.undo,redo:after.redo},entries});
}
/** 验证外部/恢复的增量 DTO,未知 version 明确要求其它解释器。
* checkChanges 可由调用方注入更严格的 Change 校验,本函数本身不依赖 registry。 */
* checkChanges 可由调用方注入更严格的 Change 校验;未传时只检查 DTO/区间形状,不解释每条 Change 的领域语义。
* rootRevision 只在此检查非负整数,不在此证明 transition 属于哪次实际发布。 */
export function validateHistoryTransition(value:unknown,checkChanges?:(value:unknown)=>unknown):asserts value is HistoryTransition{
// 四个局部校验器统一抛领域错误,分别约束普通记录、非负整数、单 ID 和 ID 数组。
// 四个局部校验器统一抛领域错误,分别约束非数组记录形状、非负整数、非空 ID 和 ID 数组。
const object=(v:unknown):Record<string,unknown>=>{if(!v||typeof v!=='object'||Array.isArray(v))throw fault('invalid_history_transition');return v as Record<string,unknown>;};
const integer=(v:unknown)=>{if(typeof v!=='number'||!Number.isSafeInteger(v)||v<0)throw fault('invalid_history_transition');};
const id=(v:unknown)=>{if(typeof v!=='string'||!v)throw fault('invalid_history_transition');};
@@ -72,8 +75,9 @@ export function validateHistoryTransition(value:unknown,checkChanges?:(value:unk
}
}
/** Conditional metadata-only replay. A mismatch leaves the supplied history untouched. */
/** 先检查栈和每个区间仍匹配预期,再在私有副本上正向/反向回放元数据。
* 任一冲突抛出而不修改输入 history;不能拿旧 journal 覆盖新的历史顺序。 */
/** 先检查两栈,再逐 entry 检查元数据/数组区间并在私有副本上正向或反向回放。
* 不改写输入两栈/entries 表;不能拿旧 journal 覆盖新的历史顺序。
* history/transition 应已捕获:补丁内 Change 会沿用并冻结,不应把此接口当任意外部值的深隔离入口。 */
export function applyHistoryTransition(history:HistoryState,transition:HistoryTransition,direction:'forward'|'reverse'):HistoryState{
validateHistoryTransition(transition);const reverse=direction==='reverse',source=reverse?transition.after:transition.before,destination=reverse?transition.before:transition.after;
if(!equal({undo:history.undo,redo:history.redo},source))throw fault('history_transition_conflict');
@@ -91,6 +95,7 @@ export function applyHistoryTransition(history:HistoryState,transition:HistoryTr
export function historyTransitionChanges(transition:HistoryTransition):readonly Change[]{
const cached=transitionChanges.get(transition);if(cached)return cached;
validateHistoryTransition(transition);
// 仅收集被记录的变化区间两侧,未重复保存的共同前后缀不在本结果中;它不是完整 history 的展开。
const changes=transition.entries.flatMap(patch=>[...patch.changes.before,...patch.changes.after]);
// 属性来自原型或 getter 时不可把一次结果当永久事实,避免错误释放资源 pin。
const ownRanges=Object.hasOwn(transition,'entries')&&transition.entries.every(patch=>Object.hasOwn(patch,'changes')&&Object.hasOwn(patch.changes,'before')&&Object.hasOwn(patch.changes,'after'));
+5 -2
View File
@@ -9,15 +9,17 @@ import { freeze } from './values.ts';
/** Pure history execution, also used to validate recovery proposals before publication. */
/** 顺序回放一组历史修改,完成整份文档校验后返回候选文档和实际 changes。
* remap 供调用者把其它历史目标同步映射到恢复后的 incarnation;失败时原文档不变。 */
* remap 供调用者把其它历史目标同步映射到恢复后的 incarnation;函数不发布任何候选。
* initial/planned 应来自已捕获的不可变状态,底层 applyChanges/freeze 不是任意外部对象的深隔离入口。 */
export function planHistoryChanges(initial:Document,planned:readonly Change[],registry:Registry){
// ID →(旧 incarnation → 新 incarnation);同一批恢复可能形成多级映射链。
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;};
// 只改生命周期身份:字段目标与整元素 before/after 都要一致,canvas 变更原样通过。
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}});}
// 整元素 before/after 只替换顶层 incarnation,记录内其它字段与引用不在此重新解释。
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;
};
@@ -31,5 +33,6 @@ export function planHistoryChanges(initial:Document,planned:readonly Change[],re
}
// 暂态允许先恢复端点再恢复边;只有整批完成后才检查最终树、引用和 owner 一致性。
validateDocument(document,registry);
// restored 只表示是否建立过恢复代次映射;即使 false,文档也可能因普通字段/删除变化而改变。
return{document,changes:freeze(changes),remap,restored:generations.size>0};
}
+3 -1
View File
@@ -30,13 +30,15 @@ export function presentationRecord(value:Json|undefined):Readonly<Record<string,
unitScale(value);return value;
}
/** Normalize only the framework-owned unit field; all other presentation properties survive. */
/** 返回替换比例后的元数据:等效值复用原对象,比例为 1 时省略保留字段。
/** 返回替换比例后的元数据:等效值原样复用;需要重写且新比例为 1 时才删除保留字段。
* 仅更新 unitScale,不清掉宿主扩展字段;字段全部清空时返回 undefined。 */
export function withUnitScale(value:Json|undefined,scale:number):Json|undefined {
if(!Number.isFinite(scale)||scale<=0)throw fault('invalid_presentation_units');
// 坐标换算可能产生接近 1 的浮点误差,统一表示能减少无意义的模型修改。
const normalized=closeNumber(scale,1)?1:scale;
// no-op 先返回,因此显式 unitScale:1 不一定被删;原始值/数组按默认比例 1 等效时也可原样通过。
if(closeScale(unitScale(value),normalized))return value;
// 只有确实改比例才要求元数据可合并,拒绝原始值/数组,避免把宿主扩展值覆盖成对象。
const fields=new Map(Object.entries(presentationRecord(value)));
if(normalized===1)fields.delete('unitScale');else fields.set('unitScale',normalized);
return fields.size?Object.fromEntries(fields):undefined;