Files
Endoleap/src/lib/canvas-core/dissolve.ts
T

60 lines
5.1 KiB
TypeScript

/**
* 解散分区的纯规划:移除一个拥有 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/入口生命周期,规划成员和引用转换;根不能解散,任何不支持路径整体拒绝。 */
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);
// 即使最终用 changes 原子提交,也要向 editor 暴露逐成员 policy 检查,防止入口权限越权。
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.
// 只解释已知组件明确声明的 canvas 引用;opaque payload 和普通字面 ID 文本保留。
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<string,Json>;
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 通过。
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]}});
}
}
return freeze({changes,commands});
}