docs(canvas): explain React bindings renderer registration and view leases
This commit is contained in:
@@ -1,14 +1,27 @@
|
||||
/**
|
||||
* 旧卡片 renderer 使用的结构化输入类型,与 headless 元素 schema 分离。
|
||||
* 这些类型描述投影/显示约定,不能用类型断言替代 registry 对原始数据、版本和引用的运行时校验。
|
||||
*/
|
||||
/** 旧绘制路径使用的二维点;是否局部/场景由投影路径的调用约定决定。 */
|
||||
type Point={x:number;y:number};
|
||||
|
||||
/** Compatibility renderer data only; never the authoritative element schema. */
|
||||
/** 兼容卡片显示的 AI 配置,不是已受理生成任务或冻结请求。 */
|
||||
export type AIConfig = { enabled?: boolean; modelId?: string; prompt?: string; propagate?: boolean; parameters?: Record<string, unknown> };
|
||||
/** 兼容分区端口布局数据,连接到内部 node/handle 的关系由业务模型解释。 */
|
||||
export type FramePort = { id: string; direction: "input" | "output"; nodeId: string; handle: string };
|
||||
/** 展开/折叠时的显示尺寸与内容比例,投影时用于分区展示。 */
|
||||
export type FramePresentation = { collapsed?: boolean; contentScale?: number; expandedSize?: { width: number; height: number }; collapsedSize?: { width: number; height: number } };
|
||||
|
||||
/** 内置旧卡片的类型枚举,不限制 registry 可扩展的 componentId 集合。 */
|
||||
export type ElementKind = "text" | "image" | "script" | "plain-text" | "icon" | "frame" | "plain-image";
|
||||
/** 分区标题的隐藏/固定大小/随缩放显示模式。 */
|
||||
export type FrameLabelMode = "hidden" | "fixed" | "scaled";
|
||||
/** 卡片正文的兼容排版配置,不含编辑器实例或 DOM 状态。 */
|
||||
export type TextStyle = { color?: string; background?: string; bold?: boolean; italic?: boolean; underline?: boolean; align?: "left" | "center" | "right"; fontFamily?: "sans" | "serif" | "mono"; lineHeight?: number };
|
||||
/** 显示视图与真实 owner 分开记录;id+incarnation 供命令拒绝旧代次目标。 */
|
||||
export type RenderedTarget={readonly id:string;readonly incarnation:number;readonly ownerCanvasId:string;readonly canvasId:string};
|
||||
/** 节点投影输入:正文/样式/资源及分区入口,不是另一份可直接写入的文档记录。 */
|
||||
export type CanvasNodeData = {
|
||||
__headlessTarget?:RenderedTarget;
|
||||
kind: ElementKind;
|
||||
@@ -29,9 +42,13 @@ export type CanvasNodeData = {
|
||||
viewScale?: number;
|
||||
};
|
||||
|
||||
/** 旧边绘制器支持的端点标记形状。 */
|
||||
export type MarkerShape = "none" | "open-arrow" | "triangle" | "circle" | "diamond" | "square";
|
||||
/** 箭头/符号尺寸与填充;不等于连线的真实端口身份。 */
|
||||
export type EdgeMarker = { type: MarkerShape; size?: number; filled?: boolean };
|
||||
/** 贝塞尔链控制结点,包含两侧切线与编辑约束模式。 */
|
||||
export type BezierKnot = { id: string; position: Point; in: Point; out: Point; mode: "corner" | "smooth" | "symmetric" };
|
||||
/** 旧边外观、动画、已投影路径和执行样式;实际生成/执行事实由 runtime 持有。 */
|
||||
export type EdgeData = {
|
||||
appearance?: { color?: string; width?: number; opacity?: number; line?: "solid" | "dashed" | "dotted"; head?: EdgeMarker; tail?: EdgeMarker };
|
||||
animation?: { mode?: "off" | "always" | "executing"; effect?: "flow" | "dash" | "pulse"; direction?: "forward" | "reverse"; durationMs?: number; color?: string };
|
||||
@@ -40,4 +57,5 @@ export type EdgeData = {
|
||||
};
|
||||
|
||||
/** Geometry input consumed by the compatibility layout, independent of UI nodes. */
|
||||
/** 仅供兼容锚点/布局计算的节点几何,measured 优先级由具体布局函数决定。 */
|
||||
export type CanvasGeometryNode={position:Point;width?:number;height?:number;style?:{width?:unknown;height?:unknown};measured?:{width?:number;height?:number};data:{kind:string;rotation?:number;fontSize?:number;viewScale?:number}};
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
/**
|
||||
* 可移植 bindings 的公共入口,集中导出投影、查询、输入与显示租约工具。
|
||||
* 不导入 React/React Flow;框架 hooks、组件注册与命令 Context 通过 react-flow.ts 明确选择。
|
||||
*/
|
||||
/** Portable scene projection and hit queries: no React or React Flow imports. */
|
||||
// 模型到场景投影、几何缓存和受预算控制的命中查询。
|
||||
export * from './projection.ts';
|
||||
export * from './geometry-cache.ts';
|
||||
export * from './projection-cache.ts';
|
||||
export * from './hit-query.ts';
|
||||
// 框架无关 renderer 描述、锚点约定与资源视图租约。
|
||||
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';
|
||||
export {routeCanvasKey,canvasKeyContext} from './input-router.ts';
|
||||
export type {CanvasKeyInput,InputClaim,KeyHandler} from './input-router.ts';
|
||||
// 仅显示失效通知的帧合并和绘制范围索引,业务命令不在这里排队。
|
||||
export {createFrameQueue,createFrameSource} from './frame-queue.ts';
|
||||
export type {FrameClock} from './frame-queue.ts';
|
||||
export {createSessionFrameSource} from './session-frames.ts';
|
||||
@@ -19,6 +27,7 @@ export {createPaintIndex} from './paint-index.ts';
|
||||
export type {PaintRegion} from './paint-index.ts';
|
||||
export {projectedPaintOverflow,createProjectedPaintIndex} from './projected-paint.ts';
|
||||
export {projectedConnectionPaintOverflow,createConnectionPaintIndex} from './connection-paint.ts';
|
||||
// 视图局部浮层与 DOM 指针捕获的生命周期工具。
|
||||
export {createOverlayIndex} from './overlays.ts';
|
||||
export type {OverlayEntry,OverlayIndex} from './overlays.ts';
|
||||
export {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from './pointer-capture.ts';
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
/**
|
||||
* 视图局部的屏幕空间浮层索引。保存位置、目标代次与焦点/输入法状态,独立于业务文档和撤销历史。
|
||||
* 同 ID 浮层重新注册时获得新 token,旧组件迟到清理不能删掉新浮层。
|
||||
*/
|
||||
import type {Target} from '../canvas-core/types.ts';
|
||||
import type {Rect} from '../canvas-core/geometry.ts';
|
||||
import {createPaintIndex} from './paint-index.ts';
|
||||
import {fault} from '../canvas-core/values.ts';
|
||||
/** bounds 使用屏幕空间;target 仍是真实元素 ID+代次,viewId 限定所属交互视图。 */
|
||||
export type OverlayEntry={readonly id:string;readonly viewId:string;readonly placement:string;readonly target:Target;readonly bounds:Rect;readonly focused:boolean;readonly composing:boolean};
|
||||
/** Ephemeral screen-space placements. A lease prevents late cleanup from removing a replacement. */
|
||||
/** 为一个有效 viewId 创建租约表、冻结快照和可重建 paint 网格。 */
|
||||
export function createOverlayIndex(viewId:string){
|
||||
if(typeof viewId!=='string'||!viewId)throw fault('view_session_required');let serial=0,snapshot:readonly OverlayEntry[]=Object.freeze([]);const entries=new Map<string,{token:number;entry:OverlayEntry}>(),listeners=new Set<()=>void>(),index=createPaintIndex();
|
||||
/** 从当前条目重建快照和空间索引后通知;这里只同步位置事实,不决定浮层 DOM 的挂载。 */
|
||||
function publish(){snapshot=Object.freeze([...entries.values()].map(row=>row.entry));index.sync(snapshot.map(entry=>({id:entry.id,bounds:entry.bounds})));for(const listener of [...listeners])listener();}
|
||||
/** 复制并冻结目标/矩形,验证身份和位置;复用 paint 索引校验矩形,拒绝不合法的外部边界。 */
|
||||
function capture(id:string,value:Omit<OverlayEntry,'id'|'viewId'>):OverlayEntry{if(typeof id!=='string'||!id||typeof value.placement!=='string'||!value.placement||typeof value.target.id!=='string'||!value.target.id||!Number.isSafeInteger(value.target.incarnation)||value.target.incarnation<1)throw fault('invalid_overlay');const entry=Object.freeze({...value,id,viewId,target:Object.freeze({...value.target}),bounds:Object.freeze({...value.bounds})});const check=createPaintIndex();check.sync([{id,bounds:entry.bounds}]);return entry;}
|
||||
// 订阅只关注本视图位置变化,调用方需用返回函数解除监听;getSnapshot 返回稳定的最近发布值。
|
||||
return Object.freeze({viewId,getSnapshot:()=>snapshot,subscribe(listener:()=>void){listeners.add(listener);return()=>{listeners.delete(listener);};},
|
||||
/** 注册或替换一个浮层,返回只能操作本 token 的 update/release。
|
||||
* update 先完整校验,新旧序列化值相同时不发布;过期租约的更新/释放均无效果。 */
|
||||
register(id:string,value:Omit<OverlayEntry,'id'|'viewId'>){const token=++serial,entry=capture(id,value);entries.set(id,{token,entry});publish();return Object.freeze({update(value:Omit<OverlayEntry,'id'|'viewId'>){if(entries.get(id)?.token!==token)return;const entry=capture(id,value),old=entries.get(id)!.entry;if(JSON.stringify(old)===JSON.stringify(entry))return;entries.set(id,{token,entry});publish();},release(){if(entries.get(id)?.token!==token)return;entries.delete(id);publish();}});},
|
||||
/** 正常浮层按视口交集筛选;已聚焦或输入法组合中的浮层保留,但目标必须仍有效。 */
|
||||
visible(bounds:Rect,current:(target:Target)=>boolean){const ids=index.query(bounds);return snapshot.filter(entry=>current(entry.target)&&(entry.focused||entry.composing||ids.has(entry.id)));},
|
||||
/** 清空所有当前浮层并发布空快照;不关闭监听服务,后续仍可注册新租约。 */
|
||||
clear(){entries.clear();publish();},
|
||||
});
|
||||
}
|
||||
/** 由工厂推导接口,调用方无权直接操作内部 token/Map。 */
|
||||
export type OverlayIndex=ReturnType<typeof createOverlayIndex>;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/**
|
||||
* 显式 React/React Flow 框架入口。按需导出 hooks、组件 renderer 装配和命令 Context,
|
||||
* 可移植 bindings/index.ts 不引用这些框架依赖;use client 指令保留模块客户端边界。
|
||||
*/
|
||||
"use client";
|
||||
/** Explicit framework boundary; consumers install the declared optional peers. */
|
||||
// 显示组件注册、命令注入和业务快照 hooks,均由应用在客户端装配。
|
||||
export * from './react-renderers.ts';
|
||||
export * from './renderer-context.ts';
|
||||
export * from './react.ts';
|
||||
@@ -7,5 +12,7 @@ export * from './react.ts';
|
||||
import type {Node,Edge} from '@xyflow/react';
|
||||
import type {ProjectionNode,ProjectionEdge} from './projection.ts';
|
||||
/** Structural adapter types add framework-owned optional metadata, never model state. */
|
||||
/** 在可移植节点投影上叠加框架可选元数据,不把测量/选择等框架字段写回文档。 */
|
||||
export type ReactFlowProjectionNode=Node<ProjectionNode['data'],string>&ProjectionNode;
|
||||
/** 连线的框架适配类型;真实端点和 owner 的权威信息仍在元素记录中。 */
|
||||
export type ReactFlowProjectionEdge=Edge<ProjectionEdge['data']>&ProjectionEdge;
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
/**
|
||||
* 把可移植 renderer 描述与受信 React 组件装配为 React Flow 类型表。
|
||||
* 验证版本/结构及 rendererId 冲突,并通过弱缓存稳定 memo 包装身份;不实例化 editor 或元素模型。
|
||||
*/
|
||||
import {memo} from 'react';
|
||||
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';
|
||||
/** 节点/连线分别提供对应组件,可另提供渐进预览组件;模型解释协议仍由 RendererDescriptor 决定。 */
|
||||
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]});
|
||||
/** 冻结的装配结果,普通与 preview 类型表分开交给各显示路径。 */
|
||||
export type ReactRenderers={readonly catalog:RendererCatalog;readonly definitions:readonly ReactRendererDefinition[];readonly nodeTypes:NodeTypes;readonly edgeTypes:EdgeTypes;readonly previewNodeTypes:NodeTypes;readonly previewEdgeTypes:EdgeTypes};
|
||||
/** 应用扩展可独立安装模型定义与显示实现;二者各自校验,不把 React 函数保存进元素数据。 */
|
||||
export type CanvasRuntimeExtension={readonly components?:readonly ComponentDefinition[];readonly renderers?:readonly ReactRendererDefinition[]};
|
||||
// 同一组件引用只创建一次 memo 包装,弱键不永久保留不再使用的插件组件。
|
||||
const memoizedRenderers=new WeakMap<object,NodeTypes[string]|EdgeTypes[string]>();
|
||||
/** 已是 memo 的组件原样返回;其它合法组件复用/创建包装,便于配合投影结构共享降低重绘。 */
|
||||
function compiledRenderer(render:NodeTypes[string]|EdgeTypes[string]):NodeTypes[string]|EdgeTypes[string]{
|
||||
if((render as {$$typeof?:symbol}).$$typeof===Symbol.for('react.memo'))return render;
|
||||
const key=render as object,previous=memoizedRenderers.get(key);if(previous)return previous;
|
||||
const compiled=memo(render as NodeTypes[string]);memoizedRenderers.set(key,compiled);return compiled;
|
||||
}
|
||||
/** 先验证纯描述,再验证 React 组件形状与可选模型 registry。
|
||||
* 同 rendererId 可供多个元素类型共用,但普通/预览组件必须是同一实现,不能后注册静默覆盖。 */
|
||||
export function createReactRenderers(definitions:readonly ReactRendererDefinition[],registry?:Registry):ReactRenderers{
|
||||
// 类型表无对象原型,保留名也已被描述目录拒绝,防止类型键继承/原型污染歧义。
|
||||
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,preview=definition.preview,tag=render&&typeof render==='object'?(render as {$$typeof?:symbol}).$$typeof:undefined;
|
||||
// 接受函数及 React memo/forward_ref/lazy 标记对象;不是组件的值在装配时拒绝。
|
||||
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');
|
||||
// 如提供模型 registry,就要求精确 componentId/dataVersion/structure 匹配,不能靠显示组件掩盖 schema 不兼容。
|
||||
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,previews=descriptor.structure==='node'?previewNodes:previewEdges,compiled=compiledRenderer(render),compiledPreview=preview?compiledRenderer(preview):undefined;
|
||||
if(Object.hasOwn(table,descriptor.rendererId)&&(table[descriptor.rendererId]!==compiled||previews[descriptor.rendererId]!==compiledPreview))throw fault('renderer_id_conflict',[descriptor.rendererId]);
|
||||
// 采用目录捕获的标准描述,冻结暴露定义和最终类型表,装配后调用方不能替换注册项。
|
||||
Object.defineProperty(table,descriptor.rendererId,{value:compiled,enumerable:true,configurable:true});if(compiledPreview)Object.defineProperty(previews,descriptor.rendererId,{value:compiledPreview,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),previewNodeTypes:Object.freeze(previewNodes),previewEdgeTypes:Object.freeze(previewEdges)});
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -12,45 +17,68 @@ 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]);
|
||||
@@ -58,36 +86,55 @@ export function useMode(target:Target){
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
/**
|
||||
* React renderer 的受控命令注入点。renderer 借用 editor 的普通 dispatch/transaction,
|
||||
* 不取得直接修改文档表或绕过校验的权限;上下文缺失时明确报错,避免默默丢编辑。
|
||||
*/
|
||||
"use client";
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { Transaction } from '../canvas-core/editor.ts';
|
||||
import type { Command, Result } from '../canvas-core/types.ts';
|
||||
/** 与普通历史单元一致的标签/分组信息,不是网络请求参数。 */
|
||||
export type RendererCommandOptions={label?:string;groupId?:string};
|
||||
/** 最小写入门面:单命令或同步逻辑事务都返回 editor 的校验/提交结果。 */
|
||||
export type CanvasRendererCommands={dispatch(command:Command,options?:RendererCommandOptions):Result;transaction(callback:(transaction:Transaction)=>unknown,options?:RendererCommandOptions):Result};
|
||||
/** Provider 由上层 renderer 装配;默认 null 表示组件尚未处于合法的编辑上下文。 */
|
||||
export const CanvasRendererCommandsContext=createContext<CanvasRendererCommands|null>(null);
|
||||
/** Trusted installed renderers submit normal validated commands, never write a second model. */
|
||||
/** 已安装的受信 renderer 通过此 hook 获取命令门面,所有业务修改仍经 editor 发布边界。 */
|
||||
export function useCanvasRendererCommands(){const commands=useContext(CanvasRendererCommandsContext);if(!commands)throw Error('Canvas renderer commands are unavailable');return commands;}
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
/**
|
||||
* 可移植的 renderer 描述表,只声明哪种元素由哪个显示实现处理,不保存 React 组件或元素实例状态。
|
||||
* 按 componentId/dataVersion/structure 精确匹配;模型 registry 能解释数据,不代表宿主一定安装了对应 renderer。
|
||||
*/
|
||||
import type { ElementRecord,Registry } from '../canvas-core/types.ts';
|
||||
import { fault } from '../canvas-core/values.ts';
|
||||
/** rendererId 是宿主显示类型名;record-v1 读取原记录,compat-v1 使用旧卡片投影,默认采用前者。 */
|
||||
export type RendererDescriptor={readonly componentId:string;readonly dataVersion:number;readonly structure:'node'|'connection';readonly rendererId:string;readonly projection?:'compat-v1'|'record-v1'};
|
||||
/** 冻结定义列表与只读解析入口;没有匹配项返回 undefined,交给上层选择降级外壳。 */
|
||||
export type RendererCatalog={readonly definitions:readonly RendererDescriptor[];resolve(element:Pick<ElementRecord,'componentId'|'dataVersion'|'structure'>):RendererDescriptor|undefined};
|
||||
/** JSON 元组键避免拼接分隔符歧义,节点与连线即使 componentId 相同也独立匹配。 */
|
||||
const key=(value:Pick<RendererDescriptor,'componentId'|'dataVersion'|'structure'>)=>JSON.stringify([value.componentId,value.dataVersion,value.structure]);
|
||||
/** 校验并复制描述项,拒绝重复定义、危险/保留 renderer 名和未知投影协议。
|
||||
* 工厂不加载代码、不自动迁移 dataVersion,也不把高版本当成兼容旧版本。 */
|
||||
export function createRendererCatalog(definitions:readonly RendererDescriptor[]):RendererCatalog{
|
||||
const entries=new Map<string,RendererDescriptor>();
|
||||
for(const value of definitions){
|
||||
@@ -11,12 +20,16 @@ export function createRendererCatalog(definitions:readonly RendererDescriptor[])
|
||||
}
|
||||
return Object.freeze({definitions:Object.freeze([...entries.values()]),resolve:(element:Pick<ElementRecord,'componentId'|'dataVersion'|'structure'>)=>entries.get(key(element))});
|
||||
}
|
||||
/** 内置旧卡片 renderer 的版本1映射,明确保留 compat-v1,避免默认原生投影破坏旧组件输入。 */
|
||||
export const builtinRendererDefinitions:readonly RendererDescriptor[]=Object.freeze([
|
||||
...['text','image','script'].map(componentId=>Object.freeze({componentId,dataVersion:1,structure:'node' as const,rendererId:'material',projection:'compat-v1' as const})),
|
||||
...Object.entries({'plain-text':'plainText','plain-image':'plainImage',frame:'frame',icon:'icon'}).map(([componentId,rendererId])=>Object.freeze({componentId,dataVersion:1,structure:'node' as const,rendererId,projection:'compat-v1' as const})),
|
||||
Object.freeze({componentId:'connection',dataVersion:1,structure:'connection',rendererId:'default',projection:'compat-v1'}),
|
||||
]);
|
||||
/** 可复用的不可变内置描述目录,不含任何 editor 的可变状态。 */
|
||||
export const builtinRendererCatalog=createRendererCatalog(builtinRendererDefinitions);
|
||||
|
||||
/** Installed renderers without a reference declaration wait for full context. */
|
||||
/** 渐进加载仅开放 registry 中声明 copy 引用规则的组件 renderer;缺声明先走降级等待完整上下文。
|
||||
* 保留 definitions 供装配检查,但 resolve 才决定当前是否允许显示实现。 */
|
||||
export function createLoadingRendererCatalog(registry:Registry,catalog:RendererCatalog):RendererCatalog{return Object.freeze({definitions:catalog.definitions,resolve:(element:Parameters<RendererCatalog['resolve']>[0])=>registry.components.get(element.componentId)?.copy?catalog.resolve(element):undefined});}
|
||||
|
||||
@@ -1,22 +1,36 @@
|
||||
/**
|
||||
* 一个视图的元素→资源显示租约管理。按元素保留未变化的已加载 URL,各图片独立完成就独立发布。
|
||||
* 这里只借用资源句柄;不拥有资源原件、上传任务或业务文档,视图销毁应释放租约而非删除资源。
|
||||
*/
|
||||
import type { Id } from '../canvas-core/types.ts';
|
||||
import { fault } from '../canvas-core/values.ts';
|
||||
import type { DisplayHandle } from '../canvas-runtime/resources.ts';
|
||||
|
||||
/** 一个元素当前一次解析尝试,控制器和记录对象身份共同隔离迟到结果。 */
|
||||
type Record = { readonly resource: Id; readonly controller: AbortController; handle?: DisplayHandle; failed: boolean };
|
||||
/** 对界面只暴露 URL 及对应资源 ID,避免只按元素 ID 把旧图片结果应用到新资源。 */
|
||||
export type ResourceViewSource = { readonly resourceId: Id; readonly url: string };
|
||||
/** A view owns subscriptions/leases, never a resource or document store. Retain
|
||||
* unchanged nodes, publish each ready source independently, and fence late work.
|
||||
*/
|
||||
/** resolve 由 engine/display service 注入,onChange 收到新的已就绪集合和当前失败数。
|
||||
* sync 输入是完整目标集合,retry 只重建失败项,dispose 幂等释放所有在途/已完成句柄。 */
|
||||
export function createResourceView(options: {
|
||||
resolve(resource: Id, signal: AbortSignal): Promise<DisplayHandle>;
|
||||
onChange(sources: Map<Id, ResourceViewSource>, failures: number): void;
|
||||
onError?(error: unknown): void;
|
||||
}) {
|
||||
// records 是当前尝试表,targets 保留最近一次期望集合供 retry;同资源用于不同元素仍各有租约。
|
||||
const records = new Map<Id, Record>(); let targets = new Map<Id, Id>(), disposed = false;
|
||||
/** 错误展示抛错不影响租约释放或其它图片的加载。 */
|
||||
const report = (error: unknown) => { try { options.onError?.(error); } catch { /* Observer errors do not own leases. */ } };
|
||||
/** 只公布已拿到 handle 的元素,失败数另报;先复制 Map,不把内部记录交给界面修改。 */
|
||||
const publish = () => { try { options.onChange(new Map([...records].flatMap(([id, record]) => record.handle ? [[id, {resourceId:record.resource,url:record.handle.url}] as [Id, ResourceViewSource]] : [])), [...records.values()].filter(record => record.failed).length); } catch (error) { report(error); } };
|
||||
/** 释放器自身抛错只报告,不让一张图片阻止其它图片清理。 */
|
||||
const releaseHandle = (handle: DisplayHandle) => { try { handle.release(); } catch (error) { report(error); } };
|
||||
/** 先中止在途解析,再归还已完成句柄;未完成解析的迟到成功也由下方结果边界归还。 */
|
||||
const release = (record: Record) => { record.controller.abort(); if(record.handle)releaseHandle(record.handle); };
|
||||
/** 完整验证元素/资源 ID 与重复项后再更新;删除或换源立即释放旧记录,未变化项继续复用。 */
|
||||
function sync(next: readonly (readonly [Id, Id])[]) {
|
||||
if (disposed) throw fault('resource_view_disposed');
|
||||
const desired = new Map<Id, Id>();
|
||||
@@ -28,13 +42,16 @@ export function createResourceView(options: {
|
||||
for (const [id, record] of records) if (desired.get(id) !== record.resource) { records.delete(id); release(record); changed = true; }
|
||||
for (const [id, resource] of desired) if (!records.has(id)) {
|
||||
const record: Record = { resource, controller: new AbortController(), failed: false }; records.set(id, record); changed = true;
|
||||
// 延到微任务发起解析,先确认该尝试仍是当前记录;不是简单按 ID 接受任意迟到 URL。
|
||||
void Promise.resolve().then(() => {
|
||||
if (disposed || records.get(id) !== record) throw fault('display_aborted');
|
||||
return options.resolve(resource, record.controller.signal);
|
||||
}).then(handle => {
|
||||
// 即使底层不响应 abort,旧尝试成功返回也必须立刻 release,不能挂到新元素状态上。
|
||||
if (disposed || records.get(id) !== record) { releaseHandle(handle); return; }
|
||||
record.handle = handle; publish();
|
||||
}, error => {
|
||||
// 取消/换源的旧失败不增加失败数;真正当前失败才发布并允许 retry。
|
||||
if (disposed || records.get(id) !== record || record.controller.signal.aborted) return;
|
||||
record.failed = true; publish(); report(error);
|
||||
});
|
||||
@@ -43,7 +60,9 @@ export function createResourceView(options: {
|
||||
}
|
||||
return Object.freeze({
|
||||
sync,
|
||||
/** 移除失败记录后按 targets 重新 sync;成功项不重新取 URL。 */
|
||||
retry() { if (disposed) throw fault('resource_view_disposed'); for (const [id, record] of records) if (record.failed) { records.delete(id); release(record); } sync([...targets]); },
|
||||
/** 中止并归还全部显示租约,发布空集合,后续 sync/retry 明确拒绝。 */
|
||||
dispose() { if (disposed) return; disposed = true; for (const record of records.values()) release(record); records.clear(); targets.clear(); publish(); },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* runtime 表的只读分桶订阅。所有通道共享一个上游监听,依赖签名由表对象身份/布尔值组成,
|
||||
* 不在每个 hook 缓存整份 Root。读取缓存与通知基线分开,提前 getSnapshot 不会吞掉随后应有的通知。
|
||||
*/
|
||||
import type {Id} from '../canvas-core/types.ts';
|
||||
import type {EngineSnapshot} from '../canvas-runtime/contracts.ts';
|
||||
import type {OperationRecord,FrozenRequest} from '../canvas-runtime/ledger.ts';
|
||||
@@ -6,29 +10,41 @@ import type {SharedAssetBinding} from '../canvas-runtime/asset-depot.ts';
|
||||
import {equal} from '../canvas-core/values.ts';
|
||||
import type {ExternalSource} from './selectors.ts';
|
||||
|
||||
/** 单个逻辑操作及它绑定的冻结请求;未知 operation ID 返回 undefined。 */
|
||||
export type OperationView={readonly operation:OperationRecord;readonly request?:FrozenRequest}|undefined;
|
||||
/** 本地上传资源与共享仓库绑定两个来源槽位;这里仅给元数据,不获取显示 URL。 */
|
||||
export type AssetMetadata={readonly record:ResourceRecord|undefined;readonly binding:SharedAssetBinding|undefined};
|
||||
/** pending 按未决操作计数;blocked/unknown 还包含请求层情况,不是互斥的节点数量。 */
|
||||
export type SyncCounts={readonly pending:number;readonly blocked:number;readonly unknown:number;readonly loading:boolean};
|
||||
/** 只允许对象身份、布尔值与 undefined 作为本模块依赖,避免重新序列化整个业务表。 */
|
||||
type Dependencies=readonly(object|boolean|undefined)[];
|
||||
|
||||
/** Read-only table projections: one upstream listener, no per-hook Root/table snapshots. */
|
||||
/** 装配 operation/asset/counts 三类通道,首次订阅连上游,最后一个通道离开后断开。 */
|
||||
export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
|
||||
// 每个 group 负责自己的桶更新,上游先推进所有组,再运行去重后的外部通知。
|
||||
type Group={update(root:EngineSnapshot,notifications:Set<()=>void>):void};
|
||||
const active=new Set<Group>(),identities=new WeakMap<object,number>();let nextIdentity=0,stop:(()=>void)|undefined;
|
||||
/** 对象用弱身份编号,undefined/布尔值用固定标记;WeakMap 不强留已被替换的表。 */
|
||||
function signature(values:Dependencies){return values.map(value=>{if(value===undefined)return'u';if(typeof value==='boolean')return value?'t':'f';let id=identities.get(value);if(id===undefined){id=++nextIdentity;identities.set(value,id);}return`o${id}`;}).join(':');}
|
||||
/** 同一根下所有 group 更新完才通知;单监听器抛错不跳过其余监听,最后重抛首错。 */
|
||||
function changed(){
|
||||
if(!active.size)return;const root=source.getSnapshot(),notifications=new Set<()=>void>();
|
||||
for(const group of active)group.update(root,notifications);
|
||||
let failed=false,error:unknown;for(const notify of notifications)try{notify();}catch(caught){if(!failed){failed=true;error=caught;}}if(failed)throw error;
|
||||
}
|
||||
/** 为共享依赖的一类按键查询创建桶。依赖不变跳过计算,失败桶即使签名未变仍可重新尝试。 */
|
||||
function group<K,V>(dependencies:(root:EngineSnapshot)=>Dependencies,read:(root:EngineSnapshot,key:K)=>V){
|
||||
// Direct reads may precede notification, so read caches cannot replace observed baselines.
|
||||
// cached 服务即时读取,observed 是上次通知基线;两者不能合并,否则渲染提前读取会漏通知。
|
||||
type Bucket={cached:V;cachedSignature:string;observed:V;listeners:Set<()=>void>};
|
||||
const buckets=new Map<K,Bucket>(),failed=new Set<K>();let observedSignature:string|undefined;
|
||||
/** 依赖戳改变才重算,并按值复用旧引用,确保外部存储读取身份稳定。 */
|
||||
function readCached(root:EngineSnapshot,key:K,bucket:Bucket,stamp:string){
|
||||
if(bucket.cachedSignature!==stamp){const value=read(root,key);if(!equal(bucket.cached,value))bucket.cached=value;bucket.cachedSignature=stamp;}
|
||||
return bucket.cached;
|
||||
}
|
||||
// 只更新有订阅的键;失败时仍通知使用方,让其读取/错误边界重新解释,不造一个成功值。
|
||||
const owner:Group={update(root,notifications){
|
||||
const stamp=signature(dependencies(root)),all=stamp!==observedSignature;if(!all&&!failed.size)return;observedSignature=stamp;
|
||||
for(const key of all?buckets.keys():[...failed]){
|
||||
@@ -37,8 +53,10 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
|
||||
for(const notify of bucket.listeners)notifications.add(notify);
|
||||
}
|
||||
}};
|
||||
/** 每键外部源,未订阅时只留局部选中值,订阅后共享 group 桶;不保留完整根快照。 */
|
||||
return{forKey(key:K):ExternalSource<V>{
|
||||
let initialized=false,local:V,localSignature:string;
|
||||
/** 已订阅用桶缓存,否则用当前依赖的局部缓存;读取不推进 observed 通知基线。 */
|
||||
function getSnapshot(){
|
||||
const root=source.getSnapshot(),stamp=signature(dependencies(root)),bucket=buckets.get(key);if(bucket)return readCached(root,key,bucket,stamp);
|
||||
if(!initialized||stamp!==localSignature){const value=read(root,key);if(!initialized||!equal(local,value))local=value;localSignature=stamp;initialized=true;}
|
||||
@@ -47,25 +65,34 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
|
||||
return Object.freeze({getSnapshot,subscribe(notify:()=>void){
|
||||
let bucket=buckets.get(key);if(!bucket){const value=getSnapshot();if(!buckets.size)observedSignature=signature(dependencies(source.getSnapshot()));bucket={cached:value,cachedSignature:signature(dependencies(source.getSnapshot())),observed:value,listeners:new Set()};buckets.set(key,bucket);active.add(owner);}
|
||||
const listener=()=>notify();bucket.listeners.add(listener);
|
||||
/** 撤销本订阅;空桶和空组立即删除,失败键也同时清理。 */
|
||||
const remove=()=>{bucket!.listeners.delete(listener);if(!bucket!.listeners.size){buckets.delete(key);failed.delete(key);}if(!buckets.size){active.delete(owner);observedSignature=undefined;}};
|
||||
// 首次注册上游失败撤回桶;正常退订只有最后一个活动组离开才停止共享监听。
|
||||
if(!stop)try{stop=source.subscribe(changed);}catch(error){remove();throw error;}
|
||||
let live=true;return()=>{if(!live)return;live=false;remove();if(!active.size){stop?.();stop=undefined;}};
|
||||
}});
|
||||
}};
|
||||
}
|
||||
// operation 通道只依赖 ledger 两张表,资源/视口变化不应使它重新计算。
|
||||
const operations=group<Id,OperationView>(root=>[root.runtime.ledger.operations,root.runtime.ledger.requests],(root,id)=>{const operation=root.runtime.ledger.operations.get(id);return operation?Object.freeze({operation,...(operation.requestId?{request:root.runtime.ledger.requests.get(operation.requestId)}:{})}):undefined;});
|
||||
// asset 通道同时观察本地资源与共享绑定,不把其中一种来源覆盖成另一种。
|
||||
const assets=group<Id,AssetMetadata>(root=>[root.runtime.resources,root.runtime.assetBindings],(root,id)=>Object.freeze({record:root.runtime.resources.get(id),binding:root.runtime.assetBindings?.get(id)}));
|
||||
let countStamp:string|undefined,projectCounts:SyncCounts,emptyCounts:SyncCounts,canvasCounts=new Map<Id,SyncCounts>();
|
||||
/** 同步计数只取操作表、请求表和是否 loading,不因文档几何或历史变化全表重数。 */
|
||||
const countDependencies=(root:EngineSnapshot):Dependencies=>[root.runtime.ledger.operations,root.runtime.ledger.requests,!!root.runtime.loading];
|
||||
// 同一依赖戳一次扫描得出项目及各画布计数,多个 canvas hook 共享结果。
|
||||
const counts=group<Id|undefined,SyncCounts>(countDependencies,(root,canvasId)=>{
|
||||
const stamp=signature(countDependencies(root));
|
||||
if(stamp!==countStamp){
|
||||
/** 空画布也保留项目 loading 标记;每张实际参与画布至多计入一次同一操作。 */
|
||||
const loading=!!root.runtime.loading,empty=()=>({pending:0,blocked:0,unknown:0,loading}),project=empty(),byCanvas=new Map<Id,ReturnType<typeof empty>>();
|
||||
const scope=(id:Id)=>{let row=byCanvas.get(id);if(!row){row=empty();byCanvas.set(id,row);}return row;};
|
||||
for(const operation of root.runtime.ledger.operations.values())if(operation.phase!=='resolved'){
|
||||
// waiting_digest 是正常准备状态,不单凭它显示 blocked;其它 blocked 理由进入阻塞计数。
|
||||
const ids=operation.intent.canvasIds.length>1?new Set(operation.intent.canvasIds):operation.intent.canvasIds,blocked=operation.phase==='blocked'&&operation.blockers.some(reason=>reason!=='waiting_digest');project.pending++;if(blocked)project.blocked++;
|
||||
for(const id of ids){const row=scope(id);row.pending++;if(blocked)row.blocked++;}
|
||||
}
|
||||
// 请求只统计仍关联未决操作的项。无 retryAt 的请求错误计 blocked,已发出但结果未知计 unknown。
|
||||
for(const request of root.runtime.ledger.requests.values())if(request.phase!=='resolved'){
|
||||
let matched=false;const ids=new Set<Id>();for(const id of request.operationIds){const operation=root.runtime.ledger.operations.get(id);if(operation&&operation.phase!=='resolved'){matched=true;for(const canvas of operation.intent.canvasIds)ids.add(canvas);}}
|
||||
if(!matched)continue;const blocked=!!request.error&&request.retryAt===undefined,unknown=request.outcome==='unresolved'&&['dispatch-marked','waiting'].includes(request.phase);
|
||||
@@ -75,5 +102,6 @@ export function createRuntimeSources(source:ExternalSource<EngineSnapshot>){
|
||||
}
|
||||
return canvasId===undefined?projectCounts:canvasCounts.get(canvasId)??emptyCounts;
|
||||
});
|
||||
// 对外只交派生源工厂,所有任务修改仍必须经 engine/editor。
|
||||
return Object.freeze({operation:operations.forKey,asset:assets.forKey,counts:(canvasId?:Id)=>counts.forKey(canvasId)});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user