diff --git a/headless/README.md b/headless/README.md index a26eb85..d99e015 100644 --- a/headless/README.md +++ b/headless/README.md @@ -602,3 +602,18 @@ attached view with the same ID. Explicit canvas navigation and detach publish th new view set together with cancellation. Session disposal fences admission before notifying, clears all views/previews, and is idempotent. These transient actions create no document/history/ledger entries and change no recovery format. + +Gesture admission also checks the target owner's visible ancestry relative to the +owning view's canvas. A collapsed or opaque ancestor is `target_hidden`; an owner +outside that view's subtree is `target_outside_view`. Node and path gestures use +the same rule. The temporary owner-to-chain Map shares work for multi-selection; +it lives only during admission or a document publication, with no project scan. + +An active gesture cancels with `coordinate_context_changed` when an entry in that +relative chain changes geometry, normalized content/unit scale, identity, or +parent relation, or becomes hidden. Entry titles and unrelated style fields do +not affect the chain. Ancestors above an independently opened child view are +excluded. Target component/version changes cancel with `target_changed`; removal +of the view canvas publishes fallback navigation and `canvas_changed` together. +Late updates/commit cannot use the canceled preview. Failed admission does not +replace the accepted baseline, and no chain, preview or gesture enters recovery. diff --git a/src/lib/canvas-core/gesture-placement.ts b/src/lib/canvas-core/gesture-placement.ts new file mode 100644 index 0000000..72b6653 --- /dev/null +++ b/src/lib/canvas-core/gesture-placement.ts @@ -0,0 +1,30 @@ +import type {Document,Geometry,Id,Registry} from './types.ts'; +import {fault} from './values.ts'; +import {unitScale} from './units.ts'; + +type PlacementStep={canvasId:Id;parentCanvasId:Id;entryId:Id;incarnation:number;componentId:string;dataVersion:number;geometry:Geometry;contentScale:number;unitScale:number}; + +/** A short-lived reader of the visible owner chain relative to one view, not the project root. + * Cache by owner only during this admission/publication, so multi-selection shares ancestry work. */ +export function createGesturePlacementReader(document:Document,viewCanvasId:Id,registry:Registry){ + const values=new Map(); + return(ownerCanvasId:Id):readonly PlacementStep[]=>{ + const cached=values.get(ownerCanvasId);if(cached)return cached; + if(!document.canvases.has(viewCanvasId))throw fault('canvas_missing',[viewCanvasId]); + const chain:PlacementStep[]=[],seen=new Set();let id=ownerCanvasId; + while(id!==viewCanvasId){ + if(seen.has(id))throw fault('canvas_cycle',[id]);seen.add(id); + const parent=document.canvases.get(id)?.parent; + if(!parent)throw fault('target_outside_view',[ownerCanvasId]); + const entry=document.elements.get(parent.entryElementId); + // A future/unknown entry is a collapsed placeholder; never interpret its payload. + if(!entry||entry.structure!=='node'||registry.resolve(entry)!=='known')throw fault('target_hidden',[ownerCanvasId]); + const raw=entry.data.presentation,presentation=raw&&typeof raw==='object'&&!Array.isArray(raw)?raw:{}; + if(presentation.collapsed===true)throw fault('target_hidden',[ownerCanvasId]); + const contentScale=typeof presentation.contentScale==='number'&&presentation.contentScale>0?presentation.contentScale:1; + chain.push({canvasId:id,parentCanvasId:parent.canvasId,entryId:entry.id,incarnation:entry.incarnation,componentId:entry.componentId,dataVersion:entry.dataVersion,geometry:entry.geometry,contentScale,unitScale:unitScale(raw)}); + id=parent.canvasId; + } + values.set(ownerCanvasId,chain);return chain; + }; +} diff --git a/src/lib/canvas-core/gestures.ts b/src/lib/canvas-core/gestures.ts index 225acd3..052350b 100644 --- a/src/lib/canvas-core/gestures.ts +++ b/src/lib/canvas-core/gestures.ts @@ -1,6 +1,7 @@ import type { Editor } from './editor.ts'; import type { Availability, Capability, ElementRecord, Geometry, Id, Json, Result, Target } from './types.ts'; import { geometryValid, targetElement } from './document.ts'; +import {createGesturePlacementReader} from './gesture-placement.ts'; import { clone, equal, fault, freeze, readMap, readSet, reject, validJson } from './values.ts'; export type ViewState = { readonly id: Id; readonly canvasId: Id; readonly selection: ReturnType>; readonly viewport: { readonly x: number; readonly y: number; readonly zoom: number }; readonly tool: 'select' | 'pan'; readonly modes: ReadonlyMap }; @@ -38,14 +39,21 @@ export function createSessions(editor: Editor) { if (canvasMissing || selection.length !== view.selection.size || modes.size !== view.modes.size) { views.set(id, freeze({ ...view, canvasId: canvasMissing ? [...document.canvases.values()].find(canvas => !canvas.parent)!.id : view.canvasId, selection: readSet(canvasMissing ? [] : selection), modes: readMap(canvasMissing ? new Map() : modes) })); changed = true; } } let cancelReason:string|undefined; - if (snapshot.connectionGesture?.phase === 'active') { + const active=snapshot.gesture?.phase==='active'?snapshot.gesture:snapshot.connectionGesture?.phase==='active'?snapshot.connectionGesture:undefined; + const activeView=active?snapshot.views.get(active.viewId):undefined; + if(activeView&&!document.canvases.has(activeView.canvasId))cancelReason='canvas_changed'; + const oldPlacement=activeView?createGesturePlacementReader(beforeDocument,activeView.canvasId,editor.registry):undefined,newPlacement=activeView?createGesturePlacementReader(document,activeView.canvasId,editor.registry):undefined; + const placementChanged=(owner:Id)=>{if(beforeDocument===document)return false;try{return !oldPlacement||!newPlacement||!equal(oldPlacement(owner),newPlacement(owner));}catch{return true;}}; + if (!cancelReason&&snapshot.connectionGesture?.phase === 'active') { const current = document.elements.get(snapshot.connectionGesture.target.id), before = connectionBaseline; - if (!current || current.structure !== 'connection' || before?.structure !== 'connection' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || !equal(current.path, before.path)) cancelReason='target_changed'; + if (!current || current.structure !== 'connection' || before?.structure !== 'connection' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || current.componentId!==before.componentId || current.dataVersion!==before.dataVersion || !equal(current.path, before.path)) cancelReason='target_changed'; + else if(placementChanged(current.ownerCanvasId))cancelReason='coordinate_context_changed'; else if (!editor.can({ type: 'connection.path', target: snapshot.connectionGesture.target, path: current.path }).enabled) cancelReason='permission_changed'; } if (snapshot.gesture?.phase === 'active'&&!cancelReason) for (const target of snapshot.gesture.targets) { const before = baseline.get(target.id), current = document.elements.get(target.id); - if (!current || current.structure !== 'node' || before?.structure !== 'node' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || !equal(current.geometry, before.geometry)) { cancelReason='target_changed'; break; } + if (!current || current.structure !== 'node' || before?.structure !== 'node' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || current.componentId!==before.componentId || current.dataVersion!==before.dataVersion || !equal(current.geometry, before.geometry)) { cancelReason='target_changed'; break; } + if(placementChanged(current.ownerCanvasId)){cancelReason='coordinate_context_changed';break;} if (!editor.can({ type: 'element.geometry', target, patch: {} }).enabled) { cancelReason='permission_changed'; break; } } if(changed||cancelReason){ @@ -110,8 +118,10 @@ export function createSessions(editor: Editor) { try { if(view(viewId).tool!=='select')throw fault('tool_not_select'); if (snapshot.gesture || snapshot.connectionGesture || !targets.length || new Set(targets.map(target => target.id)).size !== targets.length) throw fault('gesture_busy'); - baseline = new Map(targets.map(target => { const element = targetElement(editor.getSnapshot().document, target); if (element.structure !== 'node' || editor.registry.resolve(element) !== 'known') throw fault('unsupported_component', [target.id]); return [target.id, element]; })); - for (const target of targets) { const allowed = editor.can({ type: 'element.geometry', target, patch: {} }); if (!allowed.enabled) throw fault(allowed.code, allowed.targets); } + const placement=createGesturePlacementReader(editor.getSnapshot().document,view(viewId).canvasId,editor.registry); + const plannedBaseline = new Map(targets.map(target => { const element = targetElement(editor.getSnapshot().document, target); if (element.structure !== 'node' || editor.registry.resolve(element) !== 'known') throw fault('unsupported_component', [target.id]); return [target.id, element]; })); + for (const target of targets) { placement(plannedBaseline.get(target.id)!.ownerCanvasId);const allowed = editor.can({ type: 'element.geometry', target, patch: {} }); if (!allowed.enabled) throw fault(allowed.code, allowed.targets); } + baseline=plannedBaseline; snapshot = Object.freeze({ ...snapshot, lastCancelReason: null, gesture: freeze({ token: ++nextGestureToken, viewId, targets: targets.map(target => ({ ...target })), preview: readMap(new Map()), phase: 'active' as const }) }); notify(); return { enabled: true }; } catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; } @@ -122,6 +132,7 @@ export function createSessions(editor: Editor) { if (snapshot.gesture || snapshot.connectionGesture) throw fault('gesture_busy'); const element = targetElement(editor.getSnapshot().document, target); if (element.structure !== 'connection') throw fault('unsupported_capability', [target.id]); + createGesturePlacementReader(editor.getSnapshot().document,view(viewId).canvasId,editor.registry)(element.ownerCanvasId); const allowed = editor.can({ type: 'connection.path', target, path: element.path }); if (!allowed.enabled) throw fault(allowed.code, allowed.targets); connectionBaseline = element; diff --git a/tests/headless/gesture-placement.test.mjs b/tests/headless/gesture-placement.test.mjs new file mode 100644 index 0000000..e2fabf4 --- /dev/null +++ b/tests/headless/gesture-placement.test.mjs @@ -0,0 +1,20 @@ +import {test} from 'node:test'; +import assert from 'node:assert/strict'; +import {createGesturePlacementReader} from '../../src/lib/canvas-core/gesture-placement.ts'; +import {createDocument,createRegistry,builtinComponents,createEditor,createSessions} from '../../src/lib/canvas-core/index.ts'; +const node=(id,owner='root',componentId='text')=>({id,incarnation:1,structure:'node',componentId,dataVersion:1,ownerCanvasId:owner,data:{title:id,text:''},geometry:{x:0,y:0,width:100,height:100,rotation:0}}); +const path={type:'bezier-chain',knots:[{id:'k',mode:'corner',position:{x:10,y:20},in:{x:-10,y:0},out:{x:10,y:0}}]}; +function fixture(){const registry=createRegistry(builtinComponents()),document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}},{id:'grand',parent:{canvasId:'child',entryElementId:'G'}}],[node('F','root','frame'),node('G','child','frame'),node('A','child'),node('B','grand'),node('D','child'),node('C'),{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'child',source:{elementId:'A'},target:{elementId:'D'},path,data:{}}]),editor=createEditor({document,registry}),sessions=createSessions(editor);sessions.attach('v','root');sessions.attach('child-view','child');return{editor,sessions};} +test('moving an ancestor cancels a descendant preview before a late commit can use old coordinates',()=>{const{editor,sessions}=fixture();assert.equal(sessions.begin('v',[{id:'B',incarnation:1}]).enabled,true);sessions.update('v',{id:'B',incarnation:1},{x:50});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}}).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);const before=editor.getSnapshot();assert.equal(sessions.commit('v').status,'rejected');assert.strictEqual(editor.getSnapshot(),before);assert.equal(before.document.elements.get('B').geometry.x,0);}); +test('collapsed and outside-view targets cannot acquire a gesture',()=>{const{editor,sessions}=fixture();editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation:{collapsed:true}}});assert.equal(sessions.begin('v',[{id:'A',incarnation:1}]).code,'target_hidden');assert.equal(sessions.begin('child-view',[{id:'C',incarnation:1}]).code,'target_outside_view');}); +test('moving a path owner ancestor cancels its connection preview',()=>{const{editor,sessions}=fixture();assert.equal(sessions.beginConnection('v',{id:'edge',incarnation:1}).enabled,true);sessions.updateConnection('v',{...path,knots:[]});assert.equal(editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}}).status,'applied');assert.equal(sessions.getSnapshot().connectionGesture,null);}); + +test('scale and collapse cancel active descendant gestures but presentation labels do not',()=>{for(const presentation of [{contentScale:2},{unitScale:2},{collapsed:true}]){const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);sessions.update('v',{id:'B',incarnation:1},{x:50});editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{presentation}});assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().lastCancelReason,'coordinate_context_changed');}const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);const gesture=sessions.getSnapshot().gesture;editor.dispatch({type:'element.data',target:{id:'F',incarnation:1},patch:{title:'another title',presentation:{labelMode:'hidden',contentScale:1,unitScale:1}}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);editor.dispatch({type:'element.geometry',target:{id:'C',incarnation:1},patch:{x:80}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);}); +test('ancestor changes above an independently opened view do not alter its local coordinate space',()=>{const{editor,sessions}=fixture();sessions.begin('child-view',[{id:'B',incarnation:1}]);sessions.update('child-view',{id:'B',incarnation:1},{x:50});const gesture=sessions.getSnapshot().gesture;editor.dispatch({type:'element.geometry',target:{id:'F',incarnation:1},patch:{x:200}});assert.strictEqual(sessions.getSnapshot().gesture,gesture);assert.equal(sessions.commit('child-view').status,'applied');assert.equal(editor.getSnapshot().document.elements.get('B').geometry.x,50);}); +test('ancestor restore, future version and equal-geometry reparent invalidate the old context',()=>{for(const patch of [{incarnation:2},{dataVersion:99}]){const{editor,sessions}=fixture();sessions.begin('v',[{id:'B',incarnation:1}]);const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='F'?{...record,...patch}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);}const{editor,sessions}=fixture();editor.transaction(tx=>{tx.dispatch({type:'element.create',element:{...node('H','root','frame'),geometry:{...node('H').geometry,x:-24,y:-36}}});tx.dispatch({type:'canvas.create',canvas:{id:'sibling',parent:{canvasId:'root',entryElementId:'H'}}});});sessions.begin('v',[{id:'B',incarnation:1}]);assert.equal(editor.dispatch({type:'element.move',target:{id:'F',incarnation:1},canvasId:'sibling',geometry:node('F').geometry}).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);}); +test('direct target component changes cannot retain an old geometry lease',()=>{const{editor,sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);sessions.update('v',{id:'A',incarnation:1},{x:50});const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,componentId:'plain-text'}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().lastCancelReason,'target_changed');}); +test('deleting the viewed canvas publishes its root fallback and cancellation together',()=>{const{editor,sessions}=fixture();sessions.begin('child-view',[{id:'B',incarnation:1}]);const events=[];sessions.subscribe(()=>events.push(sessions.getSnapshot()));assert.equal(editor.dispatch({type:'element.remove',target:{id:'F',incarnation:1}}).status,'applied');assert.equal(events.length,1);assert.equal(events[0].views.get('child-view').canvasId,'root');assert.equal(events[0].gesture,null);assert.equal(events[0].lastCancelReason,'canvas_changed');}); + + +test('placement readers share a checked owner chain and never enumerate the project',()=>{const{editor}=fixture(),document=editor.getSnapshot().document;let reads=0;const protectedDocument={...document,canvases:{has:id=>document.canvases.has(id),get:id=>document.canvases.get(id),[Symbol.iterator](){throw Error('project enumeration');}},elements:{get(id){reads++;return document.elements.get(id);},[Symbol.iterator](){throw Error('project enumeration');}}},read=createGesturePlacementReader(protectedDocument,'root',editor.registry),first=read('grand');assert.equal(first.length,2);for(let i=0;i<1000;i++)assert.strictEqual(read('grand'),first);assert.equal(reads,2);}); +test('future ancestor data is never interpreted to decide visibility',()=>{const{editor}=fixture(),document=editor.getSnapshot().document,frame=document.elements.get('F');let inspected=0;const opaque={...frame,dataVersion:99,get data(){inspected++;throw Error('opaque payload read');}},elements=new Map(document.elements);elements.set('F',opaque);const read=createGesturePlacementReader({...document,elements},'root',editor.registry);assert.throws(()=>read('grand'),/target_hidden/);assert.equal(inspected,0);}); diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index 2cd0bf7..3d46926 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -218,7 +218,7 @@ try { await click('八向拉伸模式');await until(`document.querySelector('[data-resize-toggle="b"]')?.getAttribute('aria-pressed')==='true'`,'resize mode did not open'); await evaluate(`document.querySelector('[data-node-style="b"] select').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}))`);await pause(50);assert.equal(await evaluate(`document.querySelector('[data-resize-toggle="b"]').getAttribute('aria-pressed')`),'true','native selector Escape dismissed the canvas mode'); await evaluate(`(()=>{const button=document.querySelector('[data-resize-toggle="b"]');button.addEventListener('keydown',event=>event.preventDefault(),{once:true});button.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}));})()`);await pause(50);assert.equal(await evaluate(`document.querySelector('[data-resize-toggle="b"]').getAttribute('aria-pressed')`),'true','toolbar ignored a child control that had claimed Escape'); - await evaluate(`document.querySelector('[data-resize-toggle="b"]').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}))`);await until(`document.querySelector('[data-resize-toggle="b"]')?.getAttribute('aria-pressed')==='false'`,'toolbar did not dismiss its own resize mode').catch(async error=>{console.log('MODE DIAGNOSTIC',await evaluate(`JSON.stringify({views:[...window.__fixtureActualEngine.sessions.getSnapshot().views.values()].map(view=>({id:view.id,modes:[...view.modes]})),buttons:[...document.querySelectorAll('[data-resize-toggle="b"]')].map(node=>({pressed:node.getAttribute('aria-pressed'),html:node.outerHTML})),target:document.querySelector('.react-flow__node[data-id=b]')?.outerHTML.slice(0,1500)})`));throw error;}); + await evaluate(`document.querySelector('[data-resize-toggle="b"]').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}))`);await until(`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]');return !!node&&[...window.__fixtureActualEngine.sessions.getSnapshot().views.values()].every(view=>view.modes.get('b')!=='resize')&&!node.querySelector('[data-resize-handle]')&&!document.querySelector('[data-resize-toggle="b"][aria-pressed="true"]');})()`,'toolbar did not dismiss its own resize mode').catch(async error=>{console.log('MODE DIAGNOSTIC',await evaluate(`JSON.stringify({views:[...window.__fixtureActualEngine.sessions.getSnapshot().views.values()].map(view=>({id:view.id,modes:[...view.modes]})),buttons:[...document.querySelectorAll('[data-resize-toggle="b"]')].map(node=>({pressed:node.getAttribute('aria-pressed'),html:node.outerHTML})),target:document.querySelector('.react-flow__node[data-id=b]')?.outerHTML.slice(0,1500)})`));throw error;}); const textPoint = await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label^="单击选中"]').getBoundingClientRect();return {x:r.x+60,y:r.y+30}})()`); await call('Input.dispatchMouseEvent', { type: 'mousePressed', ...textPoint, button: 'left', buttons: 1, clickCount: 2 }); await call('Input.dispatchMouseEvent', { type: 'mouseReleased', ...textPoint, button: 'left', buttons: 0, clickCount: 2 }); @@ -489,6 +489,22 @@ try { await until(`(()=>{const viewport=document.querySelector('.react-flow__pane').getBoundingClientRect(),nodes=[...document.querySelectorAll('.react-flow__node')];return nodes.length===2&&nodes.every(node=>{const r=node.getBoundingClientRect();return r.bottomviewport.bottom;})&&!!document.querySelector('.react-flow__edge[data-id="bow-edge"] .react-flow__edge-path');})()`,'offscreen endpoints caused visible bowed paint to unmount'); assert.equal(documents.get(bowRoot).elements.size,3);console.log('PASS bowed curve paint remains mounted with both endpoint rectangles outside the viewport'); + const contextRoot=id(852),contextChild=id(853),contextFrame={...node('context-frame',contextRoot,400),componentId:'frame',geometry:{x:400,y:100,width:640,height:400,rotation:0}},contextNode={...node('context-node',contextChild,50),geometry:{x:50,y:60,width:260,height:200,rotation:0}}; + documents.set(contextRoot,createDocument(contextRoot,[{id:contextRoot,parent:null},{id:contextChild,parent:{canvasId:contextRoot,entryElementId:contextFrame.id}}],[contextFrame,contextNode]));versions.set(contextRoot,1);versions.set(contextChild,1);boards.push({id:contextRoot,workspace_id:workspace,kind:'board',version:1,title:'Gesture context board'}); + await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${contextRoot}`});await until(`!!document.querySelector('.react-flow__node[data-id="context-node"]')&&[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'nested gesture context board did not settle'); + await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + const contextPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id="context-node"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+r.width*.3,y:r.y+Math.min(20,r.height*.2)}})()`); + assert.equal(await evaluate(`document.elementFromPoint(${contextPoint.x},${contextPoint.y})?.closest('.react-flow__node')?.getAttribute('data-id')`),'context-node','nested test pointer is covered by another surface'); + await call('Input.dispatchMouseEvent',{type:'mousePressed',...contextPoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseMoved',x:contextPoint.x+35,y:contextPoint.y+25,button:'left',buttons:1}); + await until(`window.__fixtureActualEngine?.getSnapshot().document.projectId===${JSON.stringify(contextRoot)}&&window.__fixtureActualEngine.sessions.getSnapshot().gesture?.targets.some(target=>target.id==='context-node')`,'nested node did not acquire its drag'); + assert.equal(await evaluate(`window.__fixtureActualEngine.editor.dispatch({type:'element.geometry',target:{id:'context-frame',incarnation:1},patch:{x:600}}).status`),'applied'); + assert.equal(await evaluate('window.__fixtureActualEngine.sessions.getSnapshot().gesture'),null,'ancestor edit retained the old coordinate preview'); + await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:contextPoint.x+35,y:contextPoint.y+25,button:'left',buttons:0}); + for(let i=0;i<100&&documents.get(contextRoot).elements.get('context-frame').geometry.x!==600;i++)await pause(20); + assert.equal(documents.get(contextRoot).elements.get('context-frame').geometry.x,600);assert.deepEqual(documents.get(contextRoot).elements.get('context-node').geometry,contextNode.geometry); + await until(`(()=>{const node=document.querySelector('.react-flow__node[data-id="context-node"]');return node&&Math.abs(new DOMMatrix(node.style.transform).m41-674)<.01&&!node.classList.contains('dragging');})()`,'nested node did not return to its new ancestor projection'); + const contextWrites=calls.filter(call=>call.method==='POST'&&call.path.includes('/'+contextRoot+'/canvas/model/operations')).flatMap(call=>JSON.parse(call.bytes.toString()).operations.flatMap(operation=>operation.changes));assert.ok(contextWrites.length);assert.equal(contextWrites.some(change=>change.target?.id==='context-node'),false,'late nested drag wrote the old coordinate result'); + console.log('PASS ancestor movement cancels the actual nested drag; late pointerup preserves child geometry and renders its new parent position'); await click('账户和工作空间');await until(`!!document.querySelector('#workspace-account:popover-open')`,'account menu did not open');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent.trim()==='退出登录').click()`); await until(`location.pathname==='/login'&&!document.body.innerText.includes('This page couldn’t load')`,'logout did not safely detach the view'); assert.ok(calls.some(call=>call.path.endsWith('/canvas/model')&&call.headers.accept==='application/vnd.foundry.canvas-model+ndjson;version=1'));