From a2bfb0d9b5dcbf684f042fda4ee36888e835f74c Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 16:52:15 +0800 Subject: [PATCH] docs(canvas): explain headless view and command boundaries --- src/components/canvas/headless-graph-canvas.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 8e0f320..25b14f0 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -149,8 +149,10 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi // 保留调用方renderer表,并显式安装unsupported节点/边占位,未知内容仍可在场景中呈现。 const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]); // 稳定动作闭包从current/engine读取最新状态,runtime-only发布不必重建全部卡片编辑上下文。 + // current 是最近一次 React render 的快照,frame 合并期间可落后于 engine;需要强身份保证的入口仍须实时查询。 const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions }; /** 将命令拒绝或能力不可用显示为错误;成功结果无需额外复制进本地state。 */ + // 这里只呈现 rejected/disabled,不捕获同步异常,也不在成功时自动清除旧错误文案。 const report = useCallback((result: Result | { enabled: boolean; code?: string }) => { if ('status' in result && result.status === 'rejected' || 'enabled' in result && !result.enabled) setError(errorText(new Error(result.code))); }, []); @@ -204,6 +206,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi const width = (change.width ?? displayed.width!) / t.scale, height = (change.height ?? displayed.height!) / t.scale; const position = change.position ?? displayed.position, center = toLocal(t, { x: position.x + width * t.scale / 2, y: position.y + height * t.scale / 2 }); const patch: Partial = { x: center.x - width / 2, y: center.y - height / 2, width, height, ...(change.rotation !== undefined ? { rotation: ((change.rotation - t.rotation) % 360 + 360) % 360 } : {}) }; + // 入参 fontSize 未进入这个 Geometry patch;字体大小修改应走单独 data/style 命令,而不是几何租约。 report(engine.sessions.update(viewId, target(id), patch)); } /** 创建内置节点,可加入调用方已有事务;frame同时创建以节点为入口的子canvas,保证结构一起提交。 @@ -217,6 +220,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi const element: ElementRecord = { id, incarnation: 1, structure: 'node', componentId: kind, dataVersion: 1, ownerCanvasId: canvasId, geometry: { ...position, ...size, rotation: 0 }, data: { title: kind === 'frame' ? '未命名分区' : kind === 'image' ? '视觉参考' : '灵感笔记', text: '', ...(ref ? { assetId: ref.id, src: ref.src } : {}) } }; /** tx内创建元素及可选canvas关系;无外部tx时包装为一个Add element事务。 */ const create = (transaction: NonNullable) => { transaction.dispatch({ type: 'element.create', element }); if (kind === 'frame') transaction.dispatch({ type: 'canvas.create', canvas: { id: crypto.randomUUID(), parent: { canvasId, entryElementId: id } } }); }; + // 返回分配的 ID 不代表创建成功:无 tx 时这里只 report 拒绝,调用方若要依赖结果须检查事务状态。 if (tx) create(tx); else report(engine.editor.transaction(create, { label: 'Add element' })); return id; } @@ -229,6 +233,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi report(engine.editor.dispatch({ type: 'element.create', element: { id: crypto.randomUUID(), incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: commonCanvas(document, source.ownerCanvasId, destination.ownerCanvasId), source: { elementId: source.id, ...(connection.sourceHandle ? { portId: connection.sourceHandle } : {}) }, target: { elementId: destination.id, ...(connection.targetHandle ? { portId: connection.targetHandle } : {}) }, data: {}, path: null } })); } // 用节点ID+assetId序列作为显示订阅键,资源分辨率/URL交资源视图决定;组件不把Blob URL写入元素data。 + // 资源键只反映当前投影的 nodeId/assetId,身份和 URL 可用性由 resourceView/engine 重新核验。 const resourceKey = JSON.stringify(projection.nodes.filter(node => typeof node.data.assetId==='string'&&node.data.assetId).map(node => [node.id, node.data.assetId])); // 每个engine/view建立资源显示器,回调只接受当前实例;清理dispose解除订阅及显示资源占用。 useEffect(() => { @@ -245,6 +250,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi function removeSelected() { report(engine.editor.transaction(tx => { for (const id of current.current.view.selection) if (tx.getDocument().elements.has(id)) tx.dispatch({ type: 'element.remove', target: target(id) }); }, { label: 'Delete selection' })); } // 同步显示来自ledger:未resolved算待处理;waiting_digest单独等待不算阻断,无定时重试的错误和conflicts要提示处理。 const pending = [...root.runtime.ledger.operations.values()].filter(operation => operation.phase !== 'resolved'); + // “已同步”文案依据这些 ledger/冲突计数,不是所有资源解码、所有后台任务或远端生成全部完成的证明。 const blocked = pending.some(operation => operation.phase === 'blocked' && operation.blockers.some(reason => reason !== 'waiting_digest')) || [...root.runtime.ledger.requests.values()].some(request => request.phase !== 'resolved' && request.error && request.retryAt === undefined) || root.runtime.conflicts.size > 0; // 全局失焦或Escape仅取消本视图拥有的节点/连接手势;不干扰同engine其它视图的交互。 useEffect(() => { @@ -286,6 +292,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi // 单选时才启用图片适配/AI工具;图片要求已知组件、resize能力和声明的assetId资源字段,不能猜同名业务字段。 const selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined; const selectedResource=selected?.structure==='node'&&engine.editor.registry.resolve(selected)==='known'&&engine.editor.registry.supports(selected,'geometry.resize')&&typeof selected.data.assetId==='string'&&engine.editor.registry.components.get(selected.componentId)?.resourceFields?.includes('assetId')?root.runtime.resources.get(selected.data.assetId):undefined; + // canFitImage 在显示上因任意视图的 gesture 都禁用;真正命令能否执行仍由 engine.fitImage 再判断。 const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture; const aiElement = selected?.structure === 'node' && engine.editor.registry.resolve(selected) === 'known' && ['text','plain-text','script','image','plain-image'].includes(selected.componentId) ? selected : undefined; // Runtime-only publications must not invalidate every card's editing context. @@ -301,6 +308,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi select: id => engine.sessions.select(viewId, [id]), edit: (id, text) => data(id,{text},groups.current.get(id)?.incarnation===engine.getSnapshot().document.elements.get(id)?.incarnation?groups.current.get(id)?.groupId:undefined), rename: (id, title) => data(id, { title }), beginTransform, transform, finishTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) report(engine.sessions.commit(viewId)); }, cancelTransform: () => { if (engine.sessions.getSnapshot().gesture?.viewId === viewId) engine.sessions.cancel('pointer_cancel'); }, // 样式和分区补丁走data,模式rotate/resize只在sessions中切换;开启一个前先清同视图该类旧模式。 + // data 命令收到的是这次传入的 textStyle 对象;本层不读取并深合并旧样式,合并语义由 editor 定义。 style: (id, textStyle, fontSize) => data(id, json({ textStyle, ...(fontSize !== undefined ? { fontSize } : {}) })), frame: (id, patch) => data(id, json(patch)), rotating, rotate: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'rotate') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'rotate'); }, resizing, resize: id => { for (const [key, mode] of engine.sessions.getSnapshot().views.get(viewId)?.modes ?? []) if (mode === 'resize') engine.sessions.mode(viewId, key, null); if (id) engine.sessions.mode(viewId, id, 'resize'); }, @@ -318,6 +326,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi const token = engine.sessions.getSnapshot().connectionGesture!.token, element = engine.getSnapshot().document.elements.get(id)!; const transform = current.current.projection.transforms.get(element.ownerCanvasId)!; /** token护栏绑定该条连接手势,预览路径先从场景空间变回owner局部空间。 */ + // owner 的坐标 transform 在 begin 时捕获;token 护栏只核手势身份,归属变化是否合法由 sessions 校验。 const ownsGesture = () => engine.sessions.getSnapshot().connectionGesture?.token === token; return guardEditingLease(ownsGesture, { preview: (path: NonNullable) => report(engine.sessions.updateConnection(viewId, transformPath(path as Json, transform, true))), commit: () => report(engine.sessions.commitConnection(viewId)), cancel: () => engine.sessions.cancel('pointer_cancel') }); }, @@ -338,12 +347,14 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi }),[engine,viewId,report]); // 自定义renderer的dispatch/transaction也经过editor,并统一report拒绝;选项只传label/groupId。 const rendererCommands=useMemo(()=>({dispatch(command,options){const result=engine.editor.dispatch(command,{label:options?.label,groupId:options?.groupId});report(result);return result;},transaction(callback,options){const result=engine.editor.transaction(callback,{label:options?.label,groupId:options?.groupId});report(result);return result;}}),[engine,report]); + // rendererCommands 直接委托 editor;它不是额外按 viewId/渲染目标绑定的权限层,扩展 renderer 必须遵守调用契约。 // 本组件Hook全部调用后才判断视图关闭,保留React Hook调用顺序;已detach时不挂载交互surface。 if (!liveView) return

