Cancel gestures when their visible ancestor coordinate context changes

This commit is contained in:
2026-11-09 22:25:22 +08:00
parent bcc11e2e84
commit f0ee0736b7
5 changed files with 98 additions and 6 deletions
+30
View File
@@ -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;
};
}
+16 -5
View File
@@ -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;