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

201 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 完整快照上的深复制纯规划:先确定范围和稳定 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';
import { ancestors, commonCanvas, targetElement, validateDocumentSteps } from './document.ts';
import { connectionPathValid } from './features.ts';
import { canvasTransform, convertPath, toLocal, toWorld } from './coordinates.ts';
import { clone, fault, freeze } from './values.ts';
/** 完整计划:新根 ID、旧→新元素/canvas 映射和创建 changes;version 是计划格式版本。 */
export type DuplicatePlan = { readonly version: 1; readonly roots: readonly Id[]; readonly elementIds: Readonly<Record<Id, Id>>; readonly canvasIds: Readonly<Record<Id, Id>>; readonly changes: readonly Change[] };
/** 宿主提供 ID 工厂、世界空间偏移及可选预留映射;所有新 ID 必须与源/墓碑/彼此不冲突。 */
export type DuplicateOptions = { readonly createId: () => Id; readonly worldOffset?: Point; readonly mapping?: { readonly elements: Readonly<Record<Id, Id>>; readonly canvases: Readonly<Record<Id, Id>> } };
/** 复制 data 并按 schema 重写声明引用、删 omit 字段;普通字符串与资源 ID 不隐式重映射。
* 未知组件/缺复制 schema 明确拒绝;外部引用只有 preserve 且源文档确实存在时才保留。 */
export function remapCopyData(element: ElementRecord, registry: Registry, elements: ReadonlyMap<Id, Id>, canvases: ReadonlyMap<Id, Id>, 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<string, Json>;
for (const ref of schema.references) {
for (const slot of copySlots(data, ref.path)) {
if (slot.parent[slot.key] === null) continue;
/** 单个引用按 element/canvas 映射查新 ID,找不到时执行该路径的 external 策略。 */
const mapped = (value: Json): Json => {
if (typeof value !== 'string' || !value) throw fault('invalid_copy_reference', [element.id]);
const map = ref.target === 'element' ? elements : canvases, known = ref.target === 'element' ? document.elements : document.canvases;
if (map.has(value)) return map.get(value)!;
if (ref.external !== 'preserve' || !known.has(value)) throw fault('copy_external_reference', [element.id, value]);
return value;
};
const value = slot.parent[slot.key];
if (ref.many && !Array.isArray(value)) throw fault('invalid_copy_reference', [element.id]);
slot.parent[slot.key] = ref.many ? (value as Json[]).map(mapped) : mapped(value);
}
}
// 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 点不是字节/耗时硬上限,单条记录的 clone、引用转换或祖先遍历仍可能较重。 */
export function* planDuplicateSteps(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): Generator<void, DuplicatePlan> {
yield* validateDocumentSteps(document, registry);
const offset = options.worldOffset ?? { x: 32, y: 32 };
if (![offset.x, offset.y].every(Number.isFinite)) throw fault('invalid_copy_offset');
// 选区先以 ID+incarnation 定位,再用 Set 去重;不能让过期选中对象参与新一次复制。
const selected = new Set<Id>();
for (const target of selection) { yield; selected.add(targetElement(document, target).id); }
if (!selected.size) throw fault('empty_copy_selection');
// 建入口→子 canvas、owner→成员临时索引,后续 DFS 不反复扫描全表。
const childByEntry = new Map<Id, CanvasRecord>(), members = new Map<Id, ElementRecord[]>();
for (const canvas of document.canvases.values()) { yield; if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas); }
for (const element of document.elements.values()) { yield; const list = members.get(element.ownerCanvasId) ?? []; list.push(element); members.set(element.ownerCanvasId, list); }
// 去掉已被选中祖先入口包含的子项,保留选区顺序,避免 F 与内部 A 同选时生成两份 A。
const roots: Id[] = [];
for (const id of selected) { yield; if (!ancestors(document, document.elements.get(id)!.ownerCanvasId).some(canvas => { const parent = document.canvases.get(canvas)!.parent; return parent && selected.has(parent.entryElementId); })) roots.push(id); }
const copied = new Set<Id>(), copiedCanvases = new Set<Id>(), order: ({ kind: 'element'; record: ElementRecord } | { kind: 'canvas'; record: CanvasRecord })[] = [];
// 显式 DFS stack 反向压入兄弟,弹出顺序与源顺序一致;两种 Set 分别去重元素/canvas。
const work: typeof order = [];
for (let index = roots.length - 1; index >= 0; index--) { yield; const record = document.elements.get(roots[index])!; if (record.structure === 'node') work.push({kind:'element',record}); }
while (work.length) {
yield;
const item = work.pop()!;
if (item.kind === 'canvas') {
if (copiedCanvases.has(item.record.id)) continue;
copiedCanvases.add(item.record.id); order.push(item);
const list = members.get(item.record.id) ?? [];
for (let index = list.length - 1; index >= 0; index--) { yield; const member = list[index]; if (member.structure === 'node') work.push({ kind: 'element', record: member }); }
} else {
if (copied.has(item.record.id)) continue;
copied.add(item.record.id); order.push(item);
const child = childByEntry.get(item.record.id); if (child) work.push({ kind: 'canvas', record: child });
}
}
// 边另外扫描:显式选中、属于复制 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 互撞。
const elementIds = new Map<Id, Id>(), canvasIds = new Map<Id, Id>(), used = new Set<Id>();
for (const map of [document.elements, document.canvases, document.tombstones]) for (const id of map.keys()) { yield; used.add(id); }
/** 优先使用宿主预留映射,否则调用工厂;无效/重复 ID 拒绝,不偷偷改映射后继续。 */
const allocate = (source: Id, kind: 'elements' | 'canvases') => {
const supplied = options.mapping?.[kind];
const id = supplied && Object.hasOwn(supplied, source) ? supplied[source] : options.createId();
if (typeof id !== 'string' || !id || used.has(id)) throw fault('duplicate_identity', [id]);
used.add(id); return id;
};
for (const id of copied) { yield; elementIds.set(id, allocate(id, 'elements')); }
for (const id of copiedCanvases) { yield; canvasIds.set(id, allocate(id, 'canvases')); }
// 映射不允许夹带复制范围之外的源键,防止恢复时带错任务的旧分配记录。
if (options.mapping) for (const [mapping, scope] of [[options.mapping.elements,copied],[options.mapping.canvases,copiedCanvases]] as const) for (const id in mapping) { yield; if (Object.hasOwn(mapping,id) && !scope.has(id)) throw fault('copy_mapping_scope_mismatch'); }
// 映射已完整固定才转换 payload,引用可以向前指向稍后生成的记录。
const changes: Change[] = [];
for (const item of order) {
yield;
changes.push(duplicateTreeChange(document,registry,item.kind,item.record,elementIds,canvasIds,copiedCanvases,offset));
}
// 以源+新增表的临时 overlay 给边解算新端点/共同 owner,避免中间反复复制整份文档。
const nodeElements = new Map<Id, ElementRecord>(), nodeCanvases = new Map<Id, CanvasRecord>();
for (const change of changes) { yield; if (change.kind === 'element' && change.after) nodeElements.set(change.id, change.after); else if (change.kind === 'canvas' && change.after) nodeCanvases.set(change.id, change.after); }
const nodes: Document = { ...document, elements: overlay(document.elements, nodeElements), canvases: overlay(document.canvases, nodeCanvases) };
for (const edge of edges) {
yield;
const change=duplicateEdgeChange(document,registry,edge,elementIds,canvasIds,nodes);
changes.push(change);nodeElements.set(change.id,change.after!);
}
// 最终校验包含原文档与所有副本,确认 parent/entry/owner/端点/组件数据整体一致。
yield* validateDocumentSteps(nodes, registry);
// 输出普通对象使用 defineProperty 写自有键,避免特殊 ID 键触发原型 setter;最后冻结输出。
const elementMapping: Record<Id,Id> = {}, canvasMapping: Record<Id,Id> = {}, copiedRoots: Id[] = [];
for (const [id, value] of elementIds) { yield; Object.defineProperty(elementMapping,id,{value,enumerable:true}); }
for (const [id, value] of canvasIds) { yield; Object.defineProperty(canvasMapping,id,{value,enumerable:true}); }
for (const id of roots) { yield; copiedRoots.push(elementIds.get(id)!); }
for (const change of changes) { yield; freeze(change); }
return Object.freeze({ version: 1, roots: Object.freeze(copiedRoots), elementIds: Object.freeze(elementMapping), canvasIds: Object.freeze(canvasMapping), changes: Object.freeze(changes) });
}
/** Shared deterministic emitters for synchronous and checkpointed planners. */
/** 共享确定性转换器:canvas 改 parent/entry 映射,节点改身份/owner/data 与根级偏移。
* 同步及 checkpoint planner 都使用这里,避免两套复制产品规则。 */
export function duplicateTreeChange(document:Document,registry:Registry,kind:'element'|'canvas',record:ElementRecord|CanvasRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,copiedCanvases:ReadonlySet<Id>,offset:Point):Exclude<Change,{kind:'field'}>{
// 调用方须提供已固定且覆盖范围的映射;此转换器不自行分配缺失 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)! } };
return { kind: 'canvas', id: after.id, before: null, after };
} else {
const old = record as ElementRecord; if (old.structure !== 'node') throw fault('invalid_copy_node');
let geometry = clone(old.geometry);
// 仅未被复制 owner 覆盖的根级节点应用世界偏移;内部节点保持局部位置,随新分区整体移动。
if (!copiedCanvases.has(old.ownerCanvasId)) {
const transform = canvasTransform(document, old.ownerCanvasId), zero = toLocal(transform, { x: 0, y: 0 }), shift = toLocal(transform, offset);
geometry = { ...geometry, x: geometry.x + shift.x - zero.x, y: geometry.y + shift.y - zero.y };
}
const after = { ...clone(old), id: elementIds.get(old.id)!, incarnation: 1, ownerCanvasId: canvasIds.get(old.ownerCanvasId) ?? old.ownerCanvasId, data: remapCopyData(old, registry, elementIds, canvasIds, document), geometry };
return { kind: 'element', id: after.id, before: null, after };
}
}
/** 映射边端点(外部端点沿用原 ID),按新端点算共同 owner,转换曲线后创建新 ID/incarnation=1。
* 端点结构与组件定义的 copy.references 是两套路径:前者在此映射,后者交给 remapCopyData。 */
export function duplicateEdgeChange(document:Document,registry:Registry,edge:ElementRecord,elementIds:ReadonlyMap<Id,Id>,canvasIds:ReadonlyMap<Id,Id>,nodes:Document):Extract<Change,{kind:'element'}>{
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);
let path = convertPath(edge.path, canvasTransform(document, edge.ownerCanvasId), transform);
if (path && typeof path === 'object' && !Array.isArray(path) && Array.isArray(path.knots)) {
/** 对比原/新端点节点中心的世界位移,供曲线 knot 平滑分配跟随偏移。 */
const displacement = (beforeId: Id, afterId: Id) => {
const a = document.elements.get(beforeId)!, b = nodes.elements.get(afterId)!;
if (a.structure !== 'node' || b.structure !== 'node') throw fault('invalid_copy_endpoint');
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 按序号 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<string, Json>, 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 }) }; }) };
}
const after: ElementRecord = { ...clone(edge), id: elementIds.get(edge.id)!, incarnation: 1, ownerCanvasId: owner, source, target, path, data: remapCopyData(edge, registry, elementIds, canvasIds, document) };
return { kind: 'element', id: after.id, before: null, after };
}
/** 为规划器组合源与新增表,返回临时查询文档;不是可公开发布的独立持久文档。 */
export function duplicateOverlay(document:Document,elements:ReadonlyMap<Id,ElementRecord>,canvases:ReadonlyMap<Id,CanvasRecord>):Document{return {...document,elements:overlay(document.elements,elements),canvases:overlay(document.canvases,canvases)};}
/** Internal overlay avoids an O(document size) clone between planning phases. Additions own unique IDs. */
/** 只读接口覆盖层:新增 ID 保证唯一,先查 additions,迭代则 base 后 additions。
* additions 在规划过程中仍会增长,故这个 view 只供内部计算,不是固定成员的发布快照。
* 本函数不验证 base/additions 的 ID 不相交;规划器分配时保证此约定,否则 size/迭代会出现重复。 */
function overlay<K,V>(base: ReadonlyMap<K,V>, additions: ReadonlyMap<K,V>): ReadonlyMap<K,V> {
const view: ReadonlyMap<K,V> = {
// get/has 与所有迭代器读取同一组当前表;forEach 的第三参数仍是这个只读接口。
get size() { return base.size + additions.size; },
get: key => additions.has(key) ? additions.get(key) : base.get(key), has: key => additions.has(key) || base.has(key),
*entries(): Generator<[K,V],undefined> { yield* base; yield* additions; return undefined; },
*keys(): Generator<K,undefined> { yield* base.keys(); yield* additions.keys(); return undefined; },
*values(): Generator<V,undefined> { yield* base.values(); yield* additions.values(); return undefined; },
[Symbol.iterator]() { return this.entries(); },
forEach(callback,thisArg) { for (const [key,value] of view) callback.call(thisArg,value,key,view); },
};
return Object.freeze(view);
}
/** Synchronous API retained for small snapshots and callers that own scheduling. */
/** 小数据同步便捷 API:连续耗尽分步迭代器,不自行让出主线程或持久化游标。 */
export function planDuplicate(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): DuplicatePlan {
const steps = planDuplicateSteps(document,registry,selection,options);
while (true) { const step = steps.next(); if (step.done) return step.value; }
}