diff --git a/src/lib/canvas-core/copy-paths.ts b/src/lib/canvas-core/copy-paths.ts index 1edbb80..7ed7966 100644 --- a/src/lib/canvas-core/copy-paths.ts +++ b/src/lib/canvas-core/copy-paths.ts @@ -1,6 +1,7 @@ /** * 组件声明式引用路径的遍历器,供复制/解散等纯规划复用。 - * 只访问自有字段,不用字符串相似度猜 ID;这里 yield 可写 slot,调用者必须先复制数据再改。 + * 普通路径段只访问自有字段,不用字符串相似度猜 ID;这里 yield 可写 slot,调用者必须先复制数据再改。 + * path 应先经组件定义校验:本遍历器不重新禁止空路径或原型敏感键,数组 * 按下标位置枚举。 */ import type { Json } from './types.ts'; import { fault } from './values.ts'; @@ -14,7 +15,7 @@ export function* copySlots(data: Json, path: readonly string[]): Generator<{pare const {value, depth} = work.pop()!, key = path[depth]; if (key === '*') { if (!Array.isArray(value)) throw fault('invalid_copy_reference'); - // 终端 * 直接枚举数组项位置;非终端 * 继续匹配下一路径段。 + // 终端 * 直接枚举 0..length-1,不另用 hasOwn 检查空洞;非终端 * 继续匹配下一路径段。 if (depth === path.length - 1) { for (let i = 0; i < value.length; i++) yield {parent: value as unknown as Record, key: String(i)}; } else for (let i = value.length - 1; i >= 0; i--) work.push({value: value[i], depth: depth + 1}); } else if (value && typeof value === 'object' && Object.hasOwn(value, key)) { @@ -23,7 +24,8 @@ export function* copySlots(data: Json, path: readonly string[]): Generator<{pare } } } -/** 在 slot 上读取引用 ID;null 代表无引用,many 要求数组,每个 ID 必须非空字符串。 */ +/** 在 slot 上读取引用 ID;整个 slot 为 null 表示无引用,many 要求数组,每个 ID 必须非空字符串。 + * many 数组里的 null 不等于无引用,会拒绝;many 与路径中的 * 是独立的两层展开规则。 */ export function* copyReferenceIds(data: Json, ref: {path: readonly string[]; many?: boolean}): Generator { for (const {parent, key} of copySlots(data, ref.path)) { const value = parent[key]; if (value === null) continue; diff --git a/src/lib/canvas-core/dissolve.ts b/src/lib/canvas-core/dissolve.ts index c806ea6..799f233 100644 --- a/src/lib/canvas-core/dissolve.ts +++ b/src/lib/canvas-core/dissolve.ts @@ -12,7 +12,8 @@ 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/入口生命周期,规划成员和引用转换;根不能解散,任何不支持路径整体拒绝。 */ +/** 验证 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]); @@ -23,7 +24,7 @@ export function planDissolveCanvas(document:Document,registry:Registry,canvasId: 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 仅给 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}); @@ -35,7 +36,8 @@ export function planDissolveCanvas(document:Document,registry:Registry,canvasId: // 在私有候选文档上计算边的新公共上级,并把曲线从旧 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 文本保留。 + // 扫描整个 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')??[]; @@ -48,12 +50,14 @@ export function planDissolveCanvas(document:Document,registry:Registry,canvasId: 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 通过。 + // 仅产生实际改变的顶层 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}); } diff --git a/src/lib/canvas-core/duplicate-checkpoint.ts b/src/lib/canvas-core/duplicate-checkpoint.ts index 226b42f..d21723c 100644 --- a/src/lib/canvas-core/duplicate-checkpoint.ts +++ b/src/lib/canvas-core/duplicate-checkpoint.ts @@ -2,6 +2,7 @@ * 可暂停/恢复的深复制准备状态机。检查点保存 ID、阶段、游标、DFS stack 和稳定映射, * 不保存元素 payload 的第二份副本;输出记录是由源与映射确定性重建的缓存。 * 每次 step 只推进有限语义工作,但持久源校验、分片调度、checkpoint 保存和渐进加入由 runtime 负责。 + * 构造器仍同步展开源表并校验保存状态,step 也没有按字节/毫秒强制切断单记录转换。 */ import {detachJsonData} from './json-data.ts'; import type { CanvasRecord, Change, Document, ElementRecord, Id, Point, Registry, Target } from './types.ts'; @@ -22,7 +23,8 @@ type Immutable=T extends object?{readonly [K in keyof T]:Immutable}:T; export type DuplicateCheckpoint=Immutable; /** 形状辅助检查:数组成员必须是非空字符串;是否存在/重复由后续检查负责。 */ const ids=(value:unknown):value is Id[]=>Array.isArray(value)&&value.every(id=>typeof id==='string'&&!!id); -/** 校验外部检查点格式、阶段/计数/去重与前缀边界;与实际源的一致性在工厂中进一步核验。 */ +/** 检查 DTO 中明确列出的格式、阶段/计数、去重与前缀边界;不是按源重新推导整个已保存执行轨迹。 + * 与实际源的部分成员/映射一致性在工厂核验;可信持久记录与 token 来源由 runtime 边界保证。 */ export function validateDuplicateCheckpoint(value:DuplicateCheckpoint):void{ if(!value||!validJson(value)||value.version!==1)throw fault('copy_checkpoint_interpreter_unavailable'); if(![value.projectId,value.sourceToken,value.schemaToken].every(id=>typeof id==='string'&&!!id)||!phases.includes(value.phase)||!Number.isSafeInteger(value.cursor)||value.cursor<(value.phase==='seed'?-1:0)||![value.steps,value.emitted,value.emittedEdges].every(n=>Number.isSafeInteger(n)&&n>=0)||!Array.isArray(value.selection)||!value.selection.length||value.selection.some(t=>!t||typeof t.id!=='string'||!t.id||!Number.isSafeInteger(t.incarnation)||t.incarnation<1)||!value.offset||![value.offset.x,value.offset.y].every(Number.isFinite))throw fault('invalid_copy_checkpoint'); @@ -33,7 +35,8 @@ export function validateDuplicateCheckpoint(value:DuplicateCheckpoint):void{ if(!Array.isArray(value.members)||value.members.some(pair=>!Array.isArray(pair)||pair.length!==2||typeof pair[0]!=='string'||!pair[0]||!ids(pair[1]))||new Set(value.members.map(pair=>pair[0])).size!==value.members.length||value.emitted>value.order.length||value.emittedEdges>value.edges.length||value.emittedEdges>0&&value.emitted!==value.order.length||value.expanding&&(typeof value.expanding.canvas!=='string'||!value.expanding.canvas||!Number.isSafeInteger(value.expanding.index)||value.expanding.index< -1))throw fault('invalid_copy_checkpoint'); } /** 创建或恢复解释器;绑定 project/sourceToken/schemaToken/选区/偏移/预留映射,任一不符拒绝续跑。 - * token 的真实源摘要/定义签名由调用方提供并验证,这里不读取 Blob 或计算 SHA。 */ + * token 的真实源摘要/定义签名由调用方提供并验证,这里只比较字符串,不读取 Blob 或计算 SHA。 + * 绑定匹配不等于任意外来检查点已获防篡改证明;范围/阶段状态应来自可信恢复记录。 */ export function createDuplicatePlanner(document:Document,registry:Registry,selection:readonly Target[],options:DuplicateOptions,binding:{sourceToken:string;schemaToken:string;checkpoint?:DuplicateCheckpoint;topologyOnly?:boolean}){ const offset=options.worldOffset??{x:32,y:32},idFactory=options.createId,suppliedMapping=options.mapping?freeze(clone(options.mapping)):undefined; // 从检查点复制出私有可变 state,旧 checkpoint 保持不变;新任务从 selection 开始。 @@ -56,7 +59,8 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec const supplied=suppliedMapping?[...Object.keys(suppliedMapping.elements).map(id=>({id,kind:'element' as const})),...Object.keys(suppliedMapping.canvases).map(id=>({id,kind:'canvas' as const}))]:[]; // 这些 Map 是派生输出缓存,overlay 内部可读其增长,不作为第二份发布文档。 const outputElements=new Map(),outputCanvases=new Map(),output=duplicateOverlay(document,outputElements,outputCanvases),changes:Change[]=[]; - // 源校验每次重开重新执行;topologyOnly 仅用于明确的结构源,最终输出仍执行完整数据校验。 + // 源校验每次重开重新执行;topologyOnly 跳过源的组件 payload schema。 + // 本解释器继续到 result 时校验源+副本完整输出;若 runtime 在 ready 接管逐记录生成,则由那条消费路径完成后续校验。 let treeCache=0,edgeCache=0,sourceChecks:Generator|undefined=(binding.topologyOnly?validateDocumentTopologySteps:validateDocumentSteps)(document,registry),outputChecks:Generator|undefined,validated=false; /** 切换解释阶段并重置/指定该阶段 cursor,不改稳定 ID 或输出前缀。 */ const phase=(next:DuplicateCheckpoint['phase'],cursor=0)=>{state.phase=next;state.cursor=cursor;}; @@ -69,7 +73,8 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec /** 只为尚未分配的源项取新 ID;优先宿主预留值,used 同时覆盖源、墓碑与全部新值。 */ function allocate(id:Id,kind:'elements'|'canvases'){const map=suppliedMapping?.[kind],value=map&&Object.hasOwn(map,id)?map[id]:idFactory();if(typeof value!=='string'||!value||used.has(value))throw fault('duplicate_identity',[value]);used.add(value);return value;} return Object.freeze({ - /** 源已校验且刚到 emit,范围/映射固定,可以交给运行时逐记录转换,不要求完整计划已生成。 */ + /** 源检查迭代已结束、阶段刚到 emit 且 emitted=0 时可接管范围/映射;不是完整计划已生成或已加入画布。 + * topologyOnly 下“源检查结束”仍只证明拓扑路径通过,不证明所有组件 payload schema。 */ ready():boolean{return !sourceChecks&&state.phase==='emit'&&state.emitted===0;}, /** 返回阶段、语义 steps 和已分配数;不冒称解析/加入/网络已完成。 */ progress(){return Object.freeze({phase:state.phase,steps:state.steps,allocated:state.elementIds.length+state.canvasIds.length});}, @@ -98,7 +103,8 @@ export function createDuplicatePlanner(document:Document,registry:Registry,selec if(task.kind==='canvas'){if(canvases.has(task.id))break;canvases.add(task.id);state.canvases.push(task.id);state.order.push(task);state.expanding={canvas:task.id,index:(members.get(task.id)?.length??0)-1};} else{if(copied.has(task.id))break;copied.add(task.id);state.copied.push(task.id);state.order.push(task);const child=children.get(task.id);if(child)state.work.push({kind:'canvas',id:child});}break; } - // 扫描上级入边与范围内边;随后 element/canvas 分配都把新 ID 写回 state,恢复可重用。 + // 与同步规划器相同:显式选边、复制 canvas 的边及指向复制节点的入边;只有源端被复制不足以自动带上出边。 + // 随后 element/canvas 分配都把新 ID 写回 state,恢复重用已有分配,不为同一前缀再造身份。 case 'edges':if(state.cursorelementIds.get(id)!),elementIds:Object.fromEntries(elementIds),canvasIds:Object.fromEntries(canvasIds),changes:[...changes]});}, }); } diff --git a/src/lib/canvas-core/duplicate.ts b/src/lib/canvas-core/duplicate.ts index 8ee9a03..fa97112 100644 --- a/src/lib/canvas-core/duplicate.ts +++ b/src/lib/canvas-core/duplicate.ts @@ -2,6 +2,7 @@ * 完整快照上的深复制纯规划:先确定范围和稳定 ID 映射,再生成节点/canvas,最后生成边。 * 同时选分区入口和其内部节点时只复制一次;原上级 C→F 保留,新增边可连接原 C 与新 F′。 * 这里只返回计划,不改 editor、不上传图片、不启动生成;runtime 负责调度、持久源和逐批加入。 + * 源须在规划期间保持不可变;资源 ID 沿 data/copy 声明处理,不在这里复制文件字节或创建资源绑定。 */ import type { CanvasRecord, Change, Data, Document, ElementRecord, Id, Json, Point, Registry, Target } from './types.ts'; import { copySlots } from './copy-paths.ts'; @@ -20,6 +21,7 @@ export type DuplicateOptions = { readonly createId: () => Id; readonly worldOffs export function remapCopyData(element: ElementRecord, registry: Registry, elements: ReadonlyMap, canvases: ReadonlyMap, document: Document): Data { const definition = registry.components.get(element.componentId), schema = definition?.copy; if (registry.resolve(element) !== 'known' || !schema) throw fault('copy_schema_unavailable', [element.id]); + // data 完整 JSON 复制后再改声明路径;未声明为引用的字符串(即使看起来像 ID)原样保留。 const data = clone(element.data) as Record; for (const ref of schema.references) { for (const slot of copySlots(data, ref.path)) { @@ -37,13 +39,15 @@ export function remapCopyData(element: ElementRecord, registry: Registry, elemen slot.parent[slot.key] = ref.many ? (value as Json[]).map(mapped) : mapped(value); } } - // omit 删除对象字段,拒绝删除数组下标造成稀疏数据;生成任务状态通过声明省略,不复制成新任务。 + // omit 只删除声明的对象字段,数组下标不能直接删成空洞;是否清除任务状态取决于该组件 copy.omit, + // 规划器本身不识别所有插件的任务字段,也不把复制出的配置当成启动生成命令。 for (const path of schema.omit) for (const slot of copySlots(data, path)) { if (Array.isArray(slot.parent)) throw fault('invalid_copy_omit', [element.id]); delete slot.parent[slot.key]; } return data; } /** Pure, complete-snapshot planning. Runtime owns time slicing, durable source/mapping and partial progress. */ -/** 分步迭代器:yield 给调用者调度机会,本身不 await、不落盘;耗尽后才返回完整已校验计划。 */ +/** 分步迭代器:yield 给调用者调度机会,本身不 await、不落盘;耗尽后才返回完整已校验计划。 + * yield 点不是字节/耗时硬上限,单条记录的 clone、引用转换或祖先遍历仍可能较重。 */ export function* planDuplicateSteps(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): Generator { yield* validateDocumentSteps(document, registry); const offset = options.worldOffset ?? { x: 32, y: 32 }; @@ -78,6 +82,7 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele } } // 边另外扫描:显式选中、属于复制 canvas、或指向复制节点的入边均纳入,源端可仍指向原上级节点。 + // 仅 source 被复制、target 在外部的出边,不因 source 在范围内就自动纳入;显式选中等其它条件仍可纳入。 const edges: ElementRecord[] = []; for (const element of document.elements.values()) { yield; if (element.structure === 'connection' && (selected.has(element.id) || copiedCanvases.has(element.ownerCanvasId) || copied.has(element.target.elementId))) { edges.push(element); copied.add(element.id); } } // ID 分配前先保留源元素/canvas/墓碑全部 ID;同一 used 集合还防止两种新 ID 互撞。 @@ -124,6 +129,7 @@ export function* planDuplicateSteps(document: Document, registry: Registry, sele /** 共享确定性转换器:canvas 改 parent/entry 映射,节点改身份/owner/data 与根级偏移。 * 同步及 checkpoint planner 都使用这里,避免两套复制产品规则。 */ export function duplicateTreeChange(document:Document,registry:Registry,kind:'element'|'canvas',record:ElementRecord|CanvasRecord,elementIds:ReadonlyMap,canvasIds:ReadonlyMap,copiedCanvases:ReadonlySet,offset:Point):Exclude{ + // 调用方须提供已固定且覆盖范围的映射;此转换器不自行分配缺失 ID,canvas 分支依赖非空 parent、只供子 canvas 使用。 if (kind === 'canvas') { const canvas=record as CanvasRecord,parent=canvas.parent!; const after = { ...clone(canvas), id: canvasIds.get(canvas.id)!, parent: { ...parent, canvasId: canvasIds.get(parent.canvasId) ?? parent.canvasId, entryElementId: elementIds.get(parent.entryElementId)! } }; @@ -140,9 +146,11 @@ export function duplicateTreeChange(document:Document,registry:Registry,kind:'el return { kind: 'element', id: after.id, before: null, after }; } } -/** 映射边端点(外部端点沿用原 ID),按新端点算共同 owner,转换曲线后创建新 ID/incarnation=1。 */ +/** 映射边端点(外部端点沿用原 ID),按新端点算共同 owner,转换曲线后创建新 ID/incarnation=1。 + * 端点结构与组件定义的 copy.references 是两套路径:前者在此映射,后者交给 remapCopyData。 */ export function duplicateEdgeChange(document:Document,registry:Registry,edge:ElementRecord,elementIds:ReadonlyMap,canvasIds:ReadonlyMap,nodes:Document):Extract{ if (edge.structure !== 'connection') throw fault('invalid_copy_endpoint'); + // 与 convertPath 的同变换早返回不同,这里即使 owner 空间不变,也先要求复制支持该曲线格式。 if (!connectionPathValid(edge.path)) throw fault('unsupported_copy_path', [edge.id]); const source = { ...edge.source, elementId: elementIds.get(edge.source.elementId) ?? edge.source.elementId }, target = { ...edge.target, elementId: elementIds.get(edge.target.elementId) ?? edge.target.elementId }; const owner = commonCanvas(nodes, nodes.elements.get(source.elementId)!.ownerCanvasId, nodes.elements.get(target.elementId)!.ownerCanvasId), transform = canvasTransform(nodes, owner); @@ -155,7 +163,8 @@ export function duplicateEdgeChange(document:Document,registry:Registry,edge:Ele const from = toWorld(canvasTransform(document, a.ownerCanvasId), { x: a.geometry.x + a.geometry.width / 2, y: a.geometry.y + a.geometry.height / 2 }), to = toWorld(canvasTransform(nodes, b.ownerCanvasId), { x: b.geometry.x + b.geometry.width / 2, y: b.geometry.y + b.geometry.height / 2 }); return { x: to.x - from.x, y: to.y - from.y }; }; - // knot 按链中序号在两端位移间插值;一端仍为原节点时,不把整条线简单平移。 + // knot 按序号 t=(index+1)/(count+1) 在线两端世界位移间插值,不按弧长或几何距离加权。 + // 这里只移动 knot.position,in/out 等偏移向量已在前面的空间转换中处理,不再次加位移。 const from = displacement(edge.source.elementId, source.elementId), to = displacement(edge.target.elementId, target.elementId), knots = path.knots; path = { ...path, knots: knots.map((raw, index) => { const knot = raw as Record, point = knot.position as { x: number; y: number }, world = toWorld(transform, point), t = (index + 1) / (knots.length + 1); return { ...knot, position: toLocal(transform, { x: world.x + from.x * (1 - t) + to.x * t, y: world.y + from.y * (1 - t) + to.y * t }) }; }) }; } @@ -167,7 +176,8 @@ export function duplicateOverlay(document:Document,elements:ReadonlyMap(base: ReadonlyMap, additions: ReadonlyMap): ReadonlyMap { const view: ReadonlyMap = { // get/has 与所有迭代器读取同一组当前表;forEach 的第三参数仍是这个只读接口。