diff options
Diffstat (limited to 'app/assets/javascripts/content_editor/extensions/suggestions.js')
-rw-r--r-- | app/assets/javascripts/content_editor/extensions/suggestions.js | 227 |
1 files changed, 227 insertions, 0 deletions
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(); + }, +}); |