diff --git a/src/lib/canvas-core/editor.ts b/src/lib/canvas-core/editor.ts index 7fbbb46..da7aadb 100644 --- a/src/lib/canvas-core/editor.ts +++ b/src/lib/canvas-core/editor.ts @@ -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 = { 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(options: EditorOptions,queries:ReturnType)=>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) => 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(options: EditorOptions(options: EditorOptions !['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.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(options: EditorOptions):applyChanges(document, changes); return { document: next, changes: freeze(clone(changes)) }; } @@ -170,7 +180,8 @@ export function createEditor(options: EditorOptions(options: EditorOptions(); for (const change of changes) { if (change.kind === 'canvas') { @@ -239,6 +251,7 @@ export function createEditor(options: EditorOptions(options: EditorOptions 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(options: EditorOptions(options: EditorOptions([]), ...(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(options: EditorOptions[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(options: EditorOptions(options: EditorOptions(options: EditorOptions 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(options: EditorOptions(options: EditorOptions }): Result { try { assertOpen(); } catch (error) { return reject(error); } @@ -384,7 +405,8 @@ export function createEditor(options: EditorOptions!history.entries.has(id)))throw fault('invalid_history'); @@ -392,6 +414,7 @@ export function createEditor(options: EditorOptions!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(options: EditorOptions(); const immutableData=new WeakSet(); -/** 检查可安全跨持久切点复用的深不可变普通数据,最多 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(before:readonly T[],after:readonly T[]):Splice{ } /** 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=>{if(!v||typeof v!=='object'||Array.isArray(v))throw fault('invalid_history_transition');return v as Record;}; 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')); diff --git a/src/lib/canvas-core/history.ts b/src/lib/canvas-core/history.ts index 2ce48d6..a1fbf38 100644 --- a/src/lib/canvas-core/history.ts +++ b/src/lib/canvas-core/history.ts @@ -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>(); - // 追踪到最终代次,避免后面的字段修改仍指向本批较早的已替换代次。 + // 追踪到本批最终代次;每次恢复分配更大代次,因此按这些内部生成的映射链前进。 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}; } diff --git a/src/lib/canvas-core/units.ts b/src/lib/canvas-core/units.ts index fd8ca79..a89d354 100644 --- a/src/lib/canvas-core/units.ts +++ b/src/lib/canvas-core/units.ts @@ -30,13 +30,15 @@ export function presentationRecord(value:Json|undefined):Readonly