diff --git a/packages/docusaurus-theme-classic/src/theme/BackToTopButton/index.tsx b/packages/docusaurus-theme-classic/src/theme/BackToTopButton/index.tsx index a495f884b3..feba4f36ea 100644 --- a/packages/docusaurus-theme-classic/src/theme/BackToTopButton/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/BackToTopButton/index.tsx @@ -9,10 +9,9 @@ import React, {useRef, useState} from 'react'; import clsx from 'clsx'; import {useLocation} from '@docusaurus/router'; import {translate} from '@docusaurus/Translate'; -import useScrollPosition from '@theme/hooks/useScrollPosition'; import styles from './styles.module.css'; -import {ThemeClassNames} from '@docusaurus/theme-common'; +import {ThemeClassNames, useScrollPosition} from '@docusaurus/theme-common'; const threshold = 300; diff --git a/packages/docusaurus-theme-classic/src/theme/DocSidebar/index.tsx b/packages/docusaurus-theme-classic/src/theme/DocSidebar/index.tsx index a517f6d08c..281b3df330 100644 --- a/packages/docusaurus-theme-classic/src/theme/DocSidebar/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/DocSidebar/index.tsx @@ -13,9 +13,9 @@ import { MobileSecondaryMenuFiller, MobileSecondaryMenuComponent, ThemeClassNames, + useScrollPosition, } from '@docusaurus/theme-common'; import useWindowSize from '@theme/hooks/useWindowSize'; -import useScrollPosition from '@theme/hooks/useScrollPosition'; import Logo from '@theme/Logo'; import IconArrow from '@theme/IconArrow'; import {translate} from '@docusaurus/Translate'; diff --git a/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx b/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx index 7e98dacb2e..34cb7a2f15 100644 --- a/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx @@ -12,6 +12,7 @@ import { AnnouncementBarProvider, DocsPreferredVersionContextProvider, MobileSecondaryMenuProvider, + ScrollControllerProvider, } from '@docusaurus/theme-common'; import type {Props} from '@theme/LayoutProviders'; @@ -20,11 +21,13 @@ export default function LayoutProviders({children}: Props): JSX.Element { - - - {children} - - + + + + {children} + + + diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index 0c35907f4f..9c58869c4b 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -8,6 +8,7 @@ import React, {useState, cloneElement, Children, ReactElement} from 'react'; import useIsBrowser from '@docusaurus/useIsBrowser'; import useUserPreferencesContext from '@theme/hooks/useUserPreferencesContext'; +import {useScrollPositionBlocker} from '@docusaurus/theme-common'; import type {Props} from '@theme/Tabs'; import type {Props as TabItemProps} from '@theme/TabItem'; @@ -15,13 +16,6 @@ import clsx from 'clsx'; import styles from './styles.module.css'; -function isInViewport(element: HTMLElement): boolean { - const {top, left, bottom, right} = element.getBoundingClientRect(); - const {innerHeight, innerWidth} = window; - - return top >= 0 && right <= innerWidth && bottom <= innerHeight && left >= 0; -} - function TabsComponent(props: Props): JSX.Element { const { lazy, @@ -50,6 +44,8 @@ function TabsComponent(props: Props): JSX.Element { const {tabGroupChoices, setTabGroupChoices} = useUserPreferencesContext(); const [selectedValue, setSelectedValue] = useState(defaultValue); const tabRefs: (HTMLLIElement | null)[] = []; + const {blockElementScrollPositionUntilNextRender} = + useScrollPositionBlocker(); if (groupId != null) { const relevantTabGroupChoice = tabGroupChoices[groupId]; @@ -65,31 +61,17 @@ function TabsComponent(props: Props): JSX.Element { const handleTabChange = ( event: React.FocusEvent | React.MouseEvent, ) => { - const selectedTab = event.currentTarget; - const selectedTabIndex = tabRefs.indexOf(selectedTab); - const selectedTabValue = values[selectedTabIndex].value; + const newTab = event.currentTarget; + const newTabIndex = tabRefs.indexOf(newTab); + const newTabValue = values[newTabIndex].value; - setSelectedValue(selectedTabValue); + if (newTabValue !== selectedValue) { + blockElementScrollPositionUntilNextRender(newTab); + setSelectedValue(newTabValue); - if (groupId != null) { - setTabGroupChoices(groupId, selectedTabValue); - - setTimeout(() => { - if (isInViewport(selectedTab)) { - return; - } - - selectedTab.scrollIntoView({ - block: 'center', - behavior: 'smooth', - }); - - selectedTab.classList.add(styles.tabItemActive); - setTimeout( - () => selectedTab.classList.remove(styles.tabItemActive), - 2000, - ); - }, 150); + if (groupId != null) { + setTabGroupChoices(groupId, newTabValue); + } } }; diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/styles.module.css b/packages/docusaurus-theme-classic/src/theme/Tabs/styles.module.css index f2e834d3aa..5f2274d558 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/styles.module.css +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/styles.module.css @@ -8,16 +8,3 @@ .tabItem { margin-top: 0 !important; } - -.tabItemActive { - animation: blink 0.5s ease-in-out 5; -} - -@keyframes blink { - 0% { - background-color: var(--ifm-hover-overlay); - } - 100% { - background-color: rgba(0, 0, 0, 0); - } -} diff --git a/packages/docusaurus-theme-classic/src/theme/hooks/useHideableNavbar.ts b/packages/docusaurus-theme-classic/src/theme/hooks/useHideableNavbar.ts index 94b14927f8..954549524f 100644 --- a/packages/docusaurus-theme-classic/src/theme/hooks/useHideableNavbar.ts +++ b/packages/docusaurus-theme-classic/src/theme/hooks/useHideableNavbar.ts @@ -7,8 +7,7 @@ import {useState, useCallback, useEffect, useRef} from 'react'; import {useLocation} from '@docusaurus/router'; -import useScrollPosition from '@theme/hooks/useScrollPosition'; -import {useLocationChange} from '@docusaurus/theme-common'; +import {useLocationChange, useScrollPosition} from '@docusaurus/theme-common'; import type {useHideableNavbarReturns} from '@theme/hooks/useHideableNavbar'; const useHideableNavbar = (hideOnScroll: boolean): useHideableNavbarReturns => { diff --git a/packages/docusaurus-theme-classic/src/theme/hooks/useScrollPosition.ts b/packages/docusaurus-theme-classic/src/theme/hooks/useScrollPosition.ts deleted file mode 100644 index fba41cb8e0..0000000000 --- a/packages/docusaurus-theme-classic/src/theme/hooks/useScrollPosition.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * 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 {useEffect, useRef} from 'react'; -import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; -import type {ScrollPosition} from '@theme/hooks/useScrollPosition'; - -const getScrollPosition = (): ScrollPosition | null => { - return ExecutionEnvironment.canUseDOM - ? { - scrollX: window.pageXOffset, - scrollY: window.pageYOffset, - } - : null; -}; - -const useScrollPosition = ( - effect: ( - position: ScrollPosition, - lastPosition: ScrollPosition | null, - ) => void, - deps = [], -): void => { - const lastPositionRef = useRef(getScrollPosition()); - - const handleScroll = () => { - const currentPosition = getScrollPosition()!; - - if (effect) { - effect(currentPosition, lastPositionRef.current); - } - - lastPositionRef.current = currentPosition; - }; - - useEffect(() => { - const opts: AddEventListenerOptions & EventListenerOptions = { - passive: true, - }; - - handleScroll(); - window.addEventListener('scroll', handleScroll, opts); - - return () => window.removeEventListener('scroll', handleScroll, opts); - }, deps); -}; - -export default useScrollPosition; diff --git a/packages/docusaurus-theme-classic/src/types.d.ts b/packages/docusaurus-theme-classic/src/types.d.ts index d7940a5867..a59ecc3791 100644 --- a/packages/docusaurus-theme-classic/src/types.d.ts +++ b/packages/docusaurus-theme-classic/src/types.d.ts @@ -219,19 +219,6 @@ declare module '@theme/hooks/usePrismTheme' { export default usePrismTheme; } -declare module '@theme/hooks/useScrollPosition' { - export type ScrollPosition = {scrollX: number; scrollY: number}; - - const useScrollPosition: ( - effect: ( - position: ScrollPosition, - lastPosition: ScrollPosition | null, - ) => void, - deps?: unknown[], - ) => void; - export default useScrollPosition; -} - declare module '@theme/hooks/useTabGroupChoice' { export type useTabGroupChoiceReturns = { readonly tabGroupChoices: {readonly [groupId: string]: string}; diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts index 7ce4453dd5..03f349a9a0 100644 --- a/packages/docusaurus-theme-common/src/index.ts +++ b/packages/docusaurus-theme-common/src/index.ts @@ -79,3 +79,10 @@ export {default as useTOCHighlight} from './utils/useTOCHighlight'; export type {TOCHighlightConfig} from './utils/useTOCHighlight'; export {useTOCFilter} from './utils/tocUtils'; + +export { + ScrollControllerProvider, + useScrollController, + useScrollPosition, + useScrollPositionBlocker, +} from './utils/scrollUtils'; diff --git a/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx b/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx new file mode 100644 index 0000000000..9c54f405ae --- /dev/null +++ b/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx @@ -0,0 +1,230 @@ +/** + * 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 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 handleScroll = () => { + if (!scrollEventsEnabledRef.current) { + return; + } + const currentPosition = getScrollPosition()!; + + if (effect) { + effect(currentPosition, lastPositionRef.current); + } + + lastPositionRef.current = currentPosition; + }; + + useEffect(() => { + const opts: AddEventListenerOptions & EventListenerOptions = { + passive: true, + }; + + handleScroll(); + window.addEventListener('scroll', handleScroll, opts); + + return () => window.removeEventListener('scroll', handleScroll, opts); + }, 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}), []); +} + +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], + ); + + useLayoutEffect(() => { + nextLayoutEffectCallbackRef.current?.(); + }); + + return { + blockElementScrollPositionUntilNextRender, + }; +}