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 2c6d502..2015520 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 @@ -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.", 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..d25d4e3 --- /dev/null +++ b/apps/client/src/features/editor/components/table-of-contents/table-of-contents-view.tsx @@ -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 ( + + + {headings.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} + + + + ))} + + + ); +} 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/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..127ecac --- /dev/null +++ b/packages/editor-ext/src/lib/table-of-contents.ts @@ -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 { + tableOfContents: { + setTableOfContents: () => ReturnType; + unsetTableOfContents: () => ReturnType; + toggleTableOfContents: () => 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({ "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(), + // }; + // }, +});