Preserve visual units across scaled canvas and container moves
This commit is contained in:
@@ -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 '生成任务需要重新手动发起。可以在这里放弃尚未执行的请求。';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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}];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from './editor.ts';
|
||||
export * from './gestures.ts';
|
||||
export * from './coordinates.ts';
|
||||
export * from './structure.ts';
|
||||
export * from './units.ts';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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});
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { createBrowserStorage } from '../../src/lib/canvas-runtime/storage.ts';
|
||||
import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
|
||||
import { createDocument, createRegistry, builtinComponents, applyChanges } from '../../src/lib/canvas-core/index.ts';
|
||||
import { createDocument, createRegistry, builtinComponents, applyChanges, canvasTransform } from '../../src/lib/canvas-core/index.ts';
|
||||
import { createFoundryRuns } from '../../src/lib/canvas-service/runs.ts';
|
||||
import { createFoundryAssets } from '../../src/lib/canvas-service/assets.ts';
|
||||
import { createFoundryPersistence } from '../../src/lib/canvas-service/foundry.ts';
|
||||
|
||||
async function scaledContainerCase(name,assert){
|
||||
const root=crypto.randomUUID(),child=crypto.randomUUID(),target=crypto.randomUUID(),deep=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents());
|
||||
const node=(id,owner,kind='text',scale=1)=>({id,incarnation:1,structure:'node',componentId:kind,dataVersion:1,ownerCanvasId:owner,geometry:{x:id==='g'?700:100,y:80,width:300,height:200,rotation:id==='g'?70:30},data:{title:id,text:'body',fontSize:17,...(kind==='frame'?{presentation:{contentScale:scale}}:{})}});
|
||||
const original=createDocument(root,[{id:root,parent:null},{id:child,parent:{canvasId:root,entryElementId:'f'}},{id:target,parent:{canvasId:root,entryElementId:'g'}},{id:deep,parent:{canvasId:child,entryElementId:'h'}}],[node('f',root,'frame',.75),node('g',root,'frame',2.5),node('h',child,'frame',.8),node('a',deep)]),storage=createBrowserStorage({name:`${name}-units`}),calls=[];let document=original,versions=new Map([...original.canvases.keys()].map(id=>[id,1])),engine;
|
||||
const transport=async request=>{calls.push(request);const body=JSON.parse(request.body);for(const[id,version]of Object.entries(body.expected_versions))assert(version===versions.get(id),'scaled move lost version ordering');for(const operation of body.operations)document=applyChanges(document,operation.changes);const scopes=Object.keys(body.expected_versions);for(const id of scopes)versions.set(id,versions.get(id)+1);return{status:200,body:{protocol_version:2,request_id:request.requestId,status:'applied',operation_ids:body.operations.map(operation=>operation.id),model:{documentFormatVersion:1,projectId:root,complete:false,removedCanvasIds:[],canvases:[...document.canvases.values()].filter(canvas=>scopes.includes(canvas.id)),elements:[...document.elements.values()].filter(element=>scopes.includes(element.ownerCanvasId)),tombstones:{},versions:Object.fromEntries(scopes.map(id=>[id,versions.get(id)]))}}};};
|
||||
const options={scope:{accountId:account,workspaceId:workspace,projectId:root},registry,document:original,versions:new Map(versions),storage,persistence:createFoundryPersistence({workspaceId:workspace,rootId:root,registry,transport})};
|
||||
const check=()=>{for(const id of [child,deep]){const a=canvasTransform(engine.getSnapshot().document,id),b=canvasTransform(original,id);for(const key of ['x','y','rotation','scale'])assert(Math.abs(a[key]-b[key])<1e-7,'descendant transform changed: '+key);}assert(engine.getSnapshot().document.elements.get('a').data.fontSize===17,'subtree typography data was rewritten');};
|
||||
try{engine=await openProjectEngine(options);assert(engine.editor.dispatch({type:'element.move',target:{id:'f',incarnation:1},canvasId:target}).status==='applied','scaled container move failed');await engine.settle();check();assert(Math.abs(engine.getSnapshot().document.elements.get('f').data.presentation.unitScale-.4)<1e-9,'entry unit ratio was not stored');await engine.close();engine=await openProjectEngine(options);check();assert(engine.editor.history.undo().status==='applied','unit-normalized undo failed');await engine.settle();check();assert(engine.getSnapshot().document.elements.get('f').data.presentation.unitScale===undefined,'undo did not restore absent units');assert(engine.editor.history.redo().status==='applied','unit-normalized redo failed');await engine.settle();check();assert(calls.filter(call=>call.method==='POST').length===3,'unit conversion scheduled duplicate writes');await engine.close();engine=await openProjectEngine(options);check();
|
||||
}finally{await engine?.close();storage.close();indexedDB.deleteDatabase(`${name}-units`);}
|
||||
}
|
||||
|
||||
async function structureRecoveryCase(name,assert){
|
||||
const root=crypto.randomUUID(),child=crypto.randomUUID(),workspace=crypto.randomUUID(),account=crypto.randomUUID(),registry=createRegistry(builtinComponents());
|
||||
const node=(id,owner=root)=>({id,incarnation:1,structure:'node',componentId:'text',dataVersion:1,ownerCanvasId:owner,geometry:{x:0,y:0,width:300,height:200,rotation:0},data:{title:id,text:''}}),original=createDocument(root,[{id:root,parent:null}],[node('a')]),storage=createBrowserStorage({name:`${name}-structure`}),calls=[];let document=original,versions=new Map([[root,1]]),reject=true,engine;
|
||||
@@ -296,6 +307,8 @@ export async function run() {
|
||||
results.push('a persisted successful receipt with failed coordination adopts a fresh base and replan in one cut, releases its writer and never repeats the original write');
|
||||
await structureRecoveryCase(name,assert);
|
||||
results.push('structural replan restores an atomic partition/member tree through IndexedDB, undo/archive and redo/new incarnation without changing the rejected request');
|
||||
await scaledContainerCase(name,assert);
|
||||
results.push('scaled container units preserve nested transforms and typography through atomic protocol writes, IndexedDB reopening, undo and redo');
|
||||
return results;
|
||||
} finally { await leader?.release(); other?.close(); storage.close(); indexedDB.deleteDatabase(name); }
|
||||
}
|
||||
|
||||
@@ -181,10 +181,11 @@ try {
|
||||
await evaluate(`(()=>{const input=document.querySelector('[aria-label="AI 指令"]');Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,'Summarize this content');input.dispatchEvent(new Event('input',{bubbles:true}));})()`);
|
||||
await until(`!document.querySelector('[aria-label="执行 AI"]').disabled`,'AI command did not become executable');await click('执行 AI');
|
||||
await until(`document.body.innerText.includes('已受理')`,'AI admission did not persist');assert.equal(aiRuns.size,1);
|
||||
const visualMetric=`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]'),frame=[...node.querySelectorAll('div')].find(element=>element.style.zoom);return {zoom:frame.style.zoom,width:frame.style.width,height:frame.style.height,font:getComputedStyle(node.querySelector('textarea')).fontSize}})()`,beforeVisual=await evaluate(visualMetric);
|
||||
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${other}"]').click()`);await until(`location.pathname.endsWith('${other}')&&document.querySelectorAll('.react-flow__node').length===0`,'AI view detach failed');
|
||||
const run=aiRuns.get(id(700)),beforeAI=documents.get(root),beforeNode=beforeAI.elements.get('b');
|
||||
const frame={...node('relocation-frame',root,30),componentId:'frame',geometry:{x:30,y:50,width:1000,height:600,rotation:0},data:{title:'Relocated partition',text:''}};
|
||||
const movedModel=applyChanges(beforeAI,[{kind:'element',id:frame.id,before:null,after:frame},{kind:'canvas',id:partition,before:null,after:{id:partition,parent:{canvasId:root,entryElementId:frame.id}}},{kind:'field',target:{id:'b',incarnation:1},field:'ownerCanvasId',before:root,after:partition},{kind:'field',target:{id:'b',incarnation:1},field:'geometry',before:beforeNode.geometry,after:{...beforeNode.geometry,x:beforeNode.geometry.x-54,y:beforeNode.geometry.y-86}}]);
|
||||
const frame={...node('relocation-frame',root,30),componentId:'frame',geometry:{x:30,y:50,width:1000,height:600,rotation:0},data:{title:'Relocated partition',text:'',presentation:{contentScale:2}}};
|
||||
const movedModel=applyChanges(beforeAI,[{kind:'element',id:frame.id,before:null,after:frame},{kind:'canvas',id:partition,before:null,after:{id:partition,parent:{canvasId:root,entryElementId:frame.id}}},{kind:'field',target:{id:'b',incarnation:1},field:'ownerCanvasId',before:root,after:partition},{kind:'field',target:{id:'b',incarnation:1},field:'geometry',before:beforeNode.geometry,after:{...beforeNode.geometry,x:(beforeNode.geometry.x-54)/2,y:(beforeNode.geometry.y-86)/2,width:beforeNode.geometry.width/2,height:beforeNode.geometry.height/2}},{kind:'field',target:{id:'b',incarnation:1},field:'data.presentation',before:beforeNode.data.presentation??null,beforePresent:Object.hasOwn(beforeNode.data,'presentation'),after:{...beforeNode.data.presentation,unitScale:.5},afterPresent:true}]);
|
||||
const generated=applyChanges(movedModel,[{kind:'field',target:{id:'b',incarnation:1},field:'data.text',before:beforeNode.data.text,after:'Generated while another canvas was open',beforePresent:true,afterPresent:true}]);documents.set(root,generated);versions.set(root,versions.get(root)+1);versions.set(partition,2);run.status='succeeded';
|
||||
run.events.push({seq:3,run_id:id(700),type:'step.output',step_id:'step-b',board_id:root,node_id:'b',output:{output_id:id(701),kind:'text',text:'Generated while another canvas was open'}},{seq:4,run_id:id(700),type:'step.applied',step_id:'step-b',board_id:partition,origin_board_id:root,canonical_root:root,node_id:'b',canvas_version:versions.get(partition),document_format_version:1,target_incarnation:1,model:model(generated,true)},{seq:5,run_id:id(700),type:'run.completed',status:'succeeded'});
|
||||
const pollsBefore=calls.filter(call=>call.path.includes('/ai/runs/')).length;
|
||||
@@ -194,8 +195,8 @@ try {
|
||||
await call('Page.reload');await until(`location.pathname.endsWith('${other}')&&!!document.querySelector('[data-headless-canvas]')`,'other project reload failed');
|
||||
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${root}"]').click()`);await until(`!!document.querySelector('.react-flow__node[data-id=b]')&&document.querySelector('.react-flow__node[data-id=b] textarea')?.value==='Generated while another canvas was open'`,'AI output did not survive reload');
|
||||
await selectB();await click('AI 创作');await until(`document.body.innerText.includes('已更新当前元素')`,'durable RunRecord did not recover');await click('关闭 AI 工具');
|
||||
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,1,'AI recovery invoked generation twice');assert.equal(documents.get(root).elements.get('b').geometry.width,300);assert.equal(documents.get(root).elements.get('b').ownerCanvasId,partition);assert.equal(await evaluate(`document.querySelectorAll('.react-flow__node[data-id=b]').length`),1);
|
||||
console.log('PASS actual AI panel follows a remote owner move into a new partition, polls after navigation, and restores one element/RunRecord after reload without re-execution');
|
||||
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,1,'AI recovery invoked generation twice');assert.equal(documents.get(root).elements.get('b').geometry.width,150);assert.equal(documents.get(root).elements.get('b').data.presentation.unitScale,.5);assert.deepEqual(await evaluate(visualMetric),beforeVisual,'normalized units changed actual rendered size or typography');assert.equal(documents.get(root).elements.get('b').ownerCanvasId,partition);assert.equal(await evaluate(`document.querySelectorAll('.react-flow__node[data-id=b]').length`),1);
|
||||
console.log('PASS actual AI panel follows a scaled owner move with stable rendered size/typography, polls after navigation, and restores one element/RunRecord without re-execution');
|
||||
await selectB();await click('AI 创作');await until(`!!document.querySelector('[aria-label="执行 AI"]')&&!document.querySelector('[aria-label="执行 AI"]').disabled`,'AI action did not reopen');await click('执行 AI');await until(`!!document.querySelector('[aria-label="取消生成"]')`,'cancel control did not appear');await click('取消生成');await until(`document.body.innerText.includes('已取消生成')`,'durable cancellation did not finish');assert.equal(documents.get(root).elements.get('b').data.text,'Generated while another canvas was open');
|
||||
await until(`!document.querySelector('[aria-label="执行 AI"]').disabled`,'cancelled run did not release execution');await click('执行 AI');
|
||||
for(let i=0;i<100&&!aiRuns.has(id(720));i++)await pause(20);assert.ok(aiRuns.has(id(720)));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createDocument,createRegistry,builtinComponents,applyChanges,validateDocument,createEditor,canvasTransform,toWorld,convertGeometry } from '../../src/lib/canvas-core/index.ts';
|
||||
import { createDocument,createRegistry,builtinComponents,applyChanges,validateDocument,createEditor,canvasTransform,toWorld,convertGeometry,defineComponent,movable,resizable,rotatable,unitScale,withUnitScale } from '../../src/lib/canvas-core/index.ts';
|
||||
import { projectCanvas } from '../../src/lib/canvas-bindings/projection.ts';
|
||||
import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
|
||||
import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
|
||||
const registry=createRegistry(builtinComponents()),geometry={x:100,y:60,width:100,height:80,rotation:0};
|
||||
@@ -90,10 +91,10 @@ test('moving an entry follows its current child binding rather than stealing its
|
||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().canvases.get('child').parent.entryElementId,'h');assert.equal(f.server().canvases.get('child').parent.canvasId,'root');assert.equal(f.server().canvases.get('new-child').parent.canvasId,'outer');await engine.close();
|
||||
});
|
||||
|
||||
test('unknown curve formats and container scale changes remain explicit, atomic errors',()=>{
|
||||
test('unknown curve formats remain atomic errors while scaled containers preserve their child transform',()=>{
|
||||
const g=node('g','root',{componentId:'frame',data:{title:'g',text:'',presentation:{contentScale:2}}}),document=createDocument('project',[...base().canvases.values(),{id:'outer',parent:{canvasId:'root',entryElementId:'g'}}],[...base().elements.values(),g,edge('e','a','c','root',{type:'future-path',keep:true})]),editor=createEditor({registry,document}),before=editor.getSnapshot();
|
||||
assert.equal(editor.dispatch({type:'element.move',target:target('a'),canvasId:'child'}).code,'unsupported_coordinate_path');assert.equal(editor.getSnapshot(),before);
|
||||
assert.equal(editor.dispatch({type:'element.move',target:target('f'),canvasId:'outer'}).code,'container_scale_conversion_required');assert.equal(editor.getSnapshot(),before);
|
||||
const originalTransform=canvasTransform(document,'child');assert.equal(editor.dispatch({type:'element.move',target:target('f'),canvasId:'outer'}).status,'applied');const currentTransform=canvasTransform(editor.getSnapshot().document,'child');for(const key of ['x','y','scale','rotation'])near(currentTransform[key],originalTransform[key]);assert.equal(editor.getSnapshot().document.elements.get('c'),before.document.elements.get('c'));
|
||||
});
|
||||
|
||||
test('reparenting preserves fresh unknown parent metadata instead of restoring an older envelope',async()=>{
|
||||
@@ -101,3 +102,66 @@ test('reparenting preserves fresh unknown parent metadata instead of restoring a
|
||||
engine.editor.dispatch({type:'canvas.reparent',canvasId:'child',parent:{canvasId:'root',entryElementId:'h'}});await engine.settle();f.remote([{kind:'canvas',id:'child',before:original.canvases.get('child'),after:{...original.canvases.get('child'),parent:{...original.canvases.get('child').parent,future:{keep:'remote'}}}}]);
|
||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().canvases.get('child').parent.entryElementId,'h');assert.deepEqual(f.server().canvases.get('child').parent.future,{keep:'remote'});await engine.close();
|
||||
});
|
||||
|
||||
function scaledTree(){
|
||||
const outer=node('g','root',{componentId:'frame',geometry:{x:700,y:250,width:800,height:700,rotation:75},data:{title:'Target',text:'',presentation:{contentScale:2.5}}}),nested=node('nested','child',{componentId:'frame',geometry:{x:20,y:30,width:250,height:180,rotation:340},data:{title:'Nested',text:'',presentation:{contentScale:.8,collapsed:true}}});
|
||||
const original=base(),f={...frame,data:{...frame.data,presentation:{contentScale:.6,future:{keep:'metadata'}}}},inside={...node('inside','child'),data:{title:'Inside',text:'Text',fontSize:19,presentation:{unitScale:.75}}};
|
||||
return createDocument('project',[...original.canvases.values(),{id:'target',parent:{canvasId:'root',entryElementId:'g'}},{id:'deep',parent:{canvasId:'child',entryElementId:'nested'}}],[...original.elements.values()].filter(element=>element.id!=='f').concat([f,outer,nested,inside,node('future','deep',{componentId:'future-node',dataVersion:9,data:{opaque:{keep:true}}}),node('q','target'),edge('cross','inside','q','root',path),edge('opaque','inside','nested','child',{type:'future-path',payload:{keep:7}})]));
|
||||
}
|
||||
const worldNode=(document,id)=>{const element=document.elements.get(id),transform=canvasTransform(document,element.ownerCanvasId),g=convertGeometry(element.geometry,transform,{x:0,y:0,scale:1,rotation:0});return{...g,visualScale:transform.scale*((element.data.presentation?.unitScale)??1)};};
|
||||
function nearNode(a,b){for(const key of ['x','y','width','height','rotation','visualScale'])near(a[key],b[key]);}
|
||||
|
||||
test('a scaled container move preserves nested world geometry, visual units, opaque children and all canvas identities',()=>{
|
||||
const original=scaledTree(),editor=createEditor({registry,document:original}),before=editor.getSnapshot();
|
||||
assert.equal(editor.dispatch({type:'element.move',target:target('f'),canvasId:'target'}).status,'applied');const after=editor.getSnapshot().document;
|
||||
for(const id of ['f','c','inside','nested','future'])nearNode(worldNode(after,id),worldNode(before.document,id));
|
||||
for(const id of ['child','deep']){const a=canvasTransform(after,id),b=canvasTransform(before.document,id);for(const key of ['x','y','scale','rotation'])near(a[key],b[key]);}
|
||||
for(const id of ['c','inside','nested','future','opaque'])assert.equal(after.elements.get(id),before.document.elements.get(id));
|
||||
near(after.elements.get('f').data.presentation.unitScale,.4);assert.equal(after.elements.get('f').data.presentation.contentScale,.6);assert.equal(after.elements.get('cross').ownerCanvasId,'target');
|
||||
assert.equal(editor.history.undo().status,'applied');assert.deepEqual(editor.getSnapshot().document.elements.get('f').data,before.document.elements.get('f').data);assert.equal(editor.history.redo().status,'applied');nearNode(worldNode(editor.getSnapshot().document,'inside'),worldNode(before.document,'inside'));
|
||||
});
|
||||
|
||||
test('container recovery preserves fresh size, typography, overview, opacity and remote curve edits while changing units',async()=>{
|
||||
const original=scaledTree(),f=fixture(original),engine=await f.open();engine.editor.dispatch({type:'element.move',target:target('f'),canvasId:'target'});await engine.settle();const oldFrame=original.elements.get('f'),oldTarget=original.elements.get('g'),remotePath={...path,knots:[{...path.knots[0],position:{x:320,y:170}}]};
|
||||
f.remote([{kind:'field',target:target('f'),field:'geometry',before:oldFrame.geometry,after:{...oldFrame.geometry,width:700}},{kind:'field',target:target('f'),field:'data.presentation',before:oldFrame.data.presentation,after:{...oldFrame.data.presentation,contentScale:.9,collapsed:true,future:{keep:'fresh'}}},{kind:'field',target:target('g'),field:'data.presentation',before:oldTarget.data.presentation,after:{contentScale:4}},{kind:'field',target:target('inside'),field:'data.fontSize',before:19,after:27,beforePresent:true,afterPresent:true},{kind:'field',target:target('cross'),field:'path',before:path,after:remotePath}]);
|
||||
const fresh=f.server(),session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();const after=f.server();
|
||||
for(const id of ['f','inside','nested','future'])nearNode(worldNode(after,id),worldNode(fresh,id));
|
||||
near(after.elements.get('f').data.presentation.unitScale,.25);assert.equal(after.elements.get('f').data.presentation.contentScale,.9);assert.deepEqual(after.elements.get('f').data.presentation.future,{keep:'fresh'});assert.equal(after.elements.get('inside').data.fontSize,27);assert.equal(after.elements.get('opaque'),fresh.elements.get('opaque'));
|
||||
const {transformPath}=await import('../../src/lib/canvas-core/coordinates.ts'),actual=transformPath(after.elements.get('cross').path,canvasTransform(after,'target'));near(actual.knots[0].position.x,320);near(actual.knots[0].position.y,170);
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('f').ownerCanvasId,'root');assert.equal(f.server().elements.get('f').geometry.width,700);assert.equal(f.server().elements.get('inside').data.fontSize,27);await engine.close();
|
||||
});
|
||||
|
||||
test('an explicit end position changes the container pose while preserving intrinsic text size and descendant units',()=>{
|
||||
const document=scaledTree(),editor=createEditor({registry,document}),source=document.elements.get('f'),destination=canvasTransform(document,'target'),desired=convertGeometry(source.geometry,canvasTransform(document,'root'),destination);
|
||||
desired.x+=50;assert.equal(editor.dispatch({type:'element.move',target:target('f'),canvasId:'target',geometry:desired}).status,'applied');const after=editor.getSnapshot().document,a=worldNode(document,'inside'),b=worldNode(after,'inside');near(a.width,b.width);near(a.height,b.height);near(a.visualScale,b.visualScale);assert.ok(Math.abs(a.x-b.x)>1);assert.equal(after.elements.get('inside').data.fontSize,19);
|
||||
});
|
||||
|
||||
test('ordinary nodes keep visual style scale across moves, and resize bounds use intrinsic visual units',()=>{
|
||||
const document=scaledTree(),editor=createEditor({registry,document}),before=projectCanvas(editor.getSnapshot().document,'root',registry,new Set(),()=>undefined).nodes.find(node=>node.id==='a');
|
||||
editor.dispatch({type:'element.move',target:target('a'),canvasId:'target'});const current=editor.getSnapshot().document,after=projectCanvas(current,'root',registry,new Set(),()=>undefined).nodes.find(node=>node.id==='a');
|
||||
near(before.data.viewScale,after.data.viewScale);near(before.width,after.width);near(current.elements.get('a').data.presentation.unitScale,.4);
|
||||
const definition=defineComponent({id:'box',dataVersion:1,structure:'node',features:[movable(),resizable({min:80,max:400}),rotatable()],validate:data=>typeof data.title==='string'}),custom=createRegistry([...builtinComponents(),definition]);
|
||||
const boxes=createDocument('project',[...document.canvases.values()],[...document.elements.values(),node('box','root',{componentId:'box'})]),box=createEditor({registry:custom,document:boxes});box.dispatch({type:'element.move',target:target('box'),canvasId:'target'});assert.equal(box.dispatch({type:'element.geometry',target:target('box'),patch:{width:1}}).status,'applied');near(box.getSnapshot().document.elements.get('box').geometry.width,32);
|
||||
});
|
||||
|
||||
test('later presentation edits retain rebased units and fresh properties after a pending move',async()=>{
|
||||
const original=scaledTree(),f=fixture(original),engine=await f.open();engine.editor.dispatch({type:'element.move',target:target('f'),canvasId:'target'});await engine.settle();const moved=engine.getSnapshot().document.elements.get('f');engine.editor.dispatch({type:'element.data',target:target('f'),patch:{presentation:{...moved.data.presentation,collapsed:true}}});await engine.settle();
|
||||
const g=original.elements.get('g'),frame=original.elements.get('f');f.remote([{kind:'field',target:target('g'),field:'data.presentation',before:g.data.presentation,after:{contentScale:4}},{kind:'field',target:target('f'),field:'data.presentation',before:frame.data.presentation,after:{...frame.data.presentation,contentScale:.9,remoteFlag:'keep'}}]);
|
||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();const result=f.server().elements.get('f').data.presentation;near(result.unitScale,.25);assert.equal(result.contentScale,.9);assert.equal(result.remoteFlag,'keep');assert.equal(result.collapsed,true);
|
||||
assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();near(f.server().elements.get('f').data.presentation.unitScale,.25);assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(f.server().elements.get('f').data.presentation.unitScale,undefined);assert.equal(f.server().elements.get('f').data.presentation.remoteFlag,'keep');await engine.close();
|
||||
});
|
||||
|
||||
test('a creation under a changed parent basis retains its intended physical geometry and typography',async()=>{
|
||||
const original=scaledTree(),f=fixture(original),engine=await f.open();engine.editor.dispatch({type:'element.create',element:node('new','child',{data:{title:'New',text:'Body',fontSize:25}})});await engine.settle();const desired=worldNode(engine.getSnapshot().document,'new'),frame=original.elements.get('f');
|
||||
f.remote([{kind:'field',target:target('f'),field:'data.presentation',before:frame.data.presentation,after:{...frame.data.presentation,contentScale:1.2}}]);const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();nearNode(worldNode(f.server(),'new'),desired);assert.equal(f.server().elements.get('new').data.fontSize,25);near(f.server().elements.get('new').data.presentation.unitScale,.5);await engine.close();
|
||||
});
|
||||
|
||||
test('explicit presentation changes in a move survive unit conversion without dropping fresh unknown properties',async()=>{
|
||||
const original=scaledTree(),f=fixture(original),engine=await f.open();engine.editor.transaction(tx=>{tx.dispatch({type:'element.move',target:target('f'),canvasId:'target'});const moved=tx.getDocument().elements.get('f');tx.dispatch({type:'element.data',target:target('f'),patch:{presentation:{...moved.data.presentation,contentScale:.8,collapsed:false}}});});await engine.settle();const frame=original.elements.get('f');f.remote([{kind:'field',target:target('f'),field:'data.presentation',before:frame.data.presentation,after:{...frame.data.presentation,contentScale:.9,remoteFlag:'keep'}}]);
|
||||
const session=await engine.prepareRecovery(),plan=session.plan(choices(engine));f.reject=false;await session.resolve(plan);await engine.settle();assert.equal(f.server().elements.get('f').data.presentation.contentScale,.8);assert.equal(f.server().elements.get('f').data.presentation.remoteFlag,'keep');near(f.server().elements.get('f').data.presentation.unitScale,.4);await engine.close();
|
||||
});
|
||||
|
||||
test('unit metadata has a positive finite schema and tiny valid scales do not disappear during normalization',()=>{
|
||||
const editor=createEditor({registry,document:base()}),before=editor.getSnapshot();for(const value of [0,-1,Infinity,NaN,'invalid']){assert.equal(editor.dispatch({type:'element.data',target:target('a'),patch:{presentation:{unitScale:value}}}).status,'rejected');assert.equal(editor.getSnapshot(),before);}
|
||||
const next=withUnitScale({unitScale:1e-20,future:{keep:true}},1e-30);assert.equal(unitScale(next),1e-30);assert.deepEqual(next.future,{keep:true});assert.equal(withUnitScale({unitScale:.5},1),undefined);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user