画布已关闭

; // surface集中处理焦点epoch、拖动起点和键盘路由:blur微任务只清旧epoch,多触点取消当前手势。 // 快捷键按输入上下文优先级处理Escape、H/V、撤销重做及删除;只有认领成功才阻止默认行为。 // ReactFlow位移变化只转session预览,测量单独缓存;拖动结束依据起始位置补最后位移后commit。 // 工具栏撤销/深复制/导出走各自editor/runtime接口,视口缩放只改view;导出完整文档JSON,不含文件字节。 + // 复制完成回调只比较 engine/viewId,未另设卸载信号;文件暂存、复制和导出均不因关闭面板而自动回滚。 // 文件选择调用stageFiles,引用与图片节点在回调事务里一起创建;导入/AI/资源/恢复面板按加载与选择条件挂载。 return
{ if ((event.target as HTMLElement).closest('input,textarea,[contenteditable="true"],[role="textbox"]')) { focusEpoch.current++; setFocusedEditor(event.target as HTMLElement); } }} @@ -369,6 +380,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi onMoveEnd={(_, viewport) => engine.sessions.viewport(viewId, viewport)} onNodeDragStart={(event, node, nodes) => { + // 起始位置表借用投影 position 引用;后续 preview 必须保持不可变更新,不能原地篡改这些起点。 const ids = new Set((nodes.length ? nodes : [node]).map(node => node.id)); // 同时选中分区入口与后代时只对最上层目标启动手势,后代位置由结构投影带动。 const top = [...ids].filter(id => { const element = current.current.root.document.elements.get(id)!; return !ancestors(current.current.root.document, element.ownerCanvasId).some(canvasId => { const parent = current.current.root.document.canvases.get(canvasId)?.parent; return parent && ids.has(parent.entryElementId); }); }); @@ -419,9 +431,11 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi /** 挂载期生成稳定viewId并attach,借用initialViewport只初始化一次;detach只释放视图,不关闭engine。 * 交接视口存在时禁用加载初次fit,避免从只读预览切入后可视位置突然跳变。 */ export default function HeadlessGraphCanvas({ engine, board, renderers=defaultCanvasRenderers,initialViewport }: { engine: ProjectEngine; board: Board; renderers?:ReactRenderers;initialViewport?:Viewport }) { + // 初始视口只捕获一次;同一挂载内 props.initialViewport 变化不会重新定位,切项目应由宿主正确重建/切会话。 const [handoffViewport]=useState(initialViewport); const [viewId] = useState(() => crypto.randomUUID()), [attached, setAttached] = useState(false); // attach完成后才挂载CanvasView所需Provider;清理时detach视图,CanvasView卸载清理自身订阅。 + // attach/viewport 在 effect 中同步执行,未捕获异常;attached 是显示门闩,不负责服务端加载或持久化就绪。 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 ? :

正在准备画布…

; }