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 f56d7f04..03f39698 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 @@ -20,6 +20,7 @@ import { IconCalendar, IconAppWindow, IconSitemap, + IconAlignLeft2, } from "@tabler/icons-react"; import { CommandProps, @@ -153,6 +154,14 @@ const CommandGroups: SlashMenuGroupedItemsType = { command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(), }, + { + title: "Table of contents", + description: "Insert table of contents", + searchTerms: ["toc"], + icon: IconAlignLeft2, + command: ({ editor, range }: CommandProps) => + editor.chain().focus().deleteRange(range).insertTableOfContents().run(), + }, { title: "Image", description: "Upload any image from your device.", diff --git a/apps/client/src/features/editor/components/table-of-contents/table-of-contents-nodeview.module.css b/apps/client/src/features/editor/components/table-of-contents/table-of-contents-nodeview.module.css new file mode 100644 index 00000000..acff0428 --- /dev/null +++ b/apps/client/src/features/editor/components/table-of-contents/table-of-contents-nodeview.module.css @@ -0,0 +1,61 @@ +.header { +} + +.container { +} + +.emptyState { +} + +.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; + } +} + +.leftBorder { + border-left: 1px solid + light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); +} diff --git a/apps/client/src/features/editor/components/table-of-contents/table-of-contents-nodeview.tsx b/apps/client/src/features/editor/components/table-of-contents/table-of-contents-nodeview.tsx new file mode 100644 index 00000000..71cdc552 --- /dev/null +++ b/apps/client/src/features/editor/components/table-of-contents/table-of-contents-nodeview.tsx @@ -0,0 +1,53 @@ +import { Editor as CoreEditor } from "@tiptap/core"; +import { TableOfContentsStorage } from "@tiptap/extension-table-of-contents"; +import { NodeViewWrapper, useEditorState } from "@tiptap/react"; +import { memo } from "react"; +import { clsx } from "clsx"; +import classes from "./table-of-contents-nodeview.module.css"; + +export type TableOfContentsProps = { + editor: CoreEditor; + onItemClick?: () => void; +}; + +export const TableOfContentsNodeview = memo( + ({ editor, onItemClick }: TableOfContentsProps) => { + const content = useEditorState({ + editor, + selector: (ctx) => + (ctx.editor.storage.tableOfContents as TableOfContentsStorage)?.content, + }); + + return ( + + + Table of contents + {content.length > 0 ? ( + + {content.map((item) => ( + + {item.itemIndex}. {item.textContent} + + ))} + + ) : ( + + Start adding headlines to your document … + + )} + + + ); + }, +); + +TableOfContentsNodeview.displayName = "TableOfContentsNodeview"; diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 92ed8c29..8c843be1 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -11,6 +11,7 @@ import { Typography } from "@tiptap/extension-typography"; import { TextStyle } from "@tiptap/extension-text-style"; import { Color } from "@tiptap/extension-color"; import SlashCommand from "@/features/editor/extensions/slash-command"; +import { TableOfContents as TiptapTableOfContents } from "@tiptap/extension-table-of-contents"; import { Collaboration } from "@tiptap/extension-collaboration"; import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor"; import { HocuspocusProvider } from "@hocuspocus/provider"; @@ -40,6 +41,7 @@ import { Mention, Subpages, TableDndExtension, + TableOfContentsNode, } from "@docmost/editor-ext"; import { randomElement, @@ -78,6 +80,7 @@ import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboa import EmojiCommand from "./emoji-command"; import { CharacterCount } from "@tiptap/extension-character-count"; import { countWords } from "alfaaz"; +import { TableOfContentsNodeview } from "@/features/editor/components/table-of-contents/table-of-contents-nodeview.tsx"; const lowlight = createLowlight(common); lowlight.register("mermaid", plaintext); @@ -228,19 +231,23 @@ export const mainExtensions = [ SearchAndReplace.extend({ addKeyboardShortcuts() { return { - 'Mod-f': () => { + "Mod-f": () => { const event = new CustomEvent("openFindDialogFromEditor", {}); document.dispatchEvent(event); return true; }, - 'Escape': () => { + Escape: () => { const event = new CustomEvent("closeFindDialogFromEditor", {}); document.dispatchEvent(event); return true; }, - } + }; }, }).configure(), + TiptapTableOfContents, + TableOfContentsNode.configure({ + view: TableOfContentsNodeview, + }), ] as any; type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[]; diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 008bfa31..e1b9e164 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -33,6 +33,7 @@ import { Embed, Mention, Subpages, + TableOfContentsNode, } from '@docmost/editor-ext'; import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; @@ -80,6 +81,7 @@ export const tiptapExtensions = [ Embed, Mention, Subpages, + TableOfContentsNode, ] as any; export function jsonToHtml(tiptapJson: any) { diff --git a/apps/server/src/ee b/apps/server/src/ee index c92deeea..9957da11 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit c92deeeac1412129fddbc9b16c1e9c30f75e776d +Subproject commit 9957da11d68b09fc20752afafdcde6863d5e9f60 diff --git a/package.json b/package.json index 9177f81e..278d92fc 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@tiptap/extension-table": "^2.10.3", "@tiptap/extension-table-cell": "^2.10.3", "@tiptap/extension-table-header": "^2.10.3", + "@tiptap/extension-table-of-contents": "2.26.3", "@tiptap/extension-table-row": "^2.10.3", "@tiptap/extension-task-item": "^2.10.3", "@tiptap/extension-task-list": "^2.10.3", diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index a0efaa1b..ebc1773e 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -20,3 +20,4 @@ export * from "./lib/markdown"; export * from "./lib/search-and-replace"; export * from "./lib/embed-provider"; export * from "./lib/subpages"; +export * from "./lib/table-of-contents-node"; diff --git a/packages/editor-ext/src/lib/table-of-contents-node/index.ts b/packages/editor-ext/src/lib/table-of-contents-node/index.ts new file mode 100644 index 00000000..ecdd4364 --- /dev/null +++ b/packages/editor-ext/src/lib/table-of-contents-node/index.ts @@ -0,0 +1 @@ +export { TableOfContentsNode } from "./table-of-contents"; diff --git a/packages/editor-ext/src/lib/table-of-contents-node/table-of-contents.ts b/packages/editor-ext/src/lib/table-of-contents-node/table-of-contents.ts new file mode 100644 index 00000000..4272c178 --- /dev/null +++ b/packages/editor-ext/src/lib/table-of-contents-node/table-of-contents.ts @@ -0,0 +1,52 @@ +import { Node } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; + +export interface TableOfContentsNodeOptions { + HTMLAttributes: Record; + view: any; +} + +declare module "@tiptap/core" { + interface Commands { + tableOfContentsNode: { + insertTableOfContents: () => ReturnType; + }; + } +} + +export const TableOfContentsNode = Node.create({ + name: "tableOfContentsNode", + group: "block", + atom: true, + selectable: true, + draggable: true, + inline: false, + + parseHTML() { + return [ + { + tag: 'div[data-type="table-of-content"]', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["div", { ...HTMLAttributes, "data-type": "table-of-content" }]; + }, + + addNodeView() { + return ReactNodeViewRenderer(this.options.view); + }, + + addCommands() { + return { + insertTableOfContents: + () => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + }); + }, + }; + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8bf0417..9a20e67a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: '@tiptap/extension-table-header': specifier: ^2.10.3 version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0)) + '@tiptap/extension-table-of-contents': + specifier: 2.26.3 + version: 2.26.3(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0) '@tiptap/extension-table-row': specifier: ^2.10.3 version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0)) @@ -4239,6 +4242,12 @@ packages: peerDependencies: '@tiptap/core': ^2.7.0 + '@tiptap/extension-table-of-contents@2.26.3': + resolution: {integrity: sha512-uARSWaxIIx/oWqP8aDP4t4znC7dmbfpjUNFi4t9D2XS1GmQVbmbKgpinLt+K4QcXkLuRGLYqGVv4kpN3WVm1Bg==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + '@tiptap/extension-table-row@2.14.0': resolution: {integrity: sha512-a1GvCIju9xETIQu664lVQNftHqpPdRmwYp+1QzY82v3zHClso+tTLPeBSlbDdUscSmv3yZXgGML20IiOoR2l2Q==} peerDependencies: @@ -9621,6 +9630,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true @@ -14305,6 +14318,12 @@ snapshots: dependencies: '@tiptap/core': 2.14.0(@tiptap/pm@2.14.0) + '@tiptap/extension-table-of-contents@2.26.3(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)': + dependencies: + '@tiptap/core': 2.14.0(@tiptap/pm@2.14.0) + '@tiptap/pm': 2.14.0 + uuid: 10.0.0 + '@tiptap/extension-table-row@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))': dependencies: '@tiptap/core': 2.14.0(@tiptap/pm@2.14.0) @@ -20662,6 +20681,8 @@ snapshots: utils-merge@1.0.1: {} + uuid@10.0.0: {} + uuid@11.1.0: {} uuid@9.0.1: {}