Просмотр исходного кода

Merge pull request #1685 from kaishuu0123/feat/drawio-integration

Add draw.io Integration
Yuki Takei 6 лет назад
Родитель
Сommit
3234fd6803

+ 1 - 0
package.json

@@ -192,6 +192,7 @@
     "lodash-webpack-plugin": "^0.11.5",
     "markdown-it": "^10.0.0",
     "markdown-it-blockdiag": "^1.0.2",
+    "markdown-it-drawio-viewer": "^1.1.0",
     "markdown-it-emoji": "^1.4.0",
     "markdown-it-footnote": "^3.0.1",
     "markdown-it-mathjax": "^2.0.0",

+ 8 - 0
resource/cdn-manifests.js

@@ -79,6 +79,14 @@ module.exports = {
         integrity: '',
       },
     },
+    {
+      name: 'drawio-viewer',
+      url: 'https://jgraph.github.io/drawio/src/main/webapp/js/viewer.min.js',
+      groups: ['basis'],
+      args: {
+        integrity: '',
+      },
+    },
   ],
   style: [
     {

+ 78 - 0
src/client/js/components/Drawio.jsx

@@ -0,0 +1,78 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+class Drawio extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.drawioContainer = React.createRef();
+
+    this.style = {
+      borderRadius: 3,
+      border: '1px solid #d7d7d7',
+      margin: '20px 0',
+    };
+
+    this.isPreview = this.props.isPreview;
+    this.drawioContent = this.props.drawioContent;
+
+    this.onEdit = this.onEdit.bind(this);
+  }
+
+  onEdit() {
+    if (window.crowi != null) {
+      window.crowi.launchDrawioIFrame('page',
+        this.props.rangeLineNumberOfMarkdown.beginLineNumber,
+        this.props.rangeLineNumberOfMarkdown.endLineNumber);
+    }
+  }
+
+  componentDidMount() {
+    const DrawioViewer = window.GraphViewer;
+    if (DrawioViewer != null) {
+      DrawioViewer.processElements();
+    }
+  }
+
+  renderContents() {
+    return this.drawioContent;
+  }
+
+  render() {
+    return (
+      <div className="editable-with-drawio position-relative">
+        { !this.isPreview
+          && (
+          <button type="button" className="drawio-iframe-trigger position-absolute btn" onClick={this.onEdit}>
+            <i className="icon-note mr-1"></i>{this.props.t('Edit')}
+          </button>
+          )
+        }
+        <div
+          className="drawio"
+          style={this.style}
+          ref={(c) => { this.drawioContainer = c }}
+          onScroll={(event) => {
+            event.preventDefault();
+          }}
+          dangerouslySetInnerHTML={{ __html: this.renderContents() }}
+        >
+        </div>
+      </div>
+    );
+  }
+
+}
+
+Drawio.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.object.isRequired,
+  drawioContent: PropTypes.any.isRequired,
+  isPreview: PropTypes.bool,
+  rangeLineNumberOfMarkdown: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(Drawio);

+ 48 - 0
src/client/js/components/Page.jsx

@@ -11,7 +11,9 @@ import MarkdownTable from '../models/MarkdownTable';
 
 import RevisionRenderer from './Page/RevisionRenderer';
 import HandsontableModal from './PageEditor/HandsontableModal';
+import DrawioIFrame from './PageEditor/DrawioIFrame';
 import mtu from './PageEditor/MarkdownTableUtil';
+import mdu from './PageEditor/MarkdownDrawioUtil';
 
 const logger = loggerFactory('growi:Page');
 
