Skip to content

fix: 悬停面板跨间隙不再依赖固定 250ms 宽限(改为实时指针位置判定) - #66

Merged
taekchef merged 1 commit into
omdsh-dev:mainfrom
Ryuu-64:fix/pointer-position-gap
Sep 30, 2026
Merged

taekchef merged 1 commit into
omdsh-dev:mainfrom
Ryuu-64:fix/pointer-position-gap

Conversation

@Ryuu-64

@Ryuu-64 Ryuu-64 commented Sep 24, 2026

Copy link
Copy Markdown

现象

把鼠标从「批注 ×N」标签(或回复里的 Annotation N 芯片、输入框旁的胶囊)移向面板时,手稍慢面板就没了,想点面板里的「删除」按钮常常点不中。静止悬停不动没事,一开始朝面板移动就容易判成「已离开」。

根因

三类面板的定位都从触发元素外侧 6px 开始:

var top = r2.bottom + 6
if (top + h2 > window.innerHeight - 8) top = r2.top - h2 - 6

于是指针从触发元素移向面板时,必然先经过一段「谁都不在」的 6px 空档,触发元素的 mouseleave 先行发生。当前唯一阻止关闭的是固定 250ms 宽限:

function scheduleHide() {
  if (hoverGrace !== null) clearTimeout(hoverGrace)
  hoverGrace = setTimeout(function () {
    hoverGrace = null
    tipLayer.textContent = ''
  }, 250)
}

也就是说:跨间隙这件事完全在赌用户手速。指针在间隙里停留超过 250ms(触摸板细调、系统卡顿、想对准删除按钮)面板就消失;面板被放到上方时(top < 8 分支)间隙还要更大。

改动

宽限到点时不再无条件关闭,而是复查实时指针位置,只要仍在「面板 ∪ 触发元素」的并集内(容差 10px,覆盖 6px 间隙)就保持展开:

var TIP_GAP_TOLERANCE = 10
var livePointerX = null
var livePointerY = null
function onTipPointerMove(e) { livePointerX = e.clientX; livePointerY = e.clientY }
document.addEventListener('pointermove', onTipPointerMove, true)

function pointerInsideRect(r) { /* 含容差的点-矩形包含判定 */ }
function pointerWithinTipLayer() { /* 任一已展开面板命中 */ }
function shouldKeepTipOpen(trigger) {
  if (pointerWithinTipLayer()) return true
  return pointerInsideRect(trigger.getBoundingClientRect())
}

function scheduleHide(trigger) {
  if (hoverGrace !== null) clearTimeout(hoverGrace)
  hoverGrace = setTimeout(function () {
    hoverGrace = null
    if (shouldKeepTipOpen(trigger)) return   // ← 取代无条件清空
    tipLayer.textContent = ''
  }, 250)
}

hide() / bubbleHide() 同样改为调用 shouldKeepTipOpen,并各自把触发元素传进来;teardown 里移除 pointermove 监听。

为什么必须用实时坐标

mouseleave 事件里的 clientX/clientY 是离开那一刻的位置。250ms 后再拿它判断,读到的是过期值,等于没判——所以用 pointermove(capture)持续跟踪,而不是从事件对象取。

设计取舍

  • 为什么不给 tipLayer 加面积:它是 body 级单例,任何面积都会吃掉整页点击;面板本身靠 position:fixed 收事件。
  • 为什么容差是 10px:间隙是 6px,留 4px 余量给亚像素与指针抖动,同时不足以让「明确移开」被误判为停留。
  • 指针信息缺失(从未收到 pointermove)时,pointerInsideRect 返回 false,行为与改动前一致(照常关闭),不会造成「关不掉」。

回归测试

新增 test/pointer-gap.test.mjs(7 条,全部通过)。不是文本断言:把 client.js 里真实的 pointerInsideRect / pointerWithinTipLayer / shouldKeepTipOpen / scheduleHide 抽到 node:vm 沙箱里,用桩定时器与桩几何按行为验证。

用例 断言
指针停在触发元素上 走满宽限不关闭
指针停在 6px 间隙里 走满宽限不关闭(改动前这里会消失)
指针移进面板本体 走满宽限不关闭
指针真正远离 正常关闭(不能修成永不关闭)
宽限期内指针移回 按实时位置判定,不按事件发生时的坐标
cancelHide 能取消待关闭
生命周期 setup 注册 / teardown 移除 pointermove
$ node --test test/pointer-gap.test.mjs
# tests 7
# pass 7
# fail 0

与 #65 的关系

正交,互不依赖,可各自独立合并。

两者改的是同一段代码的不同方面:本 PR 只把「到点后清空」换成「到点后按实时指针位置判定」,不引入归属模型、不改清理策略,因此与 #65 的 tipOwner / clearTip 思路可以叠加。

实测

DSH Desktop 2.0.11 / 内核 0.1.5-rc.2 / Windows 11:改动前把指针停在标签与面板之间的间隙里,面板必消失;改动后可正常移入面板并点中「删除」。

面板定位用 r.bottom + 6 / r.top - h - 6,与触发元素之间必然留 6px 间隙。鼠标从
触发元素移向面板时会先触发 mouseleave,进入「谁都不在」的空档;当前唯一阻止关闭的
是固定 250ms 宽限(官方 HoverCard 的 pointer-grace 思路)。于是:

- 指针停在间隙里把 250ms 走满 → 面板消失,想点「删除」按钮常常点不中;
- 触摸板细调或系统卡顿更容易命中;
- 面板被放到上方时(top < 8 的分支)间隙还更大。

改动:
- 用 pointermove(capture)持续记录实时指针坐标;
- 新增 pointerInsideRect / pointerWithinTipLayer / shouldKeepTipOpen:宽限到点时
  复查指针是否仍在「面板 ∪ 触发元素」的并集内(容差 10px,覆盖 6px 间隙);
- scheduleHide / hide / bubbleHide 接收触发元素,统一用 shouldKeepTipOpen 判定;
- teardown 移除 pointermove 监听。

为什么必须用实时坐标:mouseleave 事件里的 clientX/clientY 是离开那一刻的位置,
250ms 后再拿它判断会读到过期值,等于没判。

回归测试 test/pointer-gap.test.mjs(7 条):把 client.js 里真实的
pointerInsideRect / pointerWithinTipLayer / shouldKeepTipOpen / scheduleHide
抽到 vm 沙箱里按行为验证 —— 覆盖指针停在触发元素上 / 停在 6px 间隙里 / 移进面板
/ 真正远离(仍会正常关闭)/ 宽限期内移回 / cancelHide / 生命周期注册。

注:本 PR 与 omdsh-dev#65 正交。omdsh-dev#65 解决「宿主 layout mutation 触发 updateChip 抹掉面板」
和「250ms 定时器跨面板误杀」;本 PR 解决「指针跨间隙时被固定宽限关掉」。两者互不
依赖,可各自独立合并。
@taekchef

Copy link
Copy Markdown
Collaborator

感谢 @Ryuu-64 ——把「赌手快」的固定宽限换成实时指针位置的确定性判定,并指出 mouseleave 事件坐标是过期坐标这个关键点,测试也是行为级的(vm 沙箱 + 桩几何),质量很高。

情况说明:你的 fork 未开放维护者推送(同 #67),因此「你的原始提交 + 与 main 的合并解决」一起快进推入了 main(42ae59f),本 PR 已标记为 Merged,署名不变。

合并中做的组合:

接下来处理 #63(会话列限定)与 #56(老内核 textarea 回车),之后发版。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants