diff options
Diffstat (limited to 'app/assets/javascripts/drawio/drawio_editor.js')
-rw-r--r-- | app/assets/javascripts/drawio/drawio_editor.js | 277 |
1 files changed, 277 insertions, 0 deletions
diff --git a/app/assets/javascripts/drawio/drawio_editor.js b/app/assets/javascripts/drawio/drawio_editor.js new file mode 100644 index 00000000000..38d1cadcc63 --- /dev/null +++ b/app/assets/javascripts/drawio/drawio_editor.js @@ -0,0 +1,277 @@ +import _ from 'lodash'; +import { createAlert, VARIANT_SUCCESS } from '~/alert'; +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, + DIAGRAM_MAX_SIZE, +} 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 diagrams.net editor')); + } + + if (diagram) { + const { diagramMarkdown, filename, diagramSvg, contentType, diagramURL } = diagram; + const resolvedURL = new URL(diagramURL, window.location.origin); + const diagramSvgSize = new Blob([diagramSvg]).size; + + if (contentType !== 'image/svg+xml') { + throw new Error(__('The selected image is not a valid SVG diagram')); + } + + if (resolvedURL.origin !== window.location.origin) { + throw new Error(__('The selected image is not an asset uploaded in the application')); + } + + if (diagramSvgSize > DIAGRAM_MAX_SIZE) { + throw new Error(__('The selected image is too large.')); + } + + 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 = 'visible'; + 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, + class: 'drawio-editor', + }); + + document.body.appendChild(iframe); + + setTimeout(() => { + if (drawIOEditorState.initialized === false) { + disposeDrawioEditor(drawIOEditorState); + createAlert({ message: __('The diagrams.net 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); +} |