From caa233e6174eda14ad15d35bd7aa407ebede4306 Mon Sep 17 00:00:00 2001 From: Ryuu-64 <61548752+Ryuu-64@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:57:04 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=82=AC=E5=81=9C=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E8=B7=A8=E9=97=B4=E9=9A=99=E4=B8=8D=E5=86=8D=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=20250ms=20=E5=AE=BD=E9=99=90=EF=BC=88?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=AE=9E=E6=97=B6=E6=8C=87=E9=92=88=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=E5=88=A4=E5=AE=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 面板定位用 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 与 #65 正交。#65 解决「宿主 layout mutation 触发 updateChip 抹掉面板」 和「250ms 定时器跨面板误杀」;本 PR 解决「指针跨间隙时被固定宽限关掉」。两者互不 依赖,可各自独立合并。 --- client.js | 65 ++++++++++++--- test/pointer-gap.test.mjs | 162 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 9 deletions(-) create mode 100644 test/pointer-gap.test.mjs diff --git a/client.js b/client.js index ab37786..a243757 100644 --- a/client.js +++ b/client.js @@ -1773,11 +1773,52 @@ window.__ModuleLoader__.load({ // 悬停宽限:标签与面板间有间隙,鼠标跨越间隙的瞬间不在任何元素上—— // 离开后给 250ms 宽限期,期间进入面板则取消关闭(官方 HoverCard 的 // pointer-grace 同款思路),同时杜绝闪烁循环。 + // + // 但固定 250ms 只是「赌手快」:面板定位是 r.bottom + 6 或 r.top - h - 6, + // 与触发元素之间有 6px 间隙,指针停在间隙里把宽限走满,面板照样消失; + // 触摸板细调或系统卡顿更容易命中。这里改成确定性判定:宽限到点时复查 + // **实时**指针位置,只要还在「触发元素 + 面板 + 间隙容差」的并集内就不关闭。 + // + // 必须用实时坐标:mouseleave 事件的 clientX/clientY 是离开那一刻的, + // 250ms 后再拿它判断会读到过期位置,等于没判。 + 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) { + if (r === undefined || r === null) return false + if (livePointerX === null || livePointerY === null) return false + return livePointerX >= r.left - TIP_GAP_TOLERANCE && livePointerX <= r.right + TIP_GAP_TOLERANCE + && livePointerY >= r.top - TIP_GAP_TOLERANCE && livePointerY <= r.bottom + TIP_GAP_TOLERANCE + } + /** 实时指针是否仍在任一已展开面板内(含间隙容差)。 */ + function pointerWithinTipLayer() { + for (var i = 0; i < tipLayer.childNodes.length; i++) { + var node = tipLayer.childNodes[i] + if (node.nodeType !== 1 || typeof node.getBoundingClientRect !== 'function') continue + if (pointerInsideRect(node.getBoundingClientRect())) return true + } + return false + } + /** 关不关面板的统一判据:指针还在面板上或触发元素上就保持展开。 */ + function shouldKeepTipOpen(trigger) { + if (pointerWithinTipLayer()) return true + if (trigger === undefined || trigger === null) return false + if (typeof trigger.getBoundingClientRect !== 'function') return false + return pointerInsideRect(trigger.getBoundingClientRect()) + } var hoverGrace = null - function scheduleHide() { + // 触发元素由调用方传入:面板留在 DOM 里时指针常常停在触发元素上 + // (还没移进面板),只判面板矩形会误关。 + function scheduleHide(trigger) { if (hoverGrace !== null) clearTimeout(hoverGrace) hoverGrace = setTimeout(function () { hoverGrace = null + if (shouldKeepTipOpen(trigger)) return tipLayer.textContent = '' }, 250) } @@ -1785,9 +1826,9 @@ window.__ModuleLoader__.load({ if (hoverGrace !== null) { clearTimeout(hoverGrace); hoverGrace = null } } chipLayer.addEventListener('mouseenter', function () { cancelHide(); showChipTip() }) - chipLayer.addEventListener('mouseleave', scheduleHide) + chipLayer.addEventListener('mouseleave', function () { scheduleHide(chipLayer) }) tipLayer.addEventListener('mouseenter', cancelHide) - tipLayer.addEventListener('mouseleave', scheduleHide) + tipLayer.addEventListener('mouseleave', function () { scheduleHide() }) function showChipTip() { if (ui.quotes.length === 0) return @@ -2044,15 +2085,16 @@ window.__ModuleLoader__.load({ if (bubbleGrace !== null) clearTimeout(bubbleGrace) bubbleGrace = setTimeout(function () { bubbleGrace = null + if (shouldKeepTipOpen(tag)) return tipLayer.textContent = '' }, 250) } function bubbleKeep() { if (bubbleGrace !== null) { clearTimeout(bubbleGrace); bubbleGrace = null } } - tag.addEventListener('mouseleave', bubbleHide) + tag.addEventListener('mouseleave', function () { scheduleHide(tag) }) tipLayer.addEventListener('mouseenter', bubbleKeep) - tipLayer.addEventListener('mouseleave', bubbleHide) + tipLayer.addEventListener('mouseleave', function () { scheduleHide(tag) }) })(items) tag.__annotationItems = items bubble.appendChild(tag) @@ -2173,9 +2215,13 @@ window.__ModuleLoader__.load({ chip.textContent = 'Annotation ' + num var item = items[num - 1] var grace = null - function hide() { + function hide(trigger) { if (grace !== null) clearTimeout(grace) - grace = setTimeout(function () { grace = null; tipLayer.textContent = '' }, 250) + grace = setTimeout(function () { + grace = null + if (shouldKeepTipOpen(trigger)) return + tipLayer.textContent = '' + }, 250) } function keep() { if (grace !== null) { clearTimeout(grace); grace = null } @@ -2217,9 +2263,9 @@ window.__ModuleLoader__.load({ el.style.top = Math.max(8, top) + 'px' el.style.width = w2 + 'px' }) - chip.addEventListener('mouseleave', hide) + chip.addEventListener('mouseleave', function () { hide(chip) }) tipLayer.addEventListener('mouseenter', keep) - tipLayer.addEventListener('mouseleave', hide) + tipLayer.addEventListener('mouseleave', function () { hide(chip) }) return chip } @@ -2326,6 +2372,7 @@ window.__ModuleLoader__.load({ document.removeEventListener('click', onSendKeyboardClick, true) document.removeEventListener('compositionstart', markImeComposing, true) document.removeEventListener('compositionend', markImeEnded, true) + document.removeEventListener('pointermove', onTipPointerMove, true) if (imeClearTimer !== null) { clearTimeout(imeClearTimer); imeClearTimer = null } window.removeEventListener('scroll', onLayoutChange, true) window.removeEventListener('resize', onLayoutChange) diff --git a/test/pointer-gap.test.mjs b/test/pointer-gap.test.mjs new file mode 100644 index 0000000..b6fe8d9 --- /dev/null +++ b/test/pointer-gap.test.mjs @@ -0,0 +1,162 @@ +// 悬停面板的「跨间隙保活」回归测试。 +// +// 背景:面板与触发元素之间留有 6px 间隙(定位是 r.bottom + 6 / r.top - h - 6), +// 鼠标跨越间隙的瞬间不在任何元素上。仅靠固定 250ms 宽限(官方 HoverCard 的 +// pointer-grace 思路)等于赌用户手快——指针停在间隙里把宽限走满,面板就消失。 +// +// 这里把 client.js 里真实的 pointerInsideRect / pointerWithinTipLayer / +// shouldKeepTipOpen / scheduleHide 抽到 vm 沙箱里,用桩定时器与桩几何按行为验证, +// 不是文本断言。 + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import vm from 'node:vm' + +const here = dirname(fileURLToPath(import.meta.url)) +const source = readFileSync(join(here, '..', 'client.js'), 'utf8') + +/** 取出 client.js 里一个顶层 function 的完整源码(花括号配平)。 */ +function extractFunction(name) { + const m = new RegExp(`function ${name}\\s*\\(`).exec(source) + if (m === null) throw new Error(`client.js 里找不到 function ${name}`) + const open = source.indexOf('{', m.index) + let depth = 0 + for (let i = open; i < source.length; i++) { + if (source[i] === '{') depth++ + else if (source[i] === '}') { + depth-- + if (depth === 0) return source.slice(m.index, i + 1) + } + } + throw new Error(`function ${name} 花括号不配平`) +} + +/** 取出 `var NAME = <字面量>` 形式的声明源码。 */ +function extractVar(name) { + const m = new RegExp(`var ${name} = ([^\\n]+)`).exec(source) + if (m === null) throw new Error(`client.js 里找不到 var ${name}`) + return `var ${name} = ${m[1]}` +} + +// 触发元素与面板之间留 6px 间隙(与真实定位一致) +const TRIGGER = { left: 100, right: 200, top: 300, bottom: 318 } +const PANEL = { left: 100, right: 420, top: 324, bottom: 400 } + +function makeEnv() { + const timers = new Map() + let seq = 0 + // tipLayer 必须模拟真实 DOM:插件的清空方式是 `tipLayer.textContent = ''`, + // 浏览器里这会移除全部子节点。若用朴素对象(赋值不生效),"面板已关闭"就观测不到, + // 用例会假绿/假红。 + const tipLayer = { + childNodes: [], + set textContent(v) { if (v === '') this.childNodes.length = 0 }, + get textContent() { return this.childNodes.map((n) => n.textContent || '').join('') }, + } + const sandbox = { + tipLayer, + console, + setTimeout(fn, ms) { const id = ++seq; timers.set(id, { fn, ms }); return id }, + clearTimeout(id) { timers.delete(id) }, + } + vm.createContext(sandbox) + vm.runInContext([ + extractVar('TIP_GAP_TOLERANCE'), + 'var livePointerX = null', + 'var livePointerY = null', + extractFunction('pointerInsideRect'), + extractFunction('pointerWithinTipLayer'), + extractFunction('shouldKeepTipOpen'), + 'var hoverGrace = null', + extractFunction('scheduleHide'), + extractFunction('cancelHide'), + 'globalThis.__env = { scheduleHide, cancelHide, shouldKeepTipOpen,' + + ' moveTo(x, y) { livePointerX = x; livePointerY = y },' + + ' get hoverGrace() { return hoverGrace } }', + ].join('\n'), sandbox) + + const rect = (r) => ({ getBoundingClientRect: () => r }) + return { + env: sandbox.__env, + tipLayer, + /** 展开一个面板(模拟 presentTip 之后的状态)。 */ + showPanel() { const p = rect(PANEL); tipLayer.childNodes.push({ nodeType: 1, ...p }); return p }, + trigger: rect(TRIGGER), + /** 触发所有到期定时器。 */ + flush() { for (const [id, t] of [...timers]) { timers.delete(id); t.fn() } }, + pending: () => timers.size, + } +} + +test('指针停在触发元素上:走满宽限不关闭', () => { + const e = makeEnv() + e.showPanel() + e.env.moveTo(150, 310) // 触发元素矩形内 + e.env.scheduleHide(e.trigger) + e.flush() + assert.equal(e.tipLayer.childNodes.length, 1, '指针还在触发元素上,面板应保持') +}) + +test('指针停在 6px 间隙里:走满宽限也不关闭(上游在这里会消失)', () => { + const e = makeEnv() + e.showPanel() + e.env.moveTo(150, 321) // 318 与 324 之间 + e.env.scheduleHide(e.trigger) + e.flush() + assert.equal(e.tipLayer.childNodes.length, 1, '指针仍在间隙容差内,面板应保持') +}) + +test('指针移进面板本体:走满宽限不关闭', () => { + const e = makeEnv() + e.showPanel() + e.env.moveTo(260, 360) + e.env.scheduleHide(e.trigger) + e.flush() + assert.equal(e.tipLayer.childNodes.length, 1, '指针在面板上,面板应保持') +}) + +test('指针真正离开:正常关闭(不能修成永不关闭)', () => { + const e = makeEnv() + e.showPanel() + e.env.moveTo(900, 900) + e.env.scheduleHide(e.trigger) + e.flush() + assert.equal(e.tipLayer.childNodes.length, 0, '指针远离后应关闭') +}) + +test('判定基于实时指针位置,而不是 mouseleave 的过期坐标', () => { + const e = makeEnv() + e.showPanel() + // 离开触发元素那一刻指针在远处(若用事件坐标会判"该关") + e.env.moveTo(900, 900) + e.env.scheduleHide(e.trigger) + e.flush() + assert.equal(e.tipLayer.childNodes.length, 0, '先确认此刻确实会关闭') + + // 重新展开后,指针在宽限期内移回面板 → 到点时不应关闭 + e.showPanel() + e.env.moveTo(900, 900) + e.env.scheduleHide(e.trigger) + e.env.moveTo(260, 360) // 250ms 内移回面板 + e.flush() + assert.equal(e.tipLayer.childNodes.length, 1, '宽限到点应复查实时位置,而不是事件发生时的坐标') +}) + +test('cancelHide 能取消待关闭', () => { + const e = makeEnv() + e.showPanel() + e.env.moveTo(900, 900) + e.env.scheduleHide(e.trigger) + assert.equal(e.pending(), 1) + e.env.cancelHide() + assert.equal(e.pending(), 0) + assert.equal(e.env.hoverGrace, null) +}) + +test('setup 里注册 pointermove、teardown 里移除', () => { + assert.match(source, /document\.addEventListener\('pointermove', onTipPointerMove, true\)/) + assert.match(source, /document\.removeEventListener\('pointermove', onTipPointerMove, true\)/) +})