import { NodePos, useEditor } from "@tiptap/react"; import { TextSelection } from "@tiptap/pm/state"; import React, { FC, useEffect, useRef, useState } from "react"; import classes from "./table-of-contents.module.css"; import clsx from "clsx"; import { Box, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; type TableOfContentsProps = { editor: ReturnType; isShare?: boolean; }; export type HeadingLink = { label: string; level: number; element: HTMLElement; position: number; }; const recalculateLinks = (nodePos: NodePos[]) => { const nodes: HTMLElement[] = []; const links: HeadingLink[] = Array.from(nodePos).reduce( (acc, item) => { const label = item.node.textContent; const level = Number(item.node.attrs.level); if (label.length && level <= 4) { acc.push({ label, level, element: item.element, //@ts-ignore position: item.resolvedPos.pos, }); nodes.push(item.element); } return acc; }, [], ); return { links, nodes }; }; export const TableOfContents: FC = (props) => { const { t } = useTranslation(); const [links, setLinks] = useState([]); const [headingDOMNodes, setHeadingDOMNodes] = useState([]); const [activeElement, setActiveElement] = useState(null); const headerPaddingRef = useRef(null); const handleScrollToHeading = (position: number) => { const { view } = props.editor; const headerOffset = parseInt( window.getComputedStyle(headerPaddingRef.current).getPropertyValue("top"), ); const { node } = view.domAtPos(position); const element = node as HTMLElement; const scrollPosition = element.getBoundingClientRect().top + window.scrollY - headerOffset; window.scrollTo({ top: scrollPosition, behavior: "smooth", }); const tr = view.state.tr; tr.setSelection(new TextSelection(tr.doc.resolve(position))); view.dispatch(tr); view.focus(); }; const handleUpdate = () => { const result = recalculateLinks(props.editor?.$nodes("heading")); setLinks(result.links); setHeadingDOMNodes(result.nodes); }; useEffect(() => { props.editor?.on("update", handleUpdate); return () => { props.editor?.off("update", handleUpdate); }; }, [props.editor]); useEffect( () => { handleUpdate(); }, props.isShare ? [props.editor] : [], ); useEffect(() => { try { const observeHandler = (entries: IntersectionObserverEntry[]) => { entries.forEach((entry) => { if (entry.isIntersecting) { setActiveElement(entry.target as HTMLElement); } }); }; let headerOffset = 0; if (headerPaddingRef.current) { headerOffset = parseInt( window .getComputedStyle(headerPaddingRef.current) .getPropertyValue("top"), ); } const observerOptions: IntersectionObserverInit = { rootMargin: `-${headerOffset}px 0px -85% 0px`, threshold: 0, root: null, }; const observer = new IntersectionObserver( observeHandler, observerOptions, ); headingDOMNodes.forEach((heading) => { observer.observe(heading); }); return () => { headingDOMNodes.forEach((heading) => { observer.unobserve(heading); }); }; } catch (err) { console.log(err); } }, [headingDOMNodes, props.editor]); if (!links.length) { return ( <> {!props.isShare && ( {t("Add headings (H1, H2, H3) to generate a table of contents.")} )} {props.isShare && ( {t("No table of contents.")} )} ); } return ( <> {props.isShare && ( {t("Table of contents")} )}
{links.map((item, idx) => ( component="button" onClick={() => handleScrollToHeading(item.position)} key={idx} className={clsx(classes.link, { [classes.linkActive]: item.element === activeElement, })} style={{ paddingLeft: `calc(${item.level} * var(--mantine-spacing-md))`, }} > {item.label} ))}
); };