Expose React canvas hooks with scoped selectors and resource lifecycles
This commit is contained in:
@@ -11,4 +11,4 @@ import { IconNode } from './icon-node';
|
||||
import { CanvasEdge } from './canvas-edge';
|
||||
function HeadlessFrameNode(props:NodeProps<CanvasNode>){return <FrameNode {...props} directAnchors />;}
|
||||
const nodes={material:MaterialNode,plainText:PlainTextNode,plainImage:PlainImageNode,frame:HeadlessFrameNode,icon:IconNode};
|
||||
export const defaultCanvasRenderers=createReactRenderers(builtinRendererDefinitions.map(definition=>({...definition,render:definition.structure==='node'?nodes[definition.rendererId as keyof typeof nodes]:CanvasEdge}) as ReactRendererDefinition));
|
||||
export const defaultCanvasRenderers=createReactRenderers(builtinRendererDefinitions.map(definition=>{const render=definition.structure==='node'?nodes[definition.rendererId as keyof typeof nodes]:CanvasEdge;return{...definition,render,preview:render} as ReactRendererDefinition;}));
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProv
|
||||
import { Copy, Download, Frame, Hand, ImagePlus, Images, Maximize, Minus, Ratio, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react";
|
||||
import type { Board } from "@/lib/workspace";
|
||||
import type { ReactRenderers } from "@/lib/canvas-bindings/react-flow";
|
||||
import { CanvasRendererCommandsContext, type CanvasRendererCommands } from "@/lib/canvas-bindings/react-flow";
|
||||
import { CanvasProvider, CanvasRendererCommandsContext, type CanvasRendererCommands } from "@/lib/canvas-bindings/react-flow";
|
||||
import { defaultCanvasRenderers } from "./canvas-renderers";
|
||||
import type { Data, ElementRecord, Geometry, Id, Json, Result } from "@/lib/canvas-core/types";
|
||||
import { equal } from "@/lib/canvas-core/values";
|
||||
@@ -275,5 +275,5 @@ export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCa
|
||||
const [handoffViewport]=useState(initialViewport);
|
||||
const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false);
|
||||
useEffect(() => { const detach = engine.sessions.attach(viewId, board.id);if(handoffViewport)engine.sessions.viewport(viewId,handoffViewport); setAttached(true); return () => { setAttached(false); detach(); }; }, [engine, viewId, board.id,handoffViewport]);
|
||||
return attached ? <ReactFlowProvider><CanvasView engine={engine} board={board} viewId={viewId} renderers={renderers} fitLoading={!handoffViewport} /></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
|
||||
return attached ? <ReactFlowProvider><CanvasProvider engine={engine} viewId={viewId}><CanvasView engine={engine} board={board} viewId={viewId} renderers={renderers} fitLoading={!handoffViewport} /></CanvasProvider></ReactFlowProvider> : <p role="status">正在准备画布…</p>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {useEffect,useMemo,useRef} from 'react';
|
||||
import {Background,BackgroundVariant,ReactFlow,useReactFlow,type Viewport,type NodeTypes} from '@xyflow/react';
|
||||
import {Background,BackgroundVariant,ReactFlow,useReactFlow,type Viewport,type NodeTypes, type NodeProps,Handle,Position} from '@xyflow/react';
|
||||
import type {Registry} from '@/lib/canvas-core/types';
|
||||
import type {ModelPreview} from '@/lib/canvas-runtime/model-preview';
|
||||
import type {ReactRenderers} from '@/lib/canvas-bindings/react-flow';
|
||||
@@ -11,6 +11,7 @@ import {CanvasRendererCommandsContext,type CanvasRendererCommands} from '@/lib/c
|
||||
import {UnsupportedNode,UnsupportedEdge} from './unsupported-elements';
|
||||
import styles from './canvas.module.css';
|
||||
|
||||
function PreparingNode({data}:NodeProps){return <div style={{width:'100%',height:'100%',padding:14,border:'1px solid #ded7ce',borderRadius:8,background:'#fffdfa'}} aria-label="正在准备元素"><span>{typeof data.title==='string'?data.title:'正在准备元素…'}</span>{(data.__previewHandles as {id?:string;type:'source'|'target'}[]??[]).map((handle,index)=><Handle key={index} type={handle.type} id={handle.id} position={handle.type==='source'?Position.Right:Position.Left} isConnectable={false}/>)}</div>;}
|
||||
const reject=()=>({status:'rejected' as const,code:'model_loading',targets:[]});
|
||||
const commands:CanvasRendererCommands=Object.freeze({dispatch:reject,transaction:reject});
|
||||
function InitialPreviewViewport({onViewport,nodeCount}:{onViewport(viewport:Viewport):void;nodeCount:number}){
|
||||
@@ -26,9 +27,10 @@ export default function LoadingGraphCanvas({preview,canvasId,registry,renderers,
|
||||
const catalog=useMemo(()=>createLoadingRendererCatalog(registry,renderers.catalog),[registry,renderers]);
|
||||
const projection=useMemo(()=>{
|
||||
const value=projectCanvas(preview.document,canvasId,registry,new Set(),()=>undefined,undefined,catalog);
|
||||
return{nodes:value.nodes.map(node=>({...node,draggable:false,selectable:false,focusable:false,connectable:false,style:{...node.style,pointerEvents:'none' as const},data:{...node.data,...(registry.resolve(preview.document.elements.get(node.id)!)==='known'&&typeof node.data.assetId==='string'&®istry.components.get(preview.document.elements.get(node.id)!.componentId)?.resourceFields?.includes('assetId')?{src:`/api/foundry/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(node.data.assetId)}?variant=preview`}:{})}})),edges:value.edges.map(edge=>({...edge,selectable:false,focusable:false}))};
|
||||
const handles=new Map<string,{id?:string;type:'source'|'target'}[]>();for(const edge of value.edges)for(const type of ['source','target'] as const){const id=edge[type],rows=handles.get(id)??[];rows.push({type,id:edge[type==='source'?'sourceHandle':'targetHandle']??undefined});handles.set(id,rows);}
|
||||
return{nodes:value.nodes.map(node=>({...node,draggable:false,selectable:false,focusable:false,connectable:false,style:{...node.style,pointerEvents:'none' as const},data:{...node.data,__previewHandles:handles.get(node.id)??[],...(registry.resolve(preview.document.elements.get(node.id)!)==='known'&&typeof node.data.assetId==='string'&®istry.components.get(preview.document.elements.get(node.id)!.componentId)?.resourceFields?.includes('assetId')?{src:`/api/foundry/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(node.data.assetId)}?variant=preview`}:{})}})),edges:value.edges.map(edge=>({...edge,selectable:false,focusable:false}))};
|
||||
},[preview.document,canvasId,workspaceId,registry,catalog]);
|
||||
const nodeTypes=useMemo<NodeTypes>(()=>{const result:NodeTypes={};for(const [id,Renderer]of Object.entries({...renderers.nodeTypes,unsupported:UnsupportedNode}))result[id]=props=><div inert style={{width:'100%',height:'100%'}}><Renderer {...props}/></div>;return result;},[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]);
|
||||
const nodeTypes=useMemo<NodeTypes>(()=>{const result:NodeTypes={};for(const [id,Renderer]of Object.entries({...Object.fromEntries(Object.keys(renderers.nodeTypes).map(id=>[id,renderers.previewNodeTypes[id]??PreparingNode])),unsupported:UnsupportedNode}))result[id]=props=><div inert style={{width:'100%',height:'100%'}}><Renderer {...props}/></div>;return result;},[renderers]),edgeTypes=useMemo(()=>({...Object.fromEntries(Object.keys(renderers.edgeTypes).map(id=>[id,renderers.previewEdgeTypes[id]??UnsupportedEdge])),unsupported:UnsupportedEdge}),[renderers]);
|
||||
return <div className={styles.editor} data-canvas-loading-preview aria-label="正在加载的画布,只读预览">
|
||||
<CanvasRendererCommandsContext.Provider value={commands}>
|
||||
<ReactFlow nodes={projection.nodes} edges={projection.edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} nodesDraggable={false} nodesConnectable={false} elementsSelectable={false} nodesFocusable={false} edgesFocusable={false} panOnDrag panOnScroll zoomOnScroll={false} minZoom={.1} maxZoom={4} deleteKeyCode={null} onMoveEnd={(_,viewport)=>onViewport(viewport)}>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)});
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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();}});}});
|
||||
}
|
||||
@@ -75,6 +75,9 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const digestLifecycle=new AbortController();
|
||||
const sourceDigest=(bytes:Blob,signal?:AbortSignal)=>{const combined=signal?AbortSignal.any([signal,digestLifecycle.signal]):digestLifecycle.signal;return options.digests?options.digests.digest(bytes,combined):importSourceDigest(bytes,combined);};
|
||||
const report = (error: unknown) => { try { options.onError?.(error); } catch { /* Error reporting cannot alter state. */ } };
|
||||
const durabilityListeners=new Set<()=>void>();let durabilityError:string|undefined,durabilityCache:Readonly<{rootRevision:number;durableRootRevision:number;closing:boolean;closed:boolean;error?:string}>|undefined;
|
||||
function getDurability(){const rootRevision=editor.getSnapshot().rootRevision;if(!durabilityCache||durabilityCache.rootRevision!==rootRevision||durabilityCache.durableRootRevision!==durableRootRevision||durabilityCache.closing!==closing||durabilityCache.closed!==closed||durabilityCache.error!==durabilityError)durabilityCache=Object.freeze({rootRevision,durableRootRevision,closing,closed,...(durabilityError?{error:durabilityError}:{})});return durabilityCache;}
|
||||
function notifyDurability(){for(const listener of [...durabilityListeners])try{listener();}catch(error){report(error);}}
|
||||
const createId = options.createId ?? (() => crypto.randomUUID());
|
||||
const network = createNetworkGate(2);
|
||||
const depot=options.assetDepot??(options.storage.assetDepot?createAssetDepot({scope:options.scope,port:options.storage.assetDepot,current:()=>!closing&&!closed&&(!options.isCurrentIdentity||options.isCurrentIdentity()),digest:sourceDigest}):undefined);
|
||||
@@ -195,15 +198,15 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const depot=options.assets?.authority&&options.storage.assetDepot?await prepareDepotCut({snapshot,registry:options.registry,storage:options.storage,leader,authority:options.assets.authority.scope,files:fileWrites,adoptedFiles,displays:displayPins,temporary:admissionPins(snapshot)}):undefined;
|
||||
const receipt = await options.storage.commit(leader, { image: encodeRecovery(snapshot), files,deleteFiles,...(depot?{depot}:{}) });
|
||||
if (receipt.durableRootRevision !== revision) throw fault('invalid_durable_receipt');
|
||||
durableRootRevision = revision; pending.delete(revision);
|
||||
durableRootRevision = revision; pending.delete(revision);notifyDurability();
|
||||
for(const id of deleteFiles){durablyPurged.add(id);collectionReservations.delete(id);}
|
||||
for (const file of files) fileWrites.delete(file.id);
|
||||
for(const [id,entry]of adoptedFiles)if(entry.rootRevision<=snapshot.rootRevision&&depot?.writes.some(write=>write.ref.asset.id===id&&write.bytes===entry.bytes))adoptedFiles.delete(id);
|
||||
if (editor.getSnapshot().runtime.ledger.durableGeneration < snapshot.runtime.ledger.generation) mutate(ledger => ledger.markDurable(snapshot.runtime.ledger.generation));
|
||||
}
|
||||
persistenceFailed = false;
|
||||
persistenceFailed = false;durabilityError=undefined;notifyDurability();
|
||||
})();
|
||||
writing = task.catch(error => { persistenceFailed = true; throw error; }).finally(() => { writing = undefined;if(!persistenceFailed){if(loadingCompletionRevision!==undefined&&durableRootRevision>=loadingCompletionRevision&&!editor.getSnapshot().runtime.loading){const release=loadingRelease;loadingRelease=undefined;loadingCompletionRevision=undefined;release?.();}if(!closing&&!closed)scheduler.refreshAdmission();} });
|
||||
writing = task.catch(error => { persistenceFailed = true;const failure=error&&typeof error==='object'?error as {code?:unknown;message?:unknown}:{};durabilityError=typeof failure.code==='string'&&failure.code?failure.code:typeof failure.message==='string'&&failure.message?failure.message:'persistence_failed';notifyDurability(); throw error; }).finally(() => { writing = undefined;if(!persistenceFailed){if(loadingCompletionRevision!==undefined&&durableRootRevision>=loadingCompletionRevision&&!editor.getSnapshot().runtime.loading){const release=loadingRelease;loadingRelease=undefined;loadingCompletionRevision=undefined;release?.();}if(!closing&&!closed)scheduler.refreshAdmission();} });
|
||||
return writing;
|
||||
}
|
||||
function collectResources(settings:{retentionMs:number;maxFiles?:number}) {
|
||||
@@ -518,7 +521,7 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
const sessions = createSessions(editor);
|
||||
const unsubscribeSizing = sessions.subscribe(() => { if (!sessions.getSnapshot().gesture) queueDefaultSizes(); });
|
||||
const unsubscribe = editor.subscribe((commit, snapshot) => {
|
||||
pending.set(snapshot.rootRevision, snapshot);
|
||||
pending.set(snapshot.rootRevision, snapshot);notifyDurability();
|
||||
const addedRuns=[...snapshot.runtime.runs.keys()].filter(id=>!trackedRuns.has(id));for(const id of addedRuns)trackedRuns.add(id);
|
||||
if(addedRuns.length)queueMicrotask(()=>runTracker.notify(addedRuns));
|
||||
if (!scheduled) { scheduled = true; queueMicrotask(() => { scheduled = false; void persist().catch(report); }); }
|
||||
@@ -980,17 +983,18 @@ export async function openProjectEngine(options: EngineOptions) {
|
||||
},
|
||||
getSnapshot: editor.getSnapshot, persist,
|
||||
cancelRecovery:async()=>{await recovery?.cancel();},
|
||||
getDurability: () => Object.freeze({ rootRevision: editor.getSnapshot().rootRevision, durableRootRevision }),
|
||||
getDurability,
|
||||
subscribeDurability(listener:()=>void){if(closed)return()=>{};durabilityListeners.add(listener);return()=>{durabilityListeners.delete(listener);};},
|
||||
retry: async () => { if(recovery)await recovery.cancel();assertWritable(false);await persist();retryDefaultSizes();const runtime=editor.getSnapshot().runtime,ids=[...runtime.resources.values()].filter(resource=>!resource.digest&&runtime.ledger.operations.get(resource.operationId)?.blockers.some(code=>digestFailures.has(code))).map(resource=>resource.operationId);if(ids.length){mutate(ledger=>{for(const id of ids)ledger.block(id,['waiting_digest']);});prepareDigests();}if(options.images){editor.updateRuntime(previous=>{const resources=new Map(previous.resources);let changed=false;for(const [id,resource]of resources)if(resource.image?.version===1&&resource.image.status==='failed'){resources.set(id,freeze({...resource,image:{version:1 as const,status:'pending' as const}}));changed=true;}return changed?Object.freeze({...previous,resources:readMap(resources)}):previous;});prepareImages();}await scheduler.resume();await runTracker.retry(); },
|
||||
async settle() { await Promise.resolve();await Promise.allSettled([...adoptionTasks.values()]);await Promise.allSettled([...copyPreparations.values()]);while(importLoads.size)await Promise.all([...importLoads.values()]);await Promise.all([...importRunners.values()].map(runner=>runner.settle())); while (digestTasks.size) await Promise.all([...digestTasks.values()]);while(imageTasks.size)await Promise.all([...imageTasks.values()]);while(sizingTask)await sizingTask; await scheduler.settle(); await runTracker.settle(); await persist();await scheduler.settle();await persist(); },
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closing = true;loadingReadController?.abort();await loadingRead?.catch(()=>{});unsubscribeSizing();orphanController?.abort();await Promise.allSettled([collection,sourceCollection,orphanCollection].filter(Boolean));digestLifecycle.abort();for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]);for(const controller of adoptionTasks.keys())controller.abort();await Promise.allSettled([...adoptionTasks.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop();
|
||||
closing = true;notifyDurability();loadingReadController?.abort();await loadingRead?.catch(()=>{});unsubscribeSizing();orphanController?.abort();await Promise.allSettled([collection,sourceCollection,orphanCollection].filter(Boolean));digestLifecycle.abort();for(const controller of preparationControllers.values())controller.abort();for(const controller of copyPreparations.keys())controller.abort();await Promise.allSettled([...copyPreparations.values()]);for(const controller of adoptionTasks.keys())controller.abort();await Promise.allSettled([...adoptionTasks.values()]); sessions.dispose(); scheduler.stop(); runTracker.stop();
|
||||
await Promise.all([...importRunners.values()].map(runner=>runner.close()));while(importLoads.size)await Promise.all([...importLoads.values()]);while(digestTasks.size)await Promise.allSettled([...digestTasks.values()]);while(imageTasks.size)await Promise.allSettled([...imageTasks.values()]);while(sizingTask)await sizingTask;
|
||||
recovery?.controller.abort();recovery?.release();
|
||||
for (const release of [...displays]) release();displayCache.releaseOwner(displayOwner);if(!options.displays)displayCache.close();
|
||||
// Failure keeps the leader/recovery material available for an explicit retry of close.
|
||||
await persist(); unsubscribe(); editor.dispose();if(!options.assetDepot)depot?.close();adoptedFiles.clear(); await leader.release(); closed = true;
|
||||
await persist(); unsubscribe(); editor.dispose();if(!options.assetDepot)depot?.close();adoptedFiles.clear(); await leader.release(); closed = true;notifyDurability();durabilityListeners.clear();
|
||||
},
|
||||
});
|
||||
} catch (error) { await leader.release(); throw error; }
|
||||
|
||||
Reference in New Issue
Block a user