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/extensions
parent62f7d5c5b69180e82ae8196b7b429eeffc8e7b4f (diff)
Add latest changes from gitlab-org/gitlab@15-5-stable-eev15.5.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor/extensions')
-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
6 files changed, 320 insertions, 15 deletions
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();
+ },
+});