Renders a table of contents element with clickable links to headings in the document.
The fastest way to add table of contents functionality is with the TocKit, which includes pre-configured TocPlugin with the Plate UI component.
'use client';
import { cva } from 'class-variance-authority';
import type { NodeKey } from 'platejs';
import { BaseDetailsPlugin } from 'platejs/details';
import {
type EditorElementProps,
NavigationFeedbackPlugin,
EditorElement,
useEditor,
useEditorSelector,
useElementSelected,
} from 'platejs/react';
import { TocPlugin } from 'platejs/toc/react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
const headingItemVariants = cva(
'block h-auto w-full cursor-pointer truncate rounded-none px-0.5 py-1.5 text-left font-medium text-muted-foreground underline decoration-[0.5px] underline-offset-4 hover:bg-accent hover:text-foreground',
{
variants: {
depth: {
1: 'pl-0.5',
2: 'pl-[26px]',
3: 'pl-[50px]',
},
},
}
);
export function TocElement({
isScroll = true,
topOffset = 80,
...props
}: EditorElementProps<typeof TocPlugin> & {
isScroll?: boolean;
topOffset?: number;
}) {
const editor = useEditor();
const navigation = useEditor().plugin(NavigationFeedbackPlugin);
const details = editor.plugin(BaseDetailsPlugin);
const openDetailsHolding = (key: NodeKey) => {
const path = (details.installed && editor.read.nodes.path(key)) || [];
const closedKeys = path.flatMap((childIndex, depth) => {
const ancestor = path.slice(0, depth);
const isTitle = childIndex === 0;
const detailsKey =
depth > 0 &&
!isTitle &&
editor.read.nodes.get(ancestor, { type: BaseDetailsPlugin })
? editor.key(ancestor)
: undefined;
return detailsKey && !details.store.get('openKeys').has(detailsKey)
? [detailsKey]
: [];
});
for (const detailsKey of closedKeys) {
details.api.setOpen(detailsKey, true);
}
return closedKeys.length > 0;
};
const selected = useElementSelected({ mode: 'text' });
const headingList = useEditorSelector(
(innerEditor) => innerEditor.plugin(TocPlugin).read.headings(),
{
equalityFn: (previous, next) =>
previous !== null &&
previous.length === next.length &&
previous.every((heading, index) => {
const nextHeading = next[index];
return (
heading.key === nextHeading?.key &&
heading.depth === nextHeading.depth &&
heading.title === nextHeading.title &&
heading.type === nextHeading.type
);
}),
shouldUpdate: (change) => !change || change.changed.hasAny('document'),
}
);
return (
<EditorElement {...props} className="mb-1 p-0">
<div
className="rounded-sm data-[selected=true]:bg-primary/10"
contentEditable={false}
data-selected={selected}
>
{headingList.length > 0 ? (
headingList.map((item) => (
<Button
key={item.key}
variant="ghost"
className={headingItemVariants({
depth: item.depth as 1 | 2 | 3,
})}
onClick={(event) => {
event.preventDefault();
const navigate = () => {
const entry = editor.read.nodes.get(item.key);
if (!entry) return;
const element = editor.api.dom.resolveDOMNode(entry[0]);
if (!element) return;
element.style.scrollMarginTop = `${topOffset}px`;
if (isScroll) {
editor.api.dom.scrollIntoView(entry[1], {
behavior: 'smooth',
block: 'start',
scrollMode: 'always',
});
}
navigation.api.flashTarget({
key: item.key,
attributes: {
className: 'rounded-md bg-(--color-highlight)',
},
});
};
if (openDetailsHolding(item.key)) {
requestAnimationFrame(navigate);
} else {
navigate();
}
}}
>
{item.title}
</Button>
))
) : (
<div className="text-sm text-gray-500">
Create a heading to display the table of contents.
</div>
)}
</div>
{props.children}
</EditorElement>
);
}
export const TocKit = [
TocPlugin.configure({
component: TocElement,
}),
];'use client';
import { cva } from 'class-variance-authority';
import type { NodeKey } from 'platejs';
import { BaseDetailsPlugin } from 'platejs/details';
import {
type EditorElementProps,
NavigationFeedbackPlugin,
EditorElement,
useEditor,
useEditorSelector,
useElementSelected,
} from 'platejs/react';
import { TocPlugin } from 'platejs/toc/react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
TocElement: Renders table of contents elements.Add the kit to your plugins:
import { createEditor } from 'platejs/react';
import { TocKit } from '@/components/editor/toc';
const editor = createEditor({
plugins: [
// ...otherPlugins,
...TocKit,
],
});import { createEditor } from 'platejs/react';
import { TocKit } from '@/components/editor/toc';
const editor = createEditor({
Include TocPlugin and HeadingPlugin in your Plate plugins array when creating the editor.
import { TocPlugin } from 'platejs/toc/react';
import { HeadingPlugin } from 'platejs/react';
import { createEditor } from 'platejs/react';
const editor = createEditor({
plugins: [
// ...otherPlugins,
HeadingPlugin,
TocPlugin,
],
});import { TocPlugin } from 'platejs/toc/react';
import { HeadingPlugin }
Configure the TocPlugin with custom component and scroll options.
import { TocPlugin } from 'platejs/toc/react';
import { HeadingPlugin } from 'platejs/react';
import { createEditor } from 'platejs/react';
import { TocElement } from '@/components/editor/toc';
import { HeadingElement } from '@/components/editor/heading';
const editor = createEditor({
plugins: [
// ...otherPlugins,
HeadingPlugin.configure({ component: HeadingElement }),
TocPlugin.configure({
component: (props) => <TocElement {...props} topOffset={80} isScroll
.configure({ component }): Assigns TocElement to render table of contents elements.TocElement.topOffset: Sets the top offset when scrolling to headings.TocElement.isScroll: Enables scrolling behavior to headings.You can add this item to the Insert Toolbar Button to insert table of contents elements:
{
icon: <TableOfContentsIcon />,
label: 'Table of contents',
value: PLUGINS.toc,
}{
icon: <TableOfContentsIcon />,
label: 'Table of contents',
value: PLUGINS.toc,
}Use EditorContainer for the editor's scrolling region.
For a custom layout, render EditorContainer inside <EditorRoot> and apply your
scrolling styles to it. EditorContainer registers that region with the editor.
import { EditorContainer, EditorContent } from 'platejs/react';
function Layout() {
return (
<EditorContainer className="h-[600px] overflow-y-auto">
<EditorContent />
</EditorContainer>
);
}import { EditorContainer, EditorContent } from 'platejs/react';
function Layout() {
return (
Plugin for generating table of contents.
Scroll options belong to the copied view. The heading query belongs to TocPlugin.
Insert table of contents element.
import { BaseTocPlugin } from 'platejs/toc';
editor.plugin(BaseTocPlugin).update.insert();import { BaseTocPlugin } from 'platejs/toc';
editor.plugin(BaseTocPlugin).update.insert();The copied toc component owns scrolling and
navigation feedback. Keep those product-facing interaction choices beside the
renderer; the package plugin owns heading discovery through
editor.plugin(TocPlugin).read.headings().
import { TocPlugin } from 'platejs/toc/react';
import { HeadingPlugin } from 'platejs/react';
import { createEditor } from 'platejs/react';
import { TocElement } from '@/components/editor/toc';
import { HeadingElement } from '@/components/editor/heading';
const editor = createEditor({
plugins: [
// ...otherPlugins,
HeadingPlugin.configure({ component: HeadingElement }),
TocPlugin.configure({
component: (props) => <TocElement {...props} topOffset={80} isScroll />,
}),
],
});