198 lines
6.3 KiB
TypeScript
198 lines
6.3 KiB
TypeScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
*/
|
|
import type {TableOfContentsEntry} from '@lexical/react/LexicalTableOfContents';
|
|
import type {HeadingTagType} from '@lexical/rich-text';
|
|
import type {NodeKey} from 'lexical';
|
|
|
|
import './index.css';
|
|
|
|
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
|
import LexicalTableOfContents from '@lexical/react/LexicalTableOfContents';
|
|
import {useEffect, useRef, useState} from 'react';
|
|
import * as React from 'react';
|
|
|
|
const MARGIN_ABOVE_EDITOR = 624;
|
|
const HEADING_WIDTH = 9;
|
|
|
|
function indent(tagName: HeadingTagType) {
|
|
if (tagName === 'h2') {
|
|
return 'heading2';
|
|
} else if (tagName === 'h3') {
|
|
return 'heading3';
|
|
}
|
|
}
|
|
|
|
function isHeadingAtTheTopOfThePage(element: HTMLElement): boolean {
|
|
const elementYPosition = element?.getClientRects()[0].y;
|
|
return (
|
|
elementYPosition >= MARGIN_ABOVE_EDITOR &&
|
|
elementYPosition <= MARGIN_ABOVE_EDITOR + HEADING_WIDTH
|
|
);
|
|
}
|
|
function isHeadingAboveViewport(element: HTMLElement): boolean {
|
|
const elementYPosition = element?.getClientRects()[0].y;
|
|
return elementYPosition < MARGIN_ABOVE_EDITOR;
|
|
}
|
|
function isHeadingBelowTheTopOfThePage(element: HTMLElement): boolean {
|
|
const elementYPosition = element?.getClientRects()[0].y;
|
|
return elementYPosition >= MARGIN_ABOVE_EDITOR + HEADING_WIDTH;
|
|
}
|
|
|
|
function TableOfContentsList({
|
|
tableOfContents,
|
|
}: {
|
|
tableOfContents: Array<TableOfContentsEntry>;
|
|
}): JSX.Element {
|
|
const [selectedKey, setSelectedKey] = useState('');
|
|
const selectedIndex = useRef(0);
|
|
const [editor] = useLexicalComposerContext();
|
|
|
|
function scrollToNode(key: NodeKey, currIndex: number) {
|
|
editor.getEditorState().read(() => {
|
|
const domElement = editor.getElementByKey(key);
|
|
if (domElement !== null) {
|
|
domElement.scrollIntoView();
|
|
setSelectedKey(key);
|
|
selectedIndex.current = currIndex;
|
|
}
|
|
});
|
|
}
|
|
|
|
useEffect(() => {
|
|
function scrollCallback() {
|
|
if (
|
|
tableOfContents.length !== 0 &&
|
|
selectedIndex.current < tableOfContents.length - 1
|
|
) {
|
|
let currentHeading = editor.getElementByKey(
|
|
tableOfContents[selectedIndex.current][0],
|
|
);
|
|
if (currentHeading !== null) {
|
|
if (isHeadingBelowTheTopOfThePage(currentHeading)) {
|
|
//On natural scroll, user is scrolling up
|
|
while (
|
|
currentHeading !== null &&
|
|
isHeadingBelowTheTopOfThePage(currentHeading) &&
|
|
selectedIndex.current > 0
|
|
) {
|
|
const prevHeading = editor.getElementByKey(
|
|
tableOfContents[selectedIndex.current - 1][0],
|
|
);
|
|
if (
|
|
prevHeading !== null &&
|
|
(isHeadingAboveViewport(prevHeading) ||
|
|
isHeadingBelowTheTopOfThePage(prevHeading))
|
|
) {
|
|
selectedIndex.current--;
|
|
}
|
|
currentHeading = prevHeading;
|
|
}
|
|
const prevHeadingKey = tableOfContents[selectedIndex.current][0];
|
|
setSelectedKey(prevHeadingKey);
|
|
} else if (isHeadingAboveViewport(currentHeading)) {
|
|
//On natural scroll, user is scrolling down
|
|
while (
|
|
currentHeading !== null &&
|
|
isHeadingAboveViewport(currentHeading) &&
|
|
selectedIndex.current < tableOfContents.length - 1
|
|
) {
|
|
const nextHeading = editor.getElementByKey(
|
|
tableOfContents[selectedIndex.current + 1][0],
|
|
);
|
|
if (
|
|
nextHeading !== null &&
|
|
(isHeadingAtTheTopOfThePage(nextHeading) ||
|
|
isHeadingAboveViewport(nextHeading))
|
|
) {
|
|
selectedIndex.current++;
|
|
}
|
|
currentHeading = nextHeading;
|
|
}
|
|
const nextHeadingKey = tableOfContents[selectedIndex.current][0];
|
|
setSelectedKey(nextHeadingKey);
|
|
}
|
|
}
|
|
} else {
|
|
selectedIndex.current = 0;
|
|
}
|
|
}
|
|
let timerId: ReturnType<typeof setTimeout>;
|
|
|
|
function debounceFunction(func: () => void, delay: number) {
|
|
clearTimeout(timerId);
|
|
timerId = setTimeout(func, delay);
|
|
}
|
|
|
|
function onScroll(): void {
|
|
debounceFunction(scrollCallback, 10);
|
|
}
|
|
|
|
document.addEventListener('scroll', onScroll);
|
|
return () => document.removeEventListener('scroll', onScroll);
|
|
}, [tableOfContents, editor]);
|
|
|
|
return (
|
|
<div className="table-of-contents">
|
|
<ul className="headings">
|
|
{tableOfContents.map(([key, text, tag], index) => {
|
|
if (index === 0) {
|
|
return (
|
|
<div className="normal-heading-wrapper" key={key}>
|
|
<div
|
|
className="first-heading"
|
|
onClick={() => scrollToNode(key, index)}
|
|
role="button"
|
|
tabIndex={0}>
|
|
{('' + text).length > 20
|
|
? text.substring(0, 20) + '...'
|
|
: text}
|
|
</div>
|
|
<br />
|
|
</div>
|
|
);
|
|
} else {
|
|
return (
|
|
<div
|
|
className={`normal-heading-wrapper ${
|
|
selectedKey === key ? 'selected-heading-wrapper' : ''
|
|
}`}
|
|
key={key}>
|
|
<div
|
|
onClick={() => scrollToNode(key, index)}
|
|
role="button"
|
|
className={indent(tag)}
|
|
tabIndex={0}>
|
|
<li
|
|
className={`normal-heading ${
|
|
selectedKey === key ? 'selected-heading' : ''
|
|
}
|
|
`}>
|
|
{('' + text).length > 27
|
|
? text.substring(0, 27) + '...'
|
|
: text}
|
|
</li>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
})}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function TableOfContentsPlugin() {
|
|
return (
|
|
<LexicalTableOfContents>
|
|
{(tableOfContents) => {
|
|
return <TableOfContentsList tableOfContents={tableOfContents} />;
|
|
}}
|
|
</LexicalTableOfContents>
|
|
);
|
|
}
|