@@ -22,11 +24,13 @@ class Page extends React.Component {
 
     this.state = {
       currentTargetTableArea: null,
+      currentTargetDrawioArea: null,
     };
 
     this.growiRenderer = this.props.appContainer.getRenderer('page');
 
     this.saveHandlerForHandsontableModal = this.saveHandlerForHandsontableModal.bind(this);
+    this.saveHandlerForDrawioIFrame = this.saveHandlerForDrawioIFrame.bind(this);
   }
 
   componentWillMount() {
@@ -45,6 +49,19 @@ class Page extends React.Component {
     this.handsontableModal.show(MarkdownTable.fromMarkdownString(tableLines));
   }
 
+  /**
+   * launch DrawioIFrame with data specified by arguments
+   * @param beginLineNumber
+   * @param endLineNumber
+   */
+  launchDrawioIFrame(beginLineNumber, endLineNumber) {
+    const markdown = this.props.pageContainer.state.markdown;
+    const drawioMarkdownArray = markdown.split(/\r\n|\r|\n/).slice(beginLineNumber, endLineNumber);
+    const drawioData = drawioMarkdownArray.slice(1, drawioMarkdownArray.length - 1).join('\n').trim();
+    this.setState({ currentTargetDrawioArea: { beginLineNumber, endLineNumber } });
+    this.drawioIFrame.show(drawioData);
+  }
+
   async saveHandlerForHandsontableModal(markdownTable) {
     const { pageContainer, editorContainer } = this.props;
     const optionsToSave = editorContainer.getCurrentOptionsToSave();
@@ -75,6 +92,36 @@ class Page extends React.Component {
     }
   }
 
+  async saveHandlerForDrawioIFrame(drawioData) {
+    const { pageContainer, editorContainer } = this.props;
+    const optionsToSave = editorContainer.getCurrentOptionsToSave();
+
+    const newMarkdown = mdu.replaceDrawioInMarkdown(
+      drawioData,
+      this.props.pageContainer.state.markdown,
+      this.state.currentTargetDrawioArea.beginLineNumber,
+      this.state.currentTargetDrawioArea.endLineNumber,
+    );
+
+    try {
+      // disable unsaved warning
+      editorContainer.disableUnsavedWarning();
+
+      // eslint-disable-next-line no-unused-vars
+      const { page, tags } = await pageContainer.save(newMarkdown, optionsToSave);
+      logger.debug('success to save');
+
+      pageContainer.showSuccessToastr();
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      pageContainer.showErrorToastr(error);
+    }
+    finally {
+      this.setState({ currentTargetDrawioArea: null });
+    }
+  }
+
   render() {
     const isMobile = this.props.appContainer.isMobile;
     const { markdown } = this.props.pageContainer.state;
@@ -83,6 +130,7 @@ class Page extends React.Component {
       <div className={isMobile ? 'page-mobile' : ''}>
         <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
         <HandsontableModal ref={(c) => { this.handsontableModal = c }} onSave={this.saveHandlerForHandsontableModal} />
+        <DrawioIFrame ref={(c) => { this.drawioIFrame = c }} onSave={this.saveHandlerForDrawioIFrame} />
       </div>
     );
   }

+ 19 - 0
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -17,8 +17,10 @@ import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
 import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
 import mtu from './MarkdownTableUtil';
+import mdu from './MarkdownDrawioUtil';
 import HandsontableModal from './HandsontableModal';
 import EditorIcon from './EditorIcon';
+import DrawioIFrame from './DrawioIFrame';
 
 const loadScript = require('simple-load-script');
 const loadCssSync = require('load-css-file');
@@ -94,6 +96,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
     this.makeHeaderHandler = this.makeHeaderHandler.bind(this);
     this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
+    this.showDrawioHandler = this.showDrawioHandler.bind(this);
   }
 
   init() {
@@ -647,6 +650,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.handsontableModal.show(mtu.getMarkdownTable(this.getCodeMirror()));
   }
 
+  showDrawioHandler() {
+    this.drawioIFrame.show(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()));
+  }
+
   getNavbarItems() {
     return [
       /* eslint-disable max-len */
@@ -746,6 +753,14 @@ export default class CodeMirrorEditor extends AbstractEditor {
       >
         <EditorIcon icon="Table" />
       </Button>,
+      <Button
+        key="nav-item-drawio"
+        bsSize="small"
+        title="draw.io"
+        onClick={this.showDrawioHandler}
+      >
+        <EditorIcon icon="Drawio" />
+      </Button>,
       /* eslint-able max-len */
     ];
   }
@@ -824,6 +839,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
           ref={(c) => { this.handsontableModal = c }}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
         />
+        <DrawioIFrame
+          ref={(c) => { this.drawioIFrame = c }}
+          onSave={(drawioData) => { return mdu.replaceFocusedDrawioWithEditor(this.getCodeMirror(), drawioData) }}
+        />
 
       </React.Fragment>
     );

+ 172 - 0
src/client/js/components/PageEditor/DrawioIFrame.jsx

