Fence session lifetimes and cancel owned previews on tool changes
This commit is contained in:
@@ -582,3 +582,23 @@ this is not a claim that the entire view is O(changed IDs). Numeric geometry cac
|
||||
remain derived Maps, with no history or durable task ownership. Engine/view
|
||||
replacement discards this cache; `clear()` also drops the retained scene and
|
||||
numeric caches. Query provenance is validated even on a cache hit.
|
||||
|
||||
Session transitions publish the final view and any owned preview cancellation in
|
||||
one snapshot. Changing tools cancels only that view's active node/path gesture;
|
||||
`begin`/`beginConnection` require the selection tool (`tool_not_select` otherwise).
|
||||
Repeated selection of the current tool is a no-op. The React Flow adapter also
|
||||
disables native connection handles in pan mode and rejects a late connection
|
||||
callback against the current tool. Multitouch interruption is view scoped.
|
||||
|
||||
Modes remain `Map<ElementId, string>` in each view. On document publication, the
|
||||
session compares the previous and current target incarnation, owner, structure,
|
||||
component and data version: replacing or moving the target clears its mode,
|
||||
while content edits and equivalent canonical clones preserve it. Missing targets
|
||||
cannot acquire a mode. Identical mode updates and redundant clears do not notify.
|
||||
This uses a borrowed current document reference, not a second mode identity store.
|
||||
|
||||
Each attach disposer runs once, so a late second cleanup cannot detach a newly
|
||||
attached view with the same ID. Explicit canvas navigation and detach publish the
|
||||
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.
|
||||
|
||||
@@ -158,6 +158,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
return id;
|
||||
}
|
||||
function connect(connection: Connection) {
|
||||
if(engine.sessions.getSnapshot().views.get(viewId)?.tool!=='select')return;
|
||||
const document = engine.getSnapshot().document, source = document.elements.get(connection.source), destination = document.elements.get(connection.target);
|
||||
if (!source || !destination) return;
|
||||
if([source.id,destination.id].some(id=>projectionCache.current!.getNode(id)?.type==='unsupported')){setError('此元素暂不支持创建新连线');return;}
|
||||
@@ -240,7 +241,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
onFocusCapture={event => { if ((event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="textbox"]')) { focusEpoch.current++; setEditorFocused(true); } }}
|
||||
onBlurCapture={() => { const epoch = ++focusEpoch.current; queueMicrotask(() => { if (focusEpoch.current === epoch) setEditorFocused(false); }); }}
|
||||
onMouseDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }),pointerId:pointerStart.current?.pointerId}; }}
|
||||
onTouchStartCapture={event => { if (event.touches.length > 1) { engine.sessions.cancel('multi_touch'); drag.current = null; return; } const touch = event.touches[0]; if (touch && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: touch.clientX, y: touch.clientY }),pointerId:pointerStart.current?.pointerId}; }}
|
||||
onTouchStartCapture={event => { if (event.touches.length > 1) { const state=engine.sessions.getSnapshot();if(state.gesture?.viewId===viewId||state.connectionGesture?.viewId===viewId)engine.sessions.cancel('multi_touch');drag.current = null;pointerStart.current=null;return; } const touch = event.touches[0]; if (touch && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: touch.clientX, y: touch.clientY }),pointerId:pointerStart.current?.pointerId}; }}
|
||||
onPointerDownCapture={event => { if (event.button === 0 && (event.target as HTMLElement).closest('.react-flow__node')) pointerStart.current = {...flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }),pointerId:event.pointerId}; }}
|
||||
onPointerCancelCapture={event => { const currentDrag=drag.current;if(!currentDrag||currentDrag.pointerId!==undefined&¤tDrag.pointerId!==event.pointerId)return;const state=engine.sessions.getSnapshot();if(state.gesture?.viewId===viewId&&state.gesture.token===currentDrag.token)engine.sessions.cancel('pointer_cancel');drag.current=null; }}
|
||||
onKeyDown={event => {
|
||||
@@ -256,7 +257,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
<NodeEditing.Provider value={nodeEditing}>
|
||||
<EdgeEditing.Provider value={edgeEditing}>
|
||||
<ReactFlow<ProjectionNode> elevateEdgesOnSelect onlyRenderVisibleElements={cullVisible} nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose}
|
||||
nodesDraggable={view.tool === 'select'} panOnDrag={view.tool === 'pan' ? true : [1, 2]} selectionOnDrag={view.tool === 'select'} panOnScroll zoomOnScroll={false} minZoom={.1} maxZoom={4} deleteKeyCode={null} defaultViewport={view.viewport}
|
||||
nodesConnectable={view.tool === 'select'} nodesDraggable={view.tool === 'select'} panOnDrag={view.tool === 'pan' ? true : [1, 2]} selectionOnDrag={view.tool === 'select'} panOnScroll zoomOnScroll={false} minZoom={.1} maxZoom={4} deleteKeyCode={null} defaultViewport={view.viewport}
|
||||
|
||||
onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)}
|
||||
onNodeDragStart={(event, node, nodes) => {
|
||||
|
||||
@@ -12,32 +12,45 @@ export type SessionSnapshot = { readonly views: ReadonlyMap<Id, ViewState>; read
|
||||
export function createSessions(editor: Editor) {
|
||||
let snapshot: SessionSnapshot = Object.freeze({ views: readMap(new Map<Id, ViewState>()), gesture: null, connectionGesture: null, lastCancelReason: null });
|
||||
let baseline = new Map<Id, ElementRecord>(), connectionBaseline: ElementRecord | undefined, disposed = false, nextGestureToken = 0;
|
||||
// Compare adjacent published identities without duplicating the per-view mode table.
|
||||
let publishedDocument=editor.getSnapshot().document;
|
||||
const listeners = new Set<() => void>();
|
||||
const notify = () => { for (const listener of [...listeners]) try { listener(); } catch { /* A renderer cannot invalidate model commits. */ } };
|
||||
function setView(view: ViewState) { const views = new Map(snapshot.views); views.set(view.id, view); snapshot = Object.freeze({ ...snapshot, views: readMap(views) }); notify(); }
|
||||
function publishViews(views:Map<Id,ViewState>,cancelOwnedView?:Id,reason='view_interrupted'){
|
||||
const owned=cancelOwnedView!==undefined&&(snapshot.gesture?.viewId===cancelOwnedView||snapshot.connectionGesture?.viewId===cancelOwnedView);
|
||||
if(owned){baseline.clear();connectionBaseline=undefined;}
|
||||
snapshot=Object.freeze({...snapshot,views:readMap(views),...(owned?{gesture:null,connectionGesture:null,lastCancelReason:reason}:{})});notify();
|
||||
}
|
||||
function setView(view: ViewState,cancelOwnedReason?:string) { const views = new Map(snapshot.views); views.set(view.id, view); publishViews(views,cancelOwnedReason?view.id:undefined,cancelOwnedReason); }
|
||||
function cancel(reason = 'canceled') {
|
||||
if (!snapshot.gesture && !snapshot.connectionGesture) return;
|
||||
baseline.clear(); connectionBaseline = undefined; snapshot = Object.freeze({ ...snapshot, gesture: null, connectionGesture: null, lastCancelReason: reason }); notify();
|
||||
}
|
||||
const detachEditor = editor.subscribe(() => {
|
||||
const document = editor.getSnapshot().document;
|
||||
const document = editor.getSnapshot().document,beforeDocument=publishedDocument;publishedDocument=document;
|
||||
const views = new Map(snapshot.views); let changed = false;
|
||||
for (const [id, view] of views) {
|
||||
const canvasMissing = !document.canvases.has(view.canvasId);
|
||||
const selection = [...view.selection].filter(target => document.elements.has(target)), modes = new Map([...view.modes].filter(([target]) => document.elements.has(target)));
|
||||
const selection = [...view.selection].filter(target => document.elements.has(target)), modes = new Map([...view.modes].filter(([target]) => {
|
||||
const before=beforeDocument.elements.get(target),after=document.elements.get(target);
|
||||
return !!before&&!!after&&before.incarnation===after.incarnation&&before.ownerCanvasId===after.ownerCanvasId&&before.structure===after.structure&&before.componentId===after.componentId&&before.dataVersion===after.dataVersion;
|
||||
}));
|
||||
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; }
|
||||
}
|
||||
if (changed) { snapshot = Object.freeze({ ...snapshot, views: readMap(views) }); notify(); }
|
||||
let cancelReason:string|undefined;
|
||||
if (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)) cancel('target_changed');
|
||||
else if (!editor.can({ type: 'connection.path', target: snapshot.connectionGesture.target, path: current.path }).enabled) cancel('permission_changed');
|
||||
if (!current || current.structure !== 'connection' || before?.structure !== 'connection' || current.incarnation !== before.incarnation || current.ownerCanvasId !== before.ownerCanvasId || !equal(current.path, before.path)) cancelReason='target_changed';
|
||||
else if (!editor.can({ type: 'connection.path', target: snapshot.connectionGesture.target, path: current.path }).enabled) cancelReason='permission_changed';
|
||||
}
|
||||
if (snapshot.gesture?.phase !== 'active') return;
|
||||
for (const target of snapshot.gesture.targets) {
|
||||
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)) { cancel('target_changed'); return; }
|
||||
if (!editor.can({ type: 'element.geometry', target, patch: {} }).enabled) { cancel('permission_changed'); return; }
|
||||
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 (!editor.can({ type: 'element.geometry', target, patch: {} }).enabled) { cancelReason='permission_changed'; break; }
|
||||
}
|
||||
if(changed||cancelReason){
|
||||
if(cancelReason){baseline.clear();connectionBaseline=undefined;}
|
||||
snapshot=Object.freeze({...snapshot,...(changed?{views:readMap(views)}:{}),...(cancelReason?{gesture:null,connectionGesture:null,lastCancelReason:cancelReason}:{})});notify();
|
||||
}
|
||||
});
|
||||
function view(id: Id) { const found = snapshot.views.get(id); if (!found || disposed) throw fault('view_detached', [id]); return found; }
|
||||
@@ -75,13 +88,12 @@ export function createSessions(editor: Editor) {
|
||||
if (disposed || snapshot.views.has(id)) throw fault('duplicate_view', [id]);
|
||||
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]);
|
||||
setView(freeze({ id, canvasId, selection: readSet<Id>([]), viewport: { x: 0, y: 0, zoom: 1 }, tool: 'select', modes: readMap(new Map<Id, string>()) }));
|
||||
return () => { if ((snapshot.gesture?.viewId === id || snapshot.connectionGesture?.viewId === id)) cancel('view_detached'); const views = new Map(snapshot.views); views.delete(id); snapshot = Object.freeze({ ...snapshot, views: readMap(views) }); notify(); };
|
||||
let attached=true;return () => { if(!attached)return;attached=false;if(!snapshot.views.has(id))return;const views=new Map(snapshot.views);views.delete(id);publishViews(views,id,'view_detached'); };
|
||||
},
|
||||
canvas(id: Id, canvasId: Id) {
|
||||
const current = view(id);
|
||||
if (!editor.getSnapshot().document.canvases.has(canvasId)) throw fault('canvas_missing', [canvasId]);
|
||||
if ((snapshot.gesture?.viewId === id || snapshot.connectionGesture?.viewId === id)) cancel('canvas_changed');
|
||||
setView(freeze({ ...current, canvasId, selection: readSet<Id>([]), modes: readMap(new Map<Id, string>()) }));
|
||||
setView(freeze({ ...current, canvasId, selection: readSet<Id>([]), modes: readMap(new Map<Id, string>()) }),'canvas_changed');
|
||||
},
|
||||
select(id: Id, ids: readonly Id[]) {
|
||||
const current = view(id), document = editor.getSnapshot().document;
|
||||
@@ -92,11 +104,11 @@ export function createSessions(editor: Editor) {
|
||||
if (![viewport.x, viewport.y, viewport.zoom].every(Number.isFinite) || viewport.zoom <= 0) throw fault('invalid_viewport');
|
||||
setView(freeze({ ...view(id), viewport: { ...viewport } }));
|
||||
},
|
||||
tool(id: Id, tool: ViewState['tool']) { setView(freeze({ ...view(id), tool })); },
|
||||
mode(id: Id, target: Id, mode: string | null) { const current = view(id), modes = new Map(current.modes); if (mode) modes.set(target, mode); else modes.delete(target); setView(freeze({ ...current, modes: readMap(modes) })); },
|
||||
tool(id: Id, tool: ViewState['tool']) { const current=view(id);if(tool!=='select'&&tool!=='pan')throw fault('invalid_tool');if(current.tool===tool)return;setView(freeze({ ...current, tool }),'tool_changed'); },
|
||||
mode(id: Id, target: Id, mode: string | null) { const current = view(id);if(mode!==null&&typeof mode!=='string')throw fault('invalid_mode');if(mode&&!editor.getSnapshot().document.elements.has(target))throw fault('element_missing',[target]);if((current.modes.get(target)??null)===(mode||null))return;const modes = new Map(current.modes); if (mode) modes.set(target, mode); else modes.delete(target); setView(freeze({ ...current, modes: readMap(modes) })); },
|
||||
begin(viewId: Id, targets: readonly Target[]): Availability {
|
||||
try {
|
||||
view(viewId);
|
||||
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); }
|
||||
@@ -106,7 +118,7 @@ export function createSessions(editor: Editor) {
|
||||
},
|
||||
beginConnection(viewId: Id, target: Target): Availability {
|
||||
try {
|
||||
view(viewId);
|
||||
if(view(viewId).tool!=='select')throw fault('tool_not_select');
|
||||
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]);
|
||||
@@ -161,6 +173,6 @@ export function createSessions(editor: Editor) {
|
||||
return snapshot.gesture?.preview.get(id) || (element?.structure === 'node' ? element.geometry : undefined);
|
||||
},
|
||||
subscribe(listener: () => void) { if (disposed) throw fault('sessions_disposed'); listeners.add(listener); return () => { listeners.delete(listener); }; },
|
||||
dispose() { cancel('disposed'); disposed = true; detachEditor(); snapshot = Object.freeze({ ...snapshot, views: readMap(new Map<Id, ViewState>()) }); notify(); listeners.clear(); },
|
||||
dispose() { if(disposed)return;disposed = true;detachEditor();baseline.clear();connectionBaseline=undefined;snapshot = Object.freeze({ ...snapshot, views: readMap(new Map<Id, ViewState>()),gesture:null,connectionGesture:null,lastCancelReason:'disposed' }); notify();listeners.clear(); },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -192,6 +192,20 @@ try {
|
||||
await call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
|
||||
await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: moved.x + 35, y: moved.y + 25, button: 'left', buttons: 0 });
|
||||
await pause(100); assert.equal(documents.get(root).elements.get('b').geometry.x, 500); assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeCancel);
|
||||
const toolPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b]').getBoundingClientRect();return {x:r.x+80,y:r.y+30}})()`);
|
||||
await call('Input.dispatchMouseEvent',{type:'mousePressed',...toolPoint,button:'left',buttons:1,clickCount:1});
|
||||
await call('Input.dispatchMouseEvent',{type:'mouseMoved',x:toolPoint.x+35,y:toolPoint.y+25,button:'left',buttons:1});
|
||||
await until(`!!window.__fixtureActualEngine.sessions.getSnapshot().gesture`,'tool-switch drag did not acquire preview');
|
||||
await evaluate(`document.querySelector('[data-headless-canvas]').focus()`);
|
||||
await call('Input.dispatchKeyEvent',{type:'keyDown',key:'h',code:'KeyH',windowsVirtualKeyCode:72});await call('Input.dispatchKeyEvent',{type:'keyUp',key:'h',code:'KeyH',windowsVirtualKeyCode:72});
|
||||
await until(`[...window.__fixtureActualEngine.sessions.getSnapshot().views.values()][0]?.tool==='pan'&&!window.__fixtureActualEngine.sessions.getSnapshot().gesture`,'H did not cancel the owned drag when switching tools');
|
||||
await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:toolPoint.x+35,y:toolPoint.y+25,button:'left',buttons:0});await pause(100);
|
||||
assert.equal(documents.get(root).elements.get('b').geometry.x,500);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,beforeCancel,'late pointerup committed a drag canceled by switching tools');
|
||||
await call('Input.dispatchKeyEvent',{type:'keyDown',key:'v',code:'KeyV',windowsVirtualKeyCode:86});await call('Input.dispatchKeyEvent',{type:'keyUp',key:'v',code:'KeyV',windowsVirtualKeyCode:86});
|
||||
await until(`[...window.__fixtureActualEngine.sessions.getSnapshot().views.values()][0]?.tool==='select'`,'V did not restore the selection tool');
|
||||
await until(`document.querySelector('[aria-label="选择工具(V)"]')?.getAttribute('aria-pressed')==='true'`,'selection tool did not render');
|
||||
await until(`(()=>{const n=document.querySelector('.react-flow__node[data-id=b]'),m=new DOMMatrix(n.style.transform);return Math.abs(m.m41-500)<.01&&!n.classList.contains('dragging');})()`,'canceled native node did not return to its committed position');
|
||||
console.log('PASS actual drag-to-pan tool switch cancels preview and late pointerup produces no document request');
|
||||
const corner = await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label="拖动右下角调整大小"]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`);
|
||||
await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: corner.x, y: corner.y, button: 'left', buttons: 1, clickCount: 1 });
|
||||
await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: corner.x + 20, y: corner.y + 10, button: 'left', buttons: 1 });
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
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}});
|
||||
function fixture(){const registry=createRegistry(builtinComponents()),document=createDocument('p',[{id:'root',parent:null},{id:'child',parent:{canvasId:'root',entryElementId:'F'}}],[node('F','root','frame'),node('A'),node('B'),{id:'edge',incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:'root',source:{elementId:'A'},target:{elementId:'B'},path:null,data:{}}]),editor=createEditor({document,registry}),sessions=createSessions(editor),detach=sessions.attach('v');sessions.attach('w');return{editor,sessions,detach};}
|
||||
test('an atomic restored incarnation cannot inherit the old element editing mode',()=>{const{editor,sessions}=fixture();sessions.mode('v','A','resize');const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,incarnation:2}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().views.get('v').modes.has('A'),false);});
|
||||
test('switching tools clears the owned preview atomically and blocks fresh transform/connection gestures in pan',()=>{const{editor,sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);sessions.update('v',{id:'A',incarnation:1},{x:50});const before=editor.getSnapshot();const observed=[];sessions.subscribe(()=>observed.push(sessions.getSnapshot()));sessions.tool('v','pan');assert.equal(observed.length,1);assert.equal(observed[0].views.get('v').tool,'pan');assert.equal(observed[0].gesture,null);assert.equal(observed[0].lastCancelReason,'tool_changed');assert.equal(sessions.begin('v',[{id:'A',incarnation:1}]).code,'tool_not_select');assert.equal(sessions.beginConnection('v',{id:'edge',incarnation:1}).code,'tool_not_select');assert.strictEqual(editor.getSnapshot(),before);});
|
||||
test('old detach cleanup cannot remove a freshly attached view with the same ID',()=>{const{sessions,detach}=fixture();detach();sessions.attach('v');detach();assert.equal(sessions.getSnapshot().views.has('v'),true);});
|
||||
|
||||
test('mode lifetime follows identity and placement while ordinary content edits preserve it',()=>{const{editor,sessions}=fixture();sessions.mode('v','A','editing');sessions.mode('w','A','resize');editor.dispatch({type:'element.data',target:{id:'A',incarnation:1},patch:{text:'new content'}});assert.equal(sessions.getSnapshot().views.get('v').modes.get('A'),'editing');assert.equal(sessions.getSnapshot().views.get('w').modes.get('A'),'resize');assert.equal(editor.dispatch({type:'element.move',target:{id:'A',incarnation:1},canvasId:'child'}).status,'applied');assert.equal(sessions.getSnapshot().views.get('v').modes.size,0);assert.equal(sessions.getSnapshot().views.get('w').modes.size,0);assert.throws(()=>sessions.mode('v','missing','editing'),/element_missing/);const before=sessions.getSnapshot();sessions.mode('v','missing',null);assert.strictEqual(sessions.getSnapshot(),before);});
|
||||
test('target replacement publishes mode invalidation and gesture cancellation together',()=>{const{editor,sessions}=fixture();sessions.mode('v','A','resize');sessions.begin('v',[{id:'A',incarnation:1}]);sessions.update('v',{id:'A',incarnation:1},{x:50});const events=[];sessions.subscribe(()=>events.push(sessions.getSnapshot()));const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,incarnation:2}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(events.length,1);assert.equal(events[0].views.get('v').modes.has('A'),false);assert.equal(events[0].gesture,null);assert.equal(events[0].lastCancelReason,'target_changed');});
|
||||
test('another view tool change preserves the gesture, and same-tool selection is idempotent',()=>{const{sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);const gesture=sessions.getSnapshot().gesture;sessions.tool('w','pan');assert.strictEqual(sessions.getSnapshot().gesture,gesture);const before=sessions.getSnapshot();sessions.tool('v','select');assert.strictEqual(sessions.getSnapshot(),before);assert.throws(()=>sessions.tool('v','unknown'),/invalid_tool/);assert.strictEqual(sessions.getSnapshot(),before);sessions.cancel();sessions.beginConnection('w',{id:'edge',incarnation:1});assert.equal(sessions.getSnapshot().connectionGesture,null);sessions.tool('w','select');assert.equal(sessions.beginConnection('w',{id:'edge',incarnation:1}).enabled,true);sessions.tool('w','pan');assert.equal(sessions.getSnapshot().connectionGesture,null);});
|
||||
test('canvas navigation and detach notify only a final session, never a new target with the old gesture',()=>{const{sessions,detach}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);let observed=[];const stop=sessions.subscribe(()=>observed.push(sessions.getSnapshot()));sessions.canvas('v','child');assert.equal(observed.length,1);assert.equal(observed[0].views.get('v').canvasId,'child');assert.equal(observed[0].gesture,null);sessions.canvas('v','root');sessions.begin('v',[{id:'A',incarnation:1}]);observed=[];detach();assert.equal(observed.length,1);assert.equal(observed[0].views.has('v'),false);assert.equal(observed[0].gesture,null);stop();});
|
||||
test('dispose fences reentrant admission and cannot leave a gesture or accept repeated cleanup',()=>{const{sessions}=fixture();sessions.begin('v',[{id:'A',incarnation:1}]);let attempt,calls=0;sessions.subscribe(()=>{calls++;attempt=sessions.begin('v',[{id:'A',incarnation:1}]);});sessions.dispose();assert.equal(attempt.enabled,false);assert.equal(attempt.code,'view_detached');assert.equal(calls,1);assert.equal(sessions.getSnapshot().views.size,0);assert.equal(sessions.getSnapshot().gesture,null);assert.equal(sessions.getSnapshot().connectionGesture,null);sessions.dispose();assert.equal(calls,1);});
|
||||
|
||||
test('component/version changes invalidate modes while invalid tags never enter a view',()=>{for(const patch of [{componentId:'plain-text'},{dataVersion:99}]){const{editor,sessions}=fixture();sessions.mode('v','A','editing');const before=editor.getSnapshot(),document=createDocument('p',[...before.document.canvases.values()],[...before.document.elements.values()].map(record=>record.id==='A'?{...record,...patch}:record));assert.equal(editor.reconcile(document,undefined,before.rootRevision).status,'applied');assert.equal(sessions.getSnapshot().views.get('v').modes.has('A'),false);const snapshot=sessions.getSnapshot();assert.throws(()=>sessions.mode('v','B',{}),/invalid_mode/);assert.strictEqual(sessions.getSnapshot(),snapshot);}});
|
||||
Reference in New Issue
Block a user