From fda147c492f2ed2e4767790babbedc4897ce0226 Mon Sep 17 00:00:00 2001 From: fantasticit Date: Tue, 22 Mar 2022 13:52:29 +0800 Subject: [PATCH 1/7] fix: fix zIndex --- .../src/components/document/editor/index.module.scss | 7 ++++++- .../src/components/template/editor/index.module.scss | 7 ++++++- .../client/src/components/tiptap/extensions/tableCell.tsx | 2 +- .../src/components/tiptap/extensions/tableHeader.tsx | 2 +- .../menus/components/bubbleMenu/bubbleMenuPlugin.tsx | 4 ++-- .../client/src/components/tiptap/views/floatMenuView.tsx | 4 ++-- packages/client/src/styles/prosemirror.scss | 6 +++--- 7 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/client/src/components/document/editor/index.module.scss b/packages/client/src/components/document/editor/index.module.scss index f14a006..8f5530f 100644 --- a/packages/client/src/components/document/editor/index.module.scss +++ b/packages/client/src/components/document/editor/index.module.scss @@ -5,7 +5,11 @@ flex-direction: column; > header { + position: relative; + z-index: 110; + background-color: var(--semi-color-nav-bg); height: 60px; + > div { overflow: auto; } @@ -27,12 +31,13 @@ > 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); &.isStandardWidth { diff --git a/packages/client/src/components/template/editor/index.module.scss b/packages/client/src/components/template/editor/index.module.scss index e06e737..afd3800 100644 --- a/packages/client/src/components/template/editor/index.module.scss +++ b/packages/client/src/components/template/editor/index.module.scss @@ -5,7 +5,11 @@ flex-direction: column; > header { + position: relative; + z-index: 110; + background-color: var(--semi-color-nav-bg); height: 60px; + > div { overflow: auto; } @@ -27,12 +31,13 @@ > 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); &.isStandardWidth { diff --git a/packages/client/src/components/tiptap/extensions/tableCell.tsx b/packages/client/src/components/tiptap/extensions/tableCell.tsx index b4a8aff..03ba72f 100644 --- a/packages/client/src/components/tiptap/extensions/tableCell.tsx +++ b/packages/client/src/components/tiptap/extensions/tableCell.tsx @@ -27,7 +27,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 f9b4a35..c891b2b 100644 --- a/packages/client/src/components/tiptap/extensions/tableHeader.tsx +++ b/packages/client/src/components/tiptap/extensions/tableHeader.tsx @@ -19,7 +19,7 @@ export const TableHeader = BuiltInTableHeader.extend({ new FloatMenuView({ editor: this.editor, tippyOptions: { - zIndex: 10000, + zIndex: 100, }, shouldShow: ({ editor }) => { if (!editor.isEditable) { 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 914483d..6007fdc 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/views/floatMenuView.tsx b/packages/client/src/components/tiptap/views/floatMenuView.tsx index 0b17d51..8754649 100644 --- a/packages/client/src/components/tiptap/views/floatMenuView.tsx +++ b/packages/client/src/components/tiptap/views/floatMenuView.tsx @@ -47,7 +47,7 @@ export class FloatMenuView { 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; } @@ -76,7 +76,7 @@ export class FloatMenuView { trigger: 'manual', placement: 'top', hideOnClick: 'toggle', - ...(this.tippyOptions ?? {}), + ...Object.assign({ zIndex: 99 }, this.tippyOptions), }); } diff --git a/packages/client/src/styles/prosemirror.scss b/packages/client/src/styles/prosemirror.scss index 83e4a11..2e66a23 100644 --- a/packages/client/src/styles/prosemirror.scss +++ b/packages/client/src/styles/prosemirror.scss @@ -284,7 +284,7 @@ .grip-column { position: absolute; - z-index: 10000; + z-index: 10; display: block; width: 100%; height: 0.7em; @@ -302,7 +302,7 @@ .grip-row { position: absolute; - z-index: 10000; + z-index: 10; display: block; height: 100%; width: 0.7em; @@ -320,7 +320,7 @@ .grip-table { position: absolute; - z-index: 10000; + z-index: 10; display: block; width: 0.8em; height: 0.8em; From d71d725dcf90511e40156199052d0be3ccebb571 Mon Sep 17 00:00:00 2001 From: fantasticit Date: Tue, 22 Mar 2022 13:57:27 +0800 Subject: [PATCH 2/7] feat: use wrapped tooltip --- packages/client/src/components/tiptap/menubar.tsx | 3 ++- packages/client/src/components/tiptap/menus/mediaInsert.tsx | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/client/src/components/tiptap/menubar.tsx b/packages/client/src/components/tiptap/menubar.tsx index 6dad596..3869547 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/mediaInsert.tsx b/packages/client/src/components/tiptap/menus/mediaInsert.tsx index 341fd6d..3db2176 100644 --- a/packages/client/src/components/tiptap/menus/mediaInsert.tsx +++ b/packages/client/src/components/tiptap/menus/mediaInsert.tsx @@ -3,7 +3,6 @@ import { Editor } from '@tiptap/core'; import { Button, Dropdown, Popover } from '@douyinfe/semi-ui'; import { IconPlus } from '@douyinfe/semi-icons'; import { Tooltip } from 'components/tooltip'; -import { Upload } from './components/upload'; import { IconDocument, IconMind, @@ -18,7 +17,6 @@ import { } from 'components/icons'; import { GridSelect } from 'components/grid-select'; import { isTitleActive } from '../services/isActive'; -import { handleFileEvent } from '../services/upload'; export const MediaInsertMenu: React.FC<{ editor: Editor }> = ({ editor }) => { if (!editor) { From 67a5ae54cf137098057d0ad4affbaee494b7eefb Mon Sep 17 00:00:00 2001 From: fantasticit Date: Tue, 22 Mar 2022 13:58:22 +0800 Subject: [PATCH 3/7] fix: fix wrapped tooltip not trigger mouseleave --- packages/client/src/styles/globals.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/client/src/styles/globals.scss b/packages/client/src/styles/globals.scss index c64ce30..c774903 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; +} From 187cdaf17cc8921502c62055d8ca777a21e31506 Mon Sep 17 00:00:00 2001 From: fantasticit Date: Tue, 22 Mar 2022 17:28:53 +0800 Subject: [PATCH 4/7] feat: html -> prosemirror node --- .../src/components/tiptap/extensions/paste.ts | 8 +- .../components/tiptap/extensions/taskItem.ts | 82 ++--- .../components/tiptap/extensions/taskList.ts | 5 - .../tiptap/services/markdown/index.ts | 12 +- .../tiptap/services/markdown/markdownTable.ts | 283 ++++++++++++++++++ .../services/markdown/markdownTaskList.ts | 175 +++++++++++ .../tiptap/services/markdown/serializer.ts | 51 +++- .../services/markdown/src/Marks/Bold.js | 13 + .../services/markdown/src/Marks/Code.js | 16 + .../services/markdown/src/Marks/Italic.js | 12 + .../services/markdown/src/Marks/Link.js | 15 + .../services/markdown/src/Marks/Mark.js | 14 + .../services/markdown/src/Nodes/BulletList.js | 15 + .../services/markdown/src/Nodes/CodeBlock.js | 29 ++ .../markdown/src/Nodes/CodeBlockWrapper.js | 10 + .../services/markdown/src/Nodes/HardBreak.js | 9 + .../services/markdown/src/Nodes/Heading.js | 22 ++ .../services/markdown/src/Nodes/Image.js | 21 ++ .../services/markdown/src/Nodes/ListItem.js | 21 ++ .../services/markdown/src/Nodes/Node.ts | 23 ++ .../markdown/src/Nodes/OrderedList.js | 9 + .../services/markdown/src/Nodes/Paragraph.js | 8 + .../services/markdown/src/Nodes/Text.js | 19 ++ .../services/markdown/src/Nodes/blockQuote.ts | 15 + .../services/markdown/src/Nodes/table.ts | 9 + .../services/markdown/src/Nodes/tableCell.ts | 9 + .../markdown/src/Nodes/tableHeader.ts | 9 + .../services/markdown/src/Nodes/tableRow.ts | 9 + .../services/markdown/src/Nodes/taskList.ts | 9 + .../markdown/src/Nodes/taskListItem.ts | 9 + .../tiptap/services/markdown/src/Renderer.js | 182 +++++++++++ .../tiptap/services/markdown/src/utils.ts | 47 +++ 32 files changed, 1100 insertions(+), 70 deletions(-) create mode 100644 packages/client/src/components/tiptap/services/markdown/markdownTable.ts create mode 100644 packages/client/src/components/tiptap/services/markdown/markdownTaskList.ts create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Marks/Bold.js create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Marks/Code.js create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Marks/Italic.js create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Marks/Link.js create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Marks/Mark.js create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/BulletList.js create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/CodeBlock.js create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/CodeBlockWrapper.js create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/HardBreak.js create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/Heading.js create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/Image.js create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/ListItem.js create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/Node.ts create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/OrderedList.js create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/Paragraph.js create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/Text.js create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/blockQuote.ts create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/table.ts create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/tableCell.ts create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/tableHeader.ts create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/tableRow.ts create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/taskList.ts create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/taskListItem.ts create mode 100644 packages/client/src/components/tiptap/services/markdown/src/Renderer.js create mode 100644 packages/client/src/components/tiptap/services/markdown/src/utils.ts diff --git a/packages/client/src/components/tiptap/extensions/paste.ts b/packages/client/src/components/tiptap/extensions/paste.ts index 811e3e7..b6f6565 100644 --- a/packages/client/src/components/tiptap/extensions/paste.ts +++ b/packages/client/src/components/tiptap/extensions/paste.ts @@ -5,6 +5,7 @@ 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 { isTitleNode } from '../services/node'; export const Paste = Extension.create({ name: 'paste', @@ -63,14 +64,17 @@ export const Paste = Extension.create({ if (isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') { event.preventDefault(); // FIXME: 由于 title schema 的存在导致反序列化必有 title 节点存在 - // const hasTitle = isTitleNode(view.props.state.doc.content.firstChild); + const firstNode = view.props.state.doc.content.firstChild; + const hasTitle = isTitleNode(firstNode) && firstNode.content.size > 0; let schema = view.props.state.schema; const doc = markdownSerializer.deserialize({ schema, content: normalizePastedMarkdown(text), + hasTitle, }); + // @ts-ignore - const transaction = view.state.tr.insert(view.state.selection.head, doc); + const transaction = view.state.tr.insert(view.state.selection.head, view.state.schema.nodeFromJSON(doc)); view.dispatch(transaction); return true; } diff --git a/packages/client/src/components/tiptap/extensions/taskItem.ts b/packages/client/src/components/tiptap/extensions/taskItem.ts index 1cb2373..63977b7 100644 --- a/packages/client/src/components/tiptap/extensions/taskItem.ts +++ b/packages/client/src/components/tiptap/extensions/taskItem.ts @@ -1,34 +1,10 @@ -import { wrappingInputRule } from '@tiptap/core'; +import { wrappingInputRule, mergeAttributes } from '@tiptap/core'; 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'; 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,35 +27,35 @@ const CustomTaskItem = BuiltInTaskItem.extend({ ]; }, - addProseMirrorPlugins() { - return [ - new Plugin({ - props: { - // @ts-ignore - handleClick: (view, pos, event) => { - const state = view.state; - const schema = state.schema; + // addProseMirrorPlugins() { + // return [ + // new Plugin({ + // props: { + // // @ts-ignore + // handleClick: (view, pos, event) => { + // const state = view.state; + // const schema = state.schema; - const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY }); - const position = state.doc.resolve(coordinates.pos); - 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) { - return; - } - const tr = state.tr; - tr.setNodeMarkup(parentList.pos, schema.nodes.taskItem, { - checked: !parentList.node.attrs.checked, - }); - view.dispatch(tr); - }, - }, - }), - ]; - }, + // const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY }); + // const position = state.doc.resolve(coordinates.pos); + // 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) { + // return; + // } + // const tr = state.tr; + // tr.setNodeMarkup(parentList.pos, schema.nodes.taskItem, { + // checked: !parentList.node.attrs.checked, + // }); + // view.dispatch(tr); + // }, + // }, + // }), + // ]; + // }, }); export const TaskItem = CustomTaskItem.configure({ nested: true }); diff --git a/packages/client/src/components/tiptap/extensions/taskList.ts b/packages/client/src/components/tiptap/extensions/taskList.ts index 9c56c26..2233788 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/services/markdown/index.ts b/packages/client/src/components/tiptap/services/markdown/index.ts index 1aae44d..a913076 100644 --- a/packages/client/src/components/tiptap/services/markdown/index.ts +++ b/packages/client/src/components/tiptap/services/markdown/index.ts @@ -3,27 +3,25 @@ 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 emoji from 'markdown-it-emoji'; import katex from '@traptitech/markdown-it-katex'; +import tasklist from './markdownTaskList'; import splitMixedLists from './markedownSplitMixedList'; import markdownUnderline from './markdownUnderline'; import markdownBanner from './markdownBanner'; +import { markdownItTable } from './markdownTable'; -export const markdown = markdownit({ - html: true, - linkify: true, - typographer: true, -}) +export const markdown = markdownit('commonmark') .enable('strikethrough') .use(sub) .use(sup) .use(footnote) .use(anchor) - .use(tasklist, { enable: true }) + .use(tasklist) .use(splitMixedLists) .use(markdownUnderline) .use(markdownBanner) + .use(markdownItTable) .use(emoji) .use(katex); diff --git a/packages/client/src/components/tiptap/services/markdown/markdownTable.ts b/packages/client/src/components/tiptap/services/markdown/markdownTable.ts new file mode 100644 index 0000000..e4aefb5 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/markdownTable.ts @@ -0,0 +1,283 @@ +// Copied from https://github.com/markdown-it/markdown-it/blob/master/lib/rules_block/table.js + +function isSpace(code) { + switch (code) { + case 0x09: + case 0x20: + return true; + } + return false; +} + +function getLine(state, line) { + var pos = state.bMarks[line] + state.tShift[line], + max = state.eMarks[line]; + + return state.src.substr(pos, max - pos); +} + +function escapedSplit(str) { + var result = [], + pos = 0, + max = str.length, + ch, + isEscaped = false, + lastPos = 0, + current = ''; + + ch = str.charCodeAt(pos); + + while (pos < max) { + if (ch === 0x7c /* | */) { + if (!isEscaped) { + // pipe separating cells, '|' + result.push(current + str.substring(lastPos, pos)); + current = ''; + lastPos = pos + 1; + } else { + // escaped pipe, '\|' + current += str.substring(lastPos, pos - 1); + lastPos = pos; + } + } + + isEscaped = ch === 0x5c /* \ */; + pos++; + + ch = str.charCodeAt(pos); + } + + result.push(current + str.substring(lastPos)); + + return result; +} + +function table(state, startLine, endLine, silent) { + var ch, + lineText, + pos, + i, + l, + nextLine, + columns, + columnCount, + token, + aligns, + t, + tableLines, + tbodyLines, + oldParentType, + terminate, + terminatorRules, + firstCh, + secondCh; + + // should have at least two lines + if (startLine + 2 > endLine) { + return false; + } + + nextLine = startLine + 1; + + if (state.sCount[nextLine] < state.blkIndent) { + return false; + } + + // if it's indented more than 3 spaces, it should be a code block + if (state.sCount[nextLine] - state.blkIndent >= 4) { + return false; + } + + // first character of the second line should be '|', '-', ':', + // and no other characters are allowed but spaces; + // basically, this is the equivalent of /^[-:|][-:|\s]*$/ regexp + + pos = state.bMarks[nextLine] + state.tShift[nextLine]; + if (pos >= state.eMarks[nextLine]) { + return false; + } + + firstCh = state.src.charCodeAt(pos++); + if (firstCh !== 0x7c /* | */ && firstCh !== 0x2d /* - */ && firstCh !== 0x3a /* : */) { + return false; + } + + if (pos >= state.eMarks[nextLine]) { + return false; + } + + secondCh = state.src.charCodeAt(pos++); + if (secondCh !== 0x7c /* | */ && secondCh !== 0x2d /* - */ && secondCh !== 0x3a /* : */ && !isSpace(secondCh)) { + return false; + } + + // if first character is '-', then second character must not be a space + // (due to parsing ambiguity with list) + if (firstCh === 0x2d /* - */ && isSpace(secondCh)) { + return false; + } + + while (pos < state.eMarks[nextLine]) { + ch = state.src.charCodeAt(pos); + + if (ch !== 0x7c /* | */ && ch !== 0x2d /* - */ && ch !== 0x3a /* : */ && !isSpace(ch)) { + return false; + } + + pos++; + } + + lineText = getLine(state, startLine + 1); + + columns = lineText.split('|'); + aligns = []; + for (i = 0; i < columns.length; i++) { + t = columns[i].trim(); + if (!t) { + // allow empty columns before and after table, but not in between columns; + // e.g. allow ` |---| `, disallow ` ---||--- ` + if (i === 0 || i === columns.length - 1) { + continue; + } else { + return false; + } + } + + if (!/^:?-+:?$/.test(t)) { + return false; + } + if (t.charCodeAt(t.length - 1) === 0x3a /* : */) { + aligns.push(t.charCodeAt(0) === 0x3a /* : */ ? 'center' : 'right'); + } else if (t.charCodeAt(0) === 0x3a /* : */) { + aligns.push('left'); + } else { + aligns.push(''); + } + } + + lineText = getLine(state, startLine).trim(); + if (lineText.indexOf('|') === -1) { + return false; + } + if (state.sCount[startLine] - state.blkIndent >= 4) { + return false; + } + columns = escapedSplit(lineText); + if (columns.length && columns[0] === '') columns.shift(); + if (columns.length && columns[columns.length - 1] === '') columns.pop(); + + // header row will define an amount of columns in the entire table, + // and align row should be exactly the same (the rest of the rows can differ) + columnCount = columns.length; + if (columnCount === 0 || columnCount !== aligns.length) { + return false; + } + + if (silent) { + return true; + } + + oldParentType = state.parentType; + state.parentType = 'table'; + + // use 'blockquote' lists for termination because it's + // the most similar to tables + terminatorRules = state.md.block.ruler.getRules('blockquote'); + + token = state.push('table_open', 'table', 1); + token.map = tableLines = [startLine, 0]; + + token = state.push('thead_open', 'thead', 1); + token.map = [startLine, startLine + 1]; + + token = state.push('tr_open', 'tr', 1); + token.map = [startLine, startLine + 1]; + + for (i = 0; i < columns.length; i++) { + token = state.push('th_open', 'th', 1); + if (aligns[i]) { + token.attrs = [['style', 'text-align:' + aligns[i]]]; + } + + token = state.push('paragraph_open', 'p', 1); + token = state.push('inline', '', 0); + token.content = columns[i].trim(); + token.children = []; + token = state.push('paragraph_close', 'p', -1); + + token = state.push('th_close', 'th', -1); + } + + token = state.push('tr_close', 'tr', -1); + token = state.push('thead_close', 'thead', -1); + + for (nextLine = startLine + 2; nextLine < endLine; nextLine++) { + if (state.sCount[nextLine] < state.blkIndent) { + break; + } + + terminate = false; + for (i = 0, l = terminatorRules.length; i < l; i++) { + if (terminatorRules[i](state, nextLine, endLine, true)) { + terminate = true; + break; + } + } + + if (terminate) { + break; + } + lineText = getLine(state, nextLine).trim(); + if (!lineText) { + break; + } + if (state.sCount[nextLine] - state.blkIndent >= 4) { + break; + } + columns = escapedSplit(lineText); + if (columns.length && columns[0] === '') columns.shift(); + if (columns.length && columns[columns.length - 1] === '') columns.pop(); + + if (nextLine === startLine + 2) { + token = state.push('tbody_open', 'tbody', 1); + token.map = tbodyLines = [startLine + 2, 0]; + } + + token = state.push('tr_open', 'tr', 1); + token.map = [nextLine, nextLine + 1]; + + for (i = 0; i < columnCount; i++) { + token = state.push('td_open', 'td', 1); + if (aligns[i]) { + token.attrs = [['style', 'text-align:' + aligns[i]]]; + } + + token = state.push('paragraph_open', 'p', 1); + token = state.push('inline', '', 0); + token.content = columns[i].trim(); + token.children = []; + token = state.push('paragraph_close', 'p', -1); + + token = state.push('td_close', 'td', -1); + } + token = state.push('tr_close', 'tr', -1); + } + + if (tbodyLines) { + token = state.push('tbody_close', 'tbody', -1); + tbodyLines[1] = nextLine; + } + + token = state.push('table_close', 'table', -1); + tableLines[1] = nextLine; + + state.parentType = oldParentType; + state.line = nextLine; + return true; +} + +export const markdownItTable = (md, options) => { + md.block.ruler.before('paragraph', 'table', table, { + alt: ['paragraph', 'reference'], + }); +}; diff --git a/packages/client/src/components/tiptap/services/markdown/markdownTaskList.ts b/packages/client/src/components/tiptap/services/markdown/markdownTaskList.ts new file mode 100644 index 0000000..0c1e0f0 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/markdownTaskList.ts @@ -0,0 +1,175 @@ +/* + * SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: ISC + */ + +// Markdown-it plugin to render GitHub-style task lists; see +// +// https://github.com/blog/1375-task-lists-in-gfm-issues-pulls-comments +// https://github.com/blog/1825-task-lists-in-all-markdown-documents + +import MarkdownIt from 'markdown-it/lib'; +import StateCore from 'markdown-it/lib/rules_core/state_core'; +import Token from 'markdown-it/lib/token'; + +interface TaskListsOptions { + enabled: boolean; + label: boolean; + lineNumber: boolean; +} + +const checkboxRegex = /^ *\[([ x])\] /i; + +export default function markdownItTaskLists( + md: MarkdownIt, + options: TaskListsOptions = { enabled: true, label: true, lineNumber: false } +): void { + md.core.ruler.after('inline', 'github-task-lists', (state) => processToken(state, options)); + md.renderer.rules.taskListItemCheckbox = (tokens) => { + const token = tokens[0]; + const checkedAttribute = token.attrGet('checked') ? 'checked=""' : ''; + const disabledAttribute = token.attrGet('disabled') ? 'disabled=""' : ''; + const id = token.attrGet('id'); + const line = token.attrGet('line'); + const idAttribute = `id="${id}"`; + const dataLineAttribute = line && options.lineNumber ? `data-line="${line}"` : ''; + + 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/serializer.ts b/packages/client/src/components/tiptap/services/markdown/serializer.ts index 6c13fc4..07b4eb6 100644 --- a/packages/client/src/components/tiptap/services/markdown/serializer.ts +++ b/packages/client/src/components/tiptap/services/markdown/serializer.ts @@ -49,6 +49,12 @@ import { renderHTMLNode, } from './serializerHelpers'; +// import * as HTML/ from 'html-to-prosemirror' + +import { Renderer } from './src/Renderer'; + +const renderer = new Renderer(); + const defaultSerializerConfig = { marks: { [Bold.name]: defaultMarkdownSerializer.marks.strong, @@ -188,14 +194,53 @@ const renderMarkdown = (rawMarkdown) => { const createMarkdownSerializer = () => ({ // 将 markdown 字符串转换为 ProseMirror JSONDocument - deserialize: ({ schema, content }) => { + deserialize: ({ schema, content, hasTitle }) => { 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; + const json = renderer.render(body); + + console.log({ hasTitle, json, body }); + + if (!hasTitle) { + const firstNode = json.content[0]; + + if (firstNode) { + if (firstNode.type === 'heading') { + firstNode.type = 'title'; + } + } + } + + const nodes = json.content; + + const result = { type: 'doc', content: [] }; + + for (let i = 0; i < nodes.length; ) { + const node = nodes[i]; + + if (node.type === 'tableRow') { + const nextNode = nodes[i + 1]; + + if (nextNode && nextNode.type === 'table') { + nextNode.content.unshift(node); + result.content.push(nextNode); + i += 2; + } else { + // 出错了!! + } + } else { + result.content.push(node); + i += 1; + } + } + + return result; + + // const state = ProseMirrorDOMParser.fromSchema(schema).parse(body); + // return state.toJSON(); }, // 将 ProseMirror JSONDocument 转换为 markdown 字符串 diff --git a/packages/client/src/components/tiptap/services/markdown/src/Marks/Bold.js b/packages/client/src/components/tiptap/services/markdown/src/Marks/Bold.js new file mode 100644 index 0000000..cdf6017 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Marks/Bold.js @@ -0,0 +1,13 @@ +import { Mark } from './Mark'; + +export class Bold extends Mark { + matching() { + return this.DOMNode.nodeName === 'STRONG'; + } + + data() { + return { + type: 'bold', + }; + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Marks/Code.js b/packages/client/src/components/tiptap/services/markdown/src/Marks/Code.js new file mode 100644 index 0000000..d79e71a --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Marks/Code.js @@ -0,0 +1,16 @@ +import { Mark } from './Mark'; +export class Code extends Mark { + matching() { + if (this.DOMNode.parentNode.nodeName === 'PRE') { + return false; + } + + return this.DOMNode.nodeName === 'CODE'; + } + + data() { + return { + type: 'code', + }; + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Marks/Italic.js b/packages/client/src/components/tiptap/services/markdown/src/Marks/Italic.js new file mode 100644 index 0000000..5c48c77 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Marks/Italic.js @@ -0,0 +1,12 @@ +import { Mark } from './Mark'; +export class Italic extends Mark { + matching() { + return this.DOMNode.nodeName === 'EM'; + } + + data() { + return { + type: 'italic', + }; + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Marks/Link.js b/packages/client/src/components/tiptap/services/markdown/src/Marks/Link.js new file mode 100644 index 0000000..50438b9 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Marks/Link.js @@ -0,0 +1,15 @@ +import { Mark } from './Mark'; +export class Link extends Mark { + matching() { + return this.DOMNode.nodeName === 'A'; + } + + data() { + return { + type: 'link', + attrs: { + href: this.DOMNode.getAttribute('href'), + }, + }; + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Marks/Mark.js b/packages/client/src/components/tiptap/services/markdown/src/Marks/Mark.js new file mode 100644 index 0000000..80053a0 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Marks/Mark.js @@ -0,0 +1,14 @@ +export class Mark { + constructor(DomNode) { + this.type = 'mark'; + this.DOMNode = DomNode; + } + + matching() { + return false; + } + + data() { + return []; + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Nodes/BulletList.js b/packages/client/src/components/tiptap/services/markdown/src/Nodes/BulletList.js new file mode 100644 index 0000000..61355ab --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Nodes/BulletList.js @@ -0,0 +1,15 @@ +import { Node } from './Node'; + +export class BulletList extends Node { + type = 'bulletList'; + + matching() { + return this.DOMNode.nodeName === 'UL'; + } + + // data() { + // return { + // type: 'bulletList', + // }; + // } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Nodes/CodeBlock.js b/packages/client/src/components/tiptap/services/markdown/src/Nodes/CodeBlock.js new file mode 100644 index 0000000..0b56106 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Nodes/CodeBlock.js @@ -0,0 +1,29 @@ +import { Node } from './Node'; +export class CodeBlock extends Node { + type = 'codeBlock'; + matching() { + return this.DOMNode.nodeName === 'CODE' && this.DOMNode.parentNode.nodeName === 'PRE'; + } + + // getLanguage() { + // const language = this.DOMNode.getAttribute('class'); + // return language ? language.replace(/^language-/, '') : language; + // } + + // data() { + // const language = this.getLanguage(); + + // if (language) { + // return { + // type: 'codeBlock', + // attrs: { + // language, + // }, + // }; + // } + + // return { + // type: 'codeBlock', + // }; + // } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Nodes/CodeBlockWrapper.js b/packages/client/src/components/tiptap/services/markdown/src/Nodes/CodeBlockWrapper.js new file mode 100644 index 0000000..d45f221 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Nodes/CodeBlockWrapper.js @@ -0,0 +1,10 @@ +import { Node } from './Node'; +export class CodeBlockWrapper extends Node { + matching() { + return this.DOMNode.nodeName === 'PRE'; + } + + data() { + return null; + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Nodes/HardBreak.js b/packages/client/src/components/tiptap/services/markdown/src/Nodes/HardBreak.js new file mode 100644 index 0000000..c2119b5 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Nodes/HardBreak.js @@ -0,0 +1,9 @@ +import { Node } from './Node'; + +export class HardBreak extends Node { + type = 'hardBreak'; + + matching() { + return this.DOMNode.nodeName === 'BR'; + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Nodes/Heading.js b/packages/client/src/components/tiptap/services/markdown/src/Nodes/Heading.js new file mode 100644 index 0000000..c50027c --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Nodes/Heading.js @@ -0,0 +1,22 @@ +import { Node } from './Node'; +export class Heading extends Node { + type = 'heading'; + + getLevel() { + const matches = this.DOMNode.nodeName.match(/^H([1-6])/); + return matches ? matches[1] : null; + } + + matching() { + return Boolean(this.getLevel()); + } + + // data() { + // return { + // type: 'heading', + // attrs: { + // level: this.getLevel(), + // }, + // }; + // } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Nodes/Image.js b/packages/client/src/components/tiptap/services/markdown/src/Nodes/Image.js new file mode 100644 index 0000000..6ea180d --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Nodes/Image.js @@ -0,0 +1,21 @@ +import { Node } from './Node'; + +export class Image extends Node { + type = 'image'; + + matching() { + return this.DOMNode.nodeName === 'IMG'; + } + + data() { + return { + type: 'image', + attrs: { + src: this.DOMNode.getAttribute('src'), + class: this.DOMNode.getAttribute('class') || undefined, + alt: this.DOMNode.getAttribute('alt') || undefined, + title: this.DOMNode.getAttribute('title') || undefined, + }, + }; + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Nodes/ListItem.js b/packages/client/src/components/tiptap/services/markdown/src/Nodes/ListItem.js new file mode 100644 index 0000000..609cb3f --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Nodes/ListItem.js @@ -0,0 +1,21 @@ +import { Node } from './Node'; +export class ListItem extends Node { + constructor(...args) { + super(...args); + this.wrapper = { + type: 'paragraph', + }; + } + + type = 'listItem'; + + matching() { + return this.DOMNode.nodeName === 'LI'; + } + + // data() { + // if (this.DOMNode.childNodes.length === 1 && this.DOMNode.childNodes[0].nodeName === 'P') { + // this.wrapper = null; + // } + // } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Nodes/Node.ts b/packages/client/src/components/tiptap/services/markdown/src/Nodes/Node.ts new file mode 100644 index 0000000..97362a5 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Nodes/Node.ts @@ -0,0 +1,23 @@ +import { getAttributes } from '../utils'; + +export class Node { + wrapper: null; + type = 'node'; + DOMNode: HTMLElement; + + constructor(DomNode: HTMLElement) { + this.wrapper = null; + this.DOMNode = DomNode; + } + + matching() { + return false; + } + + data() { + return { + type: this.type, + attrs: getAttributes(this.type, this.DOMNode), + }; + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Nodes/OrderedList.js b/packages/client/src/components/tiptap/services/markdown/src/Nodes/OrderedList.js new file mode 100644 index 0000000..0fa69da --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Nodes/OrderedList.js @@ -0,0 +1,9 @@ +import { Node } from './Node'; + +export class OrderedList extends Node { + type = 'orderedList'; + + matching() { + return this.DOMNode.nodeName === 'OL'; + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Nodes/Paragraph.js b/packages/client/src/components/tiptap/services/markdown/src/Nodes/Paragraph.js new file mode 100644 index 0000000..0ddc339 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Nodes/Paragraph.js @@ -0,0 +1,8 @@ +import { Node } from './Node'; +export class Paragraph extends Node { + type = 'paragraph'; + + matching() { + return this.DOMNode.nodeName === 'P'; + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Nodes/Text.js b/packages/client/src/components/tiptap/services/markdown/src/Nodes/Text.js new file mode 100644 index 0000000..e761c61 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Nodes/Text.js @@ -0,0 +1,19 @@ +import { Node } from './Node'; +export class Text extends Node { + matching() { + return this.DOMNode.nodeName === '#text'; + } + + data() { + const text = this.DOMNode.nodeValue.replace(/^[\n]+/g, ''); + + if (!text) { + return null; + } + + return { + type: 'text', + text, + }; + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Nodes/blockQuote.ts b/packages/client/src/components/tiptap/services/markdown/src/Nodes/blockQuote.ts new file mode 100644 index 0000000..f20163e --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Nodes/blockQuote.ts @@ -0,0 +1,15 @@ +import { Node } from './Node'; + +export class Blockquote extends Node { + type = 'blockquote'; + + matching() { + return this.DOMNode.nodeName === 'BLOCKQUOTE'; + } + + // data() { + // return { + // type: 'blockquote', + // }; + // } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Nodes/table.ts b/packages/client/src/components/tiptap/services/markdown/src/Nodes/table.ts new file mode 100644 index 0000000..a9cc31f --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Nodes/table.ts @@ -0,0 +1,9 @@ +import { Node } from './Node'; + +export class Table extends Node { + type = 'table'; + + matching() { + return this.DOMNode.nodeName === 'TBODY' && this.DOMNode.parentNode.nodeName === 'TABLE'; + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Nodes/tableCell.ts b/packages/client/src/components/tiptap/services/markdown/src/Nodes/tableCell.ts new file mode 100644 index 0000000..b90151f --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Nodes/tableCell.ts @@ -0,0 +1,9 @@ +import { Node } from './Node'; + +export class TableCell extends Node { + type = 'tableCell'; + + matching() { + return this.DOMNode.nodeName === 'TD'; + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Nodes/tableHeader.ts b/packages/client/src/components/tiptap/services/markdown/src/Nodes/tableHeader.ts new file mode 100644 index 0000000..b7625a4 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Nodes/tableHeader.ts @@ -0,0 +1,9 @@ +import { Node } from './Node'; + +export class TableHeader extends Node { + type = 'tableHeader'; + + matching() { + return this.DOMNode.nodeName === 'TH'; + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Nodes/tableRow.ts b/packages/client/src/components/tiptap/services/markdown/src/Nodes/tableRow.ts new file mode 100644 index 0000000..75d14d0 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Nodes/tableRow.ts @@ -0,0 +1,9 @@ +import { Node } from './Node'; + +export class TableRow extends Node { + type = 'tableRow'; + + matching() { + return this.DOMNode.nodeName === 'TR'; + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Nodes/taskList.ts b/packages/client/src/components/tiptap/services/markdown/src/Nodes/taskList.ts new file mode 100644 index 0000000..ea943a2 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Nodes/taskList.ts @@ -0,0 +1,9 @@ +import { Node } from './Node'; + +export class TaskList extends Node { + type = 'taskList'; + + matching() { + return this.DOMNode.nodeName === 'UL' && this.DOMNode.classList.contains('task-list'); + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Nodes/taskListItem.ts b/packages/client/src/components/tiptap/services/markdown/src/Nodes/taskListItem.ts new file mode 100644 index 0000000..bbf4795 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Nodes/taskListItem.ts @@ -0,0 +1,9 @@ +import { Node } from './Node'; + +export class TaskListItem extends Node { + type = 'taskItem'; + + matching() { + return this.DOMNode.nodeName === 'LI' && this.DOMNode.classList.contains('task-list-item'); + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/Renderer.js b/packages/client/src/components/tiptap/services/markdown/src/Renderer.js new file mode 100644 index 0000000..9bbaf49 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/Renderer.js @@ -0,0 +1,182 @@ +import { BulletList } from './Nodes/BulletList'; +import { CodeBlock } from './Nodes/CodeBlock'; +import { CodeBlockWrapper } from './Nodes/CodeBlockWrapper'; +import { HardBreak } from './Nodes/HardBreak'; +import { Heading } from './Nodes/Heading'; +import { Image } from './Nodes/Image'; +import { ListItem } from './Nodes/ListItem'; +import { OrderedList } from './Nodes/OrderedList'; +import { Paragraph } from './Nodes/Paragraph'; +import { Text } from './Nodes/Text'; +import { Blockquote } from './Nodes/blockQuote'; + +import { Table } from './Nodes/table'; +import { TableHeader } from './Nodes/tableHeader'; +import { TableRow } from './Nodes/tableRow'; +import { TableCell } from './Nodes/tableCell'; + +import { TaskList } from './Nodes/taskList'; +import { TaskListItem } from './Nodes/taskListItem'; + +import { Bold } from './Marks/Bold'; +import { Code } from './Marks/Code'; +import { Italic } from './Marks/Italic'; +import { Link } from './Marks/Link'; + +export class Renderer { + constructor() { + this.document = undefined; + this.storedMarks = []; + + this.nodes = [ + CodeBlock, + CodeBlockWrapper, + HardBreak, + Heading, + Image, + Paragraph, + Text, + Blockquote, + + Table, + TableHeader, + TableRow, + TableCell, + + // 列表 + TaskList, + TaskListItem, + OrderedList, + ListItem, + BulletList, + ]; + + this.marks = [Bold, Code, Italic, Link]; + } + + setDocument(document) { + this.document = document; + } + + stripWhitespace(value) { + // return minify(value, { + // collapseWhitespace: true, + // }); + return value; + } + + getDocumentBody() { + return this.document; + // return this.document.window.document.querySelector('body'); + } + + render(value) { + this.setDocument(value); + + console.log(value); + + const content = this.renderChildren(this.getDocumentBody()); + + return { + type: 'doc', + content, + }; + } + + renderChildren(node) { + let nodes = []; + + node.childNodes.forEach((child) => { + const NodeClass = this.getMatchingNode(child); + let MarkClass; + + if (NodeClass) { + let item = NodeClass.data(); + + if (!item) { + if (child.hasChildNodes()) { + nodes.push(...this.renderChildren(child)); + } + return; + } + + if (child.hasChildNodes()) { + item = { + ...item, + content: this.renderChildren(child), + }; + } + + if (this.storedMarks.length) { + item = { + ...item, + marks: this.storedMarks, + }; + this.storedMarks = []; + } + + if (NodeClass.wrapper) { + item.content = [ + { + ...NodeClass.wrapper, + content: item.content || [], + }, + ]; + } + + nodes.push(item); + } else if ((MarkClass = this.getMatchingMark(child))) { + this.storedMarks.push(MarkClass.data()); + + if (child.hasChildNodes()) { + nodes.push(...this.renderChildren(child)); + } + } else if (child.hasChildNodes()) { + nodes.push(...this.renderChildren(child)); + } + }); + + return nodes; + } + + getMatchingNode(item) { + return this.getMatchingClass(item, this.nodes); + } + + getMatchingMark(item) { + return this.getMatchingClass(item, this.marks); + } + + getMatchingClass(node, classes) { + for (let i in classes) { + const Class = classes[i]; + const instance = new Class(node); + // console.log(node); + if (instance.matching()) { + return instance; + } + } + + return false; + } + + addNode(node) { + this.nodes.push(node); + } + + addNodes(nodes) { + for (const i in nodes) { + this.addNode(nodes[i]); + } + } + + addMark(mark) { + this.marks.push(mark); + } + + addMarks(marks) { + for (const i in marks) { + this.addMark(marks[i]); + } + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/src/utils.ts b/packages/client/src/components/tiptap/services/markdown/src/utils.ts new file mode 100644 index 0000000..18ff85a --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/src/utils.ts @@ -0,0 +1,47 @@ +import { BaseKit } from '../../../basekit'; + +export const getAttributes = (name: string, element: HTMLElement): Record => { + const ext = BaseKit.find((ext) => ext.name === name); + + const run = ( + ret = {}, + config: Record Record }> + ) => { + return Object.keys(config).reduce((accu, key) => { + const conf = config[key]; + accu[key] = conf.default; + + if (conf.parseHTML) { + try { + accu[key] = conf.parseHTML(element); + } catch (e) { + // + } + } + + return accu; + }, ret); + }; + + let parent = ext && ext.parent; + + if (!parent) return {}; + + while (parent.parent) { + parent = parent.parent; + } + + const { config } = parent; + const { addGlobalAttributes, addAttributes } = config; + const attrs = {}; + + if (addGlobalAttributes) { + run(attrs, addGlobalAttributes.call(ext)); + } + + if (addAttributes) { + run(attrs, addAttributes.call(ext)); + } + + return attrs; +}; From 9ddab5a1348d8016c81baa5f7c558726d734a33e Mon Sep 17 00:00:00 2001 From: fantasticit Date: Tue, 22 Mar 2022 20:31:21 +0800 Subject: [PATCH 5/7] feat: improve copy paste --- .../tiptap/components/katex/index.module.scss | 2 +- .../tiptap/components/katex/index.tsx | 11 +- .../components/tiptap/extensions/banner.ts | 2 +- .../tiptap/extensions/horizontalRule.ts | 8 +- .../src/components/tiptap/extensions/katex.ts | 20 +- .../src/components/tiptap/extensions/paste.ts | 10 +- .../tiptap/menus/components/paragraph.tsx | 2 + .../markdown/htmlToProsemirror/README.md | 1 + .../markdown/htmlToProsemirror/index.ts | 7 + .../marks/bold.ts} | 2 +- .../marks/code.ts} | 3 +- .../marks/italic.ts} | 2 +- .../marks/link.ts} | 2 +- .../marks/mark.ts} | 3 + .../nodes}/blockQuote.ts | 2 +- .../nodes/bulletList.ts} | 8 +- .../htmlToProsemirror/nodes/codeBlock.ts | 9 + .../nodes/codeBlockWrapper.ts} | 3 +- .../nodes/hardBreak.ts} | 2 +- .../nodes/heading.ts} | 18 +- .../htmlToProsemirror/nodes/horizontalRule.ts | 9 + .../nodes/image.ts} | 2 +- .../markdown/htmlToProsemirror/nodes/katex.ts | 9 + .../htmlToProsemirror/nodes/listItem.ts | 16 ++ .../nodes/node.ts} | 4 +- .../nodes/orderedList.ts} | 2 +- .../nodes/paragraph.ts} | 2 +- .../nodes}/table.ts | 2 +- .../nodes}/tableCell.ts | 2 +- .../nodes}/tableHeader.ts | 2 +- .../nodes}/tableRow.ts | 2 +- .../nodes}/taskList.ts | 2 +- .../nodes}/taskListItem.ts | 2 +- .../nodes/text.ts} | 3 +- .../renderer.ts} | 67 +++--- .../markdown/htmlToProsemirror/utils.ts | 60 +++++ .../markdown/{ => markdownToHTML}/index.ts | 11 +- .../{ => markdownToHTML}/markdownBanner.ts | 0 .../markdown/markdownToHTML/markdownKatex.ts | 225 ++++++++++++++++++ .../{ => markdownToHTML}/markdownTable.ts | 0 .../{ => markdownToHTML}/markdownTaskList.ts | 0 .../{ => markdownToHTML}/markdownUnderline.ts | 0 .../markedownSplitMixedList.ts | 2 +- .../tiptap/services/markdown/serializer.ts | 37 +-- .../services/markdown/src/Nodes/CodeBlock.js | 29 --- .../services/markdown/src/Nodes/ListItem.js | 21 -- .../tiptap/services/markdown/src/utils.ts | 47 ---- packages/client/src/hooks/useTheme.tsx | 19 +- packages/client/src/styles/prosemirror.scss | 4 +- .../server/src/entities/document.entity.ts | 2 +- 50 files changed, 473 insertions(+), 227 deletions(-) create mode 100644 packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/README.md create mode 100644 packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/index.ts rename packages/client/src/components/tiptap/services/markdown/{src/Marks/Bold.js => htmlToProsemirror/marks/bold.ts} (83%) rename packages/client/src/components/tiptap/services/markdown/{src/Marks/Code.js => htmlToProsemirror/marks/code.ts} (88%) rename packages/client/src/components/tiptap/services/markdown/{src/Marks/Italic.js => htmlToProsemirror/marks/italic.ts} (83%) rename packages/client/src/components/tiptap/services/markdown/{src/Marks/Link.js => htmlToProsemirror/marks/link.ts} (87%) rename packages/client/src/components/tiptap/services/markdown/{src/Marks/Mark.js => htmlToProsemirror/marks/mark.ts} (80%) rename packages/client/src/components/tiptap/services/markdown/{src/Nodes => htmlToProsemirror/nodes}/blockQuote.ts (87%) rename packages/client/src/components/tiptap/services/markdown/{src/Nodes/BulletList.js => htmlToProsemirror/nodes/bulletList.ts} (52%) create mode 100644 packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/codeBlock.ts rename packages/client/src/components/tiptap/services/markdown/{src/Nodes/CodeBlockWrapper.js => htmlToProsemirror/nodes/codeBlockWrapper.ts} (81%) rename packages/client/src/components/tiptap/services/markdown/{src/Nodes/HardBreak.js => htmlToProsemirror/nodes/hardBreak.ts} (80%) rename packages/client/src/components/tiptap/services/markdown/{src/Nodes/Heading.js => htmlToProsemirror/nodes/heading.ts} (56%) create mode 100644 packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/horizontalRule.ts rename packages/client/src/components/tiptap/services/markdown/{src/Nodes/Image.js => htmlToProsemirror/nodes/image.ts} (93%) create mode 100644 packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/katex.ts create mode 100644 packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/listItem.ts rename packages/client/src/components/tiptap/services/markdown/{src/Nodes/Node.ts => htmlToProsemirror/nodes/node.ts} (85%) rename packages/client/src/components/tiptap/services/markdown/{src/Nodes/OrderedList.js => htmlToProsemirror/nodes/orderedList.ts} (80%) rename packages/client/src/components/tiptap/services/markdown/{src/Nodes/Paragraph.js => htmlToProsemirror/nodes/paragraph.ts} (80%) rename packages/client/src/components/tiptap/services/markdown/{src/Nodes => htmlToProsemirror/nodes}/table.ts (84%) rename packages/client/src/components/tiptap/services/markdown/{src/Nodes => htmlToProsemirror/nodes}/tableCell.ts (80%) rename packages/client/src/components/tiptap/services/markdown/{src/Nodes => htmlToProsemirror/nodes}/tableHeader.ts (80%) rename packages/client/src/components/tiptap/services/markdown/{src/Nodes => htmlToProsemirror/nodes}/tableRow.ts (80%) rename packages/client/src/components/tiptap/services/markdown/{src/Nodes => htmlToProsemirror/nodes}/taskList.ts (84%) rename packages/client/src/components/tiptap/services/markdown/{src/Nodes => htmlToProsemirror/nodes}/taskListItem.ts (85%) rename packages/client/src/components/tiptap/services/markdown/{src/Nodes/Text.js => htmlToProsemirror/nodes/text.ts} (89%) rename packages/client/src/components/tiptap/services/markdown/{src/Renderer.js => htmlToProsemirror/renderer.ts} (69%) create mode 100644 packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/utils.ts rename packages/client/src/components/tiptap/services/markdown/{ => markdownToHTML}/index.ts (74%) rename packages/client/src/components/tiptap/services/markdown/{ => markdownToHTML}/markdownBanner.ts (100%) create mode 100644 packages/client/src/components/tiptap/services/markdown/markdownToHTML/markdownKatex.ts rename packages/client/src/components/tiptap/services/markdown/{ => markdownToHTML}/markdownTable.ts (100%) rename packages/client/src/components/tiptap/services/markdown/{ => markdownToHTML}/markdownTaskList.ts (100%) rename packages/client/src/components/tiptap/services/markdown/{ => markdownToHTML}/markdownUnderline.ts (100%) rename packages/client/src/components/tiptap/services/markdown/{ => markdownToHTML}/markedownSplitMixedList.ts (97%) delete mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/CodeBlock.js delete mode 100644 packages/client/src/components/tiptap/services/markdown/src/Nodes/ListItem.js delete mode 100644 packages/client/src/components/tiptap/services/markdown/src/utils.ts 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 9796867..a77831f 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 bf7371b..47d7458 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,9 @@ const { Text } = Typography; export const KatexWrapper = ({ editor, node, updateAttributes }) => { const isEditable = editor.isEditable; const { text } = node.attrs; + + console.log(node.attrs); + const formatText = useMemo(() => { try { return katex.renderToString(`${text}`); @@ -24,8 +27,12 @@ export const KatexWrapper = ({ editor, node, updateAttributes }) => { 点击输入公式 ); + // useEffect(() => { + // updateAttributes(node.attrs); + // }, []); + return ( - + {isEditable ? ( { diff --git a/packages/client/src/components/tiptap/extensions/horizontalRule.ts b/packages/client/src/components/tiptap/extensions/horizontalRule.ts index a39e38a..f9c3743 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/katex.ts b/packages/client/src/components/tiptap/extensions/katex.ts index 0df0480..91b992c 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/paste.ts b/packages/client/src/components/tiptap/extensions/paste.ts index b6f6565..e10401a 100644 --- a/packages/client/src/components/tiptap/extensions/paste.ts +++ b/packages/client/src/components/tiptap/extensions/paste.ts @@ -1,6 +1,6 @@ import { Extension } from '@tiptap/core'; import { Plugin, PluginKey } from 'prosemirror-state'; -import { markdownSerializer } from '../services/markdown'; +import { markdownSerializer } from '../services/markdown/serializer'; import { EXTENSION_PRIORITY_HIGHEST } from '../constants'; import { handleFileEvent } from '../services/upload'; import { isInCode, LANGUAGES } from '../services/code'; @@ -63,16 +63,14 @@ export const Paste = Extension.create({ // 处理 markdown if (isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') { event.preventDefault(); - // FIXME: 由于 title schema 的存在导致反序列化必有 title 节点存在 const firstNode = view.props.state.doc.content.firstChild; const hasTitle = isTitleNode(firstNode) && firstNode.content.size > 0; - let schema = view.props.state.schema; - const doc = markdownSerializer.deserialize({ + const schema = view.props.state.schema; + const doc = markdownSerializer.markdownToProsemirror({ schema, content: normalizePastedMarkdown(text), hasTitle, }); - // @ts-ignore const transaction = view.state.tr.insert(view.state.selection.head, view.state.schema.nodeFromJSON(doc)); view.dispatch(transaction); @@ -113,7 +111,7 @@ export const Paste = Extension.create({ if (!doc) { return ''; } - const content = markdownSerializer.serialize({ + const content = markdownSerializer.proseMirrorToMarkdown({ schema: this.editor.schema, content: doc, }); diff --git a/packages/client/src/components/tiptap/menus/components/paragraph.tsx b/packages/client/src/components/tiptap/menus/components/paragraph.tsx index e45e960..c42f556 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 ( { tippyOptions={{ maxWidth: 456, }} - matchRenderContainer={(node: HTMLElement) => node.classList && node.tagName === 'TABLE'} + matchRenderContainer={(node: HTMLElement) => + node && node.classList && node.classList.contains('tableWrapper') && node.tagName === 'DIV' + } > diff --git a/packages/client/src/components/tiptap/services/dataset.ts b/packages/client/src/components/tiptap/services/dataset.ts new file mode 100644 index 0000000..7839972 --- /dev/null +++ b/packages/client/src/components/tiptap/services/dataset.ts @@ -0,0 +1,69 @@ +import { safeJSONParse } from 'helpers/json'; + +/** + * 将 JSON 转为字符串 + * @param json + */ +export const jsonToStr = (json: Record) => { + try { + return JSON.stringify(json); + } catch (e) { + return JSON.stringify({}); + } +}; + +/** + * 将字符串转为 JSON + * @param str + */ +export const strToJSON = (str: string) => { + return safeJSONParse(str); +}; + +/** + * 将 JSON 转为 DOM 节点的 dataset + * @param element + * @param json + */ +export const jsonToDOMDataset = (json: Record) => { + return Object.keys(json).map((key) => { + let value = json[key]; + + if (typeof value === 'object') { + value = JSON.stringify(value); + } + + return { + key: `data-${key}`, + value: encodeURIComponent(value as string), + }; + }); +}; + +/** + * 从 element 上提取 dataset 数据 + * @param element + * @param attribute + * @param transformToJSON 是否要转为 JSON + */ +export const getDatasetAttribute = + (attribute: string, transformToJSON: boolean = false) => + (element: HTMLElement) => { + const dataKey = attribute.startsWith('data-') ? attribute : `data-${attribute}`; + const value = decodeURIComponent(element.getAttribute(dataKey)); + + if (transformToJSON) { + try { + return JSON.parse(value); + } catch (e) { + return {}; + } + } + + if (value.includes('%') || value.includes('auto')) { + return value; + } + + const toNumber = parseInt(value); + return toNumber !== toNumber ? value : toNumber; // 避免 NaN + }; diff --git a/packages/client/src/components/tiptap/services/lowlightPlugin.ts b/packages/client/src/components/tiptap/services/lowlightPlugin.ts new file mode 100644 index 0000000..b16d28c --- /dev/null +++ b/packages/client/src/components/tiptap/services/lowlightPlugin.ts @@ -0,0 +1,139 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Decoration, DecorationSet } from 'prosemirror-view'; +import { Node as ProsemirrorNode } from 'prosemirror-model'; +import { findChildren } from '@tiptap/core'; + +function parseNodes(nodes: any[], className: string[] = []): { text: string; classes: string[] }[] { + return nodes + .map((node) => { + const classes = [...className, ...(node.properties ? node.properties.className : [])]; + + if (node.children) { + return parseNodes(node.children, classes); + } + + return { + text: node.value, + classes, + }; + }) + .flat(); +} + +function getHighlightNodes(result: any) { + // `.value` for lowlight v1, `.children` for lowlight v2 + return result.value || result.children || []; +} + +function getDecorations({ + doc, + name, + lowlight, + defaultLanguage, +}: { + doc: ProsemirrorNode; + name: string; + lowlight: any; + defaultLanguage: string | null | undefined; +}) { + const decorations: Decoration[] = []; + + findChildren(doc, (node) => node.type.name === name).forEach((block) => { + let from = block.pos + 1; + const language = block.node.attrs.language || defaultLanguage; + const languages = lowlight.listLanguages(); + const nodes = + language && languages.includes(language) + ? getHighlightNodes(lowlight.highlight(language, block.node.textContent)) + : getHighlightNodes(lowlight.highlightAuto(block.node.textContent)); + + parseNodes(nodes).forEach((node) => { + const to = from + node.text.length; + + if (node.classes.length) { + const decoration = Decoration.inline(from, to, { + class: node.classes.join(' '), + }); + + decorations.push(decoration); + } + + from = to; + }); + }); + + return DecorationSet.create(doc, decorations); +} + +export function LowlightPlugin({ + name, + lowlight, + defaultLanguage, +}: { + name: string; + lowlight: any; + defaultLanguage: string | null | undefined; +}) { + return new Plugin({ + key: new PluginKey('lowlight'), + + state: { + init: (_, { doc }) => + getDecorations({ + doc, + name, + lowlight, + defaultLanguage, + }), + apply: (transaction, decorationSet, oldState, newState) => { + const oldNodeName = oldState.selection.$head.parent.type.name; + const newNodeName = newState.selection.$head.parent.type.name; + const oldNodes = findChildren(oldState.doc, (node) => node.type.name === name); + const newNodes = findChildren(newState.doc, (node) => node.type.name === name); + + if ( + transaction.docChanged && + // Apply decorations if: + // selection includes named node, + ([oldNodeName, newNodeName].includes(name) || + // OR transaction adds/removes named node, + newNodes.length !== oldNodes.length || + // OR transaction has changes that completely encapsulte a node + // (for example, a transaction that affects the entire document). + // Such transactions can happen during collab syncing via y-prosemirror, for example. + transaction.steps.some((step) => { + // @ts-ignore + return ( + step.from !== undefined && + // @ts-ignore + step.to !== undefined && + oldNodes.some((node) => { + // @ts-ignore + return ( + node.pos >= step.from && + // @ts-ignore + node.pos + node.node.nodeSize <= step.to + ); + }) + ); + })) + ) { + return getDecorations({ + doc: transaction.doc, + name, + lowlight, + defaultLanguage, + }); + } + + return decorationSet.map(transaction.mapping, transaction.doc); + }, + }, + + props: { + decorations(state) { + return this.getState(state); + }, + }, + }); +} diff --git a/packages/client/src/components/tiptap/services/markdown/helpers.ts b/packages/client/src/components/tiptap/services/markdown/helpers.ts index bc9c7bc..686bf1d 100644 --- a/packages/client/src/components/tiptap/services/markdown/helpers.ts +++ b/packages/client/src/components/tiptap/services/markdown/helpers.ts @@ -1,4 +1,8 @@ export const isMarkdown = (text: string): boolean => { + // html + const html = text.match(/<\/?[a-z][\s\S]*>/i); + if (html && html.length) return true; + // table const tables = text.match(/^\|(\S)*\|/gm); if (tables && tables.length) return true; diff --git a/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/README.md b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/README.md deleted file mode 100644 index 7e22513..0000000 --- a/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/README.md +++ /dev/null @@ -1 +0,0 @@ -> 将 HTML 转换成 prosemirror node diff --git a/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/index.ts b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/index.ts index 273837e..20f37da 100644 --- a/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/index.ts +++ b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/index.ts @@ -2,6 +2,52 @@ import { Renderer } from './renderer'; const renderer = new Renderer(); -export const htmlToPromsemirror = (body) => { - return renderer.render(body); +/** + * 将 HTML 转换成 prosemirror node + * @param body + * @param forceATitle 是否需要一个标题 + * @returns + */ +export const htmlToPromsemirror = (body, forceATitle = false) => { + const json = renderer.render(body); + + // 设置标题 + if (forceATitle) { + const firstNode = json.content[0]; + if (firstNode && firstNode.type !== 'title') { + if (firstNode.type === 'heading' || firstNode.type === 'paragraph') { + firstNode.type = 'title'; + } + } + } + + const nodes = json.content; + const result = { type: 'doc', content: [] }; + + for (let i = 0; i < nodes.length; ) { + const node = nodes[i]; + // 目的:合并成 promirror 需要的 table 格式 + if (node.type === 'tableRow') { + const nextNode = nodes[i + 1]; + if (nextNode && nextNode.type === 'table') { + nextNode.content.unshift(node); + result.content.push(nextNode); + i += 2; + } + } else { + result.content.push(node); + i += 1; + } + } + + // trailing node + result.content.push({ + type: 'paragraph', + attrs: { + indent: 0, + textAlign: 'left', + }, + }); + + return result; }; diff --git a/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/marks/mark.ts b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/marks/mark.ts index 7a96d5f..2782e60 100644 --- a/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/marks/mark.ts +++ b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/marks/mark.ts @@ -12,6 +12,6 @@ export class Mark { } data() { - return []; + return {}; } } diff --git a/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/marks/underline.ts b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/marks/underline.ts new file mode 100644 index 0000000..ad36dc0 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/marks/underline.ts @@ -0,0 +1,13 @@ +import { Mark } from './mark'; + +export class Underline extends Mark { + matching() { + return this.DOMNode.nodeName === 'U'; + } + + data() { + return { + type: 'underline', + }; + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/attachment.ts b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/attachment.ts new file mode 100644 index 0000000..e2360c8 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/attachment.ts @@ -0,0 +1,9 @@ +import { Node } from './node'; + +export class Attachment extends Node { + type = 'attachment'; + + matching() { + return this.DOMNode.nodeName === 'DIV' && this.DOMNode.classList.contains('attachment'); + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/banner.ts b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/banner.ts new file mode 100644 index 0000000..edaf1f0 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/banner.ts @@ -0,0 +1,9 @@ +import { Node } from './node'; + +export class Banner extends Node { + type = 'banner'; + + matching() { + return this.DOMNode.nodeName === 'DIV' && this.DOMNode.classList.contains('banner'); + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/documentChildren.ts b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/documentChildren.ts new file mode 100644 index 0000000..879e70d --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/documentChildren.ts @@ -0,0 +1,9 @@ +import { Node } from './node'; + +export class DocumentChildren extends Node { + type = 'documentChildren'; + + matching() { + return this.DOMNode.nodeName === 'DIV' && this.DOMNode.classList.contains('documentChildren'); + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/documentReference.ts b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/documentReference.ts new file mode 100644 index 0000000..1ac2c64 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/documentReference.ts @@ -0,0 +1,9 @@ +import { Node } from './node'; + +export class DocumentReference extends Node { + type = 'documentReference'; + + matching() { + return this.DOMNode.nodeName === 'DIV' && this.DOMNode.classList.contains('documentReference'); + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/iframe.ts b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/iframe.ts new file mode 100644 index 0000000..113e655 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/iframe.ts @@ -0,0 +1,9 @@ +import { Node } from './node'; + +export class Iframe extends Node { + type = 'iframe'; + + matching() { + return this.DOMNode.nodeName === 'DIV' && this.DOMNode.classList.contains('iframe'); + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/mind.ts b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/mind.ts new file mode 100644 index 0000000..0b53e86 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/mind.ts @@ -0,0 +1,9 @@ +import { Node } from './node'; + +export class Mind extends Node { + type = 'mind'; + + matching() { + return this.DOMNode.nodeName === 'DIV' && this.DOMNode.classList.contains('mind'); + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/status.ts b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/status.ts new file mode 100644 index 0000000..61fba4e --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/status.ts @@ -0,0 +1,9 @@ +import { Node } from './node'; + +export class Status extends Node { + type = 'status'; + + matching() { + return this.DOMNode.nodeName === 'DIV' && this.DOMNode.classList.contains('status'); + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/text.ts b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/text.ts index 7771ccd..f1f723f 100644 --- a/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/text.ts +++ b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/text.ts @@ -14,7 +14,7 @@ export class Text extends Node { return { type: 'text', - text, + text: text.trim() || '\n', }; } } diff --git a/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/title.ts b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/title.ts new file mode 100644 index 0000000..5b1dba6 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/nodes/title.ts @@ -0,0 +1,9 @@ +import { Node } from './node'; + +export class Title extends Node { + type = 'title'; + + matching() { + return this.DOMNode.nodeName === 'P' && this.DOMNode.classList.contains('title'); + } +} diff --git a/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/renderer.ts b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/renderer.ts index 3a01db4..e21ddda 100644 --- a/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/renderer.ts +++ b/packages/client/src/components/tiptap/services/markdown/htmlToProsemirror/renderer.ts @@ -1,4 +1,12 @@ -// nodes +// 自定义节点 +import { Iframe } from './nodes/iframe'; +import { Attachment } from './nodes/attachment'; +import { Banner } from './nodes/banner'; +import { Status } from './nodes/status'; +import { DocumentReference } from './nodes/documentReference'; +import { DocumentChildren } from './nodes/documentChildren'; +import { Mind } from './nodes/mind'; +// 通用 import { CodeBlock } from './nodes/codeBlock'; import { CodeBlockWrapper } from './nodes/codeBlockWrapper'; import { HardBreak } from './nodes/hardBreak'; @@ -6,18 +14,16 @@ import { Heading } from './nodes/heading'; import { Image } from './nodes/image'; import { HorizontalRule } from './nodes/horizontalRule'; import { Blockquote } from './nodes/blockQuote'; - // 文本 +import { Title } from './nodes/title'; import { Katex } from './nodes/katex'; import { Paragraph } from './nodes/paragraph'; import { Text } from './nodes/text'; - // 表格 import { Table } from './nodes/table'; import { TableHeader } from './nodes/tableHeader'; import { TableRow } from './nodes/tableRow'; import { TableCell } from './nodes/tableCell'; - // 列表 import { TaskList } from './nodes/taskList'; import { TaskListItem } from './nodes/taskListItem'; @@ -30,6 +36,7 @@ import { Bold } from './marks/bold'; import { Code } from './marks/code'; import { Italic } from './marks/italic'; import { Link } from './marks/link'; +import { Underline } from './marks/underline'; export class Renderer { document: HTMLElement; @@ -42,6 +49,14 @@ export class Renderer { this.storedMarks = []; this.nodes = [ + Attachment, + Banner, + Iframe, + Status, + Mind, + DocumentChildren, + DocumentReference, + CodeBlock, CodeBlockWrapper, HardBreak, @@ -49,6 +64,7 @@ export class Renderer { Image, HorizontalRule, + Title, Katex, Paragraph, @@ -68,7 +84,7 @@ export class Renderer { BulletList, ]; - this.marks = [Bold, Code, Italic, Link]; + this.marks = [Bold, Code, Italic, Link, Underline]; } setDocument(document) { diff --git a/packages/client/src/components/tiptap/services/markdown/index.tsx b/packages/client/src/components/tiptap/services/markdown/index.tsx new file mode 100644 index 0000000..115b8b4 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/index.tsx @@ -0,0 +1,19 @@ +import { htmlToPromsemirror } from './htmlToProsemirror'; +import { markdownToHTML } from './markdownToHTML'; +export { prosemirrorToMarkdown } from './prosemirrorToMarkdown'; +export * from './helpers'; +export * from './markdownSourceMap'; + +// 将 markdown 字符串转换为 ProseMirror JSONDocument +export const markdownToProsemirror = ({ schema, content, hasTitle }) => { + const html = markdownToHTML(content); + + if (!html) return null; + + console.log(html); + + const parser = new DOMParser(); + const { body } = parser.parseFromString(html, 'text/html'); + body.append(document.createComment(content)); + return htmlToPromsemirror(body, !hasTitle); +}; diff --git a/packages/client/src/components/tiptap/services/markdown/markdownToHTML/index.ts b/packages/client/src/components/tiptap/services/markdown/markdownToHTML/index.ts index a505fef..e07ecc0 100644 --- a/packages/client/src/components/tiptap/services/markdown/markdownToHTML/index.ts +++ b/packages/client/src/components/tiptap/services/markdown/markdownToHTML/index.ts @@ -10,6 +10,14 @@ import splitMixedLists from './markedownSplitMixedList'; import markdownUnderline from './markdownUnderline'; import markdownBanner from './markdownBanner'; import { markdownItTable } from './markdownTable'; +import { createMarkdownContainer } from './markdownItContainer'; + +const markdownAttachment = createMarkdownContainer('attachment'); +const markdownIframe = createMarkdownContainer('iframe'); +const markdownStatus = createMarkdownContainer('status'); +const markdownMind = createMarkdownContainer('mind'); +const markdownDocumentReference = createMarkdownContainer('documentReference'); +const markdownDocumentChildren = createMarkdownContainer('documentChildren'); const markdown = markdownit('commonmark') .enable('strikethrough') @@ -19,10 +27,17 @@ const markdown = markdownit('commonmark') .use(tasklist) .use(splitMixedLists) .use(markdownUnderline) - .use(markdownBanner) .use(markdownItTable) .use(emoji) - .use(katex); + .use(katex) + // 以下为自定义节点 + .use(markdownBanner) + .use(markdownAttachment) + .use(markdownIframe) + .use(markdownStatus) + .use(markdownMind) + .use(markdownDocumentReference) + .use(markdownDocumentChildren); export const markdownToHTML = (rawMarkdown) => { return sanitize(markdown.render(rawMarkdown), {}); diff --git a/packages/client/src/components/tiptap/services/markdown/markdownToHTML/markdownBanner.ts b/packages/client/src/components/tiptap/services/markdown/markdownToHTML/markdownBanner.ts index 9260af1..c8949af 100644 --- a/packages/client/src/components/tiptap/services/markdown/markdownToHTML/markdownBanner.ts +++ b/packages/client/src/components/tiptap/services/markdown/markdownToHTML/markdownBanner.ts @@ -5,9 +5,8 @@ export const typesAvailable = ['info', 'warning', 'danger', 'success']; const buildRender = (type) => (tokens, idx, options, env, slf) => { const tag = tokens[idx]; - // add attributes to the opening tag if (tag.nesting === 1) { - tag.attrSet('data-banner', type); + tag.attrSet('data-type', type); tag.attrJoin('class', `banner banner-${type}`); } diff --git a/packages/client/src/components/tiptap/services/markdown/markdownToHTML/markdownItContainer.ts b/packages/client/src/components/tiptap/services/markdown/markdownToHTML/markdownItContainer.ts new file mode 100644 index 0000000..fb9f3a7 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/markdownToHTML/markdownItContainer.ts @@ -0,0 +1,37 @@ +import container from 'markdown-it-container'; +import { strToJSON, jsonToDOMDataset } from '../../dataset'; + +export const createMarkdownContainer = (types: string | Array) => (md) => { + if (!Array.isArray(types)) { + types = [types]; + } + + types.forEach((type) => { + const regexp = new RegExp(`^${type}\\s+(.*)$`); + + md.use(container, type, { + validate: function (params) { + return params.trim().match(regexp); + }, + + render: function (tokens, idx, options, env, slf) { + const tag = tokens[idx]; + + if (tag.nesting === 1) { + tag.attrSet('class', type); + + var m = tag.info.trim().match(regexp); + if (m[1]) { + const data = strToJSON(m[1]); + jsonToDOMDataset(data).forEach(({ key, value }) => { + tag.attrJoin(key, value); + }); + } + } + + return slf.renderToken(tokens, idx, options, env, slf); + }, + }); + }); + return md; +}; 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 0000000..85577b8 --- /dev/null +++ b/packages/client/src/components/tiptap/services/markdown/prosemirrorToMarkdown/index.ts @@ -0,0 +1,160 @@ +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, 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 { 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 { 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, + }, + ...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); + + console.log(content); + + return serializer.serialize(content, { + tightLists: true, + }); +}; 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 cf84756..084b98b 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 35e9f95..0000000 --- a/packages/client/src/components/tiptap/services/markdown/serializer.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { MarkdownSerializer as ProseMirrorMarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown'; -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'; -import { htmlToPromsemirror } from './htmlToProsemirror'; -import { markdownToHTML } from './markdownToHTML'; - -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 createMarkdownSerializer = () => ({ - // 将 markdown 字符串转换为 ProseMirror JSONDocument - markdownToProsemirror: ({ schema, content, hasTitle }) => { - const html = markdownToHTML(content); - if (!html) return null; - - const parser = new DOMParser(); - const { body } = parser.parseFromString(html, 'text/html'); - body.append(document.createComment(content)); - const json = htmlToPromsemirror(body); - - console.log({ hasTitle, json, body }); - - // 设置标题 - if (!hasTitle) { - const firstNode = json.content[0]; - if (firstNode) { - if (firstNode.type === 'heading' || firstNode.type === 'paragraph') { - firstNode.type = 'title'; - } - } - } - - const nodes = json.content; - const result = { type: 'doc', content: [] }; - - for (let i = 0; i < nodes.length; ) { - const node = nodes[i]; - // 目的:合并成 promirror 需要的 table 格式 - if (node.type === 'tableRow') { - const nextNode = nodes[i + 1]; - if (nextNode && nextNode.type === 'table') { - nextNode.content.unshift(node); - result.content.push(nextNode); - i += 2; - } - } else { - result.content.push(node); - i += 1; - } - } - - return result; - }, - - // 将 ProseMirror JSONDocument 转换为 markdown 字符串 - proseMirrorToMarkdown: ({ 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 8754649..03aed34 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,12 +37,24 @@ 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) { @@ -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; diff --git a/packages/client/src/hooks/useTheme.tsx b/packages/client/src/hooks/useTheme.tsx index d9aa43e..278eb04 100644 --- a/packages/client/src/hooks/useTheme.tsx +++ b/packages/client/src/hooks/useTheme.tsx @@ -14,15 +14,15 @@ export const useTheme = () => { }; useEffect(() => { - // const body = document.body; - // if (theme === 'dark') { - // body.setAttribute('theme-mode', 'dark'); - // return; - // } - // if (theme === 'light') { - // body.setAttribute('theme-mode', 'light'); - // return; - // } + const body = document.body; + if (theme === 'dark') { + body.setAttribute('theme-mode', 'dark'); + return; + } + if (theme === 'light') { + body.setAttribute('theme-mode', 'light'); + return; + } }, [theme]); useEffect(() => { diff --git a/packages/client/src/styles/prosemirror.scss b/packages/client/src/styles/prosemirror.scss index 7548faf..10fec37 100644 --- a/packages/client/src/styles/prosemirror.scss +++ b/packages/client/src/styles/prosemirror.scss @@ -240,19 +240,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, diff --git a/packages/server/src/dtos/template.dto.ts b/packages/server/src/dtos/template.dto.ts index 34b35e0..ca25112 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 da5a724..9efd6b9 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/services/collaboration.service.ts b/packages/server/src/services/collaboration.service.ts index 67ae330..711d22a 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 ab20df6..ff1746b 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) { From 612754fe4b5b364c3e69d0cd97558e36356d1ea2 Mon Sep 17 00:00:00 2001 From: fantasticit Date: Wed, 23 Mar 2022 20:42:47 +0800 Subject: [PATCH 7/7] feat: improve editor --- packages/client/package.json | 4 - .../document/collaboration/index.tsx | 4 +- .../src/components/document/editor/editor.tsx | 19 ++-- .../document/editor/index.module.scss | 2 + .../src/components/document/editor/index.tsx | 28 +++-- .../template/editor/index.module.scss | 2 + .../components/menuList/index.module.scss | 2 +- .../components/taskItem/index.module.scss | 5 + .../tiptap/components/taskItem/index.tsx | 23 ++++ .../components/tiptap/extensions/htmlMarks.ts | 2 +- .../src/components/tiptap/extensions/paste.ts | 3 +- .../src/components/tiptap/extensions/table.ts | 2 +- .../components/tiptap/extensions/taskItem.ts | 104 +++++++++++++----- .../tiptap/menus/baseBubbleMenu.tsx | 4 + .../tiptap/services/markdown/index.tsx | 2 - .../markdown/prosemirrorToMarkdown/index.ts | 12 +- packages/client/src/styles/prosemirror.scss | 37 ++++++- .../server/src/dtos/create-document.dto.ts | 1 - .../server/src/entities/document.entity.ts | 2 +- pnpm-lock.yaml | 28 ----- 20 files changed, 188 insertions(+), 98 deletions(-) create mode 100644 packages/client/src/components/tiptap/components/taskItem/index.module.scss create mode 100644 packages/client/src/components/tiptap/components/taskItem/index.tsx diff --git a/packages/client/package.json b/packages/client/package.json index 9dd852b..3f9d8ef 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 05f62d3..911a79a 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 c6c9719..a89f9dd 100644 --- a/packages/client/src/components/document/editor/editor.tsx +++ b/packages/client/src/components/document/editor/editor.tsx @@ -6,7 +6,6 @@ import { ILoginUser, IAuthority } from '@think/domains'; import { useToggle } from 'hooks/useToggle'; import { DEFAULT_EXTENSION, - Document, DocumentWithTitle, getCollaborationExtension, getCollaborationCursorExtension, @@ -16,6 +15,8 @@ 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'; interface IProps { @@ -44,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: [ @@ -56,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); @@ -68,8 +67,6 @@ export const Editor: React.FC = ({ user, documentId, authority, classNam toggleLoading(false); }); - // provid - provider.on('status', async ({ status }) => { console.log('status', status); }); diff --git a/packages/client/src/components/document/editor/index.module.scss b/packages/client/src/components/document/editor/index.module.scss index 8f5530f..0ba4953 100644 --- a/packages/client/src/components/document/editor/index.module.scss +++ b/packages/client/src/components/document/editor/index.module.scss @@ -9,6 +9,7 @@ z-index: 110; background-color: var(--semi-color-nav-bg); height: 60px; + user-select: none; > div { overflow: auto; @@ -39,6 +40,7 @@ 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 d021b0c..dac5fa7 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 ( - //
<> div { overflow: auto; @@ -39,6 +40,7 @@ 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/components/menuList/index.module.scss b/packages/client/src/components/tiptap/components/menuList/index.module.scss index 5d80813..2ff882f 100644 --- a/packages/client/src/components/tiptap/components/menuList/index.module.scss +++ b/packages/client/src/components/tiptap/components/menuList/index.module.scss @@ -1,6 +1,6 @@ .items { width: 160px; - max-height: 50vh; + max-height: 40vh; overflow: auto; padding: 0.2rem; position: relative; diff --git a/packages/client/src/components/tiptap/components/taskItem/index.module.scss b/packages/client/src/components/tiptap/components/taskItem/index.module.scss new file mode 100644 index 0000000..a77831f --- /dev/null +++ b/packages/client/src/components/tiptap/components/taskItem/index.module.scss @@ -0,0 +1,5 @@ +.wrap { + margin: 8px 0; + display: inline-flex; + justify-content: center; +} diff --git a/packages/client/src/components/tiptap/components/taskItem/index.tsx b/packages/client/src/components/tiptap/components/taskItem/index.tsx new file mode 100644 index 0000000..a07667f --- /dev/null +++ b/packages/client/src/components/tiptap/components/taskItem/index.tsx @@ -0,0 +1,23 @@ +import { NodeViewWrapper, NodeViewContent } from '@tiptap/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'; +import { Checkbox } from '@douyinfe/semi-ui'; +import styles from './index.module.scss'; + +const { Text } = Typography; + +export const TaskItemWrapper = ({ editor, node, updateAttributes }) => { + 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/htmlMarks.ts b/packages/client/src/components/tiptap/extensions/htmlMarks.ts index 889c1b8..cd4f160 100644 --- a/packages/client/src/components/tiptap/extensions/htmlMarks.ts +++ b/packages/client/src/components/tiptap/extensions/htmlMarks.ts @@ -11,7 +11,7 @@ const attrs = { bdo: ['dir'], }; -export const HTMLMarks = marks.map(({ name, tag }) => +export const HTMLMarks = marks.slice(1).map(({ name, tag }) => Mark.create({ name, tag, diff --git a/packages/client/src/components/tiptap/extensions/paste.ts b/packages/client/src/components/tiptap/extensions/paste.ts index 183d8ef..c8393bd 100644 --- a/packages/client/src/components/tiptap/extensions/paste.ts +++ b/packages/client/src/components/tiptap/extensions/paste.ts @@ -116,7 +116,8 @@ 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 ''; } diff --git a/packages/client/src/components/tiptap/extensions/table.ts b/packages/client/src/components/tiptap/extensions/table.ts index 7c2b052..519fc6b 100644 --- a/packages/client/src/components/tiptap/extensions/table.ts +++ b/packages/client/src/components/tiptap/extensions/table.ts @@ -40,7 +40,7 @@ export const Table = BuiltInTable.extend({ if (fixedWidth && totalWidth > 0) { HTMLAttributes.style = `width: ${totalWidth}px;`; } else if (totalWidth && totalWidth > 0) { - HTMLAttributes.style = `min-width: ${totalWidth}px`; + HTMLAttributes.style = `min-width: 100%`; } else { HTMLAttributes.style = null; } diff --git a/packages/client/src/components/tiptap/extensions/taskItem.ts b/packages/client/src/components/tiptap/extensions/taskItem.ts index 63977b7..f545028 100644 --- a/packages/client/src/components/tiptap/extensions/taskItem.ts +++ b/packages/client/src/components/tiptap/extensions/taskItem.ts @@ -1,8 +1,10 @@ -import { wrappingInputRule, mergeAttributes } from '@tiptap/core'; +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({ parseHTML() { @@ -27,35 +29,79 @@ const CustomTaskItem = BuiltInTaskItem.extend({ ]; }, - // addProseMirrorPlugins() { - // return [ - // new Plugin({ - // props: { - // // @ts-ignore - // handleClick: (view, pos, event) => { - // const state = view.state; - // const schema = state.schema; - - // const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY }); - // const position = state.doc.resolve(coordinates.pos); - // 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) { - // return; - // } - // const tr = state.tr; - // tr.setNodeMarkup(parentList.pos, schema.nodes.taskItem, { - // checked: !parentList.node.attrs.checked, - // }); - // view.dispatch(tr); - // }, - // }, - // }), - // ]; + // 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({ + props: { + // @ts-ignore + handleClick: (view, pos, event) => { + const state = view.state; + const schema = state.schema; + + const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY }); + const position = state.doc.resolve(coordinates.pos); + const parentList = findParentNodeClosestToPos(position, function (node) { + return node.type === schema.nodes.taskItem || node.type === schema.nodes.listItem; + }); + 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: nextValue, + }); + view.dispatch(tr); + }, + }, + }), + ]; + }, }); export const TaskItem = CustomTaskItem.configure({ nested: true }); diff --git a/packages/client/src/components/tiptap/menus/baseBubbleMenu.tsx b/packages/client/src/components/tiptap/menus/baseBubbleMenu.tsx index d7aa316..1710a7b 100644 --- a/packages/client/src/components/tiptap/menus/baseBubbleMenu.tsx +++ b/packages/client/src/components/tiptap/menus/baseBubbleMenu.tsx @@ -11,6 +11,8 @@ 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'; @@ -26,6 +28,8 @@ const OTHER_BUBBLE_MENU_TYPES = [ Iframe.name, Mind.name, Table.name, + TaskList.name, + TaskItem.name, DocumentReference.name, DocumentChildren.name, Katex.name, diff --git a/packages/client/src/components/tiptap/services/markdown/index.tsx b/packages/client/src/components/tiptap/services/markdown/index.tsx index 115b8b4..7e3d644 100644 --- a/packages/client/src/components/tiptap/services/markdown/index.tsx +++ b/packages/client/src/components/tiptap/services/markdown/index.tsx @@ -10,8 +10,6 @@ export const markdownToProsemirror = ({ schema, content, hasTitle }) => { if (!html) return null; - console.log(html); - const parser = new DOMParser(); const { body } = parser.parseFromString(html, 'text/html'); body.append(document.createComment(content)); diff --git a/packages/client/src/components/tiptap/services/markdown/prosemirrorToMarkdown/index.ts b/packages/client/src/components/tiptap/services/markdown/prosemirrorToMarkdown/index.ts index 85577b8..04a7672 100644 --- a/packages/client/src/components/tiptap/services/markdown/prosemirrorToMarkdown/index.ts +++ b/packages/client/src/components/tiptap/services/markdown/prosemirrorToMarkdown/index.ts @@ -10,7 +10,7 @@ import { DocumentReference } from '../../../extensions/documentReference'; import { HardBreak } from '../../../extensions/hardBreak'; import { Heading } from '../../../extensions/heading'; import { HorizontalRule } from '../../../extensions/horizontalRule'; -import { marks, HTMLMarks } from '../../../extensions/htmlMarks'; +import { marks } from '../../../extensions/htmlMarks'; import { Iframe } from '../../../extensions/iframe'; import { Image } from '../../../extensions/image'; import { Italic } from '../../../extensions/italic'; @@ -29,6 +29,7 @@ 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, @@ -66,6 +67,8 @@ const SerializerConfig = { mixable: true, expelEnclosingWhitespace: true, }, + // FIXME: 如何导出 style? + [TextStyle.name]: { open: '', close: '', mixable: true, expelEnclosingWhitespace: true }, ...marks.reduce( (acc, { name, tag }) => ({ ...acc, @@ -151,10 +154,9 @@ const SerializerConfig = { */ export const prosemirrorToMarkdown = ({ content }) => { const serializer = new ProseMirrorMarkdownSerializer(SerializerConfig.nodes, SerializerConfig.marks); - - console.log(content); - - return serializer.serialize(content, { + const markdown = serializer.serialize(content, { tightLists: true, }); + + return markdown; }; diff --git a/packages/client/src/styles/prosemirror.scss b/packages/client/src/styles/prosemirror.scss index 10fec37..6eaad4a 100644 --- a/packages/client/src/styles/prosemirror.scss +++ b/packages/client/src/styles/prosemirror.scss @@ -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; } diff --git a/packages/server/src/dtos/create-document.dto.ts b/packages/server/src/dtos/create-document.dto.ts index 84882ba..58d1f82 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/entities/document.entity.ts b/packages/server/src/entities/document.entity.ts index 2c7a9e0..b874cda 100644 --- a/packages/server/src/entities/document.entity.ts +++ b/packages/server/src/entities/document.entity.ts @@ -19,7 +19,7 @@ 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: '文档内容' }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21e7e7d..281e9ae 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