/** * 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; /** * 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( undefined, ); export function ScrollControllerProvider({ children, }: { children: ReactNode; }): JSX.Element { return ( {children} ); } 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(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, }; }