Table of Contents Component

MVP table of contents. Will add a component menu with style choices soon.
This commit is contained in:
Ryan Palmer
2024-09-03 09:16:12 +10:00
parent 1141796f24
commit 1f153dfd55
6 changed files with 193 additions and 1 deletions

View File

@ -10,6 +10,7 @@ import {
IconLetterY,
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.",

View File

@ -0,0 +1,74 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import {
IconAlertTriangleFilled,
IconCircleCheckFilled,
IconCircleXFilled,
IconInfoCircleFilled,
} from "@tabler/icons-react";
import { Alert, Divider, Group, Stack, Title, UnstyledButton, Text } from "@mantine/core";
// import classes from "./callout.module.css";
import { CalloutType } from "@docmost/editor-ext";
import React, { useMemo } from "react";
import { TextSelection } from "@tiptap/pm/state";
import classes from "./table-of-contents.module.css";
import clsx from "clsx";
export default function TableOfContentsView(props: NodeViewProps) {
const { node, editor, selected } = props;
const { type } = node.attrs;
const headings = editor.getJSON().content?.filter((c) => c.type == "heading");
return (
<NodeViewWrapper>
<Stack gap={0} className={clsx(selected ? "ProseMirror-selectednode" : "")} contentEditable={false}>
{headings.map((value, index) => (
<UnstyledButton
key={`toc-${index}`}
className={classes.heading}
style={{
marginLeft: `calc(${value.attrs?.level} * var(--mantine-spacing-md))`,
}}
onClick={(e) => {
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",
});
}
}}
>
<Group>
<Text m={6}>{value.content?.at(0).text}</Text>
<Divider className={classes.divider} />
</Group>
</UnstyledButton>
))}
</Stack>
</NodeViewWrapper>
);
}

View File

@ -0,0 +1,9 @@
.divider {
flex: 1 0 0;
}
.heading {
:hover {
background-color: var(--mantine-color-default-hover);
}
}

View File

@ -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[];

View File

@ -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";

View File

@ -0,0 +1,89 @@
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<ReturnType> {
tableOfContents: {
setTableOfContents: () => ReturnType;
unsetTableOfContents: () => ReturnType;
toggleTableOfContents: () => ReturnType;
};
}
}
export interface TableofContentsOptions {
HTMLAttributes: Record<string, any>;
view: any;
}
export const TableofContents = Node.create<TableofContentsOptions>({
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({ "data-type": this.name }, this.options.HTMLAttributes, HTMLAttributes), 0];
},
addNodeView() {
return ReactNodeViewRenderer(this.options.view);
},
addCommands() {
return {
setTableOfContents:
() =>
({ commands }) => {
return commands.setNode(this.name);
},
unsetTableOfContents:
() =>
({ commands }) => {
return commands.lift(this.name);
},
toggleTableOfContents:
() =>
({ commands }) => {
return commands.toggleWrap(this.name);
},
};
},
// addInputRules() {
// return [
// wrappingInputRule({
// find: /^:::details\s$/,
// type: this.type,
// }),
// ];
// },
// addKeyboardShortcuts() {
// return {
// "Mod-Alt-d": () => this.editor.commands.toggleDetails(),
// };
// },
});