Files
Endoleap/src/components/canvas/loading-graph-canvas.tsx
T

58 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 模型读取前缀的只读预览。直接把已校验document投影给ReactFlow,允许浏览视口,不创建可写engine或临时history/ledger。
* 这个组件不负责持久化前缀;后续可编辑partial engine的建立与交接由路由及runtime处理。
*/
"use client";
import {useEffect,useMemo,useRef} from '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';
import {createLoadingRendererCatalog} from '@/lib/canvas-bindings/renderers';
import {projectCanvas} from '@/lib/canvas-bindings/projection';
import {CanvasRendererCommandsContext,type CanvasRendererCommands} from '@/lib/canvas-bindings/react-flow';
import {UnsupportedNode,UnsupportedEdge} from './unsupported-elements';
import styles from './canvas.module.css';
/** 已知类型缺少专用preview renderer时展示准备占位;保留已投影连线需要的Handle,禁止新连接。 */
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>;}
/** 所有预览命令统一拒绝为model_loading,避免复用的renderer意外修改数据。 */
const reject=()=>({status:'rejected' as const,code:'model_loading',targets:[]});
/** 冻结命令接口仅提供拒绝结果;inert与ReactFlow只读设置之外,命令层再提供一道限制。 */
const commands:CanvasRendererCommands=Object.freeze({dispatch:reject,transaction:reject});
/** 首次存在节点且视口初始化后fitView一次,限制最大缩放为1,并把结果交给外层用于视图交接。 */
function InitialPreviewViewport({onViewport,nodeCount}:{onViewport(viewport:Viewport):void;nodeCount:number}){
// fitted是本组件挂载期标记;清理只禁止迟到通知,不取消已经发起的fitView,也不重置fitted。
const flow=useReactFlow(),fitted=useRef(false),notify=useRef(onViewport);notify.current=onViewport;
useEffect(()=>{if(!flow.viewportInitialized||fitted.current||!nodeCount)return;fitted.current=true;let active=true;void flow.fitView({padding:.2,maxZoom:1}).then(()=>{if(active)notify.current(flow.getViewport());});return()=>{active=false;};},[flow,nodeCount]);
return null;
}
/** Render the validated read prefix directly. No temporary editor, ACK, ledger or
* writable engine is constructed, and no partial model enters durable storage. */
/** renderer catalog限制加载期可用渲染路径;无法解释引用关系时沿用opaque占位,不能猜测缺失内容。 */
export default function LoadingGraphCanvas({preview,canvasId,registry,renderers,workspaceId,onViewport}: {preview:ModelPreview;canvasId:string;workspaceId:string;registry:Registry;renderers:ReactRenderers;onViewport(viewport:Viewport):void}){
// Without a declared reference schema, use the existing opaque placeholder
// until the complete document is available to the installed renderer.
const catalog=useMemo(()=>createLoadingRendererCatalog(registry,renderers.catalog),[registry,renderers]);
// 对已收到文档做只读投影;从现有边补齐各节点Handle描述,不推断尚未读取到的连线。
const projection=useMemo(()=>{
const value=projectCanvas(preview.document,canvasId,registry,new Set(),()=>undefined,undefined,catalog);
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);}
// 节点禁用拖动/选择/焦点/连接及指针,边禁用选择/焦点;已知组件声明assetId资源字段才设置预览URL。
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'&&registry.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]);
// 普通rendererId优先使用专用preview,否则节点PreparingNode、边UnsupportedEdge;节点再套inert隔离内部控件。
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]);
// 仍允许拖动或滚动平移,关闭滚轮缩放和Delete删除;onMoveEnd记录视口,进度只显示读取数量。
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)}>
<InitialPreviewViewport onViewport={onViewport} nodeCount={projection.nodes.length}/>
<Background variant={BackgroundVariant.Dots} gap={24} size={1} />
</ReactFlow>
</CanvasRendererCommandsContext.Provider>
<p role="status" style={{position:'absolute',left:'50%',transform:'translateX(-50%)',bottom:16,pointerEvents:'none'}}>正在加载… {preview.receivedElements}/{preview.totalElements},加载期间可浏览</p>
</div>;
}