From cc3efef3f1aadb2ac0390938a296eeba985a862e Mon Sep 17 00:00:00 2001 From: fantasticit Date: Fri, 29 Apr 2022 15:55:32 +0800 Subject: [PATCH] tiptap: improve copy paste --- packages/client/package.json | 2 +- .../client/src/helpers/copy-to-clipboard.js | 147 ++++++++++++++++++ packages/client/src/helpers/copy.tsx | 17 +- .../client/src/tiptap/extensions/paste.ts | 50 +++++- .../markdown-to-prosemirror/helpers.ts | 4 + .../markdown-to-html/markdownTaskList.ts | 2 +- .../markdown/prosemirror-to-markdown/index.ts | 2 +- packages/client/src/tiptap/menubar.tsx | 4 + .../src/tiptap/menus/attachment/bubble.tsx | 43 +++++ .../src/tiptap/menus/attachment/index.tsx | 15 ++ .../src/tiptap/menus/callout/bubble.tsx | 16 +- .../src/tiptap/menus/code-block/bubble.tsx | 44 ++++++ .../src/tiptap/menus/code-block/index.tsx | 15 ++ .../src/tiptap/menus/countdown/bubble.tsx | 23 ++- .../tiptap/menus/document-children/bubble.tsx | 27 +++- .../menus/document-reference/bubble.tsx | 23 ++- .../client/src/tiptap/menus/iframe/bubble.tsx | 23 ++- .../client/src/tiptap/menus/image/bubble.tsx | 25 ++- .../client/src/tiptap/menus/insert/index.tsx | 5 +- .../client/src/tiptap/menus/mind/bubble.tsx | 24 ++- .../client/src/tiptap/menus/table/bubble.tsx | 14 ++ .../src/tiptap/prose-utils/copy-node.ts | 53 +++++++ .../src/tiptap/prose-utils/delete-node.ts | 26 ++-- .../client/src/tiptap/prose-utils/index.ts | 1 + packages/client/src/tiptap/styles/node.scss | 1 + pnpm-lock.yaml | 10 +- 26 files changed, 558 insertions(+), 58 deletions(-) create mode 100644 packages/client/src/helpers/copy-to-clipboard.js create mode 100644 packages/client/src/tiptap/menus/attachment/bubble.tsx create mode 100644 packages/client/src/tiptap/menus/attachment/index.tsx create mode 100644 packages/client/src/tiptap/menus/code-block/bubble.tsx create mode 100644 packages/client/src/tiptap/menus/code-block/index.tsx create mode 100644 packages/client/src/tiptap/prose-utils/copy-node.ts diff --git a/packages/client/package.json b/packages/client/package.json index 36114bc..49d1e6c 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -56,7 +56,6 @@ "@tiptap/suggestion": "^2.0.0-beta.90", "axios": "^0.25.0", "classnames": "^2.3.1", - "copy-to-clipboard": "^3.3.1", "deep-equal": "^2.0.5", "dompurify": "^2.3.5", "interactjs": "^1.10.11", @@ -85,6 +84,7 @@ "scroll-into-view-if-needed": "^2.2.29", "swr": "^1.2.0", "tippy.js": "^6.3.7", + "toggle-selection": "^1.0.6", "viewerjs": "^1.10.4", "y-indexeddb": "^9.0.7", "y-prosemirror": "^1.0.14", diff --git a/packages/client/src/helpers/copy-to-clipboard.js b/packages/client/src/helpers/copy-to-clipboard.js new file mode 100644 index 0000000..c351004 --- /dev/null +++ b/packages/client/src/helpers/copy-to-clipboard.js @@ -0,0 +1,147 @@ +'use strict'; + +var deselectCurrent = require('toggle-selection'); + +var clipboardToIE11Formatting = { + 'text/plain': 'Text', + 'text/html': 'Url', + 'default': 'Text', +}; + +var defaultMessage = 'Copy to clipboard: #{key}, Enter'; + +function format(message) { + var copyKey = (/mac os x/i.test(navigator.userAgent) ? '⌘' : 'Ctrl') + '+C'; + return message.replace(/#{\s*key\s*}/g, copyKey); +} + +function copy(text, options) { + var debug, + message, + reselectPrevious, + range, + selection, + mark, + success = false; + if (!options) { + options = {}; + } + debug = options.debug || false; + try { + reselectPrevious = deselectCurrent(); + + range = document.createRange(); + selection = document.getSelection(); + + mark = document.createElement('span'); + mark.textContent = text; + // reset user styles for span element + mark.style.all = 'unset'; + // prevents scrolling to the end of the page + mark.style.position = 'fixed'; + mark.style.top = 0; + mark.style.clip = 'rect(0, 0, 0, 0)'; + // used to preserve spaces and line breaks + mark.style.whiteSpace = 'pre'; + // do not inherit user-select (it may be `none`) + mark.style.webkitUserSelect = 'text'; + mark.style.MozUserSelect = 'text'; + mark.style.msUserSelect = 'text'; + mark.style.userSelect = 'text'; + mark.addEventListener('copy', function (e) { + e.stopPropagation(); + if (options.format) { + e.preventDefault(); + if (typeof e.clipboardData === 'undefined') { + // IE 11 + debug && console.warn('unable to use e.clipboardData'); + debug && console.warn('trying IE specific stuff'); + window.clipboardData.clearData(); + var format = clipboardToIE11Formatting[options.format] || clipboardToIE11Formatting['default']; + if (Array.isArray(text)) { + text.forEach((item) => { + if (typeof item === 'string') { + window.clipboardData.setData(item, item); + } else { + window.clipboardData.setData(item.format || format, item.text || item); + } + }); + } else { + window.clipboardData.setData(format, text); + } + } else { + // all other browsers + e.clipboardData.clearData(); + if (Array.isArray(text)) { + text.forEach((item) => { + if (typeof item === 'string') { + e.clipboardData.setData(item, item); + } else { + e.clipboardData.setData(item.format || format, item.text || item); + } + }); + console.log(e.clipboardData); + } else { + e.clipboardData.setData(format, text); + } + } + } + if (options.onCopy) { + e.preventDefault(); + options.onCopy(e.clipboardData); + } + }); + + document.body.appendChild(mark); + + range.selectNodeContents(mark); + selection.addRange(range); + + var successful = document.execCommand('copy'); + if (!successful) { + throw new Error('copy command was unsuccessful'); + } + success = true; + } catch (err) { + debug && console.error('unable to copy using execCommand: ', err); + debug && console.warn('trying IE specific stuff'); + try { + if (Array.isArray(text)) { + text.forEach((item) => { + if (typeof item === 'string') { + window.clipboardData.setData(item, item); + } else { + window.clipboardData.setData(item.format || format, item.text || item); + } + }); + } else { + window.clipboardData.setData(format, text); + } + console.log(window.clipboardData, '1'); + options.onCopy && options.onCopy(window.clipboardData); + success = true; + } catch (err) { + debug && console.error('unable to copy using clipboardData: ', err); + debug && console.error('falling back to prompt'); + message = format('message' in options ? options.message : defaultMessage); + window.prompt(message, text); + } + } finally { + if (selection) { + if (typeof selection.removeRange == 'function') { + selection.removeRange(range); + } else { + selection.removeAllRanges(); + } + } + + if (mark) { + document.body.removeChild(mark); + } + reselectPrevious(); + } + + return success; +} + +module.exports = copy; diff --git a/packages/client/src/helpers/copy.tsx b/packages/client/src/helpers/copy.tsx index b67e005..9174696 100644 --- a/packages/client/src/helpers/copy.tsx +++ b/packages/client/src/helpers/copy.tsx @@ -1,7 +1,16 @@ -import _copy from 'copy-to-clipboard'; +import _copy from './copy-to-clipboard'; import { Toast } from '@douyinfe/semi-ui'; -export function copy(text: string, msg = '复制成功') { - Toast.success(msg); - return _copy(text); +interface Options { + debug?: boolean; + message?: string; + format?: string; // MIME type + onCopy?: (clipboardData: object) => void; +} + +export function copy(text: string | { text: string; format: string }[], options?: Options) { + options = options || {}; + options.onCopy = options.onCopy || (() => Toast.success(options.message || '复制成功')); + options.format = options.format || 'text/plain'; + return _copy(text, options); } diff --git a/packages/client/src/tiptap/extensions/paste.ts b/packages/client/src/tiptap/extensions/paste.ts index e29bd77..f798779 100644 --- a/packages/client/src/tiptap/extensions/paste.ts +++ b/packages/client/src/tiptap/extensions/paste.ts @@ -8,6 +8,8 @@ import { markdownToProsemirror, prosemirrorToMarkdown, } from 'tiptap/markdown/markdown-to-prosemirror'; +import { copyNode } from 'tiptap/prose-utils'; +import { safeJSONParse } from 'helpers/json'; const isPureText = (content): boolean => { if (!content) return false; @@ -43,7 +45,6 @@ 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) => { @@ -55,6 +56,16 @@ export const Paste = Extension.create({ 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'); + + if (node) { + const doc = safeJSONParse(node); + let tr = view.state.tr; + const selection = tr.selection; + view.dispatch(tr.insert(selection.from - 1, view.state.schema.nodeFromJSON(doc)).scrollIntoView()); + return true; + } // 粘贴代码 if (isInCode(view.state)) { @@ -80,14 +91,14 @@ export const Paste = Extension.create({ } // 处理 markdown - if (isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') { + if (markdownText || isMarkdown(text) || html.length === 0 || pasteCodeLanguage === 'markdown') { event.preventDefault(); const firstNode = view.props.state.doc.content.firstChild; const hasTitle = isTitleNode(firstNode) && firstNode.content.size > 0; const schema = view.props.state.schema; const doc = markdownToProsemirror({ schema, - content: normalizePastedMarkdown(text), + content: normalizePastedMarkdown(markdownText || text), hasTitle, }); let tr = view.state.tr; @@ -130,8 +141,41 @@ export const Paste = Extension.create({ return false; }, + handleKeyDown(view, event) { + /** + * Command + C + * Ctrl + C + */ + if ((event.ctrlKey || event.metaKey) && event.keyCode == 67) { + const { state } = view; + const $pos = state.selection.$anchor; + // @ts-ignore + const currentNode = state.selection.node; + let targetNode = null; + + if (currentNode) { + targetNode = currentNode; + } else { + if ($pos.depth) { + for (let d = $pos.depth; d > 0; d--) { + const node = $pos.node(d); + targetNode = node; + } + } + } + + if (currentNode) { + event.preventDefault(); + copyNode(currentNode); + return true; + } + } + + return false; + }, clipboardTextSerializer: (slice) => { const isText = isPureText(slice.content.toJSON()); + if (isText) { return slice.content.textBetween(0, slice.content.size, '\n\n'); } diff --git a/packages/client/src/tiptap/markdown/markdown-to-prosemirror/helpers.ts b/packages/client/src/tiptap/markdown/markdown-to-prosemirror/helpers.ts index 889c17a..9a8c420 100644 --- a/packages/client/src/tiptap/markdown/markdown-to-prosemirror/helpers.ts +++ b/packages/client/src/tiptap/markdown/markdown-to-prosemirror/helpers.ts @@ -3,6 +3,10 @@ export const isMarkdown = (text: string): boolean => { const html = text.match(/<\/?[a-z][\s\S]*>/i); if (html && html.length) return true; + // image + const image = text.match(/!\[(\s|.)?\]\((\s|.)?\)/); + if (image && image.length) return true; + // table const tables = text.match(/^\|(\S)*\|/gm); if (tables && tables.length) return true; diff --git a/packages/client/src/tiptap/markdown/markdown-to-prosemirror/markdown-to-html/markdownTaskList.ts b/packages/client/src/tiptap/markdown/markdown-to-prosemirror/markdown-to-html/markdownTaskList.ts index 0c1e0f0..980ea3b 100644 --- a/packages/client/src/tiptap/markdown/markdown-to-prosemirror/markdown-to-html/markdownTaskList.ts +++ b/packages/client/src/tiptap/markdown/markdown-to-prosemirror/markdown-to-html/markdownTaskList.ts @@ -63,7 +63,7 @@ function attrSet(token, name, value) { function processToken(state: StateCore, options: TaskListsOptions): boolean { const allTokens = state.tokens; - attrSet(allTokens[0], 'class', 'contains-task-list'); + attrSet(allTokens[0], 'class', ''); for (let i = 2; i < allTokens.length; i++) { if (!isTodoItem(allTokens, i)) { diff --git a/packages/client/src/tiptap/markdown/prosemirror-to-markdown/index.ts b/packages/client/src/tiptap/markdown/prosemirror-to-markdown/index.ts index 46b82ee..e6e8625 100644 --- a/packages/client/src/tiptap/markdown/prosemirror-to-markdown/index.ts +++ b/packages/client/src/tiptap/markdown/prosemirror-to-markdown/index.ts @@ -91,7 +91,7 @@ const SerializerConfig = { }, nodes: { - [Attachment.name]: renderCustomContainer('attachment'), + attachment: renderCustomContainer('attachment'), blockquote: (state, node) => { if (node.attrs.multiline) { state.write('>>>'); diff --git a/packages/client/src/tiptap/menubar.tsx b/packages/client/src/tiptap/menubar.tsx index c5e317b..ff836ef 100644 --- a/packages/client/src/tiptap/menubar.tsx +++ b/packages/client/src/tiptap/menubar.tsx @@ -32,7 +32,9 @@ import { Blockquote } from './menus/blockquote'; import { HorizontalRule } from './menus/horizontal-rule'; import { Search } from './menus/search'; +import { Attachment } from './menus/attachment'; import { Callout } from './menus/callout'; +import { CodeBlock } from './menus/code-block'; import { Countdonw } from './menus/countdown'; import { DocumentChildren } from './menus/document-children'; import { DocumentReference } from './menus/document-reference'; @@ -90,7 +92,9 @@ export const MenuBar: React.FC<{ editor: any }> = ({ editor }) => { + + diff --git a/packages/client/src/tiptap/menus/attachment/bubble.tsx b/packages/client/src/tiptap/menus/attachment/bubble.tsx new file mode 100644 index 0000000..28286e8 --- /dev/null +++ b/packages/client/src/tiptap/menus/attachment/bubble.tsx @@ -0,0 +1,43 @@ +import { Space, Button } from '@douyinfe/semi-ui'; +import { IconCopy, IconDelete } from '@douyinfe/semi-icons'; +import { Tooltip } from 'components/tooltip'; +import { BubbleMenu } from 'tiptap/views/bubble-menu'; +import { Attachment } from 'tiptap/extensions/attachment'; +import { copyNode, deleteNode } from 'tiptap/prose-utils'; +import { Divider } from 'tiptap/divider'; + +export const AttachmentBubbleMenu = ({ editor }) => { + return ( + editor.isActive(Attachment.name)} + tippyOptions={{ maxWidth: 'calc(100vw - 100px)' }} + > + + +