From edadc508e7c8b7bfc63120d1ca3462915ccc62eb Mon Sep 17 00:00:00 2001 From: fantasticit Date: Sun, 18 Dec 2022 12:25:04 +0800 Subject: [PATCH] support paste page html --- .../src/tiptap/core/extensions/paste.ts | 178 +++++++----------- .../client/src/tiptap/prose-utils/html.ts | 42 +++++ .../client/src/tiptap/prose-utils/index.ts | 1 + 3 files changed, 112 insertions(+), 109 deletions(-) create mode 100644 packages/client/src/tiptap/prose-utils/html.ts diff --git a/packages/client/src/tiptap/core/extensions/paste.ts b/packages/client/src/tiptap/core/extensions/paste.ts index 04963e5..80dbe07 100644 --- a/packages/client/src/tiptap/core/extensions/paste.ts +++ b/packages/client/src/tiptap/core/extensions/paste.ts @@ -1,52 +1,79 @@ -import { Editor as CoreEditor, Extension } from '@tiptap/core'; +import { Editor as CoreEditor, Extension, getSchema } from '@tiptap/core'; +import { Document } from '@tiptap/extension-document'; import { safeJSONParse } from 'helpers/json'; import { toggleMark } from 'prosemirror-commands'; -import { DOMParser, Fragment, Node, Schema } from 'prosemirror-model'; -import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'; +import { DOMParser as PMDOMParser, Fragment, Node, Schema } from 'prosemirror-model'; +import { EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state'; import { EXTENSION_PRIORITY_HIGHEST } from 'tiptap/core/constants'; import { debug, + fixHTML, handleFileEvent, isInCode, isInTitle, isMarkdown, - isTitleNode, isValidURL, normalizeMarkdown, } from 'tiptap/prose-utils'; -import { TitleExtensionName } from './title'; +const safePos = (state: EditorState, pos) => { + if (pos < 0) return 0; -function insertText(view, text) { - const texts = text.split('\n').filter(Boolean); - event.preventDefault(); - view.dispatch(view.state.tr.insertText(texts[0])); + return Math.min(state.doc.content.size, pos); +}; - const json = { +const htmlToProsemirror = (editor: CoreEditor, html, isPasteMarkdown = false) => { + const firstNode = editor.view.state.doc.content.firstChild; + const shouldInsertTitleText = !!(firstNode?.textContent?.length <= 0 ?? true); + + if (!shouldInsertTitleText && !isPasteMarkdown) return false; + + const parser = new window.DOMParser(); + const { body } = parser.parseFromString(fixHTML(html), 'text/html'); + + const schema = getSchema( + [].concat( + Document, + editor.extensionManager.extensions.filter( + (ext) => ext.type === 'node' && !['title', 'doc', 'collaboration', 'collaborationCursor'].includes(ext.name) + ) + ) + ); + + const toPasteNode = PMDOMParser.fromSchema(schema).parse(body); + const doc = { type: 'doc', - content: [{ type: 'title', attrs: { cover: '' }, content: [{ type: 'text', text: texts[0] }] }].concat( - // @ts-ignore - texts.slice(1).map((t) => { - return { - type: 'paragraph', - attrs: { indent: 0, textAlign: 'left' }, - content: [{ type: 'text', text: t }], - }; - }) - ), + content: toPasteNode.content.toJSON(), }; - let tr = view.state.tr; - const selection = tr.selection; - view.state.doc.nodesBetween(selection.from, selection.to, (node, position) => { - const startPosition = Math.min(position, selection.from) || 0; - const endPosition = Math.min(position + node.nodeSize, selection.to); - tr = tr.replaceWith(startPosition, endPosition, view.state.schema.nodeFromJSON(json)); - }); - view.dispatch(tr.scrollIntoView()); + let toInsertAtTitleNode = null; + if (shouldInsertTitleText) { + toInsertAtTitleNode = doc.content.shift(); + } + + let tr = editor.view.state.tr; + + const insertAt = isInTitle(editor.state) + ? safePos(editor.state, firstNode.nodeSize) + : safePos(editor.state, editor.state.selection.from - 1); + + const node = editor.state.schema.nodeFromJSON(doc); + tr = tr.insert(insertAt, node); + + if (shouldInsertTitleText) { + if (toInsertAtTitleNode) { + if (['heading', 'paragraph'].includes(toInsertAtTitleNode.type)) { + tr.insertText(toInsertAtTitleNode?.content?.[0]?.text, 1, 1); + } else { + tr.insert(insertAt, editor.state.schema.nodeFromJSON(toInsertAtTitleNode)); + } + } + } + + editor.view.dispatch(tr.scrollIntoView()); return true; -} +}; interface IPasteOptions { /** @@ -116,7 +143,6 @@ export const Paste = Extension.create({ 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'); @@ -134,53 +160,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); - let tr = view.state.tr; - tr = tr.replaceSelection(slice); - view.dispatch(tr.scrollIntoView()); - domNode = null; - return true; - } - - // TODO:各家 office 套件标准不一样,是否需要做成用户自行选择粘贴 html 或者 图片? - if (html?.includes('urn:schemas-microsoft-com:office') || html?.includes('')) { - const doc = htmlToProsemirror({ - editor, - 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) { @@ -209,43 +188,24 @@ export const Paste = Extension.create({ const vscodeMeta = vscode ? JSON.parse(vscode) : undefined; const pasteCodeLanguage = vscodeMeta?.mode; - if (pasteCodeLanguage && pasteCodeLanguage !== 'markdown') { - event.preventDefault(); - const { tr } = view.state; - tr.replaceSelectionWith(view.state.schema.nodes.codeBlock.create({ language: pasteCodeLanguage })); - tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(0, tr.selection.from - 1)))); - tr.insertText(text.replace(/\r\n?/g, '\n')); - tr.setMeta('paste', true); - view.dispatch(tr); - return true; + if (html.length > 0 || text.length === 0) { + return htmlToProsemirror(editor, html); } - // 处理 markdown - if (markdownText || isMarkdown(text)) { - console.log(text); + const { markdownToHTML } = extensionThis.options; + + if ((markdownText || isMarkdown(text)) && markdownToHTML) { event.preventDefault(); - const schema = view.props.state.schema; - const doc = markdownToProsemirror({ - editor, - schema, - content: normalizeMarkdown(markdownText || text), - 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; + const html = markdownToHTML(normalizeMarkdown(markdownText || text)); + if (html && html.length) return htmlToProsemirror(editor, html, true); } - if (isInTitle(view.state)) { - if (text.length) { - return insertText(view, text); - } + if (files.length) { + event.preventDefault(); + files.forEach((file) => { + handleFileEvent({ editor, file }); + }); + return true; } return false; diff --git a/packages/client/src/tiptap/prose-utils/html.ts b/packages/client/src/tiptap/prose-utils/html.ts new file mode 100644 index 0000000..b4f5cef --- /dev/null +++ b/packages/client/src/tiptap/prose-utils/html.ts @@ -0,0 +1,42 @@ +export function fixHTML(html) { + const container = document.createElement('div'); + container.innerHTML = html; + + let el; + + while ((el = container.querySelector('a > img'))) { + unwrapLink(el.parentNode, el.getAttribute('alt') || 'Image link'); + } + + while ((el = container.querySelector('p > img'))) { + unwrap(el.parentNode); + } + + while ((el = container.querySelector('a:not(p a)'))) { + wrap(el, document.createElement('p')); + } + + return container.innerHTML; +} + +function unwrap(el) { + const parent = el.parentNode; + + // Move all children to the parent element. + while (el.firstChild) parent.insertBefore(el.firstChild, el); + + parent.removeChild(el); +} + +function unwrapLink(el, replacementText) { + const parent = el.parentNode; + + while (el.firstChild) parent.insertBefore(el.firstChild, el); + + el.textContent = replacementText; +} + +function wrap(el, wrapper) { + el.parentNode.insertBefore(wrapper, el); + wrapper.appendChild(el); +} diff --git a/packages/client/src/tiptap/prose-utils/index.ts b/packages/client/src/tiptap/prose-utils/index.ts index f1edc95..bf22b88 100644 --- a/packages/client/src/tiptap/prose-utils/index.ts +++ b/packages/client/src/tiptap/prose-utils/index.ts @@ -13,6 +13,7 @@ export * from './dom-dataset'; export * from './download'; export * from './editor-container-size'; export * from './file'; +export * from './html'; export * from './lowlight-plugin'; export * from './mark'; export * from './markdown';