179 lines
20 KiB
TypeScript
179 lines
20 KiB
TypeScript
import type { Change, Document, ElementRecord, FieldChange, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts';
|
|
import { applyChanges, commonCanvas, validateDocument } from '../canvas-core/document.ts';
|
|
import { canvasTransform, convertGeometry, convertPath, type Transform } from '../canvas-core/coordinates.ts';
|
|
import { closeNumber, closeScale, presentationRecord, unitScale, withUnitScale } from '../canvas-core/units.ts';
|
|
import { removalChanges, repairConnectionOwners } from '../canvas-core/structure.ts';
|
|
import { equal, fault, freeze, readMap } from '../canvas-core/values.ts';
|
|
|
|
export type IntentContext = { readonly before:ReadonlyMap<Id,ElementRecord>; readonly after:ReadonlyMap<Id,ElementRecord>; readonly transforms:ReadonlyMap<Id,Transform>; readonly beforeTransforms:ReadonlyMap<Id,Transform> };
|
|
/** Reconstruct only immutable intention metadata. This mutable cursor is never published as a document. */
|
|
export function intentContexts(document:Document,intents:readonly OperationIntent[]):ReadonlyMap<Id,IntentContext> {
|
|
const elements=new Map(document.elements),canvases=new Map(document.canvases),contexts=new Map<Id,IntentContext>();
|
|
const cursor={...document,elements,canvases};
|
|
for(const intent of [...intents].reverse()){
|
|
const ids=new Set<Id>(),owners=new Set<Id>();
|
|
for(const change of intent.changes){
|
|
if(change.kind==='field'){ids.add(change.target.id);if(change.field==='source'||change.field==='target')for(const endpoint of [change.before,change.after])if(endpoint&&typeof endpoint==='object'&&!Array.isArray(endpoint)&&typeof endpoint.elementId==='string')ids.add(endpoint.elementId);}
|
|
else if(change.kind==='element'){ids.add(change.id);for(const record of [change.before,change.after])if(record?.structure==='connection'){ids.add(record.source.elementId);ids.add(record.target.elementId);}}
|
|
else for(const record of [change.before,change.after])if(record?.parent)ids.add(record.parent.entryElementId);
|
|
}
|
|
const after=new Map<Id,ElementRecord>();for(const id of ids){const record=elements.get(id);if(record){after.set(id,record);owners.add(record.ownerCanvasId);}}
|
|
const transforms=new Map<Id,Transform>();for(const owner of owners)if(canvases.has(owner))transforms.set(owner,canvasTransform(cursor,owner));
|
|
for(const change of [...intent.changes].reverse()){
|
|
if(change.kind==='element'){if(change.before)elements.set(change.id,change.before);else elements.delete(change.id);}
|
|
else if(change.kind==='canvas'){if(change.before)canvases.set(change.id,change.before);else canvases.delete(change.id);}
|
|
else {const current=elements.get(change.target.id);if(!current||current.incarnation!==change.target.incarnation)continue;
|
|
if(change.field.startsWith('data.')){const key=change.field.slice(5),data={...current.data};if(change.beforePresent===false)delete data[key];else data[key]=change.before;elements.set(current.id,freeze({...current,data}));}
|
|
else elements.set(current.id,freeze({...current,[change.field]:change.before}) as ElementRecord);
|
|
}
|
|
}
|
|
const before=new Map([...ids].flatMap(id=>elements.has(id)?[[id,elements.get(id)!] as const]:[])),beforeTransforms=new Map<Id,Transform>();for(const record of before.values())if(canvases.has(record.ownerCanvasId)&&!beforeTransforms.has(record.ownerCanvasId))beforeTransforms.set(record.ownerCanvasId,canvasTransform(cursor,record.ownerCanvasId));
|
|
contexts.set(intent.id,Object.freeze({before:readMap(before),after:readMap(after),transforms:readMap(transforms),beforeTransforms:readMap(beforeTransforms)}));
|
|
}
|
|
return readMap(contexts);
|
|
}
|
|
|
|
function sameConvertedPath(left:Json,right:Json):boolean {
|
|
if(equal(left,right))return true;
|
|
if(!left||!right||typeof left!=='object'||typeof right!=='object'||Array.isArray(left)||Array.isArray(right)||!Array.isArray(left.knots)||!Array.isArray(right.knots))return false;
|
|
const {knots:a,startControl:as,endControl:ae,...ar}=left,{knots:b,startControl:bs,endControl:be,...br}=right;
|
|
const point=(a:Json|undefined,b:Json|undefined)=>{if(a===undefined||b===undefined)return a===b;if(!a||!b||typeof a!=='object'||typeof b!=='object'||Array.isArray(a)||Array.isArray(b))return false;const{x:ax,y:ay,...restA}=a,{x:bx,y:by,...restB}=b;return typeof ax==='number'&&typeof ay==='number'&&typeof bx==='number'&&typeof by==='number'&&closeNumber(ax,bx)&&closeNumber(ay,by)&&equal(restA,restB);};
|
|
return equal(ar,br)&&point(as,bs)&&point(ae,be)&&a.length===b.length&&a.every((value,index)=>{const other=b[index];if(!value||!other||typeof value!=='object'||typeof other!=='object'||Array.isArray(value)||Array.isArray(other))return false;const{position:p,in:i,out:o,...rest}=value,{position:q,in:j,out:k,...remaining}=other;return equal(rest,remaining)&&point(p,q)&&point(i,j)&&point(o,k);});
|
|
}
|
|
function presentationDelta(current:Json|undefined,before:Json|undefined,after:Json|undefined):Json|undefined {
|
|
if(equal(before,after))return current;
|
|
const start=presentationRecord(before),end=presentationRecord(after),keys=new Set([...Object.keys(start),...Object.keys(end)]);keys.delete('unitScale');
|
|
const changed=[...keys].filter(key=>Object.hasOwn(start,key)!==Object.hasOwn(end,key)||!equal(start[key],end[key]));if(!changed.length)return current;
|
|
const result=new Map(Object.entries(presentationRecord(current)));for(const key of changed){if(Object.hasOwn(end,key))result.set(key,end[key]);else result.delete(key);}
|
|
return result.size?Object.fromEntries(result):undefined;
|
|
}
|
|
function movedNode(current:Extract<ElementRecord,{structure:'node'}>,originalBefore:Extract<ElementRecord,{structure:'node'}>,originalAfter:Extract<ElementRecord,{structure:'node'}>,beforeBasis:Transform,afterBasis:Transform,source:Transform,destination:Transform,intent:OperationIntent){
|
|
const world:Transform={x:0,y:0,scale:1,rotation:0},start=convertGeometry(originalBefore.geometry,beforeBasis,world),end=convertGeometry(originalAfter.geometry,afterBasis,world),fresh=convertGeometry(current.geometry,source,world);
|
|
const center=(g:typeof start)=>({x:g.x+g.width/2,y:g.y+g.height/2}),a=center(start),b=center(end),c=center(fresh),width=closeNumber(start.width,end.width)?fresh.width:end.width,height=closeNumber(start.height,end.height)?fresh.height:end.height;
|
|
const x=closeNumber(a.x,b.x)?c.x:b.x,y=closeNumber(a.y,b.y)?c.y:b.y,rotation=Math.abs(((start.rotation-end.rotation+540)%360)-180)<1e-9?fresh.rotation:end.rotation;
|
|
const geometry=convertGeometry({x:x-width/2,y:y-height/2,width,height,rotation},world,destination);
|
|
const unitEdited=intent.changes.some(change=>change.kind==='field'&&change.target.id===current.id&&change.field==='data.presentation'&&!closeScale(unitScale(change.before),unitScale(change.after)));
|
|
const oldStyle=unitScale(originalBefore.data.presentation)*beforeBasis.scale,newStyle=unitScale(originalAfter.data.presentation)*afterBasis.scale;
|
|
const desiredStyle=unitEdited&&!closeScale(oldStyle,newStyle)?newStyle:unitScale(current.data.presentation)*source.scale;
|
|
const presentation=withUnitScale(presentationDelta(current.data.presentation,originalBefore.data.presentation,originalAfter.data.presentation),desiredStyle/destination.scale);
|
|
return{geometry,presentation};
|
|
}
|
|
|
|
/** Restore batches may have inverse storage order (canvas before entry). Only reorder adjacent creates, never move across an edit/delete. */
|
|
function orderedCreates(changes:readonly Change[]):readonly Change[]{
|
|
const result:Change[]=[];
|
|
const creating=(change:Change)=>change.kind!=='field'&&!change.before&&!!change.after;
|
|
for(let offset=0;offset<changes.length;){
|
|
if(!creating(changes[offset])){result.push(changes[offset++]);continue;}
|
|
let end=offset;while(end<changes.length&&creating(changes[end]))end++;
|
|
const batch=changes.slice(offset,end),keys=batch.map(change=>`${change.kind}:${(change as Extract<Change,{kind:'element'|'canvas'}>).id}`),positions=new Map(keys.map((key,index)=>[key,index]));
|
|
if(positions.size!==batch.length)throw fault('recovery_creation_dependency_conflict');
|
|
const counts=batch.map(()=>0),waiting=new Map<number,number[]>(),ready:number[]=[];
|
|
batch.forEach((change,index)=>{
|
|
const dependencies=new Set<string>();
|
|
if(change.kind==='canvas'&&change.after?.parent){dependencies.add(`canvas:${change.after.parent.canvasId}`);dependencies.add(`element:${change.after.parent.entryElementId}`);}
|
|
if(change.kind==='element'&&change.after){dependencies.add(`canvas:${change.after.ownerCanvasId}`);if(change.after.structure==='connection'){dependencies.add(`element:${change.after.source.elementId}`);dependencies.add(`element:${change.after.target.elementId}`);}}
|
|
for(const key of dependencies){const dependency=positions.get(key);if(dependency===undefined)continue;counts[index]++;const list=waiting.get(dependency)??[];list.push(index);waiting.set(dependency,list);}
|
|
if(!counts[index])ready.push(index);
|
|
});
|
|
let emitted=0;for(let next=0;next<ready.length;next++){const index=ready[next];result.push(batch[index]);emitted++;for(const dependent of waiting.get(index)??[])if(--counts[dependent]===0)ready.push(dependent);}
|
|
if(emitted!==batch.length)throw fault('recovery_creation_dependency_conflict');offset=end;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Re-plan against current data. Whole records are used only for explicit creation/removal. */
|
|
export function rebaseIntent(document:Document,intent:OperationIntent,context:IntentContext,registry:Registry):{document:Document;changes:readonly Change[]} {
|
|
const before=document,changes:Change[]=[],moved=new Set<Id>(),deferredPaths:FieldChange[]=[];
|
|
for(const change of intent.changes)if(change.kind==='field'&&change.field==='ownerCanvasId'&&context.after.get(change.target.id)?.structure==='node')moved.add(change.target.id);
|
|
function apply(change:Change){document=applyChanges(document,[change]);changes.push(freeze(change));}
|
|
function target(change:FieldChange){const current=document.elements.get(change.target.id),prior=context.before.get(change.target.id)??context.after.get(change.target.id);if(!current||current.incarnation!==change.target.incarnation)throw fault('stale_incarnation',[change.target.id]);if(prior&&(current.componentId!==prior.componentId||current.dataVersion!==prior.dataVersion||current.structure!==prior.structure))throw fault('recovery_target_type_changed',[current.id]);if(registry.resolve(current)!=='known')throw fault('unsupported_component',[current.id]);return current;}
|
|
function endpoint(id:Id){const current=document.elements.get(id),expected=context.after.get(id);if(!current||current.structure!=='node'||!expected||current.incarnation!==expected.incarnation)throw fault('stale_endpoint',[id]);}
|
|
for(const original of orderedCreates(intent.changes)){
|
|
if(original.kind==='element'){
|
|
const current=document.elements.get(original.id);
|
|
if(original.before===null&&original.after){
|
|
const dead=document.tombstones.get(original.id),restoring=['undo','redo'].includes(intent.origin)&&dead!==undefined&&original.after.incarnation===dead+1;
|
|
if(current||dead!==undefined&&!restoring)throw fault('recovery_identity_collision',[original.id]);
|
|
if(registry.resolve(original.after)!=='known')throw fault('unsupported_component',[original.id]);
|
|
let after=original.after;
|
|
if(after.structure==='node'){const basis=context.transforms.get(after.ownerCanvasId);if(!basis)throw fault('recovery_coordinate_context_missing',[after.id]);const destination=canvasTransform(document,after.ownerCanvasId),presentation=withUnitScale(after.data.presentation,unitScale(after.data.presentation)*basis.scale/destination.scale),data={...after.data};if(presentation===undefined)delete data.presentation;else data.presentation=presentation;after={...after,data,geometry:convertGeometry(after.geometry,basis,destination)};}
|
|
if(after.structure==='connection'){
|
|
endpoint(after.source.elementId);endpoint(after.target.elementId);const owner=commonCanvas(document,document.elements.get(after.source.elementId)!.ownerCanvasId,document.elements.get(after.target.elementId)!.ownerCanvasId);
|
|
const basis=context.transforms.get(after.ownerCanvasId);if(!basis)throw fault('recovery_coordinate_context_missing',[after.id]);
|
|
after={...after,ownerCanvasId:owner,path:convertPath(after.path,basis,canvasTransform(document,owner))};
|
|
}
|
|
apply({...original,after});
|
|
}else if(original.before&&original.after===null){
|
|
if(!current)continue;
|
|
if(current.incarnation!==original.before.incarnation)throw fault('stale_incarnation',[original.id]);
|
|
if(registry.resolve(current)!=='known')throw fault('unsupported_component',[original.id]);
|
|
for(const change of removalChanges(document,{id:current.id,incarnation:current.incarnation}))apply(change);
|
|
}else throw fault('recovery_record_replacement_not_supported',[original.id]);
|
|
continue;
|
|
}
|
|
if(original.kind==='canvas'){
|
|
if(original.before?.parent&&original.after?.parent&&original.before.parent.entryElementId===original.after.parent.entryElementId&&moved.has(original.after.parent.entryElementId))continue;
|
|
const current=document.canvases.get(original.id);
|
|
if(!original.before&&original.after){if(current)throw fault('recovery_identity_collision',[original.id]);if(original.after.parent)endpoint(original.after.parent.entryElementId);apply(original);}
|
|
else if(original.before&&!original.after){if(current){if(current.parent===null)throw fault('missing_root');for(const record of document.elements.values())if(record.ownerCanvasId===current.id)throw fault('recovery_canvas_not_empty',[current.id]);apply({...original,before:current});}}
|
|
else if(original.after&¤t){if(original.after.parent)endpoint(original.after.parent.entryElementId);const after={...current,parent:original.after.parent?{...current.parent,canvasId:original.after.parent.canvasId,entryElementId:original.after.parent.entryElementId}:null};if(!equal(current,after))apply({...original,before:current,after});}
|
|
else throw fault('canvas_not_loaded',[original.id]);
|
|
continue;
|
|
}
|
|
let current=target(original);const prior=context.before.get(current.id),afterContext=context.after.get(current.id);
|
|
if(original.field==='ownerCanvasId'){
|
|
if(current.structure==='connection')continue; // Recomputed from the final endpoints and tree below.
|
|
if(!registry.supports(current,'geometry.move')||typeof original.after!=='string'||!document.canvases.has(original.after))throw fault('unsupported_capability',[current.id]);
|
|
const basis=afterContext?context.transforms.get(afterContext.ownerCanvasId):undefined;if(!afterContext||afterContext.structure!=='node'||!basis)throw fault('recovery_coordinate_context_missing',[current.id]);
|
|
const previous=context.before.get(current.id),originalSource=previous?context.beforeTransforms.get(previous.ownerCanvasId):undefined;
|
|
if(current.structure!=='node'||previous?.structure!=='node'||!originalSource)throw fault('recovery_coordinate_context_missing',[current.id]);
|
|
const converted=movedNode(current,previous,afterContext,originalSource,basis,canvasTransform(document,current.ownerCanvasId),canvasTransform(document,original.after),intent);
|
|
if(current.ownerCanvasId!==original.after)apply({...original,before:current.ownerCanvasId});
|
|
for(const canvas of document.canvases.values())if(canvas.parent?.entryElementId===current.id&&canvas.parent.canvasId!==original.after)apply({kind:'canvas',id:canvas.id,before:canvas,after:{...canvas,parent:{...canvas.parent,canvasId:original.after}}});
|
|
if(!equal(current.data.presentation,converted.presentation))apply({kind:'field',target:original.target,field:'data.presentation',before:current.data.presentation??null,beforePresent:Object.hasOwn(current.data,'presentation'),after:converted.presentation??null,afterPresent:converted.presentation!==undefined});
|
|
current=document.elements.get(current.id)!;if(current.structure==='node'&&!equal(current.geometry,converted.geometry))apply({kind:'field',target:original.target,field:'geometry',before:current.geometry,after:converted.geometry});
|
|
continue;
|
|
}
|
|
if((original.field==='geometry'||original.field==='data.presentation')&&moved.has(current.id))continue;
|
|
if(original.field==='data.presentation'){
|
|
const changedUnits=!closeScale(unitScale(original.before),unitScale(original.after));
|
|
let desired=presentationDelta(current.data.presentation,original.before,original.after);
|
|
if(changedUnits){const basis=afterContext?context.transforms.get(afterContext.ownerCanvasId):undefined;if(!basis)throw fault('recovery_coordinate_context_missing',[current.id]);desired=withUnitScale(desired,unitScale(original.after)*basis.scale/canvasTransform(document,current.ownerCanvasId).scale);}
|
|
if(!equal(current.data.presentation,desired))apply({...original,before:current.data.presentation??null,beforePresent:Object.hasOwn(current.data,'presentation'),after:desired??null,afterPresent:desired!==undefined});
|
|
continue;
|
|
}
|
|
|
|
if(original.field==='source'||original.field==='target'){
|
|
if(current.structure!=='connection'||!registry.supports(current,'connection.edit'))throw fault('unsupported_capability',[current.id]);
|
|
const next=original.after as unknown as {elementId:Id};endpoint(next.elementId);
|
|
if(!equal(current[original.field],original.after))apply({...original,before:current[original.field]});continue;
|
|
}
|
|
if(original.field==='path'){
|
|
const related=intent.changes.some(change=>change.kind==='field'&&change.target.id===current.id&&['ownerCanvasId','source','target'].includes(change.field));
|
|
if(related){if(!registry.supports(current,'connection.edit'))throw fault('unsupported_capability',[current.id]);deferredPaths.push(original);continue;}
|
|
}
|
|
if((original.field==='geometry'||original.field==='path')&&prior&&prior.ownerCanvasId!==current.ownerCanvasId)throw fault('ownership_conflict',[current.id]);
|
|
if(original.field.startsWith('data.')&&!registry.supports(current,'content.edit')||original.field==='path'&&!registry.supports(current,'connection.edit'))throw fault('unsupported_capability',[current.id]);
|
|
const key=original.field.startsWith('data.')?original.field.slice(5):undefined,actual=key===undefined?(current as unknown as Record<string,Json>)[original.field]:current.data[key]??null,present=key===undefined?undefined:Object.hasOwn(current.data,key);
|
|
let desired=original.after;
|
|
if(original.field==='geometry'&¤t.structure==='node'){
|
|
const start=original.before as unknown as typeof current.geometry,end=original.after as unknown as typeof current.geometry;
|
|
desired={...current.geometry,...Object.fromEntries((['x','y','width','height','rotation'] as const).filter(key=>start[key]!==end[key]).map(key=>[key,end[key]]))};
|
|
const next=desired as unknown as typeof current.geometry;for(const [keys,capability]of [[['x','y'],'geometry.move'],[['width','height'],'geometry.resize'],[['rotation'],'geometry.rotate']] as const)if(keys.some(key=>current.geometry[key]!==next[key])&&!registry.supports(current,capability))throw fault('unsupported_capability',[current.id]);
|
|
}
|
|
if(!equal(actual,desired)||(present??true)!==(original.afterPresent??true)){const{beforePresent:_previous,...record}=original;apply({...record,before:actual,...(present===undefined?{}:{beforePresent:present}),after:desired});}
|
|
}
|
|
for(const original of deferredPaths){
|
|
const edge=target(original),intended=context.after.get(edge.id);if(edge.structure!=='connection'||intended?.structure!=='connection')throw fault('invalid_endpoint',[edge.id]);
|
|
const previous=context.before.get(edge.id),oldBasis=previous?context.beforeTransforms.get(previous.ownerCanvasId):undefined,basis=context.transforms.get(intended.ownerCanvasId);if(!basis)throw fault('recovery_coordinate_context_missing',[edge.id]);
|
|
if(previous?.structure==='connection'&&oldBasis&&sameConvertedPath(convertPath(previous.path,oldBasis,basis),intended.path))continue;
|
|
const owner=commonCanvas(document,document.elements.get(edge.source.elementId)!.ownerCanvasId,document.elements.get(edge.target.elementId)!.ownerCanvasId);
|
|
const path=convertPath(original.after,basis,canvasTransform(document,owner));
|
|
if(edge.ownerCanvasId!==owner)apply({kind:'field',target:original.target,field:'ownerCanvasId',before:edge.ownerCanvasId,after:owner});
|
|
if(!equal(edge.path,path))apply({...original,before:edge.path,after:path});
|
|
}
|
|
for(const change of repairConnectionOwners(before,document))apply(change);
|
|
validateDocument(document,registry);return{document,changes:freeze(changes)};
|
|
}
|