diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-02-23 09:09:23 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-02-23 09:09:23 +0300 |
commit | 8b4276f873461953ee5a1fc46f084779f5847e3a (patch) | |
tree | cc3435570e15234453e711c2ddcc9b0895d87eb4 /app/assets/javascripts/drawio | |
parent | f34b26bb882947bcc1126de19fa55eb8763af32e (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/drawio')
-rw-r--r-- | app/assets/javascripts/drawio/constants.js | 13 | ||||
-rw-r--r-- | app/assets/javascripts/drawio/drawio_editor.js | 274 | ||||
-rw-r--r-- | app/assets/javascripts/drawio/markdown_field_editor_facade.js | 71 |
3 files changed, 358 insertions, 0 deletions
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; + }, +}); |