"use client"; 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 {createRuntimeSources,type OperationView} from './runtime-sources.ts'; export type {OperationView} from './runtime-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 {ElementLookup} from '../canvas-core/queries.ts'; import {equal,fault} from '../canvas-core/values.ts'; import type {DisplayHandle} from '../canvas-runtime/resources.ts'; export type CanvasBinding={readonly engine:ProjectEngine;readonly viewId?:Id}; const CanvasContext=createContext(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(source:ExternalSource,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(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}; const elementSources=new WeakMap>(); function elementSourcesFor(engine:ProjectEngine){let sources=elementSources.get(engine);if(!sources){sources=createElementSources({getSnapshot:engine.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify),lookup:(root,id)=>engine.editor.getQueries(root.document).lookupElement(id)});elementSources.set(engine,sources);}return sources;} export function useElement(ref:Id|Target):ElementView; export function useElement(ref:Id|Target,selector:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T; export function useElement(ref:Id|Target,selector?:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T|ElementView{ const {engine}=useCanvasBinding(),id=typeof ref==='string'?ref:ref.id,incarnation=typeof ref==='string'?undefined:ref.incarnation; const source=useMemo(()=>elementSourcesFor(engine).forElement(id),[engine,id]); return useSelected(source,useCallback((found:ElementLookup)=>{const view=found.status==='present'&&incarnation!==undefined&&found.element.incarnation!==incarnation?{status:'stale' as const,element:found.element}:found;return selector?selector(view):view;},[incarnation,selector]),(isEqual as ((a:T|ElementView,b:T|ElementView)=>boolean)|undefined)??(selector?Object.is:equal)); } export type ActionOptions=Parameters[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,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]);} const sessionSources=new WeakMap>(); 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(left:ExternalSource,right:ExternalSource):ExternalSource{ 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 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; 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(()=>{ 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):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]); } const runtimeSources=new WeakMap>(); function runtimeSourcesFor(engine:ProjectEngine){let sources=runtimeSources.get(engine);if(!sources){sources=createRuntimeSources({getSnapshot:engine.getSnapshot,subscribe:notify=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify)});runtimeSources.set(engine,sources);}return sources;} export function useOperation(id:Id):OperationView{const {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).operation(id),[engine,id]);return useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot);} type Durability=ReturnType; 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(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 {engine}=useCanvasBinding(),source=useMemo(()=>runtimeSourcesFor(engine).counts(canvasId),[engine,canvasId]); const durable=useDurability(syncDurability,equal),counts=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot); 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 source=useMemo(()=>runtimeSourcesFor(engine).asset(id),[engine,id]),metadata=useSyncExternalStore(source.subscribe,source.getSnapshot,source.getSnapshot); 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]); }