From ff2a38103fd17463193e4603b569e28fd8e07f42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Mon, 17 Aug 2026 21:13:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(windows):=20=E4=BF=AE=E5=A4=8D=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=9B=AE=E5=BD=95=E5=92=8C=20Git=20=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E9=BC=A0=E6=A0=87=E6=BB=9A=E8=BD=AE=E5=81=B6=E5=8F=91=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebView2 会把滚轮锁在 overflow:hidden 的树节点上,侧栏拖拽条也会挡住滚动条。将滚轮交给真正的滚动容器,避免必须先点击滚动条才能滑动。 Co-authored-by: Cursor --- .../components/file-explorer-viewport.tsx | 4 + .../styles/file-explorer-tree.css | 6 +- .../status/git-status-file-item.tsx | 4 +- .../layout/components/resizable-pane.tsx | 21 +++- .../sidebar/components/sidebar-tree.tsx | 4 +- windows/tauri/src/ui/scroll-area.tsx | 31 ++++- .../src/ui/scroll-container-wheel.test.ts | 63 ++++++++++ .../tauri/src/ui/scroll-container-wheel.ts | 114 ++++++++++++++++++ 8 files changed, 235 insertions(+), 12 deletions(-) create mode 100644 windows/tauri/src/ui/scroll-container-wheel.test.ts create mode 100644 windows/tauri/src/ui/scroll-container-wheel.ts diff --git a/windows/tauri/src/features/file-explorer/components/file-explorer-viewport.tsx b/windows/tauri/src/features/file-explorer/components/file-explorer-viewport.tsx index 378fd5551..3ddd990d9 100644 --- a/windows/tauri/src/features/file-explorer/components/file-explorer-viewport.tsx +++ b/windows/tauri/src/features/file-explorer/components/file-explorer-viewport.tsx @@ -16,6 +16,7 @@ import { getFileTreeVirtualRange, type FileTreeScrollAlignment, } from "@/features/file-explorer/lib/file-tree-viewport"; +import { bindScrollContainerWheel } from "@/ui/scroll-container-wheel"; import { cn } from "@/utils/cn"; export interface FileExplorerViewportHandle { @@ -83,11 +84,13 @@ export const FileExplorerViewport = forwardRef< const resizeObserver = new ResizeObserver(updateLayout); resizeObserver.observe(element); element.addEventListener("scroll", scheduleLayoutUpdate, { passive: true }); + const unbindWheel = bindScrollContainerWheel(element); updateLayout(); return () => { resizeObserver.disconnect(); element.removeEventListener("scroll", scheduleLayoutUpdate); + unbindWheel(); if (frameRef.current !== null) { cancelAnimationFrame(frameRef.current); frameRef.current = null; @@ -166,6 +169,7 @@ export const FileExplorerViewport = forwardRef< return (
span:last-child { min-width: 0; flex: 1 1 auto; - overflow: hidden; + overflow: clip; text-overflow: ellipsis; } diff --git a/windows/tauri/src/features/git/components/status/git-status-file-item.tsx b/windows/tauri/src/features/git/components/status/git-status-file-item.tsx index 325386c07..054c80872 100644 --- a/windows/tauri/src/features/git/components/status/git-status-file-item.tsx +++ b/windows/tauri/src/features/git/components/status/git-status-file-item.tsx @@ -50,7 +50,7 @@ export const GitFileItem = ({ return ( diff --git a/windows/tauri/src/features/layout/components/resizable-pane.tsx b/windows/tauri/src/features/layout/components/resizable-pane.tsx index 7e150c783..0b1bb0d8b 100644 --- a/windows/tauri/src/features/layout/components/resizable-pane.tsx +++ b/windows/tauri/src/features/layout/components/resizable-pane.tsx @@ -1,6 +1,10 @@ import type React from "react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; +import { + bindOverlayWheelToScrollContainer, + querySidebarScrollContainer, +} from "@/ui/scroll-container-wheel"; import { cn } from "@/utils/cn"; import { clampResponsivePaneWidth, @@ -39,6 +43,7 @@ export function ResizablePane({ const [isResizing, setIsResizing] = useState(false); const paneRef = useRef(null); const contentRef = useRef(null); + const resizeHandleRef = useRef(null); const getViewportWidth = () => (typeof window !== "undefined" ? window.innerWidth : 1280); @@ -83,6 +88,15 @@ export function ResizablePane({ return () => window.removeEventListener("resize", handleWindowResize); }, [widthKey, clampWidth]); + useLayoutEffect(() => { + const overlay = resizeHandleRef.current; + if (!overlay) return; + + return bindOverlayWheelToScrollContainer(overlay, () => + querySidebarScrollContainer(contentRef.current), + ); + }, [hidden]); + const handleMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); @@ -134,11 +148,12 @@ export function ResizablePane({ const totalWidth = hidden ? "0px" : `${width}px`; const resizeHandle = !hidden ? (
) : null} {leading ? : null} {label !== undefined ? ( - + {label} {description ? ( @@ -266,7 +266,7 @@ export const SidebarTreeRow = forwardRef children )} {trailing ? ( - + {trailing} ) : null} diff --git a/windows/tauri/src/ui/scroll-area.tsx b/windows/tauri/src/ui/scroll-area.tsx index 501835190..429032ce1 100644 --- a/windows/tauri/src/ui/scroll-area.tsx +++ b/windows/tauri/src/ui/scroll-area.tsx @@ -1,5 +1,10 @@ import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"; +import { useLayoutEffect, useState } from "react"; import type * as React from "react"; +import { + bindOverlayWheelToScrollContainer, + bindScrollContainerWheel, +} from "@/ui/scroll-container-wheel"; import { cn } from "@/utils/cn"; type ScrollAreaOrientation = "vertical" | "horizontal" | "both"; @@ -28,15 +33,37 @@ function ScrollArea({ ...props }: ScrollAreaProps) { const { ref: viewportRef, style: viewportStyle, ...resolvedViewportProps } = viewportProps ?? {}; + const [rootNode, setRootNode] = useState(null); + const [viewportNode, setViewportNode] = useState(null); + + useLayoutEffect(() => { + if (!viewportNode) return; + return bindScrollContainerWheel(viewportNode); + }, [viewportNode]); + + useLayoutEffect(() => { + if (!rootNode) return; + return bindOverlayWheelToScrollContainer(rootNode, () => viewportNode); + }, [rootNode, viewportNode]); + + const setViewportRef = (node: HTMLDivElement | null) => { + setViewportNode(node); + if (typeof viewportRef === "function") { + viewportRef(node); + } else if (viewportRef) { + viewportRef.current = node; + } + }; return ( { + test("treats pixel wheel deltas as pixels", () => { + expect( + getWheelDeltaPixels( + { deltaX: 0, deltaY: 120, deltaMode: 0 }, + { lineHeight: 16, pageWidth: 240, pageHeight: 400 }, + ), + ).toEqual({ x: 0, y: 120 }); + }); + + test("converts line and page wheel deltas to pixels", () => { + expect( + getWheelDeltaPixels( + { deltaX: 0, deltaY: 3, deltaMode: 1 }, + { lineHeight: 20, pageWidth: 240, pageHeight: 400 }, + ), + ).toEqual({ x: 0, y: 60 }); + expect( + getWheelDeltaPixels( + { deltaX: 0, deltaY: 1, deltaMode: 2 }, + { lineHeight: 20, pageWidth: 240, pageHeight: 400 }, + ), + ).toEqual({ x: 0, y: 400 }); + }); + + test("keeps horizontal project-switch gestures from capturing vertical scroll", () => { + expect(isMostlyVerticalWheel(40, 8)).toBe(false); + expect(isMostlyVerticalWheel(8, 40)).toBe(true); + }); + + test("moves a nested overflow container that Chromium would otherwise latch onto a row", () => { + const element = { + scrollTop: 0, + scrollHeight: 800, + clientHeight: 200, + }; + + expect(applyVerticalWheelToScrollContainer(element, 80)).toBe(true); + expect(element.scrollTop).toBe(80); + }); + + test("does not consume wheel events at the scroll boundary", () => { + const element = { + scrollTop: 0, + scrollHeight: 800, + clientHeight: 200, + }; + + expect(applyVerticalWheelToScrollContainer(element, -40)).toBe(false); + expect(element.scrollTop).toBe(0); + + element.scrollTop = 600; + expect(applyVerticalWheelToScrollContainer(element, 40)).toBe(false); + expect(element.scrollTop).toBe(600); + }); +}); diff --git a/windows/tauri/src/ui/scroll-container-wheel.ts b/windows/tauri/src/ui/scroll-container-wheel.ts new file mode 100644 index 000000000..be5fe2d07 --- /dev/null +++ b/windows/tauri/src/ui/scroll-container-wheel.ts @@ -0,0 +1,114 @@ +const DOM_DELTA_LINE = 1; +const DOM_DELTA_PAGE = 2; + +// Chromium/WebView2 can latch wheel events onto overflow:hidden tree rows +// instead of the sidebar scroller. Apply the delta to the real container. + +export const SIDEBAR_SCROLL_CONTAINER_SELECTOR = + "[data-slot='scroll-area-viewport'], [data-scroll-container], .file-tree-container"; + +interface WheelDeltaEvent { + deltaX: number; + deltaY: number; + deltaMode: number; +} + +interface WheelDeltaMetrics { + lineHeight: number; + pageWidth: number; + pageHeight: number; +} + +interface VerticalScrollContainer { + scrollTop: number; + scrollHeight: number; + clientHeight: number; +} + +export function isMostlyVerticalWheel(deltaX: number, deltaY: number) { + return Math.abs(deltaY) >= Math.abs(deltaX); +} + +export function getWheelDeltaPixels(event: WheelDeltaEvent, metrics: WheelDeltaMetrics) { + if (event.deltaMode === DOM_DELTA_LINE) { + return { + x: event.deltaX * metrics.lineHeight, + y: event.deltaY * metrics.lineHeight, + }; + } + + if (event.deltaMode === DOM_DELTA_PAGE) { + return { + x: event.deltaX * metrics.pageWidth, + y: event.deltaY * metrics.pageHeight, + }; + } + + return { x: event.deltaX, y: event.deltaY }; +} + +export function applyVerticalWheelToScrollContainer( + element: VerticalScrollContainer, + deltaY: number, +) { + if (deltaY === 0) return false; + + const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight); + const nextScrollTop = Math.max(0, Math.min(maxScrollTop, element.scrollTop + deltaY)); + if (nextScrollTop === element.scrollTop) return false; + + element.scrollTop = nextScrollTop; + return true; +} + +function getLineHeight(element: HTMLElement) { + const lineHeight = Number.parseFloat(getComputedStyle(element).lineHeight); + return Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : 16; +} + +function applyVerticalWheelEvent(element: HTMLElement, event: WheelEvent) { + if (event.ctrlKey || event.metaKey || event.defaultPrevented) return false; + if (!isMostlyVerticalWheel(event.deltaX, event.deltaY)) return false; + + const delta = getWheelDeltaPixels(event, { + lineHeight: getLineHeight(element), + pageWidth: element.clientWidth, + pageHeight: element.clientHeight, + }); + + return applyVerticalWheelToScrollContainer(element, delta.y); +} + +export function bindScrollContainerWheel(element: HTMLElement) { + const onWheel = (event: WheelEvent) => { + if (!applyVerticalWheelEvent(element, event)) return; + event.preventDefault(); + }; + + element.addEventListener("wheel", onWheel, { capture: true, passive: false }); + return () => { + element.removeEventListener("wheel", onWheel, { capture: true }); + }; +} + +export function bindOverlayWheelToScrollContainer( + overlay: HTMLElement, + getScrollContainer: () => HTMLElement | null, +) { + const onWheel = (event: WheelEvent) => { + const element = getScrollContainer(); + if (!element) return; + if (!applyVerticalWheelEvent(element, event)) return; + event.preventDefault(); + }; + + overlay.addEventListener("wheel", onWheel, { passive: false }); + return () => { + overlay.removeEventListener("wheel", onWheel); + }; +} + +export function querySidebarScrollContainer(root: ParentNode | null) { + if (!root) return null; + return root.querySelector(SIDEBAR_SCROLL_CONTAINER_SELECTOR); +}