From 7bd0bf2049385cd563bc76e11696c0a1e491e9e5 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 16:33:12 +0800 Subject: [PATCH] docs(canvas): explain overlays and loading preview lifetimes --- src/components/canvas/canvas-overlays.tsx | 3 +++ src/components/canvas/edge-editing.tsx | 1 + src/components/canvas/frame-node.tsx | 4 ++++ src/components/canvas/loading-graph-canvas.tsx | 4 ++++ 4 files changed, 12 insertions(+) diff --git a/src/components/canvas/canvas-overlays.tsx b/src/components/canvas/canvas-overlays.tsx index 4bca157..7f1c63d 100644 --- a/src/components/canvas/canvas-overlays.tsx +++ b/src/components/canvas/canvas-overlays.tsx @@ -16,6 +16,7 @@ export function useCanvasOverlay(targetId:string,placement:string){ const scope=useContext(CanvasOverlays),instance=useId(),refresh=useRef<()=>void>(()=>{}),cleanup=useRef<()=>void>(()=>{}); // 订阅视口变化触发重渲染,后续layout effect再读取浮层屏幕矩形。 useViewport(); + // 此处 getSnapshot 只读取当前 render;不单独订阅元素代次,外层须在元素变化时让节点重新渲染。 const incarnation=scope?.engine.getSnapshot().document.elements.get(targetId)?.incarnation; // 每次换DOM或作用域先清理旧注册;缺少DOM/Provider/元素生命周期时保持空注册。 const ref=useCallback((element:HTMLElement|null)=>{cleanup.current();refresh.current=()=>{};cleanup.current=()=>{};if(!element||!scope||incarnation===undefined)return; @@ -26,10 +27,12 @@ export function useCanvasOverlay(targetId:string,placement:string){ // 注册返回lease,后续测量只更新这一条;composition事件同步更新组合态供快捷键路由参考。 const lease=scope.index.register(instance,value()),measure=()=>lease.update(value()),start=()=>{composing=true;measure();},end=()=>{composing=false;measure();};refresh.current=measure; // focusout后等微任务再测量,让新焦点落定;ResizeObserver与首帧测量补充尺寸/挂载后的变化。 + // 已排队的 blur 微任务无法取消,清理后仍会测量;索引租约的 token 检查使旧 update 不再发布。 const blur=()=>queueMicrotask(measure),observer=new ResizeObserver(measure);observer.observe(element);element.addEventListener('focusin',measure);element.addEventListener('focusout',blur);element.addEventListener('compositionstart',start);element.addEventListener('compositionend',end);let frame=requestAnimationFrame(measure); // 清除本绑定拥有的DOM标记、首帧任务、观察器和所有监听,再释放索引租约;不删除目标元素。 cleanup.current=()=>{if(element.dataset.canvasOverlayOwner===marker){delete element.dataset.canvasOverlayView;delete element.dataset.canvasOverlayOwner;delete element.dataset.canvasOverlayTarget;delete element.dataset.canvasOverlayPlacement;}cancelAnimationFrame(frame);observer.disconnect();element.removeEventListener('focusin',measure);element.removeEventListener('focusout',blur);element.removeEventListener('compositionstart',start);element.removeEventListener('compositionend',end);lease.release();}; },[scope,instance,targetId,incarnation,placement]); + // ResizeObserver 只观察尺寸,普通页面滚动/位置动画不一定触发它;边界还依赖视口重渲染与布局提交刷新。 // 每次布局提交刷新边界;卸载清理使用ref中的最新函数,兼容期间发生的DOM/目标替换。 useLayoutEffect(()=>{refresh.current();});useLayoutEffect(()=>()=>cleanup.current(),[]);return ref; } diff --git a/src/components/canvas/edge-editing.tsx b/src/components/canvas/edge-editing.tsx index 538e58f..b3ca68e 100644 --- a/src/components/canvas/edge-editing.tsx +++ b/src/components/canvas/edge-editing.tsx @@ -9,5 +9,6 @@ import type { EdgeData } from "@/lib/canvas-v2"; /** begin可拒绝、使用旧版无返回值模式,或返回绑定手势的EditingLease;patch的transient区分临时预览。 * finish/cancel可选,active为当前活动连线ID集合;默认Set不代表全项目状态仓库。 */ const defaultEditing = { finish: undefined as undefined | (() => void), cancel: undefined as undefined | (() => void), begin: (_id?: string): boolean | void | EditingLease> => {}, patch: (_id: string, _data: EdgeData, _transient = false) => {}, active: new Set() }; +// 默认 active Set 与动作对象是共享引用,未冻结;消费方只能读取,不应通过修改默认对象来发布状态。 /** Context的类型保留可选结束钩子,兼容旧画布与新版租约式交互,不在此创建编辑任务队列。 */ export const EdgeEditing = createContext & Partial>>(defaultEditing); diff --git a/src/components/canvas/frame-node.tsx b/src/components/canvas/frame-node.tsx index e175c7d..0ccce0e 100644 --- a/src/components/canvas/frame-node.tsx +++ b/src/components/canvas/frame-node.tsx @@ -38,15 +38,19 @@ export function FrameNode(props: NodeProps & { directAnchors?: boole const cancelBlur = useRef(false); const mode = data.labelMode || "fixed"; /** 去除首尾空白,空名称回退“未命名分区”;只有与模型不同才派发修改,再归一化输入显示。 */ + // 这里只 trim/补默认名,maxLength 是输入控件限制;UTF-8 字节和数据合法性仍由命令/格式层负责。 function saveTitle() { const name = title.trim() || "未命名分区"; if (name !== data.title) editing.frame(id, { title: name }); setTitle(name); } + // title effect 无论是否正编辑都跟随外部值,外部改名可能替换未提交草稿;局部保存后也不等待远端 ACK。 // 选中时显示设置栏,动画尊重减少动态效果偏好;栏内事件停止冒泡,避免触发画布快捷键或拖动。 // 折叠切换前按viewScale还原当前尺寸,存入原状态对应的expandedSize/collapsedSize供后续恢复。 // 概览比例输入限定5%—400%,只更新presentation.contentScale;链接单独打开子画布。 + // 折叠按钮只提交 presentation 候选,不直接隐藏/删除后代;实际尺寸恢复和投影由动作实现处理。 // fixed名称与拖放提示用1/(zoom*viewScale)抵消缩放,scaled跟随画布,hidden不绘制名称。 + // 所有端口都用 source 类型配合外层连接模式;方向差异由 Start/End 能力开关表达。 // 四侧边框提供拖动命中区域;ports按同方向均匀分布,输出在右侧可起连线,输入在左侧可收连线。 return <> {selected && diff --git a/src/components/canvas/loading-graph-canvas.tsx b/src/components/canvas/loading-graph-canvas.tsx index 253c966..203c24b 100644 --- a/src/components/canvas/loading-graph-canvas.tsx +++ b/src/components/canvas/loading-graph-canvas.tsx @@ -20,11 +20,13 @@ function PreparingNode({data}:NodeProps){return
({status:'rejected' as const,code:'model_loading',targets:[]}); /** 冻结命令接口仅提供拒绝结果;inert与ReactFlow只读设置之外,命令层再提供一道限制。 */ +// transaction 也直接返回拒绝,不执行传入事务函数;这不限制自定义 renderer 自行发起的外部副作用。 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; + // fitted 在调用 fitView 前置 true;期间依赖变化会取消迟到通知,但不会重新 fit。拒绝也没有本地 catch/重试。 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; } @@ -38,12 +40,14 @@ export default function LoadingGraphCanvas({preview,canvasId,registry,renderers, // 对已收到文档做只读投影;从现有边补齐各节点Handle描述,不推断尚未读取到的连线。 const projection=useMemo(()=>{ const value=projectCanvas(preview.document,canvasId,registry,new Set(),()=>undefined,undefined,catalog); + // 每条边端点都追加描述,不按 handleId 去重;PreparingNode 使用临时索引 key,仅供预览定位。 const handles=new Map();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'&®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]); // 普通rendererId优先使用专用preview,否则节点PreparingNode、边UnsupportedEdge;节点再套inert隔离内部控件。 const nodeTypes=useMemo(()=>{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=>
;return result;},[renderers]),edgeTypes=useMemo(()=>({...Object.fromEntries(Object.keys(renderers.edgeTypes).map(id=>[id,renderers.previewEdgeTypes[id]??UnsupportedEdge])),unsupported:UnsupportedEdge}),[renderers]); + // onMoveEnd 同时记录用户移动;首次 fit 完成通知与用户移动并无额外竞争仲裁,由外层决定交接采用。 // 仍允许拖动或滚动平移,关闭滚轮缩放和Delete删除;onMoveEnd记录视口,进度只显示读取数量。 return