/** * 用户审阅冲突后按新基线重新规划原意图的纯工具,不是正常 ACK 路径的自动覆盖合并。 * 保留用户实际修改的字段/世界几何意图,未修改的当前字段继续沿用 fresh 数据;未知类型或 * 生命周期/坐标上下文无法确认时拒绝。生成的是新 changes,原冻结 body/key 不在此改写。 */ import type { Change, Document, ElementRecord, FieldChange, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts'; import { applyChanges, commonCanvas, validateDocument } from '../canvas-core/document.ts'; import { canvasTransform, convertGeometry, convertPath, type Transform } from '../canvas-core/coordinates.ts'; import { closeNumber, closeScale, presentationRecord, unitScale, withUnitScale } from '../canvas-core/units.ts'; import { removalChanges, repairConnectionOwners } from '../canvas-core/structure.ts'; import { equal, fault, freeze, readMap } from '../canvas-core/values.ts'; /** 原操作相关对象的前后记录及 owner 变换;用于区分用户意图与仅由坐标转换引起的差异。 */ export type IntentContext = { readonly before:ReadonlyMap; readonly after:ReadonlyMap; readonly transforms:ReadonlyMap; readonly beforeTransforms:ReadonlyMap }; /** Reconstruct only immutable intention metadata. This mutable cursor is never published as a document. */ /** 从已包含这些意图效果的 working 逆序退回,重建局部前后记录及 owner 变换。 * 输入顺序就是应用顺序;函数不按 sequence 排序,也不验证当前文档确由这些意图产生。 * 这里只还原意图元数据,不按 before/after 条件执行撤销,不回退墓碑、不分配新代次或发布文档。 * 只读 Map 捕获表成员,记录值沿用不可变输入;不是任意外部对象的深克隆边界。 */ export function intentContexts(document:Document,intents:readonly OperationIntent[]):ReadonlyMap { const elements=new Map(document.elements),canvases=new Map(document.canvases),contexts=new Map(); const cursor={...document,elements,canvases}; for(const intent of [...intents].reverse()){ // 收集自身、连线端点与 canvas 入口,捕获足够的身份和坐标上下文,不复制全项目到每条意图。 const ids=new Set(),owners=new Set(); for(const change of intent.changes){ if(change.kind==='field'){ids.add(change.target.id);if(change.field==='source'||change.field==='target')for(const endpoint of [change.before,change.after])if(endpoint&&typeof endpoint==='object'&&!Array.isArray(endpoint)&&typeof endpoint.elementId==='string')ids.add(endpoint.elementId);} else if(change.kind==='element'){ids.add(change.id);for(const record of [change.before,change.after])if(record?.structure==='connection'){ids.add(record.source.elementId);ids.add(record.target.elementId);}} else for(const record of [change.before,change.after])if(record?.parent)ids.add(record.parent.entryElementId); } const after=new Map();for(const id of ids){const record=elements.get(id);if(record){after.set(id,record);owners.add(record.ownerCanvasId);}} const transforms=new Map();for(const owner of owners)if(canvases.has(owner))transforms.set(owner,canvasTransform(cursor,owner)); // 直接把记录/字段写回原 before,不检查当前是否等于 after;field 缺对象/代次不符时跳过。 // cursor 中间树可不完整,随后仅在 owner 仍存在时取变换;无法解析的祖先链仍可能抛错。 for(const change of [...intent.changes].reverse()){ if(change.kind==='element'){if(change.before)elements.set(change.id,change.before);else elements.delete(change.id);} else if(change.kind==='canvas'){if(change.before)canvases.set(change.id,change.before);else canvases.delete(change.id);} else {const current=elements.get(change.target.id);if(!current||current.incarnation!==change.target.incarnation)continue; if(change.field.startsWith('data.')){const key=change.field.slice(5),data={...current.data};if(change.beforePresent===false)delete data[key];else data[key]=change.before;elements.set(current.id,freeze({...current,data}));} else elements.set(current.id,freeze({...current,[change.field]:change.before}) as ElementRecord); } } const before=new Map([...ids].flatMap(id=>elements.has(id)?[[id,elements.get(id)!] as const]:[])),beforeTransforms=new Map();for(const record of before.values())if(canvases.has(record.ownerCanvasId)&&!beforeTransforms.has(record.ownerCanvasId))beforeTransforms.set(record.ownerCanvasId,canvasTransform(cursor,record.ownerCanvasId)); contexts.set(intent.id,Object.freeze({before:readMap(before),after:readMap(after),transforms:readMap(transforms),beforeTransforms:readMap(beforeTransforms)})); } return readMap(contexts); } /** 比较路径是否仅存在坐标换算误差:点坐标用容差,其它字段与 knot 顺序仍严格比较。 */ function sameConvertedPath(left:Json,right:Json):boolean { if(equal(left,right))return true; if(!left||!right||typeof left!=='object'||typeof right!=='object'||Array.isArray(left)||Array.isArray(right)||!Array.isArray(left.knots)||!Array.isArray(right.knots))return false; const {knots:a,startControl:as,endControl:ae,...ar}=left,{knots:b,startControl:bs,endControl:be,...br}=right; /** 控制点容差比较同时保留扩展字段检查,不能把两条实际不同的曲线误认为坐标等效。 */ const point=(a:Json|undefined,b:Json|undefined)=>{if(a===undefined||b===undefined)return a===b;if(!a||!b||typeof a!=='object'||typeof b!=='object'||Array.isArray(a)||Array.isArray(b))return false;const{x:ax,y:ay,...restA}=a,{x:bx,y:by,...restB}=b;return typeof ax==='number'&&typeof ay==='number'&&typeof bx==='number'&&typeof by==='number'&&closeNumber(ax,bx)&&closeNumber(ay,by)&&equal(restA,restB);}; return equal(ar,br)&&point(as,bs)&&point(ae,be)&&a.length===b.length&&a.every((value,index)=>{const other=b[index];if(!value||!other||typeof value!=='object'||typeof other!=='object'||Array.isArray(value)||Array.isArray(other))return false;const{position:p,in:i,out:o,...rest}=value,{position:q,in:j,out:k,...remaining}=other;return equal(rest,remaining)&&point(p,q)&&point(i,j)&&point(o,k);}); } /** 以键的存在性和值比较找 presentation 顶层差异,再覆盖/删除 current 的对应键。 * 嵌套键值作为整体处理,不递归合并;unitScale 排除在本函数外,交几何分支做视觉单位换算。 * 无变化时沿用 current;有变化后若结果无键则返回 undefined,交调用方处理字段存在性。 */ function presentationDelta(current:Json|undefined,before:Json|undefined,after:Json|undefined):Json|undefined { if(equal(before,after))return current; const start=presentationRecord(before),end=presentationRecord(after),keys=new Set([...Object.keys(start),...Object.keys(end)]);keys.delete('unitScale'); const changed=[...keys].filter(key=>Object.hasOwn(start,key)!==Object.hasOwn(end,key)||!equal(start[key],end[key]));if(!changed.length)return current; const result=new Map(Object.entries(presentationRecord(current)));for(const key of changed){if(Object.hasOwn(end,key))result.set(key,end[key]);else result.delete(key);} return result.size?Object.fromEntries(result):undefined; } /** 重挂节点时先将原前/后几何与当前几何转换到世界空间,保留未修改维度的当前值。 * 用户明确改变的中心/尺寸/旋转取其意图值,再转到 fresh destination;不是盲加一份旧 local patch。 */ function movedNode(current:Extract,originalBefore:Extract,originalAfter:Extract,beforeBasis:Transform,afterBasis:Transform,source:Transform,destination:Transform,intent:OperationIntent){ const world:Transform={x:0,y:0,scale:1,rotation:0},start=convertGeometry(originalBefore.geometry,beforeBasis,world),end=convertGeometry(originalAfter.geometry,afterBasis,world),fresh=convertGeometry(current.geometry,source,world); // 世界中心用于分别判断横/纵移动意图;未变化的维度取 fresh,修改维度取原意图终值,而非叠加 delta。 const center=(g:typeof start)=>({x:g.x+g.width/2,y:g.y+g.height/2}),a=center(start),b=center(end),c=center(fresh),width=closeNumber(start.width,end.width)?fresh.width:end.width,height=closeNumber(start.height,end.height)?fresh.height:end.height; const x=closeNumber(a.x,b.x)?c.x:b.x,y=closeNumber(a.y,b.y)?c.y:b.y,rotation=Math.abs(((start.rotation-end.rotation+540)%360)-180)<1e-9?fresh.rotation:end.rotation; const geometry=convertGeometry({x:x-width/2,y:y-height/2,width,height,rotation},world,destination); // 只有原意图显式改变视觉 unitScale 才采用目标视觉尺度;纯换 owner 保留当前外观。 const unitEdited=intent.changes.some(change=>change.kind==='field'&&change.target.id===current.id&&change.field==='data.presentation'&&!closeScale(unitScale(change.before),unitScale(change.after))); const oldStyle=unitScale(originalBefore.data.presentation)*beforeBasis.scale,newStyle=unitScale(originalAfter.data.presentation)*afterBasis.scale; const desiredStyle=unitEdited&&!closeScale(oldStyle,newStyle)?newStyle:unitScale(current.data.presentation)*source.scale; const presentation=withUnitScale(presentationDelta(current.data.presentation,originalBefore.data.presentation,originalAfter.data.presentation),desiredStyle/destination.scale); return{geometry,presentation}; } /** Restore batches may have inverse storage order (canvas before entry). Only reorder adjacent creates, never move across an edit/delete. */ /** 只在连续创建段内按 parent/entry/owner/端点依赖拓扑排序,绝不跨编辑/删除重排。 * 仅段内依赖参与 Kahn 计数;段外依赖是否存在留给执行/最终校验。元素与 canvas 分命名空间。 * ready 按发现顺序排队,不保证每次都选择剩余项中原索引最小者;重 ID 或环直接拒绝。 */ function orderedCreates(changes:readonly Change[]):readonly Change[]{ const result:Change[]=[]; /** before=null 且 after 存在才是创建,整记录替换不能冒充依赖排序候选。 */ const creating=(change:Change)=>change.kind!=='field'&&!change.before&&!!change.after; for(let offset=0;offset`${change.kind}:${(change as Extract).id}`),positions=new Map(keys.map((key,index)=>[key,index])); if(positions.size!==batch.length)throw fault('recovery_creation_dependency_conflict'); // Kahn 排序:counts 记段内依赖数,waiting 存反向边,ready 按原遇到顺序入列。 const counts=batch.map(()=>0),waiting=new Map(),ready:number[]=[]; batch.forEach((change,index)=>{ const dependencies=new Set(); if(change.kind==='canvas'&&change.after?.parent){dependencies.add(`canvas:${change.after.parent.canvasId}`);dependencies.add(`element:${change.after.parent.entryElementId}`);} if(change.kind==='element'&&change.after){dependencies.add(`canvas:${change.after.ownerCanvasId}`);if(change.after.structure==='connection'){dependencies.add(`element:${change.after.source.elementId}`);dependencies.add(`element:${change.after.target.elementId}`);}} for(const key of dependencies){const dependency=positions.get(key);if(dependency===undefined)continue;counts[index]++;const list=waiting.get(dependency)??[];list.push(index);waiting.set(dependency,list);} if(!counts[index])ready.push(index); }); // 每输出一个创建就降低依赖项计数;输出不足整段说明依赖环/冲突,整体拒绝。 let emitted=0;for(let next=0;next(),deferredPaths:FieldChange[]=[]; for(const change of intent.changes)if(change.kind==='field'&&change.field==='ownerCanvasId'&&context.after.get(change.target.id)?.structure==='node')moved.add(change.target.id); /** 每条新变化立即作用于私有 candidate,后面的条件读取已重规划后的实际 before。 */ function apply(change:Change){document=applyChanges(document,[change]);changes.push(freeze(change));} /** 目标必须存在、代次相同且 registry 已知;上下文含 prior 时再比较结构/组件/版本。 * 这是字段分支共用准入,具体能力检查在各分支;上下文缺 prior 不在这里补造类型依据。 */ function target(change:FieldChange){const current=document.elements.get(change.target.id),prior=context.before.get(change.target.id)??context.after.get(change.target.id);if(!current||current.incarnation!==change.target.incarnation)throw fault('stale_incarnation',[change.target.id]);if(prior&&(current.componentId!==prior.componentId||current.dataVersion!==prior.dataVersion||current.structure!==prior.structure))throw fault('recovery_target_type_changed',[current.id]);if(registry.resolve(current)!=='known')throw fault('unsupported_component',[current.id]);return current;} /** 当前端点须为 node,且与 context.after 中对象代次一致;无上下文也拒绝。 * 这里不单独比较端点的 componentId/dataVersion,图关系是否合法仍由最终校验判断。 */ function endpoint(id:Id){const current=document.elements.get(id),expected=context.after.get(id);if(!current||current.structure!=='node'||!expected||current.incarnation!==expected.incarnation)throw fault('stale_endpoint',[id]);} // 按受限依赖顺序处理创建;删除/字段意图的原语义顺序仍保留。 for(const original of orderedCreates(intent.changes)){ if(original.kind==='element'){ const current=document.elements.get(original.id); if(original.before===null&&original.after){ // 仅 undo/redo 显式恢复允许 dead+1;其它碰撞不能静默改 ID 或复用墓碑。 const dead=document.tombstones.get(original.id),restoring=['undo','redo'].includes(intent.origin)&&dead!==undefined&&original.after.incarnation===dead+1; if(current||dead!==undefined&&!restoring)throw fault('recovery_identity_collision',[original.id]); if(registry.resolve(original.after)!=='known')throw fault('unsupported_component',[original.id]); let after=original.after; // 新建节点按原意图世界位置转到 fresh owner,表现单位随其比例换算。 if(after.structure==='node'){const basis=context.transforms.get(after.ownerCanvasId);if(!basis)throw fault('recovery_coordinate_context_missing',[after.id]);const destination=canvasTransform(document,after.ownerCanvasId),presentation=withUnitScale(after.data.presentation,unitScale(after.data.presentation)*basis.scale/destination.scale),data={...after.data};if(presentation===undefined)delete data.presentation;else data.presentation=presentation;after={...after,data,geometry:convertGeometry(after.geometry,basis,destination)};} // 新建边校验新端点代次,重新解算共同 owner,再转换原路径坐标。 if(after.structure==='connection'){ endpoint(after.source.elementId);endpoint(after.target.elementId);const owner=commonCanvas(document,document.elements.get(after.source.elementId)!.ownerCanvasId,document.elements.get(after.target.elementId)!.ownerCanvasId); const basis=context.transforms.get(after.ownerCanvasId);if(!basis)throw fault('recovery_coordinate_context_missing',[after.id]); after={...after,ownerCanvasId:owner,path:convertPath(after.path,basis,canvasTransform(document,owner))}; } apply({...original,after}); // 删除目标已不存在时直接满足,不在此以墓碑证明它曾是哪一代;仍存在则要求旧代次和已知类型。 // 按 fresh 拥有树重新规划删除,可包含远端新增子内容/关联边,而不是照抄旧删除清单。 }else if(original.before&&original.after===null){ if(!current)continue; if(current.incarnation!==original.before.incarnation)throw fault('stale_incarnation',[original.id]); if(registry.resolve(current)!=='known')throw fault('unsupported_component',[original.id]); for(const change of removalChanges(document,{id:current.id,incarnation:current.incarnation}))apply(change); }else throw fault('recovery_record_replacement_not_supported',[original.id]); continue; } // 节点重挂会统一维护其入口子 canvas;其它树变化校验入口,删除非空 canvas 明确拒绝。 if(original.kind==='canvas'){ if(original.before?.parent&&original.after?.parent&&original.before.parent.entryElementId===original.after.parent.entryElementId&&moved.has(original.after.parent.entryElementId))continue; const current=document.canvases.get(original.id); if(!original.before&&original.after){if(current)throw fault('recovery_identity_collision',[original.id]);if(original.after.parent)endpoint(original.after.parent.entryElementId);apply(original);} else if(original.before&&!original.after){if(current){if(current.parent===null)throw fault('missing_root');for(const record of document.elements.values())if(record.ownerCanvasId===current.id)throw fault('recovery_canvas_not_empty',[current.id]);apply({...original,before:current});}} else if(original.after&¤t){if(original.after.parent)endpoint(original.after.parent.entryElementId);const after={...current,parent:original.after.parent?{...current.parent,canvasId:original.after.parent.canvasId,entryElementId:original.after.parent.entryElementId}:null};if(!equal(current,after))apply({...original,before:current,after});} else throw fault('canvas_not_loaded',[original.id]); continue; } let current=target(original);const prior=context.before.get(current.id),afterContext=context.after.get(current.id); // 边 owner 留到最终端点/树确定后重算;节点重挂一次规划 owner+geometry+presentation+入口 parent。 if(original.field==='ownerCanvasId'){ if(current.structure==='connection')continue; // Recomputed from the final endpoints and tree below. if(!registry.supports(current,'geometry.move')||typeof original.after!=='string'||!document.canvases.has(original.after))throw fault('unsupported_capability',[current.id]); const basis=afterContext?context.transforms.get(afterContext.ownerCanvasId):undefined;if(!afterContext||afterContext.structure!=='node'||!basis)throw fault('recovery_coordinate_context_missing',[current.id]); const previous=context.before.get(current.id),originalSource=previous?context.beforeTransforms.get(previous.ownerCanvasId):undefined; if(current.structure!=='node'||previous?.structure!=='node'||!originalSource)throw fault('recovery_coordinate_context_missing',[current.id]); const converted=movedNode(current,previous,afterContext,originalSource,basis,canvasTransform(document,current.ownerCanvasId),canvasTransform(document,original.after),intent); if(current.ownerCanvasId!==original.after)apply({...original,before:current.ownerCanvasId}); for(const canvas of document.canvases.values())if(canvas.parent?.entryElementId===current.id&&canvas.parent.canvasId!==original.after)apply({kind:'canvas',id:canvas.id,before:canvas,after:{...canvas,parent:{...canvas.parent,canvasId:original.after}}}); if(!equal(current.data.presentation,converted.presentation))apply({kind:'field',target:original.target,field:'data.presentation',before:current.data.presentation??null,beforePresent:Object.hasOwn(current.data,'presentation'),after:converted.presentation??null,afterPresent:converted.presentation!==undefined}); current=document.elements.get(current.id)!;if(current.structure==='node'&&!equal(current.geometry,converted.geometry))apply({kind:'field',target:original.target,field:'geometry',before:current.geometry,after:converted.geometry}); continue; } // 已由重挂分支统一转换的几何/样式不再套旧字段变化,避免重复缩放或位移。 if((original.field==='geometry'||original.field==='data.presentation')&&moved.has(current.id))continue; // 合并用户改过的样式键;unitScale 按原后空间与 fresh owner 换算,保留未改样式。 if(original.field==='data.presentation'){ const changedUnits=!closeScale(unitScale(original.before),unitScale(original.after)); let desired=presentationDelta(current.data.presentation,original.before,original.after); if(changedUnits){const basis=afterContext?context.transforms.get(afterContext.ownerCanvasId):undefined;if(!basis)throw fault('recovery_coordinate_context_missing',[current.id]);desired=withUnitScale(desired,unitScale(original.after)*basis.scale/canvasTransform(document,current.ownerCanvasId).scale);} if(!equal(current.data.presentation,desired))apply({...original,before:current.data.presentation??null,beforePresent:Object.hasOwn(current.data,'presentation'),after:desired??null,afterPresent:desired!==undefined}); continue; } // 换端点仍要求 connection.edit 与当前节点身份,相关曲线路径随后再处理。 if(original.field==='source'||original.field==='target'){ if(current.structure!=='connection'||!registry.supports(current,'connection.edit'))throw fault('unsupported_capability',[current.id]); const next=original.after as unknown as {elementId:Id};endpoint(next.elementId); if(!equal(current[original.field],original.after))apply({...original,before:current[original.field]});continue; } // 与 owner/端点同时改的路径延后;孤立几何/路径若 owner 已变则拒绝盲重放。 if(original.field==='path'){ const related=intent.changes.some(change=>change.kind==='field'&&change.target.id===current.id&&['ownerCanvasId','source','target'].includes(change.field)); if(related){if(!registry.supports(current,'connection.edit'))throw fault('unsupported_capability',[current.id]);deferredPaths.push(original);continue;} } if((original.field==='geometry'||original.field==='path')&&prior&&prior.ownerCanvasId!==current.ownerCanvasId)throw fault('ownership_conflict',[current.id]); if(original.field.startsWith('data.')&&!registry.supports(current,'content.edit')||original.field==='path'&&!registry.supports(current,'connection.edit'))throw fault('unsupported_capability',[current.id]); const key=original.field.startsWith('data.')?original.field.slice(5):undefined,actual=key===undefined?(current as unknown as Record)[original.field]:current.data[key]??null,present=key===undefined?undefined:Object.hasOwn(current.data,key); let desired=original.after; // 普通几何修改仅覆盖 before→after 实际变化的轴,其余轴沿用 fresh;逐能力复核准入。 if(original.field==='geometry'&¤t.structure==='node'){ const start=original.before as unknown as typeof current.geometry,end=original.after as unknown as typeof current.geometry; desired={...current.geometry,...Object.fromEntries((['x','y','width','height','rotation'] as const).filter(key=>start[key]!==end[key]).map(key=>[key,end[key]]))}; const next=desired as unknown as typeof current.geometry;for(const [keys,capability]of [[['x','y'],'geometry.move'],[['width','height'],'geometry.resize'],[['rotation'],'geometry.rotate']] as const)if(keys.some(key=>current.geometry[key]!==next[key])&&!registry.supports(current,capability))throw fault('unsupported_capability',[current.id]); } if(!equal(actual,desired)||(present??true)!==(original.afterPresent??true)){const{beforePresent:_previous,...record}=original;apply({...record,before:actual,...(present===undefined?{}:{beforePresent:present}),after:desired});} } // 纯坐标转换造成的路径差异不当作用户改曲线;显式路径意图转到最终 owner 空间再发布候选。 for(const original of deferredPaths){ const edge=target(original),intended=context.after.get(edge.id);if(edge.structure!=='connection'||intended?.structure!=='connection')throw fault('invalid_endpoint',[edge.id]); const previous=context.before.get(edge.id),oldBasis=previous?context.beforeTransforms.get(previous.ownerCanvasId):undefined,basis=context.transforms.get(intended.ownerCanvasId);if(!basis)throw fault('recovery_coordinate_context_missing',[edge.id]); if(previous?.structure==='connection'&&oldBasis&&sameConvertedPath(convertPath(previous.path,oldBasis,basis),intended.path))continue; const owner=commonCanvas(document,document.elements.get(edge.source.elementId)!.ownerCanvasId,document.elements.get(edge.target.elementId)!.ownerCanvasId); const path=convertPath(original.after,basis,canvasTransform(document,owner)); if(edge.ownerCanvasId!==owner)apply({kind:'field',target:original.target,field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner}); if(!equal(edge.path,path))apply({...original,before:edge.path,after:path}); } // 使用初始 fresh 与最终候选修复受影响边的 owner/path,再做整图校验;空 changes 也要校验。 // 抛错不返回半成品,成功仍只是规划结果;是否采纳以及新的 operation/history 身份由调用方决定。 for(const change of repairConnectionOwners(before,document))apply(change); validateDocument(document,registry);return{document,changes:freeze(changes)}; }