Merge pull request #122 from fantasticit/feat/excel

tiptap: paste from excel sheet
This commit is contained in:
fantasticit 2022-07-13 12:00:05 +08:00 committed by GitHub
commit e91c9bb419
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 162 additions and 55 deletions

View File

@ -1,7 +1,7 @@
import { Extension } from '@tiptap/core'; import { Extension } from '@tiptap/core';
import { safeJSONParse } from 'helpers/json'; import { safeJSONParse } from 'helpers/json';
import { toggleMark } from 'prosemirror-commands'; 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 { Plugin, PluginKey, TextSelection } from 'prosemirror-state';
import { EXTENSION_PRIORITY_HIGHEST } from 'tiptap/core/constants'; import { EXTENSION_PRIORITY_HIGHEST } from 'tiptap/core/constants';
import { import {
@ -11,7 +11,6 @@ import {
isMarkdown, isMarkdown,
isTitleNode, isTitleNode,
isValidURL, isValidURL,
LANGUAGES,
normalizeMarkdown, normalizeMarkdown,
} from 'tiptap/prose-utils'; } from 'tiptap/prose-utils';
@ -20,15 +19,15 @@ import { TitleExtensionName } from './title';
interface IPasteOptions { interface IPasteOptions {
/** /**
* *
* markdown html * html prosemirror
*/ */
markdownToHTML: (arg: string) => string; htmlToProsemirror: (arg: { schema: Schema; html: string; needTitle: boolean; defaultTitle?: string }) => Node;
/** /**
* markdown prosemirror * markdown prosemirror
* FIXME: prosemirror * FIXME: prosemirror
*/ */
markdownToProsemirror: (arg: { schema: Schema; content: string; needTitle: boolean }) => unknown; markdownToProsemirror: (arg: { schema: Schema; content: string; needTitle: boolean }) => Node;
/** /**
* prosemirror markdown * prosemirror markdown
@ -42,7 +41,7 @@ export const Paste = Extension.create<IPasteOptions>({
addOptions() { addOptions() {
return { return {
markdownToHTML: (arg) => arg, htmlToProsemirror: (arg) => '',
markdownToProsemirror: (arg) => arg.content, markdownToProsemirror: (arg) => arg.content,
prosemirrorToMarkdown: (arg) => String(arg.content), prosemirrorToMarkdown: (arg) => String(arg.content),
}; };
@ -67,32 +66,22 @@ export const Paste = Extension.create<IPasteOptions>({
if (!event.clipboardData) return false; if (!event.clipboardData) return false;
// 文件
const files = Array.from(event.clipboardData.files); 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 text = event.clipboardData.getData('text/plain');
const html = event.clipboardData.getData('text/html'); const html = event.clipboardData.getData('text/html');
const vscode = event.clipboardData.getData('vscode-editor-data'); const vscode = event.clipboardData.getData('vscode-editor-data');
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');
console.log({ text, vscode, node, markdownText }); console.log({ text, vscode, node, markdownText, files });
console.log(html); console.log(html);
console.groupEnd(); console.groupEnd();
}); });
const { markdownToProsemirror } = extensionThis.options;
// 直接复制节点 // 直接复制节点
if (node) { if (node) {
const json = safeJSONParse(node); const json = safeJSONParse(node);
@ -102,6 +91,52 @@ 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;
}
// 新增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 (isValidURL(text)) {
if (!state.selection.empty) { if (!state.selection.empty) {
@ -141,31 +176,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);
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 // 处理 markdown
if (markdownText || isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') { if (markdownText || isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') {
event.preventDefault(); event.preventDefault();

View File

@ -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 { Plugin } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view'; import { Decoration, DecorationSet } from 'prosemirror-view';
import { getCellsInColumn, isRowSelected, isTableSelected, selectRow, selectTable } from 'tiptap/prose-utils'; import { getCellsInColumn, isRowSelected, isTableSelected, selectRow, selectTable } from 'tiptap/prose-utils';
export const TableCell = BuiltInTableCell.extend({ export interface TableCellOptions {
HTMLAttributes: Record<string, any>;
}
export const TableCell = Node.create<TableCellOptions>({
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() { addAttributes() {
return { return {
colspan: { colspan: {
default: 1, default: 1,
parseHTML: (element) => {
const colspan = element.getAttribute('colspan');
const value = colspan ? parseInt(colspan, 10) : 1;
return value;
},
}, },
rowspan: { rowspan: {
default: 1, default: 1,
parseHTML: (element) => {
const rowspan = element.getAttribute('rowspan');
const value = rowspan ? parseInt(rowspan, 10) : 1;
return value;
},
}, },
colwidth: { colwidth: {
default: null, default: null,
parseHTML: (element) => { parseHTML: (element) => {
const colwidth = element.getAttribute('colwidth'); 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; return value;
}, },
}, },

View File

@ -64,8 +64,8 @@ import { Title } from 'tiptap/core/extensions/title';
import { TrailingNode } from 'tiptap/core/extensions/trailing-node'; import { TrailingNode } from 'tiptap/core/extensions/trailing-node';
import { Underline } from 'tiptap/core/extensions/underline'; import { Underline } from 'tiptap/core/extensions/underline';
// markdown 支持 // markdown 支持
import { htmlToProsemirror } from 'tiptap/markdown/html-to-prosemirror';
import { markdownToProsemirror } from 'tiptap/markdown/markdown-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'; import { prosemirrorToMarkdown } from 'tiptap/markdown/prosemirror-to-markdown';
const DocumentWithTitle = Document.extend({ const DocumentWithTitle = Document.extend({
@ -133,7 +133,7 @@ export const CollaborationKit = [
TrailingNode, TrailingNode,
Underline, Underline,
Paste.configure({ Paste.configure({
markdownToHTML, htmlToProsemirror,
markdownToProsemirror, markdownToProsemirror,
prosemirrorToMarkdown, prosemirrorToMarkdown,
}), }),

View File

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

View File

@ -2,6 +2,49 @@ import { Renderer } from './renderer';
const renderer = new 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 * HTML prosemirror node
* @param body * @param body
@ -9,7 +52,7 @@ const renderer = new Renderer();
* @param defaultTitle heading paragraph * @param defaultTitle heading paragraph
* @returns * @returns
*/ */
export const htmlToPromsemirror = (body, needTitle = false, defaultTitle = '') => { export const htmlToProsemirror = (body, needTitle = false, defaultTitle = '') => {
const json = renderer.render(body); const json = renderer.render(body);
// 设置标题 // 设置标题
@ -55,5 +98,6 @@ export const htmlToPromsemirror = (body, needTitle = false, defaultTitle = '') =
} }
} }
fixNode(result);
return result; return result;
}; };

