feat(canvas): dissolve owning canvases without deleting their contents
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import type {Change,Command,Document,Id,Json,Registry,Target} from './types.ts';
|
||||
import {applyChanges,targetElement} from './document.ts';
|
||||
import {canvasTransform,convertGeometry} from './coordinates.ts';
|
||||
import {unitScale,withUnitScale} from './units.ts';
|
||||
import {copySlots} from './copy-paths.ts';
|
||||
import {repairConnectionOwners} from './structure.ts';
|
||||
import {clone,equal,fault,freeze} from './values.ts';
|
||||
|
||||
/** Remove an owning canvas, retaining its entry and promoting only direct members. */
|
||||
export function planDissolveCanvas(document:Document,registry:Registry,canvasId:Id,entryTarget:Target):{readonly changes:readonly Change[];readonly commands:readonly Command[]}{
|
||||
const canvas=document.canvases.get(canvasId);if(!canvas)throw fault('canvas_not_loaded',[canvasId]);
|
||||
if(!canvas.parent)throw fault('cannot_dissolve_root',[canvasId]);
|
||||
if(canvas.parent.entryElementId!==entryTarget.id)throw fault('canvas_entry_changed',[canvasId]);
|
||||
targetElement(document,entryTarget);
|
||||
const parent=canvas.parent.canvasId,from=canvasTransform(document,canvasId),to=canvasTransform(document,parent),changes:Change[]=[],commands:Command[]=[];
|
||||
for(const element of document.elements.values())if(element.ownerCanvasId===canvasId&&element.structure==='node'){
|
||||
if(!registry.supports(element,'geometry.move'))throw fault('unsupported_capability',[element.id,'geometry.move']);
|
||||
const target={id:element.id,incarnation:element.incarnation},geometry=convertGeometry(element.geometry,from,to),presentation=withUnitScale(element.data.presentation,unitScale(element.data.presentation)*from.scale/to.scale);
|
||||
commands.push({type:'element.move',target,canvasId:parent});
|
||||
changes.push({kind:'field',target,field:'ownerCanvasId',before:canvasId,after:parent});
|
||||
if(!equal(geometry,element.geometry))changes.push({kind:'field',target,field:'geometry',before:element.geometry,after:geometry});
|
||||
if(!equal(presentation,element.data.presentation))changes.push({kind:'field',target,field:'data.presentation',before:element.data.presentation??null,beforePresent:Object.hasOwn(element.data,'presentation'),after:presentation??null,afterPresent:presentation!==undefined});
|
||||
}
|
||||
for(const child of document.canvases.values())if(child.parent?.canvasId===canvasId)changes.push({kind:'canvas',id:child.id,before:child,after:{...child,parent:{...child.parent,canvasId:parent}}});
|
||||
changes.push({kind:'canvas',id:canvasId,before:canvas,after:null});
|
||||
const promoted=applyChanges(document,changes);changes.push(...repairConnectionOwners(document,promoted));
|
||||
// Only declared canvas references have meaning here. Opaque payloads are retained.
|
||||
for(const element of promoted.elements.values()){
|
||||
if(registry.resolve(element)!=='known')continue;
|
||||
const references=registry.components.get(element.componentId)?.copy?.references.filter(reference=>reference.target==='canvas')??[];
|
||||
if(!references.length)continue;
|
||||
const data=clone(element.data) as Record<string,Json>;
|
||||
for(const reference of references)for(const slot of copySlots(data,reference.path)){
|
||||
const value=slot.parent[slot.key];if(value===null)continue;
|
||||
if(reference.many&&!Array.isArray(value))throw fault('invalid_copy_reference',[element.id]);
|
||||
const map=(value:Json):Json=>{if(typeof value!=='string'||!value)throw fault('invalid_copy_reference',[element.id]);return value===canvasId?parent:value;};
|
||||
slot.parent[slot.key]=reference.many?(value as Json[]).map(map):map(value);
|
||||
}
|
||||
for(const key of Object.keys(data))if(!equal(data[key],element.data[key])){
|
||||
if(!registry.supports(element,'content.edit'))throw fault('unsupported_capability',[element.id,'content.edit']);
|
||||
changes.push({kind:'field',target:{id:element.id,incarnation:element.incarnation},field:`data.${key}`,before:element.data[key],after:data[key]});
|
||||
commands.push({type:'element.data',target:{id:element.id,incarnation:element.incarnation},patch:{[key]:data[key]}});
|
||||
}
|
||||
}
|
||||
return freeze({changes,commands});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {createDocumentQueries,advanceDocumentQueries,withQueryCoverage,type Quer
|
||||
import { canvasTransform, convertGeometry } from './coordinates.ts';
|
||||
import { unitScale, withUnitScale } from './units.ts';
|
||||
import { removalChanges, repairConnectionOwners } from './structure.ts';
|
||||
import {planDissolveCanvas} from './dissolve.ts';
|
||||
import { planHistoryChanges } from './history.ts';
|
||||
import { describeHistoryTransition } from './history-transition.ts';
|
||||
import { planCreationSize, validateCreationSizeEffect } from './creation-size-plan.ts';
|
||||
@@ -61,6 +62,11 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
|
||||
if (!canvas) throw fault('canvas_not_loaded', [command.canvasId]);
|
||||
changes = equal(canvas.parent, command.parent) ? [] : [{ kind: 'canvas', id: canvas.id, before: canvas, after: { ...canvas, parent: command.parent } }];
|
||||
if(changes.length)changes=[...changes,...repairConnectionOwners(document,applyChanges(document,changes))];
|
||||
} else if(command.type==='canvas.dissolve'){
|
||||
const dissolved=planDissolveCanvas(document,options.registry,command.canvasId,command.entry);changes=dissolved.changes;
|
||||
for(const member of dissolved.commands){
|
||||
const allowed=options.policy?.(member,document);if(allowed&&!allowed.enabled)throw fault(allowed.code,allowed.targets);
|
||||
}
|
||||
} else if (command.type === 'element.create') {
|
||||
const element = command.element;
|
||||
if (document.elements.has(element.id) || document.tombstones.has(element.id)) throw fault('duplicate_identity', [element.id]);
|
||||
|
||||
@@ -29,6 +29,7 @@ export type Command =
|
||||
| { readonly type: 'element.move'; readonly target: Target; readonly canvasId: Id; readonly geometry?: Geometry }
|
||||
| { readonly type: 'canvas.create'; readonly canvas: CanvasRecord }
|
||||
| { readonly type: 'canvas.reparent'; readonly canvasId: Id; readonly parent: CanvasRecord['parent'] }
|
||||
| { readonly type: 'canvas.dissolve'; readonly canvasId: Id; readonly entry: Target }
|
||||
| { readonly type: 'changes.apply'; readonly changes: readonly Change[] };
|
||||
export type Origin = 'user' | 'undo' | 'redo' | 'remote' | 'ack' | 'replay' | 'asset-resolution' | 'migration';
|
||||
export type Rejected = { readonly status: 'rejected'; readonly code: string; readonly targets: readonly Id[] };
|
||||
|
||||
@@ -31,6 +31,7 @@ export function structuralCommand(command:Command,document:Document,registry:Reg
|
||||
if(command.type==='element.create'){const element=command.element;return born(state,element.id)&&!source.document.tombstones.has(element.id)&&ownerReady(state,source,document,element.ownerCanvasId)&®istry.resolve(element)==='known'&&safeData(element.data,registry,element.componentId,document,source.document,undefined,assets)?yes:deny;}
|
||||
if(command.type==='canvas.create'){const canvas=command.canvas,parent=canvas.parent;return !source.versions.has(canvas.id)&&!!parent&&born(state,parent.entryElementId)&&ownerReady(state,source,document,parent.canvasId)?yes:deny;}
|
||||
if(command.type==='canvas.reparent')return (!source.versions.has(command.canvasId)||sourceClosed(state,source))&&!!command.parent&&ownerReady(state,source,document,command.parent.canvasId)?yes:deny;
|
||||
if(command.type==='canvas.dissolve'){const canvas=document.canvases.get(command.canvasId);return sourceClosed(state,source)&&canvas?.parent&&ownerReady(state,source,document,canvas.id)&&ownerReady(state,source,document,canvas.parent.canvasId)?yes:deny;}
|
||||
if(command.type==='element.remove'||command.type==='element.move'){const element=document.elements.get(command.target.id);return element&&(born(state,element.id)||sourceClosed(state,source))&&ownerReady(state,source,document,element.ownerCanvasId)&&(command.type!=='element.move'||ownerReady(state,source,document,command.canvasId))?yes:deny;}
|
||||
if(command.type==='connection.endpoints'){const element=document.elements.get(command.target.id);return element&&ownerReady(state,source,document,element.ownerCanvasId)?yes:deny;}
|
||||
if(command.type==='element.data'&&(born(state,command.target.id)||sourceClosed(state,source)||state.version===6&&assetPatch(command.patch,registry,document.elements.get(command.target.id)?.componentId??'')&&ownerReady(state,source,document,document.elements.get(command.target.id)?.ownerCanvasId??''))){const element=document.elements.get(command.target.id);return element&®istry.resolve(element)==='known'&&safeData({...element.data,...command.patch},registry,element.componentId,document,source.document,element.data,assets)?yes:deny;}
|
||||
|
||||
Reference in New Issue
Block a user