diff --git a/packages/client/src/tiptap/core/extensions/paste.ts b/packages/client/src/tiptap/core/extensions/paste.ts index 6f3dce5..85b7ee3 100644 --- a/packages/client/src/tiptap/core/extensions/paste.ts +++ b/packages/client/src/tiptap/core/extensions/paste.ts @@ -2,8 +2,15 @@ import { Extension } from '@tiptap/core'; import { Plugin, PluginKey } from 'prosemirror-state'; import { Schema, Fragment } from 'prosemirror-model'; import { EXTENSION_PRIORITY_HIGHEST } from 'tiptap/core/constants'; -import { handleFileEvent, isInCode, LANGUAGES, isTitleNode } from 'tiptap/prose-utils'; -import { copyNode, isMarkdown, normalizeMarkdown } from 'tiptap/prose-utils'; +import { + handleFileEvent, + isInCode, + LANGUAGES, + isTitleNode, + copyNode, + isMarkdown, + normalizeMarkdown, +} from 'tiptap/prose-utils'; import { safeJSONParse } from 'helpers/json'; interface IPasteOptions { diff --git a/packages/client/src/tiptap/core/extensions/table-of-contents.ts b/packages/client/src/tiptap/core/extensions/table-of-contents.ts new file mode 100644 index 0000000..57a0e82 --- /dev/null +++ b/packages/client/src/tiptap/core/extensions/table-of-contents.ts @@ -0,0 +1,83 @@ +import { Node, mergeAttributes } from '@tiptap/core'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import { TableOfContentsWrapper } from 'tiptap/core/wrappers/table-of-contents'; +import { isTitleNode, findNode } from 'tiptap/prose-utils'; + +declare module '@tiptap/core' { + interface Commands { + tableOfContents: { + setTableOfContents: () => ReturnType; + }; + } +} + +interface Options { + onHasOneBeforeInsert?: () => void; +} + +export const TableOfContents = Node.create({ + name: 'tableOfContents', + group: 'block', + atom: true, + + addOptions() { + return { + onHasOneBeforeInsert: () => {}, + }; + }, + + parseHTML() { + return [ + { + tag: 'toc', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['toc', mergeAttributes(HTMLAttributes)]; + }, + + addNodeView() { + return ReactNodeViewRenderer(TableOfContentsWrapper); + }, + + addCommands() { + return { + setTableOfContents: + () => + ({ commands, editor, view }) => { + const nodes = findNode(editor, this.name); + + if (nodes.length) { + this.options.onHasOneBeforeInsert(); + return; + } + + const titleNode = view.props.state.doc.content.firstChild; + + if (isTitleNode(titleNode)) { + const pos = ((titleNode.firstChild && titleNode.firstChild.nodeSize) || 0) + 1; + return commands.insertContentAt(pos, { type: this.name }); + } + + return commands.insertContent({ + type: this.name, + }); + }, + }; + }, + + addGlobalAttributes() { + return [ + { + types: ['heading'], + attributes: { + id: { + default: null, + }, + }, + }, + ]; + }, +}); diff --git a/packages/client/src/tiptap/core/wrappers/table-of-contents/index.module.scss b/packages/client/src/tiptap/core/wrappers/table-of-contents/index.module.scss new file mode 100644 index 0000000..fa29906 --- /dev/null +++ b/packages/client/src/tiptap/core/wrappers/table-of-contents/index.module.scss @@ -0,0 +1,47 @@ +.toc { + width: max-content; + max-width: 100%; + padding: 0.75rem; + margin: 0.75em 0; + background: rgb(black 0.1); + border-radius: 0.5rem; + opacity: 0.75; + + .list { + padding: 0; + margin: 0 0 12px; + list-style: none; + + &::before { + display: block; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.025rem; + text-transform: uppercase; + content: '目录'; + opacity: 0.5; + } + } + + .item { + a:hover { + opacity: 0.5; + } + + &--3 { + padding-left: 1rem; + } + + &--4 { + padding-left: 2rem; + } + + &--5 { + padding-left: 3rem; + } + + &--6 { + padding-left: 4rem; + } + } +} diff --git a/packages/client/src/tiptap/core/wrappers/table-of-contents/index.tsx b/packages/client/src/tiptap/core/wrappers/table-of-contents/index.tsx new file mode 100644 index 0000000..e3053f0 --- /dev/null +++ b/packages/client/src/tiptap/core/wrappers/table-of-contents/index.tsx @@ -0,0 +1,85 @@ +import { NodeViewWrapper } from '@tiptap/react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Collapsible, Button } from '@douyinfe/semi-ui'; +import styles from './index.module.scss'; +import { useToggle } from 'hooks/use-toggle'; + +export const TableOfContentsWrapper = ({ editor, node, updateAttributes }) => { + const [items, setItems] = useState([]); + const [visible, toggleVisible] = useToggle(false); + + const maskStyle = useMemo( + () => + visible + ? {} + : { + WebkitMaskImage: + 'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)', + }, + [visible] + ); + + const handleUpdate = useCallback(() => { + const headings = []; + const transaction = editor.state.tr; + + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'heading') { + const id = `heading-${headings.length + 1}`; + + if (node.attrs.id !== id) { + transaction.setNodeMarkup(pos, undefined, { + ...node.attrs, + id, + }); + } + + headings.push({ + level: node.attrs.level, + text: node.textContent, + id, + }); + } + }); + + transaction.setMeta('addToHistory', false); + transaction.setMeta('preventUpdate', true); + + editor.view.dispatch(transaction); + + setItems(headings); + }, [editor]); + + useEffect(handleUpdate, [handleUpdate]); + + useEffect(() => { + if (!editor) { + return null; + } + + editor.on('update', handleUpdate); + + return () => { + editor.off('update', handleUpdate); + }; + }, [editor, handleUpdate]); + + return ( + +
+ +
    + {items.map((item, index) => ( +
  • + {item.text} +
  • + ))} +
+
+ +
+
+ ); +}; diff --git a/packages/client/src/tiptap/editor/collaboration/kit.ts b/packages/client/src/tiptap/editor/collaboration/kit.ts index 893b4da..40acdbb 100644 --- a/packages/client/src/tiptap/editor/collaboration/kit.ts +++ b/packages/client/src/tiptap/editor/collaboration/kit.ts @@ -1,3 +1,4 @@ +import { Toast } from '@douyinfe/semi-ui'; // 基础扩展 import { Document } from 'tiptap/core/extensions/document'; import { BackgroundColor } from 'tiptap/core/extensions/background-color'; @@ -58,12 +59,11 @@ import { Mind } from 'tiptap/core/extensions/mind'; import { QuickInsert } from 'tiptap/core/extensions/quick-insert'; import { SearchNReplace } from 'tiptap/core/extensions/search'; import { Status } from 'tiptap/core/extensions/status'; - +import { TableOfContents } from 'tiptap/core/extensions/table-of-contents'; // markdown 支持 import { markdownToProsemirror } from 'tiptap/markdown/markdown-to-prosemirror'; import { markdownToHTML } from 'tiptap/markdown/markdown-to-prosemirror/markdown-to-html'; import { prosemirrorToMarkdown } from 'tiptap/markdown/prosemirror-to-markdown'; -import { debounce } from 'helpers/debounce'; const DocumentWithTitle = Document.extend({ content: 'title block+', @@ -141,6 +141,11 @@ export const CollaborationKit = [ QuickInsert, SearchNReplace, Status, + TableOfContents.configure({ + onHasOneBeforeInsert: () => { + Toast.info('目录已存在'); + }, + }), Title, DocumentWithTitle, ]; diff --git a/packages/client/src/tiptap/editor/menus/insert/index.tsx b/packages/client/src/tiptap/editor/menus/insert/index.tsx index 5cdb1ee..1fb9bec 100644 --- a/packages/client/src/tiptap/editor/menus/insert/index.tsx +++ b/packages/client/src/tiptap/editor/menus/insert/index.tsx @@ -30,6 +30,11 @@ const COMMANDS = [ { title: '通用', }, + { + icon: , + label: '目录', + action: (editor) => editor.chain().focus().setTableOfContents().run(), + }, { icon: , label: '表格', diff --git a/packages/client/src/tiptap/editor/menus/quick-insert.tsx b/packages/client/src/tiptap/editor/menus/quick-insert.tsx index 215c0fa..0a1622e 100644 --- a/packages/client/src/tiptap/editor/menus/quick-insert.tsx +++ b/packages/client/src/tiptap/editor/menus/quick-insert.tsx @@ -124,6 +124,17 @@ export const QUICK_INSERT_ITEMS = [ command: (editor: Editor) => editor.chain().focus().setHorizontalRule().run(), }, + { + key: '目录', + label: ( + + + 目录 + + ), + command: (editor: Editor) => editor.chain().focus().setTableOfContents().run(), + }, + { key: '表格', label: ( diff --git a/packages/client/src/tiptap/editor/menus/text/index.tsx b/packages/client/src/tiptap/editor/menus/text/index.tsx index e0a71d8..86a5230 100644 --- a/packages/client/src/tiptap/editor/menus/text/index.tsx +++ b/packages/client/src/tiptap/editor/menus/text/index.tsx @@ -21,6 +21,7 @@ import { CodeBlock } from 'tiptap/core/extensions/code-block'; import { Iframe } from 'tiptap/core/extensions/iframe'; import { Mind } from 'tiptap/core/extensions/mind'; import { Table } from 'tiptap/core/extensions/table'; +import { TableOfContents } from 'tiptap/core/extensions/table-of-contents'; import { Katex } from 'tiptap/core/extensions/katex'; import { DocumentReference } from 'tiptap/core/extensions/document-reference'; import { DocumentChildren } from 'tiptap/core/extensions/document-children'; @@ -35,6 +36,7 @@ const OTHER_BUBBLE_MENU_TYPES = [ Iframe.name, Mind.name, Table.name, + TableOfContents.name, DocumentReference.name, DocumentChildren.name, Katex.name, diff --git a/packages/client/src/tiptap/prose-utils/node.ts b/packages/client/src/tiptap/prose-utils/node.ts index b21e355..3966fd1 100644 --- a/packages/client/src/tiptap/prose-utils/node.ts +++ b/packages/client/src/tiptap/prose-utils/node.ts @@ -1,3 +1,4 @@ +import { Editor } from '@tiptap/core'; import { Node } from 'prosemirror-model'; import { EditorState } from 'prosemirror-state'; @@ -66,3 +67,23 @@ export function isInTitle(state: EditorState): boolean { export function isInCallout(state: EditorState): boolean { return isInCustomNode(state, 'callout'); } + +export const findNode = (editor: Editor, name: string) => { + const content = editor.getJSON(); + const queue = [content]; + const res = []; + + while (queue.length) { + const node = queue.shift(); + + if (node.type === name) { + res.push(node); + } + + if (node.content && node.content.length) { + queue.push(...node.content); + } + } + + return res; +};