support paste page html

This commit is contained in:
fantasticit 2022-12-18 12:25:04 +08:00
parent 8453788b84
commit edadc508e7
3 changed files with 112 additions and 109 deletions

View File

@ -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 { safeJSONParse } from 'helpers/json';
import { toggleMark } from 'prosemirror-commands'; import { toggleMark } from 'prosemirror-commands';
import { DOMParser, Fragment, Node, Schema } from 'prosemirror-model'; import { DOMParser as PMDOMParser, Fragment, Node, Schema } from 'prosemirror-model';
import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'; import { EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state';
import { EXTENSION_PRIORITY_HIGHEST } from 'tiptap/core/constants'; import { EXTENSION_PRIORITY_HIGHEST } from 'tiptap/core/constants';
import { import {
debug, debug,
fixHTML,
handleFileEvent, handleFileEvent,
isInCode, isInCode,
isInTitle, isInTitle,
isMarkdown, isMarkdown,
isTitleNode,
isValidURL, isValidURL,
normalizeMarkdown, normalizeMarkdown,
} from 'tiptap/prose-utils'; } from 'tiptap/prose-utils';
import { TitleExtensionName } from './title'; const safePos = (state: EditorState, pos) => {
if (pos < 0) return 0;
function insertText(view, text) { return Math.min(state.doc.content.size, pos);
const texts = text.split('\n').filter(Boolean); };
event.preventDefault();
view.dispatch(view.state.tr.insertText(texts[0]));
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', type: 'doc',
content: [{ type: 'title', attrs: { cover: '' }, content: [{ type: 'text', text: texts[0] }] }].concat( content: toPasteNode.content.toJSON(),
// @ts-ignore
texts.slice(1).map((t) => {
return {
type: 'paragraph',
attrs: { indent: 0, textAlign: 'left' },
content: [{ type: 'text', text: t }],
};
})
),
}; };
let tr = view.state.tr; let toInsertAtTitleNode = null;
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());
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; return true;
} };
interface IPasteOptions { interface IPasteOptions {
/** /**
@ -116,7 +143,6 @@ export const Paste = Extension.create<IPasteOptions>({
const node = event.clipboardData.getData('text/node'); const node = event.clipboardData.getData('text/node');
const markdownText = event.clipboardData.getData('text/markdown'); const markdownText = event.clipboardData.getData('text/markdown');
const { state, dispatch } = view; const { state, dispatch } = view;
const { htmlToProsemirror, markdownToProsemirror } = extensionThis.options;
debug(() => { debug(() => {
console.group('paste'); console.group('paste');
@ -134,53 +160,6 @@ export const Paste = Extension.create<IPasteOptions>({
return true; 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('</table>')) {
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 (isValidURL(text)) {
if (!state.selection.empty) { if (!state.selection.empty) {
@ -209,43 +188,24 @@ export const Paste = Extension.create<IPasteOptions>({
const vscodeMeta = vscode ? JSON.parse(vscode) : undefined; const vscodeMeta = vscode ? JSON.parse(vscode) : undefined;
const pasteCodeLanguage = vscodeMeta?.mode; const pasteCodeLanguage = vscodeMeta?.mode;
if (pasteCodeLanguage && pasteCodeLanguage !== 'markdown') { if (html.length > 0 || text.length === 0) {
event.preventDefault(); return htmlToProsemirror(editor, html);
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;
} }
// 处理 markdown const { markdownToHTML } = extensionThis.options;
if (markdownText || isMarkdown(text)) {
console.log(text); if ((markdownText || isMarkdown(text)) && markdownToHTML) {
event.preventDefault(); event.preventDefault();
const schema = view.props.state.schema; const html = markdownToHTML(normalizeMarkdown(markdownText || text));
const doc = markdownToProsemirror({ if (html && html.length) return htmlToProsemirror(editor, html, true);
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;
} }
if (isInTitle(view.state)) { if (files.length) {
if (text.length) { event.preventDefault();
return insertText(view, text); files.forEach((file) => {
} handleFileEvent({ editor, file });
});
return true;
} }
return false; return false;

View File

@ -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);
}

View File

@ -13,6 +13,7 @@ export * from './dom-dataset';
export * from './download'; export * from './download';
export * from './editor-container-size'; export * from './editor-container-size';
export * from './file'; export * from './file';
export * from './html';
export * from './lowlight-plugin'; export * from './lowlight-plugin';
export * from './mark'; export * from './mark';
export * from './markdown'; export * from './markdown';