X6 中边悬浮置顶,规避 `mouseleave` 事件丢失问题

0 阅读3分钟

最近在这个 X6 ER 图示例里,我修了一个看起来不大、但实际很容易踩坑的交互问题:

  1. 鼠标悬浮到一条边上时,希望这条边显示到更上层,避免被其他元素压住。

这篇文章记录一下这个问题的成因,以及我最后采用的修复方案。

现象

在 ER 图里,边默认是普通灰色;鼠标移动到边上时,会切换为高亮色。

但如果只做最直接的悬浮效果,会遇到两个问题:

  1. 当前悬浮的边可能仍然被别的节点或边压着,视觉上不够明显。
  2. 如果在 mouseenter 里把边提到更高层,DOM / View 层级发生变化后,原本那条边的 mouseleave 事件有概率丢失。

丢失之后的表现是:

  1. 边已经没有被鼠标悬浮,但仍然保持高亮。
  2. 再移动到其他边时,会出现多个边状态不一致的情况。

修复方案

1. 记录每条边的初始层级

为了让边在悬浮结束后能回到原始位置,先缓存每条边最初的 z-index

const edgeInitialZIndexMap = new Map<string, number>()

const cacheEdgeInitialZIndex = (edge: Edge) => {
  if (!edge || edgeInitialZIndexMap.has(edge.id)) return
  edgeInitialZIndexMap.set(edge.id, edge.getZIndex())
}

这样后面无论边被提到多高,都可以恢复回来,而不是简单写死成某个固定层级。

2. 用一个全局状态记录“当前真正处于悬浮中的边”

这里增加了一个单一状态源:

let pointerHoverEdgeId: string | null = null

这样悬浮态不再分散在每条边自己的事件回调里,而是统一由这一个状态驱动。

3. 悬浮时提升边层级,并同步样式

我把“高亮颜色 + 提升层级”收敛到一个统一的状态同步函数里:

const syncEdgeState = (edge: Edge) => {
  cacheEdgeInitialZIndex(edge)
  const baseZ = getEdgeLayerBaseZIndex()

  if (pointerHoverEdgeId === edge.id) {
    applyEdgeHoverStyle(edge)
    edge.setZIndex(baseZ + 1)
    return
  }

  applyEdgeDefaultStyle(edge)
  edge.setZIndex(getEdgeInitialZIndex(edge))
}

这里的关键点是:

  1. 当前悬浮边的层级永远设置成“现有最大层级 + 1”。
  2. 非悬浮边恢复默认样式,并回到自己的初始层级。

4. 保留边级事件处理正常路径

正常情况下,仍然优先使用 X6 自己的边事件:

graph.on('edge:mouseenter', ({ edge }) => {
  if (edge.shape === 'er-relationship') {
    syncHoverFallback(edge.id)
    syncEdgeState(edge)
  }
})

graph.on('edge:mouseleave', ({ edge }) => {
  if (pointerHoverEdgeId === edge.id) {
    pointerHoverEdgeId = null
  }
  syncEdgeState(edge)
})

这部分负责处理绝大多数正常交互路径。

5. 用容器级 mousemove 做兜底

真正解决问题的关键,在于增加了容器级监听。

当边的层级变化后,如果某次 edge:mouseleave 没有触发,就由容器上的 mousemove 去判断“鼠标现在到底悬浮在哪个元素上”,然后主动修正状态:

const handleContainerMouseMove = (event: MouseEvent) => {
  const nextEdgeId = resolveHoverEdgeId(event.target)
  syncHoverFallback(nextEdgeId)
}

其中 resolveHoverEdgeId 会通过 graph.findViewByElem(target) 反查当前命中的元素是否属于某条边。

syncHoverFallback 的职责是:

  1. 记录新的悬浮边
  2. 找到上一条悬浮边
  3. 如果上一条边不该再悬浮了,就主动把它恢复默认状态

这就把“等待 mouseleave 通知我结束”改成了“我每次都根据当前真实命中结果来纠正状态”,稳定性会高很多。

6. 鼠标离开整个容器时,统一清空悬浮状态

除了 mousemove,还补了两个离开容器的兜底:

container.addEventListener('mouseleave', handleContainerMouseLeave)
graph.on('graph:mouseleave', handleContainerMouseLeave)

这样当鼠标直接离开画布区域时,也能确保悬浮态被清掉,不会把高亮残留在最后一条边上。

7. 在 dispose 时清理容器事件

因为这次新增了原生 DOM 事件监听,所以销毁时也要把它们收掉:

const originalDispose = graph.dispose.bind(graph)
let hoverFallbackDisposed = false

graph.dispose = (...args: any[]) => {
  if (!hoverFallbackDisposed) {
    container.removeEventListener('mousemove', handleContainerMouseMove)
    container.removeEventListener('mouseleave', handleContainerMouseLeave)
    graph.off('graph:mouseleave', handleContainerMouseLeave)
    hoverFallbackDisposed = true
  }
  originalDispose(...args)
}

这是一个小细节,但很重要。否则组件反复挂载/卸载后,事件可能重复绑定。

代码位置

github.com/adai212/X6D…