feat: 标签页栏支持鼠标滚轮横向滚动 (#61)

普通鼠标滚轮只产生垂直 delta,overflow-x 的横向滚动容器不会消费它,
事件会冒泡导致页面滚动。给 TabBar 的滚动容器绑定原生非 passive wheel
监听(React 的 onWheel 在根节点是 passive,preventDefault 无效),
在标签条溢出时把 deltaY(含 deltaX、deltaMode 归一化)转为 scrollLeft:

- 未溢出时完全不拦截,页面正常滚动(空 pane 亦然)
- 按住 Shift/Ctrl/Cmd/Alt 时放行,保留浏览器原生横滚/缩放
- 每个 pane 的 TabBar 独立监听,只滚动悬停的标签条

新增 tests/tab-bar-wheel.spec.tsx(jsdom 挂载,7 个用例)。
This commit is contained in:
Atlas Zeng
2026-08-14 22:59:19 +08:00
committed by GitHub
parent 277e7d3b40
commit 001e86b1e1
2 changed files with 175 additions and 2 deletions
+23 -2
View File
@@ -5,7 +5,7 @@
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
* dropping on the strip background appends to this pane.
*/
import { useEffect, useState, type ReactNode } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import {
IconCloseFill14, IconPlusOutline16, Menu,
@@ -72,6 +72,27 @@ export function TabBar(props: {
} = props
const [menuOpen, setMenuOpen] = useState(false)
const [dragOver, setDragOver] = useState(false)
const listRef = useRef<HTMLDivElement>(null)
// Wheel over the strip scrolls the tab row horizontally (a plain mouse
// wheel emits deltaY, which overflow-x alone never consumes). Bound as a
// native NON-passive listener: React registers onWheel passively at the
// root, where preventDefault() is a no-op. Modifier keys keep their native
// meaning (shift = horizontal scroll, ctrl/cmd = zoom), and a strip that
// does not overflow leaves the event alone so the page scrolls normally.
useEffect(() => {
const el = listRef.current
if (el === null) return
const onWheel = (event: WheelEvent): void => {
if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return
if (el.scrollWidth <= el.clientWidth) return
event.preventDefault()
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? el.clientWidth : 1
el.scrollLeft += (event.deltaX + event.deltaY) * unit
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => { el.removeEventListener('wheel', onWheel) }
}, [])
useEffect(() => {
const clear = (): void => { setTabDragging(false); setDragOver(false) }
@@ -107,7 +128,7 @@ export function TabBar(props: {
if (payload !== null) onDropTab(payload, null)
}}
>
<div className={css.tabList}>
<div ref={listRef} className={css.tabList}>
{tabs.map(tab => (
<div
key={tab.id}