mirror of
https://github.com/facebook/docusaurus.git
synced 2025-04-30 02:37:59 +02:00
refactor(theme): extract plumbing code of BTT button into theme-common (#7021)
* refactor(theme): extract plumbing code of BTT button into theme-common * oops
This commit is contained in:
parent
45b7a1b7c8
commit
cb03764ce5
5 changed files with 154 additions and 115 deletions
|
@ -5,122 +5,15 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, {useRef, useState} from 'react';
|
import React from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import {translate} from '@docusaurus/Translate';
|
import {translate} from '@docusaurus/Translate';
|
||||||
|
import {ThemeClassNames, useBackToTopButton} from '@docusaurus/theme-common';
|
||||||
|
|
||||||
import styles from './styles.module.css';
|
import styles from './styles.module.css';
|
||||||
import {
|
|
||||||
ThemeClassNames,
|
|
||||||
useScrollPosition,
|
|
||||||
useLocationChange,
|
|
||||||
} from '@docusaurus/theme-common';
|
|
||||||
|
|
||||||
const threshold = 300;
|
|
||||||
|
|
||||||
// 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
|
|
||||||
const SupportsNativeSmoothScrolling = false;
|
|
||||||
// const SupportsNativeSmoothScrolling =
|
|
||||||
// ExecutionEnvironment.canUseDOM &&
|
|
||||||
// 'scrollBehavior' in document.documentElement.style;
|
|
||||||
|
|
||||||
type CancelScrollTop = () => void;
|
|
||||||
|
|
||||||
function smoothScrollTopNative(): CancelScrollTop {
|
|
||||||
window.scrollTo({top: 0, behavior: 'smooth'});
|
|
||||||
return () => {
|
|
||||||
// Nothing to cancel, it's natively cancelled if user tries to scroll down
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function smoothScrollTopPolyfill(): CancelScrollTop {
|
|
||||||
let raf: number | null = null;
|
|
||||||
function rafRecursion() {
|
|
||||||
const currentScroll = document.documentElement.scrollTop;
|
|
||||||
if (currentScroll > 0) {
|
|
||||||
raf = requestAnimationFrame(rafRecursion);
|
|
||||||
window.scrollTo(0, Math.floor(currentScroll * 0.85));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rafRecursion();
|
|
||||||
|
|
||||||
// Break the recursion. Prevents the user from "fighting" against that
|
|
||||||
// recursion producing a weird UX
|
|
||||||
return () => raf && cancelAnimationFrame(raf);
|
|
||||||
}
|
|
||||||
|
|
||||||
type UseSmoothScrollTopReturn = {
|
|
||||||
// We use a cancel function because the non-native smooth scroll-top
|
|
||||||
// implementation must be interrupted if user scroll down
|
|
||||||
smoothScrollTop: () => void;
|
|
||||||
cancelScrollToTop: CancelScrollTop;
|
|
||||||
};
|
|
||||||
|
|
||||||
function useSmoothScrollToTop(): UseSmoothScrollTopReturn {
|
|
||||||
const lastCancelRef = useRef<CancelScrollTop | null>(null);
|
|
||||||
|
|
||||||
function smoothScrollTop(): void {
|
|
||||||
lastCancelRef.current = SupportsNativeSmoothScrolling
|
|
||||||
? smoothScrollTopNative()
|
|
||||||
: smoothScrollTopPolyfill();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
smoothScrollTop,
|
|
||||||
cancelScrollToTop: () => lastCancelRef.current?.(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BackToTopButton(): JSX.Element {
|
export default function BackToTopButton(): JSX.Element {
|
||||||
const [show, setShow] = useState(false);
|
const {shown, scrollToTop} = useBackToTopButton({threshold: 300});
|
||||||
const isFocusedAnchor = useRef(false);
|
|
||||||
const {smoothScrollTop, cancelScrollToTop} = useSmoothScrollToTop();
|
|
||||||
|
|
||||||
useScrollPosition(({scrollY: scrollTop}, lastPosition) => {
|
|
||||||
const lastScrollTop = lastPosition?.scrollY;
|
|
||||||
|
|
||||||
// No lastScrollTop means component is just being mounted.
|
|
||||||
// Not really a scroll event from the user, so we ignore it
|
|
||||||
if (!lastScrollTop) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFocusedAnchor.current) {
|
|
||||||
isFocusedAnchor.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isScrollingUp = scrollTop < lastScrollTop;
|
|
||||||
|
|
||||||
if (!isScrollingUp) {
|
|
||||||
cancelScrollToTop();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scrollTop < threshold) {
|
|
||||||
setShow(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isScrollingUp) {
|
|
||||||
const documentHeight = document.documentElement.scrollHeight;
|
|
||||||
const windowHeight = window.innerHeight;
|
|
||||||
if (scrollTop + windowHeight < documentHeight) {
|
|
||||||
setShow(true);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setShow(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useLocationChange((locationChangeEvent) => {
|
|
||||||
if (locationChangeEvent.location.hash) {
|
|
||||||
isFocusedAnchor.current = true;
|
|
||||||
setShow(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label={translate({
|
aria-label={translate({
|
||||||
|
@ -132,10 +25,10 @@ export default function BackToTopButton(): JSX.Element {
|
||||||
'clean-btn',
|
'clean-btn',
|
||||||
ThemeClassNames.common.backToTopButton,
|
ThemeClassNames.common.backToTopButton,
|
||||||
styles.backToTopButton,
|
styles.backToTopButton,
|
||||||
show && styles.backToTopButtonShow,
|
shown && styles.backToTopButtonShow,
|
||||||
)}
|
)}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => smoothScrollTop()}
|
onClick={scrollToTop}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
/**
|
||||||
|
* 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 {useRef, useState} from 'react';
|
||||||
|
import {useScrollPosition, useSmoothScrollTo} from '../utils/scrollUtils';
|
||||||
|
import {useLocationChange} from '../utils/useLocationChange';
|
||||||
|
|
||||||
|
/** Wires the logic for the back to top button. */
|
||||||
|
export function useBackToTopButton({
|
||||||
|
threshold,
|
||||||
|
}: {
|
||||||
|
/**
|
||||||
|
* The minimum vertical scroll position, above which a scroll-up would not
|
||||||
|
* cause `shown` to become `true`. This is because BTT is only useful if the
|
||||||
|
* user is far down the page.
|
||||||
|
*/
|
||||||
|
threshold: number;
|
||||||
|
}): {
|
||||||
|
/**
|
||||||
|
* Whether the button should be displayed. We only show if the user has
|
||||||
|
* scrolled up and is on a vertical position greater than `threshold`.
|
||||||
|
*/
|
||||||
|
shown: boolean;
|
||||||
|
/**
|
||||||
|
* A (memoized) handle for starting the scroll, which you can directly plug
|
||||||
|
* into the props.
|
||||||
|
*/
|
||||||
|
scrollToTop: () => void;
|
||||||
|
} {
|
||||||
|
const [shown, setShown] = useState(false);
|
||||||
|
const isFocusedAnchor = useRef(false);
|
||||||
|
const {startScroll, cancelScroll} = useSmoothScrollTo();
|
||||||
|
|
||||||
|
useScrollPosition(({scrollY: scrollTop}, lastPosition) => {
|
||||||
|
const lastScrollTop = lastPosition?.scrollY;
|
||||||
|
// Component is just being mounted. Not really a scroll event from the user.
|
||||||
|
// Ignore it.
|
||||||
|
if (!lastScrollTop) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isFocusedAnchor.current) {
|
||||||
|
// This scroll position change is triggered by navigating to an anchor.
|
||||||
|
// Ignore it.
|
||||||
|
isFocusedAnchor.current = false;
|
||||||
|
} else if (scrollTop >= lastScrollTop) {
|
||||||
|
// The user has scrolled down to "fight against" the animation. Cancel any
|
||||||
|
// animation under progress.
|
||||||
|
cancelScroll();
|
||||||
|
setShown(false);
|
||||||
|
} else if (scrollTop < threshold) {
|
||||||
|
// Scrolled to the minimum position; hide the button.
|
||||||
|
setShown(false);
|
||||||
|
} else if (
|
||||||
|
scrollTop + window.innerHeight <
|
||||||
|
document.documentElement.scrollHeight
|
||||||
|
) {
|
||||||
|
setShown(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useLocationChange((locationChangeEvent) => {
|
||||||
|
if (locationChangeEvent.location.hash) {
|
||||||
|
isFocusedAnchor.current = true;
|
||||||
|
setShown(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {shown, scrollToTop: () => startScroll(0)};
|
||||||
|
}
|
|
@ -28,13 +28,11 @@ export function useHideableNavbar(hideOnScroll: boolean): {
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useScrollPosition((currentPosition, lastPosition) => {
|
useScrollPosition(({scrollY: scrollTop}, lastPosition) => {
|
||||||
if (!hideOnScroll) {
|
if (!hideOnScroll) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollTop = currentPosition.scrollY;
|
|
||||||
|
|
||||||
// Needed mostly for handling rubber band scrolling.
|
// Needed mostly for handling rubber band scrolling.
|
||||||
// See https://github.com/facebook/docusaurus/pull/5721
|
// See https://github.com/facebook/docusaurus/pull/5721
|
||||||
if (scrollTop < navbarHeight.current) {
|
if (scrollTop < navbarHeight.current) {
|
||||||
|
|
|
@ -106,6 +106,7 @@ export {
|
||||||
useScrollController,
|
useScrollController,
|
||||||
useScrollPosition,
|
useScrollPosition,
|
||||||
useScrollPositionBlocker,
|
useScrollPositionBlocker,
|
||||||
|
useSmoothScrollTo,
|
||||||
} from './utils/scrollUtils';
|
} from './utils/scrollUtils';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
@ -145,6 +146,7 @@ export {
|
||||||
type NavbarSecondaryMenuComponent,
|
type NavbarSecondaryMenuComponent,
|
||||||
} from './contexts/navbarSecondaryMenu';
|
} from './contexts/navbarSecondaryMenu';
|
||||||
|
|
||||||
|
export {useBackToTopButton} from './hooks/useBackToTopButton';
|
||||||
export {useHideableNavbar} from './hooks/useHideableNavbar';
|
export {useHideableNavbar} from './hooks/useHideableNavbar';
|
||||||
export {
|
export {
|
||||||
useKeyboardNavigation,
|
useKeyboardNavigation,
|
||||||
|
|
|
@ -232,3 +232,76 @@ export function useScrollPositionBlocker(): {
|
||||||
blockElementScrollPositionUntilNextRender,
|
blockElementScrollPositionUntilNextRender,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
const SupportsNativeSmoothScrolling = false;
|
||||||
|
// const SupportsNativeSmoothScrolling =
|
||||||
|
// ExecutionEnvironment.canUseDOM &&
|
||||||
|
// 'scrollBehavior' in document.documentElement.style;
|
||||||
|
|
||||||
|
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(Math.abs(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, because native
|
||||||
|
* support detection 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);
|
||||||
|
return {
|
||||||
|
startScroll: (top: number) => {
|
||||||
|
cancelRef.current = SupportsNativeSmoothScrolling
|
||||||
|
? smoothScrollNative(top)
|
||||||
|
: smoothScrollPolyfill(top);
|
||||||
|
},
|
||||||
|
cancelScroll: () => cancelRef?.current,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue