From ee664acb356f8123f4f6b00b73c1e1cf0866c7fb Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Thu, 20 Oct 2022 09:40:42 +0000 Subject: Add latest changes from gitlab-org/gitlab@15-5-stable-ee --- .../content_editor/extensions/diagram.js | 5 +- .../extensions/external_keydown_handler.js | 38 ++++ .../content_editor/extensions/heading.js | 16 +- .../content_editor/extensions/reference.js | 14 +- .../content_editor/extensions/reference_label.js | 35 ++++ .../content_editor/extensions/suggestions.js | 227 +++++++++++++++++++++ 6 files changed, 320 insertions(+), 15 deletions(-) create mode 100644 app/assets/javascripts/content_editor/extensions/external_keydown_handler.js create mode 100644 app/assets/javascripts/content_editor/extensions/reference_label.js create mode 100644 app/assets/javascripts/content_editor/extensions/suggestions.js (limited to 'app/assets/javascripts/content_editor/extensions') diff --git a/app/assets/javascripts/content_editor/extensions/diagram.js b/app/assets/javascripts/content_editor/extensions/diagram.js index d9983b8c1c5..7c4a56468eb 100644 --- a/app/assets/javascripts/content_editor/extensions/diagram.js +++ b/app/assets/javascripts/content_editor/extensions/diagram.js @@ -1,5 +1,6 @@ import { lowlight } from 'lowlight/lib/core'; import { textblockTypeInputRule } from '@tiptap/core'; +import { base64DecodeUnicode } from '~/lib/utils/text_utility'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; import languageLoader from '../services/code_block_language_loader'; import CodeBlockHighlight from './code_block_highlight'; @@ -45,7 +46,9 @@ export default CodeBlockHighlight.extend({ priority: PARSE_HTML_PRIORITY_HIGHEST, tag: '[data-diagram]', getContent(element, schema) { - const source = atob(element.dataset.diagramSrc.replace('data:text/plain;base64,', '')); + const source = base64DecodeUnicode( + element.dataset.diagramSrc.replace('data:text/plain;base64,', ''), + ); const node = schema.node('paragraph', {}, [schema.text(source)]); return node.content; }, diff --git a/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js b/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js new file mode 100644 index 00000000000..e940614083e --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js @@ -0,0 +1,38 @@ +import { Extension } from '@tiptap/core'; +import { Plugin, PluginKey } from 'prosemirror-state'; +import { KEYDOWN_EVENT } from '../constants'; + +/** + * This extension bubbles up the keydown event, captured by ProseMirror in the + * contenteditale element, to the presentation layer implemented in vue. + * + * The purpose of this mechanism is allowing clients of the + * content editor to attach keyboard shortcuts for behavior outside + * of the Content Editor’s boundaries, i.e. submitting a form to save changes. + */ +export default Extension.create({ + name: 'keyboardShortcut', + addOptions() { + return { + eventHub: null, + }; + }, + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey('keyboardShortcut'), + props: { + handleKeyDown: (_, event) => { + const { + options: { eventHub }, + } = this; + + eventHub.$emit(KEYDOWN_EVENT, event); + + return false; + }, + }, + }), + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/heading.js b/app/assets/javascripts/content_editor/extensions/heading.js index 48303cdeca4..41903162ba5 100644 --- a/app/assets/javascripts/content_editor/extensions/heading.js +++ b/app/assets/javascripts/content_editor/extensions/heading.js @@ -1 +1,15 @@ -export { Heading as default } from '@tiptap/extension-heading'; +import { Heading } from '@tiptap/extension-heading'; +import { textblockTypeInputRule } from '@tiptap/core'; + +export default Heading.extend({ + addInputRules() { + return this.options.levels.map((level) => { + return textblockTypeInputRule({ + // make sure heading regex doesn't conflict with issue references + find: new RegExp(`^(#{1,${level}})[ \t]$`), + type: this.type, + getAttributes: { level }, + }); + }); + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js index 5e459e65de2..707beaf1231 100644 --- a/app/assets/javascripts/content_editor/extensions/reference.js +++ b/app/assets/javascripts/content_editor/extensions/reference.js @@ -46,22 +46,10 @@ export default Node.create({ tag: 'a.gfm:not([data-link=true])', priority: PARSE_HTML_PRIORITY_HIGHEST, }, - { - tag: 'span.gl-label', - }, ]; }, renderHTML({ node }) { - return [ - 'a', - { - class: node.attrs.className, - href: node.attrs.href, - 'data-reference-type': node.attrs.referenceType, - 'data-original': node.attrs.originalText, - }, - node.attrs.text, - ]; + return ['a', { href: '#' }, node.attrs.text]; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/reference_label.js b/app/assets/javascripts/content_editor/extensions/reference_label.js new file mode 100644 index 00000000000..716e191c3d5 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/reference_label.js @@ -0,0 +1,35 @@ +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import { SCOPED_LABEL_DELIMITER } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; +import LabelWrapper from '../components/wrappers/label.vue'; +import Reference from './reference'; + +export default Reference.extend({ + name: 'reference_label', + + addAttributes() { + return { + ...this.parent(), + text: { + default: null, + parseHTML: (element) => { + const text = element.querySelector('.gl-label-text').textContent; + const scopedText = element.querySelector('.gl-label-text-scoped')?.textContent; + if (!scopedText) return text; + return `${text}${SCOPED_LABEL_DELIMITER}${scopedText}`; + }, + }, + color: { + default: null, + parseHTML: (element) => element.querySelector('.gl-label-text').style.backgroundColor, + }, + }; + }, + + parseHTML() { + return [{ tag: 'span.gl-label' }]; + }, + + addNodeView() { + return new VueNodeViewRenderer(LabelWrapper); + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js new file mode 100644 index 00000000000..8976b9cafee --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/suggestions.js @@ -0,0 +1,227 @@ +import { Node } from '@tiptap/core'; +import { VueRenderer } from '@tiptap/vue-2'; +import tippy from 'tippy.js'; +import Suggestion from '@tiptap/suggestion'; +import { PluginKey } from 'prosemirror-state'; +import { isFunction, uniqueId, memoize } from 'lodash'; +import axios from '~/lib/utils/axios_utils'; +import { initEmojiMap, getAllEmoji } from '~/emoji'; +import SuggestionsDropdown from '../components/suggestions_dropdown.vue'; + +function find(haystack, needle) { + return String(haystack).toLocaleLowerCase().includes(String(needle).toLocaleLowerCase()); +} + +function createSuggestionPlugin({ + editor, + char, + dataSource, + search, + limit = Infinity, + nodeType, + nodeProps = {}, +}) { + const fetchData = memoize( + isFunction(dataSource) ? dataSource : async () => (await axios.get(dataSource)).data, + ); + + return Suggestion({ + editor, + char, + pluginKey: new PluginKey(uniqueId('suggestions')), + + command: ({ editor: tiptapEditor, range, props }) => { + tiptapEditor + .chain() + .focus() + .insertContentAt(range, [ + { type: nodeType, attrs: props }, + { type: 'text', text: ' ' }, + ]) + .run(); + }, + + async items({ query }) { + if (!dataSource) return []; + + try { + const items = await fetchData(); + + return items.filter(search(query)).slice(0, limit); + } catch { + return []; + } + }, + + render: () => { + let component; + let popup; + + return { + onStart: (props) => { + component = new VueRenderer(SuggestionsDropdown, { + propsData: { + ...props, + char, + nodeType, + nodeProps, + }, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }); + }, + + onUpdate(props) { + component?.updateProps(props); + + if (!props.clientRect) { + return; + } + + popup?.[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup?.[0].hide(); + + return true; + } + + return component?.ref?.onKeyDown(props); + }, + + onExit() { + popup?.[0].destroy(); + component?.destroy(); + }, + }; + }, + }); +} + +export default Node.create({ + name: 'suggestions', + + addProseMirrorPlugins() { + return [ + createSuggestionPlugin({ + editor: this.editor, + char: '@', + dataSource: gl.GfmAutoComplete?.dataSources.members, + nodeType: 'reference', + nodeProps: { + referenceType: 'user', + }, + search: (query) => ({ name, username }) => find(name, query) || find(username, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '#', + dataSource: gl.GfmAutoComplete?.dataSources.issues, + nodeType: 'reference', + nodeProps: { + referenceType: 'issue', + }, + search: (query) => ({ iid, title }) => find(iid, query) || find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '$', + dataSource: gl.GfmAutoComplete?.dataSources.snippets, + nodeType: 'reference', + nodeProps: { + referenceType: 'snippet', + }, + search: (query) => ({ id, title }) => find(id, query) || find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '~', + dataSource: gl.GfmAutoComplete?.dataSources.labels, + nodeType: 'reference_label', + nodeProps: { + referenceType: 'label', + }, + search: (query) => ({ title }) => find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '&', + dataSource: gl.GfmAutoComplete?.dataSources.epics, + nodeType: 'reference', + nodeProps: { + referenceType: 'epic', + }, + search: (query) => ({ iid, title }) => find(iid, query) || find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '[vulnerability:', + dataSource: gl.GfmAutoComplete?.dataSources.vulnerabilities, + nodeType: 'reference', + nodeProps: { + referenceType: 'vulnerability', + }, + search: (query) => ({ id, title }) => find(id, query) || find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '!', + dataSource: gl.GfmAutoComplete?.dataSources.mergeRequests, + nodeType: 'reference', + nodeProps: { + referenceType: 'merge_request', + }, + search: (query) => ({ iid, title }) => find(iid, query) || find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '%', + dataSource: gl.GfmAutoComplete?.dataSources.milestones, + nodeType: 'reference', + nodeProps: { + referenceType: 'milestone', + }, + search: (query) => ({ iid, title }) => find(iid, query) || find(title, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: '/', + dataSource: gl.GfmAutoComplete?.dataSources.commands, + nodeType: 'reference', + nodeProps: { + referenceType: 'command', + }, + search: (query) => ({ name }) => find(name, query), + }), + createSuggestionPlugin({ + editor: this.editor, + char: ':', + dataSource: () => Object.values(getAllEmoji()), + nodeType: 'emoji', + search: (query) => ({ d, name }) => find(d, query) || find(name, query), + limit: 10, + }), + ]; + }, + + onCreate() { + initEmojiMap(); + }, +}); -- cgit v1.2.3