141 lines
17 KiB
TypeScript
141 lines
17 KiB
TypeScript
/**
|
||
* React 对项目 engine 的借用层:订阅读取统一根、按目标代次发命令、管理本组件的手势/显示租约。
|
||
* Provider 不拥有项目生命周期,卸载只清理本 hook 的订阅与租约,不关闭共享 engine。
|
||
* useSyncExternalStore 读取稳定派生快照;可恢复业务事实始终由 editor/runtime 保存。
|
||
*/
|
||
"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';
|
||
|
||
/** 绑定一个 engine 和可选已 attach 的视图;纯文档查询不一定需要 viewId,交互模式/手势需要。 */
|
||
export type CanvasBinding={readonly engine:ProjectEngine;readonly viewId?:Id};
|
||
// 默认空上下文用于明确检测漏装 Provider,避免 hooks 悄悄读错项目。
|
||
const CanvasContext=createContext<CanvasBinding|null>(null);
|
||
/** Borrows the engine and an already attached view. Unmount never closes the project. */
|
||
/** 只 memo 化借用引用并向子组件提供上下文;不 attach/detach 视图,也不创建/销毁项目。 */
|
||
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);}
|
||
/** 取得当前绑定,缺 Provider 时抛出稳定领域错误交给应用错误边界。 */
|
||
export function useCanvasBinding(){const binding=useContext(CanvasContext);if(!binding)throw fault('canvas_provider_missing');return binding;}
|
||
/** 将派生源接到 useSyncExternalStore。committed 保存最近已提交渲染的选中值,
|
||
* selector 或等价函数换引用后仍能复用等价结果;effect 才推进已提交基线,不在 render 中认领提交。 */
|
||
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;
|
||
}
|
||
/** 对 engine memo 化根源;已关闭实例不再注册 editor 监听,读取仍来自该实例的最终快照。 */
|
||
function useRootSource(){const {engine}=useCanvasBinding();return useMemo(()=>({getSnapshot:engine.getSnapshot,subscribe:(notify:()=>void)=>engine.getDurability().closed?()=>{}:engine.editor.subscribe(notify)}),[engine]);}
|
||
/** 按选择器订阅根的一部分,默认 Object.is,调用方可给明确等价判断避免无关刷新。 */
|
||
export function useCanvas<T>(selector:(snapshot:EngineSnapshot)=>T,isEqual:(a:T,b:T)=>boolean=Object.is):T{return useSelected(useRootSource(),selector,isEqual);}
|
||
/** 按 Target 查询时即使同 ID 有新元素也返回 stale,明确提示旧代次绑定失效。 */
|
||
export type ElementView=ElementLookup|{readonly status:'stale';readonly element:ElementRecord};
|
||
// 每 engine 共享按 ID 路由器,WeakMap 不额外持有已无人引用的项目;不是每个 hook 一份根表。
|
||
const elementSources=new WeakMap<ProjectEngine,ReturnType<typeof createElementSources>>();
|
||
/** 惰性装配元素查询桶;lookup 使用指定根文档对应的 editor 查询,避免新旧快照错配。 */
|
||
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<T>(ref:Id|Target,selector:(view:ElementView)=>T,isEqual?:(a:T,b:T)=>boolean):T;
|
||
/** ID 查询跟随当前元素;Target 查询额外核对代次。默认完整视图按值比较,自定义选择默认按引用。 */
|
||
export function useElement<T>(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));
|
||
}
|
||
/** 复用 editor 的历史标签/分组选项,不在绑定层发明另一套提交参数。 */
|
||
export type ActionOptions=Parameters<ProjectEngine['editor']['dispatch']>[1];
|
||
/** 捕获不可变 ID+代次并提供普通数据/几何/删除/移动命令;旧组件的动作由 editor 拒绝旧代次。 */
|
||
export function useActions(target:Target){const {engine}=useCanvasBinding(),{id,incarnation}=target;return useMemo(()=>{const ref=Object.freeze({id,incarnation});return Object.freeze({
|
||
// 各操作只转成正常 command;校验、history 和同步依赖由 editor/runtime 在统一边界处理。
|
||
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]);}
|
||
// 模式与手势共享每项目的按 ID 会话路由器,不缓存完整 session 到每个 hook。
|
||
const sessionSources=new WeakMap<ProjectEngine,ReturnType<typeof createSessionSources>>();
|
||
/** 惰性绑定 session 源;已关闭 engine 不再订阅其会话。 */
|
||
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;}}};
|
||
}
|
||
/** 需要交互所有权的 hook 必须有 viewId,不能从任意其它视图借用会话。 */
|
||
function useView(){const binding=useCanvasBinding();if(!binding.viewId)throw fault('view_session_required');return {...binding,viewId:binding.viewId};}
|
||
/** 将绑定控制异常转为 Availability,保留领域码/目标,未知异常归 binding_failed。 */
|
||
function available(error:unknown):Availability{return{enabled:false,code:(error as {code?:string}).code??'binding_failed',targets:(error as {targets?:readonly Id[]}).targets??[]};}
|
||
/** 同时订阅目标身份与该视图模式;旧代次返回 null,设置模式前再次读取当前元素校验。 */
|
||
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]);
|
||
}
|
||
/** 组合文档几何和 session 预览。begin 取得手势 token,update/commit/cancel 必须仍拥有同 token/view。
|
||
* effect 清理只取消本绑定取得的手势,不能因旧组件卸载取消后来另一视图的新手势。 */
|
||
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]);
|
||
// 有匹配的节点记录才提供几何,预览优先;active 来自 session 的手势所属视图判定。
|
||
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;
|
||
/** token + view + active phase 三重检查,不仅凭元素 ID 判断控制权。 */
|
||
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]);
|
||
// 控制对象因目标/视图变化而替换时也释放旧租约;成功 commit 的 finally 已清掉本地 token。
|
||
useEffect(()=>()=>controls.cancel(),[controls]);return useMemo(()=>Object.freeze({...state,...controls}),[state,controls]);
|
||
}
|
||
// operation/资源元数据/计数在同 engine 下共用分桶订阅,避免全项目每个 hook 扫一遍任务表。
|
||
const runtimeSources=new WeakMap<ProjectEngine,ReturnType<typeof createRuntimeSources>>();
|
||
/** 惰性建立 runtime 源,在 editor 成功根发布时观察表引用变化。 */
|
||
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);}
|
||
/** 本地可靠保存状态,与网络 ACK/任务结果分开观察。 */
|
||
type Durability=ReturnType<ProjectEngine['getDurability']>;
|
||
/** dirty 只说明 rootRevision 超过可靠保存根,不能用它单独判定网络同步是否结束。 */
|
||
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;
|
||
/** 使用 engine 独立的 durability 订阅,磁盘完成/错误不必伪造一次文档编辑来通知 UI。 */
|
||
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);}
|
||
/** 合成任务计数与项目保存状态,展示优先级为 closed→blocked→closing→saving→syncing→loading→synced。
|
||
* canvas 计数局部化,但 dirty/error/closing 是项目保存层级,不能当作仅该 canvas 的独立磁盘状态。 */
|
||
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]);
|
||
}
|
||
/** 订阅资源元数据并在 effect 借用显示句柄。engine/id/来源 key 变化重建尝试,retry 显式重取。
|
||
* 异步返回先核对 abort;渲染也核对 state 所属 engine/id/key,不能短暂显示上个项目或旧来源的 URL。 */
|
||
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);
|
||
// 本地上传身份/清除状态、共享授权域/摘要是显示来源的关键身份,不按 URL 字符串猜生命周期。
|
||
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}>();
|
||
// 清理中止本次读取并归还 handle;迟到成功也立即释放,不把旧结果写入当前显示。
|
||
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]);
|
||
/** 重试仅增加本 hook 的尝试序号,由 effect 清理旧租约再请求新显示句柄。 */
|
||
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]);
|
||
}
|