feat(canvas): define copy schemas and plan hierarchical duplicates

This commit is contained in:
2026-11-08 14:37:23 +08:00
parent 5296c953b1
commit be94446f12
6 changed files with 193 additions and 3 deletions
+113
View File
@@ -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 });
}
+2 -1
View File
@@ -48,7 +48,8 @@ export function builtinComponents(): readonly ComponentDefinition[] {
id, dataVersion: 1, structure: 'node', validate: textData,
features: [movable(), resizable(), ...(id === 'frame' ? [] : [rotatable()]), editable()],
resourceFields: ['image', 'plain-image'].includes(id) ? ['assetId','asset'] : [],
})).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: data => validJson(data), features: [editable(), connectionEditable()], resourceFields: [] })]);
copy: { references: [], omit: [['taskId'], ['runId'], ['progress'], ['runStatus']] },
})).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: data => validJson(data), features: [editable(), connectionEditable()], resourceFields: [], copy: { references: [], omit: [] } })]);
}
export function connectionPathValid(path: Json): boolean {
+1
View File
@@ -9,3 +9,4 @@ export * from './coordinates.ts';
export * from './structure.ts';
export * from './units.ts';
export * from './history-transition.ts';
export * from './duplicate.ts';
+8 -1
View File
@@ -7,7 +7,14 @@ export function defineFeature(id: string, handlers: Partial<Record<Capability, C
}
export function defineComponent(definition: ComponentDefinition): ComponentDefinition {
if (!definition.id || !Number.isSafeInteger(definition.dataVersion) || definition.dataVersion < 1 || typeof definition.validate !== 'function') throw fault('invalid_component');
return freeze({ ...definition, features: definition.features.map(feature => defineFeature(feature.id, feature.handlers)), migrations: definition.migrations ? { ...definition.migrations } : undefined, resourceFields: definition.resourceFields ? [...definition.resourceFields] : undefined });
const pathValid = (path: readonly string[]) => Array.isArray(path) && path.length > 0 && path.every(key => typeof key === 'string' && !!key && !['__proto__','prototype','constructor'].includes(key));
if (definition.copy) {
const references = definition.copy.references, omitted = definition.copy.omit;
if (!Array.isArray(references) || !Array.isArray(omitted) || omitted.some(path => !pathValid(path)) || references.some(ref => !pathValid(ref.path) || !['element','canvas'].includes(ref.target) || !['preserve','reject'].includes(ref.external) || ref.many !== undefined && typeof ref.many !== 'boolean')) throw fault('invalid_copy_schema');
const paths: readonly (readonly string[])[] = [...references.map(ref => ref.path), ...omitted];
for (let i = 0; i < paths.length; i++) for (let j = 0; j < i; j++) if (paths[i].slice(0, Math.min(paths[i].length, paths[j].length)).every((key, index) => key === paths[j][index])) throw fault('conflicting_copy_paths');
}
return freeze({ ...definition, copy: definition.copy ? clone(definition.copy) : undefined, features: definition.features.map(feature => defineFeature(feature.id, feature.handlers)), migrations: definition.migrations ? { ...definition.migrations } : undefined, resourceFields: definition.resourceFields ? [...definition.resourceFields] : undefined });
}
export function createRegistry(definitions: readonly ComponentDefinition[]): Registry {
const components = new Map<string, ComponentDefinition>(), capabilities = new Map<string, ReadonlyValueSet<Capability>>(), handlers = new Map<string, ReadonlyMap<Capability, CapabilityHandler>>();
+3 -1
View File
@@ -50,5 +50,7 @@ export type ReadonlyValueSet<T> = Pick<ReadonlySet<T>, 'size' | 'has' | 'entries
export type Capability = 'geometry.move' | 'geometry.resize' | 'geometry.rotate' | 'content.edit' | 'connection.edit';
export type CapabilityHandler = (context: { readonly element: ElementRecord; readonly input: Readonly<Record<string, unknown>> }) => readonly FieldChange[];
export type Feature = { readonly id: string; readonly handlers: Readonly<Partial<Record<Capability, CapabilityHandler>>> };
export type ComponentDefinition = { readonly id: string; readonly dataVersion: number; readonly structure: ElementRecord['structure']; readonly features: readonly Feature[]; readonly validate: (data: Data) => boolean; readonly migrations?: Readonly<Record<number, (data: Data) => Data>>; readonly resourceFields?: readonly string[] };
export type CopyReference = { readonly path: readonly string[]; readonly target: 'element' | 'canvas'; readonly many?: boolean; readonly external: 'preserve' | 'reject' };
export type CopySchema = { readonly references: readonly CopyReference[]; readonly omit: readonly (readonly string[])[] };
export type ComponentDefinition = { readonly id: string; readonly dataVersion: number; readonly structure: ElementRecord['structure']; readonly features: readonly Feature[]; readonly validate: (data: Data) => boolean; readonly migrations?: Readonly<Record<number, (data: Data) => Data>>; readonly resourceFields?: readonly string[]; readonly copy?: CopySchema };
export type Registry = { readonly components: ReadonlyMap<string, ComponentDefinition>; readonly capabilities: ReadonlyMap<string, ReadonlyValueSet<Capability>>; readonly handlers: ReadonlyMap<string, ReadonlyMap<Capability, CapabilityHandler>>; readonly resolve: (element: ElementRecord) => 'known' | 'unknown-component' | 'unsupported-version'; readonly supports: (element: ElementRecord, capability: Capability) => boolean };
+66
View File
@@ -0,0 +1,66 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createDocument,createRegistry,builtinComponents,defineComponent,planDuplicate,applyChanges,validateDocument,canvasTransform,toWorld} from '../../src/lib/canvas-core/index.ts';
const node=(id,owner='P',componentId='text')=>({id,incarnation:3,ownerCanvasId:owner,componentId,dataVersion:1,structure:'node',data:{title:id,text:'literal A',presentation:{contentScale:1},future:{keep:true}},geometry:{x:10,y:20,width:200,height:100,rotation:0}});
const path={type:'bezier-chain',startControl:{x:5,y:6},endControl:{x:-7,y:8},knots:[{id:'k',mode:'corner',position:{x:100,y:80},in:{x:-2,y:3},out:{x:4,y:5}}]};
const edge=(id,source,target,owner='P',curve=null)=>({id,incarnation:2,ownerCanvasId:owner,componentId:'connection',dataVersion:1,structure:'connection',source:{elementId:source,portId:'right'},target:{elementId:target,portId:'left'},path:curve,data:{title:id}});
const registry=createRegistry(builtinComponents()),F={...node('F','P','frame'),geometry:{...node().geometry,rotation:30},data:{...node().data,title:'F',presentation:{contentScale:2}}},G={...node('G','S','frame'),data:{...node().data,title:'G',presentation:{contentScale:.75}}},A={...node('A','S','image'),data:{title:'A',text:'A',assetId:'shared',src:'asset:shared',runId:'paid-run',taskId:'task',progress:.5,runStatus:'running',custom:'A'}};
const document=createDocument('P',[{id:'P',parent:null},{id:'S',parent:{canvasId:'P',entryElementId:'F'}},{id:'T',parent:{canvasId:'S',entryElementId:'G'}}],[node('C'),F,A,G,node('B','T'),node('D','T'),edge('incoming','C','F','P',path),edge('deep','C','B'),edge('internal','B','D','T',path),edge('across','A','B','S'),edge('outgoing','F','C')]);
const target=id=>({id,incarnation:document.elements.get(id).incarnation});
const fixture=(selection=['F','A'],extra={})=>{let next=0;return planDuplicate(document,registry,selection.map(target),{createId:()=>`copy-${++next}`,worldOffset:{x:32,y:48},...extra});};
const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-7,`${a} != ${b}`);
test('deep copy deduplicates ancestor selection, maps the entire tree and preserves upper incoming sources',()=>{
const before=JSON.stringify([...document.elements]),plan=fixture(),after=applyChanges(document,plan.changes);
assert.deepEqual(plan.roots,[plan.elementIds.F]);assert.equal(Object.keys(plan.canvasIds).length,2);assert.equal(Object.keys(plan.elementIds).length,9);
for(const id of ['F','A','G','B','D']){const copy=after.elements.get(plan.elementIds[id]);assert.notEqual(copy.id,id);assert.equal(copy.incarnation,1);assert.ok(document.elements.has(id));}
assert.equal(after.canvases.get(plan.canvasIds.S).parent.entryElementId,plan.elementIds.F);assert.equal(after.canvases.get(plan.canvasIds.T).parent.canvasId,plan.canvasIds.S);
const incoming=after.elements.get(plan.elementIds.incoming);assert.equal(incoming.source.elementId,'C');assert.equal(incoming.target.elementId,plan.elementIds.F);assert.equal(incoming.ownerCanvasId,'P');assert.equal(incoming.source.portId,'right');
assert.equal(after.elements.get(plan.elementIds.internal).ownerCanvasId,plan.canvasIds.T);assert.equal(after.elements.get(plan.elementIds.across).ownerCanvasId,plan.canvasIds.S);assert.equal(after.elements.get(plan.elementIds.deep).target.elementId,plan.elementIds.B);assert.equal(plan.elementIds.outgoing,undefined);
assert.equal(JSON.stringify([...document.elements]),before);validateDocument(after,registry);
// Each record boundary is legal for later incremental commits.
let partial=document;for(const change of plan.changes){partial=applyChanges(partial,[change]);validateDocument(partial,registry);}
});
test('geometry uses one world offset across rotated/scaled ownership and does not offset descendants twice',()=>{
const plan=fixture(),after=applyChanges(document,plan.changes);
for(const id of ['F','A','G','B','D']){
const a=document.elements.get(id),b=after.elements.get(plan.elementIds[id]),from=toWorld(canvasTransform(document,a.ownerCanvasId),a.geometry),to=toWorld(canvasTransform(after,b.ownerCanvasId),b.geometry);
near(to.x-from.x,32);near(to.y-from.y,48);assert.equal(b.geometry.width,a.geometry.width);assert.equal(b.geometry.rotation,a.geometry.rotation);
}
const original=toWorld(canvasTransform(document,'T'),path.knots[0].position),copied=toWorld(canvasTransform(after,plan.canvasIds.T),after.elements.get(plan.elementIds.internal).path.knots[0].position);near(copied.x-original.x,32);near(copied.y-original.y,48);
const incoming=after.elements.get(plan.elementIds.incoming).path;near(incoming.knots[0].position.x,path.knots[0].position.x+16);near(incoming.knots[0].position.y,path.knots[0].position.y+24);assert.deepEqual(incoming.startControl,path.startControl);
});
test('image copies share resources and omit execution metadata while preserving ordinary literal values',()=>{
const plan=fixture(),data=applyChanges(document,plan.changes).elements.get(plan.elementIds.A).data;
assert.equal(data.assetId,'shared');assert.equal(data.src,'asset:shared');assert.equal(data.custom,'A');for(const key of ['runId','taskId','progress','runStatus'])assert.equal(Object.hasOwn(data,key),false);
});
test('a supplied stable mapping reproduces the same plan without allocating another identity',()=>{
const first=fixture();const second=fixture(['F','A'],{mapping:{elements:first.elementIds,canvases:first.canvasIds},createId:()=>{throw Error('new identity allocated');}});assert.deepEqual(second,first);
assert.throws(()=>fixture(['F'],{createId:()=> 'C'}),/duplicate_identity/);
assert.throws(()=>fixture(['F'],{mapping:{elements:{...first.elementIds,other:'unused'},canvases:first.canvasIds}}),/copy_mapping_scope_mismatch/);
assert.throws(()=>planDuplicate(document,registry,[{id:'F',incarnation:1}],{createId:()=> 'new'}),/stale_incarnation/);
});
test('selecting the upper source too maps one new source and does not duplicate the incoming edge',()=>{
const plan=fixture(['F','A','C','incoming']),after=applyChanges(document,plan.changes),incoming=after.elements.get(plan.elementIds.incoming);
assert.equal(incoming.source.elementId,plan.elementIds.C);assert.equal(incoming.target.elementId,plan.elementIds.F);assert.equal(plan.changes.filter(change=>change.kind==='element'&&change.after?.id===incoming.id).length,1);
});
test('declared nested/list references are remapped, resources remain shared and only declared transient fields disappear',()=>{
const definition=defineComponent({...builtinComponents()[0],id:'linked',copy:{references:[{path:['links'],target:'element',many:true,external:'reject'},{path:['view'],target:'canvas',external:'reject'}],omit:[['execution']]}}),r=createRegistry([...builtinComponents(),definition]),linked={...node('L'),componentId:'linked',data:{title:'L',text:'A',links:['A','B'],view:'S',execution:{taskId:'task'},literal:'A'}},source=createDocument('P',[...document.canvases.values()],[...document.elements.values(),linked]);let next=0;
const plan=planDuplicate(source,r,[target('F'),{id:'L',incarnation:3}],{createId:()=>`ref-${++next}`}),copy=applyChanges(source,plan.changes).elements.get(plan.elementIds.L);
assert.deepEqual(copy.data.links,[plan.elementIds.A,plan.elementIds.B]);assert.equal(copy.data.view,plan.canvasIds.S);assert.equal(copy.data.literal,'A');assert.equal(copy.data.execution,undefined);
assert.throws(()=>planDuplicate(source,r,[{id:'L',incarnation:3}],{createId:()=>`external-${++next}`}),/copy_external_reference/);
});
test('missing copy schema, unknown versions, ambiguous paths and opaque curves are explicit failures',()=>{
const known=defineComponent({...builtinComponents()[0],id:'opaque',copy:undefined}),r=createRegistry([...builtinComponents(),known]),source=createDocument('P',[...document.canvases.values()],[...document.elements.values(),{...node('U'),componentId:'opaque'}]);
assert.throws(()=>planDuplicate(source,r,[{id:'U',incarnation:3}],{createId:()=> 'new'}),/copy_schema_unavailable/);
assert.throws(()=>defineComponent({...known,copy:{references:[{path:['nested'],target:'element',external:'preserve'}],omit:[['nested','id']]}}),/conflicting_copy_paths/);
assert.throws(()=>defineComponent({...known,copy:{references:[],omit:[['__proto__']]}}),/invalid_copy_schema/);
const future=createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(element=>element.id==='A'?{...element,dataVersion:9}:element));assert.throws(()=>planDuplicate(future,registry,[target('F')],{createId:(()=>{let n=0;return()=>`future-${++n}`;})()}),/copy_schema_unavailable/);
const invalid=createDocument('P',[...document.canvases.values()],[...document.elements.values()].map(element=>element.id==='incoming'?{...element,path:{type:'future'}}:element));assert.throws(()=>planDuplicate(invalid,registry,[target('F')],{createId:(()=>{let n=0;return()=>`unknown-${++n}`;})()}),/unsupported_copy_path/);
});