From 001e86b1e10ee657f01b68d637beabe427a573a5 Mon Sep 17 00:00:00 2001 From: Atlas Zeng Date: Fri, 14 Aug 2026 22:59:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A0=87=E7=AD=BE=E9=A1=B5=E6=A0=8F?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E9=BC=A0=E6=A0=87=E6=BB=9A=E8=BD=AE=E6=A8=AA?= =?UTF-8?q?=E5=90=91=E6=BB=9A=E5=8A=A8=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 普通鼠标滚轮只产生垂直 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 个用例)。 --- src/client/TabBar.tsx | 25 +++++- tests/tab-bar-wheel.spec.tsx | 152 +++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 tests/tab-bar-wheel.spec.tsx diff --git a/src/client/TabBar.tsx b/src/client/TabBar.tsx index 72c325d..4e38487 100644 --- a/src/client/TabBar.tsx +++ b/src/client/TabBar.tsx @@ -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(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) }} > -
+
{tabs.map(tab => (
).IS_REACT_ACT_ENVIRONMENT = true + +import { TabBar } from '../src/client/TabBar.tsx' +import type { SidebarTab } from '../src/client/state.ts' + +function mountBar(overflow: boolean): { list: HTMLElement; unmount: () => void } { + const container = document.createElement('div') + document.body.append(container) + const tabs: SidebarTab[] = [ + { id: 't1', type: 'explorer', title: 'Explorer' }, + { id: 't2', type: 'git', title: 'Git' }, + ] + const root: Root = createRoot(container) + act(() => { + root.render(createElement(TabBar, { + paneId: 'pane:1', + tabs, + active: 't1', + onActivate: () => {}, + onClose: () => {}, + onNewTab: () => {}, + newTabOptions: [], + onDropTab: () => {}, + })) + }) + const list = container.querySelector('[class*="tabList"]') as HTMLElement + expect(list).not.toBeNull() + // jsdom has no layout: stub the scrollport geometry to simulate overflow. + Object.defineProperty(list, 'scrollWidth', { value: overflow ? 600 : 200, configurable: true }) + Object.defineProperty(list, 'clientWidth', { value: 200, configurable: true }) + return { + list, + unmount: () => { + act(() => { root.unmount() }) + container.remove() + }, + } +} + +/** Dispatch a wheel event on the strip and return whether it was consumed. */ +function wheel(list: HTMLElement, init: WheelEventInit): WheelEvent { + const event = new WheelEvent('wheel', { cancelable: true, bubbles: true, ...init }) + list.dispatchEvent(event) + return event +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('TabBar wheel → horizontal scroll', () => { + it('translates a vertical wheel delta into scrollLeft when the strip overflows', () => { + const { list, unmount } = mountBar(true) + try { + list.scrollLeft = 0 + const event = wheel(list, { deltaY: 120 }) + expect(event.defaultPrevented).toBe(true) + expect(list.scrollLeft).toBe(120) + } finally { + unmount() + } + }) + + it('scans left for a negative delta', () => { + const { list, unmount } = mountBar(true) + try { + list.scrollLeft = 500 + const event = wheel(list, { deltaY: -80 }) + expect(event.defaultPrevented).toBe(true) + expect(list.scrollLeft).toBe(420) + } finally { + unmount() + } + }) + + it('does not consume the event when the strip does not overflow', () => { + const { list, unmount } = mountBar(false) + try { + list.scrollLeft = 0 + const event = wheel(list, { deltaY: 120 }) + expect(event.defaultPrevented).toBe(false) + expect(list.scrollLeft).toBe(0) + } finally { + unmount() + } + }) + + it('leaves modifier-key gestures to the browser', () => { + const { list, unmount } = mountBar(true) + try { + list.scrollLeft = 0 + const ctrl = wheel(list, { deltaY: 120, ctrlKey: true }) + const shift = wheel(list, { deltaY: 120, shiftKey: true }) + const meta = wheel(list, { deltaY: 120, metaKey: true }) + expect(ctrl.defaultPrevented).toBe(false) + expect(shift.defaultPrevented).toBe(false) + expect(meta.defaultPrevented).toBe(false) + expect(list.scrollLeft).toBe(0) + } finally { + unmount() + } + }) + + it('scales line-mode deltas by 16px per line', () => { + const { list, unmount } = mountBar(true) + try { + list.scrollLeft = 0 + const event = wheel(list, { deltaY: 3, deltaMode: 1 }) + expect(event.defaultPrevented).toBe(true) + expect(list.scrollLeft).toBe(48) + } finally { + unmount() + } + }) + + it('adds a horizontal trackpad delta to the scroll', () => { + const { list, unmount } = mountBar(true) + try { + list.scrollLeft = 0 + const event = wheel(list, { deltaX: 40 }) + expect(event.defaultPrevented).toBe(true) + expect(list.scrollLeft).toBe(40) + } finally { + unmount() + } + }) + + it('stops scrolling after unmount', () => { + const { list, unmount } = mountBar(true) + list.scrollLeft = 0 + unmount() + const event = wheel(list, { deltaY: 120 }) + expect(event.defaultPrevented).toBe(false) + expect(list.scrollLeft).toBe(0) + }) +})