问题缘由#
最近用 MacBook 的时间多了起来,不得不说,它的触控板确实很舒服,特别是在一些需要精细操作的场景中。例如,当我需要将音量从 1% 精准调整到 4% 时,触控板的灵敏度就非常合适。不过,我并没有因此彻底放弃鼠标:使用 Photoshop 之类的软件时,鼠标依然更加顺手;其他时候,选择鼠标还是触控板,只取决于我完成键盘操作后,我手离哪个设备更近。例如,打完一段文字后,只需将手稍稍下移,我就会很自然地使用触控板滚动页面。
问题也正是在两种设备的切换中出现的:使用鼠标滚轮调节网页视频音量时,一切符合直觉;换成触控板后,向下滚动反而会升高音量。
究其原因,其实并不复杂。使用触控板时,滚动方向遵循的是「自然滚动」原则——向上滑动,内容向下移动;向下滑动,内容向上移动。这种交互逻辑试图模拟现实世界中「推动纸张」的物理直觉。而鼠标滚轮的语义则恰好相反:向上滚动,表示返回到更上方的内容。
这里需要补充一个前提:在 macOS 的默认设置中,鼠标与触控板的「自然滚动」只能统一开启或关闭。然而,对使用传统滚轮鼠标的用户来说,让触控板保持自然滚动、鼠标保留非自然滚动,往往更符合两种设备各自的操作直觉。因此,不少人会借助 Scroll Reverser 或鼠标厂商提供的驱动,分别设置二者的滚动方向。(Magic Mouse 的交互方式更接近触控板,不属于本文讨论的传统滚轮鼠标。)
我的使用环境也是如此。因此,下文均假设触控板采用「自然滚动」,鼠标滚轮采用「非自然滚动」。
正是这种差异,使相同方向的手部动作在两种输入设备上具有相反的含义。不过,在浏览网页时,这通常不会造成困扰:系统会按照各自的滚动设置处理输入,而我们也早已习惯在两种操作逻辑之间切换。
然而,音量并不是一张可以被推动的「纸」,它只有一套与设备无关的方向语义:向上升高,向下降低。许多网页播放器按照传统鼠标滚轮的方向实现音量控制,因此切换到自然滚动的触控板后,操作方向就会显得颠倒。
如果只使用触控板,这个问题其实很简单:把播放器收到的滚动方向统一反转即可。真正让问题变复杂的,是我需要混用鼠标与触控板。无条件反转所有滚动事件,固然能修好触控板,却会同时把原本正常的鼠标滚轮也变成反向。
所以,我真正需要的并不是一个简单的 reverse,而是一个有条件的 reverse:事件来自触控板时反转方向,来自鼠标时保持不变。问题也由此转化为:网页能否判断一次滚动事件究竟来自鼠标,还是触控板?
显然,实现一个系统层级的解决方案并不现实。归根结底,我们需要在具体应用的上下文中解决这个问题——毕竟,这种交互需求几乎只会出现在看视频的时候。
为了进一步简化问题,并结合我自己的使用场景,我将目标锁定在 B 站,并决定通过一个网页脚本来实现这一行为的修正,即:实现一个仅针对于 B 站视频内的 Scroll Reverser。
研究思路#
要实现这种有条件的方向修正,首先必须区分鼠标和触控板。
遗憾的是,当前浏览器并没有提供任何可以直接区分鼠标和触控板的官方 API。通过大量资料查询,也没有找到完全可靠的解决方案。因此,我们只能依赖一些经验性特征进行推断——大多数现有方法也是如此。
Wheel Event#
在网页中,鼠标滚轮和触控板的滚动行为最终都会被统一抽象为 WheelEvent[1]。
| 参数 | 说明 |
|---|---|
| WheelEvent.deltaX | 返回 double 值,该值表示滚轮的横向滚动量。 |
| WheelEvent.deltaY | 返回 double 值,该值表示滚轮的纵向滚动量。 |
| WheelEvent.deltaZ | 返回 double 值,该值表示滚轮的 z 轴方向上的滚动量。 |
在实际使用中,deltaY 的值会根据不同的输入设备而有所差异。通过观察这些差异,我们或许可以推断出当前的输入设备类型。
尽管,一些方法可以通过分析一段时间内的滚动变化来判断输入设备,准确率也很高。但对于音量调节这种需要即时响应的操作,这种方式显然并不适用。因此,我们需要寻找一种能够在单次滚动事件中区分输入设备的方法。
因此,我写了如下代码方便测试差异。
See the Pen wheel event test by Max C. Foo (@maxchang3) on CodePen.
经过测试,结果如下:
macOS [2] [3]#
1.鼠标滚轮推动下的 deltaY 存在一个最小整数值,除去这个值之外的其他值大概率为浮点数,并且小数点后较为复杂。形如:235.867919921875。
2.在大多数情况下为整数;即使出现浮点数,其形式也相对规整,形如:2.5。
- 经过观察,这个数值会受到网页缩放比例(devicePixelRatio)的影响——原本的整数值在经过缩放系数处理后,可能转化为一个看起来「干净」的浮点数。
Windows [4]#
1.未开启平滑滚动的情况下。通过不同的力度,仅能得到几个整数值。最低为 100。高至 600.
2.开启平滑滚动的情况下,数值为分布在 1 左右的浮点数。
结论#
综合以上测试结果,我们可以构建如下判断策略:
对于 macOS (除 Firefox),可以精准的区分触控板,采取人工矫正的方式,使用鼠标的最低刻度推动获取一个最低的整数 deltaY。除去这个值外的所有数值,进一步判断是否为整数(经过乘 2 处理以避免浮点数 x.5 的情况),即可判断为触控板。
在网页缩放比例为整数倍(如 100%、200%、300%)的情况下,触控板产生的浮点数通常都会呈现为 x.5 的形式,因此乘 2 后判断是否为整数即可。
但是,当网页缩放比例为非整数倍时,这个逻辑显然是是有问题的。为了解决这一问题,我曾尝试过多种方案,例如:
- 根据当前网页缩放比例对 deltaY 进行反向修正
- 判断 deltaY 是否可以被规格化(normalize)为一个整数
- 等等
但这些方法在实际使用中都存在边界情况,始终无法做到完全可靠,只能暂时搁置。
对于 Windows 和任意平台的 Firefox。则需要设定一个合理的阈值,尽管会出现判断出错的情况,但是对于音量精细调节的场景是可以接受的。
以下是一个简单示例,其中 MOUSE_MIN 为鼠标推动下的 deltaY 的最小整数值,-1 为直接取一个固定值 100(也可为其他数值,经测试这个数值相对合理)。
const isTrackpad = (wheelEvent: WheelEvent) => { if (MOUSE_MIN === -1) return Math.abs(wheelEvent.deltaY) < 100 return Math.abs(wheelEvent.deltaY) != MOUSE_MIN && Number.isInteger(wheelEvent.deltaY * 2)}最终成果#
至此,输入设备的判断逻辑已经基本确定。
以上判断逻辑确定后,接下来就是对 B 站的视频音量控制进行拦截和处理了。
我通过 Hook EventTarget.prototype.addEventListener 拦截对应的 mousewheel 事件 (所以 B 站为什么不用 判断是否为触控板,添加代理拦截 wheelDelta 值,取相反数(这里直接取 deltaY 后做一定计算处理,他与 wheel!)wheelDelta 正负相异)后返回。
最后,我实现了一个油猴脚本,使用 vite-plugin-monkey 进行工程化开发!完整项目请看这里~
如果你感兴趣,可以安装使用:
[Greasyfork] [Github Release] [Github Pages]
一点展望#
需要强调的是,这一方案本质上仍然是观察得到的特征进行的经验判断,而非对浏览器内部实现机制的分析或利用。这也正是我在最后想进一步讨论的问题。
之前我产生过一个大胆的想法:既然 macOS 系统层级有丰富的触控板事件接口,如果 Windows 也能对等支持,我们是否可以维护一个本地中转服务,将最原始的鼠标/触控板数据实时推送到网页端(例如通过浏览器插件实时通讯?)虽然实现代价确实有点大。
希望有相关的标准早点进浏览器吧(也许我以后会去提一个 RFC?)
注释#
[1] 来自 MDN,有省略。B 站实际使用的是 mousewheel event。由于该标准已经不推荐使用,所以这里使用 WheelEvent。
[2] 仅 Chrome / Safari,Firefox 下全部为整数值。