From f25acd2beb7eb63685506d4f2718b14217fdc7e1 Mon Sep 17 00:00:00 2001 From: Josh Fraser Date: Sun, 26 Sep 2021 13:36:46 +1000 Subject: [PATCH 1/5] feat: maintain page position for grouped tabs --- .../src/theme/LayoutProviders/index.tsx | 13 +++-- .../src/theme/ScrollMonitorContext.ts | 12 +++++ .../src/theme/ScrollMonitorProvider/index.tsx | 36 +++++++++++++ .../src/theme/Tabs/index.tsx | 54 ++++++++++--------- .../src/theme/Tabs/styles.module.css | 13 ----- .../src/theme/hooks/useRestoreTop.ts | 40 ++++++++++++++ .../theme/hooks/useScrollMonitorContext.ts | 25 +++++++++ .../src/theme/hooks/useScrollPosition.ts | 5 ++ .../docusaurus-theme-classic/src/types.d.ts | 47 ++++++++++++++++ 9 files changed, 202 insertions(+), 43 deletions(-) create mode 100644 packages/docusaurus-theme-classic/src/theme/ScrollMonitorContext.ts create mode 100644 packages/docusaurus-theme-classic/src/theme/ScrollMonitorProvider/index.tsx create mode 100644 packages/docusaurus-theme-classic/src/theme/hooks/useRestoreTop.ts create mode 100644 packages/docusaurus-theme-classic/src/theme/hooks/useScrollMonitorContext.ts diff --git a/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx b/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx index 7e98dacb2ed8..89f8313b480b 100644 --- a/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx @@ -8,6 +8,7 @@ import React from 'react'; import ThemeProvider from '@theme/ThemeProvider'; import UserPreferencesProvider from '@theme/UserPreferencesProvider'; +import ScrollMonitorProvider from '@theme/ScrollMonitorProvider'; import { AnnouncementBarProvider, DocsPreferredVersionContextProvider, @@ -20,11 +21,13 @@ export default function LayoutProviders({children}: Props): JSX.Element { - - - {children} - - + + + + {children} + + + diff --git a/packages/docusaurus-theme-classic/src/theme/ScrollMonitorContext.ts b/packages/docusaurus-theme-classic/src/theme/ScrollMonitorContext.ts new file mode 100644 index 000000000000..096cb04ab54d --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/ScrollMonitorContext.ts @@ -0,0 +1,12 @@ +/** + * 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 {createContext} from 'react'; + +const ScrollMonitorContext = createContext(undefined); + +export default ScrollMonitorContext; diff --git a/packages/docusaurus-theme-classic/src/theme/ScrollMonitorProvider/index.tsx b/packages/docusaurus-theme-classic/src/theme/ScrollMonitorProvider/index.tsx new file mode 100644 index 000000000000..a01c69b92179 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/ScrollMonitorProvider/index.tsx @@ -0,0 +1,36 @@ +/** + * 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, {useMemo, useRef} from 'react'; + +import ScrollMonitorContext from '@theme/ScrollMonitorContext'; +import type {Props} from '@theme/ScrollMonitorProvider'; + +function ScrollMonitorProvider(props: Props): JSX.Element { + const isScrollMonitorEnabledRef = useRef(true); + + const handlers = useMemo( + () => ({ + enableScrollMonitor: () => { + isScrollMonitorEnabledRef.current = true; + }, + disableScrollMonitor: () => { + isScrollMonitorEnabledRef.current = false; + }, + }), + [], + ); + + return ( + + {props.children} + + ); +} + +export default ScrollMonitorProvider; diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index 4c56b2e9a787..3990a72904c6 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -5,8 +5,16 @@ * LICENSE file in the root directory of this source tree. */ -import React, {useState, cloneElement, Children, ReactElement} from 'react'; +import React, { + useState, + cloneElement, + Children, + ReactElement, + useLayoutEffect, +} from 'react'; import useUserPreferencesContext from '@theme/hooks/useUserPreferencesContext'; +import useScrollMonitorContext from '@theme/hooks/useScrollMonitorContext'; +import {useRestoreTop} from '@theme/hooks/useRestoreTop'; import type {Props} from '@theme/Tabs'; import type {Props as TabItemProps} from '@theme/TabItem'; @@ -14,13 +22,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 Tabs(props: Props): JSX.Element { const { lazy, @@ -48,6 +49,25 @@ function Tabs(props: Props): JSX.Element { const {tabGroupChoices, setTabGroupChoices} = useUserPreferencesContext(); const [selectedValue, setSelectedValue] = useState(defaultValue); const tabRefs: (HTMLLIElement | null)[] = []; + const {enableScrollMonitor, disableScrollMonitor} = useScrollMonitorContext(); + const {measureTop, restoreTop} = useRestoreTop(); + + useLayoutEffect(() => { + // prevent `restoreTop` from triggering the navbar auto hide/show by + // disabling the scroll monitoring temporarily + disableScrollMonitor(); + restoreTop(); + setTimeout(() => { + enableScrollMonitor(); + }, 150); + }, [ + // reactive deps: + tabGroupChoices, + // stable formalities: + restoreTop, + enableScrollMonitor, + disableScrollMonitor, + ]); if (groupId != null) { const relevantTabGroupChoice = tabGroupChoices[groupId]; @@ -67,27 +87,11 @@ function Tabs(props: Props): JSX.Element { const selectedTabIndex = tabRefs.indexOf(selectedTab); const selectedTabValue = values[selectedTabIndex].value; + measureTop(selectedTab); setSelectedValue(selectedTabValue); 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); } }; 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 f2e834d3aaa8..5f2274d558ca 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/useRestoreTop.ts b/packages/docusaurus-theme-classic/src/theme/hooks/useRestoreTop.ts new file mode 100644 index 000000000000..1734b2514f39 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/hooks/useRestoreTop.ts @@ -0,0 +1,40 @@ +/** + * 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 {useCallback, useRef} from 'react'; +import {UseRestoreTopReturn} from '@theme/hooks/useRestoreTop'; + +export const useRestoreTop = (): UseRestoreTopReturn => { + const lastElementRef = useRef<{elem: HTMLElement | null; top: number}>({ + elem: null, + top: 0, + }); + + const measureTop = useCallback((elem: HTMLElement) => { + lastElementRef.current = { + elem, + top: elem.getBoundingClientRect().top, + }; + }, []); + + const restoreTop = useCallback(() => { + const { + current: {elem, top}, + } = lastElementRef; + if (!elem) { + return; + } + const newTop = elem.getBoundingClientRect().top; + const heightDiff = newTop - top; + if (heightDiff) { + window.scrollBy({left: 0, top: heightDiff}); + } + lastElementRef.current = {elem: null, top: 0}; + }, []); + + return {measureTop, restoreTop}; +}; diff --git a/packages/docusaurus-theme-classic/src/theme/hooks/useScrollMonitorContext.ts b/packages/docusaurus-theme-classic/src/theme/hooks/useScrollMonitorContext.ts new file mode 100644 index 000000000000..f10a4448fc36 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/hooks/useScrollMonitorContext.ts @@ -0,0 +1,25 @@ +/** + * 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 {useContext} from 'react'; + +import ScrollMonitorContext from '@theme/ScrollMonitorContext'; +import type {ScrollMonitorContextProps} from '@theme/hooks/useScrollMonitorContext'; + +function useScrollMonitorContext(): ScrollMonitorContextProps { + const context = useContext( + ScrollMonitorContext, + ); + if (context == null) { + throw new Error( + '"useScrollMonitorContext" is used outside of "Layout" component.', + ); + } + return context; +} + +export default useScrollMonitorContext; diff --git a/packages/docusaurus-theme-classic/src/theme/hooks/useScrollPosition.ts b/packages/docusaurus-theme-classic/src/theme/hooks/useScrollPosition.ts index fba41cb8e016..ff52bbe0e19e 100644 --- a/packages/docusaurus-theme-classic/src/theme/hooks/useScrollPosition.ts +++ b/packages/docusaurus-theme-classic/src/theme/hooks/useScrollPosition.ts @@ -8,6 +8,7 @@ import {useEffect, useRef} from 'react'; import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; import type {ScrollPosition} from '@theme/hooks/useScrollPosition'; +import useScrollMonitorContext from '@theme/hooks/useScrollMonitorContext'; const getScrollPosition = (): ScrollPosition | null => { return ExecutionEnvironment.canUseDOM @@ -25,9 +26,13 @@ const useScrollPosition = ( ) => void, deps = [], ): void => { + const {isScrollMonitorEnabledRef} = useScrollMonitorContext(); const lastPositionRef = useRef(getScrollPosition()); const handleScroll = () => { + if (!isScrollMonitorEnabledRef.current) { + return; + } const currentPosition = getScrollPosition()!; if (effect) { diff --git a/packages/docusaurus-theme-classic/src/types.d.ts b/packages/docusaurus-theme-classic/src/types.d.ts index 170f41f1164c..56bbfa371bf1 100644 --- a/packages/docusaurus-theme-classic/src/types.d.ts +++ b/packages/docusaurus-theme-classic/src/types.d.ts @@ -272,6 +272,44 @@ declare module '@theme/hooks/useUserPreferencesContext' { export default function useUserPreferencesContext(): UserPreferencesContextProps; } +declare module '@theme/hooks/useScrollMonitorContext' { + export type ScrollMonitorContextProps = { + /** + * Enables scroll position monitoring for `useScrollPosition` + */ + enableScrollMonitor: () => void; + /** + * Disables scroll position monitoring for `useScrollPosition` + */ + disableScrollMonitor: () => void; + /** + * A boolean ref tracking whether scroll monitoring is enabled + */ + isScrollMonitorEnabledRef: React.MutableRefObject; + }; + + export default function useScrollMonitorContext(): ScrollMonitorContextProps; +} + +declare module '@theme/hooks/useRestoreTop' { + export type UseRestoreTopReturn = { + /** + * Measure the top of an element, and store the details + */ + measureTop: (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 + */ + restoreTop: () => void; + }; + + /** + * Provides methods to store & restore an element's position from the top of the viewport + */ + export function useRestoreTop(): UseRestoreTopReturn; +} + declare module '@theme/hooks/useWindowSize' { export const windowSizes: { desktop: 'desktop'; @@ -642,6 +680,15 @@ declare module '@theme/UserPreferencesProvider' { export default UserPreferencesProvider; } +declare module '@theme/ScrollMonitorProvider' { + import type {ReactNode} from 'react'; + + export type Props = {readonly children: ReactNode}; + + const ScrollMonitorProvider: (props: Props) => JSX.Element; + export default ScrollMonitorProvider; +} + declare module '@theme/LayoutProviders' { import type {ReactNode} from 'react'; From 7fc7e49494e6a9f8e9cc5c781e8081aa1525214d Mon Sep 17 00:00:00 2001 From: slorber Date: Fri, 8 Oct 2021 17:53:08 +0200 Subject: [PATCH 2/5] refactor, renaming and move theme-agnostic code to theme-common --- .../src/theme/BackToTopButton/index.tsx | 3 +- .../src/theme/DocSidebar/index.tsx | 2 +- .../src/theme/LayoutProviders/index.tsx | 6 +- .../src/theme/ScrollMonitorContext.ts | 12 -- .../src/theme/ScrollMonitorProvider/index.tsx | 36 ---- .../src/theme/Tabs/index.tsx | 13 +- .../src/theme/hooks/useHideableNavbar.ts | 3 +- .../src/theme/hooks/useRestoreTop.ts | 40 ----- .../theme/hooks/useScrollMonitorContext.ts | 25 --- .../src/theme/hooks/useScrollPosition.ts | 57 ------ .../docusaurus-theme-classic/src/types.d.ts | 60 ------- packages/docusaurus-theme-common/src/index.ts | 7 + .../src/utils/scrollUtils.tsx | 169 ++++++++++++++++++ 13 files changed, 188 insertions(+), 245 deletions(-) delete mode 100644 packages/docusaurus-theme-classic/src/theme/ScrollMonitorContext.ts delete mode 100644 packages/docusaurus-theme-classic/src/theme/ScrollMonitorProvider/index.tsx delete mode 100644 packages/docusaurus-theme-classic/src/theme/hooks/useRestoreTop.ts delete mode 100644 packages/docusaurus-theme-classic/src/theme/hooks/useScrollMonitorContext.ts delete mode 100644 packages/docusaurus-theme-classic/src/theme/hooks/useScrollPosition.ts create mode 100644 packages/docusaurus-theme-common/src/utils/scrollUtils.tsx diff --git a/packages/docusaurus-theme-classic/src/theme/BackToTopButton/index.tsx b/packages/docusaurus-theme-classic/src/theme/BackToTopButton/index.tsx index a495f884b36e..feba4f36ea31 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 a517f6d08c6b..281b3df330ea 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 89f8313b480b..34cb7a2f157d 100644 --- a/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx @@ -8,11 +8,11 @@ import React from 'react'; import ThemeProvider from '@theme/ThemeProvider'; import UserPreferencesProvider from '@theme/UserPreferencesProvider'; -import ScrollMonitorProvider from '@theme/ScrollMonitorProvider'; import { AnnouncementBarProvider, DocsPreferredVersionContextProvider, MobileSecondaryMenuProvider, + ScrollControllerProvider, } from '@docusaurus/theme-common'; import type {Props} from '@theme/LayoutProviders'; @@ -21,13 +21,13 @@ export default function LayoutProviders({children}: Props): JSX.Element { - + {children} - + diff --git a/packages/docusaurus-theme-classic/src/theme/ScrollMonitorContext.ts b/packages/docusaurus-theme-classic/src/theme/ScrollMonitorContext.ts deleted file mode 100644 index 096cb04ab54d..000000000000 --- a/packages/docusaurus-theme-classic/src/theme/ScrollMonitorContext.ts +++ /dev/null @@ -1,12 +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 {createContext} from 'react'; - -const ScrollMonitorContext = createContext(undefined); - -export default ScrollMonitorContext; diff --git a/packages/docusaurus-theme-classic/src/theme/ScrollMonitorProvider/index.tsx b/packages/docusaurus-theme-classic/src/theme/ScrollMonitorProvider/index.tsx deleted file mode 100644 index a01c69b92179..000000000000 --- a/packages/docusaurus-theme-classic/src/theme/ScrollMonitorProvider/index.tsx +++ /dev/null @@ -1,36 +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 React, {useMemo, useRef} from 'react'; - -import ScrollMonitorContext from '@theme/ScrollMonitorContext'; -import type {Props} from '@theme/ScrollMonitorProvider'; - -function ScrollMonitorProvider(props: Props): JSX.Element { - const isScrollMonitorEnabledRef = useRef(true); - - const handlers = useMemo( - () => ({ - enableScrollMonitor: () => { - isScrollMonitorEnabledRef.current = true; - }, - disableScrollMonitor: () => { - isScrollMonitorEnabledRef.current = false; - }, - }), - [], - ); - - return ( - - {props.children} - - ); -} - -export default ScrollMonitorProvider; diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index 0d914e1e8aca..73878c188d4f 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -14,8 +14,7 @@ import React, { } from 'react'; import useIsBrowser from '@docusaurus/useIsBrowser'; import useUserPreferencesContext from '@theme/hooks/useUserPreferencesContext'; -import useScrollMonitorContext from '@theme/hooks/useScrollMonitorContext'; -import {useRestoreTop} from '@theme/hooks/useRestoreTop'; +import {useScrollController, useRestoreTop} from '@docusaurus/theme-common'; import type {Props} from '@theme/Tabs'; import type {Props as TabItemProps} from '@theme/TabItem'; @@ -51,24 +50,24 @@ function TabsComponent(props: Props): JSX.Element { const {tabGroupChoices, setTabGroupChoices} = useUserPreferencesContext(); const [selectedValue, setSelectedValue] = useState(defaultValue); const tabRefs: (HTMLLIElement | null)[] = []; - const {enableScrollMonitor, disableScrollMonitor} = useScrollMonitorContext(); + const {enableScrollEvents, disableScrollEvents} = useScrollController(); const {measureTop, restoreTop} = useRestoreTop(); useLayoutEffect(() => { // prevent `restoreTop` from triggering the navbar auto hide/show by // disabling the scroll monitoring temporarily - disableScrollMonitor(); + disableScrollEvents(); restoreTop(); setTimeout(() => { - enableScrollMonitor(); + enableScrollEvents(); }, 150); }, [ // reactive deps: tabGroupChoices, // stable formalities: restoreTop, - enableScrollMonitor, - disableScrollMonitor, + enableScrollEvents, + disableScrollEvents, ]); if (groupId != null) { diff --git a/packages/docusaurus-theme-classic/src/theme/hooks/useHideableNavbar.ts b/packages/docusaurus-theme-classic/src/theme/hooks/useHideableNavbar.ts index 94b14927f8ca..954549524fa4 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/useRestoreTop.ts b/packages/docusaurus-theme-classic/src/theme/hooks/useRestoreTop.ts deleted file mode 100644 index 1734b2514f39..000000000000 --- a/packages/docusaurus-theme-classic/src/theme/hooks/useRestoreTop.ts +++ /dev/null @@ -1,40 +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 {useCallback, useRef} from 'react'; -import {UseRestoreTopReturn} from '@theme/hooks/useRestoreTop'; - -export const useRestoreTop = (): UseRestoreTopReturn => { - const lastElementRef = useRef<{elem: HTMLElement | null; top: number}>({ - elem: null, - top: 0, - }); - - const measureTop = useCallback((elem: HTMLElement) => { - lastElementRef.current = { - elem, - top: elem.getBoundingClientRect().top, - }; - }, []); - - const restoreTop = useCallback(() => { - const { - current: {elem, top}, - } = lastElementRef; - if (!elem) { - return; - } - const newTop = elem.getBoundingClientRect().top; - const heightDiff = newTop - top; - if (heightDiff) { - window.scrollBy({left: 0, top: heightDiff}); - } - lastElementRef.current = {elem: null, top: 0}; - }, []); - - return {measureTop, restoreTop}; -}; diff --git a/packages/docusaurus-theme-classic/src/theme/hooks/useScrollMonitorContext.ts b/packages/docusaurus-theme-classic/src/theme/hooks/useScrollMonitorContext.ts deleted file mode 100644 index f10a4448fc36..000000000000 --- a/packages/docusaurus-theme-classic/src/theme/hooks/useScrollMonitorContext.ts +++ /dev/null @@ -1,25 +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 {useContext} from 'react'; - -import ScrollMonitorContext from '@theme/ScrollMonitorContext'; -import type {ScrollMonitorContextProps} from '@theme/hooks/useScrollMonitorContext'; - -function useScrollMonitorContext(): ScrollMonitorContextProps { - const context = useContext( - ScrollMonitorContext, - ); - if (context == null) { - throw new Error( - '"useScrollMonitorContext" is used outside of "Layout" component.', - ); - } - return context; -} - -export default useScrollMonitorContext; 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 ff52bbe0e19e..000000000000 --- a/packages/docusaurus-theme-classic/src/theme/hooks/useScrollPosition.ts +++ /dev/null @@ -1,57 +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'; -import useScrollMonitorContext from '@theme/hooks/useScrollMonitorContext'; - -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 {isScrollMonitorEnabledRef} = useScrollMonitorContext(); - const lastPositionRef = useRef(getScrollPosition()); - - const handleScroll = () => { - if (!isScrollMonitorEnabledRef.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); -}; - -export default useScrollPosition; diff --git a/packages/docusaurus-theme-classic/src/types.d.ts b/packages/docusaurus-theme-classic/src/types.d.ts index 7f0f1a1a0051..a59ecc3791b4 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}; @@ -272,44 +259,6 @@ declare module '@theme/hooks/useUserPreferencesContext' { export default function useUserPreferencesContext(): UserPreferencesContextProps; } -declare module '@theme/hooks/useScrollMonitorContext' { - export type ScrollMonitorContextProps = { - /** - * Enables scroll position monitoring for `useScrollPosition` - */ - enableScrollMonitor: () => void; - /** - * Disables scroll position monitoring for `useScrollPosition` - */ - disableScrollMonitor: () => void; - /** - * A boolean ref tracking whether scroll monitoring is enabled - */ - isScrollMonitorEnabledRef: React.MutableRefObject; - }; - - export default function useScrollMonitorContext(): ScrollMonitorContextProps; -} - -declare module '@theme/hooks/useRestoreTop' { - export type UseRestoreTopReturn = { - /** - * Measure the top of an element, and store the details - */ - measureTop: (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 - */ - restoreTop: () => void; - }; - - /** - * Provides methods to store & restore an element's position from the top of the viewport - */ - export function useRestoreTop(): UseRestoreTopReturn; -} - declare module '@theme/hooks/useWindowSize' { export const windowSizes: { desktop: 'desktop'; @@ -713,15 +662,6 @@ declare module '@theme/UserPreferencesProvider' { export default UserPreferencesProvider; } -declare module '@theme/ScrollMonitorProvider' { - import type {ReactNode} from 'react'; - - export type Props = {readonly children: ReactNode}; - - const ScrollMonitorProvider: (props: Props) => JSX.Element; - export default ScrollMonitorProvider; -} - declare module '@theme/LayoutProviders' { import type {ReactNode} from 'react'; diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts index 7ce4453dd548..156b767aaa79 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, + useRestoreTop, +} 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 000000000000..430d96edcbf6 --- /dev/null +++ b/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx @@ -0,0 +1,169 @@ +/** + * 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, + useMemo, + useRef, +} from 'react'; +import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; + +/** + * We need a way to update the scroll position while ignoring scroll events + * Permits to update the scroll position without changing Navbar/BackToTop button visibility + * This API is 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 UseRestoreTopReturn = { + /** + * Measure the top of an element, and store the details + */ + measureTop: (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 + */ + restoreTop: () => void; +}; + +export const useRestoreTop = (): UseRestoreTopReturn => { + const lastElementRef = useRef<{elem: HTMLElement | null; top: number}>({ + elem: null, + top: 0, + }); + + const measureTop = useCallback((elem: HTMLElement) => { + lastElementRef.current = { + elem, + top: elem.getBoundingClientRect().top, + }; + }, []); + + const restoreTop = useCallback(() => { + const { + current: {elem, top}, + } = lastElementRef; + if (!elem) { + return; + } + const newTop = elem.getBoundingClientRect().top; + const heightDiff = newTop - top; + if (heightDiff) { + window.scrollBy({left: 0, top: heightDiff}); + } + lastElementRef.current = {elem: null, top: 0}; + }, []); + + return {measureTop, restoreTop}; +}; From a71c016577ea823e634cecbf4daf30d551d5bc32 Mon Sep 17 00:00:00 2001 From: slorber Date: Fri, 8 Oct 2021 18:46:42 +0200 Subject: [PATCH 3/5] introduce blockElementScrollPositionUntilNextRender method --- .../src/theme/Tabs/index.tsx | 28 ++++---- packages/docusaurus-theme-common/src/index.ts | 2 +- .../src/utils/scrollUtils.tsx | 65 ++++++++++++++++--- 3 files changed, 72 insertions(+), 23 deletions(-) diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index 73878c188d4f..fa92874c43b0 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -5,16 +5,10 @@ * LICENSE file in the root directory of this source tree. */ -import React, { - useState, - cloneElement, - Children, - ReactElement, - useLayoutEffect, -} from 'react'; +import React, {useState, cloneElement, Children, ReactElement} from 'react'; import useIsBrowser from '@docusaurus/useIsBrowser'; import useUserPreferencesContext from '@theme/hooks/useUserPreferencesContext'; -import {useScrollController, useRestoreTop} from '@docusaurus/theme-common'; +import {useScrollPositionBlocker} from '@docusaurus/theme-common'; import type {Props} from '@theme/Tabs'; import type {Props as TabItemProps} from '@theme/TabItem'; @@ -50,9 +44,11 @@ function TabsComponent(props: Props): JSX.Element { const {tabGroupChoices, setTabGroupChoices} = useUserPreferencesContext(); const [selectedValue, setSelectedValue] = useState(defaultValue); const tabRefs: (HTMLLIElement | null)[] = []; - const {enableScrollEvents, disableScrollEvents} = useScrollController(); - const {measureTop, restoreTop} = useRestoreTop(); + // const {measureTop, restoreTop} = useRestoreTop(); + const {blockElementScrollPositionUntilNextRender} = + useScrollPositionBlocker(); + /* useLayoutEffect(() => { // prevent `restoreTop` from triggering the navbar auto hide/show by // disabling the scroll monitoring temporarily @@ -70,6 +66,8 @@ function TabsComponent(props: Props): JSX.Element { disableScrollEvents, ]); + */ + if (groupId != null) { const relevantTabGroupChoice = tabGroupChoices[groupId]; if ( @@ -88,11 +86,13 @@ function TabsComponent(props: Props): JSX.Element { const selectedTabIndex = tabRefs.indexOf(selectedTab); const selectedTabValue = values[selectedTabIndex].value; - measureTop(selectedTab); - setSelectedValue(selectedTabValue); + if (selectedTabValue !== selectedValue) { + blockElementScrollPositionUntilNextRender(selectedTab); + setSelectedValue(selectedTabValue); - if (groupId != null) { - setTabGroupChoices(groupId, selectedTabValue); + if (groupId != null) { + setTabGroupChoices(groupId, selectedTabValue); + } } }; diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts index 156b767aaa79..03f349a9a0c0 100644 --- a/packages/docusaurus-theme-common/src/index.ts +++ b/packages/docusaurus-theme-common/src/index.ts @@ -84,5 +84,5 @@ export { ScrollControllerProvider, useScrollController, useScrollPosition, - useRestoreTop, + useScrollPositionBlocker, } from './utils/scrollUtils'; diff --git a/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx b/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx index 430d96edcbf6..43f7d9dd5ad1 100644 --- a/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx +++ b/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx @@ -11,6 +11,7 @@ import React, { useCallback, useContext, useEffect, + useLayoutEffect, useMemo, useRef, } from 'react'; @@ -43,9 +44,11 @@ function useScrollControllerContextValue(): ScrollController { () => ({ scrollEventsEnabledRef, enableScrollEvents: () => { + console.log('enable'); scrollEventsEnabledRef.current = true; }, disableScrollEvents: () => { + console.log('disable'); scrollEventsEnabledRef.current = false; }, }), @@ -125,37 +128,38 @@ export function useScrollPosition( }, deps); } -type UseRestoreTopReturn = { +type UseScrollPositionSaver = { /** * Measure the top of an element, and store the details */ - measureTop: (elem: HTMLElement) => void; + 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 + * @return boolean true if a scroll */ - restoreTop: () => void; + restore: () => boolean; }; -export const useRestoreTop = (): UseRestoreTopReturn => { +function useScrollPositionSaver(): UseScrollPositionSaver { const lastElementRef = useRef<{elem: HTMLElement | null; top: number}>({ elem: null, top: 0, }); - const measureTop = useCallback((elem: HTMLElement) => { + const save = useCallback((elem: HTMLElement) => { lastElementRef.current = { elem, top: elem.getBoundingClientRect().top, }; }, []); - const restoreTop = useCallback(() => { + const restore = useCallback(() => { const { current: {elem, top}, } = lastElementRef; if (!elem) { - return; + return false; } const newTop = elem.getBoundingClientRect().top; const heightDiff = newTop - top; @@ -163,7 +167,52 @@ export const useRestoreTop = (): UseRestoreTopReturn => { window.scrollBy({left: 0, top: heightDiff}); } lastElementRef.current = {elem: null, top: 0}; + + return heightDiff !== 0; }, []); - return {measureTop, restoreTop}; + return useMemo(() => ({save, restore}), []); +} + +type UseScrollPositionBlockerReturn = { + blockElementScrollPositionUntilNextRender: (el: HTMLElement) => void; }; +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(); + console.log({restored}); + nextLayoutEffectCallbackRef.current = undefined; + + if (restored) { + const handleScrollRestoreEvent = () => { + scrollController.enableScrollEvents(); + window.removeEventListener('scroll', handleScrollRestoreEvent); + }; + window.addEventListener('scroll', handleScrollRestoreEvent); + } else { + scrollController.enableScrollEvents(); + } + }; + }, + [scrollController], + ); + + useLayoutEffect(() => { + nextLayoutEffectCallbackRef.current?.(); + }); + + return { + blockElementScrollPositionUntilNextRender, + }; +} From 87d3feb37a62996a2b41a416dd4b5696fb5e280e Mon Sep 17 00:00:00 2001 From: slorber Date: Wed, 13 Oct 2021 18:55:10 +0200 Subject: [PATCH 4/5] cleanup --- .../src/theme/Tabs/index.tsx | 21 ------------ .../src/utils/scrollUtils.tsx | 32 +++++++++++++------ 2 files changed, 22 insertions(+), 31 deletions(-) diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index fa92874c43b0..9b300ceb49e7 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -44,30 +44,9 @@ function TabsComponent(props: Props): JSX.Element { const {tabGroupChoices, setTabGroupChoices} = useUserPreferencesContext(); const [selectedValue, setSelectedValue] = useState(defaultValue); const tabRefs: (HTMLLIElement | null)[] = []; - // const {measureTop, restoreTop} = useRestoreTop(); const {blockElementScrollPositionUntilNextRender} = useScrollPositionBlocker(); - /* - useLayoutEffect(() => { - // prevent `restoreTop` from triggering the navbar auto hide/show by - // disabling the scroll monitoring temporarily - disableScrollEvents(); - restoreTop(); - setTimeout(() => { - enableScrollEvents(); - }, 150); - }, [ - // reactive deps: - tabGroupChoices, - // stable formalities: - restoreTop, - enableScrollEvents, - disableScrollEvents, - ]); - - */ - if (groupId != null) { const relevantTabGroupChoice = tabGroupChoices[groupId]; if ( diff --git a/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx b/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx index 43f7d9dd5ad1..9c54f405ae31 100644 --- a/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx +++ b/packages/docusaurus-theme-common/src/utils/scrollUtils.tsx @@ -19,8 +19,10 @@ import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; /** * We need a way to update the scroll position while ignoring scroll events - * Permits to update the scroll position without changing Navbar/BackToTop button visibility - * This API is motivated by https://github.com/facebook/docusaurus/pull/5618 + * 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 = { /** @@ -44,11 +46,9 @@ function useScrollControllerContextValue(): ScrollController { () => ({ scrollEventsEnabledRef, enableScrollEvents: () => { - console.log('enable'); scrollEventsEnabledRef.current = true; }, disableScrollEvents: () => { - console.log('disable'); scrollEventsEnabledRef.current = false; }, }), @@ -136,9 +136,8 @@ type UseScrollPositionSaver = { /** * Restore the page position to keep the stored element's position from * the top of the viewport, and remove the stored details - * @return boolean true if a scroll */ - restore: () => boolean; + restore: () => {restored: boolean}; }; function useScrollPositionSaver(): UseScrollPositionSaver { @@ -159,7 +158,7 @@ function useScrollPositionSaver(): UseScrollPositionSaver { current: {elem, top}, } = lastElementRef; if (!elem) { - return false; + return {restored: false}; } const newTop = elem.getBoundingClientRect().top; const heightDiff = newTop - top; @@ -168,7 +167,7 @@ function useScrollPositionSaver(): UseScrollPositionSaver { } lastElementRef.current = {elem: null, top: 0}; - return heightDiff !== 0; + return {restored: heightDiff !== 0}; }, []); return useMemo(() => ({save, restore}), []); @@ -177,6 +176,17 @@ function useScrollPositionSaver(): UseScrollPositionSaver { 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(); @@ -190,10 +200,12 @@ export function useScrollPositionBlocker(): UseScrollPositionBlockerReturn { scrollPositionSaver.save(el); scrollController.disableScrollEvents(); nextLayoutEffectCallbackRef.current = () => { - const restored = scrollPositionSaver.restore(); - console.log({restored}); + 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(); From 105370dcd3cfece25d954817bde38b00eebb5b65 Mon Sep 17 00:00:00 2001 From: slorber Date: Wed, 13 Oct 2021 18:58:32 +0200 Subject: [PATCH 5/5] minor rename --- .../src/theme/Tabs/index.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx index 9b300ceb49e7..9c58869c4b3b 100644 --- a/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Tabs/index.tsx @@ -61,16 +61,16 @@ 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; - if (selectedTabValue !== selectedValue) { - blockElementScrollPositionUntilNextRender(selectedTab); - setSelectedValue(selectedTabValue); + if (newTabValue !== selectedValue) { + blockElementScrollPositionUntilNextRender(newTab); + setSelectedValue(newTabValue); if (groupId != null) { - setTabGroupChoices(groupId, selectedTabValue); + setTabGroupChoices(groupId, newTabValue); } } };