From 4bed78aa641b71f5112bf610ff03218fdb12d89e Mon Sep 17 00:00:00 2001 From: fantasticit Date: Wed, 13 Jul 2022 11:57:39 +0800 Subject: [PATCH] tiptap: paste from excel sheet --- .../src/tiptap/core/extensions/paste.ts | 96 ++++++++++--------- .../src/tiptap/core/extensions/table-cell.tsx | 40 +++++++- .../src/tiptap/editor/collaboration/kit.ts | 4 +- .../markdown/html-to-prosemirror/index.ts | 18 ++++ .../html-to-prosemirror/index.ts | 46 ++++++++- .../html-to-prosemirror/utils.ts | 6 +- .../markdown-to-prosemirror/index.tsx | 7 +- 7 files changed, 162 insertions(+), 55 deletions(-) create mode 100644 packages/client/src/tiptap/markdown/html-to-prosemirror/index.ts diff --git a/packages/client/src/tiptap/core/extensions/paste.ts b/packages/client/src/tiptap/core/extensions/paste.ts index 1a15d554..56a84306 100644 --- a/packages/client/src/tiptap/core/extensions/paste.ts +++ b/packages/client/src/tiptap/core/extensions/paste.ts @@ -1,7 +1,7 @@ import { Extension } from '@tiptap/core'; import { safeJSONParse } from 'helpers/json'; import { toggleMark } from 'prosemirror-commands'; -import { DOMParser, Fragment, Schema } from 'prosemirror-model'; +import { DOMParser, Fragment, Node, Schema } from 'prosemirror-model'; import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'; import { EXTENSION_PRIORITY_HIGHEST } from 'tiptap/core/constants'; import { @@ -11,7 +11,6 @@ import { isMarkdown, isTitleNode, isValidURL, - LANGUAGES, normalizeMarkdown, } from 'tiptap/prose-utils'; @@ -20,15 +19,15 @@ import { TitleExtensionName } from './title'; interface IPasteOptions { /** * - * 将 markdown 转换为 html + * 将 html 转换为 prosemirror */ - markdownToHTML: (arg: string) => string; + htmlToProsemirror: (arg: { schema: Schema; html: string; needTitle: boolean; defaultTitle?: string }) => Node; /** * 将 markdown 转换为 prosemirror 节点 * FIXME: prosemirror 节点的类型是什么? */ - markdownToProsemirror: (arg: { schema: Schema; content: string; needTitle: boolean }) => unknown; + markdownToProsemirror: (arg: { schema: Schema; content: string; needTitle: boolean }) => Node; /** * 将 prosemirror 转换为 markdown @@ -42,7 +41,7 @@ export const Paste = Extension.create({ addOptions() { return { - markdownToHTML: (arg) => arg, + htmlToProsemirror: (arg) => '', markdownToProsemirror: (arg) => arg.content, prosemirrorToMarkdown: (arg) => String(arg.content), }; @@ -67,32 +66,22 @@ export const Paste = Extension.create({ if (!event.clipboardData) return false; - // 文件 const files = Array.from(event.clipboardData.files); - if (files.length) { - event.preventDefault(); - files.forEach((file) => { - handleFileEvent({ editor, file }); - }); - return true; - } - const text = event.clipboardData.getData('text/plain'); const html = event.clipboardData.getData('text/html'); const vscode = event.clipboardData.getData('vscode-editor-data'); const node = event.clipboardData.getData('text/node'); const markdownText = event.clipboardData.getData('text/markdown'); const { state, dispatch } = view; + const { htmlToProsemirror, markdownToProsemirror } = extensionThis.options; debug(() => { console.group('paste'); - console.log({ text, vscode, node, markdownText }); + console.log({ text, vscode, node, markdownText, files }); console.log(html); console.groupEnd(); }); - const { markdownToProsemirror } = extensionThis.options; - // 直接复制节点 if (node) { const json = safeJSONParse(node); @@ -102,6 +91,52 @@ export const Paste = Extension.create({ return true; } + const firstNode = view.props.state.doc.content.firstChild; + const hasTitleExtension = !!editor.extensionManager.extensions.find( + (extension) => extension.name === TitleExtensionName + ); + const hasTitle = isTitleNode(firstNode) && firstNode.content.size > 0; + + // If the HTML on the clipboard is from Prosemirror then the best + // compatability is to just use the HTML parser, regardless of + // whether it "looks" like Markdown, see: outline/outline#2416 + if (html?.includes('data-pm-slice')) { + let domNode = document.createElement('div'); + domNode.innerHTML = html; + const slice = DOMParser.fromSchema(editor.schema).parseSlice(domNode); + let tr = view.state.tr; + tr = tr.replaceSelection(slice); + view.dispatch(tr.scrollIntoView()); + domNode = null; + return true; + } + + // 新增:office 套件内容处理 + if (html?.includes('urn:schemas-microsoft-com:office')) { + const doc = htmlToProsemirror({ + schema: editor.schema, + html, + needTitle: hasTitleExtension && !hasTitle, + }); + let tr = view.state.tr; + const selection = tr.selection; + view.state.doc.nodesBetween(selection.from, selection.to, (node, position) => { + const startPosition = hasTitle ? Math.min(position, selection.from) : 0; + const endPosition = Math.min(position + node.nodeSize, selection.to); + tr = tr.replaceWith(startPosition, endPosition, view.state.schema.nodeFromJSON(doc)); + }); + view.dispatch(tr.scrollIntoView()); + return true; + } + + if (files.length) { + event.preventDefault(); + files.forEach((file) => { + handleFileEvent({ editor, file }); + }); + return true; + } + // 链接 if (isValidURL(text)) { if (!state.selection.empty) { @@ -141,31 +176,6 @@ export const Paste = Extension.create({ return true; } - const firstNode = view.props.state.doc.content.firstChild; - const hasTitleExtension = !!editor.extensionManager.extensions.find( - (extension) => extension.name === TitleExtensionName - ); - const hasTitle = isTitleNode(firstNode) && firstNode.content.size > 0; - - // If the HTML on the clipboard is from Prosemirror then the best - // compatability is to just use the HTML parser, regardless of - // whether it "looks" like Markdown, see: outline/outline#2416 - if (html?.includes('data-pm-slice')) { - let domNode = document.createElement('div'); - domNode.innerHTML = html; - const slice = DOMParser.fromSchema(editor.schema).parseSlice(domNode); - - debug(() => { - console.log('html', domNode, html, slice); - }); - - let tr = view.state.tr; - tr = tr.replaceSelection(slice); - view.dispatch(tr.scrollIntoView()); - domNode = null; - return true; - } - // 处理 markdown if (markdownText || isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') { event.preventDefault(); diff --git a/packages/client/src/tiptap/core/extensions/table-cell.tsx b/packages/client/src/tiptap/core/extensions/table-cell.tsx index 521b776a..aa4b09b8 100644 --- a/packages/client/src/tiptap/core/extensions/table-cell.tsx +++ b/packages/client/src/tiptap/core/extensions/table-cell.tsx @@ -1,23 +1,55 @@ -import { TableCell as BuiltInTableCell } from '@tiptap/extension-table-cell'; +import { mergeAttributes, Node } from '@tiptap/core'; import { Plugin } from 'prosemirror-state'; import { Decoration, DecorationSet } from 'prosemirror-view'; import { getCellsInColumn, isRowSelected, isTableSelected, selectRow, selectTable } from 'tiptap/prose-utils'; -export const TableCell = BuiltInTableCell.extend({ +export interface TableCellOptions { + HTMLAttributes: Record; +} + +export const TableCell = Node.create({ + name: 'tableCell', + content: 'block+', + tableRole: 'cell', + isolating: true, + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + parseHTML() { + return [{ tag: 'td' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + }, + addAttributes() { return { colspan: { default: 1, + parseHTML: (element) => { + const colspan = element.getAttribute('colspan'); + const value = colspan ? parseInt(colspan, 10) : 1; + return value; + }, }, rowspan: { default: 1, + parseHTML: (element) => { + const rowspan = element.getAttribute('rowspan'); + const value = rowspan ? parseInt(rowspan, 10) : 1; + return value; + }, }, colwidth: { default: null, parseHTML: (element) => { const colwidth = element.getAttribute('colwidth'); - const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null; - + const value = colwidth ? [parseInt(colwidth, 10)] : null; return value; }, }, diff --git a/packages/client/src/tiptap/editor/collaboration/kit.ts b/packages/client/src/tiptap/editor/collaboration/kit.ts index deba6268..df8d52c6 100644 --- a/packages/client/src/tiptap/editor/collaboration/kit.ts +++ b/packages/client/src/tiptap/editor/collaboration/kit.ts @@ -64,8 +64,8 @@ import { Title } from 'tiptap/core/extensions/title'; import { TrailingNode } from 'tiptap/core/extensions/trailing-node'; import { Underline } from 'tiptap/core/extensions/underline'; // markdown 支持 +import { htmlToProsemirror } from 'tiptap/markdown/html-to-prosemirror'; import { markdownToProsemirror } from 'tiptap/markdown/markdown-to-prosemirror'; -import { markdownToHTML } from 'tiptap/markdown/markdown-to-prosemirror/markdown-to-html'; import { prosemirrorToMarkdown } from 'tiptap/markdown/prosemirror-to-markdown'; const DocumentWithTitle = Document.extend({ @@ -133,7 +133,7 @@ export const CollaborationKit = [ TrailingNode, Underline, Paste.configure({ - markdownToHTML, + htmlToProsemirror, markdownToProsemirror, prosemirrorToMarkdown, }), diff --git a/packages/client/src/tiptap/markdown/html-to-prosemirror/index.ts b/packages/client/src/tiptap/markdown/html-to-prosemirror/index.ts new file mode 100644 index 00000000..a3a0050d --- /dev/null +++ b/packages/client/src/tiptap/markdown/html-to-prosemirror/index.ts @@ -0,0 +1,18 @@ +import { extractImage } from '../markdown-to-prosemirror'; +import { htmlToProsemirror as mdHTMLToProsemirror } from '../markdown-to-prosemirror/html-to-prosemirror'; + +/** + * 将 HTML 转换成 prosemirror node + * @param schema + * @param html + * @param needTitle 是否需要一个标题 + * @param defaultTitle 优先作为文档标题,否则默认读取一个 heading 或者 paragraph 的文字内容 + * @returns + */ +export const htmlToProsemirror = ({ schema, html, needTitle, defaultTitle = '' }) => { + const parser = new DOMParser(); + const { body } = parser.parseFromString(extractImage(html), 'text/html'); + body.append(document.createComment(html)); + const doc = mdHTMLToProsemirror(body, needTitle, defaultTitle); + return doc; +}; diff --git a/packages/client/src/tiptap/markdown/markdown-to-prosemirror/html-to-prosemirror/index.ts b/packages/client/src/tiptap/markdown/markdown-to-prosemirror/html-to-prosemirror/index.ts index a17be467..2a1e8785 100644 --- a/packages/client/src/tiptap/markdown/markdown-to-prosemirror/html-to-prosemirror/index.ts +++ b/packages/client/src/tiptap/markdown/markdown-to-prosemirror/html-to-prosemirror/index.ts @@ -2,6 +2,49 @@ import { Renderer } from './renderer'; const renderer = new Renderer(); +/** + * 表格的内容格式不正确,需要进行过滤修复 + * @param doc + * @returns + */ +function fixNode(doc) { + if (!doc) return; + + const queue = [doc]; + + while (queue.length) { + const node = queue.shift(); + + if (node.content) { + node.content = node.content.filter((subNode) => !(subNode.type === 'text' && subNode.text === '\n')); + } + + if (node.type === 'table') { + node.content = (node.content || []).filter((subNode) => subNode.type.includes('table')); + } + + if (node.type === 'tableRow') { + node.content = (node.content || []).filter((subNode) => subNode.type === 'tableCell'); + } + + if (node.type === 'tableCell') { + (node.content || []).forEach((subNode, i) => { + if (subNode && subNode.type === 'text') { + node.content[i] = { + attrs: subNode.attrs || {}, + content: [subNode], + type: 'paragraph', + }; + } + }); + } + + if (node.content) { + queue.push(...(node.content || []).filter((subNode) => subNode.type.includes('table'))); + } + } +} + /** * 将 HTML 转换成 prosemirror node * @param body @@ -9,7 +52,7 @@ const renderer = new Renderer(); * @param defaultTitle 优先作为文档标题,否则默认读取一个 heading 或者 paragraph 的文字内容 * @returns */ -export const htmlToPromsemirror = (body, needTitle = false, defaultTitle = '') => { +export const htmlToProsemirror = (body, needTitle = false, defaultTitle = '') => { const json = renderer.render(body); // 设置标题 @@ -55,5 +98,6 @@ export const htmlToPromsemirror = (body, needTitle = false, defaultTitle = '') = } } + fixNode(result); return result; }; diff --git a/packages/client/src/tiptap/markdown/markdown-to-prosemirror/html-to-prosemirror/utils.ts b/packages/client/src/tiptap/markdown/markdown-to-prosemirror/html-to-prosemirror/utils.ts index e5875513..e8231ec2 100644 --- a/packages/client/src/tiptap/markdown/markdown-to-prosemirror/html-to-prosemirror/utils.ts +++ b/packages/client/src/tiptap/markdown/markdown-to-prosemirror/html-to-prosemirror/utils.ts @@ -14,12 +14,16 @@ const getAttribute = ( ) => { return Object.keys(config).reduce((accu, key) => { const conf = config[key]; - accu[key] = conf.default; + accu[key] = null; if (conf.parseHTML) { accu[key] = conf.parseHTML(element); } + if (!accu[key]) { + accu[key] = conf.default; + } + return accu; }, ret); }; diff --git a/packages/client/src/tiptap/markdown/markdown-to-prosemirror/index.tsx b/packages/client/src/tiptap/markdown/markdown-to-prosemirror/index.tsx index 728ccbee..40a094ea 100644 --- a/packages/client/src/tiptap/markdown/markdown-to-prosemirror/index.tsx +++ b/packages/client/src/tiptap/markdown/markdown-to-prosemirror/index.tsx @@ -1,4 +1,4 @@ -import { htmlToPromsemirror } from './html-to-prosemirror'; +import { htmlToProsemirror } from './html-to-prosemirror'; import { markdownToHTML } from './markdown-to-html'; /** @@ -12,7 +12,7 @@ import { markdownToHTML } from './markdown-to-html'; * @param html * @returns */ -const extractImage = (html) => { +export const extractImage = (html) => { let matches = []; // eslint-disable-next-line no-useless-escape @@ -32,9 +32,8 @@ export const markdownToProsemirror = ({ schema, content, needTitle, defaultTitle const parser = new DOMParser(); const { body } = parser.parseFromString(extractImage(html), 'text/html'); - body.append(document.createComment(content)); - const node = htmlToPromsemirror(body, needTitle, defaultTitle); + const node = htmlToProsemirror(body, needTitle, defaultTitle); return node; };