/
scrollUtils.tsx
304 lines (272 loc) · 9.23 KB
/
scrollUtils.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
type ReactNode,
} from 'react';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import useIsBrowser from '@docusaurus/useIsBrowser';
import {useEvent, ReactContextError} from './reactUtils';
type ScrollController = {
/** A boolean ref tracking whether scroll events are enabled. */
scrollEventsEnabledRef: React.MutableRefObject<boolean>;
/** Enable scroll events in `useScrollPosition`. */
enableScrollEvents: () => void;
/** Disable scroll events in `useScrollPosition`. */
disableScrollEvents: () => void;
};
function useScrollControllerContextValue(): ScrollController {
const scrollEventsEnabledRef = useRef(true);
return useMemo(
() => ({
scrollEventsEnabledRef,
enableScrollEvents: () => {
scrollEventsEnabledRef.current = true;
},
disableScrollEvents: () => {
scrollEventsEnabledRef.current = false;
},
}),
[],
);
}
const ScrollMonitorContext = React.createContext<ScrollController | undefined>(
undefined,
);
export function ScrollControllerProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const value = useScrollControllerContextValue();
return (
<ScrollMonitorContext.Provider value={value}>
{children}
</ScrollMonitorContext.Provider>
);
}
/**
* We need a way to update the scroll position while ignoring scroll events
* so as not to toggle Navbar/BackToTop visibility.
*
* This API permits to temporarily disable/ignore scroll events. Motivated by
* https://github.com/facebook/docusaurus/pull/5618
*/
export function useScrollController(): ScrollController {
const context = useContext(ScrollMonitorContext);
if (context == null) {
throw new ReactContextError('ScrollControllerProvider');
}
return context;
}
type ScrollPosition = {scrollX: number; scrollY: number};
const getScrollPosition = (): ScrollPosition | null =>
ExecutionEnvironment.canUseDOM
? {
scrollX: window.pageXOffset,
scrollY: window.pageYOffset,
}
: null;
/**
* This hook fires an effect when the scroll position changes. The effect will
* be provided with the before/after scroll positions. Note that the effect may
* not be always run: if scrolling is disabled through `useScrollController`, it
* will be a no-op.
*
* @see {@link useScrollController}
*/
export function useScrollPosition(
effect: (
position: ScrollPosition,
lastPosition: ScrollPosition | null,
) => void,
deps: unknown[] = [],
): void {
const {scrollEventsEnabledRef} = useScrollController();
const lastPositionRef = useRef<ScrollPosition | null>(getScrollPosition());
const dynamicEffect = useEvent(effect);
useEffect(() => {
const handleScroll = () => {
if (!scrollEventsEnabledRef.current) {
return;
}
const currentPosition = getScrollPosition()!;
dynamicEffect(currentPosition, lastPositionRef.current);
lastPositionRef.current = currentPosition;
};
const opts: AddEventListenerOptions & EventListenerOptions = {
passive: true,
};
handleScroll();
window.addEventListener('scroll', handleScroll, opts);
return () => window.removeEventListener('scroll', handleScroll, opts);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dynamicEffect, scrollEventsEnabledRef, ...deps]);
}
type UseScrollPositionSaver = {
/** Measure the top of an element, and store the details. */
save: (elem: HTMLElement) => void;
/**
* Restore the page position to keep the stored element's position from
* the top of the viewport, and remove the stored details.
*/
restore: () => {restored: boolean};
};
function useScrollPositionSaver(): UseScrollPositionSaver {
const lastElementRef = useRef<{elem: HTMLElement | null; top: number}>({
elem: null,
top: 0,
});
const save = useCallback((elem: HTMLElement) => {
lastElementRef.current = {
elem,
top: elem.getBoundingClientRect().top,
};
}, []);
const restore = useCallback(() => {
const {
current: {elem, top},
} = lastElementRef;
if (!elem) {
return {restored: false};
}
const newTop = elem.getBoundingClientRect().top;
const heightDiff = newTop - top;
if (heightDiff) {
window.scrollBy({left: 0, top: heightDiff});
}
lastElementRef.current = {elem: null, top: 0};
return {restored: heightDiff !== 0};
}, []);
return useMemo(() => ({save, restore}), [restore, save]);
}
/**
* This hook permits to "block" the scroll position of a DOM element.
* The idea is that we should be able to update DOM content above this element
* but the screen position of this element should not change.
*
* Feature motivated by the Tabs groups: clicking on a tab may affect tabs of
* the same group upper in the tree, yet to avoid a bad UX, the clicked tab must
* remain under the user mouse.
*
* @see https://github.com/facebook/docusaurus/pull/5618
*/
export function useScrollPositionBlocker(): {
/**
* Takes an element, and keeps its screen position no matter what's getting
* rendered above it, until the next render.
*/
blockElementScrollPositionUntilNextRender: (el: HTMLElement) => void;
} {
const scrollController = useScrollController();
const scrollPositionSaver = useScrollPositionSaver();
const nextLayoutEffectCallbackRef = useRef<(() => void) | undefined>(
undefined,
);
const blockElementScrollPositionUntilNextRender = useCallback(
(el: HTMLElement) => {
scrollPositionSaver.save(el);
scrollController.disableScrollEvents();
nextLayoutEffectCallbackRef.current = () => {
const {restored} = scrollPositionSaver.restore();
nextLayoutEffectCallbackRef.current = undefined;
// Restoring the former scroll position will trigger a scroll event. We
// need to wait for next scroll event to happen before enabling the
// scrollController events again.
if (restored) {
const handleScrollRestoreEvent = () => {
scrollController.enableScrollEvents();
window.removeEventListener('scroll', handleScrollRestoreEvent);
};
window.addEventListener('scroll', handleScrollRestoreEvent);
} else {
scrollController.enableScrollEvents();
}
};
},
[scrollController, scrollPositionSaver],
);
useLayoutEffect(() => {
nextLayoutEffectCallbackRef.current?.();
});
return {
blockElementScrollPositionUntilNextRender,
};
}
type CancelScrollTop = () => void;
function smoothScrollNative(top: number): CancelScrollTop {
window.scrollTo({top, behavior: 'smooth'});
return () => {
// Nothing to cancel, it's natively cancelled if user tries to scroll down
};
}
function smoothScrollPolyfill(top: number): CancelScrollTop {
let raf: number | null = null;
const isUpScroll = document.documentElement.scrollTop > top;
function rafRecursion() {
const currentScroll = document.documentElement.scrollTop;
if (
(isUpScroll && currentScroll > top) ||
(!isUpScroll && currentScroll < top)
) {
raf = requestAnimationFrame(rafRecursion);
window.scrollTo(0, Math.floor((currentScroll - top) * 0.85) + top);
}
}
rafRecursion();
// Break the recursion. Prevents the user from "fighting" against that
// recursion producing a weird UX
return () => raf && cancelAnimationFrame(raf);
}
/**
* A "smart polyfill" of `window.scrollTo({ top, behavior: "smooth" })`.
* This currently always uses a polyfilled implementation unless
* `scroll-behavior: smooth` has been set in CSS, because native support
* detection for scroll behavior seems unreliable.
*
* This hook does not do anything by itself: it returns a start and a stop
* handle. You can execute either handle at any time.
*/
export function useSmoothScrollTo(): {
/**
* Start the scroll.
*
* @param top The final scroll top position.
*/
startScroll: (top: number) => void;
/**
* A cancel function, because the non-native smooth scroll-top
* implementation must be interrupted if user scrolls down. If there's no
* existing animation or the scroll is using native behavior, this is a no-op.
*/
cancelScroll: CancelScrollTop;
} {
const cancelRef = useRef<CancelScrollTop | null>(null);
const isBrowser = useIsBrowser();
// Not all have support for smooth scrolling (particularly Safari mobile iOS)
// TODO proper detection is currently unreliable!
// see https://github.com/wessberg/scroll-behavior-polyfill/issues/16
// For now, we only use native scroll behavior if smooth is already set,
// because otherwise the polyfill produces a weird UX when both CSS and JS try
// to scroll a page, and they cancel each other.
const supportsNativeSmoothScrolling =
isBrowser &&
getComputedStyle(document.documentElement).scrollBehavior === 'smooth';
return {
startScroll: (top: number) => {
cancelRef.current = supportsNativeSmoothScrolling
? smoothScrollNative(top)
: smoothScrollPolyfill(top);
},
cancelScroll: () => cancelRef.current?.(),
};
}