diff options
Diffstat (limited to 'app/assets/javascripts/content_editor/extensions')
9 files changed, 197 insertions, 39 deletions
diff --git a/app/assets/javascripts/content_editor/extensions/code.js b/app/assets/javascripts/content_editor/extensions/code.js index 53f6d9b995c..8477c8dbd28 100644 --- a/app/assets/javascripts/content_editor/extensions/code.js +++ b/app/assets/javascripts/content_editor/extensions/code.js @@ -1,12 +1,22 @@ +import { Mark } from '@tiptap/core'; import Code from '@tiptap/extension-code'; import { EXTENSION_PRIORITY_LOWER } from '../constants'; export default Code.extend({ excludes: null, + /** * Reduce the rendering priority of the code mark to * ensure the bold, italic, and strikethrough marks * are rendered first. */ priority: EXTENSION_PRIORITY_LOWER, + + addKeyboardShortcuts() { + return { + ArrowRight: () => { + return Mark.handleExit({ editor: this.editor, mark: this }); + }, + }; + }, }); diff --git a/app/assets/javascripts/content_editor/extensions/description_item.js b/app/assets/javascripts/content_editor/extensions/description_item.js index 06fecf8196d..d3fa4bb84bd 100644 --- a/app/assets/javascripts/content_editor/extensions/description_item.js +++ b/app/assets/javascripts/content_editor/extensions/description_item.js @@ -39,9 +39,13 @@ export default Node.create({ addKeyboardShortcuts() { return { Enter: () => { + if (!this.editor.isActive('descriptionItem')) return false; + return this.editor.commands.splitListItem('descriptionItem'); }, Tab: () => { + if (!this.editor.isActive('descriptionItem')) return false; + const { isTerm } = this.editor.getAttributes('descriptionItem'); if (isTerm) return this.editor.commands.updateAttributes('descriptionItem', { isTerm: !isTerm }); @@ -49,6 +53,8 @@ export default Node.create({ return false; }, 'Shift-Tab': () => { + if (!this.editor.isActive('descriptionItem')) return false; + const { isTerm } = this.editor.getAttributes('descriptionItem'); if (isTerm) return this.editor.commands.liftListItem('descriptionItem'); diff --git a/app/assets/javascripts/content_editor/extensions/details_content.js b/app/assets/javascripts/content_editor/extensions/details_content.js index fbe58664a10..61bef0729db 100644 --- a/app/assets/javascripts/content_editor/extensions/details_content.js +++ b/app/assets/javascripts/content_editor/extensions/details_content.js @@ -26,8 +26,16 @@ export default Node.create({ addKeyboardShortcuts() { return { - Enter: () => this.editor.commands.splitListItem('detailsContent'), - 'Shift-Tab': () => this.editor.commands.liftListItem('detailsContent'), + Enter: () => { + if (!this.editor.isActive('detailsContent')) return false; + + return this.editor.commands.splitListItem('detailsContent'); + }, + 'Shift-Tab': () => { + if (!this.editor.isActive('detailsContent')) return false; + + return this.editor.commands.liftListItem('detailsContent'); + }, }; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/drawio_diagram.js b/app/assets/javascripts/content_editor/extensions/drawio_diagram.js index 8c3012ecf59..0d453919571 100644 --- a/app/assets/javascripts/content_editor/extensions/drawio_diagram.js +++ b/app/assets/javascripts/content_editor/extensions/drawio_diagram.js @@ -1,7 +1,6 @@ import { create } from '~/drawio/content_editor_facade'; import { launchDrawioEditor } from '~/drawio/drawio_editor'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; -import createAssetResolver from '../services/asset_resolver'; import Image from './image'; export default Image.extend({ @@ -10,7 +9,7 @@ export default Image.extend({ return { ...this.parent?.(), uploadsPath: null, - renderMarkdown: null, + assetResolver: null, }; }, parseHTML() { @@ -32,7 +31,7 @@ export default Image.extend({ tiptapEditor: this.editor, drawioNodeName: this.name, uploadsPath: this.options.uploadsPath, - assetResolver: createAssetResolver({ renderMarkdown: this.options.renderMarkdown }), + assetResolver: this.options.assetResolver, }), }); }, diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js index b83814103d1..584e7b9e4f7 100644 --- a/app/assets/javascripts/content_editor/extensions/link.js +++ b/app/assets/javascripts/content_editor/extensions/link.js @@ -40,7 +40,6 @@ export default Link.extend({ }, addAttributes() { return { - ...this.parent?.(), uploading: { default: false, renderHTML: ({ uploading }) => (uploading ? { class: 'with-attachment-icon' } : {}), diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js index 82fa5ce6c1d..db13438de5e 100644 --- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js +++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js @@ -1,5 +1,7 @@ +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 { __ } from '~/locale'; import { VARIANT_DANGER } from '~/alert'; import createMarkdownDeserializer from '../services/gl_api_markdown_deserializer'; @@ -9,47 +11,55 @@ 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, 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) }; +} + export default Extension.create({ name: 'pasteMarkdown', priority: EXTENSION_PRIORITY_HIGHEST, addOptions() { return { renderMarkdown: null, + serializer: null, }; }, addCommands() { return { - pasteMarkdown: (markdown) => () => { + pasteContent: (content = '', processMarkdown = true) => async () => { const { editor, options } = this; const { renderMarkdown, eventHub } = options; const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); - deserializer - .deserialize({ schema: editor.schema, markdown }) + const pasteSchemaSpec = { ...editor.schema.spec }; + pasteSchemaSpec.marks = OrderedMap.from(pasteSchemaSpec.marks).remove('span'); + pasteSchemaSpec.nodes = OrderedMap.from(pasteSchemaSpec.nodes).remove('div').remove('pre'); + const schema = new Schema(pasteSchemaSpec); + + const promise = processMarkdown + ? deserializer.deserialize({ schema, markdown: content }) + : Promise.resolve(parseHTML(schema, content)); + + promise .then(({ document }) => { - if (!document) { - return; - } + if (!document) return; - const { state, view } = editor; - const { tr, selection } = state; const { firstChild } = document.content; - const content = + const toPaste = document.content.childCount === 1 && firstChild.type.name === 'paragraph' ? firstChild.content : document.content; - if (selection.to - selection.from > 0) { - tr.replaceWith(selection.from, selection.to, content); - } else { - tr.insert(selection.from, content); - } - - view.dispatch(tr); + editor.commands.insertContent(toPaste.toJSON()); }) .catch(() => { eventHub.$emit(ALERT_EVENT, { @@ -65,24 +75,57 @@ export default Extension.create({ 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('pasteMarkdown'), 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 content = clipboardData.getData(TEXT_FORMAT); - const { state } = view; - const { tr, selection } = state; - const { from, to } = selection; + + 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) { - tr.insertText(content.replace(/^\s+|\s+$/gm, ''), from, to); - view.dispatch(tr); + this.editor.commands.insertContentAt( + { from, to }, + textContent.replace(/^\s+|\s+$/gm, ''), + ); return true; } @@ -91,18 +134,19 @@ export default Extension.create({ const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {}; const language = vsCodeMeta.mode; - if (!content || (hasHTML && !hasVsCode) || (hasVsCode && language !== 'markdown')) { - return false; - } - // if a code block is active, paste as plain text - if (CODE_BLOCK_NODE_TYPES.some((type) => this.editor.isActive(type))) { + if (!textContent || CODE_BLOCK_NODE_TYPES.some((type) => this.editor.isActive(type))) { return false; } - this.editor.commands.pasteMarkdown(content); + if (hasVsCode) { + return this.editor.commands.pasteContent( + language === 'markdown' ? textContent : `\`\`\`${language}\n${textContent}\n\`\`\``, + true, + ); + } - return true; + return this.editor.commands.pasteContent(hasHTML ? htmlContent : textContent, !hasHTML); }, }, }), diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js index b56aa8596a0..ef69b9bbda6 100644 --- a/app/assets/javascripts/content_editor/extensions/reference.js +++ b/app/assets/javascripts/content_editor/extensions/reference.js @@ -1,4 +1,4 @@ -import { Node } from '@tiptap/core'; +import { Node, InputRule } from '@tiptap/core'; import { VueNodeViewRenderer } from '@tiptap/vue-2'; import ReferenceWrapper from '../components/wrappers/reference.vue'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; @@ -8,6 +8,21 @@ const getAnchor = (element) => { return element.querySelector('a'); }; +const findReference = (editor, reference) => { + let position; + + editor.view.state.doc.descendants((descendant, pos) => { + if (descendant.isText && descendant.text.includes(reference)) { + position = pos + descendant.text.indexOf(reference); + return false; + } + + return true; + }); + + return position; +}; + export default Node.create({ name: 'reference', @@ -17,6 +32,12 @@ export default Node.create({ atom: true, + addOptions() { + return { + assetResolver: null, + }; + }, + addAttributes() { return { className: { @@ -42,6 +63,54 @@ export default Node.create({ }; }, + addInputRules() { + const { editor } = this; + const { assetResolver } = this.options; + const referenceInputRegex = /(?:^|\s)([\w/]*([!&#])\d+(\+?s?))(?:\s|\n)/m; + const referenceTypes = { + '#': 'issue', + '!': 'merge_request', + '&': 'epic', + }; + + return [ + new InputRule({ + find: referenceInputRegex, + handler: async ({ match }) => { + const [, referenceId, referenceSymbol, expansionType] = match; + const referenceType = referenceTypes[referenceSymbol]; + + const { + href, + text, + expandedText, + fullyExpandedText, + } = await assetResolver.resolveReference(referenceId); + + if (!text) return; + + let referenceText = text; + if (expansionType === '+') referenceText = expandedText; + if (expansionType === '+s') referenceText = fullyExpandedText; + + const position = findReference(editor, referenceId); + if (!position) return; + + editor.view.dispatch( + editor.state.tr.replaceWith(position, position + referenceId.length, [ + this.type.create({ + referenceType, + originalText: referenceId, + href, + text: referenceText, + }), + ]), + ); + }, + }), + ]; + }, + parseHTML() { return [ { @@ -51,6 +120,19 @@ export default Node.create({ ]; }, + renderHTML({ node }) { + return [ + 'gl-reference', + { + 'data-reference-type': node.attrs.referenceType, + 'data-original-text': node.attrs.originalText, + href: node.attrs.href, + text: node.attrs.text, + }, + node.attrs.text, + ]; + }, + addNodeView() { return new VueNodeViewRenderer(ReferenceWrapper); }, diff --git a/app/assets/javascripts/content_editor/extensions/reference_label.js b/app/assets/javascripts/content_editor/extensions/reference_label.js index 0441f8ef8d2..9cd55a0f87c 100644 --- a/app/assets/javascripts/content_editor/extensions/reference_label.js +++ b/app/assets/javascripts/content_editor/extensions/reference_label.js @@ -4,7 +4,7 @@ import LabelWrapper from '../components/wrappers/reference_label.vue'; import Reference from './reference'; export default Reference.extend({ - name: 'reference_label', + name: 'referenceLabel', addAttributes() { return { @@ -20,11 +20,21 @@ export default Reference.extend({ }, color: { default: null, - parseHTML: (element) => element.querySelector('.gl-label-text').style.backgroundColor, + parseHTML: (element) => { + let color = element.querySelector('.gl-label-text').style.backgroundColor; + if (!color || color.startsWith('var')) + color = element.style.getPropertyValue('--label-background-color'); + + return color; + }, }, }; }, + addInputRules() { + return []; + }, + parseHTML() { return [{ tag: 'span.gl-label' }]; }, diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js index e72b5c7365c..f29222a5289 100644 --- a/app/assets/javascripts/content_editor/extensions/suggestions.js +++ b/app/assets/javascripts/content_editor/extensions/suggestions.js @@ -162,7 +162,7 @@ export default Node.create({ editor: this.editor, char: '~', dataSource: this.options.autocompleteDataSources.labels, - nodeType: 'reference_label', + nodeType: 'referenceLabel', nodeProps: { referenceType: 'label', }, |