/** * 解散分区的纯规划:移除一个拥有 canvas,把直属成员提升到父级,保留原入口元素及原线。 * 不同于删除子树,后代 canvas 内容保持原直接 owner。返回 changes 和权限预检命令, * commands 不是另一个任务队列,真正提交由 editor 统一完成。 */ import type {Change,Command,Document,Id,Json,Registry,Target} from './types.ts'; import {applyChanges,targetElement} from './document.ts'; import {canvasTransform,convertGeometry} from './coordinates.ts'; import {unitScale,withUnitScale} from './units.ts'; import {copySlots} from './copy-paths.ts'; import {repairConnectionOwners} from './structure.ts'; import {clone,equal,fault,freeze} from './values.ts'; /** Remove an owning canvas, retaining its entry and promoting only direct members. */ /** 验证 canvas/入口生命周期并规划成员/引用转换;根不能解散,必需转换遇到不支持能力或格式时抛错。 * 源应是有效不可变文档;本函数不执行最终 validateDocument,也不调用宿主 policy 或直接发布。 */ export function planDissolveCanvas(document:Document,registry:Registry,canvasId:Id,entryTarget:Target):{readonly changes:readonly Change[];readonly commands:readonly Command[]}{ const canvas=document.canvases.get(canvasId);if(!canvas)throw fault('canvas_not_loaded',[canvasId]); if(!canvas.parent)throw fault('cannot_dissolve_root',[canvasId]); if(canvas.parent.entryElementId!==entryTarget.id)throw fault('canvas_entry_changed',[canvasId]); targetElement(document,entryTarget); const parent=canvas.parent.canvasId,from=canvasTransform(document,canvasId),to=canvasTransform(document,parent),changes:Change[]=[],commands:Command[]=[]; // 只提升直属节点;保持世界几何并修正表现单位,边在成员移动后统一修复公共 owner。 for(const element of document.elements.values())if(element.ownerCanvasId===canvasId&&element.structure==='node'){ if(!registry.supports(element,'geometry.move'))throw fault('unsupported_capability',[element.id,'geometry.move']); const target={id:element.id,incarnation:element.incarnation},geometry=convertGeometry(element.geometry,from,to),presentation=withUnitScale(element.data.presentation,unitScale(element.data.presentation)*from.scale/to.scale); // commands 仅给 editor 逐成员做 policy 检查,不在此执行 element.move handler;几何转换直接由上面的坐标数学生成。 commands.push({type:'element.move',target,canvasId:parent}); changes.push({kind:'field',target,field:'ownerCanvasId',before:canvasId,after:parent}); if(!equal(geometry,element.geometry))changes.push({kind:'field',target,field:'geometry',before:element.geometry,after:geometry}); if(!equal(presentation,element.data.presentation))changes.push({kind:'field',target,field:'data.presentation',before:element.data.presentation??null,beforePresent:Object.hasOwn(element.data,'presentation'),after:presentation??null,afterPresent:presentation!==undefined}); } // 直属子 canvas 的入口随节点提升,因此只改其 parent,不递归改孙辈成员 owner。 for(const child of document.canvases.values())if(child.parent?.canvasId===canvasId)changes.push({kind:'canvas',id:child.id,before:child,after:{...child,parent:{...child.parent,canvasId:parent}}}); changes.push({kind:'canvas',id:canvasId,before:canvas,after:null}); // 在私有候选文档上计算边的新公共上级,并把曲线从旧 owner 空间一并转换。 const promoted=applyChanges(document,changes);changes.push(...repairConnectionOwners(document,promoted)); // Only declared canvas references have meaning here. Opaque payloads are retained. // 扫描整个 promoted 文档中已知组件声明的 canvas 引用,因此子树外的引用也可能需要修正。 // opaque payload 与未声明的普通字面 ID 保留;引用路径复用 copy schema,但这里不执行复制的 external 策略。 for(const element of promoted.elements.values()){ if(registry.resolve(element)!=='known')continue; const references=registry.components.get(element.componentId)?.copy?.references.filter(reference=>reference.target==='canvas')??[]; if(!references.length)continue; const data=clone(element.data) as Record; for(const reference of references)for(const slot of copySlots(data,reference.path)){ const value=slot.parent[slot.key];if(value===null)continue; if(reference.many&&!Array.isArray(value))throw fault('invalid_copy_reference',[element.id]); // 被解散 canvas 的引用改指父级;many 与单值共用严格 ID 检查,不扩展成自由端点政策。 const map=(value:Json):Json=>{if(typeof value!=='string'||!value)throw fault('invalid_copy_reference',[element.id]);return value===canvasId?parent:value;}; slot.parent[slot.key]=reference.many?(value as Json[]).map(map):map(value); } // 仅产生实际改变的顶层 data 字段;此处检查 content.edit 已安装,宿主 policy 由返回 commands 交给 editor 重查。 // 不调用 content.edit handler 生成变化,最终 schema/关系检查仍由事务发布路径执行。 for(const key of Object.keys(data))if(!equal(data[key],element.data[key])){ if(!registry.supports(element,'content.edit'))throw fault('unsupported_capability',[element.id,'content.edit']); changes.push({kind:'field',target:{id:element.id,incarnation:element.incarnation},field:`data.${key}`,before:element.data[key],after:data[key]}); commands.push({type:'element.data',target:{id:element.id,incarnation:element.incarnation},patch:{[key]:data[key]}}); } } // 保留原入口元素与边 ID;只移除 canvas 记录,不用 removalChanges 的子树删除语义。 return freeze({changes,commands}); }