mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-05 21:27:24 +02:00
fix: refactor TOC highlighting + handle edge cases (#5361)
This commit is contained in:
parent
416e2a7a29
commit
b8841de53a
4 changed files with 112 additions and 86 deletions
|
@ -48,7 +48,7 @@ const createAnchorHeading = (
|
||||||
<a
|
<a
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={clsx('anchor', {
|
className={clsx('anchor', `anchor__${Tag}`, {
|
||||||
[styles.enhancedAnchor]: !hideOnScroll,
|
[styles.enhancedAnchor]: !hideOnScroll,
|
||||||
})}
|
})}
|
||||||
id={id}
|
id={id}
|
||||||
|
|
|
@ -7,13 +7,18 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import useTOCHighlight from '@theme/hooks/useTOCHighlight';
|
import useTOCHighlight, {
|
||||||
|
Params as TOCHighlightParams,
|
||||||
|
} from '@theme/hooks/useTOCHighlight';
|
||||||
import type {TOCProps, TOCHeadingsProps} from '@theme/TOC';
|
import type {TOCProps, TOCHeadingsProps} from '@theme/TOC';
|
||||||
import styles from './styles.module.css';
|
import styles from './styles.module.css';
|
||||||
|
|
||||||
const LINK_CLASS_NAME = 'table-of-contents__link';
|
const LINK_CLASS_NAME = 'table-of-contents__link';
|
||||||
const ACTIVE_LINK_CLASS_NAME = 'table-of-contents__link--active';
|
|
||||||
const TOP_OFFSET = 100;
|
const TOC_HIGHLIGHT_PARAMS: TOCHighlightParams = {
|
||||||
|
linkClassName: LINK_CLASS_NAME,
|
||||||
|
linkActiveClassName: 'table-of-contents__link--active',
|
||||||
|
};
|
||||||
|
|
||||||
/* eslint-disable jsx-a11y/control-has-associated-label */
|
/* eslint-disable jsx-a11y/control-has-associated-label */
|
||||||
export function TOCHeadings({
|
export function TOCHeadings({
|
||||||
|
@ -45,7 +50,7 @@ export function TOCHeadings({
|
||||||
}
|
}
|
||||||
|
|
||||||
function TOC({toc}: TOCProps): JSX.Element {
|
function TOC({toc}: TOCProps): JSX.Element {
|
||||||
useTOCHighlight(LINK_CLASS_NAME, ACTIVE_LINK_CLASS_NAME, TOP_OFFSET);
|
useTOCHighlight(TOC_HIGHLIGHT_PARAMS);
|
||||||
return (
|
return (
|
||||||
<div className={clsx(styles.tableOfContents, 'thin-scrollbar')}>
|
<div className={clsx(styles.tableOfContents, 'thin-scrollbar')}>
|
||||||
<TOCHeadings toc={toc} />
|
<TOCHeadings toc={toc} />
|
||||||
|
|
|
@ -5,94 +5,115 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {useEffect, useState} from 'react';
|
import {Params} from '@theme/hooks/useTOCHighlight';
|
||||||
|
import {useEffect, useRef} from 'react';
|
||||||
|
|
||||||
function useTOCHighlight(
|
// If the anchor has no height and is just a "marker" in the dom; we'll use the parent (normally the link text) rect boundaries instead
|
||||||
linkClassName: string,
|
function getVisibleBoundingClientRect(element: HTMLElement): DOMRect {
|
||||||
linkActiveClassName: string,
|
const rect = element.getBoundingClientRect();
|
||||||
topOffset: number,
|
const hasNoHeight = rect.top === rect.bottom;
|
||||||
): void {
|
if (hasNoHeight) {
|
||||||
const [lastActiveLink, setLastActiveLink] = useState<
|
return getVisibleBoundingClientRect(element.parentNode as HTMLElement);
|
||||||
HTMLAnchorElement | undefined
|
}
|
||||||
>(undefined);
|
return rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Considering we divide viewport into 2 zones of each 50vh
|
||||||
|
// This returns true if an element is in the first zone (ie, appear in viewport, near the top)
|
||||||
|
function isInViewportTopHalf(boundingRect: DOMRect) {
|
||||||
|
return boundingRect.top > 0 && boundingRect.bottom < window.innerHeight / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnchors() {
|
||||||
|
// For toc highlighting, we only consider h2/h3 anchors
|
||||||
|
const selector = '.anchor.anchor__h2, .anchor.anchor__h3';
|
||||||
|
return Array.from(document.querySelectorAll(selector)) as HTMLElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveAnchor(): Element | null {
|
||||||
|
const anchors = getAnchors();
|
||||||
|
|
||||||
|
const anchorTopOffset = 100; // Skip anchors that are too close to the viewport top
|
||||||
|
|
||||||
|
// Naming is hard
|
||||||
|
// The "nextVisibleAnchor" is the first anchor that appear under the viewport top boundary
|
||||||
|
// Note: it does not mean this anchor is visible yet, but if user continues scrolling down, it will be the first to become visible
|
||||||
|
const nextVisibleAnchor = anchors.find((anchor) => {
|
||||||
|
const boundingRect = getVisibleBoundingClientRect(anchor);
|
||||||
|
return boundingRect.top >= anchorTopOffset;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextVisibleAnchor) {
|
||||||
|
const boundingRect = getVisibleBoundingClientRect(nextVisibleAnchor);
|
||||||
|
// If anchor is in the top half of the viewport: it is the one we consider "active"
|
||||||
|
// (unless it's too close to the top and and soon to be scrolled outside viewport)
|
||||||
|
if (isInViewportTopHalf(boundingRect)) {
|
||||||
|
return nextVisibleAnchor;
|
||||||
|
}
|
||||||
|
// If anchor is in the bottom half of the viewport, or under the viewport, we consider the active anchor is the previous one
|
||||||
|
// This is because the main text appearing in the user screen mostly belong to the previous anchor
|
||||||
|
else {
|
||||||
|
// Returns null for the first anchor, see https://github.com/facebook/docusaurus/issues/5318
|
||||||
|
return anchors[anchors.indexOf(nextVisibleAnchor) - 1] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// no anchor under viewport top? (ie we are at the bottom of the page)
|
||||||
|
// => highlight the last anchor found
|
||||||
|
else {
|
||||||
|
return anchors[anchors.length - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinkAnchorValue(link: HTMLAnchorElement): string {
|
||||||
|
return decodeURIComponent(link.href.substring(link.href.indexOf('#') + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinks(linkClassName: string) {
|
||||||
|
return Array.from(
|
||||||
|
document.getElementsByClassName(linkClassName),
|
||||||
|
) as HTMLAnchorElement[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function useTOCHighlight(params: Params): void {
|
||||||
|
const lastActiveLinkRef = useRef<HTMLAnchorElement | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function setActiveLink() {
|
const {linkClassName, linkActiveClassName} = params;
|
||||||
function getActiveHeaderAnchor(): Element | null {
|
|
||||||
const headersAnchors: Element[] = Array.from(
|
|
||||||
document.getElementsByClassName('anchor'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const firstAnchorUnderViewportTop = headersAnchors.find((anchor) => {
|
function updateLinkActiveClass(link: HTMLAnchorElement, active: boolean) {
|
||||||
const {top} = anchor.getBoundingClientRect();
|
if (active) {
|
||||||
return top >= topOffset;
|
if (lastActiveLinkRef.current && lastActiveLinkRef.current !== link) {
|
||||||
});
|
lastActiveLinkRef.current?.classList.remove(linkActiveClassName);
|
||||||
|
|
||||||
if (firstAnchorUnderViewportTop) {
|
|
||||||
// If first anchor in viewport is under a certain threshold, we consider it's not the active anchor.
|
|
||||||
// In such case, the active anchor is the previous one (if it exists), that may be above the viewport
|
|
||||||
if (
|
|
||||||
firstAnchorUnderViewportTop.getBoundingClientRect().top >= topOffset
|
|
||||||
) {
|
|
||||||
const previousAnchor =
|
|
||||||
headersAnchors[
|
|
||||||
headersAnchors.indexOf(firstAnchorUnderViewportTop) - 1
|
|
||||||
];
|
|
||||||
return previousAnchor ?? firstAnchorUnderViewportTop;
|
|
||||||
}
|
|
||||||
// If the anchor is at the top of the viewport, we consider it's the first anchor
|
|
||||||
else {
|
|
||||||
return firstAnchorUnderViewportTop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// no anchor under viewport top? (ie we are at the bottom of the page)
|
|
||||||
else {
|
|
||||||
// highlight the last anchor found
|
|
||||||
return headersAnchors[headersAnchors.length - 1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeHeaderAnchor = getActiveHeaderAnchor();
|
|
||||||
|
|
||||||
if (activeHeaderAnchor) {
|
|
||||||
let index = 0;
|
|
||||||
let itemHighlighted = false;
|
|
||||||
|
|
||||||
// @ts-expect-error: Must be <a> tags.
|
|
||||||
const links: HTMLCollectionOf<HTMLAnchorElement> = document.getElementsByClassName(
|
|
||||||
linkClassName,
|
|
||||||
);
|
|
||||||
while (index < links.length && !itemHighlighted) {
|
|
||||||
const link = links[index];
|
|
||||||
const {href} = link;
|
|
||||||
const anchorValue = decodeURIComponent(
|
|
||||||
href.substring(href.indexOf('#') + 1),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (activeHeaderAnchor.id === anchorValue) {
|
|
||||||
if (lastActiveLink) {
|
|
||||||
lastActiveLink.classList.remove(linkActiveClassName);
|
|
||||||
}
|
|
||||||
link.classList.add(linkActiveClassName);
|
|
||||||
setLastActiveLink(link);
|
|
||||||
itemHighlighted = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
index += 1;
|
|
||||||
}
|
}
|
||||||
|
link.classList.add(linkActiveClassName);
|
||||||
|
lastActiveLinkRef.current = link;
|
||||||
|
} else {
|
||||||
|
link.classList.remove(linkActiveClassName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('scroll', setActiveLink);
|
function updateActiveLink() {
|
||||||
document.addEventListener('resize', setActiveLink);
|
const links = getLinks(linkClassName);
|
||||||
|
const activeAnchor = getActiveAnchor();
|
||||||
|
const activeLink = links.find(
|
||||||
|
(link) => activeAnchor && activeAnchor.id === getLinkAnchorValue(link),
|
||||||
|
);
|
||||||
|
|
||||||
setActiveLink();
|
links.forEach((link) => {
|
||||||
|
updateLinkActiveClass(link, link === activeLink);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('scroll', updateActiveLink);
|
||||||
|
document.addEventListener('resize', updateActiveLink);
|
||||||
|
|
||||||
|
updateActiveLink();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('scroll', setActiveLink);
|
document.removeEventListener('scroll', updateActiveLink);
|
||||||
document.removeEventListener('resize', setActiveLink);
|
document.removeEventListener('resize', updateActiveLink);
|
||||||
};
|
};
|
||||||
});
|
}, [params]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useTOCHighlight;
|
export default useTOCHighlight;
|
||||||
|
|
10
packages/docusaurus-theme-classic/src/types.d.ts
vendored
10
packages/docusaurus-theme-classic/src/types.d.ts
vendored
|
@ -255,11 +255,11 @@ declare module '@theme/hooks/useThemeContext' {
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@theme/hooks/useTOCHighlight' {
|
declare module '@theme/hooks/useTOCHighlight' {
|
||||||
export default function useTOCHighlight(
|
export type Params = {
|
||||||
linkClassName: string,
|
linkClassName: string;
|
||||||
linkActiveClassName: string,
|
linkActiveClassName: string;
|
||||||
topOffset: number,
|
};
|
||||||
): void;
|
export default function useTOCHighlight(params: Params): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@theme/hooks/useUserPreferencesContext' {
|
declare module '@theme/hooks/useUserPreferencesContext' {
|
||||||
|
|
Loading…
Add table
Reference in a new issue