Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-10-20 12:40:42 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-10-20 12:40:42 +0300
commitee664acb356f8123f4f6b00b73c1e1cf0866c7fb (patch)
treef8479f94a28f66654c6a4f6fb99bad6b4e86a40e /app/assets/javascripts/content_editor
parent62f7d5c5b69180e82ae8196b7b429eeffc8e7b4f (diff)
Add latest changes from gitlab-org/gitlab@15-5-stable-eev15.5.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor')
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue20
-rw-r--r--app/assets/javascripts/content_editor/components/editor_state_observer.vue4
-rw-r--r--app/assets/javascripts/content_editor/components/suggestions_dropdown.vue264
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/label.vue34
-rw-r--r--app/assets/javascripts/content_editor/constants/index.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/diagram.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/external_keydown_handler.js38
-rw-r--r--app/assets/javascripts/content_editor/extensions/heading.js16
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference.js14
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference_label.js35
-rw-r--r--app/assets/javascripts/content_editor/extensions/suggestions.js227
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js6
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js7
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js5
14 files changed, 655 insertions, 26 deletions
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index 659c447e861..22381377389 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -3,7 +3,7 @@ import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
import { __ } from '~/locale';
import { VARIANT_DANGER } from '~/flash';
import { createContentEditor } from '../services/create_content_editor';
-import { ALERT_EVENT } from '../constants';
+import { ALERT_EVENT, TIPTAP_AUTOFOCUS_OPTIONS } from '../constants';
import ContentEditorAlert from './content_editor_alert.vue';
import ContentEditorProvider from './content_editor_provider.vue';
import EditorStateObserver from './editor_state_observer.vue';
@@ -51,6 +51,12 @@ export default {
required: false,
default: '',
},
+ autofocus: {
+ type: [String, Boolean],
+ required: false,
+ default: false,
+ validator: (autofocus) => TIPTAP_AUTOFOCUS_OPTIONS.includes(autofocus),
+ },
},
data() {
return {
@@ -67,7 +73,7 @@ export default {
},
},
created() {
- const { renderMarkdown, uploadsPath, extensions, serializerConfig } = this;
+ const { renderMarkdown, uploadsPath, extensions, serializerConfig, autofocus } = this;
// This is a non-reactive attribute intentionally since this is a complex object.
this.contentEditor = createContentEditor({
@@ -75,6 +81,9 @@ export default {
uploadsPath,
extensions,
serializerConfig,
+ tiptapOptions: {
+ autofocus,
+ },
});
},
mounted() {
@@ -141,7 +150,12 @@ export default {
<template>
<content-editor-provider :content-editor="contentEditor">
<div>
- <editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" />
+ <editor-state-observer
+ @docUpdate="notifyChange"
+ @focus="focus"
+ @blur="blur"
+ @keydown="$emit('keydown', $event)"
+ />
<content-editor-alert />
<div
data-testid="content-editor"
diff --git a/app/assets/javascripts/content_editor/components/editor_state_observer.vue b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
index 41c3771bf41..ccb46e3b593 100644
--- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue
+++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
@@ -1,6 +1,6 @@
<script>
import { debounce } from 'lodash';
-import { ALERT_EVENT } from '../constants';
+import { ALERT_EVENT, KEYDOWN_EVENT } from '../constants';
export const tiptapToComponentMap = {
update: 'docUpdate',
@@ -10,7 +10,7 @@ export const tiptapToComponentMap = {
blur: 'blur',
};
-export const eventHubEvents = [ALERT_EVENT];
+export const eventHubEvents = [ALERT_EVENT, KEYDOWN_EVENT];
const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName];
diff --git a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue
new file mode 100644
index 00000000000..987b7044272
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue
@@ -0,0 +1,264 @@
+<script>
+import { GlDropdownItem, GlAvatarLabeled } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlDropdownItem,
+ GlAvatarLabeled,
+ },
+
+ props: {
+ char: {
+ type: String,
+ required: true,
+ },
+
+ nodeType: {
+ type: String,
+ required: true,
+ },
+
+ nodeProps: {
+ type: Object,
+ required: true,
+ },
+
+ items: {
+ type: Array,
+ required: true,
+ },
+
+ command: {
+ type: Function,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ selectedIndex: 0,
+ };
+ },
+
+ computed: {
+ isReference() {
+ return this.nodeType.startsWith('reference');
+ },
+
+ isCommand() {
+ return this.isReference && this.nodeProps.referenceType === 'command';
+ },
+
+ isUser() {
+ return this.isReference && this.nodeProps.referenceType === 'user';
+ },
+
+ isIssue() {
+ return this.isReference && this.nodeProps.referenceType === 'issue';
+ },
+
+ isLabel() {
+ return this.isReference && this.nodeProps.referenceType === 'label';
+ },
+
+ isEpic() {
+ return this.isReference && this.nodeProps.referenceType === 'epic';
+ },
+
+ isSnippet() {
+ return this.isReference && this.nodeProps.referenceType === 'snippet';
+ },
+
+ isVulnerability() {
+ return this.isReference && this.nodeProps.referenceType === 'vulnerability';
+ },
+
+ isMergeRequest() {
+ return this.isReference && this.nodeProps.referenceType === 'merge_request';
+ },
+
+ isMilestone() {
+ return this.isReference && this.nodeProps.referenceType === 'milestone';
+ },
+
+ isEmoji() {
+ return this.nodeType === 'emoji';
+ },
+ },
+
+ watch: {
+ items() {
+ this.selectedIndex = 0;
+ },
+ },
+
+ methods: {
+ getText(item) {
+ if (this.isEmoji) return item.e;
+
+ switch (this.isReference && this.nodeProps.referenceType) {
+ case 'user':
+ return `${this.char}${item.username}`;
+ case 'issue':
+ case 'merge_request':
+ return `${this.char}${item.iid}`;
+ case 'snippet':
+ return `${this.char}${item.id}`;
+ case 'milestone':
+ return `${this.char}${item.title}`;
+ case 'label':
+ return item.title;
+ case 'command':
+ return `${this.char}${item.name}`;
+ case 'epic':
+ return item.reference;
+ case 'vulnerability':
+ return `[vulnerability:${item.id}]`;
+ default:
+ return '';
+ }
+ },
+
+ getProps(item) {
+ const props = {};
+
+ if (this.isEmoji) {
+ Object.assign(props, {
+ name: item.name,
+ unicodeVersion: item.u,
+ title: item.d,
+ moji: item.e,
+ });
+ }
+
+ if (this.isLabel || this.isMilestone) {
+ Object.assign(props, {
+ originalText: `${this.char}${
+ /\W/.test(item.title) ? JSON.stringify(item.title) : item.title
+ }`,
+ });
+ }
+
+ if (this.isLabel) {
+ Object.assign(props, {
+ text: item.title,
+ color: item.color,
+ });
+ }
+
+ Object.assign(props, this.nodeProps);
+
+ return props;
+ },
+
+ onKeyDown({ event }) {
+ if (event.key === 'ArrowUp') {
+ this.upHandler();
+ return true;
+ }
+
+ if (event.key === 'ArrowDown') {
+ this.downHandler();
+ return true;
+ }
+
+ if (event.key === 'Enter') {
+ this.enterHandler();
+ return true;
+ }
+
+ return false;
+ },
+
+ upHandler() {
+ this.selectedIndex = (this.selectedIndex + this.items.length - 1) % this.items.length;
+ },
+
+ downHandler() {
+ this.selectedIndex = (this.selectedIndex + 1) % this.items.length;
+ },
+
+ enterHandler() {
+ this.selectItem(this.selectedIndex);
+ },
+
+ selectItem(index) {
+ const item = this.items[index];
+
+ if (item) {
+ this.command({
+ text: this.getText(item),
+ ...this.getProps(item),
+ });
+ }
+ },
+
+ avatarSubLabel(item) {
+ return item.count ? `${item.name} (${item.count})` : item.name;
+ },
+ },
+};
+</script>
+
+<template>
+ <ul
+ :class="{ show: items.length > 0 }"
+ class="gl-new-dropdown dropdown-menu gl-relative"
+ data-testid="content-editor-suggestions-dropdown"
+ >
+ <div class="gl-new-dropdown-inner gl-overflow-y-auto">
+ <gl-dropdown-item
+ v-for="(item, index) in items"
+ :key="index"
+ :class="{ 'gl-bg-gray-50': index === selectedIndex }"
+ @click="selectItem(index)"
+ >
+ <gl-avatar-labeled
+ v-if="isUser"
+ :label="item.username"
+ :sub-label="avatarSubLabel(item)"
+ :src="item.avatar_url"
+ :entity-name="item.username"
+ :shape="item.type === 'Group' ? 'rect' : 'circle'"
+ :size="32"
+ />
+ <span v-if="isIssue || isMergeRequest">
+ <small>{{ item.iid }}</small>
+ {{ item.title }}
+ </span>
+ <span v-if="isVulnerability || isSnippet">
+ <small>{{ item.id }}</small>
+ {{ item.title }}
+ </span>
+ <span v-if="isEpic">
+ <small>{{ item.reference }}</small>
+ {{ item.title }}
+ </span>
+ <span v-if="isMilestone">
+ {{ item.title }}
+ </span>
+ <span v-if="isLabel" class="gl-display-flex gl-align-items-center">
+ <span
+ data-testid="label-color-box"
+ class="gl-rounded-base gl-display-block gl-w-5 gl-h-5 gl-mr-3"
+ :style="{ backgroundColor: item.color }"
+ ></span>
+ {{ item.title }}
+ </span>
+ <span v-if="isCommand">
+ /{{ item.name }} <small> {{ item.params[0] }} </small><br />
+ <em>
+ <small> {{ item.description }} </small>
+ </em>
+ </span>
+ <div v-if="isEmoji" class="gl-display-flex gl-align-items-center">
+ <div class="gl-pr-4 gl-font-lg">{{ item.e }}</div>
+ <div class="gl-flex-grow-1">
+ {{ item.name }}<br />
+ <small>{{ item.d }}</small>
+ </div>
+ </div>
+ </gl-dropdown-item>
+ </div>
+ </ul>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/label.vue b/app/assets/javascripts/content_editor/components/wrappers/label.vue
new file mode 100644
index 00000000000..4206c866032
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/label.vue
@@ -0,0 +1,34 @@
+<script>
+import { NodeViewWrapper } from '@tiptap/vue-2';
+import { GlLabel } from '@gitlab/ui';
+import { isScopedLabel } from '~/lib/utils/common_utils';
+
+export default {
+ name: 'DetailsWrapper',
+ components: {
+ NodeViewWrapper,
+ GlLabel,
+ },
+ props: {
+ node: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ isScopedLabel() {
+ return isScopedLabel({ title: this.node.attrs.originalText });
+ },
+ },
+};
+</script>
+<template>
+ <node-view-wrapper class="gl-display-inline-block">
+ <gl-label
+ size="sm"
+ :scoped="isScopedLabel"
+ :background-color="node.attrs.color"
+ :title="node.attrs.text"
+ />
+ </node-view-wrapper>
+</template>
diff --git a/app/assets/javascripts/content_editor/constants/index.js b/app/assets/javascripts/content_editor/constants/index.js
index 564cca23afa..14862727811 100644
--- a/app/assets/javascripts/content_editor/constants/index.js
+++ b/app/assets/javascripts/content_editor/constants/index.js
@@ -42,10 +42,8 @@ export const TEXT_STYLE_DROPDOWN_ITEMS = [
},
];
-export const LOADING_CONTENT_EVENT = 'loading';
-export const LOADING_SUCCESS_EVENT = 'loadingSuccess';
-export const LOADING_ERROR_EVENT = 'loadingError';
export const ALERT_EVENT = 'alert';
+export const KEYDOWN_EVENT = 'keydown';
export const PARSE_HTML_PRIORITY_LOWEST = 1;
export const PARSE_HTML_PRIORITY_DEFAULT = 50;
@@ -66,3 +64,5 @@ export const SAFE_VIDEO_EXT = ['mp4', 'm4v', 'mov', 'webm', 'ogv'];
export const SAFE_AUDIO_EXT = ['mp3', 'oga', 'ogg', 'spx', 'wav'];
export const DIAGRAM_LANGUAGES = ['plantuml', 'mermaid'];
+
+export const TIPTAP_AUTOFOCUS_OPTIONS = [true, false, 'start', 'end', 'all'];
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();
+ },
+});
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
index 5ed7f3dc23d..0d78390e769 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -18,6 +18,7 @@ import Diagram from '../extensions/diagram';
import Document from '../extensions/document';
import Dropcursor from '../extensions/dropcursor';
import Emoji from '../extensions/emoji';
+import ExternalKeydownHandler from '../extensions/external_keydown_handler';
import Figure from '../extensions/figure';
import FigureCaption from '../extensions/figure_caption';
import FootnoteDefinition from '../extensions/footnote_definition';
@@ -42,10 +43,12 @@ import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
import PasteMarkdown from '../extensions/paste_markdown';
import Reference from '../extensions/reference';
+import ReferenceLabel from '../extensions/reference_label';
import ReferenceDefinition from '../extensions/reference_definition';
import Sourcemap from '../extensions/sourcemap';
import Strike from '../extensions/strike';
import Subscript from '../extensions/subscript';
+import Suggestions from '../extensions/suggestions';
import Superscript from '../extensions/superscript';
import Table from '../extensions/table';
import TableCell from '../extensions/table_cell';
@@ -121,6 +124,7 @@ export const createContentEditor = ({
Image,
InlineDiff,
Italic,
+ ExternalKeydownHandler.configure({ eventHub }),
Link,
ListItem,
Loading,
@@ -129,10 +133,12 @@ export const createContentEditor = ({
Paragraph,
PasteMarkdown.configure({ eventHub, renderMarkdown }),
Reference,
+ ReferenceLabel,
ReferenceDefinition,
Sourcemap,
Strike,
Subscript,
+ Suggestions,
Superscript,
TableCell,
TableHeader,
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index ba0cad6c91c..c990f6cf0b3 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -33,6 +33,7 @@ import MathInline from '../extensions/math_inline';
import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
import Reference from '../extensions/reference';
+import ReferenceLabel from '../extensions/reference_label';
import ReferenceDefinition from '../extensions/reference_definition';
import Strike from '../extensions/strike';
import Subscript from '../extensions/subscript';
@@ -61,6 +62,7 @@ import {
renderHTMLNode,
renderContent,
renderBulletList,
+ renderReference,
preserveUnchanged,
bold,
italic,
@@ -184,9 +186,8 @@ const defaultSerializerConfig = {
[ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item),
[OrderedList.name]: preserveUnchanged(renderOrderedList),
[Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph),
- [Reference.name]: (state, node) => {
- state.write(node.attrs.originalText || node.attrs.text);
- },
+ [Reference.name]: renderReference,
+ [ReferenceLabel.name]: renderReference,
[ReferenceDefinition.name]: preserveUnchanged({
render: (state, node, parent, index, same, sourceMarkdown) => {
const nextSibling = parent.maybeChild(index + 1);
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 41114571df7..5c0cb21075a 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -280,6 +280,7 @@ export function renderTableRow(state, node) {
}
export function renderTable(state, node) {
+ state.flushClose();
setIsInBlockTable(node, shouldRenderHTMLTable(node));
if (isInBlockTable(node)) renderTagOpen(state, 'table');
@@ -422,6 +423,10 @@ export function renderOrderedList(state, node) {
});
}
+export function renderReference(state, node) {
+ state.write(node.attrs.originalText || node.attrs.text);
+}
+
const generateBoldTags = (wrapTagName = openTag) => {
return (_, mark) => {
const type = /^(\*\*|__|<strong|<b).*/.exec(mark.attrs.sourceMarkdown)?.[1];