mirror of
https://github.com/docmost/docmost.git
synced 2025-11-18 21:01:08 +10:00
feat: add Table of contents (#981)
* chore: add table of contents module * refactor * lint * null check --------- Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
This commit is contained in:
@ -0,0 +1,54 @@
|
||||
.headerPadding {
|
||||
display: none;
|
||||
top: calc(
|
||||
var(--app-shell-header-offset, 0rem) + var(--app-shell-header-height, 0rem)
|
||||
);
|
||||
}
|
||||
|
||||
.link {
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
word-wrap: break-word;
|
||||
background-color: transparent;
|
||||
color: var(--mantine-color-text);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
line-height: var(--mantine-line-height-sm);
|
||||
padding: 6px;
|
||||
border-top-right-radius: var(--mantine-radius-sm);
|
||||
border-bottom-right-radius: var(--mantine-radius-sm);
|
||||
border: none;
|
||||
|
||||
@mixin hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-2),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
}
|
||||
|
||||
@media (max-width: $mantine-breakpoint-sm) {
|
||||
& {
|
||||
border: none !important;
|
||||
padding-left: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.linkActive {
|
||||
font-weight: 500;
|
||||
border-left-color: light-dark(
|
||||
var(--mantine-color-grey-5),
|
||||
var(--mantine-color-grey-3)
|
||||
);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-1));
|
||||
|
||||
&,
|
||||
&:hover {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-3),
|
||||
var(--mantine-color-dark-5)
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,165 @@
|
||||
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<typeof useEditor>;
|
||||
};
|
||||
|
||||
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<HeadingLink[]>(
|
||||
(acc, item) => {
|
||||
const label = item.node.textContent;
|
||||
const level = Number(item.node.attrs.level);
|
||||
if (label.length && level <= 3) {
|
||||
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<TableOfContentsProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [links, setLinks] = useState<HeadingLink[]>([]);
|
||||
const [headingDOMNodes, setHeadingDOMNodes] = useState<HTMLElement[]>([]);
|
||||
const [activeElement, setActiveElement] = useState<HTMLElement | null>(null);
|
||||
const headerPaddingRef = useRef<HTMLDivElement | null>(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();
|
||||
}, []);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Text size="sm">
|
||||
{t("Add headings (H1, H2, H3) to generate a table of contents.")}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
{links.map((item, idx) => (
|
||||
<Box<"button">
|
||||
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}
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
<div ref={headerPaddingRef} className={classes.headerPadding} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user