mirror of
https://github.com/facebook/docusaurus.git
synced 2025-06-05 12:22:45 +02:00
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:
parent
caba1e4908
commit
c86dfbda61
50 changed files with 1522 additions and 214 deletions
|
@ -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: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
54
packages/docusaurus-theme-common/src/utils/tocUtils.ts
Normal file
54
packages/docusaurus-theme-common/src/utils/tocUtils.ts
Normal 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]);
|
||||
}
|
178
packages/docusaurus-theme-common/src/utils/useTOCHighlight.ts
Normal file
178
packages/docusaurus-theme-common/src/utils/useTOCHighlight.ts
Normal 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;
|
|
@ -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 {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue