diff --git a/packages/client/src/components/tiptap/extensions/paste.ts b/packages/client/src/components/tiptap/extensions/paste.ts index 811e3e75..b6f65658 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 1cb2373f..63977b73 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 9c56c26a..2233788e 100644 --- a/packages/client/src/components/tiptap/extensions/taskList.ts +++ b/packages/client/src/components/tiptap/extensions/taskList.ts @@ -1,4 +1,3 @@ -import { mergeAttributes } from '@tiptap/core'; import { TaskList as BuiltInTaskList } from '@tiptap/extension-task-list'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; @@ -11,8 +10,4 @@ export const TaskList = BuiltInTaskList.extend({ }, ]; }, - - renderHTML({ HTMLAttributes: { numeric, ...HTMLAttributes } }) { - return [numeric ? 'ol' : 'ul', mergeAttributes(HTMLAttributes, { 'data-type': 'taskList' }), 0]; - }, }); diff --git a/packages/client/src/components/tiptap/services/markdown/index.ts b/packages/client/src/components/tiptap/services/markdown/index.ts index 1aae44d9..a9130762 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 00000000..e4aefb52 --- /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 00000000..0c1e0f0c --- /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 6c13fc42..07b4eb64 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 00000000..cdf6017f --- /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 00000000..d79e71a1 --- /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 00000000..5c48c774 --- /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 00000000..50438b94 --- /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 00000000..80053a06 --- /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 00000000..61355ab8 --- /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 00000000..0b561060 --- /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 00000000..d45f221d --- /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 00000000..c2119b50 --- /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 00000000..c50027c3 --- /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 00000000..6ea180d8 --- /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 00000000..609cb3fc --- /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 00000000..97362a58 --- /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 00000000..0fa69da5 --- /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 00000000..0ddc339c --- /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 00000000..e761c616 --- /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 00000000..f20163ed --- /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 00000000..a9cc31f6 --- /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 00000000..b90151f8 --- /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 00000000..b7625a4a --- /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 00000000..75d14d0b --- /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 00000000..ea943a26 --- /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 00000000..bbf4795e --- /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 00000000..9bbaf499 --- /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 00000000..18ff85a4 --- /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; +};