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'; export type DuplicatePlan = { readonly version: 1; readonly roots: readonly Id[]; readonly elementIds: Readonly>; readonly canvasIds: Readonly>; readonly changes: readonly Change[] }; export type DuplicateOptions = { readonly createId: () => Id; readonly worldOffset?: Point; readonly mapping?: { readonly elements: Readonly>; readonly canvases: Readonly> } }; 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]); const data = clone(element.data) as Record; for (const ref of schema.references) { for (const slot of copySlots(data, ref.path)) { if (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) 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. */ 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 }; if (![offset.x, offset.y].every(Number.isFinite)) throw fault('invalid_copy_offset'); const selected = new Set(); for (const target of selection) { yield; selected.add(targetElement(document, target).id); } if (!selected.size) throw fault('empty_copy_selection'); const childByEntry = new Map(), members = new Map(); 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); } 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(), copiedCanvases = new Set(), order: ({ kind: 'element'; record: ElementRecord } | { kind: 'canvas'; record: CanvasRecord })[] = []; 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 }); } } 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); } } const elementIds = new Map(), canvasIds = new Map(), used = new Set(); for (const map of [document.elements, document.canvases, document.tombstones]) for (const id of map.keys()) { yield; used.add(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'); } const changes: Change[] = []; for (const item of order) { yield; changes.push(duplicateTreeChange(document,registry,item.kind,item.record,elementIds,canvasIds,copiedCanvases,offset)); } const nodeElements = new Map(), nodeCanvases = new Map(); 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!); } yield* validateDocumentSteps(nodes, registry); const elementMapping: Record = {}, canvasMapping: Record = {}, 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. */ export function duplicateTreeChange(document:Document,registry:Registry,kind:'element'|'canvas',record:ElementRecord|CanvasRecord,elementIds:ReadonlyMap,canvasIds:ReadonlyMap,copiedCanvases:ReadonlySet,offset:Point):Exclude{ 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); 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 }; } } 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'); 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, 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,canvases:ReadonlyMap):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. */ function overlay(base: ReadonlyMap, additions: ReadonlyMap): ReadonlyMap { const view: ReadonlyMap = { 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 { yield* base.keys(); yield* additions.keys(); return undefined; }, *values(): Generator { 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. */ 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; } }