feat(v2): allow specifying TOC max depth (themeConfig + frontMatter) (#5578)

* feat: add all TOC levels to MDX loader

* feat: add theme-level config for heading depth

* test: add remark MDX loader test

* fix: limit maxDepth validation to H2 - H6

* refactor: set default `maxDepth` using `joi`

* refactor: `maxDepth` -> `maxHeadingLevel

* refactor: invert underlying TOC depth API

* refactor: make TOC algorithm level-aware

* feat: add support for per-doc TOC heading levels

* feat: support document-level heading levels for blog

* fix: correct validation for toc level frontmatter

* fix: ensure TOC doesn't generate redundant DOM

* perf: simpler TOC heading search alg

* docs: document heading level props for `TOCInline`

* Update website/docs/guides/markdown-features/markdown-features-inline-toc.mdx

Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>

* docs: fix docs (again)

* create dedicated  test file for heading searching logic: exhaustive tests will be simpler to write

* toc search: add real-world test

* fix test

* add dogfooding tests for toc min/max

* add test for min/max toc frontmatter

* reverse min/max order

* add theme minHeadingLevel + tests

* simpler TOC rendering logic

* simplify TOC implementation (temp, WIP)

* reverse unnatural order for minHeadingLevel/maxHeadingLevel

* add TOC dogfooding tests to all content plugins

* expose toc min/max heading level frontmatter to all 3 content plugins

* refactor blogLayout: accept toc ReactElement directly

* move toc utils to theme-common

* add tests for filterTOC

* create new generic TOCItems component

* useless css file copied

* fix toc highlighting className conflicts

* update doc

* fix types

Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
Co-authored-by: slorber <lorber.sebastien@gmail.com>
This commit is contained in:
Erick Zhao 2021-09-29 02:19:11 -07:00 committed by GitHub
parent caba1e4908
commit c86dfbda61
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 1522 additions and 214 deletions

View file

@ -0,0 +1,197 @@
/**
* 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 {TOCItem} from '@docusaurus/types';
import {filterTOC} from '../tocUtils';
describe('filterTOC', () => {
test('filter a toc with all heading levels', () => {
const toc: TOCItem[] = [
{
id: 'alpha',
level: 1,
value: 'alpha',
children: [
{
id: 'bravo',
level: 2,
value: 'Bravo',
children: [
{
id: 'charlie',
level: 3,
value: 'Charlie',
children: [
{
id: 'delta',
level: 4,
value: 'Delta',
children: [
{
id: 'echo',
level: 5,
value: 'Echo',
children: [
{
id: 'foxtrot',
level: 6,
value: 'Foxtrot',
children: [],
},
],
},
],
},
],
},
],
},
],
},
];
expect(filterTOC({toc, minHeadingLevel: 2, maxHeadingLevel: 2})).toEqual([
{
id: 'bravo',
level: 2,
value: 'Bravo',
children: [],
},
]);
expect(filterTOC({toc, minHeadingLevel: 3, maxHeadingLevel: 3})).toEqual([
{
id: 'charlie',
level: 3,
value: 'Charlie',
children: [],
},
]);
expect(filterTOC({toc, minHeadingLevel: 2, maxHeadingLevel: 3})).toEqual([
{
id: 'bravo',
level: 2,
value: 'Bravo',
children: [
{
id: 'charlie',
level: 3,
value: 'Charlie',
children: [],
},
],
},
]);
expect(filterTOC({toc, minHeadingLevel: 2, maxHeadingLevel: 4})).toEqual([
{
id: 'bravo',
level: 2,
value: 'Bravo',
children: [
{
id: 'charlie',
level: 3,
value: 'Charlie',
children: [
{
id: 'delta',
level: 4,
value: 'Delta',
children: [],
},
],
},
],
},
]);
});
// It's not 100% clear exactly how the TOC should behave under weird heading levels provided by the user
// Adding a test so that behavior stays the same over time
test('filter invalid heading levels (but possible) TOC', () => {
const toc: TOCItem[] = [
{
id: 'charlie',
level: 3,
value: 'Charlie',
children: [],
},
{
id: 'bravo',
level: 2,
value: 'Bravo',
children: [
{
id: 'delta',
level: 4,
value: 'Delta',
children: [],
},
],
},
];
expect(filterTOC({toc, minHeadingLevel: 2, maxHeadingLevel: 2})).toEqual([
{
id: 'bravo',
level: 2,
value: 'Bravo',
children: [],
},
]);
expect(filterTOC({toc, minHeadingLevel: 3, maxHeadingLevel: 3})).toEqual([
{
id: 'charlie',
level: 3,
value: 'Charlie',
children: [],
},
]);
expect(filterTOC({toc, minHeadingLevel: 4, maxHeadingLevel: 4})).toEqual([
{
id: 'delta',
level: 4,
value: 'Delta',
children: [],
},
]);
expect(filterTOC({toc, minHeadingLevel: 2, maxHeadingLevel: 3})).toEqual([
{
id: 'charlie',
level: 3,
value: 'Charlie',
children: [],
},
{
id: 'bravo',
level: 2,
value: 'Bravo',
children: [],
},
]);
expect(filterTOC({toc, minHeadingLevel: 3, maxHeadingLevel: 4})).toEqual([
{
id: 'charlie',
level: 3,
value: 'Charlie',
children: [],
},
{
id: 'delta',
level: 4,
value: 'Delta',
children: [],
},
]);
});
});

View file

@ -0,0 +1,54 @@
/**
* 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 {useMemo} from 'react';
import {TOCItem} from '@docusaurus/types';
type FilterTOCParam = {
toc: readonly TOCItem[];
minHeadingLevel: number;
maxHeadingLevel: number;
};
export function filterTOC({
toc,
minHeadingLevel,
maxHeadingLevel,
}: FilterTOCParam): TOCItem[] {
function isValid(item: TOCItem) {
return item.level >= minHeadingLevel && item.level <= maxHeadingLevel;
}
return toc.flatMap((item) => {
const filteredChildren = filterTOC({
toc: item.children,
minHeadingLevel,
maxHeadingLevel,
});
if (isValid(item)) {
return [
{
...item,
children: filteredChildren,
},
];
} else {
return filteredChildren;
}
});
}
// Memoize potentially expensive filtering logic
export function useTOCFilter({
toc,
minHeadingLevel,
maxHeadingLevel,
}: FilterTOCParam): readonly TOCItem[] {
return useMemo(() => {
return filterTOC({toc, minHeadingLevel, maxHeadingLevel});
}, [toc, minHeadingLevel, maxHeadingLevel]);
}

View file

@ -0,0 +1,178 @@
/**
* 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 {useThemeConfig} from './useThemeConfig';
/*
TODO make the hardcoded theme-classic classnames configurable
(or add them to ThemeClassNames?)
*/
// 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
function getVisibleBoundingClientRect(element: HTMLElement): DOMRect {
const rect = element.getBoundingClientRect();
const hasNoHeight = rect.top === rect.bottom;
if (hasNoHeight) {
return getVisibleBoundingClientRect(element.parentNode as HTMLElement);
}
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({
minHeadingLevel,
maxHeadingLevel,
}: {
minHeadingLevel: number;
maxHeadingLevel: number;
}) {
const selectors = [];
for (let i = minHeadingLevel; i <= maxHeadingLevel; i += 1) {
selectors.push(`.anchor.anchor__h${i}`);
}
const selector = selectors.join(', ');
return Array.from(document.querySelectorAll(selector)) as HTMLElement[];
}
function getActiveAnchor(
anchors: HTMLElement[],
{
anchorTopOffset,
}: {
anchorTopOffset: number;
},
): Element | null {
// 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 getNavbarHeight(): number {
// Not ideal to obtain actual height this way
// Using TS ! (not ?) because otherwise a bad selector would be un-noticed
return document.querySelector('.navbar')!.clientHeight;
}
function useAnchorTopOffsetRef() {
const anchorTopOffsetRef = useRef<number>(0);
const {
navbar: {hideOnScroll},
} = useThemeConfig();
useEffect(() => {
anchorTopOffsetRef.current = hideOnScroll ? 0 : getNavbarHeight();
}, [hideOnScroll]);
return anchorTopOffsetRef;
}
export type TOCHighlightConfig = {
linkClassName: string;
linkActiveClassName: string;
minHeadingLevel: number;
maxHeadingLevel: number;
};
function useTOCHighlight(config: TOCHighlightConfig | undefined): void {
const lastActiveLinkRef = useRef<HTMLAnchorElement | undefined>(undefined);
const anchorTopOffsetRef = useAnchorTopOffsetRef();
useEffect(() => {
if (!config) {
// no-op, highlighting is disabled
return () => {};
}
const {
linkClassName,
linkActiveClassName,
minHeadingLevel,
maxHeadingLevel,
} = config;
function updateLinkActiveClass(link: HTMLAnchorElement, active: boolean) {
if (active) {
if (lastActiveLinkRef.current && lastActiveLinkRef.current !== link) {
lastActiveLinkRef.current?.classList.remove(linkActiveClassName);
}
link.classList.add(linkActiveClassName);
lastActiveLinkRef.current = link;
} else {
link.classList.remove(linkActiveClassName);
}
}
function updateActiveLink() {
const links = getLinks(linkClassName);
const anchors = getAnchors({minHeadingLevel, maxHeadingLevel});
const activeAnchor = getActiveAnchor(anchors, {
anchorTopOffset: anchorTopOffsetRef.current,
});
const activeLink = links.find(
(link) => activeAnchor && activeAnchor.id === getLinkAnchorValue(link),
);
links.forEach((link) => {
updateLinkActiveClass(link, link === activeLink);
});
}
document.addEventListener('scroll', updateActiveLink);
document.addEventListener('resize', updateActiveLink);
updateActiveLink();
return () => {
document.removeEventListener('scroll', updateActiveLink);
document.removeEventListener('resize', updateActiveLink);
};
}, [config, anchorTopOffsetRef]);
}
export default useTOCHighlight;

View file

@ -85,6 +85,11 @@ export type Footer = {
links: FooterLinks[];
};
export type TableOfContents = {
minHeadingLevel: number;
maxHeadingLevel: number;
};
export type ThemeConfig = {
docs: {
versionPersistence: DocsVersionPersistence;
@ -104,6 +109,7 @@ export type ThemeConfig = {
image?: string;
metadatas: Array<Record<string, string>>;
sidebarCollapsible: boolean;
tableOfContents: TableOfContents;
};
export function useThemeConfig(): ThemeConfig {