Skip to content
32 changes: 32 additions & 0 deletions apps/web/src/components/HistorySwipeNavigation.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,36 @@ describe("HistorySwipeNavigation", () => {

expect(navigateHistory).not.toHaveBeenCalled();
});

// A vertical scroll 15° off vertical.
const drift = Array.from({ length: 30 }, () => [-4, 15] as const);
it.each([
[
"a vertical scroll that starts slightly diagonally keeps the thread",
[[-5, 3], ...drift],
false,
],
[
"a swipe that leans diagonally for two events navigates",
[
[-5, 3],
[-2, 2],
[-150, 0],
],
true,
],
["a single diagonal event past the trigger keeps the thread", [[-130, 100]], false],
] as const)("%s", async (_name, events, navigates) => {
navigateHistory.mockReset();
using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel");
await using screen = await render(<HistorySwipeNavigation />);
const panel = screen.container.appendChild(document.createElement("div"));
for (const [deltaX, deltaY] of [...events, [0, 0] as const]) {
panel.dispatchEvent(
new WheelEvent("wheel", { deltaX, deltaY, bubbles: true, composed: true }),
);
}

expect(navigateHistory).toHaveBeenCalledTimes(navigates ? 1 : 0);
});
});
24 changes: 23 additions & 1 deletion apps/web/src/components/HistorySwipeNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { canNavigateHistory, navigateHistory, type HistoryDirection } from "../n
const SWIPE_TRIGGER_DISTANCE = 120;
// Movement needed before the gesture picks an axis; first events are often diagonal.
const SWIPE_AXIS_LOCK_DISTANCE = 8;
// Vertical scrolls drift sideways, so a swipe's horizontal travel must clearly dominate once
// it has moved far enough for its direction to be more than noise.
const SWIPE_HORIZONTAL_DOMINANCE = 2;
const SWIPE_DOMINANCE_DISTANCE = 30;
const MOMENTUM_SAMPLE_SIZE = 5;

type SwipeBounds = { left: number; top: number; width: number; height: number };
Expand All @@ -17,6 +21,8 @@ type SwipeGesture =
direction: HistoryDirection;
distance: number;
deltas: number[];
travelX: number;
travelY: number;
y: number;
bounds: SwipeBounds;
}
Expand Down Expand Up @@ -52,11 +58,19 @@ function canScrollHorizontally(path: readonly EventTarget[], deltaX: number): bo
return false;
}

function isVerticalScroll(travelX: number, travelY: number): boolean {
return (
travelX + travelY >= SWIPE_DOMINANCE_DISTANCE && travelX <= SWIPE_HORIZONTAL_DOMINANCE * travelY
);
}

function lockSwipeAxis(
pending: Extract<SwipeGesture, { kind: "pending" }>,
): Exclude<SwipeGesture, { kind: "pending" }> {
const { path, sumX, sumY } = pending;
if (Math.abs(sumX) <= Math.abs(sumY)) return { kind: "ignored" };
if (Math.abs(sumX) <= Math.abs(sumY) || isVerticalScroll(Math.abs(sumX), Math.abs(sumY))) {
return { kind: "ignored" };
}
if (path.some((node) => node instanceof Element && node.matches('[role="dialog"]'))) {
return { kind: "ignored" };
}
Expand All @@ -68,6 +82,8 @@ function lockSwipeAxis(
direction,
distance: Math.abs(sumX),
deltas: [],
travelX: Math.abs(sumX),
travelY: Math.abs(sumY),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
y: pending.y,
bounds: readMainContentBounds(),
};
Expand Down Expand Up @@ -134,6 +150,12 @@ export function HistorySwipeNavigation() {
} else {
const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX;
if (event.deltaX === 0 && event.deltaY === 0) return release();
gesture.travelX += Math.abs(event.deltaX);
gesture.travelY += Math.abs(event.deltaY);
if (isVerticalScroll(gesture.travelX, gesture.travelY)) {
gesture = { kind: "ignored" };
return hideIndicator();
}
gesture.deltas = [...gesture.deltas.slice(1 - MOMENTUM_SAMPLE_SIZE), delta];
if (isMomentumTail(gesture.deltas)) return release();
gesture.distance = Math.max(0, gesture.distance + delta);
Expand Down
Loading