Preserve visual units across scaled canvas and container moves

This commit is contained in:
2026-11-08 02:54:36 +08:00
parent d702240240
commit f741a4830f
11 changed files with 182 additions and 27 deletions
@@ -19,7 +19,7 @@ function message(error:unknown){
if(code==='ownership_conflict')return '元素已移入其它分区,当前坐标需要重新确认,暂时无法直接保留。';
if(code==='recovery_identity_collision')return '这个身份已在服务端使用,不能覆盖另一份内容。可以放弃本地创建,或重新创建一个元素。';
if(code==='stale_endpoint')return '连线所指元素已删除或进入新的生命周期,请重新选择端点。';
if(code==='container_scale_conversion_required')return '这个分区包含子内容,跨不同缩放空间的整体转换尚未支持,原内容仍已保留。';
if(code==='unsupported_presentation_units'||code==='invalid_presentation_units')return '这个元素的展示单位暂时无法转换,原内容已保留,请确认展示格式后重试。';
if(code==='unsupported_coordinate_path')return '这条曲线使用未知坐标格式,当前无法安全转换归属,原路径仍已保留。';
if(code?.startsWith('recovery_history')||code==='recovery_record_replacement_not_supported')return '这批修改含结构或撤销操作,暂时无法自动处理。原内容和历史均已保留。';
if(code==='recovery_action_requires_new_command')return '生成任务需要重新手动发起。可以在这里放弃尚未执行的请求。';
+2 -1
View File
@@ -1,5 +1,6 @@
import type { Document, ElementRecord, Geometry, Id, Json, Point, Registry } from '../canvas-core/types.ts';
import { connectionPathValid } from '../canvas-core/features.ts';
import { unitScale } from '../canvas-core/units.ts';
import { fault } from '../canvas-core/values.ts';
import type { CanvasNode } from '../canvas-elements.ts';
import type { CanvasEdgeModel, EdgeData } from '../canvas-v2.ts';
@@ -28,7 +29,7 @@ export function projectCanvas(document: Document, canvasId: Id, registry: Regist
const child = childByEntry.get(element.id), data = element.data;
const projected: ProjectionNode = { id: element.id, type, position: { x: center.x - width / 2, y: center.y - height / 2 }, width, height, selected: selected.has(element.id), draggable: known && registry.supports(element, 'geometry.move'), zIndex: element.componentId === 'frame' ? depth - 100 : depth * 2,
style: { width, height, ...(element.componentId === 'frame' ? { pointerEvents: 'none' as const } : {}) },
data: { ...data, kind: element.componentId as CanvasNode['data']['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: typeof data.text === 'string' ? data.text : '', rotation: (g.rotation + transform.rotation) % 360, viewScale: transform.scale, ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNode['data'],
data: { ...data, kind: element.componentId as CanvasNode['data']['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: typeof data.text === 'string' ? data.text : '', rotation: (g.rotation + transform.rotation) % 360, viewScale: transform.scale*(known?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNode['data'],
};
nodes.push(projected); visible.add(element.id);
if (child) {
+8 -6
View File
@@ -1,8 +1,10 @@
import type { Document, Geometry, Id, Json, Point } from './types.ts';
import { ancestors } from './document.ts';
import { equal, fault } from './values.ts';
import { fault } from './values.ts';
import { closeNumber, closeScale, unitScale } from './units.ts';
import { connectionPathValid } from './features.ts';
export type Transform = { x: number; y: number; scale: number; rotation: number };
export const sameTransform=(a:Transform,b:Transform)=>closeNumber(a.x,b.x)&&closeNumber(a.y,b.y)&&closeScale(a.scale,b.scale)&&Math.abs(((a.rotation-b.rotation+540)%360)-180)<1e-9;
const origin: Transform = { x: 0, y: 0, scale: 1, rotation: 0 };
const rotate = (point: Point, angle: number): Point => { const a = angle * Math.PI / 180; return { x: point.x * Math.cos(a) - point.y * Math.sin(a), y: point.x * Math.sin(a) + point.y * Math.cos(a) }; };
export function toWorld(transform: Transform, point: Point): Point { const value = rotate(point, transform.rotation); return { x: transform.x + value.x * transform.scale, y: transform.y + value.y * transform.scale }; }
@@ -23,10 +25,10 @@ export function transformPath(path: Json, transform: Transform, inverse = false)
/** Format-1 child content origin: the same inset, scale and rotation used by renderer projection. */
export function childCanvasTransform(parent:Transform,geometry:Geometry,presentation:Json|undefined):Transform {
const data=object(presentation),scale=typeof data.contentScale==='number'&&data.contentScale>0?data.contentScale:1;
const offset=rotate({x:24-geometry.width/2,y:36-geometry.height/2},geometry.rotation);
const data=object(presentation),units=unitScale(presentation),scale=(typeof data.contentScale==='number'&&data.contentScale>0?data.contentScale:1)*units;
const offset=rotate({x:24*units-geometry.width/2,y:36*units-geometry.height/2},geometry.rotation);
const start=toWorld(parent,{x:geometry.x+geometry.width/2+offset.x,y:geometry.y+geometry.height/2+offset.y});
return{...start,scale:parent.scale*scale,rotation:parent.rotation+geometry.rotation};
return{...start,scale:parent.scale*scale,rotation:((parent.rotation+geometry.rotation)%360+360)%360};
}
export function canvasTransform(document:Document,canvasId:Id):Transform {
let result:Transform={...origin};
@@ -38,12 +40,12 @@ export function canvasTransform(document:Document,canvasId:Id):Transform {
return result;
}
export function convertGeometry(geometry:Geometry,from:Transform,to:Transform):Geometry {
if(equal(from,to))return geometry;
if(sameTransform(from,to))return geometry;
const center=toLocal(to,toWorld(from,{x:geometry.x+geometry.width/2,y:geometry.y+geometry.height/2})),ratio=from.scale/to.scale,width=geometry.width*ratio,height=geometry.height*ratio;
return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((geometry.rotation+from.rotation-to.rotation)%360+360)%360};
}
export function convertPath(path:Json,from:Transform,to:Transform):Json {
if(path===null||equal(from,to))return path;
if(path===null||sameTransform(from,to))return path;
if(!connectionPathValid(path))throw fault('unsupported_coordinate_path');
return transformPath(transformPath(path,from),to,true);
}
+3 -1
View File
@@ -3,6 +3,7 @@ import { applyChanges, createDocument, invert, targetElement, validateDocument }
import { clone, equal, fault, freeze, readMap, reject, validJson } from './values.ts';
import { canvasTransform, convertGeometry } from './coordinates.ts';
import { unitScale, withUnitScale } from './units.ts';
import { removalChanges, repairConnectionOwners } from './structure.ts';
export type EditorOptions<Runtime = undefined> = {
@@ -74,7 +75,8 @@ export function createEditor<Runtime = undefined>(options: EditorOptions<Runtime
if (!options.registry.supports(element, 'geometry.move')) throw fault('unsupported_capability', [element.id]);
changes = [{ kind: 'field', target: command.target, field: 'ownerCanvasId', before: element.ownerCanvasId, after: command.canvasId }];
if(element.structure==='node'){
if([...document.canvases.values()].some(canvas=>canvas.parent?.entryElementId===element.id)&&canvasTransform(document,element.ownerCanvasId).scale!==canvasTransform(document,command.canvasId).scale)throw fault('container_scale_conversion_required',[element.id]);
const source=canvasTransform(document,element.ownerCanvasId),destination=canvasTransform(document,command.canvasId),presentation=withUnitScale(element.data.presentation,unitScale(element.data.presentation)*source.scale/destination.scale);
if(!equal(presentation,element.data.presentation))changes=[...changes,{kind:'field',target:command.target,field:'data.presentation',before:element.data.presentation??null,beforePresent:Object.hasOwn(element.data,'presentation'),after:presentation??null,afterPresent:presentation!==undefined}];
const geometry=command.geometry??(element.ownerCanvasId===command.canvasId?element.geometry:convertGeometry(element.geometry,canvasTransform(document,element.ownerCanvasId),canvasTransform(document,command.canvasId)));
if(!equal(geometry,element.geometry))changes=[...changes,{kind:'field',target:command.target,field:'geometry',before:element.geometry,after:geometry}];
}
+3 -2
View File
@@ -1,5 +1,6 @@
import type { CapabilityHandler, ComponentDefinition, Data, FieldChange, Geometry, Json } from './types.ts';
import { defineComponent, defineFeature } from './registry.ts';
import { presentationUnitsValid, unitScale } from './units.ts';
import { equal, fault, validJson } from './values.ts';
const geometryHandler = (allowed: readonly (keyof Geometry)[], constrain?: (geometry: Geometry) => Geometry): CapabilityHandler => ({ element, input }) => {
@@ -18,7 +19,7 @@ export const resizable = (inputOptions: { min?: number; max?: number; aspect?: b
if (element.structure !== 'node') throw fault('unsupported_capability', [element.id]);
const patch = input.patch as Partial<Geometry>;
return geometryHandler(['width', 'height'], geometry => {
const min = options.min ?? 1, max = options.max ?? 100000;
const units=unitScale(element.data.presentation),min=(options.min??1)*units,max=(options.max??100000)*units;
let width = Math.min(max, Math.max(min, geometry.width)), height = Math.min(max, Math.max(min, geometry.height));
if (options.aspect) {
const ratio = element.geometry.width / element.geometry.height;
@@ -41,7 +42,7 @@ export const editable = () => defineFeature('content.edit', {
});
},
});
const textData = (data: Data) => validJson(data) && typeof data.title === 'string' && typeof data.text === 'string';
const textData = (data: Data) => presentationUnitsValid(data.presentation) && validJson(data) && typeof data.title === 'string' && typeof data.text === 'string';
export function builtinComponents(): readonly ComponentDefinition[] {
return ['text', 'image', 'script', 'plain-text', 'plain-image', 'icon', 'frame'].map(id => defineComponent({
id, dataVersion: 1, structure: 'node', validate: textData,
+1
View File
@@ -7,3 +7,4 @@ export * from './editor.ts';
export * from './gestures.ts';
export * from './coordinates.ts';
export * from './structure.ts';
export * from './units.ts';
+30
View File
@@ -0,0 +1,30 @@
import type { Json } from './types.ts';
import { fault } from './values.ts';
export type PresentationUnits = { readonly unitScale?: number };
/** Reserved presentation metadata, independent of canvas ownership. Absence means one visual unit per owner unit. */
export function presentationUnitsValid(value:Json|undefined):boolean {
if(!value||typeof value!=='object'||Array.isArray(value))return true;
return value.unitScale===undefined||typeof value.unitScale==='number'&&Number.isFinite(value.unitScale)&&value.unitScale>0;
}
export function unitScale(value:Json|undefined):number {
if(!presentationUnitsValid(value))throw fault('invalid_presentation_units');
return value&&typeof value==='object'&&!Array.isArray(value)&&typeof value.unitScale==='number'?value.unitScale:1;
}
export const closeNumber=(a:number,b:number)=>Math.abs(a-b)<=Number.EPSILON*64*Math.max(1,Math.abs(a),Math.abs(b));
export const closeScale=(a:number,b:number)=>Math.abs(a-b)<=Number.EPSILON*64*Math.max(Number.MIN_VALUE,Math.abs(a),Math.abs(b));
export function presentationRecord(value:Json|undefined):Readonly<Record<string,Json>> {
if(value===undefined||value===null)return{};
if(typeof value!=='object'||Array.isArray(value))throw fault('unsupported_presentation_units');
unitScale(value);return value;
}
/** Normalize only the framework-owned unit field; all other presentation properties survive. */
export function withUnitScale(value:Json|undefined,scale:number):Json|undefined {
if(!Number.isFinite(scale)||scale<=0)throw fault('invalid_presentation_units');
const normalized=closeNumber(scale,1)?1:scale;
if(closeScale(unitScale(value),normalized))return value;
const fields=new Map(Object.entries(presentationRecord(value)));
if(normalized===1)fields.delete('unitScale');else fields.set('unitScale',normalized);
return fields.size?Object.fromEntries(fields):undefined;
}
+48 -8
View File
@@ -1,10 +1,11 @@
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> };
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>();
@@ -26,11 +27,38 @@ export function intentContexts(document:Document,intents:readonly OperationInten
else elements.set(current.id,freeze({...current,[change.field]:change.before}) as ElementRecord);
}
}
contexts.set(intent.id,Object.freeze({before:readMap(new Map([...ids].flatMap(id=>elements.has(id)?[[id,elements.get(id)!] as const]:[]))),after:readMap(after),transforms:readMap(transforms)}));
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};
}
/** 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[]=[];
@@ -45,7 +73,7 @@ export function rebaseIntent(document:Document,intent:OperationIntent,context:In
if(current||document.tombstones.has(original.id))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]);after={...after,geometry:convertGeometry(after.geometry,basis,canvasTransform(document,after.ownerCanvasId))};}
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]);
@@ -74,14 +102,24 @@ export function rebaseIntent(document:Document,intent:OperationIntent,context:In
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]);
if([...document.canvases.values()].some(canvas=>canvas.parent?.entryElementId===current.id)&&canvasTransform(document,current.ownerCanvasId).scale!==canvasTransform(document,original.after).scale)throw fault('container_scale_conversion_required',[current.id]);
const geometry=convertGeometry(afterContext.geometry,basis,canvasTransform(document,original.after));
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}}});
current=document.elements.get(current.id)!;if(current.structure==='node'&&!equal(current.geometry,geometry))apply({kind:'field',target:original.target,field:'geometry',before:current.geometry,after:geometry});
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'&&moved.has(current.id))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);
@@ -104,7 +142,9 @@ export function rebaseIntent(document:Document,intent:OperationIntent,context:In
}
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 owner=commonCanvas(document,document.elements.get(edge.source.elementId)!.ownerCanvasId,document.elements.get(edge.target.elementId)!.ownerCanvasId),basis=context.transforms.get(intended.ownerCanvasId);if(!basis)throw fault('recovery_coordinate_context_missing',[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});