给选中节点加工具栏时,第一版往往只有两步:读取节点的 x/y,再写进绝对定位的 div。画布一缩放、平移,或是外层容器开始裁切,工具栏就不再贴着节点。
局部坐标和视口坐标混用会让工具栏错位,CSS 无法补上这层换算。要让浮层稳定跟随画布,React 和 X6 之间还需要补上一层同步。
错位来自两个世界
X6 的节点和边使用图的局部坐标。工具栏、右键菜单是普通 DOM,position: fixed 取的是浏览器视口坐标。画布缩放、平移以及容器本身的偏移,都夹在这两个坐标系之间。
1 2 3 4 5 6 7 8
| 图模型坐标 local(x, y) │ │ graph.localToClient ▼ 浏览器坐标 client(x, y) │ ▼ Portal DOM 的 left / top
|
如果直接把 node.position() 的结果塞进 CSS,只有在缩放比例为 1、没有平移、容器恰好从视口左上角开始时才会正确。
Portal 只解决挂载层级
createPortal 可以把工具栏渲染到图容器外层或 document.body,从而避开 SVG 内部层级、overflow 裁切和局部 z-index 的限制:
1 2 3 4
| return createPortal( <div className="toolbar" style={style}>...</div>, getContainer(), );
|
Portal 解决的是挂载层级,图坐标仍要换算成 CSS 坐标。前者决定「在哪渲染」,后者才决定「渲染到哪」。
节点工具栏:从选区外接盒推导锚点
多选时,工具栏应跟着整个选区走,所以先计算选中节点的外接盒:
1 2 3
| const { minX, minY, maxX, maxY } = getCoordinateExtremum(nodes); const minPoint = graph.localToClient({ x: minX, y: minY }); const maxPoint = graph.localToClient({ x: maxX, y: maxY });
|
若工具栏宽度是 toolbarWidth,将其置于选区顶部中间:
1 2
| const left = minPoint.x + (maxPoint.x - minPoint.x - toolbarWidth) / 2; const top = minPoint.y - toolbarHeight - offset;
|
计算极值的成本是 O(s),s 为当前选中节点数;单节点和边工具栏通常是 O(1)。相比整张图的渲染,这点计算量通常不值得优先优化。
边工具栏不能照搬节点策略
边和节点不一样,通常又细又长。水平边的工具栏放在上方居中还算自然;垂直边仍放上方就容易压住边本身,放到右侧更顺手。因此可以根据边的 BBox 宽高选择锚点:
1 2 3 4 5
| if (width >= height) { } else { }
|
这里没有唯一的规则。边的路由方向、工具栏避障,以及靠近视口边缘时翻转位置,都属于这一层的布局策略。
右键菜单要保存局部事件点
cell:contextmenu 事件给出的 x/y 是图的局部坐标。打开菜单时应保存这个点,后续再由它换算 CSS 坐标:
1 2 3
| setEventPoint({ x, y }); const point = graph.localToClient({ x, y }); setStyle({ left: `${point.x}px`, top: `${point.y}px` });
|
原因在于后续缩放或平移。若只保留旧的 left/top,菜单会停在旧视口位置;保留局部点后,可以在视口变换时重新执行转换。
画布平移:监听 translate
画布视口平移时,X6 会触发 Graph 的 translate 事件;回调里的 tx、ty 是当前平移偏移量。不要把它们累加到旧的 CSS 坐标上。缩放、容器偏移和连续平移混在一起算,位置迟早会漂。
更稳妥的方式是留下局部锚点,让 translate 和 scale 共用一个重定位函数。节点工具栏从 node.position() 和 node.size() 计算选区的局部外接盒;右键菜单直接保存 cell:contextmenu 给出的局部 x/y。视口变化后,再把这些局部坐标交给 graph.localToClient:
1 2 3 4 5
| graph.on('translate', () => { const point = graph.localToClient(eventPoint); setStyle({ left: point.x, top: point.y }); });
|
localToClient 会带上当前缩放、平移和图容器在视口中的位置,因此重算时不必手动读取或叠加 tx/ty。
订阅事件,把浮层看成投影
工具栏定位后仍要跟着 Graph 的状态变化。至少要监听这些事件。
| 图事件 |
React 侧动作 |
selection:changed |
根据选中 Cell 类型决定展示哪类工具栏 |
cell:contextmenu |
保存目标 Cell 和局部坐标,打开菜单 |
blank:click |
清空浮层状态 |
scale |
按当前选区或局部事件点重算位置 |
translate |
用同一套局部锚点重算位置 |
cell:removed |
移除失效引用,必要时关闭浮层 |
Graph 保留图上的真实状态;React state 只描述当前展示什么浮层,以及它锚定的对象。
stateDiagram-v2
[*] --> Hidden
Hidden --> NodeToolbar: selection:changed
Hidden --> ContextMenu: cell:contextmenu
NodeToolbar --> Hidden: blank:click / cell:removed
ContextMenu --> Hidden: blank:click / cell:removed
NodeToolbar --> NodeToolbar: scale / translate → recalculate
ContextMenu --> ContextMenu: scale / translate → localToClient
下面把订阅、重定位和清理收在同一个 useEffect 里。示例只保留节点工具栏和右键菜单,接入业务时把两个 div 换成对应组件即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135
| import type { CSSProperties } from 'react'; import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Cell, Dom, Graph, Node } from '@antv/x6';
type Point = { x: number; y: number }; type Overlay = | { kind: 'toolbar'; nodes: Node[] } | { kind: 'context-menu'; cell: Cell; localPoint: Point } | null;
type DynamicOverlayProps = { graph: Graph; container: HTMLElement; };
function getToolbarStyle(graph: Graph, nodes: Node[]): CSSProperties { const boxes = nodes.map((node) => { const { x, y } = node.position(); const { width, height } = node.size(); return { x, y, right: x + width, bottom: y + height }; }); const minX = Math.min(...boxes.map((box) => box.x)); const minY = Math.min(...boxes.map((box) => box.y)); const maxX = Math.max(...boxes.map((box) => box.right)); const minPoint = graph.localToClient({ x: minX, y: minY }); const maxPoint = graph.localToClient({ x: maxX, y: minY });
return { position: 'fixed', left: minPoint.x + (maxPoint.x - minPoint.x - 264) / 2, top: minPoint.y - 60, }; }
function getOverlayStyle(graph: Graph, overlay: Exclude<Overlay, null>) { if (overlay.kind === 'toolbar') { return getToolbarStyle(graph, overlay.nodes); }
const point = graph.localToClient(overlay.localPoint); return { position: 'fixed', left: point.x, top: point.y }; }
export function DynamicOverlay({ graph, container }: DynamicOverlayProps) { const [overlay, setOverlay] = useState<Overlay>(null); const [style, setStyle] = useState<CSSProperties>(); const overlayRef = useRef<Overlay>(null);
useEffect(() => { const commit = (next: Overlay) => { overlayRef.current = next; setOverlay(next); setStyle(next ? getOverlayStyle(graph, next) : undefined); };
const clear = () => commit(null);
const reposition = () => { const current = overlayRef.current; if (current) { setStyle(getOverlayStyle(graph, current)); } };
const handleSelectionChanged = ({ selected }: { selected: Cell[] }) => { if (selected.length > 0 && selected.every((cell) => cell.isNode())) { commit({ kind: 'toolbar', nodes: selected as Node[] }); } else { clear(); } };
const handleContextMenu = ({ e, cell, x, y, }: { e: Dom.ContextMenuEvent; cell: Cell; x: number; y: number; }) => { e.preventDefault(); commit({ kind: 'context-menu', cell, localPoint: { x, y } }); };
const handleCellRemoved = ({ cell }: { cell: Cell }) => { const current = overlayRef.current; if (!current) return;
if (current.kind === 'context-menu' && current.cell.id === cell.id) { clear(); } if (current.kind === 'toolbar') { const nodes = current.nodes.filter((node) => node.id !== cell.id); commit(nodes.length > 0 ? { kind: 'toolbar', nodes } : null); } };
graph.on('selection:changed', handleSelectionChanged); graph.on('cell:contextmenu', handleContextMenu); graph.on('blank:click', clear); graph.on('scale', reposition); graph.on('translate', reposition); graph.on('cell:removed', handleCellRemoved);
return () => { graph.off('selection:changed', handleSelectionChanged); graph.off('cell:contextmenu', handleContextMenu); graph.off('blank:click', clear); graph.off('scale', reposition); graph.off('translate', reposition); graph.off('cell:removed', handleCellRemoved); }; }, [graph]);
if (!overlay || !style) return null;
return createPortal( overlay.kind === 'toolbar' ? ( <div className="toolbar" style={style}> 已选中 {overlay.nodes.length} 个节点 </div> ) : ( <div className="context-menu" style={style}> {overlay.cell.id} 的右键菜单 </div> ), container, ); }
|
删除时也要清理状态
如果 React state 还握着一个已删除的 Cell,下一次缩放或渲染就可能访问失效对象。处理删除事件时,要同步更新缓存的节点或边;集合清空后,浮层也该关掉。
这不只发生在图编辑器里。订阅型 UI 一旦引用外部可销毁对象,就必须在对象销毁时把引用回收掉。
常见误区
- 只用 Portal:层级问题解决了,坐标问题还在。
- 只在选中时计算一次:一缩放或平移,位置就过期。
- 把 Graph 放进 React state:高频图交互会带来不必要的 React 重渲染。保留实例引用,只投影少量 UI 状态更合适。
- 不处理混选:多节点、单边、节点边混选都要有显示规则,否则工具栏不知道该表达什么。
总结
要让浮层在可缩放画布上跟住目标,这三部分缺一不可。
1 2 3
| 事件订阅:什么时候更新 + 坐标变换:如何从 local 到 client + Portal 容器:在哪个层级渲染
|
这三部分连起来,浮层才会在画布变化时持续跟住它的锚点。