diff --git a/packages/client/package.json b/packages/client/package.json index 608281aa..cebf3644 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -32,6 +32,7 @@ "@tiptap/extension-hard-break": "^2.0.0-beta.30", "@tiptap/extension-heading": "^2.0.0-beta.26", "@tiptap/extension-highlight": "^2.0.0-beta.33", + "@tiptap/extension-horizontal-rule": "^2.0.0-beta.31", "@tiptap/extension-image": "^2.0.0-beta.25", "@tiptap/extension-italic": "^2.0.0-beta.25", "@tiptap/extension-link": "^2.0.0-beta.36", @@ -51,6 +52,7 @@ "@tiptap/extension-text-style": "^2.0.0-beta.23", "@tiptap/extension-underline": "^2.0.0-beta.23", "@tiptap/react": "^2.0.0-beta.107", + "@traptitech/markdown-it-katex": "^3.5.0", "axios": "^0.25.0", "classnames": "^2.3.1", "copy-to-clipboard": "^3.3.1", @@ -59,6 +61,12 @@ "interactjs": "^1.10.11", "katex": "^0.15.2", "lowlight": "^2.5.0", + "markdown-it": "^12.3.2", + "markdown-it-anchor": "^8.4.1", + "markdown-it-footnote": "^3.0.3", + "markdown-it-sub": "^1.0.0", + "markdown-it-sup": "^1.0.0", + "markdown-it-task-lists": "^2.1.1", "marked": "^4.0.12", "next": "12.0.10", "prosemirror-markdown": "^1.7.0", diff --git a/packages/client/src/components/document/editor/editor.tsx b/packages/client/src/components/document/editor/editor.tsx index d84342d8..25bed9be 100644 --- a/packages/client/src/components/document/editor/editor.tsx +++ b/packages/client/src/components/document/editor/editor.tsx @@ -12,7 +12,6 @@ import { getProvider, destoryProvider, MenuBar, - Toc, } from 'components/tiptap'; import { DataRender } from 'components/data-render'; import { joinUser } from 'components/document/collaboration'; diff --git a/packages/client/src/components/tiptap/base-kit.tsx b/packages/client/src/components/tiptap/base-kit.tsx deleted file mode 100644 index ebdef190..00000000 --- a/packages/client/src/components/tiptap/base-kit.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { Document, TitledDocument, Title } from './extensions/title'; -import Placeholder from '@tiptap/extension-placeholder'; -import Paragraph from '@tiptap/extension-paragraph'; -import Text from '@tiptap/extension-text'; -import Strike from '@tiptap/extension-strike'; -import Underline from '@tiptap/extension-underline'; -import TextStyle from '@tiptap/extension-text-style'; -import { Color } from '@tiptap/extension-color'; -import Blockquote from '@tiptap/extension-blockquote'; -import Bold from '@tiptap/extension-bold'; -import Code from '@tiptap/extension-code'; -import Highlight from '@tiptap/extension-highlight'; -import TextAlign from '@tiptap/extension-text-align'; -import Dropcursor from '@tiptap/extension-dropcursor'; -import Gapcursor from '@tiptap/extension-gapcursor'; -import HardBreak from '@tiptap/extension-hard-break'; -import Heading from '@tiptap/extension-heading'; -import Italic from '@tiptap/extension-italic'; -import OrderedList from '@tiptap/extension-ordered-list'; -import BulletList from '@tiptap/extension-bullet-list'; -import ListItem from '@tiptap/extension-list-item'; -import TaskList from '@tiptap/extension-task-list'; -import TaskItem from '@tiptap/extension-task-item'; -import { HorizontalRule } from './extensions/horizontal-rule'; -import { BackgroundColor } from './extensions/background-color'; -import { Link } from './extensions/link'; -import { FontSize } from './extensions/font-size'; -import { ColorHighlighter } from './extensions/color-highlight'; -import { Indent } from './extensions/indent'; -import { Div } from './extensions/div'; -import { Banner } from './extensions/banner'; -import { CodeBlock } from './extensions/code-block'; -import { Iframe } from './extensions/iframe'; -import { Mind } from './extensions/mind'; -import { Image } from './extensions/image'; -import { Status } from './extensions/status'; -import { Paste } from './extensions/paste'; -import { Table, TableRow, TableCell, TableHeader } from './extensions/table'; -import { Toc } from './extensions/toc'; -import { TrailingNode } from './extensions/trailing-node'; -import { Attachment } from './extensions/attachment'; -import { Katex } from './extensions/katex'; -import { DocumentReference } from './extensions/documents/reference'; -import { DocumentChildren } from './extensions/documents/children'; - -export { Document, TitledDocument }; - -export const BaseExtension = [ - Placeholder.configure({ - placeholder: ({ node }) => { - if (node.type.name === 'title') { - return '请输入标题'; - } - return '请输入内容'; - }, - showOnlyWhenEditable: true, - }), - Title, - Paragraph, - Text, - Strike, - Underline, - TextStyle, - Color, - BackgroundColor, - Bold, - Code, - Dropcursor, - Gapcursor, - HardBreak, - Heading, - HorizontalRule, - Italic, - OrderedList, - BulletList, - ListItem, - TaskList, - TaskItem.configure({ - nested: true, - }), - Highlight.configure({ multicolor: true }), - TextAlign.configure({ - types: ['heading', 'paragraph', 'image'], - }), - Link.configure({ openOnClick: false }), - Blockquote, - FontSize, - ColorHighlighter, - Indent, - CodeBlock, - Div, - Banner, - Iframe, - Mind, - Image, - Status, - Paste, - Table.configure({ - resizable: true, - }), - TableRow, - TableCell, - TableHeader, - Toc, - TrailingNode, - Attachment, - Katex, - DocumentReference, - DocumentChildren, -]; diff --git a/packages/client/src/components/tiptap/basekit.tsx b/packages/client/src/components/tiptap/basekit.tsx new file mode 100644 index 00000000..8c94d4ef --- /dev/null +++ b/packages/client/src/components/tiptap/basekit.tsx @@ -0,0 +1,109 @@ +import { Attachment } from './extensions/attachment'; +import { BackgroundColor } from './extensions/backgroundColor'; +import { Banner } from './extensions/banner'; +import { Blockquote } from './extensions/blockquote'; +import { Bold } from './extensions/bold'; +import { BulletList } from './extensions/bulletList'; +import { Code } from './extensions/code'; +import { CodeBlock } from './extensions/codeBlock'; +import { Color } from './extensions/color'; +import { ColorHighlighter } from './extensions/colorHighlighter'; +import { DocumentChildren } from './extensions/documentChildren'; +import { DocumentReference } from './extensions/documentReference'; +import { Dropcursor } from './extensions/dropCursor'; +import { FontSize } from './extensions/fontSize'; +import { FootnoteDefinition } from './extensions/footnoteDefinition'; +import { FootnoteReference } from './extensions/footnoteReference'; +import { FootnotesSection } from './extensions/footnotesSection'; +import { Gapcursor } from './extensions/gapCursor'; +import { HardBreak } from './extensions/hardBreak'; +import { Heading } from './extensions/heading'; +import { HorizontalRule } from './extensions/horizontalRule'; +import { HTMLMarks } from './extensions/htmlMarks'; +import { Iframe } from './extensions/iframe'; +import { Image } from './extensions/image'; +import { Italic } from './extensions/italic'; +import { Katex } from './extensions/katex'; +import { Link } from './extensions/link'; +import { ListItem } from './extensions/listItem'; +import { Mind } from './extensions/mind'; +import { OrderedList } from './extensions/orderedList'; +import { Paragraph } from './extensions/paragraph'; +import { PasteFile } from './extensions/pasteFile'; +import { PasteMarkdown } from './extensions/pasteMarkdown'; +import { Placeholder } from './extensions/placeholder'; +import { Status } from './extensions/status'; +import { Strike } from './extensions/strike'; +import { Table } from './extensions/table'; +import { TableCell } from './extensions/tableCell'; +import { TableHeader } from './extensions/tableHeader'; +import { TableRow } from './extensions/tableRow'; +import { Text } from './extensions/text'; +import { TextAlign } from './extensions/textAlign'; +import { TextStyle } from './extensions/textStyle'; +import { TaskItem } from './extensions/taskItem'; +import { TaskList } from './extensions/taskList'; +import { Title } from './extensions/title'; +import { TrailingNode } from './extensions/trailingNode'; +import { Underline } from './extensions/underline'; + +export const BaseKit = [ + Attachment, + BackgroundColor, + Banner, + Blockquote, + Bold, + BulletList, + Code, + CodeBlock, + Color, + ColorHighlighter, + DocumentChildren, + DocumentReference, + Dropcursor, + FontSize, + FootnoteDefinition, + FootnoteReference, + FootnotesSection, + Gapcursor, + HardBreak, + Heading, + HorizontalRule, + ...HTMLMarks, + Iframe, + Image, + Italic, + Katex, + Link, + ListItem, + Mind, + OrderedList, + Paragraph, + PasteFile, + PasteMarkdown, + Placeholder.configure({ + placeholder: ({ node }) => { + if (node.type.name === 'title') { + return '请输入标题'; + } + return '请输入内容'; + }, + showOnlyWhenEditable: true, + }), + Status, + Strike, + Table, + TableCell, + TableHeader, + TableRow, + Text, + TextAlign.configure({ + types: ['heading', 'paragraph', 'image'], + }), + TextStyle, + TaskItem, + TaskList, + Title, + TrailingNode, + Underline, +]; diff --git a/packages/client/src/components/tiptap/extensions/attachment/index.module.scss b/packages/client/src/components/tiptap/components/attachment/index.module.scss similarity index 100% rename from packages/client/src/components/tiptap/extensions/attachment/index.module.scss rename to packages/client/src/components/tiptap/components/attachment/index.module.scss diff --git a/packages/client/src/components/tiptap/components/attachment/index.tsx b/packages/client/src/components/tiptap/components/attachment/index.tsx new file mode 100644 index 00000000..ff6494d0 --- /dev/null +++ b/packages/client/src/components/tiptap/components/attachment/index.tsx @@ -0,0 +1,28 @@ +import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'; +import { Button, Tooltip } from '@douyinfe/semi-ui'; +import { IconDownload } from '@douyinfe/semi-icons'; +import { download } from '../../services/download'; +import styles from './index.module.scss'; + +export const AttachmentWrapper = ({ node }) => { + const { name, url } = node.attrs; + + return ( + +
+ {name} + + +
+ +
+ ); +}; diff --git a/packages/client/src/components/tiptap/extensions/banner/index.module.scss b/packages/client/src/components/tiptap/components/banner/index.module.scss similarity index 100% rename from packages/client/src/components/tiptap/extensions/banner/index.module.scss rename to packages/client/src/components/tiptap/components/banner/index.module.scss diff --git a/packages/client/src/components/tiptap/components/banner/index.tsx b/packages/client/src/components/tiptap/components/banner/index.tsx new file mode 100644 index 00000000..df58bbf3 --- /dev/null +++ b/packages/client/src/components/tiptap/components/banner/index.tsx @@ -0,0 +1,16 @@ +import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'; +import { Banner as SemiBanner } from '@douyinfe/semi-ui'; +import styles from './index.module.scss'; + +export const BannerWrapper = ({ node }) => { + return ( + + } + closeIcon={null} + fullMode={false} + /> + + ); +}; diff --git a/packages/client/src/components/tiptap/extensions/code-block/index.module.scss b/packages/client/src/components/tiptap/components/codeBlock/index.module.scss similarity index 100% rename from packages/client/src/components/tiptap/extensions/code-block/index.module.scss rename to packages/client/src/components/tiptap/components/codeBlock/index.module.scss diff --git a/packages/client/src/components/tiptap/extensions/code-block/index.tsx b/packages/client/src/components/tiptap/components/codeBlock/index.tsx similarity index 91% rename from packages/client/src/components/tiptap/extensions/code-block/index.tsx rename to packages/client/src/components/tiptap/components/codeBlock/index.tsx index bd06f79b..724a7534 100644 --- a/packages/client/src/components/tiptap/extensions/code-block/index.tsx +++ b/packages/client/src/components/tiptap/components/codeBlock/index.tsx @@ -8,7 +8,7 @@ import { lowlight } from 'lowlight'; import { copy } from 'helpers/copy'; import styles from './index.module.scss'; -const Render = ({ +export const CodeBlockWrapper = ({ editor, node: { attrs: { language: defaultLanguage }, @@ -53,9 +53,3 @@ const Render = ({ ); }; - -export const CodeBlock = CodeBlockLowlight.extend({ - addNodeView() { - return ReactNodeViewRenderer(Render); - }, -}).configure({ lowlight }); diff --git a/packages/client/src/components/tiptap/extensions/documents/children/index.module.scss b/packages/client/src/components/tiptap/components/documentChildren/index.module.scss similarity index 100% rename from packages/client/src/components/tiptap/extensions/documents/children/index.module.scss rename to packages/client/src/components/tiptap/components/documentChildren/index.module.scss diff --git a/packages/client/src/components/tiptap/extensions/documents/children/index.tsx b/packages/client/src/components/tiptap/components/documentChildren/index.tsx similarity index 56% rename from packages/client/src/components/tiptap/extensions/documents/children/index.tsx rename to packages/client/src/components/tiptap/components/documentChildren/index.tsx index 6f716a86..0f1545ae 100644 --- a/packages/client/src/components/tiptap/extensions/documents/children/index.tsx +++ b/packages/client/src/components/tiptap/components/documentChildren/index.tsx @@ -1,15 +1,7 @@ -import { - Node, - Command, - mergeAttributes, - textInputRule, - textblockTypeInputRule, - wrappingInputRule, -} from '@tiptap/core'; -import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react'; +import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'; import { useRouter } from 'next/router'; import Link from 'next/link'; -import { Space, Popover, Tag, Input, Typography } from '@douyinfe/semi-ui'; +import { Typography } from '@douyinfe/semi-ui'; import { useChildrenDocument } from 'data/document'; import { DataRender } from 'components/data-render'; import { Empty } from 'components/empty'; @@ -18,70 +10,7 @@ import styles from './index.module.scss'; const { Text } = Typography; -declare module '@tiptap/core' { - interface Commands { - documentChildren: { - setDocumentChildren: () => Command; - }; - } -} - -export const DocumentChildrenInputRegex = /^documentChildren\$$/; - -const DocumentChildrenExtension = Node.create({ - name: 'documentChildren', - group: 'block', - defining: true, - draggable: true, - atom: true, - - addAttributes() { - return { - color: { - default: 'grey', - }, - text: { - default: '', - }, - }; - }, - - parseHTML() { - return [{ tag: 'div[data-type=documentChildren]' }]; - }, - - renderHTML({ HTMLAttributes }) { - return [ - 'div', - mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes), - ]; - }, - - // @ts-ignore - addCommands() { - return { - setDocumentChildren: - () => - ({ commands }) => { - return commands.insertContent({ - type: this.name, - attrs: {}, - }); - }, - }; - }, - - addInputRules() { - return [ - wrappingInputRule({ - find: DocumentChildrenInputRegex, - type: this.type, - }), - ]; - }, -}); - -const Render = () => { +export const DocumentChildrenWrapper = () => { const { pathname, query } = useRouter(); const wikiId = query?.wikiId; const documentId = query?.documentId; @@ -135,9 +64,3 @@ const Render = () => { ); }; - -export const DocumentChildren = DocumentChildrenExtension.extend({ - addNodeView() { - return ReactNodeViewRenderer(Render); - }, -}); diff --git a/packages/client/src/components/tiptap/extensions/documents/reference/index.module.scss b/packages/client/src/components/tiptap/components/documentReference/index.module.scss similarity index 100% rename from packages/client/src/components/tiptap/extensions/documents/reference/index.module.scss rename to packages/client/src/components/tiptap/components/documentReference/index.module.scss diff --git a/packages/client/src/components/tiptap/extensions/documents/reference/index.tsx b/packages/client/src/components/tiptap/components/documentReference/index.tsx similarity index 50% rename from packages/client/src/components/tiptap/extensions/documents/reference/index.tsx rename to packages/client/src/components/tiptap/components/documentReference/index.tsx index d0a8fdd1..4fafe00f 100644 --- a/packages/client/src/components/tiptap/extensions/documents/reference/index.tsx +++ b/packages/client/src/components/tiptap/components/documentReference/index.tsx @@ -1,5 +1,4 @@ -import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core'; -import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react'; +import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'; import { useRouter } from 'next/router'; import Link from 'next/link'; import { Select } from '@douyinfe/semi-ui'; @@ -8,73 +7,7 @@ import { DataRender } from 'components/data-render'; import { IconDocument } from 'components/icons'; import styles from './index.module.scss'; -declare module '@tiptap/core' { - interface Commands { - documentReference: { - setDocumentReference: () => Command; - }; - } -} - -export const DocumentReferenceInputRegex = /^documentReference\$$/; - -const DocumentReferenceExtension = Node.create({ - name: 'documentReference', - group: 'block', - defining: true, - draggable: true, - atom: true, - - addAttributes() { - return { - wikiId: { - default: '', - }, - documentId: { - default: '', - }, - title: { - default: '', - }, - }; - }, - - parseHTML() { - return [{ tag: 'div[data-type=documentReference]' }]; - }, - - renderHTML({ HTMLAttributes }) { - return [ - 'div', - mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes), - ]; - }, - - // @ts-ignore - addCommands() { - return { - setDocumentReference: - () => - ({ commands }) => { - return commands.insertContent({ - type: this.name, - attrs: {}, - }); - }, - }; - }, - - addInputRules() { - return [ - wrappingInputRule({ - find: DocumentReferenceInputRegex, - type: this.type, - }), - ]; - }, -}); - -const Render = ({ editor, node, updateAttributes }) => { +export const DocumentReferenceWrapper = ({ editor, node, updateAttributes }) => { const { pathname, query } = useRouter(); const wikiIdFromUrl = query?.wikiId; const isShare = pathname.includes('share'); @@ -83,7 +16,7 @@ const Render = ({ editor, node, updateAttributes }) => { const { data: tocs, loading, error } = useWikiTocs(isShare ? null : wikiIdFromUrl); const selectDoc = (str) => { - const [wikiId, documentId, title] = str.split('/'); + const [wikiId, title, documentId] = str.split('/'); updateAttributes({ wikiId, documentId, title }); }; @@ -98,12 +31,13 @@ const Render = ({ editor, node, updateAttributes }) => { updateAttributes({ url })} + > + + )} + {url && ( +
+ +
+ )} + + ); + + if (!isEditable && !url) { + return null; + } + + return ( + + {isEditable ? ( + + {content} + + ) : ( +
{content}
+ )} +
+ ); +}; diff --git a/packages/client/src/components/tiptap/extensions/image.tsx b/packages/client/src/components/tiptap/components/image/index.tsx similarity index 55% rename from packages/client/src/components/tiptap/extensions/image.tsx rename to packages/client/src/components/tiptap/components/image/index.tsx index 00d4365d..da645271 100644 --- a/packages/client/src/components/tiptap/extensions/image.tsx +++ b/packages/client/src/components/tiptap/components/image/index.tsx @@ -1,8 +1,7 @@ -import { Image as TImage } from '@tiptap/extension-image'; -import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'; +import { NodeViewWrapper } from '@tiptap/react'; import { Resizeable } from 'components/resizeable'; -const Render = ({ editor, node, updateAttributes }) => { +export const ImageWrapper = ({ editor, node, updateAttributes }) => { const isEditable = editor.isEditable; const { src, alt, title, width, height, textAlign } = node.attrs; @@ -24,29 +23,3 @@ const Render = ({ editor, node, updateAttributes }) => { ); }; - -export const Image = TImage.extend({ - draggable: true, - addAttributes() { - return { - src: { - default: null, - }, - alt: { - default: null, - }, - title: { - default: null, - }, - width: { - default: 'auto', - }, - height: { - default: 'auto', - }, - }; - }, - addNodeView() { - return ReactNodeViewRenderer(Render); - }, -}); diff --git a/packages/client/src/components/tiptap/extensions/katex/index.module.scss b/packages/client/src/components/tiptap/components/katex/index.module.scss similarity index 100% rename from packages/client/src/components/tiptap/extensions/katex/index.module.scss rename to packages/client/src/components/tiptap/components/katex/index.module.scss diff --git a/packages/client/src/components/tiptap/extensions/katex/index.tsx b/packages/client/src/components/tiptap/components/katex/index.tsx similarity index 53% rename from packages/client/src/components/tiptap/extensions/katex/index.tsx rename to packages/client/src/components/tiptap/components/katex/index.tsx index 0d6215b0..4fb32af8 100644 --- a/packages/client/src/components/tiptap/extensions/katex/index.tsx +++ b/packages/client/src/components/tiptap/components/katex/index.tsx @@ -1,77 +1,13 @@ -import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core'; -import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react'; +import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'; +import { useMemo } from 'react'; import { Popover, TextArea, Typography, Space } from '@douyinfe/semi-ui'; import { IconHelpCircle } from '@douyinfe/semi-icons'; import katex from 'katex'; import styles from './index.module.scss'; -import { useMemo } from 'react'; - -declare module '@tiptap/core' { - interface Commands { - katex: { - setKatex: () => Command; - }; - } -} const { Text } = Typography; -export const KatexInputRegex = /^\$\$(.+)?\$$/; - -const KatexExtension = Node.create({ - name: 'katex', - group: 'block', - defining: true, - draggable: true, - atom: true, - - addAttributes() { - return { - text: { - default: '', - }, - }; - }, - - parseHTML() { - return [{ tag: 'div[data-type=katex]' }]; - }, - - renderHTML({ HTMLAttributes }) { - return [ - 'div', - mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes), - ]; - }, - - // @ts-ignore - addCommands() { - return { - setKatex: - (options) => - ({ commands }) => { - return commands.insertContent({ - type: this.name, - attrs: options, - }); - }, - }; - }, - - addInputRules() { - return [ - wrappingInputRule({ - find: KatexInputRegex, - type: this.type, - getAttributes: (match) => { - return { text: match[1] }; - }, - }), - ]; - }, -}); - -const Render = ({ editor, node, updateAttributes }) => { +export const KatexWrapper = ({ editor, node, updateAttributes }) => { const isEditable = editor.isEditable; const { text } = node.attrs; const formatText = useMemo(() => { @@ -123,9 +59,3 @@ const Render = ({ editor, node, updateAttributes }) => { ); }; - -export const Katex = KatexExtension.extend({ - addNodeView() { - return ReactNodeViewRenderer(Render); - }, -}); diff --git a/packages/client/src/components/tiptap/extensions/mind/index.module.scss b/packages/client/src/components/tiptap/components/mind/index.module.scss similarity index 100% rename from packages/client/src/components/tiptap/extensions/mind/index.module.scss rename to packages/client/src/components/tiptap/components/mind/index.module.scss diff --git a/packages/client/src/components/tiptap/extensions/mind/index.tsx b/packages/client/src/components/tiptap/components/mind/index.tsx similarity index 65% rename from packages/client/src/components/tiptap/extensions/mind/index.tsx rename to packages/client/src/components/tiptap/components/mind/index.tsx index 93110073..01599e27 100644 --- a/packages/client/src/components/tiptap/extensions/mind/index.tsx +++ b/packages/client/src/components/tiptap/components/mind/index.tsx @@ -1,5 +1,4 @@ -import { Node, mergeAttributes } from '@tiptap/core'; -import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react'; +import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'; import { useCallback, useEffect, useRef } from 'react'; import { Button } from '@douyinfe/semi-ui'; import { IconMinus, IconPlus } from '@douyinfe/semi-icons'; @@ -9,84 +8,7 @@ import deepEqual from 'deep-equal'; import jsMind from './jsmind.jsx'; import styles from './index.module.scss'; -const DEFAULT_MIND_DATA = { - meta: { - name: 'jsMind', - author: 'think', - version: '0.2', - }, - format: 'node_tree', - data: { id: 'root', topic: '中心节点', children: [] }, -}; - -const MindNode = Node.create({ - name: 'jsmind', - content: '', - marks: '', - group: 'block', - draggable: true, - atom: true, - - addOptions() { - return { - HTMLAttributes: { - 'data-type': 'jsmind', - }, - }; - }, - - addAttributes() { - return { - width: { - default: '100%', - }, - height: { - default: 240, - }, - data: { - default: DEFAULT_MIND_DATA, - }, - }; - }, - - parseHTML() { - return [ - { - tag: 'div[data-type="jsmind"]', - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; - }, - - // @ts-ignore - addCommands() { - return { - insertMind: - (options) => - ({ tr, commands, chain, editor }) => { - if (tr.selection?.node?.type?.name == this.name) { - return commands.updateAttributes(this.name, options); - } - - const { selection } = editor.state; - const pos = selection.$head; - return chain() - .insertContentAt(pos.before(), [ - { - type: this.name, - attrs: { data: DEFAULT_MIND_DATA }, - }, - ]) - .run(); - }, - }; - }, -}); - -const Render = ({ editor, node, updateAttributes }) => { +export const MindWrapper = ({ editor, node, updateAttributes }) => { const $container = useRef(); const $mind = useRef(); const isEditable = editor.isEditable; @@ -216,9 +138,3 @@ const Render = ({ editor, node, updateAttributes }) => { ); }; - -export const Mind = MindNode.extend({ - addNodeView() { - return ReactNodeViewRenderer(Render); - }, -}); diff --git a/packages/client/src/components/tiptap/extensions/mind/jsmind.jsx b/packages/client/src/components/tiptap/components/mind/jsmind.jsx similarity index 99% rename from packages/client/src/components/tiptap/extensions/mind/jsmind.jsx rename to packages/client/src/components/tiptap/components/mind/jsmind.jsx index d218931d..be83a7a5 100644 --- a/packages/client/src/components/tiptap/extensions/mind/jsmind.jsx +++ b/packages/client/src/components/tiptap/components/mind/jsmind.jsx @@ -2,7 +2,7 @@ import ReactDOM from 'react-dom'; import { Space, Button, Tooltip } from '@douyinfe/semi-ui'; import { IconPlus, IconDelete } from '@douyinfe/semi-icons'; import { IconZoomOut, IconZoomIn } from 'components/icons'; -import { Divider } from '../../components/divider'; +import { Divider } from '../divider'; import styles from './index.module.scss'; /* diff --git a/packages/client/src/components/tiptap/extensions/status/index.module.scss b/packages/client/src/components/tiptap/components/status/index.module.scss similarity index 100% rename from packages/client/src/components/tiptap/extensions/status/index.module.scss rename to packages/client/src/components/tiptap/components/status/index.module.scss diff --git a/packages/client/src/components/tiptap/extensions/status/index.tsx b/packages/client/src/components/tiptap/components/status/index.tsx similarity index 54% rename from packages/client/src/components/tiptap/extensions/status/index.tsx rename to packages/client/src/components/tiptap/components/status/index.tsx index 99e96367..4a67d18c 100644 --- a/packages/client/src/components/tiptap/extensions/status/index.tsx +++ b/packages/client/src/components/tiptap/components/status/index.tsx @@ -1,61 +1,8 @@ -import { Node, Command, mergeAttributes } from '@tiptap/core'; -import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react'; +import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'; import { Space, Popover, Tag, Input } from '@douyinfe/semi-ui'; import styles from './index.module.scss'; -declare module '@tiptap/core' { - interface Commands { - status: { - setStatus: () => Command; - }; - } -} - -const StatusExtension = Node.create({ - name: 'status', - content: 'text*', - group: 'inline', - inline: true, - atom: true, - - addAttributes() { - return { - color: { - default: 'grey', - }, - text: { - default: '', - }, - }; - }, - - parseHTML() { - return [{ tag: 'span[data-type=status]' }]; - }, - - renderHTML({ HTMLAttributes }) { - return [ - 'span', - mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes), - ]; - }, - - // @ts-ignore - addCommands() { - return { - setStatus: - (options) => - ({ commands }) => { - return commands.insertContent({ - type: this.name, - attrs: options, - }); - }, - }; - }, -}); - -const Render = ({ editor, node, updateAttributes }) => { +export const StatusWrapper = ({ editor, node, updateAttributes }) => { const isEditable = editor.isEditable; const { color, text } = node.attrs; const content = {text || '设置状态'}; @@ -101,9 +48,3 @@ const Render = ({ editor, node, updateAttributes }) => { ); }; - -export const Status = StatusExtension.extend({ - addNodeView() { - return ReactNodeViewRenderer(Render); - }, -}); diff --git a/packages/client/src/components/tiptap/constants.ts b/packages/client/src/components/tiptap/constants.ts new file mode 100644 index 00000000..92cd07e7 --- /dev/null +++ b/packages/client/src/components/tiptap/constants.ts @@ -0,0 +1,6 @@ +export const PARSE_HTML_PRIORITY_LOWEST = 1; +export const PARSE_HTML_PRIORITY_DEFAULT = 50; +export const PARSE_HTML_PRIORITY_HIGHEST = 100; +export const EXTENSION_PRIORITY_LOWER = 75; +export const EXTENSION_PRIORITY_DEFAULT = 100; +export const EXTENSION_PRIORITY_HIGHEST = 200; diff --git a/packages/client/src/components/tiptap/extensions/attachment.ts b/packages/client/src/components/tiptap/extensions/attachment.ts new file mode 100644 index 00000000..0a3e6c44 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/attachment.ts @@ -0,0 +1,50 @@ +import { Node, mergeAttributes } from '@tiptap/core'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import { AttachmentWrapper } from '../components/attachment'; + +export const Attachment = Node.create({ + name: 'attachment', + group: 'block', + draggable: true, + atom: true, + + addOptions() { + return { + HTMLAttributes: { + class: 'attachment', + }, + }; + }, + + parseHTML() { + return [{ tag: 'div[class=attachment]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; + }, + + addAttributes() { + return { + name: { + default: null, + }, + url: { + default: null, + }, + }; + }, + // @ts-ignore + addCommands() { + return { + setAttachment: + (attrs) => + ({ chain }) => { + return chain().insertContent({ type: this.name, attrs }).run(); + }, + }; + }, + addNodeView() { + return ReactNodeViewRenderer(AttachmentWrapper); + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/attachment/index.tsx b/packages/client/src/components/tiptap/extensions/attachment/index.tsx deleted file mode 100644 index 97148123..00000000 --- a/packages/client/src/components/tiptap/extensions/attachment/index.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Node, mergeAttributes } from '@tiptap/core'; -import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react'; -import { Button, Tooltip } from '@douyinfe/semi-ui'; -import { IconDownload } from '@douyinfe/semi-icons'; -import { download } from '../../utils/download'; -import styles from './index.module.scss'; - -const Render = ({ node }) => { - const { name, url } = node.attrs; - - return ( - -
- {name} - - -
- -
- ); -}; - -export const Attachment = Node.create({ - name: 'attachment', - group: 'block', - draggable: true, - atom: true, - - addOptions() { - return { - HTMLAttributes: { - class: 'attachment', - }, - }; - }, - - parseHTML() { - return [{ tag: 'div[class=attachment]' }]; - }, - - renderHTML({ HTMLAttributes }) { - return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; - }, - - addAttributes() { - return { - name: { - default: null, - }, - url: { - default: null, - }, - }; - }, - // @ts-ignore - addCommands() { - return { - setAttachment: - (attrs) => - ({ chain }) => { - return chain().insertContent({ type: this.name, attrs }).run(); - }, - }; - }, - addNodeView() { - return ReactNodeViewRenderer(Render); - }, -}); diff --git a/packages/client/src/components/tiptap/extensions/background-color.tsx b/packages/client/src/components/tiptap/extensions/backgroundColor.ts similarity index 93% rename from packages/client/src/components/tiptap/extensions/background-color.tsx rename to packages/client/src/components/tiptap/extensions/backgroundColor.ts index f748e5c3..409f09c8 100644 --- a/packages/client/src/components/tiptap/extensions/background-color.tsx +++ b/packages/client/src/components/tiptap/extensions/backgroundColor.ts @@ -8,13 +8,7 @@ export type ColorOptions = { declare module '@tiptap/core' { interface Commands { backgroundColor: { - /** - * Set the text color - */ setBackgroundColor: (color: string) => ReturnType; - /** - * Unset the text color - */ unsetBackgroundColor: () => ReturnType; }; } diff --git a/packages/client/src/components/tiptap/extensions/banner/index.tsx b/packages/client/src/components/tiptap/extensions/banner.ts similarity index 63% rename from packages/client/src/components/tiptap/extensions/banner/index.tsx rename to packages/client/src/components/tiptap/extensions/banner.ts index 6ef49d77..7ae03c29 100644 --- a/packages/client/src/components/tiptap/extensions/banner/index.tsx +++ b/packages/client/src/components/tiptap/extensions/banner.ts @@ -1,7 +1,6 @@ import { Node, Command, mergeAttributes } from '@tiptap/core'; -import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react'; -import { Banner as SemiBanner } from '@douyinfe/semi-ui'; -import styles from './index.module.scss'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import { BannerWrapper } from '../components/banner'; declare module '@tiptap/core' { interface Commands { @@ -11,7 +10,7 @@ declare module '@tiptap/core' { } } -const BannerExtension = Node.create({ +export const Banner = Node.create({ name: 'banner', content: 'block*', group: 'block', @@ -57,23 +56,8 @@ const BannerExtension = Node.create({ }, }; }, -}); -const Render = ({ node }) => { - return ( - - } - closeIcon={null} - fullMode={false} - /> - - ); -}; - -export const Banner = BannerExtension.extend({ addNodeView() { - return ReactNodeViewRenderer(Render); + return ReactNodeViewRenderer(BannerWrapper); }, }); diff --git a/packages/client/src/components/tiptap/extensions/blockquote.ts b/packages/client/src/components/tiptap/extensions/blockquote.ts new file mode 100644 index 00000000..a4f04be4 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/blockquote.ts @@ -0,0 +1,37 @@ +import { Blockquote as BuiltInBlockquote } from '@tiptap/extension-blockquote'; +import { wrappingInputRule } from '@tiptap/core'; +import { getParents } from '../services/dom'; +import { getMarkdownSource } from '../services/markdownSourceMap'; + +export const Blockquote = BuiltInBlockquote.extend({ + addAttributes() { + return { + ...this.parent?.(), + + multiline: { + default: false, + parseHTML: (element) => { + const source = getMarkdownSource(element); + const parentsIncludeBlockquote = getParents(element).some( + (p) => p.nodeName.toLowerCase() === 'blockquote' + ); + + return source && !source.startsWith('>') && !parentsIncludeBlockquote; + }, + }, + }; + }, + + addInputRules() { + const multilineInputRegex = /^\s*>>>\s$/gm; + + return [ + ...this.parent?.(), + wrappingInputRule({ + find: multilineInputRegex, + type: this.type, + getAttributes: () => ({ multiline: true }), + }), + ]; + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/bold.ts b/packages/client/src/components/tiptap/extensions/bold.ts new file mode 100644 index 00000000..42c2bc6d --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/bold.ts @@ -0,0 +1 @@ +export { Bold } from '@tiptap/extension-bold'; diff --git a/packages/client/src/components/tiptap/extensions/bulletList.ts b/packages/client/src/components/tiptap/extensions/bulletList.ts new file mode 100644 index 00000000..5acd1a52 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/bulletList.ts @@ -0,0 +1,19 @@ +import { BulletList as BuiltInBulletList } from '@tiptap/extension-bullet-list'; +import { getMarkdownSource } from '../services/markdownSourceMap'; + +export const BulletList = BuiltInBulletList.extend({ + addAttributes() { + return { + ...this.parent?.(), + + bullet: { + default: '*', + parseHTML(element) { + const bullet = getMarkdownSource(element)?.charAt(0); + + return '*+-'.includes(bullet) ? bullet : '*'; + }, + }, + }; + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/code.ts b/packages/client/src/components/tiptap/extensions/code.ts new file mode 100644 index 00000000..12cfda15 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/code.ts @@ -0,0 +1,12 @@ +import BuiltInCode from '@tiptap/extension-code'; +import { EXTENSION_PRIORITY_LOWER } from '../constants'; + +export const Code = BuiltInCode.extend({ + excludes: null, + /** + * Reduce the rendering priority of the code mark to + * ensure the bold, italic, and strikethrough marks + * are rendered first. + */ + priority: EXTENSION_PRIORITY_LOWER, +}); diff --git a/packages/client/src/components/tiptap/extensions/codeBlock.ts b/packages/client/src/components/tiptap/extensions/codeBlock.ts new file mode 100644 index 00000000..93c9a8bb --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/codeBlock.ts @@ -0,0 +1,37 @@ +import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import { lowlight } from 'lowlight/lib/all'; +import { CodeBlockWrapper } from '../components/codeBlock'; + +const extractLanguage = (element) => element.getAttribute('lang'); + +export const CodeBlock = CodeBlockLowlight.extend({ + isolating: true, + + addAttributes() { + return { + language: { + default: null, + parseHTML: (element) => extractLanguage(element), + }, + class: { + default: 'code highlight', + }, + }; + }, + renderHTML({ HTMLAttributes }) { + return [ + 'pre', + { + ...HTMLAttributes, + class: `content-editor-code-block ${HTMLAttributes.class}`, + }, + ['code', {}, 0], + ]; + }, + addNodeView() { + return ReactNodeViewRenderer(CodeBlockWrapper); + }, +}).configure({ + lowlight, +}); diff --git a/packages/client/src/components/tiptap/extensions/color.ts b/packages/client/src/components/tiptap/extensions/color.ts new file mode 100644 index 00000000..365a159e --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/color.ts @@ -0,0 +1,3 @@ +import { Color } from '@tiptap/extension-color'; + +export { Color }; diff --git a/packages/client/src/components/tiptap/extensions/color-highlight.tsx b/packages/client/src/components/tiptap/extensions/colorHighlighter.ts similarity index 92% rename from packages/client/src/components/tiptap/extensions/color-highlight.tsx rename to packages/client/src/components/tiptap/extensions/colorHighlighter.ts index e8d227e9..6bc2bc55 100644 --- a/packages/client/src/components/tiptap/extensions/color-highlight.tsx +++ b/packages/client/src/components/tiptap/extensions/colorHighlighter.ts @@ -1,6 +1,6 @@ import { Extension } from '@tiptap/core'; import { Plugin } from 'prosemirror-state'; -import findColors from '../utils/find-colors'; +import { findColors } from '../services/color'; export const ColorHighlighter = Extension.create({ name: 'colorHighlighter', diff --git a/packages/client/src/components/tiptap/extensions/div.tsx b/packages/client/src/components/tiptap/extensions/div.tsx deleted file mode 100644 index 18a54fb7..00000000 --- a/packages/client/src/components/tiptap/extensions/div.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Node, mergeAttributes } from '@tiptap/core'; - -export const Div = Node.create({ - name: 'div', - addOptions() { - return { - HTMLAttributes: {}, - }; - }, - content: 'block*', - group: 'block', - defining: true, - parseHTML() { - return [{ tag: 'div' }]; - }, - renderHTML({ HTMLAttributes }) { - return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; - }, - // @ts-ignore - addCommands() { - return { - setDiv: - (attributes) => - ({ commands }) => { - return commands.wrapIn('div', attributes); - }, - toggleDiv: - (attributes) => - ({ commands }) => { - return commands.toggleWrap('div', attributes); - }, - unsetDiv: - (attributes) => - ({ commands }) => { - return commands.lift('div'); - }, - }; - }, -}); diff --git a/packages/client/src/components/tiptap/extensions/document.ts b/packages/client/src/components/tiptap/extensions/document.ts new file mode 100644 index 00000000..6d88d965 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/document.ts @@ -0,0 +1 @@ +export { Document } from '@tiptap/extension-document'; diff --git a/packages/client/src/components/tiptap/extensions/documentChildren.ts b/packages/client/src/components/tiptap/extensions/documentChildren.ts new file mode 100644 index 00000000..19597fa5 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/documentChildren.ts @@ -0,0 +1,70 @@ +import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import { DocumentChildrenWrapper } from '../components/documentChildren'; + +declare module '@tiptap/core' { + interface Commands { + documentChildren: { + setDocumentChildren: () => Command; + }; + } +} + +export const DocumentChildrenInputRegex = /^documentChildren\$$/; + +export const DocumentChildren = Node.create({ + name: 'documentChildren', + group: 'block', + defining: true, + draggable: true, + atom: true, + + addAttributes() { + return { + color: { + default: 'grey', + }, + text: { + default: '', + }, + }; + }, + + parseHTML() { + return [{ tag: 'div[data-type=documentChildren]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes), + ]; + }, + + // @ts-ignore + addCommands() { + return { + setDocumentChildren: + () => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: {}, + }); + }, + }; + }, + + addInputRules() { + return [ + wrappingInputRule({ + find: DocumentChildrenInputRegex, + type: this.type, + }), + ]; + }, + + addNodeView() { + return ReactNodeViewRenderer(DocumentChildrenWrapper); + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/documentReference.ts b/packages/client/src/components/tiptap/extensions/documentReference.ts new file mode 100644 index 00000000..1837ab5b --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/documentReference.ts @@ -0,0 +1,73 @@ +import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import { DocumentReferenceWrapper } from '../components/documentReference'; + +declare module '@tiptap/core' { + interface Commands { + documentReference: { + setDocumentReference: () => Command; + }; + } +} + +export const DocumentReferenceInputRegex = /^documentReference\$$/; + +export const DocumentReference = Node.create({ + name: 'documentReference', + group: 'block', + defining: true, + draggable: true, + atom: true, + + addAttributes() { + return { + wikiId: { + default: '', + }, + documentId: { + default: '', + }, + title: { + default: '', + }, + }; + }, + + parseHTML() { + return [{ tag: 'div[data-type=documentReference]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes), + ]; + }, + + // @ts-ignore + addCommands() { + return { + setDocumentReference: + () => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: {}, + }); + }, + }; + }, + + addInputRules() { + return [ + wrappingInputRule({ + find: DocumentReferenceInputRegex, + type: this.type, + }), + ]; + }, + + addNodeView() { + return ReactNodeViewRenderer(DocumentReferenceWrapper); + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/dropCursor.ts b/packages/client/src/components/tiptap/extensions/dropCursor.ts new file mode 100644 index 00000000..32d4e8bb --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/dropCursor.ts @@ -0,0 +1 @@ +export { Dropcursor } from '@tiptap/extension-dropcursor'; diff --git a/packages/client/src/components/tiptap/extensions/font-size.tsx b/packages/client/src/components/tiptap/extensions/fontSize.ts similarity index 100% rename from packages/client/src/components/tiptap/extensions/font-size.tsx rename to packages/client/src/components/tiptap/extensions/fontSize.ts diff --git a/packages/client/src/components/tiptap/extensions/footnoteDefinition.ts b/packages/client/src/components/tiptap/extensions/footnoteDefinition.ts new file mode 100644 index 00000000..4c8d96a4 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/footnoteDefinition.ts @@ -0,0 +1,21 @@ +import { mergeAttributes, Node } from '@tiptap/core'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; + +export const FootnoteDefinition = Node.create({ + name: 'footnoteDefinition', + + content: 'paragraph', + + group: 'block', + + parseHTML() { + return [ + { tag: 'section.footnotes li' }, + { tag: '.footnote-backref', priority: PARSE_HTML_PRIORITY_HIGHEST, ignore: true }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['li', mergeAttributes(HTMLAttributes), 0]; + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/footnoteReference.ts b/packages/client/src/components/tiptap/extensions/footnoteReference.ts new file mode 100644 index 00000000..91b30568 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/footnoteReference.ts @@ -0,0 +1,37 @@ +import { Node, mergeAttributes } from '@tiptap/core'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; + +export const FootnoteReference = Node.create({ + name: 'footnoteReference', + + inline: true, + + group: 'inline', + + atom: true, + + draggable: true, + + selectable: true, + + addAttributes() { + return { + footnoteId: { + default: null, + parseHTML: (element) => element.querySelector('a').getAttribute('id'), + }, + footnoteNumber: { + default: null, + parseHTML: (element) => element.textContent, + }, + }; + }, + + parseHTML() { + return [{ tag: 'sup.footnote-ref', priority: PARSE_HTML_PRIORITY_HIGHEST }]; + }, + + renderHTML({ HTMLAttributes: { footnoteNumber, footnoteId, ...HTMLAttributes } }) { + return ['sup', mergeAttributes(HTMLAttributes), footnoteNumber]; + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/footnotesSection.ts b/packages/client/src/components/tiptap/extensions/footnotesSection.ts new file mode 100644 index 00000000..78f388ab --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/footnotesSection.ts @@ -0,0 +1,19 @@ +import { mergeAttributes, Node } from '@tiptap/core'; + +export const FootnotesSection = Node.create({ + name: 'footnotesSection', + + content: 'footnoteDefinition+', + + group: 'block', + + isolating: true, + + parseHTML() { + return [{ tag: 'section.footnotes > ol' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['ol', mergeAttributes(HTMLAttributes, { class: 'footnotes gl-font-sm' }), 0]; + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/gapCursor.ts b/packages/client/src/components/tiptap/extensions/gapCursor.ts new file mode 100644 index 00000000..671b5e4e --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/gapCursor.ts @@ -0,0 +1 @@ +export { Gapcursor } from '@tiptap/extension-gapcursor'; diff --git a/packages/client/src/components/tiptap/extensions/hardBreak.ts b/packages/client/src/components/tiptap/extensions/hardBreak.ts new file mode 100644 index 00000000..879d1b0c --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/hardBreak.ts @@ -0,0 +1,3 @@ +import HardBreak from '@tiptap/extension-hard-break'; + +export { HardBreak }; diff --git a/packages/client/src/components/tiptap/extensions/heading.ts b/packages/client/src/components/tiptap/extensions/heading.ts new file mode 100644 index 00000000..c163809c --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/heading.ts @@ -0,0 +1 @@ +export { Heading } from '@tiptap/extension-heading'; diff --git a/packages/client/src/components/tiptap/extensions/horizontal-rule.tsx b/packages/client/src/components/tiptap/extensions/horizontalRule.ts similarity index 55% rename from packages/client/src/components/tiptap/extensions/horizontal-rule.tsx rename to packages/client/src/components/tiptap/extensions/horizontalRule.ts index a1a94a44..a39e38a2 100644 --- a/packages/client/src/components/tiptap/extensions/horizontal-rule.tsx +++ b/packages/client/src/components/tiptap/extensions/horizontalRule.ts @@ -8,9 +8,6 @@ export interface HorizontalRuleOptions { declare module '@tiptap/core' { interface Commands { horizontalRule: { - /** - * Add a horizontal rule - */ setHorizontalRule: () => ReturnType; }; } @@ -41,34 +38,30 @@ export const HorizontalRule = Node.create({ setHorizontalRule: () => ({ chain }) => { - return ( - chain() - .insertContent({ type: this.name }) - // set cursor after horizontal rule - .command(({ tr, dispatch }) => { - if (dispatch) { - const { $to } = tr.selection; - const posAfter = $to.end(); + return chain() + .insertContent({ type: this.name }) + .command(({ tr, dispatch }) => { + if (dispatch) { + const { $to } = tr.selection; + const posAfter = $to.end(); - if ($to.nodeAfter) { - tr.setSelection(TextSelection.create(tr.doc, $to.pos)); - } else { - // add node after horizontal rule if it’s the end of the document - const node = $to.parent.type.contentMatch.defaultType?.create(); + if ($to.nodeAfter) { + tr.setSelection(TextSelection.create(tr.doc, $to.pos)); + } else { + const node = $to.parent.type.contentMatch.defaultType?.create(); - if (node) { - tr.insert(posAfter, node); - tr.setSelection(TextSelection.create(tr.doc, posAfter)); - } + if (node) { + tr.insert(posAfter, node); + tr.setSelection(TextSelection.create(tr.doc, posAfter)); } - - tr.scrollIntoView(); } - return true; - }) - .run() - ); + tr.scrollIntoView(); + } + + return true; + }) + .run(); }, }; }, diff --git a/packages/client/src/components/tiptap/extensions/htmlMarks.ts b/packages/client/src/components/tiptap/extensions/htmlMarks.ts new file mode 100644 index 00000000..0dfd2e36 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/htmlMarks.ts @@ -0,0 +1,71 @@ +import { Mark, mergeAttributes, markInputRule } from '@tiptap/core'; +import { PARSE_HTML_PRIORITY_LOWEST } from '../constants'; +import { markInputRegex, extractMarkAttributesFromMatch } from '../services/markUtils'; + +const marks = [ + 'ins', + 'abbr', + 'bdo', + 'cite', + 'dfn', + 'mark', + 'small', + 'span', + 'time', + 'kbd', + 'q', + 'samp', + 'var', + 'ruby', + 'rp', + 'rt', +]; + +const attrs = { + time: ['datetime'], + abbr: ['title'], + span: ['dir'], + bdo: ['dir'], +}; + +export const HTMLMarks = marks.map((name) => + Mark.create({ + name, + inclusive: false, + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + addAttributes() { + return (attrs[name] || []).reduce( + (acc, attr) => ({ + ...acc, + [attr]: { + default: null, + parseHTML: (element) => element.getAttribute(attr), + }, + }), + {} + ); + }, + + parseHTML() { + return [{ tag: name, priority: PARSE_HTML_PRIORITY_LOWEST }]; + }, + + renderHTML({ HTMLAttributes }) { + return [name, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + }, + + addInputRules() { + return [ + markInputRule({ + find: markInputRegex(name), + type: this.type, + getAttributes: extractMarkAttributesFromMatch, + }), + ]; + }, + }) +); diff --git a/packages/client/src/components/tiptap/extensions/iframe.ts b/packages/client/src/components/tiptap/extensions/iframe.ts new file mode 100644 index 00000000..fbaf188f --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/iframe.ts @@ -0,0 +1,76 @@ +import { Node, mergeAttributes } from '@tiptap/core'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import { IframeWrapper } from '../components/iframe'; + +export const Iframe = Node.create({ + name: 'external-iframe', + content: '', + marks: '', + group: 'block', + draggable: true, + atom: true, + + addOptions() { + return { + HTMLAttributes: { + 'data-type': 'external-iframe', + }, + }; + }, + + addAttributes() { + return { + width: { + default: '100%', + }, + height: { + default: 54, + }, + url: { + default: null, + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'iframe[data-type="external-iframe"]', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['iframe', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; + }, + + // @ts-ignore + addCommands() { + return { + insertIframe: + (options) => + ({ tr, commands, chain, editor }) => { + if (tr.selection?.node?.type?.name == this.name) { + return commands.updateAttributes(this.name, options); + } + + const { url } = options || {}; + const { selection } = editor.state; + const pos = selection.$head; + + return chain() + .insertContentAt(pos.before(), [ + { + type: this.name, + attrs: { url }, + }, + ]) + .run(); + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(IframeWrapper); + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/iframe/index.tsx b/packages/client/src/components/tiptap/extensions/iframe/index.tsx deleted file mode 100644 index 304ff80a..00000000 --- a/packages/client/src/components/tiptap/extensions/iframe/index.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { Node, mergeAttributes } from '@tiptap/core'; -import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react'; -import { Input } from '@douyinfe/semi-ui'; -import { Resizeable } from 'components/resizeable'; -import styles from './index.module.scss'; - -const IframeNode = Node.create({ - name: 'external-iframe', - content: '', - marks: '', - group: 'block', - draggable: true, - atom: true, - - addOptions() { - return { - HTMLAttributes: { - 'data-type': 'external-iframe', - }, - }; - }, - - addAttributes() { - return { - width: { - default: '100%', - }, - height: { - default: 54, - }, - url: { - default: null, - }, - }; - }, - - parseHTML() { - return [ - { - tag: 'iframe[data-type="external-iframe"]', - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return ['iframe', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; - }, - - // @ts-ignore - addCommands() { - return { - insertIframe: - (options) => - ({ tr, commands, chain, editor }) => { - if (tr.selection?.node?.type?.name == this.name) { - return commands.updateAttributes(this.name, options); - } - - const { url } = options || {}; - const { selection } = editor.state; - const pos = selection.$head; - - return chain() - .insertContentAt(pos.before(), [ - { - type: this.name, - attrs: { url }, - }, - ]) - .run(); - }, - }; - }, -}); - -const Render = ({ editor, node, updateAttributes }) => { - const isEditable = editor.isEditable; - const { url, width, height } = node.attrs; - - const onResize = (size) => { - updateAttributes({ width: size.width, height: size.height }); - }; - const content = ( - - {isEditable && ( -
- updateAttributes({ url })} - > -
- )} - {url && ( -
- -
- )} -
- ); - - if (!isEditable && !url) { - return null; - } - - return ( - - {isEditable ? ( - - {content} - - ) : ( -
{content}
- )} -
- ); -}; - -export const Iframe = IframeNode.extend({ - addNodeView() { - return ReactNodeViewRenderer(Render); - }, -}); diff --git a/packages/client/src/components/tiptap/extensions/image.ts b/packages/client/src/components/tiptap/extensions/image.ts new file mode 100644 index 00000000..9573d5f5 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/image.ts @@ -0,0 +1,48 @@ +import { Image as BuiltInImage } from '@tiptap/extension-image'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import { ImageWrapper } from '../components/image'; + +const resolveImageEl = (element) => + element.nodeName === 'IMG' ? element : element.querySelector('img'); + +export const Image = BuiltInImage.extend({ + addOptions() { + return { + ...this.parent?.(), + inline: true, + }; + }, + addAttributes() { + return { + ...this.parent?.(), + src: { + default: null, + parseHTML: (element) => { + const img = resolveImageEl(element); + + return img.dataset.src || img.getAttribute('src'); + }, + }, + alt: { + default: null, + parseHTML: (element) => { + const img = resolveImageEl(element); + + return img.getAttribute('alt'); + }, + }, + title: { + default: null, + }, + width: { + default: 'auto', + }, + height: { + default: 'auto', + }, + }; + }, + addNodeView() { + return ReactNodeViewRenderer(ImageWrapper); + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/indent.tsx b/packages/client/src/components/tiptap/extensions/indent.tsx deleted file mode 100644 index 9bb32aec..00000000 --- a/packages/client/src/components/tiptap/extensions/indent.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { Command, Extension } from '@tiptap/core'; -import { sinkListItem, liftListItem } from 'prosemirror-schema-list'; -import { TextSelection, AllSelection, Transaction } from 'prosemirror-state'; -import { clamp } from '../utils/shared'; -import { isListActive } from '../utils/active'; -import { getNodeType } from '../utils/type'; -import { isListNode } from '../utils/node'; - -type IndentOptions = { - types: string[]; - indentLevels: number[]; - defaultIndentLevel: number; -}; - -declare module '@tiptap/core' { - interface Commands { - indent: { - indent: () => Command; - outdent: () => Command; - }; - } -} - -export enum IndentProps { - min = 0, - max = 210, - more = 30, - less = -30, -} - -function setNodeIndentMarkup(tr: Transaction, pos: number, delta: number): Transaction { - if (!tr.doc) return tr; - - const node = tr.doc.nodeAt(pos); - if (!node) return tr; - - const minIndent = IndentProps.min; - const maxIndent = IndentProps.max; - - const indent = clamp((node.attrs.indent || 0) + delta, minIndent, maxIndent); - - if (indent === node.attrs.indent) return tr; - - const nodeAttrs = { - ...node.attrs, - indent, - }; - - return tr.setNodeMarkup(pos, node.type, nodeAttrs, node.marks); -} - -function updateIndentLevel(tr: Transaction, delta: number): Transaction { - const { doc, selection } = tr; - - if (!doc || !selection) return tr; - - if (!(selection instanceof TextSelection || selection instanceof AllSelection)) { - return tr; - } - - const { from, to } = selection; - - doc.nodesBetween(from, to, (node, pos) => { - const nodeType = node.type; - - if (nodeType.name === 'paragraph' || nodeType.name === 'heading') { - tr = setNodeIndentMarkup(tr, pos, delta); - return false; - } - if (isListNode(node)) { - return false; - } - return true; - }); - - return tr; -} - -export const Indent = Extension.create({ - name: 'indent', - - addOptions() { - return { - types: ['heading', 'paragraph'], - indentLevels: [0, 30, 60, 90, 120, 150, 180, 210], - defaultIndentLevel: 0, - }; - }, - - addGlobalAttributes() { - return [ - { - types: this.options.types, - attributes: { - indent: { - default: this.options.defaultIndentLevel, - renderHTML: (attributes) => ({ - style: `margin-left: ${attributes.indent}px!important;`, - }), - parseHTML: (element) => - parseInt(element.style.marginLeft) || this.options.defaultIndentLevel, - }, - }, - }, - ]; - }, - - addCommands() { - return { - indent: - () => - ({ tr, state, dispatch }) => { - if (isListActive(this.editor)) { - const name = this.editor.can().liftListItem('taskItem') ? 'taskItem' : 'listItem'; - const type = getNodeType(name, state.schema); - return sinkListItem(type)(state, dispatch); - } - - const { selection } = state; - tr = tr.setSelection(selection); - tr = updateIndentLevel(tr, IndentProps.more); - - if (tr.docChanged) { - dispatch && dispatch(tr); - return true; - } - - return false; - }, - outdent: - () => - ({ tr, state, dispatch }) => { - if (isListActive(this.editor)) { - const name = this.editor.can().liftListItem('taskItem') ? 'taskItem' : 'listItem'; - const type = getNodeType(name, state.schema); - return liftListItem(type)(state, dispatch); - } - - const { selection } = state; - tr = tr.setSelection(selection); - tr = updateIndentLevel(tr, IndentProps.less); - - if (tr.docChanged) { - dispatch && dispatch(tr); - return true; - } - - return false; - }, - }; - }, - - // @ts-ignore - addKeyboardShortcuts() { - return { - 'Tab': () => { - return this.editor.commands.indent(); - }, - 'Shift-Tab': () => { - return this.editor.commands.outdent(); - }, - }; - }, -}); diff --git a/packages/client/src/components/tiptap/extensions/italic.ts b/packages/client/src/components/tiptap/extensions/italic.ts new file mode 100644 index 00000000..5e4ea78a --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/italic.ts @@ -0,0 +1 @@ +export { Italic } from '@tiptap/extension-italic'; diff --git a/packages/client/src/components/tiptap/extensions/katex.ts b/packages/client/src/components/tiptap/extensions/katex.ts new file mode 100644 index 00000000..cf91b919 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/katex.ts @@ -0,0 +1,71 @@ +import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import { KatexWrapper } from '../components/katex'; + +declare module '@tiptap/core' { + interface Commands { + katex: { + setKatex: () => Command; + }; + } +} + +export const KatexInputRegex = /^\$\$(.+)?\$\$$/; + +export const Katex = Node.create({ + name: 'katex', + group: 'block', + defining: true, + draggable: true, + selectable: true, + atom: true, + + addAttributes() { + return { + text: { + default: '', + }, + }; + }, + + parseHTML() { + return [{ tag: 'div[data-type=katex]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes), + ]; + }, + + // @ts-ignore + addCommands() { + return { + setKatex: + (options) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: options, + }); + }, + }; + }, + + addInputRules() { + return [ + wrappingInputRule({ + find: KatexInputRegex, + type: this.type, + getAttributes: (match) => { + return { text: match[1] }; + }, + }), + ]; + }, + + addNodeView() { + return ReactNodeViewRenderer(KatexWrapper); + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/link.ts b/packages/client/src/components/tiptap/extensions/link.ts new file mode 100644 index 00000000..bdce766b --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/link.ts @@ -0,0 +1,62 @@ +import { markInputRule } from '@tiptap/core'; +import { Link as BuiltInLink } from '@tiptap/extension-link'; + +const extractHrefFromMatch = (match) => { + return { href: match.groups.href }; +}; + +export const extractHrefFromMarkdownLink = (match) => { + /** + * Removes the last capture group from the match to satisfy + * tiptap markInputRule expectation of having the content as + * the last capture group in the match. + * + * https://github.com/ueberdosis/tiptap/blob/%40tiptap/core%402.0.0-beta.75/packages/core/src/inputRules/markInputRule.ts#L11 + */ + match.pop(); + return extractHrefFromMatch(match); +}; + +export const Link = BuiltInLink.extend({ + addOptions() { + return { + ...this.parent?.(), + openOnClick: false, + }; + }, + + addInputRules() { + const markdownLinkSyntaxInputRuleRegExp = /(?:^|\s)\[([\w|\s|-]+)\]\((?.+?)\)$/gm; + const urlSyntaxRegExp = /(?:^|\s)(?(?:https?:\/\/|www\.)[\S]+)(?:\s|\n)$/gim; + + return [ + markInputRule({ + find: markdownLinkSyntaxInputRuleRegExp, + type: this.type, + getAttributes: extractHrefFromMarkdownLink, + }), + markInputRule({ + find: urlSyntaxRegExp, + type: this.type, + getAttributes: extractHrefFromMatch, + }), + ]; + }, + addAttributes() { + return { + ...this.parent?.(), + href: { + default: null, + parseHTML: (element) => element.getAttribute('href'), + }, + title: { + title: null, + parseHTML: (element) => element.getAttribute('title'), + }, + canonicalSrc: { + default: null, + parseHTML: (element) => element.dataset.canonicalSrc, + }, + }; + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/link.tsx b/packages/client/src/components/tiptap/extensions/link.tsx deleted file mode 100644 index 5451f69a..00000000 --- a/packages/client/src/components/tiptap/extensions/link.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { Link as TLink } from '@tiptap/extension-link'; - -export const Link = TLink.extend({}); diff --git a/packages/client/src/components/tiptap/extensions/listItem.ts b/packages/client/src/components/tiptap/extensions/listItem.ts new file mode 100644 index 00000000..a47d734b --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/listItem.ts @@ -0,0 +1 @@ +export { ListItem } from '@tiptap/extension-list-item'; diff --git a/packages/client/src/components/tiptap/extensions/mind.ts b/packages/client/src/components/tiptap/extensions/mind.ts new file mode 100644 index 00000000..f27b1674 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/mind.ts @@ -0,0 +1,84 @@ +import { Node, mergeAttributes } from '@tiptap/core'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import { MindWrapper } from '../components/mind'; + +const DEFAULT_MIND_DATA = { + meta: { + name: 'jsMind', + author: 'think', + version: '0.2', + }, + format: 'node_tree', + data: { id: 'root', topic: '中心节点', children: [] }, +}; + +export const Mind = Node.create({ + name: 'jsmind', + content: '', + marks: '', + group: 'block', + draggable: true, + atom: true, + + addOptions() { + return { + HTMLAttributes: { + 'data-type': 'jsmind', + }, + }; + }, + + addAttributes() { + return { + width: { + default: '100%', + }, + height: { + default: 240, + }, + data: { + default: DEFAULT_MIND_DATA, + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'div[data-type="jsmind"]', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; + }, + + // @ts-ignore + addCommands() { + return { + insertMind: + (options) => + ({ tr, commands, chain, editor }) => { + if (tr.selection?.node?.type?.name == this.name) { + return commands.updateAttributes(this.name, options); + } + + const { selection } = editor.state; + const pos = selection.$head; + return chain() + .insertContentAt(pos.before(), [ + { + type: this.name, + attrs: { data: DEFAULT_MIND_DATA }, + }, + ]) + .run(); + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(MindWrapper); + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/orderedList.ts b/packages/client/src/components/tiptap/extensions/orderedList.ts new file mode 100644 index 00000000..8e9149c9 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/orderedList.ts @@ -0,0 +1,15 @@ +import { OrderedList as BuiltInOrderedList } from '@tiptap/extension-ordered-list'; +import { getMarkdownSource } from '../services/markdownSourceMap'; + +export const OrderedList = BuiltInOrderedList.extend({ + addAttributes() { + return { + ...this.parent?.(), + + parens: { + default: false, + parseHTML: (element) => /^[0-9]+\)/.test(getMarkdownSource(element)), + }, + }; + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/paragraph.ts b/packages/client/src/components/tiptap/extensions/paragraph.ts new file mode 100644 index 00000000..8674999e --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/paragraph.ts @@ -0,0 +1 @@ +export { Paragraph } from '@tiptap/extension-paragraph'; diff --git a/packages/client/src/components/tiptap/extensions/paste.tsx b/packages/client/src/components/tiptap/extensions/paste.tsx deleted file mode 100644 index 6b436b9e..00000000 --- a/packages/client/src/components/tiptap/extensions/paste.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { Plugin, EditorState } from 'prosemirror-state'; -import { Extension } from '@tiptap/core'; -// @ts-ignore -import { lowlight } from 'lowlight'; -import { uploadFile } from 'services/file'; -import { Attachment } from './attachment'; -import { Image } from './image'; -import { markdownSerializer } from '../markdown'; - -const isMarkActive = - (type) => - (state: EditorState): boolean => { - if (!type) { - return false; - } - - const { from, $from, to, empty } = state.selection; - - return empty - ? type.isInSet(state.storedMarks || $from.marks()) - : state.doc.rangeHasMark(from, to, type); - }; - -export default function isInCode(state: EditorState): boolean { - if (state.schema.nodes.codeBlock) { - const $head = state.selection.$head; - for (let d = $head.depth; d > 0; d--) { - if ($head.node(d).type === state.schema.nodes.codeBlock) { - return true; - } - } - } - - return isMarkActive(state.schema.marks.code)(state); -} - -const LANGUAGES = lowlight.listLanguages().reduce((a, language) => { - a[language] = language; - return a; -}, {}); - -export const acceptedMimes = { - image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'], -}; - -function isMarkdown(text: string): boolean { - // code-ish - const fences = text.match(/^```/gm); - if (fences && fences.length > 1) return true; - - // link-ish - if (text.match(/\[[^]+\]\(https?:\/\/\S+\)/gm)) return true; - if (text.match(/\[[^]+\]\(\/\S+\)/gm)) return true; - - // heading-ish - if (text.match(/^#{1,6}\s+\S+/gm)) return true; - - // list-ish - const listItems = text.match(/^[\d-*].?\s\S+/gm); - if (listItems && listItems.length > 1) return true; - - return false; -} - -function normalizePastedMarkdown(text: string): string { - const CHECKBOX_REGEX = /^\s?(\[(X|\s|_|-)\]\s(.*)?)/gim; - - while (text.match(CHECKBOX_REGEX)) { - text = text.replace(CHECKBOX_REGEX, (match) => `- ${match.trim()}`); - } - - return text; -} - -export const Paste = Extension.create({ - addProseMirrorPlugins() { - return [ - new Plugin({ - props: { - // @ts-ignore - handlePaste: async (view, event: ClipboardEvent) => { - if (view.props.editable && !view.props.editable(view.state)) { - return false; - } - if (!event.clipboardData) return false; - - const file = event.clipboardData.files[0]; - const text = event.clipboardData.getData('text/plain'); - const html = event.clipboardData.getData('text/html'); - const vscode = event.clipboardData.getData('vscode-editor-data'); - - if (file) { - event.preventDefault(); - const url = await uploadFile(file); - let node = null; - if (acceptedMimes.image.includes(file?.type)) { - node = view.props.state.schema.nodes[Image.name].create({ - src: url, - }); - } else { - node = view.props.state.schema.nodes[Attachment.name].create({ - url, - name: file.name, - }); - } - const transaction = view.state.tr.replaceSelectionWith(node); - view.dispatch(transaction); - return true; - } - - // 粘贴代码 - if (isInCode(view.state)) { - event.preventDefault(); - view.dispatch(view.state.tr.insertText(text)); - return true; - } - - const vscodeMeta = vscode ? JSON.parse(vscode) : undefined; - const pasteCodeLanguage = vscodeMeta?.mode; - - // if (pasteCodeLanguage && pasteCodeLanguage !== "markdown") { - // event.preventDefault(); - // view.dispatch( - // view.state.tr.replaceSelectionWith( - // view.state.schema.nodes.codeBlock.create({ - // language: Object.keys(LANGUAGES).includes(vscodeMeta.mode) - // ? vscodeMeta.mode - // : null, - // }) - // ) - // ); - // view.dispatch(view.state.tr.insertText(text)); - // return true; - // } - - // 处理 markdown - if (isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') { - event.preventDefault(); - const paste = markdownSerializer.deserialize({ - schema: view.props.state.schema, - content: normalizePastedMarkdown(text), - }); - const transaction = view.state.tr.replaceSelectionWith(paste); - view.dispatch(transaction); - return true; - } - return false; - }, - // @ts-ignore - handleDrop: async (view, event: any) => { - if (view.props.editable && !view.props.editable(view.state)) { - return false; - } - - const hasFiles = event.dataTransfer.files.length > 0; - if (!hasFiles) return false; - - event.preventDefault(); - - const files = Array.from(event.dataTransfer.files); - files.forEach(async (file: any) => { - if (!file) { - return; - } - const url = await uploadFile(file); - let node = null; - if (acceptedMimes.image.includes(file?.type)) { - node = view.props.state.schema.nodes[Image.name].create({ - src: url, - }); - } else { - node = view.props.state.schema.nodes[Attachment.name].create({ - url, - name: file.name, - }); - } - const transaction = view.state.tr.replaceSelectionWith(node); - view.dispatch(transaction); - return true; - }); - }, - clipboardTextSerializer: (slice) => { - const doc = this.editor.schema.topNodeType.createAndFill(undefined, slice.content); - if (!doc) { - return ''; - } - const content = markdownSerializer.serialize({ - schema: this.editor.schema, - document: doc, - }); - - return content; - }, - }, - }), - ]; - }, -}); diff --git a/packages/client/src/components/tiptap/extensions/pasteFile.ts b/packages/client/src/components/tiptap/extensions/pasteFile.ts new file mode 100644 index 00000000..54967c87 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/pasteFile.ts @@ -0,0 +1,83 @@ +import { Plugin } from 'prosemirror-state'; +import { Extension } from '@tiptap/core'; +import { uploadFile } from 'services/file'; +import { Attachment } from './attachment'; +import { Image } from './image'; + +export const acceptedMimes = { + image: ['image/jpeg', 'image/png', 'image/gif', 'image/jpg'], +}; + +export const PasteFile = Extension.create({ + addProseMirrorPlugins() { + return [ + new Plugin({ + props: { + // @ts-ignore + handlePaste: async (view, event: ClipboardEvent) => { + if (view.props.editable && !view.props.editable(view.state)) { + return false; + } + if (!event.clipboardData) return false; + + const file = event.clipboardData.files[0]; + + if (file) { + event.preventDefault(); + const url = await uploadFile(file); + let node = null; + if (acceptedMimes.image.includes(file?.type)) { + node = view.props.state.schema.nodes[Image.name].create({ + src: url, + }); + } else { + node = view.props.state.schema.nodes[Attachment.name].create({ + url, + name: file.name, + }); + } + const transaction = view.state.tr.replaceSelectionWith(node); + view.dispatch(transaction); + return true; + } + + return false; + }, + // @ts-ignore + handleDrop: async (view, event: any) => { + if (view.props.editable && !view.props.editable(view.state)) { + return false; + } + + const hasFiles = event.dataTransfer.files.length > 0; + if (!hasFiles) return false; + + event.preventDefault(); + + const files = Array.from(event.dataTransfer.files); + files.forEach(async (file: any) => { + if (!file) { + return; + } + const url = await uploadFile(file); + let node = null; + if (acceptedMimes.image.includes(file?.type)) { + node = view.props.state.schema.nodes[Image.name].create({ + src: url, + }); + } else { + node = view.props.state.schema.nodes[Attachment.name].create({ + url, + name: file.name, + }); + } + const transaction = view.state.tr.replaceSelectionWith(node); + view.dispatch(transaction); + return true; + }); + }, + }, + }), + ]; + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/pasteMarkdown.ts b/packages/client/src/components/tiptap/extensions/pasteMarkdown.ts new file mode 100644 index 00000000..9c8db06f --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/pasteMarkdown.ts @@ -0,0 +1,71 @@ +import { Extension } from '@tiptap/core'; +import { Plugin, PluginKey } from 'prosemirror-state'; +import { markdownSerializer } from '../services/serializer'; +import { EXTENSION_PRIORITY_HIGHEST } from '../constants'; + +const TEXT_FORMAT = 'text/plain'; +const HTML_FORMAT = 'text/html'; +const VS_CODE_FORMAT = 'vscode-editor-data'; + +export const PasteMarkdown = Extension.create({ + name: 'pasteMarkdown', + priority: EXTENSION_PRIORITY_HIGHEST, + // @ts-ignore + addCommands() { + return { + pasteMarkdown: (markdown) => () => { + const { editor } = this; + const { state, view } = editor; + const { tr, selection } = state; + + const document = markdownSerializer.deserialize({ + schema: view.props.state.schema, + content: markdown, + }); + + // tr.replaceWith(selection.from - 1, selection.to, document.content); + // view.dispatch(tr); + const transaction = view.state.tr.replaceSelectionWith(document); + view.dispatch(transaction); + return true; + }, + }; + }, + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey('pasteMarkdown'), + props: { + handlePaste: (_, event) => { + const { clipboardData } = event; + const content = clipboardData.getData(TEXT_FORMAT); + const hasHTML = clipboardData.types.some((type) => type === HTML_FORMAT); + const hasVsCode = clipboardData.types.some((type) => type === VS_CODE_FORMAT); + const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {}; + const language = vsCodeMeta.mode; + + if (!content || (hasHTML && !hasVsCode) || (hasVsCode && language !== 'markdown')) { + return false; + } + + // @ts-ignore + this.editor.commands.pasteMarkdown(content); + return true; + }, + clipboardTextSerializer: (slice) => { + const doc = this.editor.schema.topNodeType.createAndFill(undefined, slice.content); + if (!doc) { + return ''; + } + const content = markdownSerializer.serialize({ + schema: this.editor.schema, + content: doc, + }); + + return content; + }, + }, + }), + ]; + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/placeholder.ts b/packages/client/src/components/tiptap/extensions/placeholder.ts new file mode 100644 index 00000000..4cb8641c --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/placeholder.ts @@ -0,0 +1,3 @@ +import Placeholder from '@tiptap/extension-placeholder'; + +export { Placeholder }; diff --git a/packages/client/src/components/tiptap/extensions/status.ts b/packages/client/src/components/tiptap/extensions/status.ts new file mode 100644 index 00000000..b03f32e9 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/status.ts @@ -0,0 +1,59 @@ +import { Node, Command, mergeAttributes } from '@tiptap/core'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import { StatusWrapper } from '../components/status'; + +declare module '@tiptap/core' { + interface Commands { + status: { + setStatus: () => Command; + }; + } +} + +export const Status = Node.create({ + name: 'status', + content: 'text*', + group: 'inline', + inline: true, + atom: true, + + addAttributes() { + return { + color: { + default: 'grey', + }, + text: { + default: '', + }, + }; + }, + + parseHTML() { + return [{ tag: 'span[data-type=status]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'span', + mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes), + ]; + }, + + // @ts-ignore + addCommands() { + return { + setStatus: + (options) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: options, + }); + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(StatusWrapper); + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/strike.ts b/packages/client/src/components/tiptap/extensions/strike.ts new file mode 100644 index 00000000..dfe22eea --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/strike.ts @@ -0,0 +1 @@ +export { Strike } from '@tiptap/extension-strike'; diff --git a/packages/client/src/components/tiptap/extensions/table.ts b/packages/client/src/components/tiptap/extensions/table.ts new file mode 100644 index 00000000..4023b259 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/table.ts @@ -0,0 +1,5 @@ +import { Table as BuiltInTable } from '@tiptap/extension-table'; + +export const Table = BuiltInTable.configure({ + resizable: true, +}); diff --git a/packages/client/src/components/tiptap/extensions/table.tsx b/packages/client/src/components/tiptap/extensions/table.tsx deleted file mode 100644 index c49d4199..00000000 --- a/packages/client/src/components/tiptap/extensions/table.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import Table from '@tiptap/extension-table'; -import TableRow from '@tiptap/extension-table-row'; -import TableCell from '@tiptap/extension-table-cell'; -import TableHeader from '@tiptap/extension-table-header'; - -export { Table, TableRow, TableCell, TableHeader }; diff --git a/packages/client/src/components/tiptap/extensions/tableCell.ts b/packages/client/src/components/tiptap/extensions/tableCell.ts new file mode 100644 index 00000000..d24f72e9 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/tableCell.ts @@ -0,0 +1 @@ +export { TableCell } from '@tiptap/extension-table-cell'; diff --git a/packages/client/src/components/tiptap/extensions/tableHeader.ts b/packages/client/src/components/tiptap/extensions/tableHeader.ts new file mode 100644 index 00000000..e2acc4a9 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/tableHeader.ts @@ -0,0 +1 @@ +export { TableHeader } from '@tiptap/extension-table-header'; diff --git a/packages/client/src/components/tiptap/extensions/tableRow.ts b/packages/client/src/components/tiptap/extensions/tableRow.ts new file mode 100644 index 00000000..795a5dd9 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/tableRow.ts @@ -0,0 +1,5 @@ +import { TableRow as BuiltInTableRow } from '@tiptap/extension-table-row'; + +export const TableRow = BuiltInTableRow.extend({ + allowGapCursor: false, +}); diff --git a/packages/client/src/components/tiptap/extensions/taskItem.ts b/packages/client/src/components/tiptap/extensions/taskItem.ts new file mode 100644 index 00000000..b305c891 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/taskItem.ts @@ -0,0 +1,37 @@ +import { TaskItem as BuiltInTaskItem } from '@tiptap/extension-task-item'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; + +export const TaskItem = BuiltInTaskItem.extend({ + addOptions() { + return { + nested: true, + HTMLAttributes: {}, + }; + }, + + addAttributes() { + return { + checked: { + default: false, + parseHTML: (element) => { + const checkbox = element.querySelector('input[type=checkbox].task-list-item-checkbox'); + // @ts-ignore + return checkbox?.checked; + }, + renderHTML: (attributes) => ({ + 'data-checked': attributes.checked, + }), + keepOnSplit: false, + }, + }; + }, + + parseHTML() { + return [ + { + tag: 'li.task-list-item', + priority: PARSE_HTML_PRIORITY_HIGHEST, + }, + ]; + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/taskList.ts b/packages/client/src/components/tiptap/extensions/taskList.ts new file mode 100644 index 00000000..623dfd42 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/taskList.ts @@ -0,0 +1,38 @@ +import { mergeAttributes } from '@tiptap/core'; +import { TaskList as BuiltInTaskList } from '@tiptap/extension-task-list'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; +import { getMarkdownSource } from '../services/markdownSourceMap'; + +export const TaskList = BuiltInTaskList.extend({ + addAttributes() { + return { + numeric: { + default: false, + parseHTML: (element) => element.tagName.toLowerCase() === 'ol', + }, + start: { + default: 1, + parseHTML: (element) => + element.hasAttribute('start') ? parseInt(element.getAttribute('start') || '', 10) : 1, + }, + + parens: { + default: false, + parseHTML: (element) => /^[0-9]+\)/.test(getMarkdownSource(element)), + }, + }; + }, + + parseHTML() { + return [ + { + tag: '.task-list', + priority: PARSE_HTML_PRIORITY_HIGHEST, + }, + ]; + }, + + renderHTML({ HTMLAttributes: { numeric, ...HTMLAttributes } }) { + return [numeric ? 'ol' : 'ul', mergeAttributes(HTMLAttributes, { 'data-type': 'taskList' }), 0]; + }, +}); diff --git a/packages/client/src/components/tiptap/extensions/text.ts b/packages/client/src/components/tiptap/extensions/text.ts new file mode 100644 index 00000000..8583607d --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/text.ts @@ -0,0 +1 @@ +export { Text } from '@tiptap/extension-text'; diff --git a/packages/client/src/components/tiptap/extensions/textAlign.ts b/packages/client/src/components/tiptap/extensions/textAlign.ts new file mode 100644 index 00000000..0a9ba123 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/textAlign.ts @@ -0,0 +1,3 @@ +import TextAlign from '@tiptap/extension-text-align'; + +export { TextAlign }; diff --git a/packages/client/src/components/tiptap/extensions/textStyle.ts b/packages/client/src/components/tiptap/extensions/textStyle.ts new file mode 100644 index 00000000..e9fd6796 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/textStyle.ts @@ -0,0 +1,3 @@ +import TextStyle from '@tiptap/extension-text-style'; + +export { TextStyle }; diff --git a/packages/client/src/components/tiptap/extensions/title.tsx b/packages/client/src/components/tiptap/extensions/title.tsx index 4d915051..6d7d957e 100644 --- a/packages/client/src/components/tiptap/extensions/title.tsx +++ b/packages/client/src/components/tiptap/extensions/title.tsx @@ -1,7 +1,6 @@ import { Node, mergeAttributes } from '@tiptap/core'; -import Document from '@tiptap/extension-document'; -const Title = Node.create({ +export const Title = Node.create({ name: 'title', group: 'block', content: 'text*', @@ -26,9 +25,3 @@ const Title = Node.create({ return ['h1', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; }, }); - -const TitledDocument = Document.extend({ - content: 'title block+', -}); - -export { Document, Title, TitledDocument }; diff --git a/packages/client/src/components/tiptap/extensions/toc/index.tsx b/packages/client/src/components/tiptap/extensions/toc/index.tsx deleted file mode 100644 index 5861cf41..00000000 --- a/packages/client/src/components/tiptap/extensions/toc/index.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { Command, Node, mergeAttributes } from '@tiptap/core'; -import { ReactNodeViewRenderer } from '@tiptap/react'; - -declare module '@tiptap/core' { - interface Commands { - tableOfContents: { - setToc: () => Command; - }; - } -} - -const Component = ({ editor }) => { - const [items, setItems] = useState([]); - - 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, []); - - useEffect(() => { - if (!editor) { - return null; - } - - editor.on('update', handleUpdate); - - return () => { - editor.off('update', handleUpdate); - }; - }, [editor]); - - return null; -}; - -export const Toc = Node.create({ - name: 'tableOfContents', - group: 'block', - atom: true, - - parseHTML() { - return [ - { - tag: 'toc', - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - return ['toc', mergeAttributes(HTMLAttributes)]; - }, - - addNodeView() { - return ReactNodeViewRenderer(Component); - }, - - addGlobalAttributes() { - return [ - { - types: ['heading'], - attributes: { - id: { - default: null, - }, - }, - }, - ]; - }, - - addCommands() { - return { - setToc: - () => - ({ chain }) => { - return chain().insertContent({ type: this.name }).run(); - }, - }; - }, -}); diff --git a/packages/client/src/components/tiptap/extensions/trailing-node.tsx b/packages/client/src/components/tiptap/extensions/trailingNode.tsx similarity index 100% rename from packages/client/src/components/tiptap/extensions/trailing-node.tsx rename to packages/client/src/components/tiptap/extensions/trailingNode.tsx diff --git a/packages/client/src/components/tiptap/extensions/underline.ts b/packages/client/src/components/tiptap/extensions/underline.ts new file mode 100644 index 00000000..ce0e56c3 --- /dev/null +++ b/packages/client/src/components/tiptap/extensions/underline.ts @@ -0,0 +1,3 @@ +import Underline from '@tiptap/extension-underline'; + +export { Underline }; diff --git a/packages/client/src/components/tiptap/index.tsx b/packages/client/src/components/tiptap/index.ts similarity index 75% rename from packages/client/src/components/tiptap/index.tsx rename to packages/client/src/components/tiptap/index.ts index eed6cf9e..acd7c3e0 100644 --- a/packages/client/src/components/tiptap/index.tsx +++ b/packages/client/src/components/tiptap/index.ts @@ -1,17 +1,22 @@ -import { BaseExtension, Document, TitledDocument as DocumentWithTitle } from './base-kit'; import { HocuspocusProvider } from '@hocuspocus/provider'; import Collaboration from '@tiptap/extension-collaboration'; import CollaborationCursor from '@tiptap/extension-collaboration-cursor'; import { getRandomColor } from 'helpers/color'; +import { Document } from './extensions/document'; +import { BaseKit } from './basekit'; export { getSchema } from '@tiptap/core'; export * from './menubar'; export * from './provider'; export * from './skeleton'; -export * from './toc'; -export { Document, DocumentWithTitle }; -export const DEFAULT_EXTENSION = [...BaseExtension]; +export const DocumentWithTitle = Document.extend({ + content: 'title block+', +}); + +export { Document }; +export const DEFAULT_EXTENSION = [...BaseKit]; + export const getCollaborationExtension = (provider: HocuspocusProvider) => { return Collaboration.configure({ document: provider.document, diff --git a/packages/client/src/components/tiptap/markdown/index.tsx b/packages/client/src/components/tiptap/markdown/index.tsx deleted file mode 100644 index 6dfbdbf1..00000000 --- a/packages/client/src/components/tiptap/markdown/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './serializer'; diff --git a/packages/client/src/components/tiptap/markdown/serializer.tsx b/packages/client/src/components/tiptap/markdown/serializer.tsx deleted file mode 100644 index 00110b08..00000000 --- a/packages/client/src/components/tiptap/markdown/serializer.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model'; -import { marked } from 'marked'; -import { sanitize } from 'dompurify'; -import { - MarkdownSerializer as ProseMirrorMarkdownSerializer, - defaultMarkdownSerializer, -} from 'prosemirror-markdown'; -import { Document, TitledDocument, Title } from '../extensions/title'; -import Placeholder from '@tiptap/extension-placeholder'; -import Paragraph from '@tiptap/extension-paragraph'; -import Text from '@tiptap/extension-text'; -import Strike from '@tiptap/extension-strike'; -import Underline from '@tiptap/extension-underline'; -import TextStyle from '@tiptap/extension-text-style'; -import { Color } from '@tiptap/extension-color'; -import Blockquote from '@tiptap/extension-blockquote'; -import Bold from '@tiptap/extension-bold'; -import Code from '@tiptap/extension-code'; -import Highlight from '@tiptap/extension-highlight'; -import TextAlign from '@tiptap/extension-text-align'; -import Dropcursor from '@tiptap/extension-dropcursor'; -import Gapcursor from '@tiptap/extension-gapcursor'; -import HardBreak from '@tiptap/extension-hard-break'; -import Heading from '@tiptap/extension-heading'; -import Italic from '@tiptap/extension-italic'; -import OrderedList from '@tiptap/extension-ordered-list'; -import BulletList from '@tiptap/extension-bullet-list'; -import ListItem from '@tiptap/extension-list-item'; -import TaskList from '@tiptap/extension-task-list'; -import TaskItem from '@tiptap/extension-task-item'; -import { BackgroundColor } from '../extensions/background-color'; -import { Link } from '../extensions/link'; -import { FontSize } from '../extensions/font-size'; -import { ColorHighlighter } from '../extensions/color-highlight'; -import { Indent } from '../extensions/indent'; -import { Div } from '../extensions/div'; -import { Banner } from '../extensions/banner'; -import { CodeBlock } from '../extensions/code-block'; -import { Iframe } from '../extensions/iframe'; -import { Mind } from '../extensions/mind'; -import { Katex } from '../extensions/katex'; -import { Image } from '../extensions/image'; -import { HorizontalRule } from '../extensions/horizontal-rule'; -import { Table, TableCell, TableHeader, TableRow } from '../extensions/table'; -import { DocumentChildren } from '../extensions/documents/children'; - -import { - isPlainURL, - renderHardBreak, - renderTable, - renderTableCell, - renderTableRow, - openTag, - closeTag, - renderOrderedList, - renderImage, - renderPlayable, - renderHTMLNode, - renderContent, -} from './helpers'; - -const defaultSerializerConfig = { - marks: { - [Bold.name]: defaultMarkdownSerializer.marks.strong, - [Italic.name]: { - open: '_', - close: '_', - mixable: true, - expelEnclosingWhitespace: true, - }, - [Code.name]: defaultMarkdownSerializer.marks.code, - // [Subscript.name]: { open: "", close: "", mixable: true }, - // [Superscript.name]: { open: "", close: "", mixable: true }, - // [InlineDiff.name]: { - // mixable: true, - // open(state, mark) { - // return mark.attrs.type === "addition" ? "{+" : "{-"; - // }, - // close(state, mark) { - // return mark.attrs.type === "addition" ? "+}" : "-}"; - // }, - // }, - [Link.name]: { - open(state, mark, parent, index) { - return isPlainURL(mark, parent, index, 1) ? '<' : '['; - }, - close(state, mark, parent, index) { - const href = mark.attrs.canonicalSrc || mark.attrs.href; - - return isPlainURL(mark, parent, index, -1) - ? '>' - : `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`; - }, - }, - [Strike.name]: { - open: '~~', - close: '~~', - mixable: true, - expelEnclosingWhitespace: true, - }, - }, - - nodes: { - // [Audio.name]: renderPlayable, - [Blockquote.name]: (state, node) => { - if (node.attrs.multiline) { - state.write('>>>'); - state.ensureNewLine(); - state.renderContent(node); - state.ensureNewLine(); - state.write('>>>'); - state.closeBlock(node); - } else { - state.wrapBlock('> ', null, node, () => state.renderContent(node)); - } - }, - [BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list, - [CodeBlock.name]: (state, node) => { - state.write(`\`\`\`${node.attrs.language || ''}\n`); - state.text(node.textContent, false); - state.ensureNewLine(); - state.write('```'); - state.closeBlock(node); - }, - [Katex.name]: (state, node) => { - state.ensureNewLine(); - state.write(`\$\$${node.attrs.text || ''}\$`); - state.closeBlock(node); - }, - [DocumentChildren.name]: (state, node) => { - state.ensureNewLine(); - state.write(`documentChildren$`); - state.closeBlock(node); - }, - [Mind.name]: (state, node) => { - state.ensureNewLine(); - state.write(`mind$`); - state.closeBlock(node); - }, - // [DescriptionList.name]: renderHTMLNode("dl", true), - // [DescriptionItem.name]: (state, node, parent, index) => { - // if (index === 1) state.ensureNewLine(); - // renderHTMLNode(node.attrs.isTerm ? "dt" : "dd")(state, node); - // if (index === parent.childCount - 1) state.ensureNewLine(); - // }, - // [Details.name]: renderHTMLNode("details", true), - // [DetailsContent.name]: (state, node, parent, index) => { - // if (!index) renderHTMLNode("summary")(state, node); - // else { - // if (index === 1) state.ensureNewLine(); - // renderContent(state, node); - // if (index === parent.childCount - 1) state.ensureNewLine(); - // } - // }, - // [Emoji.name]: (state, node) => { - // const { name } = node.attrs; - - // state.write(`:${name}:`); - // }, - // [FootnoteDefinition.name]: (state, node) => { - // state.renderInline(node); - // }, - // [FootnoteReference.name]: (state, node) => { - // state.write(`[^${node.attrs.footnoteNumber}]`); - // }, - // [FootnotesSection.name]: (state, node) => { - // state.renderList(node, "", (index) => `[^${index + 1}]: `); - // }, - // [Frontmatter.name]: (state, node) => { - // const { language } = node.attrs; - // const syntax = { - // toml: "+++", - // json: ";;;", - // yaml: "---", - // }[language]; - - // state.write(`${syntax}\n`); - // state.text(node.textContent, false); - // state.ensureNewLine(); - // state.write(syntax); - // state.closeBlock(node); - // }, - [Title.name]: renderHTMLNode('div', true, true), - // [FigureCaption.name]: renderHTMLNode("figcaption"), - [HardBreak.name]: renderHardBreak, - [Heading.name]: defaultMarkdownSerializer.nodes.heading, - [HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule, - [Image.name]: renderImage, - [ListItem.name]: defaultMarkdownSerializer.nodes.list_item, - [OrderedList.name]: renderOrderedList, - [Paragraph.name]: defaultMarkdownSerializer.nodes.paragraph, - // [Reference.name]: (state, node) => { - // state.write(node.attrs.originalText || node.attrs.text); - // }, - // [TableOfContents.name]: (state, node) => { - // state.write("[[_TOC_]]"); - // state.closeBlock(node); - // }, - [Table.name]: renderTable, - [TableCell.name]: renderTableCell, - [TableHeader.name]: renderTableCell, - [TableRow.name]: renderTableRow, - [TaskItem.name]: (state, node) => { - state.write(`[${node.attrs.checked ? 'x' : ' '}] `); - state.renderContent(node); - }, - [TaskList.name]: (state, node) => { - if (node.attrs.numeric) renderOrderedList(state, node); - else defaultMarkdownSerializer.nodes.bullet_list(state, node); - }, - [Text.name]: defaultMarkdownSerializer.nodes.text, - }, -}; - -const renderMarkdown = (rawMarkdown) => { - return sanitize(marked(rawMarkdown), {}); -}; - -const createMarkdownSerializer = () => ({ - deserialize: ({ schema, content }) => { - const html = renderMarkdown(content); - if (!html) return null; - const parser = new DOMParser(); - const { body } = parser.parseFromString(html, 'text/html'); - body.append(document.createComment(content)); - const state = ProseMirrorDOMParser.fromSchema(schema).parse(body); - return state; - }, - - serialize: ({ schema, document }) => { - // const proseMirrorDocument = schema.nodeFromJSON(content); - const serializer = new ProseMirrorMarkdownSerializer( - { - ...defaultSerializerConfig.nodes, - }, - { - ...defaultSerializerConfig.marks, - } - ); - return serializer.serialize(document, { - tightLists: true, - }); - }, -}); - -export const markdownSerializer = createMarkdownSerializer(); diff --git a/packages/client/src/components/tiptap/menus/align.tsx b/packages/client/src/components/tiptap/menus/align.tsx index e5956da1..0ee3d336 100644 --- a/packages/client/src/components/tiptap/menus/align.tsx +++ b/packages/client/src/components/tiptap/menus/align.tsx @@ -6,7 +6,7 @@ import { IconAlignRight, IconAlignJustify, } from '@douyinfe/semi-icons'; -import { isTitleActive } from '../utils/active'; +import { isTitleActive } from './utils/active'; export const AlignMenu = ({ editor }) => { const current = (() => { diff --git a/packages/client/src/components/tiptap/menus/banner.tsx b/packages/client/src/components/tiptap/menus/banner.tsx index c2c8c75b..6328476c 100644 --- a/packages/client/src/components/tiptap/menus/banner.tsx +++ b/packages/client/src/components/tiptap/menus/banner.tsx @@ -6,10 +6,10 @@ import { IconClear, IconInfoCircle, } from '@douyinfe/semi-icons'; -import { BubbleMenu } from '../components/bubble-menu'; +import { BubbleMenu } from './components/bubble-menu'; import { Divider } from '../components/divider'; import { Banner } from '../extensions/banner'; -import { deleteNode } from '../utils/delete'; +import { deleteNode } from './utils/delete'; export const BannerBubbleMenu = ({ editor }) => { return ( diff --git a/packages/client/src/components/tiptap/menus/base-bubble-menu.tsx b/packages/client/src/components/tiptap/menus/base-bubble-menu.tsx index 6a36811e..6e914e7c 100644 --- a/packages/client/src/components/tiptap/menus/base-bubble-menu.tsx +++ b/packages/client/src/components/tiptap/menus/base-bubble-menu.tsx @@ -10,8 +10,8 @@ import { Iframe } from '../extensions/iframe'; import { Mind } from '../extensions/mind'; import { Table } from '../extensions/table'; import { Katex } from '../extensions/katex'; -import { DocumentReference } from '../extensions/documents/reference'; -import { DocumentChildren } from '../extensions/documents/children'; +import { DocumentReference } from '../extensions/documentReference'; +import { DocumentChildren } from '../extensions/documentChildren'; import { BaseMenu } from './base-menu'; const OTHER_BUBBLE_MENU_TYPES = [ diff --git a/packages/client/src/components/tiptap/menus/base-insert.tsx b/packages/client/src/components/tiptap/menus/base-insert.tsx index d49a4f83..42c55d0e 100644 --- a/packages/client/src/components/tiptap/menus/base-insert.tsx +++ b/packages/client/src/components/tiptap/menus/base-insert.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Button, Tooltip } from '@douyinfe/semi-ui'; import { IconQuote, IconCheckboxIndeterminate, IconLink } from '@douyinfe/semi-icons'; -import { isTitleActive } from '../utils/active'; +import { isTitleActive } from './utils/active'; import { Emoji } from './components/emoji'; export const BaseInsertMenu: React.FC<{ editor: any }> = ({ editor }) => { diff --git a/packages/client/src/components/tiptap/menus/base-menu.tsx b/packages/client/src/components/tiptap/menus/base-menu.tsx index ed35d9c4..ea94bc87 100644 --- a/packages/client/src/components/tiptap/menus/base-menu.tsx +++ b/packages/client/src/components/tiptap/menus/base-menu.tsx @@ -7,7 +7,7 @@ import { IconUnderline, IconCode, } from '@douyinfe/semi-icons'; -import { isTitleActive } from '../utils/active'; +import { isTitleActive } from './utils/active'; import { ColorMenu } from './color'; export const BaseMenu: React.FC<{ editor: any }> = ({ editor }) => { diff --git a/packages/client/src/components/tiptap/menus/color.tsx b/packages/client/src/components/tiptap/menus/color.tsx index 7f983bd0..44523e07 100644 --- a/packages/client/src/components/tiptap/menus/color.tsx +++ b/packages/client/src/components/tiptap/menus/color.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { Button, Tooltip } from '@douyinfe/semi-ui'; import { IconFont, IconMark } from '@douyinfe/semi-icons'; -import { isTitleActive } from '../utils/active'; -import { Color } from '../components/color'; +import { isTitleActive } from './utils/active'; +import { Color } from './components/color'; export const ColorMenu: React.FC<{ editor: any }> = ({ editor }) => { const { color, backgroundColor } = editor.getAttributes('textStyle'); diff --git a/packages/client/src/components/tiptap/components/bubble-menu/bubble-menu-plugin.tsx b/packages/client/src/components/tiptap/menus/components/bubble-menu/bubble-menu-plugin.tsx similarity index 100% rename from packages/client/src/components/tiptap/components/bubble-menu/bubble-menu-plugin.tsx rename to packages/client/src/components/tiptap/menus/components/bubble-menu/bubble-menu-plugin.tsx diff --git a/packages/client/src/components/tiptap/components/bubble-menu/index.tsx b/packages/client/src/components/tiptap/menus/components/bubble-menu/index.tsx similarity index 100% rename from packages/client/src/components/tiptap/components/bubble-menu/index.tsx rename to packages/client/src/components/tiptap/menus/components/bubble-menu/index.tsx diff --git a/packages/client/src/components/tiptap/components/color.tsx b/packages/client/src/components/tiptap/menus/components/color/index.tsx similarity index 100% rename from packages/client/src/components/tiptap/components/color.tsx rename to packages/client/src/components/tiptap/menus/components/color/index.tsx diff --git a/packages/client/src/components/tiptap/components/style.module.scss b/packages/client/src/components/tiptap/menus/components/color/style.module.scss similarity index 100% rename from packages/client/src/components/tiptap/components/style.module.scss rename to packages/client/src/components/tiptap/menus/components/color/style.module.scss diff --git a/packages/client/src/components/tiptap/menus/components/font-size.tsx b/packages/client/src/components/tiptap/menus/components/font-size.tsx index ce355c8e..47185357 100644 --- a/packages/client/src/components/tiptap/menus/components/font-size.tsx +++ b/packages/client/src/components/tiptap/menus/components/font-size.tsx @@ -1,6 +1,6 @@ import React, { useCallback } from 'react'; import { Select } from '@douyinfe/semi-ui'; -import { isTitleActive } from '../../utils/active'; +import { isTitleActive } from '../utils/active'; export const FONT_SIZES = [12, 13, 14, 15, 16, 19, 22, 24, 29, 32, 40, 48]; diff --git a/packages/client/src/components/tiptap/menus/components/paragraph.tsx b/packages/client/src/components/tiptap/menus/components/paragraph.tsx index 2340bf75..fc967047 100644 --- a/packages/client/src/components/tiptap/menus/components/paragraph.tsx +++ b/packages/client/src/components/tiptap/menus/components/paragraph.tsx @@ -1,6 +1,6 @@ import React, { useCallback } from 'react'; import { Select } from '@douyinfe/semi-ui'; -import { isTitleActive } from '../../utils/active'; +import { isTitleActive } from '../utils/active'; const getCurrentCaretTitle = (editor) => { if (editor.isActive('heading', { level: 1 })) return 1; diff --git a/packages/client/src/components/tiptap/menus/image.tsx b/packages/client/src/components/tiptap/menus/image.tsx index 38456390..ecd1d4c5 100644 --- a/packages/client/src/components/tiptap/menus/image.tsx +++ b/packages/client/src/components/tiptap/menus/image.tsx @@ -8,10 +8,10 @@ import { IconDelete, } from '@douyinfe/semi-icons'; import { Upload } from 'components/upload'; -import { BubbleMenu } from '../components/bubble-menu'; +import { BubbleMenu } from './components/bubble-menu'; import { Divider } from '../components/divider'; import { Image } from '../extensions/image'; -import { getImageOriginSize } from '../utils/image'; +import { getImageOriginSize } from './utils/image'; const { Text } = Typography; diff --git a/packages/client/src/components/tiptap/menus/link.tsx b/packages/client/src/components/tiptap/menus/link.tsx index 24b4f8e8..4fd29f48 100644 --- a/packages/client/src/components/tiptap/menus/link.tsx +++ b/packages/client/src/components/tiptap/menus/link.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { Space, Button, Tooltip, Input } from '@douyinfe/semi-ui'; import { IconExternalOpen, IconUnlink, IconTickCircle } from '@douyinfe/semi-icons'; -import { BubbleMenu } from '../components/bubble-menu'; +import { BubbleMenu } from './components/bubble-menu'; import { Link } from '../extensions/link'; export const LinkBubbleMenu = ({ editor }) => { diff --git a/packages/client/src/components/tiptap/menus/list.tsx b/packages/client/src/components/tiptap/menus/list.tsx index b8214145..a07f814a 100644 --- a/packages/client/src/components/tiptap/menus/list.tsx +++ b/packages/client/src/components/tiptap/menus/list.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Button, Tooltip } from '@douyinfe/semi-ui'; import { IconList, IconOrderedList, IconIndentLeft, IconIndentRight } from '@douyinfe/semi-icons'; import { IconTask } from 'components/icons'; -import { isTitleActive } from '../utils/active'; +import { isTitleActive } from './utils/active'; export const ListMenu: React.FC<{ editor: any }> = ({ editor }) => { if (!editor) { diff --git a/packages/client/src/components/tiptap/menus/media-insert.tsx b/packages/client/src/components/tiptap/menus/media-insert.tsx index 595148a6..4399289e 100644 --- a/packages/client/src/components/tiptap/menus/media-insert.tsx +++ b/packages/client/src/components/tiptap/menus/media-insert.tsx @@ -15,8 +15,8 @@ import { IconMath, } from 'components/icons'; import { GridSelect } from 'components/grid-select'; -import { isTitleActive } from '../utils/active'; -import { getImageOriginSize } from '../utils/image'; +import { isTitleActive } from './utils/active'; +import { getImageOriginSize } from './utils/image'; export const MediaInsertMenu: React.FC<{ editor: any }> = ({ editor }) => { if (!editor) { diff --git a/packages/client/src/components/tiptap/menus/table.tsx b/packages/client/src/components/tiptap/menus/table.tsx index a01aad26..1db38371 100644 --- a/packages/client/src/components/tiptap/menus/table.tsx +++ b/packages/client/src/components/tiptap/menus/table.tsx @@ -10,10 +10,8 @@ import { IconSplitCell, IconDeleteTable, } from 'components/icons'; -import { Upload } from 'components/upload'; -import { BubbleMenu } from '../components/bubble-menu'; -import { Divider } from '../components/divider'; -import { Table, TableCell } from '../extensions/table'; +import { BubbleMenu } from './components/bubble-menu'; +import { Table } from '../extensions/table'; export const TableBubbleMenu = ({ editor }) => { return ( diff --git a/packages/client/src/components/tiptap/utils/active.ts b/packages/client/src/components/tiptap/menus/utils/active.ts similarity index 100% rename from packages/client/src/components/tiptap/utils/active.ts rename to packages/client/src/components/tiptap/menus/utils/active.ts diff --git a/packages/client/src/components/tiptap/utils/delete.tsx b/packages/client/src/components/tiptap/menus/utils/delete.tsx similarity index 100% rename from packages/client/src/components/tiptap/utils/delete.tsx rename to packages/client/src/components/tiptap/menus/utils/delete.tsx diff --git a/packages/client/src/components/tiptap/utils/image.ts b/packages/client/src/components/tiptap/menus/utils/image.ts similarity index 100% rename from packages/client/src/components/tiptap/utils/image.ts rename to packages/client/src/components/tiptap/menus/utils/image.ts diff --git a/packages/client/src/components/tiptap/utils/node.ts b/packages/client/src/components/tiptap/menus/utils/node.ts similarity index 100% rename from packages/client/src/components/tiptap/utils/node.ts rename to packages/client/src/components/tiptap/menus/utils/node.ts diff --git a/packages/client/src/components/tiptap/utils/shared.ts b/packages/client/src/components/tiptap/menus/utils/shared.ts similarity index 100% rename from packages/client/src/components/tiptap/utils/shared.ts rename to packages/client/src/components/tiptap/menus/utils/shared.ts diff --git a/packages/client/src/components/tiptap/utils/type.ts b/packages/client/src/components/tiptap/menus/utils/type.ts similarity index 100% rename from packages/client/src/components/tiptap/utils/type.ts rename to packages/client/src/components/tiptap/menus/utils/type.ts diff --git a/packages/client/src/components/tiptap/utils/find-colors.tsx b/packages/client/src/components/tiptap/services/color.ts similarity index 92% rename from packages/client/src/components/tiptap/utils/find-colors.tsx rename to packages/client/src/components/tiptap/services/color.ts index 79b3331b..fd5648b6 100644 --- a/packages/client/src/components/tiptap/utils/find-colors.tsx +++ b/packages/client/src/components/tiptap/services/color.ts @@ -1,7 +1,7 @@ import { Decoration, DecorationSet } from 'prosemirror-view'; import { Node } from 'prosemirror-model'; -export default function (doc: Node): DecorationSet { +export function findColors(doc: Node): DecorationSet { const hexColor = /(#[0-9a-f]{3,6})\b/gi; const decorations: Decoration[] = []; diff --git a/packages/client/src/components/tiptap/services/dom.ts b/packages/client/src/components/tiptap/services/dom.ts new file mode 100644 index 00000000..16a789ee --- /dev/null +++ b/packages/client/src/components/tiptap/services/dom.ts @@ -0,0 +1,11 @@ +export const getParents = (element) => { + const parents = []; + let parent = element.parentNode; + + do { + parents.push(parent); + parent = parent.parentNode; + } while (parent); + + return parents; +}; diff --git a/packages/client/src/components/tiptap/utils/download.ts b/packages/client/src/components/tiptap/services/download.ts similarity index 100% rename from packages/client/src/components/tiptap/utils/download.ts rename to packages/client/src/components/tiptap/services/download.ts diff --git a/packages/client/src/components/tiptap/services/markUtils.ts b/packages/client/src/components/tiptap/services/markUtils.ts new file mode 100644 index 00000000..6ccfed78 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markUtils.ts @@ -0,0 +1,17 @@ +export const markInputRegex = (tag) => + new RegExp(`(<(${tag})((?: \\w+=".+?")+)?>([^<]+))$`, 'gm'); + +export const extractMarkAttributesFromMatch = ([, , , attrsString]) => { + const attrRegex = /(\w+)="(.+?)"/g; + const attrs = {}; + + let key; + let value; + + do { + [, key, value] = attrRegex.exec(attrsString) || []; + if (key) attrs[key] = value; + } while (key); + + return attrs; +}; diff --git a/packages/client/src/components/tiptap/services/markdownSourceMap.ts b/packages/client/src/components/tiptap/services/markdownSourceMap.ts new file mode 100644 index 00000000..61c7c3d0 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdownSourceMap.ts @@ -0,0 +1,42 @@ +const isString = (val) => typeof val === 'string'; + +export const getFullSource = (element) => { + const commentNode = element.ownerDocument.body.lastChild; + + if (commentNode?.nodeName === '#comment' && isString(commentNode.textContent)) { + return commentNode.textContent.split('\n'); + } + + return []; +}; + +const getRangeFromSourcePos = (sourcePos) => { + const [start, end] = sourcePos.split('-'); + const [startRow, startCol] = start.split(':'); + const [endRow, endCol] = end.split(':'); + + return { + start: { row: Number(startRow) - 1, col: Number(startCol) - 1 }, + end: { row: Number(endRow) - 1, col: Number(endCol) - 1 }, + }; +}; + +export const getMarkdownSource = (element) => { + if (!element.dataset.sourcepos) return undefined; + + const source = getFullSource(element); + const range = getRangeFromSourcePos(element.dataset.sourcepos); + let elSource = ''; + + for (let i = range.start.row; i <= range.end.row; i += 1) { + if (i === range.start.row) { + elSource += source[i]?.substring(range.start.col); + } else if (i === range.end.row) { + elSource += `\n${source[i]?.substring(0, range.start.col)}`; + } else { + elSource += `\n${source[i]}` || ''; + } + } + + return elSource.trim(); +}; diff --git a/packages/client/src/components/tiptap/services/marked.ts b/packages/client/src/components/tiptap/services/marked.ts new file mode 100644 index 00000000..9a67fef8 --- /dev/null +++ b/packages/client/src/components/tiptap/services/marked.ts @@ -0,0 +1,15 @@ +import markdownit from 'markdown-it'; +import sub from 'markdown-it-sub'; +import sup from 'markdown-it-sup'; +import footnote from 'markdown-it-footnote'; +import anchor from 'markdown-it-anchor'; +import tasklist from 'markdown-it-task-lists'; +import katex from '@traptitech/markdown-it-katex'; + +export const marked = markdownit() + .use(sub) + .use(sup) + .use(footnote) + .use(anchor) + .use(tasklist) + .use(katex); diff --git a/packages/client/src/components/tiptap/services/serializer.ts b/packages/client/src/components/tiptap/services/serializer.ts new file mode 100644 index 00000000..75512f77 --- /dev/null +++ b/packages/client/src/components/tiptap/services/serializer.ts @@ -0,0 +1,209 @@ +import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model'; +import { sanitize } from 'dompurify'; +import { + MarkdownSerializer as ProseMirrorMarkdownSerializer, + defaultMarkdownSerializer, +} from 'prosemirror-markdown'; +import { marked } from './marked'; +import { Attachment } from '../extensions/attachment'; +import { Banner } from '../extensions/banner'; +import { Blockquote } from '../extensions/blockquote'; +import { Bold } from '../extensions/bold'; +import { BulletList } from '../extensions/bulletList'; +import { Code } from '../extensions/code'; +import { CodeBlock } from '../extensions/codeBlock'; +import { DocumentChildren } from '../extensions/documentChildren'; +import { DocumentReference } from '../extensions/documentReference'; +import { FootnoteDefinition } from '../extensions/footnoteDefinition'; +import { FootnoteReference } from '../extensions/footnoteReference'; +import { FootnotesSection } from '../extensions/footnotesSection'; +import { HardBreak } from '../extensions/hardBreak'; +import { Heading } from '../extensions/heading'; +import { HorizontalRule } from '../extensions/horizontalRule'; +import { HTMLMarks } from '../extensions/htmlMarks'; +import { Iframe } from '../extensions/iframe'; +import { Image } from '../extensions/image'; +import { Italic } from '../extensions/italic'; +import { Katex } from '../extensions/katex'; +import { Link } from '../extensions/link'; +import { ListItem } from '../extensions/listItem'; +import { Mind } from '../extensions/mind'; +import { OrderedList } from '../extensions/orderedList'; +import { Paragraph } from '../extensions/paragraph'; +import { Strike } from '../extensions/strike'; +import { Table } from '../extensions/table'; +import { TableCell } from '../extensions/tableCell'; +import { TableHeader } from '../extensions/tableHeader'; +import { TableRow } from '../extensions/tableRow'; +import { Text } from '../extensions/text'; +import { TaskItem } from '../extensions/taskItem'; +import { TaskList } from '../extensions/taskList'; +import { Title } from '../extensions/title'; +import { + isPlainURL, + renderHardBreak, + renderTable, + renderTableCell, + renderTableRow, + openTag, + closeTag, + renderOrderedList, + renderImage, + renderHTMLNode, +} from './serializerHelpers'; + +const defaultSerializerConfig = { + marks: { + [Bold.name]: defaultMarkdownSerializer.marks.strong, + [Italic.name]: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true }, + [Code.name]: defaultMarkdownSerializer.marks.code, + [Link.name]: { + open(state, mark, parent, index) { + return isPlainURL(mark, parent, index, 1) ? '<' : '['; + }, + close(state, mark, parent, index) { + const href = mark.attrs.canonicalSrc || mark.attrs.href; + return isPlainURL(mark, parent, index, -1) + ? '>' + : `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`; + }, + }, + [Strike.name]: { + open: '~~', + close: '~~', + mixable: true, + expelEnclosingWhitespace: true, + }, + ...HTMLMarks.reduce( + (acc, { name }) => ({ + ...acc, + [name]: { + mixable: true, + open(state, node) { + return openTag(name, node.attrs); + }, + close: closeTag(name), + }, + }), + {} + ), + }, + + nodes: { + [Attachment.name]: (state, node) => { + state.ensureNewLine(); + state.write(`attachment$`); + state.closeBlock(node); + }, + [Banner.name]: (state, node) => { + state.ensureNewLine(); + state.write(`banner$`); + state.closeBlock(node); + }, + [Blockquote.name]: (state, node) => { + if (node.attrs.multiline) { + state.write('>>>'); + state.ensureNewLine(); + state.renderContent(node); + state.ensureNewLine(); + state.write('>>>'); + state.closeBlock(node); + } else { + state.wrapBlock('> ', null, node, () => state.renderContent(node)); + } + }, + [BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list, + [CodeBlock.name]: (state, node) => { + state.write(`\`\`\`${node.attrs.language || ''}\n`); + state.text(node.textContent, false); + state.ensureNewLine(); + state.write('```'); + state.closeBlock(node); + }, + [DocumentChildren.name]: (state, node) => { + state.ensureNewLine(); + state.write(`documentChildren$`); + state.closeBlock(node); + }, + [DocumentReference.name]: (state, node) => { + state.ensureNewLine(); + state.write(`documentReference$`); + state.closeBlock(node); + }, + [FootnoteDefinition.name]: (state, node) => { + state.renderInline(node); + }, + [FootnoteReference.name]: (state, node) => { + state.write(`[^${node.attrs.footnoteNumber}]`); + }, + [FootnotesSection.name]: (state, node) => { + state.renderList(node, '', (index) => `[^${index + 1}]: `); + }, + [HardBreak.name]: renderHardBreak, + [Heading.name]: defaultMarkdownSerializer.nodes.heading, + [HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule, + [Iframe.name]: renderImage, + [Image.name]: renderImage, + [Katex.name]: (state, node) => { + state.ensureNewLine(); + state.write(`\$\$${node.attrs.text || ''}\$\$`); + state.closeBlock(node); + }, + [ListItem.name]: defaultMarkdownSerializer.nodes.list_item, + [Mind.name]: (state, node) => { + state.ensureNewLine(); + state.write(`mind$`); + state.closeBlock(node); + }, + [OrderedList.name]: renderOrderedList, + [Paragraph.name]: defaultMarkdownSerializer.nodes.paragraph, + [Table.name]: renderTable, + [TableCell.name]: renderTableCell, + [TableHeader.name]: renderTableCell, + [TableRow.name]: renderTableRow, + [TaskItem.name]: (state, node) => { + state.write(`[${node.attrs.checked ? 'x' : ' '}] `); + state.renderContent(node); + }, + [TaskList.name]: (state, node) => { + if (node.attrs.numeric) renderOrderedList(state, node); + else defaultMarkdownSerializer.nodes.bullet_list(state, node); + }, + [Text.name]: defaultMarkdownSerializer.nodes.text, + [Title.name]: renderHTMLNode('h1', true, true, { class: 'title' }), + }, +}; + +const renderMarkdown = (rawMarkdown) => { + return sanitize(marked.render(rawMarkdown), {}); +}; + +const createMarkdownSerializer = () => ({ + // 将 markdown 字符串转换为 ProseMirror JSONDocument + deserialize: ({ schema, content }) => { + const html = renderMarkdown(content); + if (!html) return null; + const parser = new DOMParser(); + const { body } = parser.parseFromString(html, 'text/html'); + body.append(document.createComment(content)); + const state = ProseMirrorDOMParser.fromSchema(schema).parse(body); + return state; + }, + + // 将 ProseMirror JSONDocument 转换为 markdown 字符串 + serialize: ({ schema, content }) => { + const serializer = new ProseMirrorMarkdownSerializer( + { + ...defaultSerializerConfig.nodes, + }, + { + ...defaultSerializerConfig.marks, + } + ); + return serializer.serialize(content, { + tightLists: true, + }); + }, +}); + +export const markdownSerializer = createMarkdownSerializer(); diff --git a/packages/client/src/components/tiptap/markdown/helpers.tsx b/packages/client/src/components/tiptap/services/serializerHelpers.ts similarity index 97% rename from packages/client/src/components/tiptap/markdown/helpers.tsx rename to packages/client/src/components/tiptap/services/serializerHelpers.ts index 03facc55..f0697d3b 100644 --- a/packages/client/src/components/tiptap/markdown/helpers.tsx +++ b/packages/client/src/components/tiptap/services/serializerHelpers.ts @@ -271,9 +271,14 @@ export function renderContent(state, node, forceRenderInline) { } } -export function renderHTMLNode(tagName, forceRenderInline = false, needNewLine = false) { +export function renderHTMLNode( + tagName, + forceRenderInline = false, + needNewLine = false, + attrs = {} +) { return (state, node) => { - renderTagOpen(state, tagName, node.attrs); + renderTagOpen(state, tagName, Object.assign({}, node.attrs || {}, attrs)); renderContent(state, node, forceRenderInline); renderTagClose(state, tagName, false); if (needNewLine) { diff --git a/packages/client/src/components/tiptap/toc/index.tsx b/packages/client/src/components/tiptap/toc/index.tsx deleted file mode 100644 index 27a943bb..00000000 --- a/packages/client/src/components/tiptap/toc/index.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { Anchor } from '@douyinfe/semi-ui'; -import styles from './style.module.scss'; - -export const Toc = ({ editor, getContainer }) => { - const [items, setItems] = useState([]); - - const handleUpdate = useCallback(() => { - if (!editor) return; - - const headings = []; - - editor.state.doc.descendants((node, pos) => { - if (node.type.name === 'heading') { - const id = `heading-${headings.length + 1}`; - - headings.push({ - level: node.attrs.level, - text: node.textContent, - id, - }); - } - }); - - setItems(headings); - }, [editor]); - - useEffect(handleUpdate, []); - - useEffect(() => { - if (!editor) { - return null; - } - - editor.on('update', handleUpdate); - - return () => { - editor.off('update', handleUpdate); - }; - }, [editor]); - - return ( - - {items.map((item, index) => ( - - ))} - - ); - - // return ( - //
    - // {items.map((item, index) => ( - //
  • - // {item.text} - //
  • - // ))} - //
- // ); -}; diff --git a/packages/client/src/components/tiptap/toc/style.module.scss b/packages/client/src/components/tiptap/toc/style.module.scss deleted file mode 100644 index 8da5de4d..00000000 --- a/packages/client/src/components/tiptap/toc/style.module.scss +++ /dev/null @@ -1,27 +0,0 @@ -.toc { - background: rgba(black, 0.1); - border-radius: 0.5rem; - opacity: 0.75; - padding: 0.75rem; - - &.list { - list-style: none; - padding: 0; - - &::before { - content: "Table of Contents"; - display: block; - font-size: 0.75rem; - font-weight: 700; - letter-spacing: 0.025rem; - opacity: 0.5; - text-transform: uppercase; - } - } - - &.item { - a:hover { - opacity: 0.5; - } - } -}