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:
Joshua Chen 2022-03-26 21:05:48 +08:00 committed by GitHub
parent 45b7a1b7c8
commit cb03764ce5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 154 additions and 115 deletions

View file

@ -5,122 +5,15 @@
* 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 {translate} from '@docusaurus/Translate';
import {ThemeClassNames, useBackToTopButton} from '@docusaurus/theme-common';
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 {
const [show, setShow] = useState(false);
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);
}
});
const {shown, scrollToTop} = useBackToTopButton({threshold: 300});
return (
<button
aria-label={translate({
@ -132,10 +25,10 @@ export default function BackToTopButton(): JSX.Element {
'clean-btn',
ThemeClassNames.common.backToTopButton,
styles.backToTopButton,
show && styles.backToTopButtonShow,
shown && styles.backToTopButtonShow,
)}
type="button"
onClick={() => smoothScrollTop()}
onClick={scrollToTop}
/>
);
}

View file

@ -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)};
}

View file

@ -28,13 +28,11 @@ export function useHideableNavbar(hideOnScroll: boolean): {
}
}, []);
useScrollPosition((currentPosition, lastPosition) => {
useScrollPosition(({scrollY: scrollTop}, lastPosition) => {
if (!hideOnScroll) {
return;
}
const scrollTop = currentPosition.scrollY;
// Needed mostly for handling rubber band scrolling.
// See https://github.com/facebook/docusaurus/pull/5721
if (scrollTop < navbarHeight.current) {

View file

@ -106,6 +106,7 @@ export {
useScrollController,
useScrollPosition,
useScrollPositionBlocker,
useSmoothScrollTo,
} from './utils/scrollUtils';
export {
@ -145,6 +146,7 @@ export {
type NavbarSecondaryMenuComponent,
} from './contexts/navbarSecondaryMenu';
export {useBackToTopButton} from './hooks/useBackToTopButton';
export {useHideableNavbar} from './hooks/useHideableNavbar';
export {
useKeyboardNavigation,

View file

@ -232,3 +232,76 @@ export function useScrollPositionBlocker(): {
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,
};
}