diff --git a/packages/client/package.json b/packages/client/package.json index 9dd852b4..3f9d8ef1 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -53,7 +53,6 @@ "@tiptap/extension-underline": "^2.0.0-beta.23", "@tiptap/react": "^2.0.0-beta.107", "@tiptap/suggestion": "^2.0.0-beta.90", - "@traptitech/markdown-it-katex": "^3.5.0", "axios": "^0.25.0", "classnames": "^2.3.1", "copy-to-clipboard": "^3.3.1", @@ -66,11 +65,8 @@ "markdown-it-anchor": "^8.4.1", "markdown-it-container": "^3.0.0", "markdown-it-emoji": "^2.0.0", - "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", "prosemirror-tables": "^1.1.1", diff --git a/packages/client/src/components/document/collaboration/index.tsx b/packages/client/src/components/document/collaboration/index.tsx index 05f62d38..911a79a4 100644 --- a/packages/client/src/components/document/collaboration/index.tsx +++ b/packages/client/src/components/document/collaboration/index.tsx @@ -78,8 +78,8 @@ export const DocumentCollaboration: React.FC = ({ wikiId, documentId }) CollaborationEventEmitter.on(KEY, ({ states: users }) => { const newCollaborationUsers = users .filter(Boolean) - .map((state) => ({ ...state.user, clientId: state.clientId })) - .filter(Boolean); + .filter((state) => state.user) + .map((state) => ({ ...state.user, clientId: state.clientId })); if ( collaborationUsers.length === newCollaborationUsers.length && diff --git a/packages/client/src/components/document/editor/editor.tsx b/packages/client/src/components/document/editor/editor.tsx index 4320408d..a89f9dd1 100644 --- a/packages/client/src/components/document/editor/editor.tsx +++ b/packages/client/src/components/document/editor/editor.tsx @@ -1,12 +1,11 @@ import React, { useMemo, useEffect } from 'react'; import cls from 'classnames'; import { useEditor, EditorContent } from '@tiptap/react'; -import { Layout, Nav, BackTop, Toast } from '@douyinfe/semi-ui'; +import { BackTop } from '@douyinfe/semi-ui'; import { ILoginUser, IAuthority } from '@think/domains'; import { useToggle } from 'hooks/useToggle'; import { DEFAULT_EXTENSION, - Document, DocumentWithTitle, getCollaborationExtension, getCollaborationCursorExtension, @@ -16,10 +15,10 @@ import { } from 'components/tiptap'; import { DataRender } from 'components/data-render'; import { joinUser } from 'components/document/collaboration'; +import { debounce } from 'helpers/debounce'; +import { changeTitle } from './index'; import styles from './index.module.scss'; -const { Header, Content } = Layout; - interface IProps { user: ILoginUser; documentId: string; @@ -46,10 +45,6 @@ export const Editor: React.FC = ({ user, documentId, authority, classNam }); }, [documentId, user.token]); - const noTitleEditor = useEditor({ - extensions: [...DEFAULT_EXTENSION, Document], - }); - const editor = useEditor({ editable: authority && authority.editable, extensions: [ @@ -58,10 +53,12 @@ export const Editor: React.FC = ({ user, documentId, authority, classNam getCollaborationExtension(provider), getCollaborationCursorExtension(provider, user), ], - editorProps: { - // @ts-ignore - noTitleEditor, - }, + onTransaction: debounce(({ transaction }) => { + try { + const title = transaction.doc.content.firstChild.content.firstChild.textContent; + changeTitle(title); + } catch (e) {} + }, 200), }); const [loading, toggleLoading] = useToggle(true); diff --git a/packages/client/src/components/document/editor/index.module.scss b/packages/client/src/components/document/editor/index.module.scss index f14a0061..0ba4953b 100644 --- a/packages/client/src/components/document/editor/index.module.scss +++ b/packages/client/src/components/document/editor/index.module.scss @@ -5,7 +5,12 @@ flex-direction: column; > header { + position: relative; + z-index: 110; + background-color: var(--semi-color-nav-bg); height: 60px; + user-select: none; + > div { overflow: auto; } @@ -27,13 +32,15 @@ > header { position: relative; - z-index: 10001; + z-index: 110; height: 50px; padding: 0 24px; display: flex; align-items: center; overflow: hidden; + background-color: var(--semi-color-nav-bg); border-bottom: 1px solid var(--semi-color-border); + user-select: none; &.isStandardWidth { justify-content: center; diff --git a/packages/client/src/components/document/editor/index.tsx b/packages/client/src/components/document/editor/index.tsx index d021b0cf..dac5fa71 100644 --- a/packages/client/src/components/document/editor/index.tsx +++ b/packages/client/src/components/document/editor/index.tsx @@ -1,6 +1,6 @@ import Router from 'next/router'; -import React, { useCallback, useMemo } from 'react'; -import { Layout, Nav, Skeleton, Typography, Space, Button, Tooltip, Spin, Popover } from '@douyinfe/semi-ui'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Nav, Skeleton, Typography, Space, Button, Tooltip, Spin, Popover } from '@douyinfe/semi-ui'; import { IconChevronLeft, IconArticle } from '@douyinfe/semi-icons'; import { useUser } from 'data/user'; import { useDocumentDetail } from 'data/document'; @@ -13,12 +13,19 @@ import { DocumentStar } from 'components/document/star'; import { DocumentCollaboration } from 'components/document/collaboration'; import { DocumentStyle } from 'components/document/style'; import { useDocumentStyle } from 'hooks/useDocumentStyle'; +import { EventEmitter } from 'helpers/event-emitter'; import { Editor } from './editor'; import styles from './index.module.scss'; -const { Header, Content } = Layout; const { Text } = Typography; +const em = new EventEmitter(); +const TITLE_CHANGE_EVENT = 'TITLE_CHANGE_EVENT'; + +export const changeTitle = (title) => { + em.emit(TITLE_CHANGE_EVENT, title); +}; + interface IProps { documentId: string; } @@ -30,7 +37,7 @@ export const DocumentEditor: React.FC = ({ documentId }) => { const editorWrapClassNames = useMemo(() => { return width === 'standardWidth' ? styles.isStandardWidth : styles.isFullWidth; }, [width]); - + const [title, setTitle] = useState(''); const { user } = useUser(); const { data: documentAndAuth, loading: docAuthLoading, error: docAuthError } = useDocumentDetail(documentId); const { document, authority } = documentAndAuth || {}; @@ -54,13 +61,21 @@ export const DocumentEditor: React.FC = ({ documentId }) => { } normalContent={() => ( - {document.title} + {title} )} /> ); + useEffect(() => { + em.on(TITLE_CHANGE_EVENT, setTitle); + + return () => { + em.destroy(); + }; + }, []); + return (
@@ -91,10 +106,9 @@ export const DocumentEditor: React.FC = ({ documentId }) => {
} - error={null} + error={docAuthError} normalContent={() => { return ( - //
<> HTMLEl const el = container && container(); - console.log(el); - if (!el) return content; return createPortal(content, el); }; diff --git a/packages/client/src/components/template/editor/index.module.scss b/packages/client/src/components/template/editor/index.module.scss index e06e7376..769bbe2d 100644 --- a/packages/client/src/components/template/editor/index.module.scss +++ b/packages/client/src/components/template/editor/index.module.scss @@ -5,7 +5,12 @@ flex-direction: column; > header { + position: relative; + z-index: 110; + background-color: var(--semi-color-nav-bg); height: 60px; + user-select: none; + > div { overflow: auto; } @@ -27,13 +32,15 @@ > header { position: relative; - z-index: 10001; + z-index: 110; height: 50px; padding: 0 24px; display: flex; align-items: center; overflow: hidden; + background-color: var(--semi-color-nav-bg); border-bottom: 1px solid var(--semi-color-border); + user-select: none; &.isStandardWidth { > div { diff --git a/packages/client/src/components/tiptap/basekit.tsx b/packages/client/src/components/tiptap/basekit.tsx index f8436940..78bae278 100644 --- a/packages/client/src/components/tiptap/basekit.tsx +++ b/packages/client/src/components/tiptap/basekit.tsx @@ -15,9 +15,6 @@ import { Emoji } from './extensions/emoji'; import { EvokeMenu } from './extensions/evokeMenu'; import { Focus } from './extensions/focus'; 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'; @@ -34,7 +31,6 @@ import { Loading } from './extensions/loading'; import { Mind } from './extensions/mind'; import { OrderedList } from './extensions/orderedList'; import { Paragraph } from './extensions/paragraph'; -import { Paste } from './extensions/paste'; import { Placeholder } from './extensions/placeholder'; import { SearchNReplace } from './extensions/search'; import { Status } from './extensions/status'; @@ -51,6 +47,7 @@ import { TaskList } from './extensions/taskList'; import { Title } from './extensions/title'; import { TrailingNode } from './extensions/trailingNode'; import { Underline } from './extensions/underline'; +import { Paste } from './extensions/paste'; export const BaseKit = [ Attachment, @@ -70,9 +67,6 @@ export const BaseKit = [ EvokeMenu, Focus, FontSize, - FootnoteDefinition, - FootnoteReference, - FootnotesSection, Gapcursor, HardBreak, Heading, @@ -89,7 +83,6 @@ export const BaseKit = [ Mind, OrderedList, Paragraph, - Paste, Placeholder, SearchNReplace, Status, @@ -106,4 +99,5 @@ export const BaseKit = [ Title, TrailingNode, Underline, + Paste, ]; diff --git a/packages/client/src/components/tiptap/components/attachment/index.tsx b/packages/client/src/components/tiptap/components/attachment/index.tsx index 726a2d77..3f1de1d7 100644 --- a/packages/client/src/components/tiptap/components/attachment/index.tsx +++ b/packages/client/src/components/tiptap/components/attachment/index.tsx @@ -49,7 +49,7 @@ const getFileTypeIcon = (type: FileType) => { export const AttachmentWrapper = ({ editor, node, updateAttributes }) => { const $upload = useRef(); const isEditable = editor.isEditable; - const { autoTrigger, fileName, fileSize, fileExt, fileType, url, error } = node.attrs; + const { hasTrigger, fileName, fileSize, fileExt, fileType, url, error } = node.attrs; const [loading, toggleLoading] = useToggle(false); const [visible, toggleVisible] = useToggle(false); @@ -81,11 +81,11 @@ export const AttachmentWrapper = ({ editor, node, updateAttributes }) => { const type = normalizeFileType(fileType); useEffect(() => { - if (!url && !autoTrigger) { + if (!url && !hasTrigger) { selectFile(); - updateAttributes({ autoTrigger: true }); + updateAttributes({ hasTrigger: true }); } - }, [url, autoTrigger]); + }, [url, hasTrigger]); const content = (() => { if (error) { diff --git a/packages/client/src/components/tiptap/components/documentChildren/index.tsx b/packages/client/src/components/tiptap/components/documentChildren/index.tsx index e5258656..32b22498 100644 --- a/packages/client/src/components/tiptap/components/documentChildren/index.tsx +++ b/packages/client/src/components/tiptap/components/documentChildren/index.tsx @@ -8,17 +8,31 @@ import { DataRender } from 'components/data-render'; import { Empty } from 'components/empty'; import { IconDocument } from 'components/icons'; import styles from './index.module.scss'; +import { useEffect } from 'react'; const { Text } = Typography; -export const DocumentChildrenWrapper = ({ editor }) => { +export const DocumentChildrenWrapper = ({ editor, node, updateAttributes }) => { const isEditable = editor.isEditable; const { pathname, query } = useRouter(); - const wikiId = query?.wikiId; - const documentId = query?.documentId; + let { wikiId, documentId } = node.attrs; + if (!wikiId) { + query?.wikiId; + } + if (!documentId) { + documentId = query?.documentId; + } const isShare = pathname.includes('share'); const { data: documents, loading, error } = useChildrenDocument({ wikiId, documentId, isShare }); + useEffect(() => { + const attrs = node.attrs; + + if (attrs.wikiId !== wikiId || attrs.documentId !== documentId) { + updateAttributes({ wikiId, documentId }); + } + }, [node.attrs, wikiId, documentId]); + return (
diff --git a/packages/client/src/components/tiptap/components/iframe/index.tsx b/packages/client/src/components/tiptap/components/iframe/index.tsx index 061377f1..c90c1ea6 100644 --- a/packages/client/src/components/tiptap/components/iframe/index.tsx +++ b/packages/client/src/components/tiptap/components/iframe/index.tsx @@ -7,6 +7,8 @@ export const IframeWrapper = ({ editor, node, updateAttributes }) => { const isEditable = editor.isEditable; const { url, width, height } = node.attrs; + console.log('render iframe', node.attrs); + const onResize = (size) => { updateAttributes({ width: size.width, height: size.height }); }; diff --git a/packages/client/src/components/tiptap/components/image/index.tsx b/packages/client/src/components/tiptap/components/image/index.tsx index 39a6f66f..e519847e 100644 --- a/packages/client/src/components/tiptap/components/image/index.tsx +++ b/packages/client/src/components/tiptap/components/image/index.tsx @@ -11,7 +11,7 @@ const { Text } = Typography; export const ImageWrapper = ({ editor, node, updateAttributes }) => { const isEditable = editor.isEditable; - const { autoTrigger, error, src, alt, title, width, height, textAlign } = node.attrs; + const { hasTrigger, error, src, alt, title, width, height, textAlign } = node.attrs; const $upload = useRef(); const [loading, toggleLoading] = useToggle(false); @@ -45,11 +45,11 @@ export const ImageWrapper = ({ editor, node, updateAttributes }) => { }; useEffect(() => { - if (!src && !autoTrigger) { + if (!src && !hasTrigger) { selectFile(); - updateAttributes({ autoTrigger: true }); + updateAttributes({ hasTrigger: true }); } - }, [src, autoTrigger]); + }, [src, hasTrigger]); const content = (() => { if (error) { diff --git a/packages/client/src/components/tiptap/components/katex/index.module.scss b/packages/client/src/components/tiptap/components/katex/index.module.scss index 9796867f..a77831f1 100644 --- a/packages/client/src/components/tiptap/components/katex/index.module.scss +++ b/packages/client/src/components/tiptap/components/katex/index.module.scss @@ -1,5 +1,5 @@ .wrap { margin: 8px 0; - display: flex; + display: inline-flex; justify-content: center; } diff --git a/packages/client/src/components/tiptap/components/katex/index.tsx b/packages/client/src/components/tiptap/components/katex/index.tsx index bf7371b3..76c41ae7 100644 --- a/packages/client/src/components/tiptap/components/katex/index.tsx +++ b/packages/client/src/components/tiptap/components/katex/index.tsx @@ -1,5 +1,5 @@ import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'; -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { Popover, TextArea, Typography, Space } from '@douyinfe/semi-ui'; import { IconHelpCircle } from '@douyinfe/semi-icons'; import katex from 'katex'; @@ -10,6 +10,7 @@ const { Text } = Typography; export const KatexWrapper = ({ editor, node, updateAttributes }) => { const isEditable = editor.isEditable; const { text } = node.attrs; + const formatText = useMemo(() => { try { return katex.renderToString(`${text}`); @@ -25,7 +26,7 @@ export const KatexWrapper = ({ editor, node, updateAttributes }) => { ); return ( - + {isEditable ? ( { + const isEditable = editor.isEditable; + const { checked } = node.attrs; + + console.log(node.attrs); + + return ( + + updateAttributes({ checked: e.target.checked })} /> + + + ); +}; diff --git a/packages/client/src/components/tiptap/extensions/attachment.ts b/packages/client/src/components/tiptap/extensions/attachment.ts index 2cd406e7..9cabf05c 100644 --- a/packages/client/src/components/tiptap/extensions/attachment.ts +++ b/packages/client/src/components/tiptap/extensions/attachment.ts @@ -1,6 +1,7 @@ import { Node, mergeAttributes } from '@tiptap/core'; import { ReactNodeViewRenderer } from '@tiptap/react'; import { AttachmentWrapper } from '../components/attachment'; +import { getDatasetAttribute } from '../services/dataset'; declare module '@tiptap/core' { interface Commands { @@ -38,27 +39,35 @@ export const Attachment = Node.create({ return { fileName: { default: null, + parseHTML: getDatasetAttribute('filename'), }, fileSize: { default: null, + parseHTML: getDatasetAttribute('filesize'), }, fileType: { default: null, + parseHTML: getDatasetAttribute('filetype'), }, fileExt: { default: null, + parseHTML: getDatasetAttribute('fileext'), }, url: { default: null, + parseHTML: getDatasetAttribute('url'), }, - autoTrigger: { + hasTrigger: { default: false, + parseHTML: (element) => getDatasetAttribute('hastrigger')(element) === 'true', }, error: { default: null, + parseHTML: getDatasetAttribute('error'), }, }; }, + // @ts-ignore addCommands() { return { diff --git a/packages/client/src/components/tiptap/extensions/banner.ts b/packages/client/src/components/tiptap/extensions/banner.ts index a25a9bc4..c0e80480 100644 --- a/packages/client/src/components/tiptap/extensions/banner.ts +++ b/packages/client/src/components/tiptap/extensions/banner.ts @@ -1,7 +1,8 @@ import { Node, Command, mergeAttributes, wrappingInputRule } from '@tiptap/core'; import { ReactNodeViewRenderer } from '@tiptap/react'; import { BannerWrapper } from '../components/banner'; -import { typesAvailable } from '../services/markdown/markdownBanner'; +import { typesAvailable } from '../services/markdown/markdownToHTML/markdownBanner'; +import { getDatasetAttribute } from '../services/dataset'; declare module '@tiptap/core' { interface Commands { @@ -17,24 +18,15 @@ export const Banner = Node.create({ group: 'block', defining: true, - addOptions() { - return { - types: typesAvailable, - HTMLAttributes: { - class: 'banner', - }, - }; - }, - addAttributes() { return { type: { default: 'info', rendered: false, - parseHTML: (element) => element.getAttribute('data-banner'), + parseHTML: getDatasetAttribute('info'), renderHTML: (attributes) => { return { - 'data-banner': attributes.type, + 'data-type': attributes.type, 'class': `banner banner-${attributes.type}`, }; }, @@ -42,6 +34,14 @@ export const Banner = Node.create({ }; }, + addOptions() { + return { + HTMLAttributes: { + class: 'banner', + }, + }; + }, + parseHTML() { return [ { @@ -50,16 +50,8 @@ export const Banner = Node.create({ ]; }, - renderHTML({ node, HTMLAttributes }) { - const { class: classy } = this.options.HTMLAttributes; - - const attributes = { - ...this.options.HTMLAttributes, - 'data-callout': node.attrs.type, - 'class': `${classy} ${classy}-${node.attrs.type}`, - }; - - return ['div', mergeAttributes(attributes, HTMLAttributes), 0]; + renderHTML({ HTMLAttributes }) { + return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; }, // @ts-ignore diff --git a/packages/client/src/components/tiptap/extensions/blockquote.ts b/packages/client/src/components/tiptap/extensions/blockquote.ts index 4e8628e2..71d6c9bc 100644 --- a/packages/client/src/components/tiptap/extensions/blockquote.ts +++ b/packages/client/src/components/tiptap/extensions/blockquote.ts @@ -1,7 +1,7 @@ import { Blockquote as BuiltInBlockquote } from '@tiptap/extension-blockquote'; import { wrappingInputRule } from '@tiptap/core'; import { getParents } from '../services/dom'; -import { getMarkdownSource } from '../services/markdown/markdownSourceMap'; +import { getMarkdownSource } from '../services/markdown'; export const Blockquote = BuiltInBlockquote.extend({ addAttributes() { diff --git a/packages/client/src/components/tiptap/extensions/bulletList.ts b/packages/client/src/components/tiptap/extensions/bulletList.ts index 4fa3bc29..306d6e15 100644 --- a/packages/client/src/components/tiptap/extensions/bulletList.ts +++ b/packages/client/src/components/tiptap/extensions/bulletList.ts @@ -1,5 +1,5 @@ import { BulletList as BuiltInBulletList } from '@tiptap/extension-bullet-list'; -import { getMarkdownSource } from '../services/markdown/markdownSourceMap'; +import { getMarkdownSource } from '../services/markdown'; import { listInputRule } from '../services/listInputRule'; export const BulletList = BuiltInBulletList.extend({ diff --git a/packages/client/src/components/tiptap/extensions/codeBlock.ts b/packages/client/src/components/tiptap/extensions/codeBlock.ts index 93c9a8bb..962a0547 100644 --- a/packages/client/src/components/tiptap/extensions/codeBlock.ts +++ b/packages/client/src/components/tiptap/extensions/codeBlock.ts @@ -1,37 +1,324 @@ -import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'; -import { ReactNodeViewRenderer } from '@tiptap/react'; import { lowlight } from 'lowlight/lib/all'; +import { Node, textblockTypeInputRule, mergeAttributes } from '@tiptap/core'; +import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import { LowlightPlugin } from '../services/lowlightPlugin'; import { CodeBlockWrapper } from '../components/codeBlock'; -const extractLanguage = (element) => element.getAttribute('lang'); +export interface CodeBlockOptions { + /** + * Adds a prefix to language classes that are applied to code tags. + * Defaults to `'language-'`. + */ + languageClassPrefix: string; + /** + * Define whether the node should be exited on triple enter. + * Defaults to `true`. + */ + exitOnTripleEnter: boolean; + /** + * Define whether the node should be exited on arrow down if there is no node after it. + * Defaults to `true`. + */ + exitOnArrowDown: boolean; + /** + * Custom HTML attributes that should be added to the rendered HTML tag. + */ + HTMLAttributes: Record; +} -export const CodeBlock = CodeBlockLowlight.extend({ - isolating: true, +declare module '@tiptap/core' { + interface Commands { + codeBlock: { + /** + * Set a code block + */ + setCodeBlock: (attributes?: { language: string }) => ReturnType; + /** + * Toggle a code block + */ + toggleCodeBlock: (attributes?: { language: string }) => ReturnType; + }; + } +} + +export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/; +export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/; + +export const BuiltInCodeBlock = Node.create({ + name: 'codeBlock', + + addOptions() { + return { + languageClassPrefix: 'language-', + exitOnTripleEnter: true, + exitOnArrowDown: true, + HTMLAttributes: {}, + }; + }, + + content: 'text*', + + marks: '', + + group: 'block', + + code: true, + + defining: true, addAttributes() { return { language: { default: null, - parseHTML: (element) => extractLanguage(element), - }, - class: { - default: 'code highlight', + parseHTML: (element) => { + const { languageClassPrefix } = this.options; + const classNames = Array.from(element.firstElementChild?.classList || element.classList || []); + const languages = classNames + .filter((className) => className.startsWith(languageClassPrefix)) + .map((className) => className.replace(languageClassPrefix, '')); + const language = languages[0]; + + if (!language) { + return null; + } + + return language; + }, + rendered: false, }, }; }, - renderHTML({ HTMLAttributes }) { + + parseHTML() { return [ - 'pre', { - ...HTMLAttributes, - class: `content-editor-code-block ${HTMLAttributes.class}`, + tag: 'pre', + preserveWhitespace: 'full', }, - ['code', {}, 0], ]; }, + + renderHTML({ node, HTMLAttributes }) { + return [ + 'pre', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + [ + 'code', + { + class: node.attrs.language ? this.options.languageClassPrefix + node.attrs.language : null, + }, + 0, + ], + ]; + }, + + addCommands() { + return { + setCodeBlock: + (attributes) => + ({ commands }) => { + return commands.setNode(this.name, attributes); + }, + toggleCodeBlock: + (attributes) => + ({ commands }) => { + return commands.toggleNode(this.name, 'paragraph', attributes); + }, + }; + }, + + addKeyboardShortcuts() { + return { + 'Mod-Alt-c': () => this.editor.commands.toggleCodeBlock(), + + // remove code block when at start of document or code block is empty + 'Backspace': () => { + const { empty, $anchor } = this.editor.state.selection; + const isAtStart = $anchor.pos === 1; + + if (!empty || $anchor.parent.type.name !== this.name) { + return false; + } + + if (isAtStart || !$anchor.parent.textContent.length) { + return this.editor.commands.clearNodes(); + } + + return false; + }, + + // exit node on triple enter + 'Enter': ({ editor }) => { + if (!this.options.exitOnTripleEnter) { + return false; + } + + const { state } = editor; + const { selection } = state; + const { $from, empty } = selection; + + if (!empty || $from.parent.type !== this.type) { + return false; + } + + const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2; + const endsWithDoubleNewline = $from.parent.textContent.endsWith('\n\n'); + + if (!isAtEnd || !endsWithDoubleNewline) { + return false; + } + + return editor + .chain() + .command(({ tr }) => { + tr.delete($from.pos - 2, $from.pos); + + return true; + }) + .exitCode() + .run(); + }, + + // exit node on arrow down + 'ArrowDown': ({ editor }) => { + if (!this.options.exitOnArrowDown) { + return false; + } + + const { state } = editor; + const { selection, doc } = state; + const { $from, empty } = selection; + + if (!empty || $from.parent.type !== this.type) { + return false; + } + + const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2; + + if (!isAtEnd) { + return false; + } + + const after = $from.after(); + + if (after === undefined) { + return false; + } + + const nodeAfter = doc.nodeAt(after); + + if (nodeAfter) { + return false; + } + + return editor.commands.exitCode(); + }, + }; + }, + + addInputRules() { + return [ + textblockTypeInputRule({ + find: backtickInputRegex, + type: this.type, + getAttributes: (match) => ({ + language: match[1], + }), + }), + textblockTypeInputRule({ + find: tildeInputRegex, + type: this.type, + getAttributes: (match) => ({ + language: match[1], + }), + }), + ]; + }, + + addProseMirrorPlugins() { + return [ + // this plugin creates a code block for pasted content from VS Code + // we can also detect the copied code language + new Plugin({ + key: new PluginKey('codeBlockVSCodeHandler'), + props: { + handlePaste: (view, event) => { + if (!event.clipboardData) { + return false; + } + + // don’t create a new code block within code blocks + if (this.editor.isActive(this.type.name)) { + return false; + } + + const text = event.clipboardData.getData('text/plain'); + const vscode = event.clipboardData.getData('vscode-editor-data'); + const vscodeData = vscode ? JSON.parse(vscode) : undefined; + const language = vscodeData?.mode; + + if (!text || !language) { + return false; + } + + const { tr } = view.state; + + // create an empty code block + tr.replaceSelectionWith(this.type.create({ language })); + + // put cursor inside the newly created code block + tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(0, tr.selection.from - 2)))); + + // add text to code block + // strip carriage return chars from text pasted as code + // see: https://github.com/ProseMirror/prosemirror-view/commit/a50a6bcceb4ce52ac8fcc6162488d8875613aacd + tr.insertText(text.replace(/\r\n?/g, '\n')); + + // store meta information + // this is useful for other plugins that depends on the paste event + // like the paste rule plugin + tr.setMeta('paste', true); + + view.dispatch(tr); + + return true; + }, + }, + }), + ]; + }, +}); + +export interface CodeBlockLowlightOptions extends CodeBlockOptions { + lowlight: any; + defaultLanguage: string | null | undefined; +} + +export const CodeBlock = BuiltInCodeBlock.extend({ + addOptions() { + return { + ...this.parent?.(), + lowlight, + defaultLanguage: null, + }; + }, + + addProseMirrorPlugins() { + return [ + ...(this.parent?.() || []), + LowlightPlugin({ + name: this.name, + lowlight: this.options.lowlight, + defaultLanguage: this.options.defaultLanguage, + }), + ]; + }, + addNodeView() { return ReactNodeViewRenderer(CodeBlockWrapper); }, }).configure({ lowlight, + defaultLanguage: 'auto', }); diff --git a/packages/client/src/components/tiptap/extensions/documentChildren.ts b/packages/client/src/components/tiptap/extensions/documentChildren.ts index 5609c2f8..cfae46de 100644 --- a/packages/client/src/components/tiptap/extensions/documentChildren.ts +++ b/packages/client/src/components/tiptap/extensions/documentChildren.ts @@ -1,6 +1,7 @@ import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core'; import { ReactNodeViewRenderer } from '@tiptap/react'; import { DocumentChildrenWrapper } from '../components/documentChildren'; +import { getDatasetAttribute } from '../services/dataset'; declare module '@tiptap/core' { interface Commands { @@ -21,23 +22,35 @@ export const DocumentChildren = Node.create({ addAttributes() { return { - color: { - default: 'grey', - }, - text: { + wikiId: { default: '', + parseHTML: getDatasetAttribute('wikiId'), + }, + documentId: { + default: '', + parseHTML: getDatasetAttribute('documentId'), + }, + }; + }, + addOptions() { + return { + HTMLAttributes: { + class: 'documentChildren', }, }; }, parseHTML() { - return [{ tag: 'div[data-type=documentChildren]' }]; + return [ + { + tag: 'div', + }, + ]; }, renderHTML({ HTMLAttributes }) { - return ['div', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)]; + return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; }, - // @ts-ignore addCommands() { return { diff --git a/packages/client/src/components/tiptap/extensions/documentReference.ts b/packages/client/src/components/tiptap/extensions/documentReference.ts index 08d09a33..179afb47 100644 --- a/packages/client/src/components/tiptap/extensions/documentReference.ts +++ b/packages/client/src/components/tiptap/extensions/documentReference.ts @@ -1,6 +1,7 @@ import { Node, mergeAttributes, wrappingInputRule } from '@tiptap/core'; import { ReactNodeViewRenderer } from '@tiptap/react'; import { DocumentReferenceWrapper } from '../components/documentReference'; +import { getDatasetAttribute } from '../services/dataset'; declare module '@tiptap/core' { interface Commands { @@ -23,22 +24,37 @@ export const DocumentReference = Node.create({ return { wikiId: { default: '', + parseHTML: getDatasetAttribute('wikiId'), }, documentId: { default: '', + parseHTML: getDatasetAttribute('documentId'), }, title: { default: '', + parseHTML: getDatasetAttribute('title'), + }, + }; + }, + + addOptions() { + return { + HTMLAttributes: { + class: 'documentReference', }, }; }, parseHTML() { - return [{ tag: 'div[data-type=documentReference]' }]; + return [ + { + tag: 'div', + }, + ]; }, renderHTML({ HTMLAttributes }) { - return ['div', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)]; + return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; }, // @ts-ignore diff --git a/packages/client/src/components/tiptap/extensions/footnoteDefinition.ts b/packages/client/src/components/tiptap/extensions/footnoteDefinition.ts deleted file mode 100644 index 4c8d96a4..00000000 --- a/packages/client/src/components/tiptap/extensions/footnoteDefinition.ts +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 91b30568..00000000 --- a/packages/client/src/components/tiptap/extensions/footnoteReference.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 78f388ab..00000000 --- a/packages/client/src/components/tiptap/extensions/footnotesSection.ts +++ /dev/null @@ -1,19 +0,0 @@ -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/horizontalRule.ts b/packages/client/src/components/tiptap/extensions/horizontalRule.ts index a39e38a2..f9c37438 100644 --- a/packages/client/src/components/tiptap/extensions/horizontalRule.ts +++ b/packages/client/src/components/tiptap/extensions/horizontalRule.ts @@ -19,18 +19,16 @@ export const HorizontalRule = Node.create({ addOptions() { return { - HTMLAttributes: { - class: 'hr-line', - }, + HTMLAttributes: {}, }; }, parseHTML() { - return [{ tag: 'div[class=hr-line]' }]; + return [{ tag: 'hr' }]; }, renderHTML({ HTMLAttributes }) { - return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; + return ['hr', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; }, addCommands() { diff --git a/packages/client/src/components/tiptap/extensions/htmlMarks.ts b/packages/client/src/components/tiptap/extensions/htmlMarks.ts index 0dfd2e36..cd4f1605 100644 --- a/packages/client/src/components/tiptap/extensions/htmlMarks.ts +++ b/packages/client/src/components/tiptap/extensions/htmlMarks.ts @@ -2,24 +2,7 @@ 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', -]; +export const marks = [{ name: 'underline', tag: 'u' }]; const attrs = { time: ['datetime'], @@ -28,9 +11,10 @@ const attrs = { bdo: ['dir'], }; -export const HTMLMarks = marks.map((name) => +export const HTMLMarks = marks.slice(1).map(({ name, tag }) => Mark.create({ name, + tag, inclusive: false, addOptions() { return { @@ -51,17 +35,17 @@ export const HTMLMarks = marks.map((name) => }, parseHTML() { - return [{ tag: name, priority: PARSE_HTML_PRIORITY_LOWEST }]; + return [{ tag: tag, priority: PARSE_HTML_PRIORITY_LOWEST }]; }, renderHTML({ HTMLAttributes }) { - return [name, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + return [tag, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; }, addInputRules() { return [ markInputRule({ - find: markInputRegex(name), + find: markInputRegex(tag), 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 index bdd337e8..109d400d 100644 --- a/packages/client/src/components/tiptap/extensions/iframe.ts +++ b/packages/client/src/components/tiptap/extensions/iframe.ts @@ -1,6 +1,7 @@ import { Node, mergeAttributes } from '@tiptap/core'; import { ReactNodeViewRenderer } from '@tiptap/react'; import { IframeWrapper } from '../components/iframe'; +import { getDatasetAttribute } from '../services/dataset'; declare module '@tiptap/core' { interface Commands { @@ -11,7 +12,7 @@ declare module '@tiptap/core' { } export const Iframe = Node.create({ - name: 'external-iframe', + name: 'iframe', content: '', marks: '', group: 'block', @@ -21,7 +22,7 @@ export const Iframe = Node.create({ addOptions() { return { HTMLAttributes: { - 'data-type': 'external-iframe', + class: 'iframe', }, }; }, @@ -30,12 +31,15 @@ export const Iframe = Node.create({ return { width: { default: '100%', + parseHTML: getDatasetAttribute('width'), }, height: { - default: 54, + default: 200, + parseHTML: getDatasetAttribute('height'), }, url: { default: null, + parseHTML: getDatasetAttribute('url'), }, }; }, @@ -43,7 +47,7 @@ export const Iframe = Node.create({ parseHTML() { return [ { - tag: 'iframe[data-type="external-iframe"]', + tag: 'iframe', }, ]; }, diff --git a/packages/client/src/components/tiptap/extensions/image.ts b/packages/client/src/components/tiptap/extensions/image.ts index 00325d7b..4a308859 100644 --- a/packages/client/src/components/tiptap/extensions/image.ts +++ b/packages/client/src/components/tiptap/extensions/image.ts @@ -51,7 +51,7 @@ export const Image = BuiltInImage.extend({ height: { default: 'auto', }, - autoTrigger: { + hasTrigger: { default: false, }, error: { diff --git a/packages/client/src/components/tiptap/extensions/katex.ts b/packages/client/src/components/tiptap/extensions/katex.ts index 0df0480f..91b992ce 100644 --- a/packages/client/src/components/tiptap/extensions/katex.ts +++ b/packages/client/src/components/tiptap/extensions/katex.ts @@ -14,26 +14,36 @@ export const KatexInputRegex = /^\$\$(.+)?\$\$$/; export const Katex = Node.create({ name: 'katex', - group: 'block', - defining: true, - draggable: true, + group: 'inline', + inline: true, selectable: true, atom: true, + addOptions() { + return { + HTMLAttributes: { + class: 'katex', + }, + }; + }, + addAttributes() { return { text: { default: '', + parseHTML: (element) => { + return element.getAttribute('data-text'); + }, }, }; }, parseHTML() { - return [{ tag: 'div[data-type=katex]' }]; + return [{ tag: 'span.katex' }]; }, renderHTML({ HTMLAttributes }) { - return ['div', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)]; + return ['span', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)]; }, // @ts-ignore diff --git a/packages/client/src/components/tiptap/extensions/mind.ts b/packages/client/src/components/tiptap/extensions/mind.ts index f55d47fc..4831927b 100644 --- a/packages/client/src/components/tiptap/extensions/mind.ts +++ b/packages/client/src/components/tiptap/extensions/mind.ts @@ -1,6 +1,8 @@ import { Node, mergeAttributes, nodeInputRule } from '@tiptap/core'; import { ReactNodeViewRenderer } from '@tiptap/react'; +import { safeJSONParse } from 'helpers/json'; import { MindWrapper } from '../components/mind'; +import { getDatasetAttribute } from '../services/dataset'; const DEFAULT_MIND_DATA = { meta: { @@ -21,31 +23,34 @@ declare module '@tiptap/core' { } export const Mind = Node.create({ - name: 'jsmind', + name: 'mind', content: '', marks: '', group: 'block', draggable: true, atom: true, - addOptions() { - return { - HTMLAttributes: { - 'data-type': 'jsmind', - }, - }; - }, - addAttributes() { return { width: { default: '100%', + parseHTML: getDatasetAttribute('width'), }, height: { default: 240, + parseHTML: getDatasetAttribute('height'), }, data: { default: DEFAULT_MIND_DATA, + parseHTML: getDatasetAttribute('data', true), + }, + }; + }, + + addOptions() { + return { + HTMLAttributes: { + class: 'mind', }, }; }, @@ -53,7 +58,7 @@ export const Mind = Node.create({ parseHTML() { return [ { - tag: 'div[data-type="jsmind"]', + tag: 'div', }, ]; }, diff --git a/packages/client/src/components/tiptap/extensions/orderedList.ts b/packages/client/src/components/tiptap/extensions/orderedList.ts index c0746f85..c2b478e2 100644 --- a/packages/client/src/components/tiptap/extensions/orderedList.ts +++ b/packages/client/src/components/tiptap/extensions/orderedList.ts @@ -1,5 +1,5 @@ import { OrderedList as BuiltInOrderedList } from '@tiptap/extension-ordered-list'; -import { getMarkdownSource } from '../services/markdown/markdownSourceMap'; +import { getMarkdownSource } from '../services/markdown'; export const OrderedList = BuiltInOrderedList.extend({ addAttributes() { diff --git a/packages/client/src/components/tiptap/extensions/paste.ts b/packages/client/src/components/tiptap/extensions/paste.ts index 811e3e75..c8393bde 100644 --- a/packages/client/src/components/tiptap/extensions/paste.ts +++ b/packages/client/src/components/tiptap/extensions/paste.ts @@ -1,10 +1,15 @@ import { Extension } from '@tiptap/core'; import { Plugin, PluginKey } from 'prosemirror-state'; -import { markdownSerializer } from '../services/markdown'; import { EXTENSION_PRIORITY_HIGHEST } from '../constants'; import { handleFileEvent } from '../services/upload'; import { isInCode, LANGUAGES } from '../services/code'; -import { isMarkdown, normalizePastedMarkdown } from '../services/markdown/helpers'; +import { + isMarkdown, + normalizePastedMarkdown, + markdownToProsemirror, + prosemirrorToMarkdown, +} from '../services/markdown'; +import { isTitleNode } from '../services/node'; export const Paste = Extension.create({ name: 'paste', @@ -39,7 +44,7 @@ export const Paste = Extension.create({ // 粘贴代码 if (isInCode(view.state)) { event.preventDefault(); - view.dispatch(view.state.tr.insertText(text)); + view.dispatch(view.state.tr.insertText(text).scrollIntoView()); return true; } @@ -55,23 +60,29 @@ export const Paste = Extension.create({ }) ) ); - view.dispatch(view.state.tr.insertText(text)); + view.dispatch(view.state.tr.insertText(text).scrollIntoView()); return true; } // 处理 markdown if (isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') { event.preventDefault(); - // FIXME: 由于 title schema 的存在导致反序列化必有 title 节点存在 - // const hasTitle = isTitleNode(view.props.state.doc.content.firstChild); - let schema = view.props.state.schema; - const doc = markdownSerializer.deserialize({ + const firstNode = view.props.state.doc.content.firstChild; + const hasTitle = isTitleNode(firstNode) && firstNode.content.size > 0; + const schema = view.props.state.schema; + const doc = markdownToProsemirror({ schema, content: normalizePastedMarkdown(text), + hasTitle, }); - // @ts-ignore - const transaction = view.state.tr.insert(view.state.selection.head, doc); - view.dispatch(transaction); + let tr = view.state.tr; + const selection = tr.selection; + view.state.doc.nodesBetween(selection.from, selection.to, (node, position) => { + const startPosition = hasTitle ? Math.min(position, selection.from) : 0; + const endPosition = Math.min(position + node.nodeSize, selection.to); + tr = tr.replaceWith(startPosition, endPosition, view.state.schema.nodeFromJSON(doc)); + }); + view.dispatch(tr.scrollIntoView()); return true; } @@ -105,12 +116,13 @@ export const Paste = Extension.create({ return false; }, clipboardTextSerializer: (slice) => { - const doc = this.editor.schema.topNodeType.createAndFill(undefined, slice.content); + const doc = slice.content; + if (!doc) { return ''; } - const content = markdownSerializer.serialize({ - schema: this.editor.schema, + + const content = prosemirrorToMarkdown({ content: doc, }); diff --git a/packages/client/src/components/tiptap/extensions/status.ts b/packages/client/src/components/tiptap/extensions/status.ts index 50676708..00107d97 100644 --- a/packages/client/src/components/tiptap/extensions/status.ts +++ b/packages/client/src/components/tiptap/extensions/status.ts @@ -1,6 +1,7 @@ import { Node, mergeAttributes } from '@tiptap/core'; import { ReactNodeViewRenderer } from '@tiptap/react'; import { StatusWrapper } from '../components/status'; +import { getDatasetAttribute } from '../services/dataset'; declare module '@tiptap/core' { interface Commands { @@ -20,19 +21,33 @@ export const Status = Node.create({ return { color: { default: 'grey', + parseHTML: getDatasetAttribute('color'), }, text: { default: '', + parseHTML: getDatasetAttribute('text'), + }, + }; + }, + + addOptions() { + return { + HTMLAttributes: { + class: 'status', }, }; }, parseHTML() { - return [{ tag: 'span[data-type=status]' }]; + return [ + { + tag: 'div', + }, + ]; }, renderHTML({ HTMLAttributes }) { - return ['span', mergeAttributes((this.options && this.options.HTMLAttributes) || {}, HTMLAttributes)]; + return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; }, // @ts-ignore diff --git a/packages/client/src/components/tiptap/extensions/table.ts b/packages/client/src/components/tiptap/extensions/table.ts index 097b4dec..519fc6ba 100644 --- a/packages/client/src/components/tiptap/extensions/table.ts +++ b/packages/client/src/components/tiptap/extensions/table.ts @@ -1,5 +1,56 @@ +import { mergeAttributes } from '@tiptap/core'; import { Table as BuiltInTable } from '@tiptap/extension-table'; -export const Table = BuiltInTable.configure({ - resizable: false, +export const Table = BuiltInTable.extend({ + addAttributes() { + return { + style: { + default: null, + }, + }; + }, + renderHTML({ node, HTMLAttributes }) { + let totalWidth = 0; + let fixedWidth = true; + + try { + // use first row to determine width of table; + // @ts-ignore + const tr = node.content.content[0]; + tr.content.content.forEach((td) => { + if (td.attrs.colwidth) { + td.attrs.colwidth.forEach((col) => { + if (!col) { + fixedWidth = false; + totalWidth += this.options.cellMinWidth; + } else { + totalWidth += col; + } + }); + } else { + fixedWidth = false; + const colspan = td.attrs.colspan ? td.attrs.colspan : 1; + totalWidth += this.options.cellMinWidth * colspan; + } + }); + } catch (error) { + fixedWidth = false; + } + + if (fixedWidth && totalWidth > 0) { + HTMLAttributes.style = `width: ${totalWidth}px;`; + } else if (totalWidth && totalWidth > 0) { + HTMLAttributes.style = `min-width: 100%`; + } else { + HTMLAttributes.style = null; + } + + return [ + 'div', + { class: 'tableWrapper' }, + ['table', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), ['tbody', 0]], + ]; + }, +}).configure({ + resizable: true, }); diff --git a/packages/client/src/components/tiptap/extensions/tableCell.tsx b/packages/client/src/components/tiptap/extensions/tableCell.tsx index b4a8affc..f3f80654 100644 --- a/packages/client/src/components/tiptap/extensions/tableCell.tsx +++ b/packages/client/src/components/tiptap/extensions/tableCell.tsx @@ -1,6 +1,7 @@ import ReactDOM from 'react-dom'; import { Button } from '@douyinfe/semi-ui'; import { IconDelete, IconPlus } from '@douyinfe/semi-icons'; +import { mergeAttributes } from '@tiptap/core'; import { TableCell as BuiltInTableCell } from '@tiptap/extension-table-cell'; import { Tooltip } from 'components/tooltip'; import { Plugin, PluginKey } from 'prosemirror-state'; @@ -16,6 +17,56 @@ import { import { FloatMenuView } from '../views/floatMenuView'; export const TableCell = BuiltInTableCell.extend({ + addAttributes() { + return { + colspan: { + default: 1, + }, + rowspan: { + default: 1, + }, + colwidth: { + default: null, + parseHTML: (element) => { + const colwidth = element.getAttribute('colwidth'); + const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null; + + return value; + }, + }, + style: { + default: null, + }, + }; + }, + + renderHTML({ HTMLAttributes }) { + let totalWidth = 0; + let fixedWidth = true; + + if (HTMLAttributes.colwidth) { + HTMLAttributes.colwidth.forEach((col) => { + if (!col) { + fixedWidth = false; + } else { + totalWidth += col; + } + }); + } else { + fixedWidth = false; + } + + if (fixedWidth && totalWidth > 0) { + HTMLAttributes.style = `width: ${totalWidth}px;`; + } else if (totalWidth && totalWidth > 0) { + HTMLAttributes.style = `min-width: ${totalWidth}px`; + } else { + HTMLAttributes.style = null; + } + + return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + }, + addProseMirrorPlugins() { const extensionThis = this; let selectedRowIndex = -1; @@ -27,7 +78,7 @@ export const TableCell = BuiltInTableCell.extend({ new FloatMenuView({ editor: this.editor, tippyOptions: { - zIndex: 10000, + zIndex: 100, offset: [-28, 0], }, shouldShow: ({ editor }, floatMenuView) => { diff --git a/packages/client/src/components/tiptap/extensions/tableHeader.tsx b/packages/client/src/components/tiptap/extensions/tableHeader.tsx index f9b4a356..acd512c5 100644 --- a/packages/client/src/components/tiptap/extensions/tableHeader.tsx +++ b/packages/client/src/components/tiptap/extensions/tableHeader.tsx @@ -8,7 +8,62 @@ import { Decoration, DecorationSet } from 'prosemirror-view'; import { getCellsInRow, isColumnSelected, isTableSelected, selectColumn } from '../services/table'; import { FloatMenuView } from '../views/floatMenuView'; +// @flow +/* eslint-disable no-unused-vars */ +import { mergeAttributes } from '@tiptap/core'; +// import TableHeader from "@tiptap/extension-table-header"; + export const TableHeader = BuiltInTableHeader.extend({ + addAttributes() { + return { + colspan: { + default: 1, + }, + rowspan: { + default: 1, + }, + colwidth: { + default: null, + parseHTML: (element) => { + const colwidth = element.getAttribute('colwidth'); + const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null; + + return value; + }, + }, + style: { + default: null, + }, + }; + }, + + renderHTML({ HTMLAttributes }) { + let totalWidth = 0; + let fixedWidth = true; + + if (HTMLAttributes.colwidth) { + HTMLAttributes.colwidth.forEach((col) => { + if (!col) { + fixedWidth = false; + } else { + totalWidth += col; + } + }); + } else { + fixedWidth = false; + } + + if (fixedWidth && totalWidth > 0) { + HTMLAttributes.style = `width: ${totalWidth}px;`; + } else if (totalWidth && totalWidth > 0) { + HTMLAttributes.style = `min-width: ${totalWidth}px`; + } else { + HTMLAttributes.style = null; + } + + return ['th', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + }, + addProseMirrorPlugins() { const extensionThis = this; @@ -19,9 +74,9 @@ export const TableHeader = BuiltInTableHeader.extend({ new FloatMenuView({ editor: this.editor, tippyOptions: { - zIndex: 10000, + zIndex: 100, }, - shouldShow: ({ editor }) => { + shouldShow: ({ editor }, floatMenuView) => { if (!editor.isEditable) { return false; } @@ -30,6 +85,12 @@ export const TableHeader = BuiltInTableHeader.extend({ return false; } const cells = getCellsInRow(0)(selection); + + if (cells && cells[0]) { + const node = editor.view.nodeDOM(cells[0].pos) as HTMLElement; + floatMenuView.setConatiner(node.parentElement.parentElement.parentElement.parentElement); + } + return !!cells?.some((cell, index) => isColumnSelected(index)(selection)); }, init: (dom, editor) => { diff --git a/packages/client/src/components/tiptap/extensions/taskItem.ts b/packages/client/src/components/tiptap/extensions/taskItem.ts index 1cb2373f..f5450286 100644 --- a/packages/client/src/components/tiptap/extensions/taskItem.ts +++ b/packages/client/src/components/tiptap/extensions/taskItem.ts @@ -1,34 +1,12 @@ import { wrappingInputRule } from '@tiptap/core'; +import { ReactNodeViewRenderer } from '@tiptap/react'; import { TaskItem as BuiltInTaskItem } from '@tiptap/extension-task-item'; import { Plugin } from 'prosemirror-state'; import { findParentNodeClosestToPos } from 'prosemirror-utils'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; +import { TaskItemWrapper } from '../components/taskItem'; const CustomTaskItem = 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 [ { @@ -51,6 +29,44 @@ const CustomTaskItem = BuiltInTaskItem.extend({ ]; }, + // addNodeView() { + // return ReactNodeViewRenderer(TaskItemWrapper); + // }, + + addNodeView() { + return ({ node, HTMLAttributes, getPos, editor }) => { + const listItem = document.createElement('li'); + const checkboxWrapper = document.createElement('span'); + const content = document.createElement('div'); + + checkboxWrapper.contentEditable = 'false'; + + Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => { + listItem.setAttribute(key, value); + }); + + listItem.dataset.checked = node.attrs.checked; + listItem.append(checkboxWrapper, content); + + Object.entries(HTMLAttributes).forEach(([key, value]) => { + listItem.setAttribute(key, value); + }); + + return { + dom: listItem, + contentDOM: content, + update: (updatedNode) => { + if (updatedNode.type !== this.type) { + return false; + } + + listItem.dataset.checked = updatedNode.attrs.checked; + return true; + }, + }; + }; + }, + addProseMirrorPlugins() { return [ new Plugin({ @@ -65,14 +81,20 @@ const CustomTaskItem = BuiltInTaskItem.extend({ const parentList = findParentNodeClosestToPos(position, function (node) { return node.type === schema.nodes.taskItem || node.type === schema.nodes.listItem; }); - // @ts-ignore - const isListClicked = event.target.tagName.toLowerCase() === 'li'; - if (!isListClicked || !parentList || parentList.node.type !== schema.nodes.taskItem) { + if (!parentList) { return; } + const element = view.nodeDOM(parentList.pos) as HTMLLIElement; + if (element.tagName.toLowerCase() !== 'li') return; + + const parentElement = element.parentElement; + const type = parentElement && parentElement.getAttribute('data-type'); + if (!type || type.toLowerCase() !== 'tasklist') return; + const tr = state.tr; + const nextValue = !(element.getAttribute('data-checked') === 'true'); tr.setNodeMarkup(parentList.pos, schema.nodes.taskItem, { - checked: !parentList.node.attrs.checked, + checked: nextValue, }); view.dispatch(tr); }, diff --git a/packages/client/src/components/tiptap/extensions/taskList.ts b/packages/client/src/components/tiptap/extensions/taskList.ts index 9c56c26a..2233788e 100644 --- a/packages/client/src/components/tiptap/extensions/taskList.ts +++ b/packages/client/src/components/tiptap/extensions/taskList.ts @@ -1,4 +1,3 @@ -import { mergeAttributes } from '@tiptap/core'; import { TaskList as BuiltInTaskList } from '@tiptap/extension-task-list'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; @@ -11,8 +10,4 @@ export const TaskList = BuiltInTaskList.extend({ }, ]; }, - - renderHTML({ HTMLAttributes: { numeric, ...HTMLAttributes } }) { - return [numeric ? 'ol' : 'ul', mergeAttributes(HTMLAttributes, { 'data-type': 'taskList' }), 0]; - }, }); diff --git a/packages/client/src/components/tiptap/extensions/title.tsx b/packages/client/src/components/tiptap/extensions/title.tsx index 319c1724..452ec751 100644 --- a/packages/client/src/components/tiptap/extensions/title.tsx +++ b/packages/client/src/components/tiptap/extensions/title.tsx @@ -1,13 +1,23 @@ import { Node, mergeAttributes } from '@tiptap/core'; -export const Title = Node.create({ +export interface TitleOptions { + HTMLAttributes: Record; +} + +declare module '@tiptap/core' { + interface Commands { + title: { + setTitle: (attributes) => ReturnType; + toggleTitle: (attributes) => ReturnType; + }; + } +} + +export const Title = Node.create({ name: 'title', - content: 'text*', - selectable: true, + content: 'inline*', + group: 'block', defining: true, - inline: false, - group: 'basic', - allowGapCursor: true, addOptions() { return { @@ -16,16 +26,15 @@ export const Title = Node.create({ }, }; }, - parseHTML() { return [ { - tag: 'h1[class=title]', + tag: 'p[class=title]', }, ]; }, renderHTML({ HTMLAttributes }) { - return ['h1', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + return ['p', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; }, }); diff --git a/packages/client/src/components/tiptap/menubar.tsx b/packages/client/src/components/tiptap/menubar.tsx index 6dad5960..38695471 100644 --- a/packages/client/src/components/tiptap/menubar.tsx +++ b/packages/client/src/components/tiptap/menubar.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { Space, Button, Tooltip } from '@douyinfe/semi-ui'; +import { Space, Button } from '@douyinfe/semi-ui'; import { IconUndo, IconRedo } from '@douyinfe/semi-icons'; +import { Tooltip } from 'components/tooltip'; import { IconClear } from 'components/icons'; import { Divider } from './components/divider'; import { MediaInsertMenu } from './menus/mediaInsert'; diff --git a/packages/client/src/components/tiptap/menus/baseBubbleMenu.tsx b/packages/client/src/components/tiptap/menus/baseBubbleMenu.tsx index 45a35fb8..1710a7b5 100644 --- a/packages/client/src/components/tiptap/menus/baseBubbleMenu.tsx +++ b/packages/client/src/components/tiptap/menus/baseBubbleMenu.tsx @@ -6,10 +6,13 @@ import { Link } from '../extensions/link'; import { Attachment } from '../extensions/attachment'; import { Image } from '../extensions/image'; import { Banner } from '../extensions/banner'; +import { Status } from '../extensions/status'; import { HorizontalRule } from '../extensions/horizontalRule'; import { Iframe } from '../extensions/iframe'; import { Mind } from '../extensions/mind'; import { Table } from '../extensions/table'; +import { TaskList } from '../extensions/taskList'; +import { TaskItem } from '../extensions/taskItem'; import { Katex } from '../extensions/katex'; import { DocumentReference } from '../extensions/documentReference'; import { DocumentChildren } from '../extensions/documentChildren'; @@ -21,9 +24,12 @@ const OTHER_BUBBLE_MENU_TYPES = [ Attachment.name, Image.name, Banner.name, + Status.name, Iframe.name, Mind.name, Table.name, + TaskList.name, + TaskItem.name, DocumentReference.name, DocumentChildren.name, Katex.name, diff --git a/packages/client/src/components/tiptap/menus/components/bubbleMenu/bubbleMenuPlugin.tsx b/packages/client/src/components/tiptap/menus/components/bubbleMenu/bubbleMenuPlugin.tsx index 914483dd..6007fdc9 100644 --- a/packages/client/src/components/tiptap/menus/components/bubbleMenu/bubbleMenuPlugin.tsx +++ b/packages/client/src/components/tiptap/menus/components/bubbleMenu/bubbleMenuPlugin.tsx @@ -84,7 +84,7 @@ export class BubbleMenuView { this.view.dom.addEventListener('dragstart', this.dragstartHandler); this.editor.on('focus', this.focusHandler); this.editor.on('blur', this.blurHandler); - this.tippyOptions = tippyOptions; + this.tippyOptions = tippyOptions || {}; // Detaches menu content from its current parent this.element.remove(); this.element.style.visibility = 'visible'; @@ -133,7 +133,7 @@ export class BubbleMenuView { trigger: 'manual', placement: 'top', hideOnClick: 'toggle', - ...this.tippyOptions, + ...Object.assign({ zIndex: 99 }, this.tippyOptions), }); // maybe we have to hide tippy on its own blur event as well diff --git a/packages/client/src/components/tiptap/menus/components/paragraph.tsx b/packages/client/src/components/tiptap/menus/components/paragraph.tsx index e45e9600..037c06af 100644 --- a/packages/client/src/components/tiptap/menus/components/paragraph.tsx +++ b/packages/client/src/components/tiptap/menus/components/paragraph.tsx @@ -21,6 +21,8 @@ export const Paragraph = ({ editor }) => { } }, []); + // console.log(getCurrentCaretTitle(editor)); + return ( `; + }; + + md.renderer.rules.taskListItemLabel_close = () => { + return '

'; + }; + + md.renderer.rules.taskListItemLabel_open = (tokens) => { + const token = tokens[0]; + const id = token.attrGet('id'); + return `

`; + }; +} + +function attrSet(token, name, value) { + var index = token.attrIndex(name); + var attr = [name, value]; + + if (index < 0) { + token.attrPush(attr); + } else { + token.attrs[index] = attr; + } +} + +function processToken(state: StateCore, options: TaskListsOptions): boolean { + const allTokens = state.tokens; + + attrSet(allTokens[0], 'class', 'contains-task-list'); + + for (let i = 2; i < allTokens.length; i++) { + if (!isTodoItem(allTokens, i)) { + continue; + } + + const { isChecked } = todoify(allTokens[i], options); + allTokens[i - 2].attrJoin('class', `task-list-item`); + allTokens[i - 2].attrJoin('data-checked', isChecked ? `true` : `false`); + + const parentToken = findParentToken(allTokens, i - 2); + if (parentToken) { + parentToken.attrJoin('class', 'task-list'); + } + } + return false; +} + +function findParentToken(tokens: Token[], index: number): Token | undefined { + const targetLevel = tokens[index].level - 1; + for (let currentTokenIndex = index - 1; currentTokenIndex >= 0; currentTokenIndex--) { + if (tokens[currentTokenIndex].level === targetLevel) { + return tokens[currentTokenIndex]; + } + } + return undefined; +} + +function isTodoItem(tokens: Token[], index: number): boolean { + return ( + isInline(tokens[index]) && + isParagraph(tokens[index - 1]) && + isListItem(tokens[index - 2]) && + startsWithTodoMarkdown(tokens[index]) + ); +} + +function todoify(token: Token, options: TaskListsOptions) { + if (token.children == null) { + return; + } + + const id = generateIdForToken(token); + + const { checkbox, isChecked } = createCheckboxToken(token, options.enabled, id); + token.children.splice(0, 0, checkbox); + token.children[1].content = token.children[1].content.replace(checkboxRegex, ''); + + if (options.label) { + token.children.splice(1, 0, createLabelBeginToken(id)); + token.children.push(createLabelEndToken()); + } + + return { isChecked }; +} + +function generateIdForToken(token: Token): string { + if (token.map) { + return `task-item-${token.map[0]}`; + } else { + return `task-item-${Math.ceil(Math.random() * (10000 * 1000) - 1000)}`; + } +} + +function createCheckboxToken(token: Token, enabled: boolean, id: string): Token { + const checkbox = new Token('taskListItemCheckbox', '', 0); + if (!enabled) { + checkbox.attrSet('disabled', 'true'); + } + if (token.map) { + checkbox.attrSet('line', token.map[0].toString()); + } + + checkbox.attrSet('id', id); + + const checkboxRegexResult = checkboxRegex.exec(token.content); + const isChecked = !!checkboxRegexResult && checkboxRegexResult[1].toLowerCase() === 'x'; + if (isChecked) { + checkbox.attrSet('checked', 'true'); + } + + return { checkbox, isChecked }; +} + +function createLabelBeginToken(id: string): Token { + const labelBeginToken = new Token('taskListItemLabel_open', '', 1); + labelBeginToken.attrSet('id', id); + return labelBeginToken; +} + +function createLabelEndToken(): Token { + return new Token('taskListItemLabel_close', '', -1); +} + +function isInline(token: Token): boolean { + return token.type === 'inline'; +} + +function isParagraph(token: Token): boolean { + return token.type === 'paragraph_open'; +} + +function isListItem(token: Token): boolean { + return token.type === 'list_item_open'; +} + +function startsWithTodoMarkdown(token: Token): boolean { + return checkboxRegex.test(token.content); +} diff --git a/packages/client/src/components/tiptap/services/markdown/markdownUnderline.ts b/packages/client/src/components/tiptap/services/markdown/markdownToHTML/markdownUnderline.ts similarity index 100% rename from packages/client/src/components/tiptap/services/markdown/markdownUnderline.ts rename to packages/client/src/components/tiptap/services/markdown/markdownToHTML/markdownUnderline.ts diff --git a/packages/client/src/components/tiptap/services/markdown/markedownSplitMixedList.ts b/packages/client/src/components/tiptap/services/markdown/markdownToHTML/markedownSplitMixedList.ts similarity index 97% rename from packages/client/src/components/tiptap/services/markdown/markedownSplitMixedList.ts rename to packages/client/src/components/tiptap/services/markdown/markdownToHTML/markedownSplitMixedList.ts index 3c3869d9..bb2de5c0 100644 --- a/packages/client/src/components/tiptap/services/markdown/markedownSplitMixedList.ts +++ b/packages/client/src/components/tiptap/services/markdown/markdownToHTML/markedownSplitMixedList.ts @@ -7,7 +7,7 @@ export default function splitMixedLists(md) { for (let i = 0; i < tokens.length; i++) { const token = tokens[i]; - if (token.attrGet('class') !== 'contains-task-list') { + if (token.attrGet('class') !== 'task-list') { continue; } const firstChild = tokens[i + 1]; diff --git a/packages/client/src/components/tiptap/services/markdown/prosemirrorToMarkdown/index.ts b/packages/client/src/components/tiptap/services/markdown/prosemirrorToMarkdown/index.ts new file mode 100644 index 00000000..04a76722 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/prosemirrorToMarkdown/index.ts @@ -0,0 +1,162 @@ +import { MarkdownSerializer as ProseMirrorMarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown'; +import { Attachment } from '../../../extensions/attachment'; +import { Banner } from '../../../extensions/banner'; +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 { HardBreak } from '../../../extensions/hardBreak'; +import { Heading } from '../../../extensions/heading'; +import { HorizontalRule } from '../../../extensions/horizontalRule'; +import { marks } 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 { 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 { TaskItem } from '../../../extensions/taskItem'; +import { TaskList } from '../../../extensions/taskList'; +import { TextStyle } from '../../../extensions/textStyle'; +import { Title } from '../../../extensions/title'; +import { + isPlainURL, + renderHardBreak, + renderTable, + renderTableCell, + renderTableRow, + openTag, + closeTag, + renderOrderedList, + renderImage, + renderCustomContainer, + renderHTMLNode, +} from './serializerHelpers'; + +const SerializerConfig = { + 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, + }, + // FIXME: 如何导出 style? + [TextStyle.name]: { open: '', close: '', mixable: true, expelEnclosingWhitespace: true }, + ...marks.reduce( + (acc, { name, tag }) => ({ + ...acc, + [name]: { + mixable: true, + open(state, node) { + return openTag(tag, node.attrs); + }, + close: closeTag(tag), + }, + }), + {} + ), + }, + + nodes: { + [Attachment.name]: renderCustomContainer('attachment'), + [Banner.name]: (state, node) => { + state.write(`:::${node.attrs.type || 'info'}\n`); + state.ensureNewLine(); + state.renderContent(node); + state.ensureNewLine(); + state.write(':::'); + state.closeBlock(node); + }, + blockquote: (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]: renderCustomContainer('documentChildren'), + [DocumentReference.name]: renderCustomContainer('documentReference'), + [HardBreak.name]: renderHardBreak, + [Heading.name]: defaultMarkdownSerializer.nodes.heading, + [HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule, + [Iframe.name]: renderCustomContainer('iframe'), + [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]: renderCustomContainer('mind'), + [OrderedList.name]: renderOrderedList, + [Paragraph.name]: defaultMarkdownSerializer.nodes.paragraph, + [Status.name]: renderCustomContainer('status'), + [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) => { + state.renderList(node, ' ', () => (node.attrs.bullet || '*') + ' '); + }, + [Text.name]: defaultMarkdownSerializer.nodes.text, + [Title.name]: renderHTMLNode('p', false, true, { class: 'title' }), + }, +}; + +/** + * 将 ProseMirror Document Node JSON 转换为 markdown 字符串 + * @param param.content + * @returns + */ +export const prosemirrorToMarkdown = ({ content }) => { + const serializer = new ProseMirrorMarkdownSerializer(SerializerConfig.nodes, SerializerConfig.marks); + const markdown = serializer.serialize(content, { + tightLists: true, + }); + + return markdown; +}; diff --git a/packages/client/src/components/tiptap/services/markdown/serializerHelpers.ts b/packages/client/src/components/tiptap/services/markdown/prosemirrorToMarkdown/serializerHelpers.ts similarity index 97% rename from packages/client/src/components/tiptap/services/markdown/serializerHelpers.ts rename to packages/client/src/components/tiptap/services/markdown/prosemirrorToMarkdown/serializerHelpers.ts index cf847563..084b98b9 100644 --- a/packages/client/src/components/tiptap/services/markdown/serializerHelpers.ts +++ b/packages/client/src/components/tiptap/services/markdown/prosemirrorToMarkdown/serializerHelpers.ts @@ -1,3 +1,5 @@ +import { jsonToStr } from '../../dataset'; + const uniq = (arr: string[]) => [...new Set(arr)]; function isString(value) { @@ -271,7 +273,7 @@ export function renderHTMLNode(tagName, forceRenderInline = false, needNewLine = renderTagClose(state, tagName, false); if (needNewLine) { state.ensureNewLine(); - state.write('
'); + state.write('\n'); state.ensureNewLine(); } }; @@ -346,3 +348,11 @@ export function renderImage(state, node) { export function renderPlayable(state, node) { renderImage(state, node); } + +export function renderCustomContainer(name) { + return function (state, node) { + state.ensureNewLine(); + state.write(`::: ${name} ${jsonToStr(node.attrs)}\n:::\n`); + state.closeBlock(node); + }; +} diff --git a/packages/client/src/components/tiptap/services/markdown/serializer.ts b/packages/client/src/components/tiptap/services/markdown/serializer.ts deleted file mode 100644 index 6c13fc42..00000000 --- a/packages/client/src/components/tiptap/services/markdown/serializer.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model'; -import { sanitize } from 'dompurify'; -import { MarkdownSerializer as ProseMirrorMarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown'; -import { markdown } from '.'; -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.write(`:::${node.attrs.type || 'info'}\n`); - state.ensureNewLine(); - state.renderContent(node); - state.ensureNewLine(); - state.write(':::'); - 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.write(`$mind\n`); - state.ensureNewLine(); - state.renderContent(node); - state.ensureNewLine(); - 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) => { - state.renderList(node, ' ', () => (node.attrs.bullet || '*') + ' '); - }, - [Text.name]: defaultMarkdownSerializer.nodes.text, - [Title.name]: (state, node) => { - if (!node.textContent) return; - - state.write(`# `); - state.text(node.textContent, false); - state.ensureNewLine(); - state.closeBlock(node); - }, - }, -}; - -const renderMarkdown = (rawMarkdown) => { - return sanitize(markdown.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/views/floatMenuView.tsx b/packages/client/src/components/tiptap/views/floatMenuView.tsx index 0b17d510..03aed345 100644 --- a/packages/client/src/components/tiptap/views/floatMenuView.tsx +++ b/packages/client/src/components/tiptap/views/floatMenuView.tsx @@ -24,6 +24,7 @@ export type FloatMenuViewOptions = { export class FloatMenuView { public editor: Editor; public parentNode: null | HTMLElement; + public container: null | HTMLElement; private dom: HTMLElement; private popup: Instance; private _update: FloatMenuViewOptions['update']; @@ -36,18 +37,30 @@ export class FloatMenuView { } if (isNodeSelection(state.selection)) { const node = view.nodeDOM(range.from) as HTMLElement; + console.log(node); if (node) { return node.getBoundingClientRect(); } } - return posToDOMRect(view, range.from, range.to); + + const rangeRect = posToDOMRect(view, range.from, range.to); + + if (this.container) { + const containerRect = this.container.getBoundingClientRect(); + + if (rangeRect.width > containerRect.width) { + return containerRect; + } + } + + return rangeRect; }; constructor(props: FloatMenuViewOptions) { this.editor = props.editor; this.shouldShow = props.shouldShow; - this.tippyOptions = props.tippyOptions; + this.tippyOptions = props.tippyOptions || {}; if (props.getReferenceClientRect) { this.getReferenceClientRect = props.getReferenceClientRect; } @@ -61,6 +74,14 @@ export class FloatMenuView { this.createPopup(); } + setConatiner(el) { + this.container = el; + // this.popup?.setProps({ + // appendTo: el, + // }); + // this.popup?. + } + createPopup() { const { element: editorElement } = this.editor.options; const editorIsAttached = !!editorElement.parentElement; @@ -76,7 +97,7 @@ export class FloatMenuView { trigger: 'manual', placement: 'top', hideOnClick: 'toggle', - ...(this.tippyOptions ?? {}), + ...Object.assign({ zIndex: 99 }, this.tippyOptions), }); } diff --git a/packages/client/src/hooks/useTheme.tsx b/packages/client/src/hooks/useTheme.tsx index ae869a16..278eb046 100644 --- a/packages/client/src/hooks/useTheme.tsx +++ b/packages/client/src/hooks/useTheme.tsx @@ -19,7 +19,6 @@ export const useTheme = () => { body.setAttribute('theme-mode', 'dark'); return; } - if (theme === 'light') { body.setAttribute('theme-mode', 'light'); return; diff --git a/packages/client/src/styles/globals.scss b/packages/client/src/styles/globals.scss index c64ce30e..c7749036 100644 --- a/packages/client/src/styles/globals.scss +++ b/packages/client/src/styles/globals.scss @@ -204,3 +204,8 @@ a { flex-direction: column; align-items: center; } + +// @see https://github.com/react-component/tooltip/issues/18#issuecomment-411476678 +.semi-button-disabled { + pointer-events: none; +} diff --git a/packages/client/src/styles/prosemirror.scss b/packages/client/src/styles/prosemirror.scss index 83e4a11e..6eaad4ae 100644 --- a/packages/client/src/styles/prosemirror.scss +++ b/packages/client/src/styles/prosemirror.scss @@ -54,8 +54,8 @@ } } - .hr-line { - width: 100%; + hr { + border: 0; height: 2px; background: var(--semi-color-border); margin: 18px 0; @@ -178,11 +178,31 @@ li { display: flex; align-items: center; + cursor: pointer; - > label { - flex: 0 0 auto; - user-select: none; - transform: translateY(2px); + > span { + position: relative; + display: block; + width: 16px; + height: 16px; + border: 1px solid var(--semi-color-border); + border-radius: 2px; + background-color: #fff; + + &::after { + content: ' '; + position: absolute; + left: 4.071429px; + top: -0.357143px; + width: 6.714286px; + height: 12.142857px; + border: 2px solid #fff; + border-top: 0; + border-left: 0; + transition: all 0.1s cubic-bezier(0.71, -0.46, 0.88, 0.6), opacity 0.1s; + transform: rotate(45deg) scale(0); + opacity: 0; + } } > div { @@ -193,6 +213,15 @@ &[data-checked='true'] { color: var(--semi-color-text-2); + > span { + background-color: var(--semi-color-primary); + + &::after { + opacity: 1; + transform: rotate(45deg) scale(1); + } + } + > div { text-decoration: line-through; } @@ -240,19 +269,27 @@ padding: 0; white-space: pre; background-color: transparent; + + width: 100%; + max-height: 370px; + overflow: auto; + overscroll-behavior: contain; } } .tableWrapper { - width: 100%; + max-width: 100%; margin: 1em 0; + overflow: auto; + + &.has-focus { + padding-left: 1em; + } } table { border-collapse: collapse; table-layout: fixed; - min-width: 100%; - max-width: 100%; margin: 1em 0; td, @@ -284,7 +321,7 @@ .grip-column { position: absolute; - z-index: 10000; + z-index: 10; display: block; width: 100%; height: 0.7em; @@ -302,7 +339,7 @@ .grip-row { position: absolute; - z-index: 10000; + z-index: 10; display: block; height: 100%; width: 0.7em; @@ -320,7 +357,7 @@ .grip-table { position: absolute; - z-index: 10000; + z-index: 10; display: block; width: 0.8em; height: 0.8em; diff --git a/packages/server/src/dtos/create-document.dto.ts b/packages/server/src/dtos/create-document.dto.ts index 84882ba5..58d1f828 100644 --- a/packages/server/src/dtos/create-document.dto.ts +++ b/packages/server/src/dtos/create-document.dto.ts @@ -10,7 +10,6 @@ export class CreateDocumentDto { @IsString({ message: '文档名称类型错误(正确类型为:String)' }) @IsNotEmpty({ message: '文档名称不能为空' }) @MinLength(1, { message: '文档名称至少1个字符' }) - @MaxLength(50, { message: '文档名称最多50个字符' }) @IsOptional() readonly title?: string; diff --git a/packages/server/src/dtos/template.dto.ts b/packages/server/src/dtos/template.dto.ts index 34b35e05..ca251122 100644 --- a/packages/server/src/dtos/template.dto.ts +++ b/packages/server/src/dtos/template.dto.ts @@ -8,11 +8,11 @@ export class TemplateDto { readonly title: string; @IsOptional() - content: string; + content?: string; @IsOptional() - state: Uint8Array; + state?: Uint8Array; @IsOptional() - isPublic: boolean; + isPublic?: boolean; } diff --git a/packages/server/src/dtos/update-document.dto.ts b/packages/server/src/dtos/update-document.dto.ts index da5a724c..9efd6b90 100644 --- a/packages/server/src/dtos/update-document.dto.ts +++ b/packages/server/src/dtos/update-document.dto.ts @@ -8,7 +8,7 @@ export class UpdateDocumentDto { readonly title: string; @IsOptional() - content: string; + content?: string; @IsOptional() state?: Uint8Array; diff --git a/packages/server/src/entities/document.entity.ts b/packages/server/src/entities/document.entity.ts index 7d12cc17..b874cdaf 100644 --- a/packages/server/src/entities/document.entity.ts +++ b/packages/server/src/entities/document.entity.ts @@ -19,13 +19,13 @@ export class DocumentEntity { @Column({ type: 'varchar', comment: '父文档 Id', default: null }) public parentDocumentId: string; - @Column({ type: 'varchar', length: 50, comment: '文档标题', default: '' }) + @Column({ type: 'varchar', default: '未命名文档', comment: '文档标题' }) public title: string; @Column({ type: 'text', comment: '文档内容' }) public content: string; - @Column({ type: 'blob', comment: '文档内容' }) + @Column({ type: 'longblob', comment: '文档内容' }) public state: Uint8Array; @Column({ diff --git a/packages/server/src/services/collaboration.service.ts b/packages/server/src/services/collaboration.service.ts index 67ae3302..711d22a5 100644 --- a/packages/server/src/services/collaboration.service.ts +++ b/packages/server/src/services/collaboration.service.ts @@ -65,6 +65,7 @@ export class CollaborationService { onAuthenticate: this.onAuthenticate.bind(this), onLoadDocument: this.onLoadDocument.bind(this), onChange: this.onChange.bind(this), + onDisconnect: this.onDisconnect.bind(this), }); this.server = server; this.server.listen(lodash.get(getConfig(), 'server.collaborationPort', 5003)); @@ -213,4 +214,40 @@ export class CollaborationService { state, }); } + + async onDisconnect(data) { + const { requestParameters, document } = data; + const targetId = requestParameters.get('targetId'); + const docType = requestParameters.get('docType'); + const userId = requestParameters.get('userId'); + + switch (docType) { + case 'document': { + const documentId = targetId; + const { title } = await this.documentService.findById(documentId); + + if (!title) { + await this.documentService.updateDocument({ id: userId } as OutUser, targetId, { + title: '未命名文档', + }); + } + break; + } + + case 'template': { + const templateId = targetId; + const { title } = await this.templateService.findById(templateId); + + if (!title) { + await this.templateService.updateTemplate({ id: userId } as OutUser, targetId, { + title: '未命名模板', + }); + } + break; + } + + default: + throw new Error('未知类型'); + } + } } diff --git a/packages/server/src/services/template.service.ts b/packages/server/src/services/template.service.ts index ab20df65..ff1746ba 100644 --- a/packages/server/src/services/template.service.ts +++ b/packages/server/src/services/template.service.ts @@ -79,7 +79,7 @@ export class TemplateService { * @param id * @param tag */ - async updateTemplate(user, id, dto: TemplateDto & { id: string }) { + async updateTemplate(user, id, dto: TemplateDto) { const old = await this.templateRepo.findOne(id); if (user.id !== old.createUserId) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21e7e7d1..281e9aed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,7 +87,6 @@ importers: '@tiptap/extension-underline': ^2.0.0-beta.23 '@tiptap/react': ^2.0.0-beta.107 '@tiptap/suggestion': ^2.0.0-beta.90 - '@traptitech/markdown-it-katex': ^3.5.0 '@types/node': 17.0.13 '@types/react': 17.0.38 axios: ^0.25.0 @@ -102,11 +101,8 @@ importers: markdown-it-anchor: ^8.4.1 markdown-it-container: ^3.0.0 markdown-it-emoji: ^2.0.0 - 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 prosemirror-tables: ^1.1.1 @@ -166,7 +162,6 @@ importers: '@tiptap/extension-underline': 2.0.0-beta.23_@tiptap+core@2.0.0-beta.171 '@tiptap/react': 2.0.0-beta.107_a3fcdb91535fe17b69dfabaa94f3bb3d '@tiptap/suggestion': 2.0.0-beta.90_@tiptap+core@2.0.0-beta.171 - '@traptitech/markdown-it-katex': 3.5.0 axios: 0.25.0 classnames: 2.3.1 copy-to-clipboard: 3.3.1 @@ -179,11 +174,8 @@ importers: markdown-it-anchor: 8.4.1_markdown-it@12.3.2 markdown-it-container: 3.0.0 markdown-it-emoji: 2.0.0 - 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_react-dom@17.0.2+react@17.0.2 prosemirror-markdown: 1.7.0 prosemirror-tables: 1.1.1 @@ -1953,12 +1945,6 @@ packages: resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} engines: {node: '>= 6'} - /@traptitech/markdown-it-katex/3.5.0: - resolution: {integrity: sha512-7/GI3ETKJjrZD9+azn7WraDWo0ZQ6grtzR4I36qu7U0vOJMBtC+znX7UghdOScrgGnxqGvgWm07SYnlcCtdCvw==} - dependencies: - katex: 0.15.2 - dev: false - /@tsconfig/node10/1.0.8: resolution: {integrity: sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==} dev: true @@ -6008,10 +5994,6 @@ packages: resolution: {integrity: sha512-39j7/9vP/CPCKbEI44oV8yoPJTpvfeReTn/COgRhSpNrjWF3PfP/JUxxB0hxV6ynOY8KH8Y8aX9NMDdo6z+6YQ==} dev: false - /markdown-it-footnote/3.0.3: - resolution: {integrity: sha512-YZMSuCGVZAjzKMn+xqIco9d1cLGxbELHZ9do/TSYVzraooV8ypsppKNmUJ0fVH5ljkCInQAtFpm8Rb3eXSrt5w==} - dev: false - /markdown-it-sub/1.0.0: resolution: {integrity: sha1-N1/WAm6ufdywEkl/ZBEZXqHjr+g=} dev: false @@ -6020,10 +6002,6 @@ packages: resolution: {integrity: sha1-y5yf+RpSVawI8/09YyhuFd8KH8M=} dev: false - /markdown-it-task-lists/2.1.1: - resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==} - dev: false - /markdown-it/12.3.2: resolution: {integrity: sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==} hasBin: true @@ -6035,12 +6013,6 @@ packages: uc.micro: 1.0.6 dev: false - /marked/4.0.12: - resolution: {integrity: sha512-hgibXWrEDNBWgGiK18j/4lkS6ihTe9sxtV4Q1OQppb/0zzyPSzoFANBa5MfsG/zgsWklmNnhm0XACZOH/0HBiQ==} - engines: {node: '>= 12'} - hasBin: true - dev: false - /mdurl/1.0.1: resolution: {integrity: sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=} dev: false