diff --git a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx index 10bf817b7363f..827994cc5113f 100644 --- a/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.browser.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { render } from "../../test/render"; +import { render, stubGlobal } from "../../test/render"; import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; const navigateHistory = vi.hoisted(() => vi.fn()); @@ -60,6 +60,59 @@ describe("HistorySwipeNavigation", () => { expect(navigateHistory).toHaveBeenCalledTimes(navigates ? 1 : 0); }); + it.each([ + [ + "a macOS trackpad swipe opening with one native pixel at 299% zoom navigates", + 1.2 ** 6, + [[-1 / 1.2 ** 6, 1], ...Array.from({ length: 6 }, () => [-60 / 1.2 ** 6, 60] as const)], + true, + ], + [ + "a macOS trackpad swipe opening on a wheel-notch multiple navigates", + 1, + [ + [-40, 120], + [-60, 180], + [-60, 180], + ], + true, + ], + [ + "slow macOS mouse wheel notches keep the thread", + 1, + [ + [-40, 120], + [-40, 120], + [-40, 120], + ], + false, + ], + [ + "two mouse wheel notches at 120% zoom keep the thread", + 1.2, + [ + [-100, 99], + [-100, 99], + ], + false, + ], + ] as const)("%s", async (_name, zoomFactor, events, navigates) => { + navigateHistory.mockReset(); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + using _bridge = stubGlobal("desktopBridge", { + getWindowZoomFactor: () => zoomFactor, + onTrackpadScrollBegin: () => () => {}, + onTrackpadScrollEnd: () => () => {}, + }); + await using screen = await render(); + const target = screen.container.appendChild(document.createElement("div")); + for (const [deltaX, wheelDeltaX] of [...events, [0, 0]]) { + const init = { deltaX, wheelDeltaX, bubbles: true }; + target.dispatchEvent(new WheelEvent("wheel", init)); + } + expect(navigateHistory).toHaveBeenCalledTimes(navigates ? 1 : 0); + }); + it("a horizontal mouse wheel tick off macOS keeps the thread", async () => { navigateHistory.mockReset(); using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Win32"); diff --git a/apps/web/src/components/HistorySwipeNavigation.tsx b/apps/web/src/components/HistorySwipeNavigation.tsx index e1adc04491a91..6d057bc400b41 100644 --- a/apps/web/src/components/HistorySwipeNavigation.tsx +++ b/apps/web/src/components/HistorySwipeNavigation.tsx @@ -8,12 +8,21 @@ const SWIPE_TRIGGER_DISTANCE = 120; // Movement needed before the gesture picks an axis; first events are often diagonal. const SWIPE_AXIS_LOCK_DISTANCE = 8; const MOMENTUM_SAMPLE_SIZE = 5; +const WHEEL_NOTCH_DELTA = 120; type SwipeBounds = { left: number; top: number; width: number; height: number }; type SwipeGesture = - | { kind: "pending"; path: EventTarget[]; y: number; sumX: number; sumY: number } + | { + kind: "pending"; + path: EventTarget[]; + y: number; + sumX: number; + sumY: number; + notchesOnly: boolean; + } | { kind: "tracking"; + notchesOnly: boolean; direction: HistoryDirection; distance: number; deltas: number[]; @@ -37,6 +46,24 @@ function readMainContentBounds(): SwipeBounds { return { left, top, width, height }; } +// Electron cannot tell a wheel from a trackpad, but Chromium's legacy wheelDelta counts wheel +// notches in multiples of 120, divided by page zoom and truncated (99 for one notch at 120%). +// A trackpad's pixel deltas can land on a multiple too, but a whole swipe rarely stays on them. +function isMouseWheelNotch(event: WheelEvent): boolean { + const { wheelDeltaX = 0, wheelDeltaY = 0 } = event as WheelEvent & { + wheelDeltaX?: number; + wheelDeltaY?: number; + }; + const notchDelta = WHEEL_NOTCH_DELTA / (window.desktopBridge?.getWindowZoomFactor() ?? 1); + const notches = [wheelDeltaX, wheelDeltaY].map((delta) => Math.round(delta / notchDelta)); + return ( + notches.some((count) => count !== 0) && + [wheelDeltaX, wheelDeltaY].every( + (delta, index) => Math.abs(delta - notches[index]! * notchDelta) < 1.5, + ) + ); +} + // `event.target` stops at a shadow host; the composed path also reaches scrollers inside it. function canScrollHorizontally(path: readonly EventTarget[], deltaX: number): boolean { for (const element of path) { @@ -55,7 +82,7 @@ function canScrollHorizontally(path: readonly EventTarget[], deltaX: number): bo function lockSwipeAxis( pending: Extract, ): Exclude { - const { path, sumX, sumY } = pending; + const { path, sumX, sumY, notchesOnly } = pending; if (Math.abs(sumX) <= Math.abs(sumY)) return { kind: "ignored" }; if (path.some((node) => node instanceof Element && node.matches('[role="dialog"]'))) { return { kind: "ignored" }; @@ -65,6 +92,7 @@ function lockSwipeAxis( if (!canNavigateHistory(direction)) return { kind: "ignored" }; return { kind: "tracking", + notchesOnly, direction, distance: Math.abs(sumX), deltas: [], @@ -103,7 +131,11 @@ export function HistorySwipeNavigation() { setSwipe((current) => (current === null ? null : { ...current, tracking: false })); // The fingers lifted: navigate if the swipe went far enough, then ignore its momentum. const release = () => { - if (gesture?.kind === "tracking" && gesture.distance >= SWIPE_TRIGGER_DISTANCE) { + if ( + gesture?.kind === "tracking" && + !gesture.notchesOnly && + gesture.distance >= SWIPE_TRIGGER_DISTANCE + ) { navigateHistory(gesture.direction); } gesture = { kind: "ignored" }; @@ -122,9 +154,18 @@ export function HistorySwipeNavigation() { gesture = event.ctrlKey || event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL ? { kind: "ignored" } - : { kind: "pending", path: event.composedPath(), y: event.clientY, sumX: 0, sumY: 0 }; + : { + kind: "pending", + path: event.composedPath(), + y: event.clientY, + sumX: 0, + sumY: 0, + notchesOnly: true, + }; } if (gesture.kind === "ignored") return; + const moved = event.deltaX !== 0 || event.deltaY !== 0; + if (moved && !isMouseWheelNotch(event)) gesture.notchesOnly = false; if (gesture.kind === "pending") { gesture.sumX += event.deltaX; gesture.sumY += event.deltaY; @@ -133,11 +174,12 @@ export function HistorySwipeNavigation() { if (gesture.kind === "ignored") return; } else { const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX; - if (event.deltaX === 0 && event.deltaY === 0) return release(); + if (!moved) return release(); gesture.deltas = [...gesture.deltas.slice(1 - MOMENTUM_SAMPLE_SIZE), delta]; if (isMomentumTail(gesture.deltas)) return release(); gesture.distance = Math.max(0, gesture.distance + delta); } + if (gesture.notchesOnly) return; setSwipe({ direction: gesture.direction, progress: Math.min(1, gesture.distance / SWIPE_TRIGGER_DISTANCE), diff --git a/apps/web/src/components/RightPanelTabs.browser.test.tsx b/apps/web/src/components/RightPanelTabs.browser.test.tsx index f803cb41520bc..2d38910b7045e 100644 --- a/apps/web/src/components/RightPanelTabs.browser.test.tsx +++ b/apps/web/src/components/RightPanelTabs.browser.test.tsx @@ -25,7 +25,11 @@ function trackpad() { }; let scrolling = false; return { - bridge: { onTrackpadScrollBegin: subscribe(begin), onTrackpadScrollEnd: subscribe(end) }, + bridge: { + getWindowZoomFactor: () => 1, + onTrackpadScrollBegin: subscribe(begin), + onTrackpadScrollEnd: subscribe(end), + }, scroll(target: Element, deltas: readonly number[]) { let scroller: Element | null = target; while (