201 lines
18 KiB
TypeScript
201 lines
18 KiB
TypeScript
/**
|
||
* 完整快照上的深复制纯规划:先确定范围和稳定 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; }
|
||
}
|