Cancel gestures when their visible ancestor coordinate context changes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<Id,readonly PlacementStep[]>();
|
||||
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<Id>();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;
|
||||
};
|
||||
}
|
||||
@@ -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<typeof readSet<Id>>; readonly viewport: { readonly x: number; readonly y: number; readonly zoom: number }; readonly tool: 'select' | 'pan'; readonly modes: ReadonlyMap<Id, string> };
|
||||
@@ -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<Id, Geometry>()), 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;
|
||||
|
||||
@@ -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);});
|
||||
@@ -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.bottom<viewport.top||r.top>viewport.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'));
|
||||
|
||||
Reference in New Issue
Block a user