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 (