From f741a4830f1ed5184244f8db81ce8ec8b0ae3f4a Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sun, 8 Nov 2026 02:54:36 +0800 Subject: [PATCH] Preserve visual units across scaled canvas and container moves --- .../canvas/headless-recovery-dialog.tsx | 2 +- src/lib/canvas-bindings/projection.ts | 3 +- src/lib/canvas-core/coordinates.ts | 14 ++-- src/lib/canvas-core/editor.ts | 4 +- src/lib/canvas-core/features.ts | 5 +- src/lib/canvas-core/index.ts | 1 + src/lib/canvas-core/units.ts | 30 ++++++++ src/lib/canvas-runtime/change-rebase.ts | 56 ++++++++++++--- tests/headless/browser-storage-cases.mjs | 15 +++- tests/headless/renderer.integration.mjs | 9 +-- tests/headless/structure-recovery.test.mjs | 70 ++++++++++++++++++- 11 files changed, 182 insertions(+), 27 deletions(-) create mode 100644 src/lib/canvas-core/units.ts diff --git a/src/components/canvas/headless-recovery-dialog.tsx b/src/components/canvas/headless-recovery-dialog.tsx index a27862e..c718679 100644 --- a/src/components/canvas/headless-recovery-dialog.tsx +++ b/src/components/canvas/headless-recovery-dialog.tsx @@ -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 '生成任务需要重新手动发起。可以在这里放弃尚未执行的请求。'; diff --git a/src/lib/canvas-bindings/projection.ts b/src/lib/canvas-bindings/projection.ts index 2b3ce11..6f3cbf3 100644 --- a/src/lib/canvas-bindings/projection.ts +++ b/src/lib/canvas-bindings/projection.ts @@ -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) { diff --git a/src/lib/canvas-core/coordinates.ts b/src/lib/canvas-core/coordinates.ts index 2b6e37f..bc419e8 100644 --- a/src/lib/canvas-core/coordinates.ts +++ b/src/lib/canvas-core/coordinates.ts @@ -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); } diff --git a/src/lib/canvas-core/editor.ts b/src/lib/canvas-core/editor.ts index 16f6e9a..dbffd9a 100644 --- a/src/lib/canvas-core/editor.ts +++ b/src/lib/canvas-core/editor.ts @@ -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 = { @@ -74,7 +75,8 @@ export function createEditor(options: EditorOptionscanvas.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}]; } diff --git a/src/lib/canvas-core/features.ts b/src/lib/canvas-core/features.ts index 8f0d389..051cefb 100644 --- a/src/lib/canvas-core/features.ts +++ b/src/lib/canvas-core/features.ts @@ -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; 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, diff --git a/src/lib/canvas-core/index.ts b/src/lib/canvas-core/index.ts index eba5e08..6e90f67 100644 --- a/src/lib/canvas-core/index.ts +++ b/src/lib/canvas-core/index.ts @@ -7,3 +7,4 @@ export * from './editor.ts'; export * from './gestures.ts'; export * from './coordinates.ts'; export * from './structure.ts'; +export * from './units.ts'; diff --git a/src/lib/canvas-core/units.ts b/src/lib/canvas-core/units.ts new file mode 100644 index 0000000..75c0414 --- /dev/null +++ b/src/lib/canvas-core/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> { + 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; +} diff --git a/src/lib/canvas-runtime/change-rebase.ts b/src/lib/canvas-runtime/change-rebase.ts index a154191..1d4cd1b 100644 --- a/src/lib/canvas-runtime/change-rebase.ts +++ b/src/lib/canvas-runtime/change-rebase.ts @@ -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; readonly after:ReadonlyMap; readonly transforms:ReadonlyMap }; +export type IntentContext = { readonly before:ReadonlyMap; readonly after:ReadonlyMap; readonly transforms:ReadonlyMap; readonly beforeTransforms:ReadonlyMap }; /** Reconstruct only immutable intention metadata. This mutable cursor is never published as a document. */ export function intentContexts(document:Document,intents:readonly OperationIntent[]):ReadonlyMap { const elements=new Map(document.elements),canvases=new Map(document.canvases),contexts=new Map(); @@ -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();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,originalBefore:Extract,originalAfter:Extract,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(),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}); diff --git a/tests/headless/browser-storage-cases.mjs b/tests/headless/browser-storage-cases.mjs index 8a3f7bd..b83e10f 100644 --- a/tests/headless/browser-storage-cases.mjs +++ b/tests/headless/browser-storage-cases.mjs @@ -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); } } diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index ff03709..8780855 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -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))); diff --git a/tests/headless/structure-recovery.test.mjs b/tests/headless/structure-recovery.test.mjs index a909db8..b5734b8 100644 --- a/tests/headless/structure-recovery.test.mjs +++ b/tests/headless/structure-recovery.test.mjs @@ -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); +});