Open History to see the last commit
1import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
2import { useEffect, useState } from "react";
3
4import { cn, isMacPlatform } from "../lib/utils";
5import { canNavigateHistory, navigateHistory, type HistoryDirection } from "../navigationHistory";
6
7const SWIPE_TRIGGER_DISTANCE = 120;
8// Movement needed before the gesture picks an axis; first events are often diagonal.
9const SWIPE_AXIS_LOCK_DISTANCE = 8;
10// Vertical scrolls drift sideways, so a swipe's horizontal travel must clearly dominate once
11// it has moved far enough for its direction to be more than noise.
12const SWIPE_HORIZONTAL_DOMINANCE = 2;
13const SWIPE_DOMINANCE_DISTANCE = 30;
14const MOMENTUM_SAMPLE_SIZE = 5;
15
16type SwipeBounds = { left: number; top: number; width: number; height: number };
17type SwipeGesture =
18 | { kind: "pending"; path: EventTarget[]; y: number; sumX: number; sumY: number }
19 | {
20 kind: "tracking";
21 direction: HistoryDirection;
22 distance: number;
23 deltas: number[];
24 travelX: number;
25 travelY: number;
26 y: number;
27 bounds: SwipeBounds;
28 }
29 // Not a swipe, or one already decided: the rest of its events are ignored.
30 | { kind: "ignored" };
31type SwipeIndicatorState = {
32 direction: HistoryDirection;
33 progress: number;
34 y: number;
35 bounds: SwipeBounds;
36 tracking: boolean;
37};
38
39function readMainContentBounds(): SwipeBounds {
40 const main = document.querySelector('[data-slot="sidebar-inset"]');
41 if (!main) return { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
42 const { left, top, width, height } = main.getBoundingClientRect();
43 return { left, top, width, height };
44}
45
46// `event.target` stops at a shadow host; the composed path also reaches scrollers inside it.
47function canScrollHorizontally(path: readonly EventTarget[], deltaX: number): boolean {
48 for (const element of path) {
49 if (!(element instanceof Element) || element.scrollWidth <= element.clientWidth) continue;
50 const { overflowX } = getComputedStyle(element);
51 if (overflowX !== "auto" && overflowX !== "scroll") continue;
52 const canScroll =
53 deltaX < 0
54 ? element.scrollLeft > 0
55 : element.scrollLeft + element.clientWidth < element.scrollWidth - 1;
56 if (canScroll) return true;
57 }
58 return false;
59}
60
61function isVerticalScroll(travelX: number, travelY: number): boolean {
62 return (
63 travelX + travelY >= SWIPE_DOMINANCE_DISTANCE && travelX <= SWIPE_HORIZONTAL_DOMINANCE * travelY
64 );
65}
66
67function lockSwipeAxis(
68 pending: Extract<SwipeGesture, { kind: "pending" }>,
69): Exclude<SwipeGesture, { kind: "pending" }> {
70 const { path, sumX, sumY } = pending;
71 if (Math.abs(sumX) <= Math.abs(sumY) || isVerticalScroll(Math.abs(sumX), Math.abs(sumY))) {
72 return { kind: "ignored" };
73 }
74 if (path.some((node) => node instanceof Element && node.matches('[role="dialog"]'))) {
75 return { kind: "ignored" };
76 }
77 if (canScrollHorizontally(path, sumX)) return { kind: "ignored" };
78 const direction = sumX < 0 ? "back" : "forward";
79 if (!canNavigateHistory(direction)) return { kind: "ignored" };
80 return {
81 kind: "tracking",
82 direction,
83 distance: Math.abs(sumX),
84 deltas: [],
85 travelX: Math.abs(sumX),
86 travelY: Math.abs(sumY),
87 y: pending.y,
88 bounds: readMainContentBounds(),
89 };
90}
91
92/**
93 * macOS momentum after the fingers lift decays by a near-constant ratio per
94 * event; finger movement is noisier. Deltas are signed along the swipe.
95 */
96export function isMomentumTail(deltas: readonly number[]): boolean {
97 if (deltas.length < MOMENTUM_SAMPLE_SIZE) return false;
98 const recent = deltas.slice(-MOMENTUM_SAMPLE_SIZE);
99 if (recent.some((delta) => delta <= 0)) return false;
100 const ratios = recent.slice(1).map((delta, index) => delta / recent[index]!);
101 return (
102 ratios.every((ratio) => ratio >= 0.7 && ratio < 1) &&
103 Math.max(...ratios) - Math.min(...ratios) < 0.1
104 );
105}
106
107/**
108 * Two-finger horizontal trackpad swipes go back and forward, like a browser.
109 * macOS only: on Windows and Linux a mouse tilt wheel is indistinguishable from a trackpad swipe.
110 */
111export function HistorySwipeNavigation() {
112 const [swipe, setSwipe] = useState<SwipeIndicatorState | null>(null);
113
114 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window wheel and desktop gesture listeners track trackpad swipes
115 useEffect(() => {
116 if (!isMacPlatform(navigator.platform)) return;
117 let gesture: SwipeGesture | null = null;
118 const hideIndicator = () =>
119 setSwipe((current) => (current === null ? null : { ...current, tracking: false }));
120 // The fingers lifted: navigate if the swipe went far enough, then ignore its momentum.
121 const release = () => {
122 if (gesture?.kind === "tracking" && gesture.distance >= SWIPE_TRIGGER_DISTANCE) {
123 navigateHistory(gesture.direction);
124 }
125 gesture = { kind: "ignored" };
126 hideIndicator();
127 };
128 // Chromium brackets each trackpad scroll, momentum included, with these.
129 const unsubscribeScrollBegin = window.desktopBridge?.onTrackpadScrollBegin(() => {
130 if (gesture?.kind === "ignored") gesture = null;
131 });
132 const unsubscribeScrollEnd = window.desktopBridge?.onTrackpadScrollEnd(() => {
133 if (gesture?.kind === "tracking") release();
134 gesture = null;
135 });
136 const onWheel = (event: WheelEvent) => {
137 if (gesture === null) {
138 gesture =
139 event.ctrlKey || event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL
140 ? { kind: "ignored" }
141 : { kind: "pending", path: event.composedPath(), y: event.clientY, sumX: 0, sumY: 0 };
142 }
143 if (gesture.kind === "ignored") return;
144 if (gesture.kind === "pending") {
145 gesture.sumX += event.deltaX;
146 gesture.sumY += event.deltaY;
147 if (Math.abs(gesture.sumX) + Math.abs(gesture.sumY) < SWIPE_AXIS_LOCK_DISTANCE) return;
148 gesture = lockSwipeAxis({ ...gesture, path: [...gesture.path, ...event.composedPath()] });
149 if (gesture.kind === "ignored") return;
150 } else {
151 const delta = gesture.direction === "back" ? -event.deltaX : event.deltaX;
152 if (event.deltaX === 0 && event.deltaY === 0) return release();
153 gesture.travelX += Math.abs(event.deltaX);
154 gesture.travelY += Math.abs(event.deltaY);
155 if (isVerticalScroll(gesture.travelX, gesture.travelY)) {
156 gesture = { kind: "ignored" };
157 return hideIndicator();
158 }
159 gesture.deltas = [...gesture.deltas.slice(1 - MOMENTUM_SAMPLE_SIZE), delta];
160 if (isMomentumTail(gesture.deltas)) return release();
161 gesture.distance = Math.max(0, gesture.distance + delta);
162 }
163 setSwipe({
164 direction: gesture.direction,
165 progress: Math.min(1, gesture.distance / SWIPE_TRIGGER_DISTANCE),
166 y: gesture.y,
167 bounds: gesture.bounds,
168 tracking: true,
169 });
170 };
171 window.addEventListener("wheel", onWheel, { capture: true, passive: true });
172 return () => {
173 unsubscribeScrollBegin?.();
174 unsubscribeScrollEnd?.();
175 window.removeEventListener("wheel", onWheel, { capture: true });
176 };
177 }, []);
178
179 return swipe === null ? null : <SwipeIndicator {...swipe} />;
180}
181
182// A circle pinned to the edge of the main content that peeks in up to half its width.
183function SwipeIndicator({ direction, progress, y, bounds, tracking }: SwipeIndicatorState) {
184 const armed = progress >= 1;
185 const Icon = direction === "back" ? ArrowLeftIcon : ArrowRightIcon;
186 const hidden = 100 - progress * 50;
187 return (
188 <div
189 aria-hidden="true"
190 className="pointer-events-none fixed z-100 overflow-hidden"
191 style={bounds}
192 >
193 <div
194 className={cn(
195 "absolute flex size-14 items-center rounded-full bg-muted text-foreground dark:bg-black dark:text-white",
196 direction === "back" ? "left-0 justify-end pr-2" : "right-0 justify-start pl-2",
197 !tracking && "transition-[opacity,transform] duration-200 ease-out",
198 )}
199 style={{
200 top: `clamp(0px, calc(${y - bounds.top}px - 1.75rem), calc(100% - 3.5rem))`,
201 opacity: tracking ? 1 : 0,
202 transform: `translateX(${direction === "back" ? -hidden : hidden}%)`,
203 }}
204 >
205 <Icon
206 className={cn(
207 "size-4 transition-opacity duration-100",
208 armed ? "opacity-100" : "opacity-40",
209 )}
210 />
211 </div>
212 </div>
213 );
214}