Scope mode and gesture subscriptions and isolate controller leases

This commit is contained in:
2026-11-09 20:58:57 +08:00
parent ccd61bfb90
commit b833f0a2fc
6 changed files with 130 additions and 17 deletions
+15 -8
View File
@@ -2,10 +2,10 @@
import {createContext,createElement,useCallback,useContext,useEffect,useMemo,useRef,useState,useSyncExternalStore,type ReactNode} from 'react';
import {createSelectorStore,type ExternalSource} from './selectors.ts';
import {createElementSources} from './element-sources.ts';
import {createSessionSources,type GestureSelection} from './session-sources.ts';
import type {ProjectEngine} from '../canvas-runtime/workspace.ts';
import type {EngineSnapshot} from '../canvas-runtime/contracts.ts';
import type {Target,Id,Data,Geometry,ElementRecord,Availability,Result} from '../canvas-core/types.ts';
import type {SessionSnapshot} from '../canvas-core/gestures.ts';
import type {ElementLookup} from '../canvas-core/queries.ts';
import {equal,fault} from '../canvas-core/values.ts';
import type {OperationRecord,FrozenRequest} from '../canvas-runtime/ledger.ts';
@@ -42,22 +42,29 @@ export function useActions(target:Target){const {engine}=useCanvasBinding(),{id,
remove:(options?:ActionOptions)=>engine.editor.dispatch({type:'element.remove',target:ref},options),
moveTo:(canvasId:Id,geometry?:Geometry,options?:ActionOptions)=>engine.editor.dispatch({type:'element.move',target:ref,canvasId,...(geometry?{geometry}:{})},options),
});},[engine,id,incarnation]);}
type ViewSnapshot={root:EngineSnapshot;sessions:SessionSnapshot};
function useViewSource():ExternalSource<ViewSnapshot>{const {engine}=useCanvasBinding();return useMemo(()=>{let previous:ViewSnapshot|undefined;return{getSnapshot(){const root=engine.getSnapshot(),sessions=engine.sessions.getSnapshot();if(!previous||previous.root!==root||previous.sessions!==sessions)previous={root,sessions};return previous;},subscribe(notify:()=>void){if(engine.getDurability().closed)return()=>{};const a=engine.editor.subscribe(notify),b=engine.sessions.subscribe(notify);return()=>{a();b();};}};},[engine]);}
const sessionSources=new WeakMap<ProjectEngine,ReturnType<typeof createSessionSources>>();
function sessionSourcesFor(engine:ProjectEngine){let sources=sessionSources.get(engine);if(!sources){sources=createSessionSources({getSnapshot:engine.sessions.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.sessions.subscribe(notify)});sessionSources.set(engine,sources);}return sources;}
function pairedSource<A,B>(left:ExternalSource<A>,right:ExternalSource<B>):ExternalSource<readonly[A,B]>{
let previous:readonly[A,B]|undefined;
return{getSnapshot(){const a=left.getSnapshot(),b=right.getSnapshot();if(!previous||!Object.is(previous[0],a)||!Object.is(previous[1],b))previous=Object.freeze([a,b] as const);return previous;},subscribe(notify){const a=left.subscribe(notify);try{const b=right.subscribe(notify);return()=>{a();b();};}catch(error){a();throw error;}}};
}
function useView(){const binding=useCanvasBinding();if(!binding.viewId)throw fault('view_session_required');return {...binding,viewId:binding.viewId};}
function available(error:unknown):Availability{return{enabled:false,code:(error as {code?:string}).code??'binding_failed',targets:(error as {targets?:readonly Id[]}).targets??[]};}
export function useMode(target:Target){
const {engine,viewId}=useView(),{id,incarnation}=target;
const mode=useSelected(useViewSource(),useCallback(({root,sessions}:ViewSnapshot)=>root.document.elements.get(id)?.incarnation===incarnation?sessions.views.get(viewId)?.modes.get(id)??null:null,[id,incarnation,viewId]));
const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).mode(viewId,id)),[engine,viewId,id]);
const mode=useSelected(source,useCallback(([found,mode]:readonly[ElementLookup,string|null])=>found.status==='present'&&found.element.incarnation===incarnation?mode:null,[incarnation]));
const setMode=useCallback((mode:string|null):Availability=>{try{if(engine.getSnapshot().document.elements.get(id)?.incarnation!==incarnation)throw fault('stale_incarnation',[id]);engine.sessions.mode(viewId,id,mode);return{enabled:true};}catch(error){return available(error);}},[engine,viewId,id,incarnation]);
return useMemo(()=>Object.freeze({mode,setMode}),[mode,setMode]);
}
export function useGestureBindings(target:Target){
const {engine,viewId}=useView(),{id,incarnation}=target,lease=useRef<number|null>(null);
const state=useSelected(useViewSource(),useCallback(({root,sessions}:ViewSnapshot)=>{const element=root.document.elements.get(id),gesture=sessions.gesture;return{geometry:element?.incarnation===incarnation&&element.structure==='node'?sessions.gesture?.preview.get(id)??element.geometry:undefined,active:!!gesture&&gesture.viewId===viewId&&gesture.targets.some(t=>t.id===id&&t.incarnation===incarnation)};},[id,incarnation,viewId]),equal);
const {engine,viewId}=useView(),{id,incarnation}=target;
const source=useMemo(()=>pairedSource(elementSourcesFor(engine).forElement(id),sessionSourcesFor(engine).gesture(viewId,{id,incarnation})),[engine,viewId,id,incarnation]);
const state=useSelected(source,useCallback(([found,transient]:readonly[ElementLookup,GestureSelection])=>({geometry:found.status==='present'&&found.element.incarnation===incarnation&&found.element.structure==='node'?transient.preview??found.element.geometry:undefined,active:transient.active}),[incarnation]),equal);
const controls=useMemo(()=>{
const owned=()=>lease.current!==null&&engine.sessions.getSnapshot().gesture?.token===lease.current&&engine.sessions.getSnapshot().gesture?.viewId===viewId&&engine.sessions.getSnapshot().gesture?.phase==='active';
return Object.freeze({begin():Availability{const result=engine.sessions.begin(viewId,[{id,incarnation}]);if(result.enabled)lease.current=engine.sessions.getSnapshot().gesture!.token;return result;},update(patch:Partial<Geometry>):Availability{return owned()?engine.sessions.update(viewId,{id,incarnation},patch):{enabled:false,code:'gesture_not_owned',targets:[id]};},commit():Result{if(!owned())return{status:'rejected',code:'gesture_not_owned',targets:[id]};try{return engine.sessions.commit(viewId);}finally{lease.current=null;}},cancel(){if(owned())engine.sessions.cancel('binding_canceled');lease.current=null;}});
let lease:number|null=null;
const owned=()=>lease!==null&&engine.sessions.getSnapshot().gesture?.token===lease&&engine.sessions.getSnapshot().gesture?.viewId===viewId&&engine.sessions.getSnapshot().gesture?.phase==='active';
return Object.freeze({begin():Availability{const result=engine.sessions.begin(viewId,[{id,incarnation}]);if(result.enabled)lease=engine.sessions.getSnapshot().gesture!.token;return result;},update(patch:Partial<Geometry>):Availability{return owned()?engine.sessions.update(viewId,{id,incarnation},patch):{enabled:false,code:'gesture_not_owned',targets:[id]};},commit():Result{if(!owned())return{status:'rejected',code:'gesture_not_owned',targets:[id]};try{return engine.sessions.commit(viewId);}finally{lease=null;}},cancel(){if(owned())engine.sessions.cancel('binding_canceled');lease=null;}});
},[engine,viewId,id,incarnation]);
useEffect(()=>()=>controls.cancel(),[controls]);return useMemo(()=>Object.freeze({...state,...controls}),[state,controls]);
}
@@ -0,0 +1,54 @@
import type {Id,Target,Geometry} from '../canvas-core/types.ts';
import type {SessionSnapshot,GestureState} from '../canvas-core/gestures.ts';
import {equal} from '../canvas-core/values.ts';
import type {ExternalSource} from './selectors.ts';
export type GestureSelection={readonly preview:Geometry|undefined;readonly active:boolean};
/** Derived view/target subscriptions. Values retain no document or full per-hook session. */
export function createSessionSources(source:ExternalSource<SessionSnapshot>){
type Observer={update(snapshot:SessionSnapshot):boolean;notify():void};
const modes=new Map<Id,Map<Id,Set<Observer>>>(),gestures=new Map<Id,Set<Observer>>();
const targetIndexes=new WeakMap<readonly Target[],ReadonlyMap<Id,number>>();
let stop:(()=>void)|undefined,previous:SessionSnapshot|undefined;
function targets(gesture:GestureState){let index=targetIndexes.get(gesture.targets);if(!index){index=new Map(gesture.targets.map(target=>[target.id,target.incarnation]));targetIndexes.set(gesture.targets,index);}return index;}
function changed(){
if(!previous)return;const before=previous,next=source.getSnapshot();previous=next;
const candidates=new Set<Observer>(),add=(items:Set<Observer>|undefined)=>{for(const item of items??[])candidates.add(item);};
if(before.views!==next.views)for(const[viewId,byId]of modes){
const old=before.views.get(viewId)?.modes,current=next.views.get(viewId)?.modes;if(old===current)continue;
for(const id of new Set([...(old?.keys()??[]),...(current?.keys()??[])]))if(old?.get(id)!==current?.get(id))add(byId.get(id));
}
const old=before.gesture,current=next.gesture;
if(old!==current){
const ids=new Set<Id>();
if(!old||!current||old.token!==current.token||old.viewId!==current.viewId||old.targets!==current.targets){for(const gesture of [old,current])for(const target of gesture?.targets??[])ids.add(target.id);}
else if(old.preview!==current.preview){for(const[id,value]of old.preview)if(current.preview.get(id)!==value)ids.add(id);for(const[id,value]of current.preview)if(old.preview.get(id)!==value)ids.add(id);}
for(const id of ids)add(gestures.get(id));
}
const notifications:Observer[]=[];for(const observer of candidates)if(observer.update(next))notifications.push(observer);
let failed=false,error:unknown;for(const observer of notifications)try{observer.notify();}catch(caught){if(!failed){failed=true;error=caught;}}if(failed)throw error;
}
function channel<T>(read:(snapshot:SessionSnapshot)=>T,register:(observer:Observer)=>()=>void):ExternalSource<T>{
let initialized=false,value:T;
return Object.freeze({
getSnapshot(){const next=read(source.getSnapshot());if(!initialized||!equal(value,next))value=next;initialized=true;return value;},
subscribe(notify:()=>void){
let observed=read(source.getSnapshot()),failed=false;
const observer:Observer={notify,update(snapshot){try{const next=read(snapshot);if(!failed&&equal(observed,next))return false;observed=next;failed=false;}catch{failed=true;}return true;}};
const remove=register(observer);
if(!stop){previous=source.getSnapshot();try{stop=source.subscribe(changed);}catch(error){remove();previous=undefined;throw error;}}
let active=true;return()=>{if(!active)return;active=false;remove();if(!modes.size&&!gestures.size){stop?.();stop=undefined;previous=undefined;}};
},
});
}
return Object.freeze({
mode(viewId:Id,id:Id):ExternalSource<string|null>{return channel(snapshot=>snapshot.views.get(viewId)?.modes.get(id)??null,observer=>{
let byId=modes.get(viewId);if(!byId){byId=new Map();modes.set(viewId,byId);}let bucket=byId.get(id);if(!bucket){bucket=new Set();byId.set(id,bucket);}bucket.add(observer);
return()=>{bucket!.delete(observer);if(!bucket!.size)byId!.delete(id);if(!byId!.size)modes.delete(viewId);};
});},
gesture(viewId:Id,target:Target):ExternalSource<GestureSelection>{const{id,incarnation}=target;return channel(snapshot=>{
const gesture=snapshot.gesture,matched=!!gesture&&targets(gesture).get(id)===incarnation;
return{preview:matched?gesture!.preview.get(id):undefined,active:matched&&gesture!.viewId===viewId};
},observer=>{let bucket=gestures.get(id);if(!bucket){bucket=new Set();gestures.set(id,bucket);}bucket.add(observer);return()=>{bucket!.delete(observer);if(!bucket!.size)gestures.delete(id);};});},
});
}