@@ -0,0 +1,172 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import i18next from 'i18next';
+
+export default class DrawioIFrame extends React.PureComponent {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      shown: false,
+      drawioMxFile: '',
+      style: {
+        zIndex: 9999,
+        top: 0,
+        left: 0,
+        bottom: 0,
+      },
+    };
+
+    this.drawioIFrame = React.createRef();
+
+    this.headerColor = '#334455';
+    this.fontFamily = "Lato, -apple-system, BlinkMacSystemFont, 'Hiragino Kaku Gothic ProN', Meiryo, sans-serif";
+
+    this.init = this.init.bind(this);
+    this.cancel = this.cancel.bind(this);
+    this.receiveFromDrawio = this.receiveFromDrawio.bind(this);
+    this.onResizeWindow = this.onResizeWindow.bind(this);
+    this.drawioURL = this.drawioURL.bind(this);
+  }
+
+  init(drawioMxFile) {
+    const initDrawioMxFile = drawioMxFile;
+    this.setState(
+      {
+        drawioMxFile: initDrawioMxFile,
+      },
+    );
+  }
+
+  show(drawioMxFile) {
+    this.init(drawioMxFile);
+
+    this.setState({
+      style: Object.assign({}, this.state.style, {
+        width: window.innerWidth,
+        height: window.innerHeight,
+      }),
+    });
+
+    window.addEventListener('resize', this.onResizeWindow);
+    window.addEventListener('message', this.receiveFromDrawio);
+    this.setState({ shown: true });
+  }
+
+  hide() {
+    this.setState({
+      shown: false,
+    });
+  }
+
+  cancel() {
+    this.hide();
+  }
+
+  receiveFromDrawio(event) {
+    if (event.data === 'ready') {
+      event.source.postMessage(this.state.drawioMxFile, '*');
+      return;
+    }
+
+    if (event.data === '{"event":"configure"}') {
+      if (event.source == null) {
+        return;
+      }
+
+      // refs:
+      //  * https://desk.draw.io/support/solutions/articles/16000103852-how-to-customise-the-draw-io-interface
+      //  * https://desk.draw.io/support/solutions/articles/16000042544-how-does-embed-mode-work-
+      //  * https://desk.draw.io/support/solutions/articles/16000058316-how-to-configure-draw-io-
+      event.source.postMessage(JSON.stringify({
+        action: 'configure',
+        config: {
+          css: `
+          .geMenubarContainer { background-color: ${this.headerColor} !important; }
+          .geMenubar { background-color: ${this.headerColor} !important; }
+          .geEditor { font-family: ${this.fontFamily} !important; }
+          html td.mxPopupMenuItem {
+            font-family: ${this.fontFamily} !important;
+            font-size: 8pt !important;
+          }
+          `,
+          customFonts: ['Lato', 'Charter'],
+        },
+      }), '*');
+
+      return;
+    }
+
+    if (typeof event.data === 'string' && event.data.match(/mxfile/)) {
+      if (event.data.length > 0) {
+        const parser = new DOMParser();
+        const dom = parser.parseFromString(event.data, 'text/xml');
+        const value = dom.getElementsByTagName('diagram')[0].innerHTML;
+        this.props.onSave(value);
+      }
+
+      window.removeEventListener('resize', this.onResizeWindow);
+      window.removeEventListener('message', this.receiveFromDrawio);
+      this.hide();
+
+      return;
+    }
+
+    if (typeof event.data === 'string' && event.data.length === 0) {
+      window.removeEventListener('resize', this.onResizeWindow);
+      window.removeEventListener('message', this.receiveFromDrawio);
+      this.hide();
+
+      return;
+    }
+
+    // NOTHING DONE. (Receive unknown iframe message.)
+  }
+
+  onResizeWindow(event) {
+    this.setState({
+      style: Object.assign({}, this.state.style, {
+        width: window.innerWidth,
+        height: window.innerHeight,
+      }),
+    });
+  }
+
+  drawioURL() {
+    const url = new URL('https://www.draw.io/');
+
+    // refs: https://desk.draw.io/support/solutions/articles/16000042546-what-url-parameters-are-supported-
+    url.searchParams.append('spin', 1);
+    url.searchParams.append('embed', 1);
+    url.searchParams.append('lang', i18next.language);
+    url.searchParams.append('ui', 'atlas');
+    url.searchParams.append('configure', 1);
+
+    return url;
+  }
+
+  render() {
+    return (
+      <div>
+        {
+          this.state.shown
+          && (
+            <iframe
+              ref={(c) => { this.drawioIFrame = c }}
+              src={this.drawioURL()}
+              className="position-fixed"
+              style={this.state.style}
+            >
+            </iframe>
+          )
+        }
+      </div>
+    );
+  }
+
+}
+
+DrawioIFrame.propTypes = {
+  onSave: PropTypes.func,
+};

