|
|
|
@@ -0,0 +1,113 @@
|
|
|
|
|
import type { CanvasRecord, Change, Data, Document, ElementRecord, Id, Json, Point, Registry, Target } from './types.ts';
|
|
|
|
|
import { ancestors, applyChanges, commonCanvas, targetElement, validateDocument } from './document.ts';
|
|
|
|
|
import { connectionPathValid } from './features.ts';
|
|
|
|
|
import { canvasTransform, convertPath, toLocal, toWorld } from './coordinates.ts';
|
|
|
|
|
import { clone, fault, freeze } from './values.ts';
|
|
|
|
|
|
|
|
|
|
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[] };
|
|
|
|
|
export type DuplicateOptions = { readonly createId: () => Id; readonly worldOffset?: Point; readonly mapping?: { readonly elements: Readonly<Record<Id, Id>>; readonly canvases: Readonly<Record<Id, Id>> } };
|
|
|
|
|
|
|
|
|
|
function copyData(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]);
|
|
|
|
|
const data = clone(element.data) as Record<string, Json>;
|
|
|
|
|
const locate = (path: readonly string[]) => {
|
|
|
|
|
let parent: Json = data;
|
|
|
|
|
for (const key of path.slice(0, -1)) { if (!parent || typeof parent !== 'object' || !Object.hasOwn(parent, key)) return; parent = (parent as Record<string, Json>)[key]; }
|
|
|
|
|
if (parent && typeof parent === 'object' && Object.hasOwn(parent, path.at(-1)!)) return { parent: parent as Record<string, Json>, key: path.at(-1)! };
|
|
|
|
|
};
|
|
|
|
|
for (const ref of schema.references) {
|
|
|
|
|
const slot = locate(ref.path); if (!slot || slot.parent[slot.key] === null) continue;
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
for (const path of schema.omit) { const slot = locate(path); if (slot) { 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. */
|
|
|
|
|
export function planDuplicate(document: Document, registry: Registry, selection: readonly Target[], options: DuplicateOptions): DuplicatePlan {
|
|
|
|
|
validateDocument(document, registry);
|
|
|
|
|
const offset = options.worldOffset ?? { x: 32, y: 32 };
|
|
|
|
|
if (![offset.x, offset.y].every(Number.isFinite)) throw fault('invalid_copy_offset');
|
|
|
|
|
const selected = new Set(selection.map(target => targetElement(document, target).id));
|
|
|
|
|
if (!selected.size) throw fault('empty_copy_selection');
|
|
|
|
|
const childByEntry = new Map<Id, CanvasRecord>(), members = new Map<Id, ElementRecord[]>();
|
|
|
|
|
for (const canvas of document.canvases.values()) if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas);
|
|
|
|
|
for (const element of document.elements.values()) { const list = members.get(element.ownerCanvasId) ?? []; list.push(element); members.set(element.ownerCanvasId, list); }
|
|
|
|
|
const roots = [...selected].filter(id => !ancestors(document, document.elements.get(id)!.ownerCanvasId).some(canvas => { const parent = document.canvases.get(canvas)!.parent; return parent && selected.has(parent.entryElementId); }));
|
|
|
|
|
const copied = new Set<Id>(), copiedCanvases = new Set<Id>(), order: ({ kind: 'element'; record: ElementRecord } | { kind: 'canvas'; record: CanvasRecord })[] = [];
|
|
|
|
|
const stack = roots.filter(id => document.elements.get(id)!.structure === 'node').reverse().map(id => ({ kind: 'element' as const, record: document.elements.get(id)! }));
|
|
|
|
|
const work: typeof order = stack;
|
|
|
|
|
while (work.length) {
|
|
|
|
|
const item = work.pop()!;
|
|
|
|
|
if (item.kind === 'canvas') {
|
|
|
|
|
if (copiedCanvases.has(item.record.id)) continue;
|
|
|
|
|
copiedCanvases.add(item.record.id); order.push(item);
|
|
|
|
|
for (const member of [...(members.get(item.record.id) ?? [])].reverse()) 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 });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const edges = [...document.elements.values()].filter(element => element.structure === 'connection' && (selected.has(element.id) || copiedCanvases.has(element.ownerCanvasId) || copied.has(element.target.elementId)));
|
|
|
|
|
for (const edge of edges) copied.add(edge.id);
|
|
|
|
|
const elementIds = new Map<Id, Id>(), canvasIds = new Map<Id, Id>(), used = new Set([...document.elements.keys(), ...document.canvases.keys(), ...document.tombstones.keys()]);
|
|
|
|
|
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) elementIds.set(id, allocate(id, 'elements'));
|
|
|
|
|
for (const id of copiedCanvases) canvasIds.set(id, allocate(id, 'canvases'));
|
|
|
|
|
if (options.mapping && (Object.keys(options.mapping.elements).some(id => !copied.has(id)) || Object.keys(options.mapping.canvases).some(id => !copiedCanvases.has(id)))) throw fault('copy_mapping_scope_mismatch');
|
|
|
|
|
const changes: Change[] = [];
|
|
|
|
|
for (const item of order) {
|
|
|
|
|
if (item.kind === 'canvas') {
|
|
|
|
|
const parent = item.record.parent!;
|
|
|
|
|
const after = { ...clone(item.record), id: canvasIds.get(item.record.id)!, parent: { ...parent, canvasId: canvasIds.get(parent.canvasId) ?? parent.canvasId, entryElementId: elementIds.get(parent.entryElementId)! } };
|
|
|
|
|
changes.push({ kind: 'canvas', id: after.id, before: null, after });
|
|
|
|
|
} else {
|
|
|
|
|
const old = item.record; if (old.structure !== 'node') throw fault('invalid_copy_node');
|
|
|
|
|
let geometry = clone(old.geometry);
|
|
|
|
|
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: copyData(old, registry, elementIds, canvasIds, document), geometry };
|
|
|
|
|
changes.push({ kind: 'element', id: after.id, before: null, after });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const nodes = applyChanges(document, changes);
|
|
|
|
|
for (const edge of edges) {
|
|
|
|
|
if (edge.structure !== 'connection') continue;
|
|
|
|
|
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)) {
|
|
|
|
|
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 };
|
|
|
|
|
};
|
|
|
|
|
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: copyData(edge, registry, elementIds, canvasIds, document) };
|
|
|
|
|
changes.push({ kind: 'element', id: after.id, before: null, after });
|
|
|
|
|
}
|
|
|
|
|
validateDocument(applyChanges(document, changes), registry);
|
|
|
|
|
return freeze({ version: 1, roots: roots.map(id => elementIds.get(id)!), elementIds: Object.fromEntries(elementIds), canvasIds: Object.fromEntries(canvasIds), changes });
|
|
|
|
|
}
|