@@ -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 ;