Разница между файлами не показана из-за своего большого размера
+ 3 - 0
src/client/js/components/PageEditor/EditorIcon.jsx


+ 161 - 0
src/client/js/components/PageEditor/MarkdownDrawioUtil.js

@@ -0,0 +1,161 @@
+/**
+ * Utility for markdown drawio
+ */
+class MarkdownDrawioUtil {
+
+  constructor() {
+    this.lineBeginPartOfDrawioRE = /^:::(\s.*)drawio$/;
+    this.lineEndPartOfDrawioRE = /^:::$/;
+  }
+
+  /**
+   * return the postion of the BOD(beginning of drawio)
+   * (If the cursor is not in a drawio block, return its position)
+   */
+  getBod(editor) {
+    const curPos = editor.getCursor();
+    const firstLine = editor.getDoc().firstLine();
+
+    if (this.lineBeginPartOfDrawioRE.test(editor.getDoc().getLine(curPos.line))) {
+      return { line: curPos.line, ch: 0 };
+    }
+
+    let line = curPos.line - 1;
+    let isFound = false;
+    for (; line >= firstLine; line--) {
+      const strLine = editor.getDoc().getLine(line);
+      if (this.lineBeginPartOfDrawioRE.test(strLine)) {
+        isFound = true;
+        break;
+      }
+
+      if (this.lineEndPartOfDrawioRE.test(strLine)) {
+        isFound = false;
+        break;
+      }
+    }
+
+    if (!isFound) {
+      return { line: curPos.line, ch: curPos.ch };
+    }
+
+    const bodLine = Math.max(firstLine, line);
+    return { line: bodLine, ch: 0 };
+  }
+
+  /**
+   * return the postion of the EOD(end of drawio)
+   * (If the cursor is not in a drawio block, return its position)
+   */
+  getEod(editor) {
+    const curPos = editor.getCursor();
+    const lastLine = editor.getDoc().lastLine();
+
+    if (this.lineEndPartOfDrawioRE.test(editor.getDoc().getLine(curPos.line))) {
+      return { line: curPos.line, ch: editor.getDoc().getLine(curPos.line).length };
+    }
+
+    let line = curPos.line + 1;
+    let isFound = false;
+    for (; line <= lastLine; line++) {
+      const strLine = editor.getDoc().getLine(line);
+      if (this.lineEndPartOfDrawioRE.test(strLine)) {
+        isFound = true;
+        break;
+      }
+
+      if (this.lineBeginPartOfDrawioRE.test(strLine)) {
+        isFound = false;
+        break;
+      }
+    }
+
+    if (!isFound) {
+      return { line: curPos.line, ch: curPos.ch };
+    }
+
+    const eodLine = Math.min(line, lastLine);
+    const lineLength = editor.getDoc().getLine(eodLine).length;
+    return { line: eodLine, ch: lineLength };
+  }
+
+  /**
+   * return boolean value whether the cursor position is in a drawio
+   */
+  isInDrawioBlock(editor) {
+    return (this.getBod(editor) !== this.getEod(editor));
+  }
+
+  /**
+   * return drawioData instance where the cursor is
+   * (If the cursor is not in a drawio block, return current line)
+   */
+  getMarkdownDrawioMxfile(editor) {
+    const curPos = editor.getCursor();
+
+    if (this.isInDrawioBlock(editor)) {
+      const bod = this.getBod(editor);
+      const eod = this.getEod(editor);
+
+      // skip block begin sesion("::: drawio")
+      bod.line++;
+      // skip block end sesion(":::")
+      eod.line--;
+      eod.ch = editor.getDoc().getLine(eod.line).length;
+
+      return editor.getDoc().getRange(bod, eod);
+    }
+
+    return editor.getDoc().getLine(curPos.line);
+  }
+
+  replaceFocusedDrawioWithEditor(editor, drawioData) {
+    const curPos = editor.getCursor();
+    const drawioBlock = ['::: drawio', drawioData.toString(), ':::'].join('\n');
+    let beginPos;
+    let endPos;
+
+    if (this.isInDrawioBlock(editor)) {
+      beginPos = this.getBod(editor);
+      endPos = this.getEod(editor);
+    }
+    else {
+      beginPos = { line: curPos.line, ch: curPos.ch };
+      endPos = { line: curPos.line, ch: curPos.ch };
+    }
+
+    editor.getDoc().replaceRange(drawioBlock, beginPos, endPos);
+  }
+
+  /**
+   * return markdown where the drawioData specified by line number params is replaced to the drawioData specified by drawioData param
+   * @param {string} drawioData
+   * @param {string} markdown
+   * @param beginLineNumber
+   * @param endLineNumber
+   */
+  replaceDrawioInMarkdown(drawioData, markdown, beginLineNumber, endLineNumber) {
+    const splitMarkdown = markdown.split(/\r\n|\r|\n/);
+    const markdownBeforeDrawio = splitMarkdown.slice(0, beginLineNumber);
+    const markdownAfterDrawio = splitMarkdown.slice(endLineNumber);
+
+    let newMarkdown = '';
+    if (markdownBeforeDrawio.length > 0) {
+      newMarkdown += `${markdownBeforeDrawio.join('\n')}\n`;
+      newMarkdown += '::: drawio\n';
+    }
+    newMarkdown += drawioData;
+    if (markdownAfterDrawio.length > 0) {
+      newMarkdown += '\n:::';
+      newMarkdown += `\n${markdownAfterDrawio.join('\n')}`;
+    }
+
+    return newMarkdown;
+  }
+
+}
+
+// singleton pattern
+const instance = new MarkdownDrawioUtil();
+Object.freeze(instance);
+export default instance;

