From 0a265f9981533cfa6e2055dbd247613ab3964553 Mon Sep 17 00:00:00 2001 From: QiShaoXuan Date: Thu, 11 Aug 2022 17:06:16 +0800 Subject: [PATCH] refactor: refactor paste behavior (not support selection) --- .../clipboard/browser-clipboard-back.ts | 422 ++++++++++++++ .../src/editor/clipboard/browser-clipboard.ts | 322 +---------- .../editor-core/src/editor/clipboard/paste.ts | 535 ++++++++++++++++++ .../editor-core/src/editor/clipboard/utils.ts | 14 + 4 files changed, 991 insertions(+), 302 deletions(-) create mode 100644 libs/components/editor-core/src/editor/clipboard/browser-clipboard-back.ts create mode 100644 libs/components/editor-core/src/editor/clipboard/paste.ts create mode 100644 libs/components/editor-core/src/editor/clipboard/utils.ts diff --git a/libs/components/editor-core/src/editor/clipboard/browser-clipboard-back.ts b/libs/components/editor-core/src/editor/clipboard/browser-clipboard-back.ts new file mode 100644 index 0000000000..6c6d4ad031 --- /dev/null +++ b/libs/components/editor-core/src/editor/clipboard/browser-clipboard-back.ts @@ -0,0 +1,422 @@ +import { HooksRunner } from '../types'; +import { + OFFICE_CLIPBOARD_MIMETYPE, + InnerClipInfo, + ClipBlockInfo, +} from './types'; +import { Editor } from '../editor'; +import { AsyncBlock } from '../block'; +import ClipboardParse from './clipboard-parse'; +import { SelectInfo } from '../selection'; +import { + Protocol, + BlockFlavorKeys, + services, +} from '@toeverything/datasource/db-service'; +import { MarkdownParser } from './markdown-parse'; + +// todo needs to be a switch +const SUPPORT_MARKDOWN_PASTE = true; + +const shouldHandlerContinue = (event: Event, editor: Editor) => { + const filterNodes = ['INPUT', 'SELECT', 'TEXTAREA']; + + if (event.defaultPrevented) { + return false; + } + if (filterNodes.includes((event.target as HTMLElement)?.tagName)) { + return false; + } + + return editor.selectionManager.currentSelectInfo.type !== 'None'; +}; + +enum ClipboardAction { + COPY = 'copy', + CUT = 'cut', + PASTE = 'paste', +} +class BrowserClipboard { + private _eventTarget: Element; + private _hooks: HooksRunner; + private _editor: Editor; + private _clipboardParse: ClipboardParse; + private _markdownParse: MarkdownParser; + + private static _optimalMimeType: string[] = [ + OFFICE_CLIPBOARD_MIMETYPE.DOCS_DOCUMENT_SLICE_CLIP_WRAPPED, + OFFICE_CLIPBOARD_MIMETYPE.HTML, + OFFICE_CLIPBOARD_MIMETYPE.TEXT, + ]; + + constructor(eventTarget: Element, hooks: HooksRunner, editor: Editor) { + this._eventTarget = eventTarget; + this._hooks = hooks; + this._editor = editor; + this._clipboardParse = new ClipboardParse(editor); + this._markdownParse = new MarkdownParser(); + this._initialize(); + } + + public getClipboardParse() { + return this._clipboardParse; + } + + private _initialize() { + this._handleCopy = this._handleCopy.bind(this); + this._handleCut = this._handleCut.bind(this); + this._handlePaste = this._handlePaste.bind(this); + + document.addEventListener(ClipboardAction.COPY, this._handleCopy); + document.addEventListener(ClipboardAction.CUT, this._handleCut); + document.addEventListener(ClipboardAction.PASTE, this._handlePaste); + this._eventTarget.addEventListener( + ClipboardAction.COPY, + this._handleCopy + ); + this._eventTarget.addEventListener( + ClipboardAction.CUT, + this._handleCut + ); + this._eventTarget.addEventListener( + ClipboardAction.PASTE, + this._handlePaste + ); + } + + private _handleCopy(e: Event) { + if (!shouldHandlerContinue(e, this._editor)) { + return; + } + + this._dispatchClipboardEvent(ClipboardAction.COPY, e as ClipboardEvent); + } + + private _handleCut(e: Event) { + if (!shouldHandlerContinue(e, this._editor)) { + return; + } + + this._dispatchClipboardEvent(ClipboardAction.CUT, e as ClipboardEvent); + } + + private _handlePaste(e: Event) { + if (!shouldHandlerContinue(e, this._editor)) { + return; + } + e.stopPropagation(); + + const clipboardData = (e as ClipboardEvent).clipboardData; + + const isPureFile = this._isPureFileInClipboard(clipboardData); + + if (isPureFile) { + this._pasteFile(clipboardData); + } else { + this._pasteContent(clipboardData); + } + // this._editor.selectionManager + // .getSelectInfo() + // .then(selectionInfo => console.log(selectionInfo)); + } + + private _pasteContent(clipboardData: any) { + const originClip: { data: any; type: any } = this.getOptimalClip( + clipboardData + ) as { data: any; type: any }; + + const originTextClipData = clipboardData.getData( + OFFICE_CLIPBOARD_MIMETYPE.TEXT + ); + + let clipData = originClip['data']; + + if (originClip['type'] === OFFICE_CLIPBOARD_MIMETYPE.TEXT) { + clipData = this._excapeHtml(clipData); + } + + switch (originClip['type']) { + /** Protocol paste */ + case OFFICE_CLIPBOARD_MIMETYPE.DOCS_DOCUMENT_SLICE_CLIP_WRAPPED: + this._firePasteEditAction(clipData); + break; + case OFFICE_CLIPBOARD_MIMETYPE.HTML: + this._pasteHtml(clipData, originTextClipData); + break; + case OFFICE_CLIPBOARD_MIMETYPE.TEXT: + this._pasteText(clipData, originTextClipData); + break; + + default: + break; + } + } + + private _pasteHtml(clipData: any, originTextClipData: any) { + if (SUPPORT_MARKDOWN_PASTE) { + const hasMarkdown = + this._markdownParse.checkIfTextContainsMd(originTextClipData); + if (hasMarkdown) { + try { + const convertedDataObj = + this._markdownParse.md2Html(originTextClipData); + if (convertedDataObj.isConverted) { + clipData = convertedDataObj.text; + } + } catch (e) { + console.error(e); + clipData = originTextClipData; + } + } + } + + const blocks = this._clipboardParse.html2blocks(clipData); + this.insert_blocks(blocks); + } + + private _pasteText(clipData: any, originTextClipData: any) { + const blocks = this._clipboardParse.text2blocks(clipData); + this.insert_blocks(blocks); + } + + private async _pasteFile(clipboardData: any) { + const file = this._getImageFile(clipboardData); + if (file) { + const result = await services.api.file.create({ + workspace: this._editor.workspace, + file: file, + }); + const blockInfo: ClipBlockInfo = { + type: 'image', + properties: { + image: { + value: result.id, + name: file.name, + size: file.size, + type: file.type, + }, + }, + children: [] as ClipBlockInfo[], + }; + this.insert_blocks([blockInfo]); + } + } + + private _getImageFile(clipboardData: any) { + const files = clipboardData.files; + if (files && files[0] && files[0].type.indexOf('image') > -1) { + return files[0]; + } + return; + } + + private _excapeHtml(data: any, onlySpace?: any) { + if (!onlySpace) { + // TODO: + // data = string.htmlEscape(data); + // data = data.replace(/\n/g, '
'); + } + + // data = data.replace(/ /g, ' '); + // data = data.replace(/\t/g, '    '); + return data; + } + + public getOptimalClip(clipboardData: any) { + const mimeTypeArr = BrowserClipboard._optimalMimeType; + + for (let i = 0; i < mimeTypeArr.length; i++) { + const data = + clipboardData[mimeTypeArr[i]] || + clipboardData.getData(mimeTypeArr[i]); + + if (data) { + return { + type: mimeTypeArr[i], + data: data, + }; + } + } + + return ''; + } + + private _isPureFileInClipboard(clipboardData: DataTransfer) { + const types = clipboardData.types; + + return ( + (types.length === 1 && types[0] === 'Files') || + (types.length === 2 && + (types.includes('text/plain') || types.includes('text/html')) && + types.includes('Files')) + ); + } + + private async _firePasteEditAction(clipboardData: any) { + const clipInfo: InnerClipInfo = JSON.parse(clipboardData); + clipInfo && this.insert_blocks(clipInfo.data, clipInfo.select); + } + + private _canEditText(type: BlockFlavorKeys) { + return ( + type === Protocol.Block.Type.page || + type === Protocol.Block.Type.text || + type === Protocol.Block.Type.heading1 || + type === Protocol.Block.Type.heading2 || + type === Protocol.Block.Type.heading3 || + type === Protocol.Block.Type.quote || + type === Protocol.Block.Type.todo || + type === Protocol.Block.Type.code || + type === Protocol.Block.Type.callout || + type === Protocol.Block.Type.numbered || + type === Protocol.Block.Type.bullet + ); + } + + // TODO: cursor positioning problem + private async insert_blocks(blocks: ClipBlockInfo[], select?: SelectInfo) { + if (blocks.length === 0) { + return; + } + + const cur_select_info = + await this._editor.selectionManager.getSelectInfo(); + if (cur_select_info.blocks.length === 0) { + return; + } + + let beginIndex = 0; + const curNodeId = + cur_select_info.blocks[cur_select_info.blocks.length - 1].blockId; + let curBlock = await this._editor.getBlockById(curNodeId); + const blockView = this._editor.getView(curBlock.type); + if ( + cur_select_info.type === 'Range' && + curBlock.type === 'text' && + blockView.isEmpty(curBlock) + ) { + await curBlock.setType(blocks[0].type); + curBlock.setProperties(blocks[0].properties); + await this._pasteChildren(curBlock, blocks[0].children); + beginIndex = 1; + } else if ( + select?.type === 'Range' && + cur_select_info.type === 'Range' && + this._canEditText(curBlock.type) && + this._canEditText(blocks[0].type) + ) { + if ( + cur_select_info.blocks.length > 0 && + cur_select_info.blocks[0].startInfo + ) { + const startInfo = cur_select_info.blocks[0].startInfo; + const endInfo = cur_select_info.blocks[0].endInfo; + const curTextValue = curBlock.getProperty('text').value; + const pre_curTextValue = curTextValue.slice( + 0, + startInfo.arrayIndex + ); + const lastCurTextValue = curTextValue.slice( + endInfo.arrayIndex + 1 + ); + const preText = curTextValue[ + startInfo.arrayIndex + ].text.substring(0, startInfo.offset); + const lastText = curTextValue[ + endInfo.arrayIndex + ].text.substring(endInfo.offset); + + let lastBlock: ClipBlockInfo = blocks[blocks.length - 1]; + if (!this._canEditText(lastBlock.type)) { + lastBlock = { type: 'text', children: [] }; + blocks.push(lastBlock); + } + const lastValues = lastBlock.properties?.text?.value; + lastText && lastValues.push({ text: lastText }); + lastValues.push(...lastCurTextValue); + lastBlock.properties = { + text: { value: lastValues }, + }; + + const insertInfo = blocks[0].properties.text; + preText && pre_curTextValue.push({ text: preText }); + pre_curTextValue.push(...insertInfo.value); + this._editor.blockHelper.setBlockBlur(curNodeId); + setTimeout(async () => { + const curBlock = await this._editor.getBlockById(curNodeId); + curBlock.setProperties({ + text: { value: pre_curTextValue }, + }); + await this._pasteChildren(curBlock, blocks[0].children); + }, 0); + beginIndex = 1; + } + } + + for (let i = beginIndex; i < blocks.length; i++) { + const nextBlock = await this._editor.createBlock(blocks[i].type); + nextBlock.setProperties(blocks[i].properties); + if (curBlock.type === 'page') { + curBlock.prepend(nextBlock); + } else { + curBlock.after(nextBlock); + } + + await this._pasteChildren(nextBlock, blocks[i].children); + curBlock = nextBlock; + } + } + + private async _pasteChildren(parent: AsyncBlock, children: any[]) { + for (let i = 0; i < children.length; i++) { + const nextBlock = await this._editor.createBlock(children[i].type); + nextBlock.setProperties(children[i].properties); + await parent.append(nextBlock); + await this._pasteChildren(nextBlock, children[i].children); + } + } + + private _preCopyCut(action: ClipboardAction, e: ClipboardEvent) { + switch (action) { + case ClipboardAction.COPY: + this._hooks.beforeCopy(e); + break; + + case ClipboardAction.CUT: + this._hooks.beforeCut(e); + break; + } + } + + private _dispatchClipboardEvent( + action: ClipboardAction, + e: ClipboardEvent + ) { + this._preCopyCut(action, e); + } + + dispose() { + document.removeEventListener(ClipboardAction.COPY, this._handleCopy); + document.removeEventListener(ClipboardAction.CUT, this._handleCut); + document.removeEventListener(ClipboardAction.PASTE, this._handlePaste); + this._eventTarget.removeEventListener( + ClipboardAction.COPY, + this._handleCopy + ); + this._eventTarget.removeEventListener( + ClipboardAction.CUT, + this._handleCut + ); + this._eventTarget.removeEventListener( + ClipboardAction.PASTE, + this._handlePaste + ); + this._clipboardParse.dispose(); + this._clipboardParse = null; + this._eventTarget = null; + this._hooks = null; + this._editor = null; + } +} + +export { BrowserClipboard }; diff --git a/libs/components/editor-core/src/editor/clipboard/browser-clipboard.ts b/libs/components/editor-core/src/editor/clipboard/browser-clipboard.ts index 6c6d4ad031..64eecbc5ca 100644 --- a/libs/components/editor-core/src/editor/clipboard/browser-clipboard.ts +++ b/libs/components/editor-core/src/editor/clipboard/browser-clipboard.ts @@ -14,40 +14,24 @@ import { services, } from '@toeverything/datasource/db-service'; import { MarkdownParser } from './markdown-parse'; - +import { shouldHandlerContinue } from './utils'; +import { Paste } from './paste'; // todo needs to be a switch -const SUPPORT_MARKDOWN_PASTE = true; - -const shouldHandlerContinue = (event: Event, editor: Editor) => { - const filterNodes = ['INPUT', 'SELECT', 'TEXTAREA']; - - if (event.defaultPrevented) { - return false; - } - if (filterNodes.includes((event.target as HTMLElement)?.tagName)) { - return false; - } - - return editor.selectionManager.currentSelectInfo.type !== 'None'; -}; enum ClipboardAction { COPY = 'copy', CUT = 'cut', PASTE = 'paste', } + +//TODO: need to consider the cursor position after inserting the children class BrowserClipboard { private _eventTarget: Element; private _hooks: HooksRunner; private _editor: Editor; private _clipboardParse: ClipboardParse; private _markdownParse: MarkdownParser; - - private static _optimalMimeType: string[] = [ - OFFICE_CLIPBOARD_MIMETYPE.DOCS_DOCUMENT_SLICE_CLIP_WRAPPED, - OFFICE_CLIPBOARD_MIMETYPE.HTML, - OFFICE_CLIPBOARD_MIMETYPE.TEXT, - ]; + private _paste: Paste; constructor(eventTarget: Element, hooks: HooksRunner, editor: Editor) { this._eventTarget = eventTarget; @@ -55,6 +39,11 @@ class BrowserClipboard { this._editor = editor; this._clipboardParse = new ClipboardParse(editor); this._markdownParse = new MarkdownParser(); + this._paste = new Paste( + editor, + this._clipboardParse, + this._markdownParse + ); this._initialize(); } @@ -65,11 +54,13 @@ class BrowserClipboard { private _initialize() { this._handleCopy = this._handleCopy.bind(this); this._handleCut = this._handleCut.bind(this); - this._handlePaste = this._handlePaste.bind(this); document.addEventListener(ClipboardAction.COPY, this._handleCopy); document.addEventListener(ClipboardAction.CUT, this._handleCut); - document.addEventListener(ClipboardAction.PASTE, this._handlePaste); + document.addEventListener( + ClipboardAction.PASTE, + this._paste.handlePaste + ); this._eventTarget.addEventListener( ClipboardAction.COPY, this._handleCopy @@ -80,7 +71,7 @@ class BrowserClipboard { ); this._eventTarget.addEventListener( ClipboardAction.PASTE, - this._handlePaste + this._paste.handlePaste ); } @@ -100,282 +91,6 @@ class BrowserClipboard { this._dispatchClipboardEvent(ClipboardAction.CUT, e as ClipboardEvent); } - private _handlePaste(e: Event) { - if (!shouldHandlerContinue(e, this._editor)) { - return; - } - e.stopPropagation(); - - const clipboardData = (e as ClipboardEvent).clipboardData; - - const isPureFile = this._isPureFileInClipboard(clipboardData); - - if (isPureFile) { - this._pasteFile(clipboardData); - } else { - this._pasteContent(clipboardData); - } - // this._editor.selectionManager - // .getSelectInfo() - // .then(selectionInfo => console.log(selectionInfo)); - } - - private _pasteContent(clipboardData: any) { - const originClip: { data: any; type: any } = this.getOptimalClip( - clipboardData - ) as { data: any; type: any }; - - const originTextClipData = clipboardData.getData( - OFFICE_CLIPBOARD_MIMETYPE.TEXT - ); - - let clipData = originClip['data']; - - if (originClip['type'] === OFFICE_CLIPBOARD_MIMETYPE.TEXT) { - clipData = this._excapeHtml(clipData); - } - - switch (originClip['type']) { - /** Protocol paste */ - case OFFICE_CLIPBOARD_MIMETYPE.DOCS_DOCUMENT_SLICE_CLIP_WRAPPED: - this._firePasteEditAction(clipData); - break; - case OFFICE_CLIPBOARD_MIMETYPE.HTML: - this._pasteHtml(clipData, originTextClipData); - break; - case OFFICE_CLIPBOARD_MIMETYPE.TEXT: - this._pasteText(clipData, originTextClipData); - break; - - default: - break; - } - } - - private _pasteHtml(clipData: any, originTextClipData: any) { - if (SUPPORT_MARKDOWN_PASTE) { - const hasMarkdown = - this._markdownParse.checkIfTextContainsMd(originTextClipData); - if (hasMarkdown) { - try { - const convertedDataObj = - this._markdownParse.md2Html(originTextClipData); - if (convertedDataObj.isConverted) { - clipData = convertedDataObj.text; - } - } catch (e) { - console.error(e); - clipData = originTextClipData; - } - } - } - - const blocks = this._clipboardParse.html2blocks(clipData); - this.insert_blocks(blocks); - } - - private _pasteText(clipData: any, originTextClipData: any) { - const blocks = this._clipboardParse.text2blocks(clipData); - this.insert_blocks(blocks); - } - - private async _pasteFile(clipboardData: any) { - const file = this._getImageFile(clipboardData); - if (file) { - const result = await services.api.file.create({ - workspace: this._editor.workspace, - file: file, - }); - const blockInfo: ClipBlockInfo = { - type: 'image', - properties: { - image: { - value: result.id, - name: file.name, - size: file.size, - type: file.type, - }, - }, - children: [] as ClipBlockInfo[], - }; - this.insert_blocks([blockInfo]); - } - } - - private _getImageFile(clipboardData: any) { - const files = clipboardData.files; - if (files && files[0] && files[0].type.indexOf('image') > -1) { - return files[0]; - } - return; - } - - private _excapeHtml(data: any, onlySpace?: any) { - if (!onlySpace) { - // TODO: - // data = string.htmlEscape(data); - // data = data.replace(/\n/g, '
'); - } - - // data = data.replace(/ /g, ' '); - // data = data.replace(/\t/g, '    '); - return data; - } - - public getOptimalClip(clipboardData: any) { - const mimeTypeArr = BrowserClipboard._optimalMimeType; - - for (let i = 0; i < mimeTypeArr.length; i++) { - const data = - clipboardData[mimeTypeArr[i]] || - clipboardData.getData(mimeTypeArr[i]); - - if (data) { - return { - type: mimeTypeArr[i], - data: data, - }; - } - } - - return ''; - } - - private _isPureFileInClipboard(clipboardData: DataTransfer) { - const types = clipboardData.types; - - return ( - (types.length === 1 && types[0] === 'Files') || - (types.length === 2 && - (types.includes('text/plain') || types.includes('text/html')) && - types.includes('Files')) - ); - } - - private async _firePasteEditAction(clipboardData: any) { - const clipInfo: InnerClipInfo = JSON.parse(clipboardData); - clipInfo && this.insert_blocks(clipInfo.data, clipInfo.select); - } - - private _canEditText(type: BlockFlavorKeys) { - return ( - type === Protocol.Block.Type.page || - type === Protocol.Block.Type.text || - type === Protocol.Block.Type.heading1 || - type === Protocol.Block.Type.heading2 || - type === Protocol.Block.Type.heading3 || - type === Protocol.Block.Type.quote || - type === Protocol.Block.Type.todo || - type === Protocol.Block.Type.code || - type === Protocol.Block.Type.callout || - type === Protocol.Block.Type.numbered || - type === Protocol.Block.Type.bullet - ); - } - - // TODO: cursor positioning problem - private async insert_blocks(blocks: ClipBlockInfo[], select?: SelectInfo) { - if (blocks.length === 0) { - return; - } - - const cur_select_info = - await this._editor.selectionManager.getSelectInfo(); - if (cur_select_info.blocks.length === 0) { - return; - } - - let beginIndex = 0; - const curNodeId = - cur_select_info.blocks[cur_select_info.blocks.length - 1].blockId; - let curBlock = await this._editor.getBlockById(curNodeId); - const blockView = this._editor.getView(curBlock.type); - if ( - cur_select_info.type === 'Range' && - curBlock.type === 'text' && - blockView.isEmpty(curBlock) - ) { - await curBlock.setType(blocks[0].type); - curBlock.setProperties(blocks[0].properties); - await this._pasteChildren(curBlock, blocks[0].children); - beginIndex = 1; - } else if ( - select?.type === 'Range' && - cur_select_info.type === 'Range' && - this._canEditText(curBlock.type) && - this._canEditText(blocks[0].type) - ) { - if ( - cur_select_info.blocks.length > 0 && - cur_select_info.blocks[0].startInfo - ) { - const startInfo = cur_select_info.blocks[0].startInfo; - const endInfo = cur_select_info.blocks[0].endInfo; - const curTextValue = curBlock.getProperty('text').value; - const pre_curTextValue = curTextValue.slice( - 0, - startInfo.arrayIndex - ); - const lastCurTextValue = curTextValue.slice( - endInfo.arrayIndex + 1 - ); - const preText = curTextValue[ - startInfo.arrayIndex - ].text.substring(0, startInfo.offset); - const lastText = curTextValue[ - endInfo.arrayIndex - ].text.substring(endInfo.offset); - - let lastBlock: ClipBlockInfo = blocks[blocks.length - 1]; - if (!this._canEditText(lastBlock.type)) { - lastBlock = { type: 'text', children: [] }; - blocks.push(lastBlock); - } - const lastValues = lastBlock.properties?.text?.value; - lastText && lastValues.push({ text: lastText }); - lastValues.push(...lastCurTextValue); - lastBlock.properties = { - text: { value: lastValues }, - }; - - const insertInfo = blocks[0].properties.text; - preText && pre_curTextValue.push({ text: preText }); - pre_curTextValue.push(...insertInfo.value); - this._editor.blockHelper.setBlockBlur(curNodeId); - setTimeout(async () => { - const curBlock = await this._editor.getBlockById(curNodeId); - curBlock.setProperties({ - text: { value: pre_curTextValue }, - }); - await this._pasteChildren(curBlock, blocks[0].children); - }, 0); - beginIndex = 1; - } - } - - for (let i = beginIndex; i < blocks.length; i++) { - const nextBlock = await this._editor.createBlock(blocks[i].type); - nextBlock.setProperties(blocks[i].properties); - if (curBlock.type === 'page') { - curBlock.prepend(nextBlock); - } else { - curBlock.after(nextBlock); - } - - await this._pasteChildren(nextBlock, blocks[i].children); - curBlock = nextBlock; - } - } - - private async _pasteChildren(parent: AsyncBlock, children: any[]) { - for (let i = 0; i < children.length; i++) { - const nextBlock = await this._editor.createBlock(children[i].type); - nextBlock.setProperties(children[i].properties); - await parent.append(nextBlock); - await this._pasteChildren(nextBlock, children[i].children); - } - } - private _preCopyCut(action: ClipboardAction, e: ClipboardEvent) { switch (action) { case ClipboardAction.COPY: @@ -398,7 +113,10 @@ class BrowserClipboard { dispose() { document.removeEventListener(ClipboardAction.COPY, this._handleCopy); document.removeEventListener(ClipboardAction.CUT, this._handleCut); - document.removeEventListener(ClipboardAction.PASTE, this._handlePaste); + document.removeEventListener( + ClipboardAction.PASTE, + this._paste.handlePaste + ); this._eventTarget.removeEventListener( ClipboardAction.COPY, this._handleCopy @@ -409,7 +127,7 @@ class BrowserClipboard { ); this._eventTarget.removeEventListener( ClipboardAction.PASTE, - this._handlePaste + this._paste.handlePaste ); this._clipboardParse.dispose(); this._clipboardParse = null; diff --git a/libs/components/editor-core/src/editor/clipboard/paste.ts b/libs/components/editor-core/src/editor/clipboard/paste.ts new file mode 100644 index 0000000000..022b3de214 --- /dev/null +++ b/libs/components/editor-core/src/editor/clipboard/paste.ts @@ -0,0 +1,535 @@ +import { HooksRunner } from '../types'; +import { + OFFICE_CLIPBOARD_MIMETYPE, + InnerClipInfo, + ClipBlockInfo, +} from './types'; +import { Editor } from '../editor'; +import { AsyncBlock } from '../block'; +import ClipboardParse from './clipboard-parse'; +import { SelectInfo } from '../selection'; +import { + Protocol, + BlockFlavorKeys, + services, +} from '@toeverything/datasource/db-service'; +import { MarkdownParser } from './markdown-parse'; +import { shouldHandlerContinue } from './utils'; +const SUPPORT_MARKDOWN_PASTE = true; + +type TextValueItem = { + text: string; + [key: string]: any; +}; + +export class Paste { + private _editor: Editor; + private _markdownParse: MarkdownParser; + private _clipboardParse: ClipboardParse; + + constructor( + editor: Editor, + clipboardParse: ClipboardParse, + markdownParse: MarkdownParser + ) { + this._markdownParse = markdownParse; + this._clipboardParse = clipboardParse; + this._editor = editor; + this.handlePaste = this.handlePaste.bind(this); + } + private static _optimalMimeType: string[] = [ + OFFICE_CLIPBOARD_MIMETYPE.DOCS_DOCUMENT_SLICE_CLIP_WRAPPED, + OFFICE_CLIPBOARD_MIMETYPE.HTML, + OFFICE_CLIPBOARD_MIMETYPE.TEXT, + ]; + public handlePaste(e: Event) { + if (!shouldHandlerContinue(e, this._editor)) { + return; + } + e.stopPropagation(); + + const clipboardData = (e as ClipboardEvent).clipboardData; + + const isPureFile = Paste._isPureFileInClipboard(clipboardData); + if (isPureFile) { + this._pasteFile(clipboardData); + } else { + this._pasteContent(clipboardData); + } + } + public getOptimalClip(clipboardData: any) { + const mimeTypeArr = Paste._optimalMimeType; + + for (let i = 0; i < mimeTypeArr.length; i++) { + const data = + clipboardData[mimeTypeArr[i]] || + clipboardData.getData(mimeTypeArr[i]); + + if (data) { + return { + type: mimeTypeArr[i], + data: data, + }; + } + } + + return ''; + } + + private _pasteContent(clipboardData: any) { + const originClip: { data: any; type: any } = this.getOptimalClip( + clipboardData + ) as { data: any; type: any }; + + const originTextClipData = clipboardData.getData( + OFFICE_CLIPBOARD_MIMETYPE.TEXT + ); + + let clipData = originClip['data']; + + if (originClip['type'] === OFFICE_CLIPBOARD_MIMETYPE.TEXT) { + clipData = Paste._excapeHtml(clipData); + } + + switch (originClip['type']) { + /** Protocol paste */ + case OFFICE_CLIPBOARD_MIMETYPE.DOCS_DOCUMENT_SLICE_CLIP_WRAPPED: + this._firePasteEditAction(clipData); + break; + case OFFICE_CLIPBOARD_MIMETYPE.HTML: + this._pasteHtml(clipData, originTextClipData); + break; + case OFFICE_CLIPBOARD_MIMETYPE.TEXT: + this._pasteText(clipData, originTextClipData); + break; + + default: + break; + } + } + private async _firePasteEditAction(clipboardData: any) { + const clipInfo: InnerClipInfo = JSON.parse(clipboardData); + clipInfo && this._insertBlocks(clipInfo.data, clipInfo.select); + } + private async _pasteFile(clipboardData: any) { + const file = Paste._getImageFile(clipboardData); + if (file) { + const result = await services.api.file.create({ + workspace: this._editor.workspace, + file: file, + }); + const blockInfo: ClipBlockInfo = { + type: 'image', + properties: { + image: { + value: result.id, + name: file.name, + size: file.size, + type: file.type, + }, + }, + children: [] as ClipBlockInfo[], + }; + await this._insertBlocks([blockInfo]); + } + } + private static _isPureFileInClipboard(clipboardData: DataTransfer) { + const types = clipboardData.types; + + return ( + (types.length === 1 && types[0] === 'Files') || + (types.length === 2 && + (types.includes('text/plain') || types.includes('text/html')) && + types.includes('Files')) + ); + } + + private static _isTextEditBlock(type: BlockFlavorKeys) { + return ( + type === Protocol.Block.Type.page || + type === Protocol.Block.Type.text || + type === Protocol.Block.Type.heading1 || + type === Protocol.Block.Type.heading2 || + type === Protocol.Block.Type.heading3 || + type === Protocol.Block.Type.quote || + type === Protocol.Block.Type.todo || + type === Protocol.Block.Type.code || + type === Protocol.Block.Type.callout || + type === Protocol.Block.Type.numbered || + type === Protocol.Block.Type.bullet + ); + } + + private async _insertBlocks( + blocks: ClipBlockInfo[], + pasteSelect?: SelectInfo + ) { + if (blocks.length === 0) { + return; + } + const currentSelectInfo = + await this._editor.selectionManager.getSelectInfo(); + + // 当选区在某一个block中时 + // select?.type === 'Range' + if (currentSelectInfo.type === 'Range') { + // 当 currentSelectInfo.type === 'Range' 时,光标选中的block必然只有一个 + const selectedBlock = await this._editor.getBlockById( + currentSelectInfo.blocks[0].blockId + ); + const isSelectedBlockEdit = Paste._isTextEditBlock( + selectedBlock.type + ); + if (isSelectedBlockEdit) { + const shouldSplitBlock = + blocks.length > 1 || + !Paste._isTextEditBlock(blocks[0].type); + const pureText = !shouldSplitBlock + ? blocks[0].properties.text.value + : [{ text: '' }]; + this._editor.blockHelper.setBlockBlur( + currentSelectInfo.blocks[0].blockId + ); + + const { startInfo, endInfo } = currentSelectInfo.blocks[0]; + + // 选中的当前的可编辑block的文字信息 + const currentTextValue = + selectedBlock.getProperty('text').value; + // 当光标选区跨越不同样式文字时 + if (startInfo?.arrayIndex !== endInfo?.arrayIndex) { + if (shouldSplitBlock) { + const newTextValue = currentTextValue.reduce( + ( + newTextValue: TextValueItem[], + textStore: TextValueItem, + i: number + ) => { + if (i < startInfo?.arrayIndex) { + newTextValue.push(textStore); + } + const { text, ...props } = textStore; + + if (i === startInfo?.arrayIndex) { + newTextValue.push({ + text: text.slice(0, startInfo?.offset), + ...props, + }); + } + return newTextValue; + }, + [] + ); + const nextTextValue = currentTextValue.reduce( + ( + newTextValue: TextValueItem[], + textStore: TextValueItem, + i: number + ) => { + if (i > endInfo?.arrayIndex) { + newTextValue.push(textStore); + } + const { text, ...props } = textStore; + + if (i === endInfo?.arrayIndex) { + newTextValue.push({ + text: text.slice(endInfo?.offset), + ...props, + }); + } + return newTextValue; + }, + [] + ); + + selectedBlock.setProperties({ + text: { + value: newTextValue, + }, + }); + const pasteBlocks = await this._createBlocks(blocks); + pasteBlocks.forEach(block => { + selectedBlock.after(block); + }); + const nextBlock = await this._editor.createBlock( + selectedBlock?.type + ); + nextBlock.setProperties({ + text: { + value: nextTextValue, + }, + }); + pasteBlocks[pasteBlocks.length - 1].after(nextBlock); + + this._setEndSelectToBlock( + pasteBlocks[pasteBlocks.length - 1].id + ); + } else { + const newTextValue = currentTextValue.reduce( + ( + newTextValue: TextValueItem[], + textStore: TextValueItem, + i: number + ) => { + if ( + i < startInfo?.arrayIndex || + i > endInfo?.arrayIndex + ) { + newTextValue.push(textStore); + } + const { text, ...props } = textStore; + + if (i === startInfo?.arrayIndex) { + newTextValue.push({ + text: text.slice(0, startInfo?.offset), + ...props, + }); + } else if (i === endInfo?.arrayIndex) { + newTextValue.push({ + text: text.slice(endInfo?.offset), + ...props, + }); + } + return newTextValue; + }, + [] + ); + newTextValue.splice( + startInfo?.arrayIndex + 1, + 0, + ...pureText + ); + selectedBlock.setProperties({ + text: { + value: newTextValue, + }, + }); + } + } + // 当光标选区没有跨越不同样式文字时 + if (startInfo?.arrayIndex === endInfo?.arrayIndex) { + if (shouldSplitBlock) { + const newTextValue = currentTextValue.reduce( + ( + newTextValue: TextValueItem[], + textStore: TextValueItem, + i: number + ) => { + if (i < startInfo?.arrayIndex) { + newTextValue.push(textStore); + } + const { text, ...props } = textStore; + + if (i === startInfo?.arrayIndex) { + newTextValue.push({ + text: `${text.slice( + 0, + startInfo?.offset + )}`, + ...props, + }); + } + return newTextValue; + }, + [] + ); + + const nextTextValue = currentTextValue.reduce( + ( + nextTextValue: TextValueItem[], + textStore: TextValueItem, + i: number + ) => { + if (i > endInfo?.arrayIndex) { + nextTextValue.push(textStore); + } + const { text, ...props } = textStore; + + if (i === endInfo?.arrayIndex) { + nextTextValue.push({ + text: `${text.slice(endInfo?.offset)}`, + ...props, + }); + } + return nextTextValue; + }, + [] + ); + selectedBlock.setProperties({ + text: { + value: newTextValue, + }, + }); + const pasteBlocks = await this._createBlocks(blocks); + pasteBlocks.forEach((block: AsyncBlock) => { + selectedBlock.after(block); + }); + const nextBlock = await this._editor.createBlock( + selectedBlock?.type + ); + nextBlock.setProperties({ + text: { + value: nextTextValue, + }, + }); + pasteBlocks[pasteBlocks.length - 1].after(nextBlock); + + this._setEndSelectToBlock( + pasteBlocks[pasteBlocks.length - 1].id + ); + } else { + const newTextValue = currentTextValue.reduce( + ( + newTextValue: TextValueItem[], + textStore: TextValueItem, + i: number + ) => { + if (i !== startInfo?.arrayIndex) { + newTextValue.push(textStore); + } + const { text, ...props } = textStore; + + if (i === startInfo?.arrayIndex) { + newTextValue.push({ + text: `${text.slice( + 0, + startInfo?.offset + )}`, + ...props, + }); + newTextValue.push(...pureText); + + newTextValue.push({ + text: `${text.slice(endInfo?.offset)}`, + ...props, + }); + } + return newTextValue; + }, + [] + ); + selectedBlock.setProperties({ + text: { + value: newTextValue, + }, + }); + + const pastedTextLength = pureText.reduce( + (sumLength: number, textItem: TextValueItem) => { + sumLength += textItem.text.length; + return sumLength; + }, + 0 + ); + + // this._editor.selectionManager.moveCursor( + // window.getSelection().getRangeAt(0), + // pastedTextLength, + // selectedBlock.id + // ); + } + } + } else { + const pasteBlocks = await this._createBlocks(blocks); + pasteBlocks.forEach(block => { + selectedBlock.after(block); + }); + this._setEndSelectToBlock( + pasteBlocks[pasteBlocks.length - 1].id + ); + } + } + + if (currentSelectInfo.type === 'Block') { + const selectedBlock = await this._editor.getBlockById( + currentSelectInfo.blocks[currentSelectInfo.blocks.length - 1] + .blockId + ); + const pasteBlocks = await this._createBlocks(blocks); + + let groupBlock: AsyncBlock; + if ( + selectedBlock?.type === 'group' || + selectedBlock?.type === 'page' + ) { + groupBlock = await this._editor.createBlock('group'); + pasteBlocks.forEach(block => { + groupBlock.append(block); + }); + await selectedBlock.after(groupBlock); + } else { + pasteBlocks.forEach(block => { + selectedBlock.after(block); + }); + } + this._setEndSelectToBlock(pasteBlocks[pasteBlocks.length - 1].id); + } + } + + private _setEndSelectToBlock(blockId: string) { + setTimeout(() => { + this._editor.selectionManager.activeNodeByNodeId(blockId, 'end'); + }, 100); + } + + private async _createBlocks(blocks: ClipBlockInfo[], parentId?: string) { + return Promise.all( + blocks.map(async clipBlockInfo => { + const block = await this._editor.createBlock( + clipBlockInfo.type + ); + block?.setProperties(clipBlockInfo.properties); + await this._createBlocks(clipBlockInfo.children, block?.id); + return block; + }) + ); + } + + private async _pasteHtml(clipData: any, originTextClipData: any) { + if (SUPPORT_MARKDOWN_PASTE) { + const hasMarkdown = + this._markdownParse.checkIfTextContainsMd(originTextClipData); + if (hasMarkdown) { + try { + const convertedDataObj = + this._markdownParse.md2Html(originTextClipData); + if (convertedDataObj.isConverted) { + clipData = convertedDataObj.text; + } + } catch (e) { + console.error(e); + clipData = originTextClipData; + } + } + } + + const blocks = this._clipboardParse.html2blocks(clipData); + + await this._insertBlocks(blocks); + } + + private async _pasteText(clipData: any, originTextClipData: any) { + const blocks = this._clipboardParse.text2blocks(clipData); + await this._insertBlocks(blocks); + } + + private static _getImageFile(clipboardData: any) { + const files = clipboardData.files; + if (files && files[0] && files[0].type.indexOf('image') > -1) { + return files[0]; + } + return; + } + + private static _excapeHtml(data: any, onlySpace?: any) { + if (!onlySpace) { + // TODO: + // data = string.htmlEscape(data); + // data = data.replace(/\n/g, '
'); + } + + // data = data.replace(/ /g, ' '); + // data = data.replace(/\t/g, '    '); + return data; + } +} diff --git a/libs/components/editor-core/src/editor/clipboard/utils.ts b/libs/components/editor-core/src/editor/clipboard/utils.ts new file mode 100644 index 0000000000..7b4d420a3a --- /dev/null +++ b/libs/components/editor-core/src/editor/clipboard/utils.ts @@ -0,0 +1,14 @@ +import {Editor} from "../editor"; + +export const shouldHandlerContinue = (event: Event, editor: Editor) => { + const filterNodes = ['INPUT', 'SELECT', 'TEXTAREA']; + + if (event.defaultPrevented) { + return false; + } + if (filterNodes.includes((event.target as HTMLElement)?.tagName)) { + return false; + } + + return editor.selectionManager.currentSelectInfo.type !== 'None'; +};