Expose React canvas hooks with scoped selectors and resource lifecycles

This commit is contained in:
2026-11-09 17:34:56 +08:00
parent d7b3321df1
commit 575b5a9256
18 changed files with 259 additions and 25 deletions
+3
View File
@@ -7,3 +7,6 @@ export * from './renderers.ts';
export * from './connectors.ts';
export * from './resource-view.ts';
export type * from './compat-types.ts';
export {createSelectorStore} from './selectors.ts';
export type {ExternalSource} from './selectors.ts';
+1
View File
@@ -2,6 +2,7 @@
/** Explicit framework boundary; consumers install the declared optional peers. */
export * from './react-renderers.ts';
export * from './renderer-context.ts';
export * from './react.ts';
import type {Node,Edge} from '@xyflow/react';
import type {ProjectionNode,ProjectionEdge} from './projection.ts';
+9 -8
View File
@@ -2,18 +2,19 @@ import type { NodeTypes, EdgeTypes } from '@xyflow/react';
import type { ComponentDefinition, Registry } from '../canvas-core/types.ts';
import { fault } from '../canvas-core/values.ts';
import { createRendererCatalog, type RendererDescriptor, type RendererCatalog } from './renderers.ts';
export type ReactRendererDefinition=RendererDescriptor&({readonly structure:'node';readonly render:NodeTypes[string]}|{readonly structure:'connection';readonly render:EdgeTypes[string]});
export type ReactRenderers={readonly catalog:RendererCatalog;readonly definitions:readonly ReactRendererDefinition[];readonly nodeTypes:NodeTypes;readonly edgeTypes:EdgeTypes};
export type ReactRendererDefinition=RendererDescriptor&({readonly structure:'node';readonly render:NodeTypes[string];readonly preview?:NodeTypes[string]}|{readonly structure:'connection';readonly render:EdgeTypes[string];readonly preview?:EdgeTypes[string]});
export type ReactRenderers={readonly catalog:RendererCatalog;readonly definitions:readonly ReactRendererDefinition[];readonly nodeTypes:NodeTypes;readonly edgeTypes:EdgeTypes;readonly previewNodeTypes:NodeTypes;readonly previewEdgeTypes:EdgeTypes};
export type CanvasRuntimeExtension={readonly components?:readonly ComponentDefinition[];readonly renderers?:readonly ReactRendererDefinition[]};
export function createReactRenderers(definitions:readonly ReactRendererDefinition[],registry?:Registry):ReactRenderers{
const catalog=createRendererCatalog(definitions),nodes:NodeTypes=Object.create(null),edges:EdgeTypes=Object.create(null),captured:ReactRendererDefinition[]=[];
const catalog=createRendererCatalog(definitions),nodes:NodeTypes=Object.create(null),edges:EdgeTypes=Object.create(null),previewNodes:NodeTypes=Object.create(null),previewEdges:EdgeTypes=Object.create(null),captured:ReactRendererDefinition[]=[];
for(let index=0;index<definitions.length;index++){
const definition=definitions[index],descriptor=catalog.definitions[index],render=definition.render,tag=render&&typeof render==='object'?(render as {$$typeof?:symbol}).$$typeof:undefined;
const definition=definitions[index],descriptor=catalog.definitions[index],render=definition.render,preview=definition.preview,tag=render&&typeof render==='object'?(render as {$$typeof?:symbol}).$$typeof:undefined;
if(typeof render!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(tag as symbol))throw fault('invalid_react_renderer');
const previewTag=preview&&typeof preview==='object'?(preview as {$$typeof?:symbol}).$$typeof:undefined;if(preview!==undefined&&typeof preview!=='function'&&![Symbol.for('react.memo'),Symbol.for('react.forward_ref'),Symbol.for('react.lazy')].includes(previewTag as symbol))throw fault('invalid_react_preview');
if(registry){const model=registry.components.get(descriptor.componentId);if(!model||model.dataVersion!==descriptor.dataVersion||model.structure!==descriptor.structure)throw fault('renderer_model_mismatch',[descriptor.componentId]);}
const table=descriptor.structure==='node'?nodes:edges;
if(Object.hasOwn(table,descriptor.rendererId)&&table[descriptor.rendererId]!==render)throw fault('renderer_id_conflict',[descriptor.rendererId]);
Object.defineProperty(table,descriptor.rendererId,{value:render,enumerable:true,configurable:true});captured.push(Object.freeze({...descriptor,render}) as ReactRendererDefinition);
const table=descriptor.structure==='node'?nodes:edges,previews=descriptor.structure==='node'?previewNodes:previewEdges;
if(Object.hasOwn(table,descriptor.rendererId)&&(table[descriptor.rendererId]!==render||previews[descriptor.rendererId]!==preview))throw fault('renderer_id_conflict',[descriptor.rendererId]);
Object.defineProperty(table,descriptor.rendererId,{value:render,enumerable:true,configurable:true});if(preview)Object.defineProperty(previews,descriptor.rendererId,{value:preview,enumerable:true,configurable:true});captured.push(Object.freeze({...descriptor,render,...(preview?{preview}:{})}) as ReactRendererDefinition);
}
return Object.freeze({catalog,definitions:Object.freeze(captured),nodeTypes:Object.freeze(nodes),edgeTypes:Object.freeze(edges)});
return Object.freeze({catalog,definitions:Object.freeze(captured),nodeTypes:Object.freeze(nodes),edgeTypes:Object.freeze(edges),previewNodeTypes:Object.freeze(previewNodes),previewEdgeTypes:Object.freeze(previewEdges)});
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import {createContext,createElement,useCallback,useContext,useEffect,useMemo,useRef,useState,useSyncExternalStore,type ReactNode} from 'react';
import {createSelectorStore,type ExternalSource} from './selectors.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';
import type {DisplayHandle} from '../canvas-runtime/resources.ts';
export type CanvasBinding={readonly engine:ProjectEngine;readonly viewId?:Id};
const CanvasContext=createContext<CanvasBinding|null>(null);
/** Borrows the engine and an already attached view. Unmount never closes the project. */
export function CanvasProvider({engine,viewId,children}:{engine:ProjectEngine;viewId?:Id;children?:ReactNode}){const value=useMemo(()=>Object.freeze({engine,viewId}),[engine,viewId]);return createElement(CanvasContext.Provider,{value},children);}
export function useCanvasBinding(){const binding=useContext(CanvasContext);if(!binding)throw fault('canvas_provider_missing');return binding;}
function useSelected<S,T>(source:ExternalSource<S>,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is):T{
const committed=useRef<{value:T}|null>(null);
const store=useMemo(()=>createSelectorStore(source,(snapshot:S)=>{const selected=selector(snapshot);return committed.current&&isEqual(committed.current.value,selected)?committed.current.value:selected;},isEqual),[source,selector,isEqual]);
const value=useSyncExternalStore(store.subscribe,store.getSnapshot,store.getSnapshot);
useEffect(()=>{committed.current={value};},[value]);return value;
}
function useRootSource(){const {engine}=useCanvasBinding();return useMemo(()=>({getSnapshot:engine.getSnapshot,subscribe:(notify:()=>void)=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify)}),[engine]);}
export function useCanvas<T>(selector:(snapshot:EngineSnapshot)=>T,isEqual:(a:T,b:T)=>boolean=Object.is):T{return useSelected(useRootSource(),selector,isEqual);}
export type ElementView=ElementLookup|{readonly status:'stale';readonly element:ElementRecord};
export function useElement(ref:Id|Target):ElementView{
const {engine}=useCanvasBinding(),id=typeof ref==='string'?ref:ref.id,incarnation=typeof ref==='string'?undefined:ref.incarnation;
return useCanvas(useCallback(snapshot=>{const found=engine.editor.getQueries(snapshot.document).lookupElement(id);return found.status==='present'&&incarnation!==undefined&&found.element.incarnation!==incarnation?{status:'stale' as const,element:found.element}:found;},[engine,id,incarnation]),equal);
}
export type ActionOptions=Parameters<ProjectEngine['editor']['dispatch']>[1];
export function useActions(target:Target){const {engine}=useCanvasBinding(),{id,incarnation}=target;return useMemo(()=>{const ref=Object.freeze({id,incarnation});return Object.freeze({
rename:(title:string,options?:ActionOptions)=>engine.editor.dispatch({type:'element.data',target:ref,patch:{title}},options),
setData:(patch:Data,options?:ActionOptions)=>engine.editor.dispatch({type:'element.data',target:ref,patch},options),
resize:(patch:Partial<Geometry>,options?:ActionOptions)=>engine.editor.dispatch({type:'element.geometry',target:ref,patch},options),
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]);}
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 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 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;}});
},[engine,viewId,id,incarnation]);
useEffect(()=>()=>controls.cancel(),[controls]);return useMemo(()=>Object.freeze({...state,...controls}),[state,controls]);
}
export type OperationView={readonly operation:OperationRecord;readonly request?:FrozenRequest}|undefined;
export function useOperation(id:Id):OperationView{return useCanvas(useCallback(root=>{const operation=root.runtime.ledger.operations.get(id);return operation?{operation,...(operation.requestId?{request:root.runtime.ledger.requests.get(operation.requestId)}:{})}:undefined;},[id]),equal);}
type Durability=ReturnType<ProjectEngine['getDurability']>;
const syncDurability=(value:Durability)=>({dirty:value.rootRevision>value.durableRootRevision,error:value.error,closing:value.closing,closed:value.closed});
const inactiveOwner=(value:Durability)=>value.closing||value.closed;
function useDurability<T>(selector:(value:Durability)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){const {engine}=useCanvasBinding();const source=useMemo(()=>({getSnapshot:engine.getDurability,subscribe:engine.subscribeDurability}),[engine]);return useSelected(source,selector,isEqual);}
export function useSyncStatus(canvasId?:Id){
const durable=useDurability(syncDurability,equal),counts=useCanvas(useCallback(root=>{
const operations=[...root.runtime.ledger.operations.values()].filter(row=>row.phase!=='resolved'&&(canvasId===undefined||row.intent.canvasIds.includes(canvasId))),ids=new Set(operations.map(row=>row.intent.id)),requests=[...root.runtime.ledger.requests.values()].filter(row=>row.phase!=='resolved'&&row.operationIds.some(id=>ids.has(id)));
const blocked=operations.filter(row=>row.phase==='blocked'&&row.blockers.some(reason=>reason!=='waiting_digest')).length+requests.filter(row=>row.error&&row.retryAt===undefined).length,unknown=requests.filter(row=>row.outcome==='unresolved'&&['dispatch-marked','waiting'].includes(row.phase)).length;
return {pending:operations.length,blocked,unknown,loading:!!root.runtime.loading};
},[canvasId]),equal);
const dirty=durable.dirty;
return useMemo(()=>Object.freeze({...counts,dirty,error:durable.error,status:durable.closed?'closed' as const:durable.error||counts.blocked?'blocked' as const:durable.closing?'closing' as const:dirty?'saving' as const:counts.pending?'syncing' as const:counts.loading?'loading' as const:'synced' as const}),[counts,dirty,durable.error,durable.closing,durable.closed]);
}
export function useAsset(id:Id){
const {engine}=useCanvasBinding(),inactive=useDurability(inactiveOwner);
const metadata=useCanvas(useCallback(root=>({record:root.runtime.resources.get(id),binding:root.runtime.assetBindings?.get(id)}),[id]),equal);
const key=JSON.stringify([metadata.record?.operationId,metadata.record?.retirement?.purgedAt,metadata.binding?.authority,metadata.binding?.digest]);
const [attempt,setAttempt]=useState(0),[state,setState]=useState<{engine:ProjectEngine;id:Id;key:string;status:'loading'|'ready'|'error';url?:string;error?:unknown}>();
useEffect(()=>{if(inactive){setState({engine,id,key,status:'error',error:fault('engine_closing')});return;}const controller=new AbortController();let handle:DisplayHandle|undefined;setState({engine,id,key,status:'loading'});void engine.displayResource(id,{signal:controller.signal}).then(value=>{if(controller.signal.aborted){value.release();return;}handle=value;setState({engine,id,key,status:'ready',url:value.url});},error=>{if(!controller.signal.aborted)setState({engine,id,key,status:'error',error});});return()=>{controller.abort();handle?.release();};},[engine,id,key,attempt,inactive]);
const retry=useCallback(()=>setAttempt(value=>value+1),[]);
return useMemo(()=>Object.freeze({...metadata,...(inactive?{status:'error' as const,url:undefined,error:fault('engine_closing')}:state?.engine===engine&&state.id===id&&state.key===key?{status:state.status,url:state.url,error:state.error}:{status:'loading' as const,url:undefined,error:undefined}),retry}),[metadata,state,engine,id,key,retry,inactive]);
}
+11
View File
@@ -0,0 +1,11 @@
/** One immutable source, one cached selection; no second document or operation state. */
export type ExternalSource<S>={getSnapshot():S;subscribe(notify:()=>void):()=>void};
export function createSelectorStore<S,T>(source:ExternalSource<S>,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){
let initialized=false,snapshot:S,selection:T;
function getSnapshot():T{
const current=source.getSnapshot();if(initialized&&Object.is(snapshot,current))return selection;
const next=selector(current),same=initialized&&isEqual(selection,next);
snapshot=current;if(!same)selection=next;initialized=true;return selection;
}
return Object.freeze({getSnapshot,subscribe(notify:()=>void){let observed=getSnapshot();return source.subscribe(()=>{let next:T;try{next=getSnapshot();}catch{notify();return;}if(!Object.is(observed,next)){observed=next;notify();}});}});
}