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
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-02-23 09:09:23 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-02-23 09:09:23 +0300
commit8b4276f873461953ee5a1fc46f084779f5847e3a (patch)
treecc3435570e15234453e711c2ddcc9b0895d87eb4 /app
parentf34b26bb882947bcc1126de19fa55eb8763af32e (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/behaviors/markdown/copy_as_gfm.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/schema.js2
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue2
-rw-r--r--app/assets/javascripts/content_editor/extensions/attachment.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/color_chip.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/external_keydown_handler.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/paste_markdown.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/suggestions.js2
-rw-r--r--app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js2
-rw-r--r--app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js2
-rw-r--r--app/assets/javascripts/drawio/constants.js13
-rw-r--r--app/assets/javascripts/drawio/drawio_editor.js274
-rw-r--r--app/assets/javascripts/drawio/markdown_field_editor_facade.js71
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js48
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/drawio_toolbar_button.vue48
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue23
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue6
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue4
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue3
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_links.query.graphql39
-rw-r--r--app/controllers/concerns/wiki_actions.rb9
23 files changed, 520 insertions, 50 deletions
diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
index 19ebab36481..36317444af9 100644
--- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
@@ -164,7 +164,7 @@ export class CopyAsGFM {
static nodeToGFM(node) {
return Promise.all([
- import(/* webpackChunkName: 'gfm_copy_extra' */ 'prosemirror-model'),
+ import(/* webpackChunkName: 'gfm_copy_extra' */ '@tiptap/pm/model'),
import(/* webpackChunkName: 'gfm_copy_extra' */ './schema'),
import(/* webpackChunkName: 'gfm_copy_extra' */ './serializer'),
])
diff --git a/app/assets/javascripts/behaviors/markdown/schema.js b/app/assets/javascripts/behaviors/markdown/schema.js
index 1b0f46ff4cb..31bab23c8b0 100644
--- a/app/assets/javascripts/behaviors/markdown/schema.js
+++ b/app/assets/javascripts/behaviors/markdown/schema.js
@@ -1,4 +1,4 @@
-import { Schema } from 'prosemirror-model';
+import { Schema } from '@tiptap/pm/model';
import editorExtensions from './editor_extensions';
const nodes = editorExtensions.nodes.reduce(
diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
index 6456540a0dd..4d948f4ec05 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
@@ -1,7 +1,7 @@
<script>
import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
-import { selectedRect as getSelectedRect } from '@_ueberdosis/prosemirror-tables';
+import { selectedRect as getSelectedRect } from '@tiptap/pm/tables';
import { __ } from '~/locale';
const TABLE_CELL_HEADER = 'th';
diff --git a/app/assets/javascripts/content_editor/extensions/attachment.js b/app/assets/javascripts/content_editor/extensions/attachment.js
index 9634730f637..0d5b8e56a6c 100644
--- a/app/assets/javascripts/content_editor/extensions/attachment.js
+++ b/app/assets/javascripts/content_editor/extensions/attachment.js
@@ -1,5 +1,5 @@
import { Extension } from '@tiptap/core';
-import { Plugin, PluginKey } from 'prosemirror-state';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
import { handleFileEvent } from '../services/upload_helpers';
export default Extension.create({
diff --git a/app/assets/javascripts/content_editor/extensions/color_chip.js b/app/assets/javascripts/content_editor/extensions/color_chip.js
index deb5029a1f0..c49b541bbaf 100644
--- a/app/assets/javascripts/content_editor/extensions/color_chip.js
+++ b/app/assets/javascripts/content_editor/extensions/color_chip.js
@@ -1,6 +1,6 @@
import { Node } from '@tiptap/core';
-import { Plugin, PluginKey } from 'prosemirror-state';
-import { Decoration, DecorationSet } from 'prosemirror-view';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
+import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { isValidColorExpression } from '~/lib/utils/color_utils';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
diff --git a/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js b/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js
index e940614083e..e48100c15a7 100644
--- a/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js
+++ b/app/assets/javascripts/content_editor/extensions/external_keydown_handler.js
@@ -1,5 +1,5 @@
import { Extension } from '@tiptap/core';
-import { Plugin, PluginKey } from 'prosemirror-state';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
import { KEYDOWN_EVENT } from '../constants';
/**
diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
index 848c4c12a9a..fa465160598 100644
--- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js
+++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
@@ -1,5 +1,5 @@
import { Extension } from '@tiptap/core';
-import { Plugin, PluginKey } from 'prosemirror-state';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
import { __ } from '~/locale';
import { VARIANT_DANGER } from '~/flash';
import createMarkdownDeserializer from '../services/gl_api_markdown_deserializer';
diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js
index a9628c78add..eb53a3a61b3 100644
--- a/app/assets/javascripts/content_editor/extensions/suggestions.js
+++ b/app/assets/javascripts/content_editor/extensions/suggestions.js
@@ -2,7 +2,7 @@ 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 { PluginKey } from '@tiptap/pm/state';
import { isFunction, uniqueId, memoize } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { initEmojiMap, getAllEmoji } from '~/emoji';
diff --git a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
index 796dc06ad93..e65644d2c5c 100644
--- a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
+++ b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
@@ -1,4 +1,4 @@
-import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
+import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model';
import { replaceCommentsWith } from '~/lib/utils/dom_utils';
export default ({ render }) => {
diff --git a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
index 28a50adca6b..c8972515c25 100644
--- a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
+++ b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
@@ -19,7 +19,7 @@
* visit-parents documentation: https://github.com/syntax-tree/unist-util-visit-parents
*/
-import { Mark } from 'prosemirror-model';
+import { Mark } from '@tiptap/pm/model';
import { visitParents, SKIP } from 'unist-util-visit-parents';
import { isFunction, isString, noop, mapValues } from 'lodash';
diff --git a/app/assets/javascripts/drawio/constants.js b/app/assets/javascripts/drawio/constants.js
new file mode 100644
index 00000000000..a5f1d1e71d2
--- /dev/null
+++ b/app/assets/javascripts/drawio/constants.js
@@ -0,0 +1,13 @@
+/*
+ * TODO: Make this URL configurable
+ */
+export const DRAWIO_EDITOR_URL =
+ 'https://embed.diagrams.net/?ui=sketch&noSaveBtn=1&saveAndExit=1&keepmodified=1&spin=1&embed=1&libraries=1&configure=1&proto=json&toSvg=1'; // TODO Make it configurable
+
+export const DRAWIO_FRAME_ID = 'drawio-frame';
+
+export const DARK_BACKGROUND_COLOR = '#202020';
+
+export const DIAGRAM_BACKGROUND_COLOR = '#ffffff';
+
+export const DRAWIO_IFRAME_TIMEOUT = 4000;
diff --git a/app/assets/javascripts/drawio/drawio_editor.js b/app/assets/javascripts/drawio/drawio_editor.js
new file mode 100644
index 00000000000..06e7f536426
--- /dev/null
+++ b/app/assets/javascripts/drawio/drawio_editor.js
@@ -0,0 +1,274 @@
+import _ from 'lodash';
+import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { darkModeEnabled } from '~/lib/utils/color_utils';
+import { __ } from '~/locale';
+import { setAttributes } from '~/lib/utils/dom_utils';
+import {
+ DARK_BACKGROUND_COLOR,
+ DRAWIO_EDITOR_URL,
+ DRAWIO_FRAME_ID,
+ DIAGRAM_BACKGROUND_COLOR,
+ DRAWIO_IFRAME_TIMEOUT,
+} from './constants';
+
+function updateDrawioEditorState(drawIOEditorState, data) {
+ Object.assign(drawIOEditorState, data);
+}
+
+function postMessageToDrawioEditor(drawIOEditorState, message) {
+ const { origin } = new URL(DRAWIO_EDITOR_URL);
+
+ drawIOEditorState.iframe.contentWindow.postMessage(JSON.stringify(message), origin);
+}
+
+function disposeDrawioEditor(drawIOEditorState) {
+ drawIOEditorState.disposeEventListener();
+ drawIOEditorState.iframe.remove();
+}
+
+function getSvg(data) {
+ const svgPath = atob(data.substring(data.indexOf(',') + 1));
+
+ return `<?xml version="1.0" encoding="UTF-8"?>\n\
+ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n\
+ ${svgPath}`;
+}
+
+async function saveDiagram(drawIOEditorState, editorFacade) {
+ const { newDiagram, diagramMarkdown, filename, diagramSvg } = drawIOEditorState;
+ const filenameWithExt = filename.endsWith('.drawio.svg') ? filename : `${filename}.drawio.svg`;
+
+ postMessageToDrawioEditor(drawIOEditorState, {
+ action: 'spinner',
+ show: true,
+ messageKey: 'saving',
+ });
+
+ try {
+ const uploadResults = await editorFacade.uploadDiagram({
+ filename: filenameWithExt,
+ diagramSvg,
+ });
+
+ if (newDiagram) {
+ editorFacade.insertDiagram({ uploadResults });
+ } else {
+ editorFacade.updateDiagram({ diagramMarkdown, uploadResults });
+ }
+
+ createAlert({
+ message: __('Diagram saved successfully.'),
+ variant: VARIANT_SUCCESS,
+ fadeTransition: true,
+ });
+ setTimeout(() => disposeDrawioEditor(drawIOEditorState), 10);
+ } catch {
+ postMessageToDrawioEditor(drawIOEditorState, { action: 'spinner', show: false });
+ postMessageToDrawioEditor(drawIOEditorState, {
+ action: 'dialog',
+ titleKey: 'error',
+ modified: true,
+ buttonKey: 'close',
+ messageKey: 'errorSavingFile',
+ });
+ }
+}
+
+function promptName(drawIOEditorState, name, errKey) {
+ postMessageToDrawioEditor(drawIOEditorState, {
+ action: 'prompt',
+ titleKey: 'filename',
+ okKey: 'save',
+ defaultValue: name || '',
+ });
+
+ if (errKey !== null) {
+ postMessageToDrawioEditor(drawIOEditorState, {
+ action: 'dialog',
+ titleKey: 'error',
+ messageKey: errKey,
+ buttonKey: 'ok',
+ });
+ }
+}
+
+function sendLoadDiagramMessage(drawIOEditorState) {
+ postMessageToDrawioEditor(drawIOEditorState, {
+ action: 'load',
+ xml: drawIOEditorState.diagramSvg,
+ border: 8,
+ background: DIAGRAM_BACKGROUND_COLOR,
+ dark: drawIOEditorState.dark,
+ title: drawIOEditorState.filename,
+ });
+}
+
+async function loadExistingDiagram(drawIOEditorState, editorFacade) {
+ let diagram = null;
+
+ try {
+ diagram = await editorFacade.getDiagram();
+ } catch (e) {
+ throw new Error(__('Cannot load the diagram into the draw.io editor'));
+ }
+
+ if (diagram) {
+ const { diagramMarkdown, filename, diagramSvg, contentType } = diagram;
+
+ if (contentType !== 'image/svg+xml') {
+ throw new Error(__('The selected image is not a diagram'));
+ }
+
+ updateDrawioEditorState(drawIOEditorState, {
+ newDiagram: false,
+ filename,
+ diagramMarkdown,
+ diagramSvg,
+ });
+ } else {
+ updateDrawioEditorState(drawIOEditorState, {
+ newDiagram: true,
+ });
+ }
+
+ sendLoadDiagramMessage(drawIOEditorState);
+}
+
+async function prepareEditor(drawIOEditorState, editorFacade) {
+ const { iframe } = drawIOEditorState;
+
+ iframe.style.cursor = 'wait';
+
+ try {
+ await loadExistingDiagram(drawIOEditorState, editorFacade);
+
+ iframe.style.visibility = '';
+ iframe.style.cursor = '';
+ window.scrollTo(0, 0);
+ } catch (e) {
+ createAlert({
+ message: e.message,
+ error: e,
+ });
+ disposeDrawioEditor(drawIOEditorState);
+ }
+}
+
+function configureDrawIOEditor(drawIOEditorState) {
+ postMessageToDrawioEditor(drawIOEditorState, {
+ action: 'configure',
+ config: {
+ darkColor: DARK_BACKGROUND_COLOR,
+ settingsName: 'gitlab',
+ },
+ colorSchemeMeta: drawIOEditorState.dark, // For transparent iframe background in dark mode
+ });
+ updateDrawioEditorState(drawIOEditorState, {
+ initialized: true,
+ });
+}
+
+function onDrawIOEditorMessage(drawIOEditorState, editorFacade, evt) {
+ if (_.isNil(evt) || evt.source !== drawIOEditorState.iframe.contentWindow) {
+ return;
+ }
+
+ const msg = JSON.parse(evt.data);
+
+ if (msg.event === 'configure') {
+ configureDrawIOEditor(drawIOEditorState);
+ } else if (msg.event === 'init') {
+ prepareEditor(drawIOEditorState, editorFacade);
+ } else if (msg.event === 'exit') {
+ disposeDrawioEditor(drawIOEditorState);
+ } else if (msg.event === 'prompt') {
+ updateDrawioEditorState(drawIOEditorState, {
+ filename: msg.value,
+ });
+
+ if (!drawIOEditorState.filename) {
+ promptName(drawIOEditorState, 'diagram.drawio.svg', 'filenameShort');
+ } else {
+ saveDiagram(drawIOEditorState, editorFacade);
+ }
+ } else if (msg.event === 'export') {
+ updateDrawioEditorState(drawIOEditorState, {
+ diagramSvg: getSvg(msg.data),
+ });
+ // TODO Add this to draw.io editor configuration
+ sendLoadDiagramMessage(drawIOEditorState); // Save removes diagram from the editor, so we need to reload it.
+ postMessageToDrawioEditor(drawIOEditorState, { action: 'status', modified: true }); // And set editor modified flag to true.
+ if (!drawIOEditorState.filename) {
+ promptName(drawIOEditorState, 'diagram.drawio.svg', null);
+ } else {
+ saveDiagram(drawIOEditorState, editorFacade);
+ }
+ }
+}
+
+function createEditorIFrame(drawIOEditorState) {
+ const iframe = document.createElement('iframe');
+
+ setAttributes(iframe, {
+ id: DRAWIO_FRAME_ID,
+ src: DRAWIO_EDITOR_URL,
+ });
+
+ iframe.style.position = 'absolute';
+ iframe.style.border = '0';
+ iframe.style.top = '0px';
+ iframe.style.left = '0px';
+ iframe.style.width = '100%';
+ iframe.style.height = '100%';
+ iframe.style.zIndex = '1100';
+ iframe.style.visibility = 'hidden';
+
+ document.body.appendChild(iframe);
+
+ setTimeout(() => {
+ if (drawIOEditorState.initialized === false) {
+ disposeDrawioEditor(drawIOEditorState);
+ createAlert({ message: __('The draw.io editor could not be loaded.') });
+ }
+ }, DRAWIO_IFRAME_TIMEOUT);
+
+ updateDrawioEditorState(drawIOEditorState, {
+ iframe,
+ });
+}
+
+function attachDrawioIFrameMessageListener(drawIOEditorState, editorFacade) {
+ const evtHandler = (evt) => {
+ onDrawIOEditorMessage(drawIOEditorState, editorFacade, evt);
+ };
+
+ window.addEventListener('message', evtHandler);
+
+ // Stores a function in the editor state object that allows disposing
+ // the message event listener when the editor exits.
+ updateDrawioEditorState(drawIOEditorState, {
+ disposeEventListener: () => {
+ window.removeEventListener('message', evtHandler);
+ },
+ });
+}
+
+const createDrawioEditorState = ({ filename = null }) => ({
+ newDiagram: true,
+ filename,
+ diagramSvg: null,
+ diagramMarkdown: null,
+ iframe: null,
+ isBusy: false,
+ initialized: false,
+ dark: darkModeEnabled(),
+ disposeEventListener: null,
+});
+
+export function launchDrawioEditor({ editorFacade, filename }) {
+ const drawIOEditorState = createDrawioEditorState({ filename });
+
+ // The execution order of these two functions matter
+ attachDrawioIFrameMessageListener(drawIOEditorState, editorFacade);
+ createEditorIFrame(drawIOEditorState);
+}
diff --git a/app/assets/javascripts/drawio/markdown_field_editor_facade.js b/app/assets/javascripts/drawio/markdown_field_editor_facade.js
new file mode 100644
index 00000000000..b2506ce6bf8
--- /dev/null
+++ b/app/assets/javascripts/drawio/markdown_field_editor_facade.js
@@ -0,0 +1,71 @@
+import { insertMarkdownText, resolveSelectedImage } from '~/lib/utils/text_markdown';
+import axios from '~/lib/utils/axios_utils';
+
+/**
+ * A set of functions to decouple the markdown_field component from
+ * the draw.io editor.
+ * It allows the draw.io editor to obtain a selected drawio_diagram
+ * and replace it or insert a new drawio_diagram node without coupling
+ * the drawio_editor to the Markdown Field implementation details
+ *
+ * @param {Object} params Factory function parameters
+ * @param {Object} params.textArea Textarea used to edit and display markdown source
+ * @param {String} params.markdownPreviewPath API endpoint to render Markdown
+ * @param {String} params.uploadsPath API endpoint to upload files
+ *
+ * @returns A markdown_field_facade object with operations
+ * with operations to get a selected diagram, upload a diagram,
+ * insert a new one in the Markdown Field, and update
+ * an existing’s diagram URL.
+ */
+export const create = ({ textArea, markdownPreviewPath, uploadsPath }) => ({
+ getDiagram: async () => {
+ const image = await resolveSelectedImage(textArea, markdownPreviewPath);
+
+ if (!image) {
+ return null;
+ }
+
+ const { imageURL, imageMarkdown, filename } = image;
+ const response = await axios.get(imageURL, { responseType: 'text' });
+ const diagramSvg = response.data;
+ const contentType = response.headers['content-type'];
+
+ return {
+ diagramMarkdown: imageMarkdown,
+ filename,
+ diagramSvg,
+ contentType,
+ };
+ },
+ updateDiagram: ({ uploadResults, diagramMarkdown }) => {
+ textArea.focus();
+
+ // eslint-disable-next-line no-param-reassign
+ textArea.value = textArea.value.replace(diagramMarkdown, uploadResults.link.markdown);
+ textArea.dispatchEvent(new Event('input'));
+ },
+ insertDiagram: ({ uploadResults }) => {
+ textArea.focus();
+ const markdown = textArea.value;
+ const selectedMD = markdown.substring(textArea.selectionStart, textArea.selectionEnd);
+
+ // This method dispatches the input event.
+ insertMarkdownText({
+ textArea,
+ text: markdown,
+ tag: uploadResults.link.markdown,
+ selected: selectedMD,
+ });
+ },
+ uploadDiagram: async ({ filename, diagramSvg }) => {
+ const blob = new Blob([diagramSvg], { type: 'image/svg+xml' });
+ const formData = new FormData();
+
+ formData.append('file', blob, filename);
+
+ const response = await axios.post(uploadsPath, formData);
+
+ return response.data;
+ },
+});
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 05ed08931bb..ebc118c0d87 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -2,6 +2,7 @@
import $ from 'jquery';
import Shortcuts from '~/behaviors/shortcuts/shortcuts';
import { insertText } from '~/lib/utils/common_utils';
+import axios from '~/lib/utils/axios_utils';
const LINK_TAG_PATTERN = '[{text}](url)';
const INDENT_CHAR = ' ';
@@ -669,3 +670,50 @@ export function removeMarkdownListeners(form) {
// eslint-disable-next-line @gitlab/no-global-event-off
return $('.js-md', form).off('click');
}
+
+/**
+ * If the textarea cursor is positioned in a Markdown image declaration,
+ * it uses the Markdown API to resolve the image’s absolute URL.
+ * @param {Object} textarea Textarea DOM element
+ * @param {String} markdownPreviewPath Markdown API path
+ * @returns {Object} an object containing the image’s absolute URL, filename,
+ * and the markdown declaration. If the textarea cursor is not positioned
+ * in an image, it returns null.
+ */
+export const resolveSelectedImage = async (textArea, markdownPreviewPath = '') => {
+ const { lines, startPos } = linesFromSelection(textArea);
+
+ // image declarations can’t span more than one line in Markdown
+ if (lines > 0) {
+ return null;
+ }
+
+ const selectedLine = lines[0];
+
+ if (!/!\[.+?\]\(.+?\)/.test(selectedLine)) return null;
+
+ const lineSelectionStart = textArea.selectionStart - startPos;
+ const preExlm = selectedLine.substring(0, lineSelectionStart).lastIndexOf('!');
+ const postClose = selectedLine.substring(lineSelectionStart).indexOf(')');
+
+ if (preExlm >= 0 && postClose >= 0) {
+ const imageMarkdown = selectedLine.substring(preExlm, lineSelectionStart + postClose + 1);
+ const { data } = await axios.post(markdownPreviewPath, { text: imageMarkdown });
+ const parser = new DOMParser();
+
+ const dom = parser.parseFromString(data.body, 'text/html');
+ const imageURL = dom.body.querySelector('a').getAttribute('href');
+
+ if (imageURL) {
+ const filename = imageURL.substring(imageURL.lastIndexOf('/') + 1);
+
+ return {
+ imageMarkdown,
+ imageURL,
+ filename,
+ };
+ }
+ }
+
+ return null;
+};
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
index 0d2bbfbbc43..549c964cce4 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -351,6 +351,7 @@ export default {
:enable-content-editor="isMarkdownFormat"
:enable-preview="isMarkdownFormat"
:autofocus="pageInfo.persisted"
+ :drawio-enabled="true"
@contentEditor="notifyContentEditorActive"
@markdownField="notifyContentEditorInactive"
@keydown.ctrl.enter="submitFormShortcut"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/drawio_toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/drawio_toolbar_button.vue
new file mode 100644
index 00000000000..a66becb5c92
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/drawio_toolbar_button.vue
@@ -0,0 +1,48 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { launchDrawioEditor } from '~/drawio/drawio_editor';
+import { create } from '~/drawio/markdown_field_editor_facade';
+
+export default {
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ uploadsPath: {
+ type: String,
+ required: true,
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ getTextArea() {
+ return document.querySelector('.js-gfm-input');
+ },
+ launchDrawioEditor() {
+ launchDrawioEditor({
+ editorFacade: create({
+ uploadsPath: this.uploadsPath,
+ textArea: this.getTextArea(),
+ markdownPreviewPath: this.markdownPreviewPath,
+ }),
+ });
+ },
+ },
+};
+</script>
+<template>
+ <gl-button
+ v-gl-tooltip
+ :title="__('Insert or edit diagram')"
+ :aria-label="__('Insert or edit diagram')"
+ category="tertiary"
+ icon="diagram"
+ @click="launchDrawioEditor"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 6f4cddbdfa2..bca7d90135e 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -132,6 +132,11 @@ export default {
required: false,
default: false,
},
+ drawioEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -355,6 +360,10 @@ export default {
:enable-preview="enablePreview"
:show-suggest-popover="showSuggestPopover"
:suggestion-start-index="suggestionsStartIndex"
+ :uploads-path="uploadsPath"
+ :markdown-preview-path="markdownPreviewPath"
+ :drawio-enabled="drawioEnabled"
+ data-testid="markdownHeader"
:restricted-tool-bar-items="restrictedToolBarItems"
@preview-markdown="showPreviewTab"
@write-markdown="showWriteTab"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index e83441e59a2..5d692c69fc0 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -15,6 +15,7 @@ import { getSelectedFragment } from '~/lib/utils/common_utils';
import { s__, __ } from '~/locale';
import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
import ToolbarButton from './toolbar_button.vue';
+import DrawioToolbarButton from './drawio_toolbar_button.vue';
export default {
components: {
@@ -23,6 +24,7 @@ export default {
GlButton,
GlTabs,
GlTab,
+ DrawioToolbarButton,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -62,6 +64,21 @@ export default {
required: false,
default: () => [],
},
+ uploadsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ drawioEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -369,8 +386,12 @@ export default {
icon="paperclip"
@click="handleAttachFile"
/>
+ <drawio-toolbar-button
+ v-if="drawioEnabled"
+ :uploads-path="uploadsPath"
+ :markdown-preview-path="markdownPreviewPath"
+ />
<toolbar-button
- v-if="!restrictedToolBarItems.includes('full-screen')"
class="js-zen-enter"
:prepend="true"
:button-title="__('Go full screen')"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
index 7e6b0e4a63b..2a82986e90b 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
@@ -76,6 +76,11 @@ export default {
required: false,
default: false,
},
+ drawioEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -147,6 +152,7 @@ export default {
:autocomplete-data-sources="autocompleteDataSources"
:uploads-path="uploadsPath"
:enable-preview="enablePreview"
+ :drawio-enabled="drawioEnabled"
show-content-editor-switcher
class="bordered-box"
@enableContentEditor="onEditingModeChange('contentEditor')"
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue
index 6974804523a..80802cb3858 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue
@@ -70,7 +70,7 @@ export default {
<item-milestone
v-if="milestone"
:milestone="milestone"
- class="gl-display-flex gl-align-items-center gl-mr-5 gl-max-w-15 gl-text-secondary! gl-cursor-help! gl-text-decoration-none!"
+ class="gl-display-flex gl-align-items-center gl-mr-5 gl-max-w-15 gl-line-height-normal gl-text-secondary! gl-cursor-help! gl-text-decoration-none!"
/>
<gl-avatars-inline
v-if="assignees.length"
@@ -97,7 +97,7 @@ export default {
:background-color="label.color"
:description="label.description"
:scoped="showScopedLabel(label)"
- class="gl-mt-3 gl-sm-mt-0 gl-mr-2 gl-mb-auto gl-label-sm"
+ class="gl-my-2 gl-mr-2 gl-mb-auto gl-label-sm"
tooltip-placement="top"
/>
</div>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
index acd2792a806..e2956c18678 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
@@ -63,6 +63,9 @@ export default {
id: this.issuableGid,
};
},
+ context: {
+ isSingleRequest: true,
+ },
skip() {
return !this.issuableId;
},
diff --git a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
index 7d7bb9c7fc5..b9d4bb29bbf 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
@@ -1,40 +1,7 @@
+#import "./work_item.fragment.graphql"
+
query workItemLinksQuery($id: WorkItemID!) {
workItem(id: $id) {
- id
- workItemType {
- id
- name
- }
- title
- userPermissions {
- deleteWorkItem
- updateWorkItem
- }
- confidential
- widgets {
- type
- ... on WorkItemWidgetHierarchy {
- type
- parent {
- id
- }
- children {
- nodes {
- id
- iid
- confidential
- workItemType {
- id
- name
- iconName
- }
- title
- state
- createdAt
- closedAt
- }
- }
- }
- }
+ ...WorkItem
}
}
diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb
index 46303cefb60..0e160ce2ea0 100644
--- a/app/controllers/concerns/wiki_actions.rb
+++ b/app/controllers/concerns/wiki_actions.rb
@@ -11,6 +11,15 @@ module WikiActions
RESCUE_GIT_TIMEOUTS_IN = %w[show edit history diff pages].freeze
included do
+ content_security_policy do |p|
+ next if p.directives.blank?
+
+ default_frame_src = p.directives['frame-src'] || p.directives['default-src']
+ frame_src_values = Array.wrap(default_frame_src) | ['https://embed.diagrams.net'].compact
+
+ p.frame_src(*frame_src_values)
+ end
+
before_action { respond_to :html }
before_action :authorize_read_wiki!