+ 15 - 0
src/client/js/services/AppContainer.js

@@ -13,6 +13,10 @@ import {
   RestoreCodeBlockInterceptor,
 } from '../util/interceptor/detach-code-blocks';
 
+import {
+  DrawioInterceptor,
+} from '../util/interceptor/drawio-interceptor';
+
 import i18nFactory from '../util/i18n';
 import apiv3ErrorHandler from '../util/apiv3ErrorHandler';
 
@@ -52,6 +56,7 @@ export default class AppContainer extends Container {
 
     this.interceptorManager = new InterceptorManager();
     this.interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(this), 10); // process as soon as possible
+    this.interceptorManager.addInterceptor(new DrawioInterceptor(this), 20);
     this.interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(this), 900); // process as late as possible
 
     const userlang = body.dataset.userlang;
@@ -299,6 +304,16 @@ export default class AppContainer extends Container {
     targetComponent.launchHandsontableModal(beginLineNumber, endLineNumber);
   }
 
+  launchDrawioIFrame(componentKind, beginLineNumber, endLineNumber) {
+    let targetComponent;
+    switch (componentKind) {
+      case 'page':
+        targetComponent = this.getComponentInstance('Page');
+        break;
+    }
+    targetComponent.launchDrawioIFrame(beginLineNumber, endLineNumber);
+  }
+
   async apiGet(path, params) {
     return this.apiRequest('get', path, { params });
   }

+ 2 - 0
src/client/js/util/GrowiRenderer.js

@@ -14,6 +14,7 @@ import TableConfigurer from './markdown-it/table';
 import TaskListsConfigurer from './markdown-it/task-lists';
 import TocAndAnchorConfigurer from './markdown-it/toc-and-anchor';
 import BlockdiagConfigurer from './markdown-it/blockdiag';
+import DrawioViewerConfigurer from './markdown-it/drawio-viewer';
 import TableWithHandsontableButtonConfigurer from './markdown-it/table-with-handsontable-button';
 import HeaderWithEditLinkConfigurer from './markdown-it/header-with-edit-link';
 
@@ -67,6 +68,7 @@ export default class GrowiRenderer {
       new HeaderConfigurer(appContainer),
       new EmojiConfigurer(appContainer),
       new MathJaxConfigurer(appContainer),
+      new DrawioViewerConfigurer(appContainer),
       new PlantUMLConfigurer(appContainer),
       new BlockdiagConfigurer(appContainer),
     ];

