diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts
index 8e6cdb1..267fc1e 100644
--- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts
+++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts
@@ -9,6 +9,7 @@ import {
IconInfoCircle,
IconList,
IconListNumbers,
+ IconListTree,
IconMath,
IconMathFunction,
IconMovie,
@@ -96,6 +97,20 @@ const CommandGroups: SlashMenuGroupedItemsType = {
.run();
},
},
+ {
+ title: "Table of Contents",
+ description: "Create a table of contents.",
+ searchTerms: ["table", "contents", "list"],
+ icon: IconListTree,
+ command: ({ editor, range }: CommandProps) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .toggleTableOfContents()
+ .run();
+ },
+ },
{
title: "Bullet list",
description: "Create a simple bullet list.",
diff --git a/apps/client/src/features/editor/components/table-of-contents/table-of-contents-menu.tsx b/apps/client/src/features/editor/components/table-of-contents/table-of-contents-menu.tsx
new file mode 100644
index 0000000..dcd4dd0
--- /dev/null
+++ b/apps/client/src/features/editor/components/table-of-contents/table-of-contents-menu.tsx
@@ -0,0 +1,124 @@
+import { BubbleMenu as BaseBubbleMenu, findParentNode, posToDOMRect } from "@tiptap/react";
+import React, { useCallback } from "react";
+import { sticky } from "tippy.js";
+import { Node as PMNode } from "prosemirror-model";
+import { EditorMenuProps, ShouldShowProps } from "@/features/editor/components/table/types/types.ts";
+import {
+ ActionIcon,
+ DividerVariant,
+ Group,
+ SegmentedControl,
+ Select,
+ Tooltip,
+ Text,
+ Checkbox,
+ Card,
+ Fieldset,
+} from "@mantine/core";
+import { IconLayoutAlignCenter, IconLayoutAlignLeft, IconLayoutAlignRight } from "@tabler/icons-react";
+import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx";
+
+export function TableOfContentsMenu({ editor }: EditorMenuProps) {
+ const shouldShow = useCallback(
+ ({ state }: ShouldShowProps) => {
+ if (!state) {
+ return false;
+ }
+
+ return editor.isActive("tableOfContents");
+ },
+ [editor]
+ );
+
+ const getReferenceClientRect = useCallback(() => {
+ const { selection } = editor.state;
+ const predicate = (node: PMNode) => node.type.name === "tableOfContents";
+ const parent = findParentNode(predicate)(selection);
+
+ if (parent) {
+ const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
+ return dom.getBoundingClientRect();
+ }
+
+ return posToDOMRect(editor.view, selection.from, selection.to);
+ }, [editor]);
+
+ const setDividerType = useCallback(
+ (type: DividerVariant) => {
+ editor.chain().focus(undefined, { scrollIntoView: false }).setDividerType(type).run();
+ },
+ [editor]
+ );
+
+ const setTableType = useCallback(
+ (type: "Contents" | "Child Pages") => {
+ editor.chain().focus(undefined, { scrollIntoView: false }).setTableType(type).run();
+ },
+ [editor]
+ );
+
+ const setPageIcons = useCallback(
+ (icons: boolean) => {
+ editor.chain().focus(undefined, { scrollIntoView: false }).setPageIcons(icons).run();
+ },
+ [editor]
+ );
+
+ return (
+
+
+
+ );
+}
+
+export default TableOfContentsMenu;
diff --git a/apps/client/src/features/editor/components/table-of-contents/table-of-contents-view.tsx b/apps/client/src/features/editor/components/table-of-contents/table-of-contents-view.tsx
new file mode 100644
index 0000000..1e54d12
--- /dev/null
+++ b/apps/client/src/features/editor/components/table-of-contents/table-of-contents-view.tsx
@@ -0,0 +1,199 @@
+import { JSONContent, NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
+import {
+ IconAlertTriangleFilled,
+ IconCircleCheckFilled,
+ IconCircleXFilled,
+ IconInfoCircleFilled,
+} from "@tabler/icons-react";
+import { Alert, Divider, Group, Stack, Title, UnstyledButton, Text, DividerVariant } from "@mantine/core";
+import { CalloutType } from "@docmost/editor-ext";
+import React, { useEffect, useMemo, useState } from "react";
+import { TextSelection } from "@tiptap/pm/state";
+import classes from "./table-of-contents.module.css";
+import clsx from "clsx";
+import { useGetRootSidebarPagesQuery, usePageQuery } from "@/features/page/queries/page-query";
+import { IPage, SidebarPagesParams } from "@/features/page/types/page.types";
+import { queryClient } from "@/main";
+import { getSidebarPages } from "@/features/page/services/page-service";
+import { useToggle } from "@mantine/hooks";
+import { useNavigate, useParams } from "react-router-dom";
+import { buildPageUrl } from "@/features/page/page.utils";
+import { string } from "zod";
+
+export default function TableOfContentsView(props: NodeViewProps) {
+ const { node, editor, selected } = props;
+
+ const { dividerType, tableType, icons } = node.attrs as {
+ dividerType: DividerVariant & "none";
+ tableType: "Contents" | "Child Pages";
+ icons: boolean;
+ };
+
+ const pageId = editor.storage?.pageId;
+
+ const { data: page } = usePageQuery({
+ pageId: pageId,
+ });
+
+ const { pageSlug, spaceSlug } = useParams();
+
+ const navigate = useNavigate();
+
+ const [childPages, setChildPages] = useState([]);
+ const [headings, setHeadings] = useState([]);
+
+ const fetchChildren = async (params: SidebarPagesParams) => {
+ return await queryClient.fetchQuery({
+ queryKey: ["toc-child-pages", params],
+ queryFn: () => getSidebarPages(params),
+ staleTime: 10 * 60 * 1000,
+ });
+ };
+
+ // Max depth to prevent infinite recursion errors
+ const MAX_RECURSION_DEPTH = 10;
+
+ const fetchAllChildren = async (
+ currentPage: IPage,
+ pages: (IPage & { depth: number })[],
+ depth: number = 0
+ ): Promise => {
+ // Prevent infinite recursion
+ if (depth > MAX_RECURSION_DEPTH) {
+ console.warn("Max recursion depth reached");
+ return;
+ }
+
+ const params: SidebarPagesParams = {
+ pageId: currentPage.id,
+ spaceId: currentPage.spaceId,
+ };
+
+ const result = await fetchChildren(params);
+ const children = result.items;
+
+ // Store the children in the relationships map
+ for (let child of children) pages.push({ ...child, depth });
+
+ // Use requestIdleCallback to allow the browser to perform other tasks
+ for (const child of children) {
+ if (child.hasChildren) {
+ await new Promise((resolve) =>
+ requestIdleCallback(() => {
+ fetchAllChildren(child, pages, depth + 1).then(resolve);
+ })
+ );
+ }
+ }
+ };
+
+ useEffect(() => {
+ if (!page) return;
+
+ (async () => {
+ if (tableType == "Child Pages") {
+ // Initialize the child pagse array
+ const pages: (IPage & { depth: number })[] = [];
+
+ // Fetch all children recursively
+ await fetchAllChildren(page, pages);
+
+ const tocChildPages: JSX.Element[] = pages.map((value, index) => (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.button != 0) return;
+
+ const pageSlug = buildPageUrl(spaceSlug, value.slugId, value.title);
+
+ // opted to not use "replace" so that browser back button workers properly
+ navigate(pageSlug);
+ }}
+ >
+
+
+ {icons ? value.icon : ""} {value.title}
+
+ {dividerType != "none" && }
+
+
+ ));
+
+ setChildPages(tocChildPages);
+ } else {
+ const contentHeadings = editor.getJSON().content?.filter((c) => c.type == "heading");
+
+ const tocHeadings: JSX.Element[] = contentHeadings.map((value, index) => (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.button != 0) return;
+
+ if (editor) {
+ const headings = editor.view.dom.querySelectorAll("h1, h2, h3, h4, h5, h6");
+ const clickedHeading = headings[index];
+
+ // find selected heading position in DOM relative to editors view
+ const pos = editor.view.posAtDOM(clickedHeading, 0);
+
+ // start new state transaction on editors view state
+ const tr = editor.view.state.tr;
+ // move editor cursor to heading
+ tr.setSelection(new TextSelection(tr.doc.resolve(pos)));
+ editor.view.dispatch(tr);
+ editor.view.focus();
+
+ window.scrollTo({
+ top:
+ clickedHeading.getBoundingClientRect().top -
+ // subtract half of elements height to avoid viewport clipping
+ clickedHeading.getBoundingClientRect().height / 2 -
+ // substract headers height so that heading is visible after scroll.
+ // getComputedStyles is not evaluating "--app-shell-header-height" so have hardcoded pixels.
+ 45 * 2 +
+ window.scrollY,
+ behavior: "smooth",
+ });
+ }
+ }}
+ >
+
+ {value.content?.at(0).text}
+ {dividerType != "none" && }
+
+
+ ));
+
+ setHeadings(tocHeadings);
+ }
+ })();
+ }, [page == undefined, dividerType, tableType, icons]);
+
+ return (
+
+
+ e.preventDefault()}
+ >
+ {tableType == "Contents" && headings}
+
+ {tableType == "Child Pages" && childPages}
+
+
+ );
+}
diff --git a/apps/client/src/features/editor/components/table-of-contents/table-of-contents.module.css b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.module.css
new file mode 100644
index 0000000..9ce2f70
--- /dev/null
+++ b/apps/client/src/features/editor/components/table-of-contents/table-of-contents.module.css
@@ -0,0 +1,9 @@
+.divider {
+ flex: 1 0 0;
+}
+
+.heading {
+ :hover {
+ background-color: var(--mantine-color-default-hover);
+ }
+}
\ No newline at end of file
diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts
index 1123c30..f185c7c 100644
--- a/apps/client/src/features/editor/extensions/extensions.ts
+++ b/apps/client/src/features/editor/extensions/extensions.ts
@@ -35,6 +35,7 @@ import {
CustomCodeBlock,
Drawio,
Excalidraw,
+ TableofContents,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -54,6 +55,7 @@ import CodeBlockView from "@/features/editor/components/code-block/code-block-vi
import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import plaintext from "highlight.js/lib/languages/plaintext";
+import TableOfContentsView from "../components/table-of-contents/table-of-contents-view";
const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
@@ -162,6 +164,9 @@ export const mainExtensions = [
Excalidraw.configure({
view: ExcalidrawView,
}),
+ TableofContents.configure({
+ view: TableOfContentsView
+ })
] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx
index 5030468..5ff4afc 100644
--- a/apps/client/src/features/editor/page-editor.tsx
+++ b/apps/client/src/features/editor/page-editor.tsx
@@ -39,6 +39,7 @@ import {
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
import DrawioMenu from "./components/drawio/drawio-menu";
+import TableOfContentsMenu from "./components/table-of-contents/table-of-contents-menu";
interface PageEditorProps {
pageId: string;
@@ -175,6 +176,7 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
+
)}
diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts
index 80e2035..64d7a04 100644
--- a/packages/editor-ext/src/index.ts
+++ b/packages/editor-ext/src/index.ts
@@ -14,4 +14,4 @@ export * from "./lib/attachment";
export * from "./lib/custom-code-block"
export * from "./lib/drawio";
export * from "./lib/excalidraw";
-
+export * from "./lib/table-of-contents";
diff --git a/packages/editor-ext/src/lib/table-of-contents.ts b/packages/editor-ext/src/lib/table-of-contents.ts
new file mode 100644
index 0000000..7c0e740
--- /dev/null
+++ b/packages/editor-ext/src/lib/table-of-contents.ts
@@ -0,0 +1,115 @@
+import { Node, findChildren, findParentNode, mergeAttributes, wrappingInputRule } from "@tiptap/core";
+import { icon, setAttributes } from "./utils";
+import { ReactNodeViewRenderer } from "@tiptap/react";
+
+declare module "@tiptap/core" {
+ interface Commands {
+ tableOfContents: {
+ setTableOfContents: () => ReturnType;
+ unsetTableOfContents: () => ReturnType;
+ toggleTableOfContents: () => ReturnType;
+ setDividerType: (type: "solid" | "dashed" | "dotted" | "none") => ReturnType;
+ setTableType: (type: "Contents" | "Child Pages") => ReturnType;
+ setPageIcons: (icons: boolean) => ReturnType;
+ };
+ }
+}
+
+export interface TableofContentsOptions {
+ HTMLAttributes: Record;
+ view: any;
+}
+
+export const TableofContents = Node.create({
+ name: "tableOfContents",
+
+ content: "block+",
+ inline: false,
+ group: "block",
+ isolating: true,
+ atom: true,
+ defining: true,
+
+ addOptions() {
+ return {
+ HTMLAttributes: {},
+ view: null,
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: `div[data-type="${this.name}"]`,
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ["div", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
+ },
+
+ addAttributes() {
+ return {
+ dividerType: {
+ default: "solid",
+ parseHTML: (element) => element.getAttribute("data-divider-type"),
+ renderHTML: (attributes) => ({
+ "data-divider-type": attributes.dividerType,
+ }),
+ },
+ tableType: {
+ default: "Contents",
+ parseHTML: (element) => element.getAttribute("data-table-type"),
+ renderHTML: (attributes) => ({
+ "data-table-type": attributes.tableType,
+ }),
+ },
+ icons: {
+ default: true,
+ parseHTML: (element) => element.getAttribute("data-page-icons"),
+ renderHTML: (attributes) => ({
+ "data-page-icons": attributes.tableType,
+ }),
+ },
+ };
+ },
+
+ addNodeView() {
+ return ReactNodeViewRenderer(this.options.view);
+ },
+
+ addCommands() {
+ return {
+ setTableOfContents:
+ () =>
+ ({ commands }) =>
+ commands.setNode(this.name),
+
+ unsetTableOfContents:
+ () =>
+ ({ commands }) =>
+ commands.lift(this.name),
+
+ toggleTableOfContents:
+ () =>
+ ({ commands }) =>
+ commands.toggleWrap(this.name),
+
+ setDividerType:
+ (type) =>
+ ({ commands }) =>
+ commands.updateAttributes("tableOfContents", { dividerType: type }),
+
+ setTableType:
+ (type) =>
+ ({ commands }) =>
+ commands.updateAttributes("tableOfContents", { tableType: type }),
+
+ setPageIcons:
+ (icons) =>
+ ({ commands }) =>
+ commands.updateAttributes("tableOfContents", { icons: icons }),
+ };
+ },
+});