diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-07-19 17:16:28 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-07-19 17:16:28 +0300 |
commit | e4384360a16dd9a19d4d2d25d0ef1f2b862ed2a6 (patch) | |
tree | 2fcdfa7dcdb9db8f5208b2562f4b4e803d671243 /app/assets/javascripts/content_editor/extensions/copy_paste.js | |
parent | ffda4e7bcac36987f936b4ba515995a6698698f0 (diff) |
Add latest changes from gitlab-org/gitlab@16-2-stable-eev16.2.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor/extensions/copy_paste.js')
-rw-r--r-- | app/assets/javascripts/content_editor/extensions/copy_paste.js | 191 |
1 files changed, 191 insertions, 0 deletions
diff --git a/app/assets/javascripts/content_editor/extensions/copy_paste.js b/app/assets/javascripts/content_editor/extensions/copy_paste.js new file mode 100644 index 00000000000..f484ce98e90 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/copy_paste.js @@ -0,0 +1,191 @@ +import OrderedMap from 'orderedmap'; +import { Extension } from '@tiptap/core'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { Schema, DOMParser as ProseMirrorDOMParser, DOMSerializer } from '@tiptap/pm/model'; +import { uniqueId } from 'lodash'; +import { __ } from '~/locale'; +import { VARIANT_DANGER } from '~/alert'; +import createMarkdownDeserializer from '../services/gl_api_markdown_deserializer'; +import { ALERT_EVENT, EXTENSION_PRIORITY_HIGHEST } from '../constants'; +import CodeBlockHighlight from './code_block_highlight'; +import CodeSuggestion from './code_suggestion'; +import Diagram from './diagram'; +import Frontmatter from './frontmatter'; + +const TEXT_FORMAT = 'text/plain'; +const GFM_FORMAT = 'text/x-gfm'; +const HTML_FORMAT = 'text/html'; +const VS_CODE_FORMAT = 'vscode-editor-data'; +const CODE_BLOCK_NODE_TYPES = [ + CodeBlockHighlight.name, + CodeSuggestion.name, + Diagram.name, + Frontmatter.name, +]; + +function parseHTML(schema, html) { + const parser = new DOMParser(); + const startTag = '<body>'; + const endTag = '</body>'; + const { body } = parser.parseFromString(startTag + html + endTag, 'text/html'); + return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body) }; +} + +const findLoader = (editor, loaderId) => { + let position; + + editor.view.state.doc.descendants((descendant, pos) => { + if (descendant.type.name === 'loading' && descendant.attrs.id === loaderId) { + position = pos; + return false; + } + + return true; + }); + + return position; +}; + +export default Extension.create({ + name: 'copyPaste', + priority: EXTENSION_PRIORITY_HIGHEST, + addOptions() { + return { + renderMarkdown: null, + serializer: null, + }; + }, + addCommands() { + return { + pasteContent: (content = '', processMarkdown = true) => () => { + const { editor, options } = this; + const { renderMarkdown, eventHub } = options; + const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); + + const pasteSchemaSpec = { ...editor.schema.spec }; + pasteSchemaSpec.marks = OrderedMap.from(pasteSchemaSpec.marks).remove('span'); + pasteSchemaSpec.nodes = OrderedMap.from(pasteSchemaSpec.nodes).remove('div').remove('pre'); + const pasteSchema = new Schema(pasteSchemaSpec); + + const promise = processMarkdown + ? deserializer.deserialize({ schema: pasteSchema, markdown: content }) + : Promise.resolve(parseHTML(pasteSchema, content)); + const loaderId = uniqueId('loading'); + + Promise.resolve() + .then(() => { + editor.commands.insertContent({ type: 'loading', attrs: { id: loaderId } }); + return promise; + }) + .then(async ({ document }) => { + if (!document) return; + + const pos = findLoader(editor, loaderId); + if (!pos) return; + + const { firstChild, childCount } = document.content; + const toPaste = + childCount === 1 && firstChild.type.name === 'paragraph' + ? firstChild.content + : document.content; + + editor + .chain() + .deleteRange({ from: pos, to: pos + 1 }) + .insertContentAt(pos, toPaste.toJSON(), { + updateSelection: false, + }) + .run(); + }) + .catch(() => { + eventHub.$emit(ALERT_EVENT, { + message: __('An error occurred while pasting text in the editor. Please try again.'), + variant: VARIANT_DANGER, + }); + }); + + return true; + }, + }; + }, + addProseMirrorPlugins() { + let pasteRaw = false; + + const handleCutAndCopy = (view, event) => { + const slice = view.state.selection.content(); + const gfmContent = this.options.serializer.serialize({ doc: slice.content }); + const documentFragment = DOMSerializer.fromSchema(view.state.schema).serializeFragment( + slice.content, + ); + const div = document.createElement('div'); + div.appendChild(documentFragment); + + event.clipboardData.setData(TEXT_FORMAT, div.innerText); + event.clipboardData.setData(HTML_FORMAT, div.innerHTML); + event.clipboardData.setData(GFM_FORMAT, gfmContent); + + event.preventDefault(); + event.stopPropagation(); + }; + + return [ + new Plugin({ + key: new PluginKey('copyPaste'), + props: { + handleDOMEvents: { + copy: handleCutAndCopy, + cut: (view, event) => { + handleCutAndCopy(view, event); + this.editor.commands.deleteSelection(); + }, + }, + handleKeyDown: (_, event) => { + pasteRaw = event.key === 'v' && (event.metaKey || event.ctrlKey) && event.shiftKey; + }, + + handlePaste: (view, event) => { + const { clipboardData } = event; + + const gfmContent = clipboardData.getData(GFM_FORMAT); + + if (gfmContent) { + return this.editor.commands.pasteContent(gfmContent, true); + } + + const textContent = clipboardData.getData(TEXT_FORMAT); + const htmlContent = clipboardData.getData(HTML_FORMAT); + + const { from, to } = view.state.selection; + + if (pasteRaw) { + this.editor.commands.insertContentAt( + { from, to }, + textContent.replace(/^\s+|\s+$/gm, ''), + ); + return true; + } + + const hasHTML = clipboardData.types.some((type) => type === HTML_FORMAT); + const hasVsCode = clipboardData.types.some((type) => type === VS_CODE_FORMAT); + const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {}; + const language = vsCodeMeta.mode; + + // if a code block is active, paste as plain text + if (!textContent || CODE_BLOCK_NODE_TYPES.some((type) => this.editor.isActive(type))) { + return false; + } + + if (hasVsCode) { + return this.editor.commands.pasteContent( + language === 'markdown' ? textContent : `\`\`\`${language}\n${textContent}\n\`\`\``, + true, + ); + } + + return this.editor.commands.pasteContent(hasHTML ? htmlContent : textContent, !hasHTML); + }, + }, + }), + ]; + }, +}); |