docusaurus/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx

238 lines
6.4 KiB
TypeScript

/**
* 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, {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import {useDynamicCallback} from './reactUtils';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
/**
* We need a way to update the scroll position while ignoring scroll events
* without affecting Navbar/BackToTop visibility
*
* This API permits to temporarily disable/ignore scroll events
* Motivated by https://github.com/facebook/docusaurus/pull/5618
*/
type ScrollController = {
/**
* A boolean ref tracking whether scroll events are enabled
*/
scrollEventsEnabledRef: React.MutableRefObject<boolean>;
/**
* Enables scroll events in `useScrollPosition`
*/
enableScrollEvents: () => void;
/**
* Disables 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 = createContext<ScrollController | undefined>(
undefined,
);
export function ScrollControllerProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
return (
<ScrollMonitorContext.Provider value={useScrollControllerContextValue()}>
{children}
</ScrollMonitorContext.Provider>
);
}
export function useScrollController(): ScrollController {
const context = useContext(ScrollMonitorContext);
if (context == null) {
throw new Error(
'"useScrollController" is used but no context provider was found in the React tree.',
);
}
return context;
}
const getScrollPosition = (): ScrollPosition | null => {
return ExecutionEnvironment.canUseDOM
? {
scrollX: window.pageXOffset,
scrollY: window.pageYOffset,
}
: null;
};
type ScrollPosition = {scrollX: number; scrollY: number};
export function useScrollPosition(
effect: (
position: ScrollPosition,
lastPosition: ScrollPosition | null,
) => void,
deps: unknown[] = [],
): void {
const {scrollEventsEnabledRef} = useScrollController();
const lastPositionRef = useRef<ScrollPosition | null>(getScrollPosition());
const dynamicEffect = useDynamicCallback(effect);
useEffect(() => {
const handleScroll = () => {
if (!scrollEventsEnabledRef.current) {
return;
}
const currentPosition = getScrollPosition()!;
if (dynamicEffect) {
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);
}, [
dynamicEffect,
scrollEventsEnabledRef,
// eslint-disable-next-line react-hooks/exhaustive-deps
...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]);
}
type UseScrollPositionBlockerReturn = {
blockElementScrollPositionUntilNextRender: (el: HTMLElement) => void;
};
/**
* 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 GIF here: https://github.com/facebook/docusaurus/pull/5618
*/
export function useScrollPositionBlocker(): UseScrollPositionBlockerReturn {
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 again the scrollController events
if (restored) {
const handleScrollRestoreEvent = () => {
scrollController.enableScrollEvents();
window.removeEventListener('scroll', handleScrollRestoreEvent);
};
window.addEventListener('scroll', handleScrollRestoreEvent);
} else {
scrollController.enableScrollEvents();
}
};
},
[scrollController, scrollPositionSaver],
);
useLayoutEffect(() => {
nextLayoutEffectCallbackRef.current?.();
});
return {
blockElementScrollPositionUntilNextRender,
};
}