+ 148 - 0
src/client/js/util/interceptor/drawio-interceptor.js

@@ -0,0 +1,148 @@
+/* eslint-disable import/prefer-default-export */
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { BasicInterceptor } from 'growi-commons';
+import Drawio from '../../components/Drawio';
+
+/**
+ * The interceptor for draw.io
+ *
+ *  replace draw.io tag (render by markdown-it-drawio-viewer) to a React target element
+ */
+export class DrawioInterceptor extends BasicInterceptor {
+
+  constructor(appContainer) {
+    super();
+
+    this.previousPreviewContext = null;
+    this.appContainer = appContainer;
+
+    const DrawioViewer = window.GraphViewer;
+    if (DrawioViewer != null) {
+      // viewer.min.js の Resize による Scroll イベントを抑止するために無効化する
+      DrawioViewer.useResizeSensor = false;
+    }
+  }
+
+  /**
+   * @inheritdoc
+   */
+  isInterceptWhen(contextName) {
+    return (
+      contextName === 'preRenderHtml'
+      || contextName === 'preRenderPreviewHtml'
+      || contextName === 'postRenderHtml'
+      || contextName === 'postRenderPreviewHtml'
+    );
+  }
+
+  /**
+   * @inheritdoc
+   */
+  isProcessableParallel() {
+    return false;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async process(contextName, ...args) {
+    const context = Object.assign(args[0]); // clone
+
+    if (contextName === 'preRenderHtml' || contextName === 'preRenderPreviewHtml') {
+      return this.drawioPreRender(contextName, context);
+    }
+
+    if (contextName === 'postRenderHtml' || contextName === 'postRenderPreviewHtml') {
+      this.drawioPostRender(contextName, context);
+      return;
+    }
+  }
+
+  /**
+   * @inheritdoc
+   */
+  createRandomStr(length) {
+    const bag = 'abcdefghijklmnopqrstuvwxyz0123456789';
+    let generated = '';
+    for (let i = 0; i < length; i++) {
+      generated += bag[Math.floor(Math.random() * bag.length)];
+    }
+    return generated;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  drawioPreRender(contextName, context) {
+    const div = document.createElement('div');
+    div.innerHTML = context.parsedHTML;
+
+    context.DrawioMap = {};
+    Array.from(div.querySelectorAll('.mxgraph')).forEach((element) => {
+      const domId = `mxgraph-${this.createRandomStr(8)}`;
+
+      context.DrawioMap[domId] = {
+        rangeLineNumberOfMarkdown: {
+          beginLineNumber: element.parentNode.dataset.beginLineNumberOfMarkdown,
+          endLineNumber: element.parentNode.dataset.endLineNumberOfMarkdown,
+        },
+        contentHtml: element.outerHTML,
+      };
+      element.outerHTML = `<div id="${domId}"></div>`;
+    });
+    context.parsedHTML = div.innerHTML;
+
+    // unmount
+    if (contextName === 'preRenderPreviewHtml') {
+      this.unmountPreviousReactDOMs(context);
+    }
+
+    // resolve
+    return context;
+  }
+
+  /**
+   * @inheritdoc
+   */
+  drawioPostRender(contextName, context) {
+    const isPreview = (contextName === 'postRenderPreviewHtml');
+
+    Object.keys(context.DrawioMap).forEach((domId) => {
+      const elem = document.getElementById(domId);
+      if (elem) {
+        this.renderReactDOM(context.DrawioMap[domId], elem, isPreview);
+      }
+    });
+  }
+
+  /**
+   * @inheritdoc
+   */
+  renderReactDOM(drawioMapEntry, elem, isPreview) {
+    ReactDOM.render(
+      // eslint-disable-next-line react/jsx-filename-extension
+      <Drawio
+        appContainer={this.appContainer}
+        drawioContent={drawioMapEntry.contentHtml}
+        isPreview={isPreview}
+        rangeLineNumberOfMarkdown={drawioMapEntry.rangeLineNumberOfMarkdown}
+      />,
+      elem,
+    );
+  }
+
+  /**
+   * @inheritdoc
+   */
+  unmountPreviousReactDOMs(newContext) {
+    if (this.previousPreviewContext != null) {
+      Array.from(document.querySelectorAll('.mxgraph')).forEach((element) => {
+        ReactDOM.unmountComponentAtNode(element);
+      });
+    }
+
+    this.previousPreviewContext = newContext;
+  }
+
+}

+ 9 - 0
src/client/js/util/markdown-it/drawio-viewer.js

@@ -0,0 +1,9 @@
+export default class DrawioViewerConfigurer {
+
+  configure(md) {
+    md.use(require('markdown-it-drawio-viewer'), {
+      marker: ':::',
+    });
+  }
+
+}

+ 25 - 0
src/client/styles/scss/_page.scss

@@ -8,6 +8,7 @@
    * header
    */
   header {
+
     // the container of h1
     div.title-container {
       padding-right: 5px;
@@ -38,6 +39,7 @@
 
     // change button opacity
     &:hover {
+
       .btn.btn-copy,
       .btn-copy-link,
       .btn.btn-edit,
@@ -114,6 +116,7 @@
 .main-container .main .content-main .revision-history {
   .revision-history-list {
     .revision-history-outer {
+
       // add border-top except of first element
       &:not(:first-of-type) {
         border-top: 1px solid $border;
@@ -206,6 +209,28 @@
   }
 }
 
+/**
+ * for drawio with drawio iframe button
+ */
+.editable-with-drawio {
+  .drawio-iframe-trigger {
+    top: 11px;
+    right: 10px;
+    z-index: 14;
+    font-size: 12px;
+    line-height: 1;
+    color: $linktext;
+    background-color: transparent;
+    border: 1px solid $linktext;
+    opacity: 1;
+
+    &:hover {
+      color: $white;
+      background-color: $linktext;
+    }
+  }
+}
+
 /*
  * for Presentation
  */

+ 17 - 1
yarn.lock

@@ -8328,6 +8328,14 @@ markdown-it-blockdiag@^1.0.2:
     url-join "^4.0.0"
     utf8-bytes "0.0.1"
 
+markdown-it-drawio-viewer@^1.1.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/markdown-it-drawio-viewer/-/markdown-it-drawio-viewer-1.1.2.tgz#4ae442545d32bd44bb9f8ca18085315cea1ec692"
+  integrity sha512-YD+wZ4SDipZf1+TeHcUDWhGqN+FaiA+77be1flcfCli1a4cGy3fo8r2QnPRAn+rVEhTpMB6cQsUS5I0KxwHFYQ==
+  dependencies:
+    markdown-it-fence "0.1.3"
+    xmldoc "^1.1.2"
+
 markdown-it-emoji@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/markdown-it-emoji/-/markdown-it-emoji-1.4.0.tgz#9bee0e9a990a963ba96df6980c4fddb05dfb4dcc"
@@ -8335,6 +8343,7 @@ markdown-it-emoji@^1.4.0:
 markdown-it-fence@0.1.3:
   version "0.1.3"
   resolved "https://registry.yarnpkg.com/markdown-it-fence/-/markdown-it-fence-0.1.3.tgz#26e90149b7de5658cdb27096b2e2b5ec4e72d6ce"
+  integrity sha512-mdZbkwJUyZXhyDX4QzD+WnIhxWBq9oIIJU22E6jCT7MHgIp79oEOJfwXIl/HqmfIxnXEdC47sBGn4h6chM4DKw==
 
 markdown-it-footnote@^3.0.1:
   version "3.0.1"
@@ -11944,7 +11953,7 @@ sax@1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a"
 
-sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4:
+sax@>=0.6.0, sax@^1.2.1, sax@^1.2.4, sax@~1.2.4:
   version "1.2.4"
   resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
   integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
@@ -14428,6 +14437,13 @@ xmlchars@^2.1.1:
   resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
   integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==
 
+xmldoc@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/xmldoc/-/xmldoc-1.1.2.tgz#6666e029fe25470d599cd30e23ff0d1ed50466d7"
+  integrity sha512-ruPC/fyPNck2BD1dpz0AZZyrEwMOrWTO5lDdIXS91rs3wtm4j+T8Rp2o+zoOYkkAxJTZRPOSnOGei1egoRmKMQ==
+  dependencies:
+    sax "^1.2.1"
+
 xmldom@0.1.27:
   version "0.1.27"
   resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.27.tgz#d501f97b3bdb403af8ef9ecc20573187aadac0e9"

Некоторые файлы не были показаны из-за большого количества измененных файлов