View File

@ -14,12 +14,16 @@ const getAttribute = (
) => { ) => {
return Object.keys(config).reduce((accu, key) => { return Object.keys(config).reduce((accu, key) => {
const conf = config[key]; const conf = config[key];
accu[key] = conf.default; accu[key] = null;
if (conf.parseHTML) { if (conf.parseHTML) {
accu[key] = conf.parseHTML(element); accu[key] = conf.parseHTML(element);
} }
if (!accu[key]) {
accu[key] = conf.default;
}
return accu; return accu;
}, ret); }, ret);
}; };

View File

@ -1,4 +1,4 @@
import { htmlToPromsemirror } from './html-to-prosemirror'; import { htmlToProsemirror } from './html-to-prosemirror';
import { markdownToHTML } from './markdown-to-html'; import { markdownToHTML } from './markdown-to-html';
/** /**
@ -12,7 +12,7 @@ import { markdownToHTML } from './markdown-to-html';
* @param html * @param html
* @returns * @returns
*/ */
const extractImage = (html) => { export const extractImage = (html) => {
let matches = []; let matches = [];
// eslint-disable-next-line no-useless-escape // eslint-disable-next-line no-useless-escape
@ -32,9 +32,8 @@ export const markdownToProsemirror = ({ schema, content, needTitle, defaultTitle
const parser = new DOMParser(); const parser = new DOMParser();
const { body } = parser.parseFromString(extractImage(html), 'text/html'); const { body } = parser.parseFromString(extractImage(html), 'text/html');
body.append(document.createComment(content)); body.append(document.createComment(content));
const node = htmlToPromsemirror(body, needTitle, defaultTitle); const node = htmlToProsemirror(body, needTitle, defaultTitle);
return node; return node;
}; };