Building a Flowchart Editor Based on drawio

drawio is a powerful open-source online flowchart editor that supports drawing various types of diagrams. It provides support for both web and client-side editing, and offers the export of multiple resource types.

Description

In our daily writing of papers and documents, we often have the need to draw flowcharts in order to better illustrate specific steps and processes. At such times, we might think of using Visio or ProcessOn, but be daunted by the large size of Visio or the limitations of the free version of ProcessOn. This is when we turn to our protagonist - drawio. For regular users, drawio provides a simple, free, and unlimited space for advanced drawing tools. For advanced developers, it offers a straightforward and quick way to build a free, powerful drawing tool for themselves and their team. It's a win-win situation.

The history of the drawio project can be traced back to 2005 when the JGraph team started developing mxGraph, a JavaScript and SVG-based chart library for creating interactive charts in web applications, supporting Firefox 1.5 and Internet Explorer 5.5. In 2012, the JGraph team removed the Java applet-related parts of the existing program and changed its domain from diagram.ly to draw.io, because the founder thought that io sounded cooler than ly. Thus, drawio became a chart editor based on mxGraph that could run in a browser and, initially, was an internal tool. Later, the mxGraph team decided to release it as an open-source project. In 2020, due to security and copyright considerations, the JGraph team moved draw.io to the diagrams.net domain, which remains an active open-source project with a large user base and contributors, supporting various types of charts including flowcharts, organizational charts, UML diagrams, and various file formats such as XML, PNG, JPEG, and PDF.

Integrating drawio into our own projects has many advantages, including but not limited to out-of-the-box functionality, suitability for production environments, open-source nature, support for customization, and a strong community. However, drawio also has its limitations. As can be seen from the brief summary, the project's history dates back quite far and it lacks support for ESM. There are a significant number of prototype chain modifications, making the code quite complex, with poor readability and maintainability. It also doesn't support TypeScript, all of which are issues that need solving. In fact, a more modern approach favored in modern browsers would be to use a completely Canvas-based drawing board. However, this approach comes with a considerable cost. Therefore, if we want to integrate a flowchart editor into our own projects at a low cost, drawio is one of the best choices.

The question is, how do we integrate drawio into our own projects? We offer two approaches here. One is as a standalone editor, by bundling the Npm package into our own projects. The other is to embed drawio using an iframe and communicate with the deployed drawio project. Both approaches can be used to accomplish the integration of the flowchart. The related content described in this article is available on Github | Editor DEMO.

Standalone Editor

First, let's explore how to integrate a standalone editor into our own projects. Let's first take a look at the mxGraph project, whose documentation is available at https://jgraph.github.io/mxgraph/. We can see that mxGraph supports three languages: .NET, Java, and JavaScript. Here, we are primarily interested in its support for JavaScript. In the documentation, we can find numerous examples, with the “Graph Editor” being the one we should focus on. When we open this example at https://jgraph.github.io/mxgraph/javascript/examples/grapheditor/www/index.html, we find a complete editor project. Furthermore, we can see that the link has a .html extension and is deployed on Github's Git Pages. This means that the .html suffix is not generated by the backend but is a complete frontend project. Therefore, in theory, we can integrate it into our own projects as a pure frontend package.

Given that frontend development nowadays heavily relies on Npm packages, we would prefer to integrate this package directly as a dependency into our project. However, upon reviewing the related code, we find that it is not a simple task. For example, when we open the Graph.js file, we are surprised to find that this single file contains as many as 11,941 lines of code, not to mention that the core part actually includes 10 core classes such as:

Actions.js Dialogs.js Editor.js EditorUi.js Format.js Graph.js Menus.js Shapes.js Sidebar.js Toolbar.js

And if we carefully observe the relevant variable naming, we can find that these ten core classes are not packed or obfuscated code. In other words, they are written in this form originally, which could make it quite difficult to maintain during our secondary development. Also, we can't really expect support for TS because this is indeed a very old project. When it was first developed, TypeScript might not have even started yet. On top of that, if there is a current need to use mxGraph as a basis to develop a new project from scratch instead of integrating an existing project, it is currently more recommended to use maxGraph. mxGraph has long ceased maintenance, while maxGraph aims to provide as much as possible the same functionality as mxGraph. It supports TypeScript, Tree Shaking, and ES Module as a modern vector graphics library.

Returning to the issue of integrating the independent editor, our goal is to create a Graph Editor, and this editor is based on mxGraph. So, our first step now is to install mxGraph as a dependency. mxGraph has an npm package, so just install this dependency directly. For TS projects, there is also a @typed-mxgraph/typed-mxgraph package. Then specify the typeRoots configuration in the tsconfig.json, and you're good to go. In fact, we are not too concerned about the TS definitions here, because as we described earlier, the main modules are defined in JS. However, it's still very useful when fixing some BUG. So, after installing the main package of mxGraph and the TS definitions, let's first define the modules to be referenced. Of course, actually here because mxGraph doesn't have ESM, there is no support for Tree Shaking. The main purpose here is to facilitate the subsequent module references and the configuration of initializing the modules.

import factory from "mxgraph";

declare global {
  interface Window {
    mxBasePath: string;
    mxLoadResources: boolean;
    mxForceIncludes: boolean;
    mxLoadStylesheets: boolean;
    mxResourceExtension: string;
  }
}

window.mxBasePath = "static";
window.mxLoadResources = false;
window.mxForceIncludes = false;
window.mxLoadStylesheets = false;
window.mxResourceExtension = ".txt";

const mx = factory({
  // https://github.com/jgraph/mxgraph/issues/479
  mxBasePath: "static",
});

// The modules that need to be referenced
// In reality, all modules will still be packaged
export const {
  mxGraph,
  // ...
} = mx;

When writing this referencing module, because mxGraph does not have ESM support, I considered using maxGraph as a substitute, but after some attempts, it ultimately failed. There seems to be a certain GAP between the two packages, so I ended up choosing to use mxGraph after all. Additionally, if necessary, you can configure externals to avoid the need for a complete package of mxGraph, but I won't go into detail about this configuration here. So, the next main task is to bring in the Graph Editor. This part is the most time-consuming and troublesome. During the integration process, we primarily did the following:

Separate the main module and integrate it into our current project. This part of the work is actually relatively simple. It just involves downloading all the necessary code into our own project. Of course, at the beginning, it's a bit confusing because it's difficult to organize this part of the code without understanding it. Additionally, the project uses a lot of values on the window object, making it hard to find so many undefined variables without the help of some tools. Simply copying the code over won't make it run directly, so we need to address all these issues such as undef and external resource references.

Deal with all resource files, including images and style modules, and remove all dependency path resource references. This part of the work involves handling external resource references. The Graph Editor actually has many external resource references, including multilingual support and images. The configurations we made such as mxBasePath and mxResourceExtension are for handling external resources. However, since we prefer to use it as an npm package, dealing with resource path issues is relatively tricky. Therefore, our approach here is to process all image resources into Base64 and integrate them directly. In the process, we also modified the related code to prevent it from making requests to load external resources. Additionally, due to some objective reasons during the modification process, the project's image resources are divided into two types: one is converted into a Base64 TS file, and the other is loaded using a loader. However, both are essentially Base64 resources. The goal here is to no longer make requests for external resources.

Utilize ESLint to streamline some of the code, remove support for some IE browsers, and use Prettier to format the code of the various modules. This part of the work is quite complex. First, we use ESLint to streamline the code, meaning progressively relaxing ESLint rules for the core modules. We modify the related code based on these rules. For example, using no-undef helps to find all undefined modules, and then we handle the references to these modules. The no-unused-vars rule helps to identify unused variables, allowing us to streamline the code. We are currently more focused on modern browsers and no longer want to provide additional support for IE browsers, so we also removed some IE-compatible code. With the help of Prettier and the prettier/prettier rule, we can format the code. After formatting the code, we can see that the implementation of the related modules is more comfortable, and it also solves some implicit issues. For example, taking the core class Graph.js as an example, the code was streamlined from 11,941 lines to 10,637 lines.

Handle multilingual support, currently supporting EN and ZH-CN. This part of the work primarily involves multilingual support. We no longer want to load external resources, and multilingual support is no exception. We have defined the relevant languages here. To load a specific language, you only need to pass the language module's configuration when starting the editor. Additionally, not all language modules need to be loaded. We have implemented a method to load them on demand to reduce the package size. In fact, we also recommend lazy loading the main package into your project.

After completing the integration mentioned above, we were able to successfully launch the project in its entirety. However, during actual usage, we discovered some bugs. For example, when we opened the latest online link for Graph Editor, we found that the Sketch style was not working. Therefore, we still need to fix some bugs in the entire package. Here, we mainly listed three bug fixes for reference.

External module loading issue. It is well known (or maybe not that well known) that many modules of mxGraph are attached to the window. These modules come in various types, such as graphic modules like mxGraphModel, mxGeometry, mxCell, and so on, as well as utility modules like mxUtils, mxEvent, mxCodec, and so on. However, since we are importing it as an npm package, we do not want to pollute the global variables. Moreover, when loading graphics via XML, we need to find these graphic modules; otherwise, the graphics won't be displayed. After analyzing the source code, we found that the dynamic loading occurs in the decode method of mxCodec. Therefore, we need to handle the loading function of these modules here. While directly attaching the mxGraph module package to the window using the external approach may also be a feasible solution, we chose to override the relevant modules to achieve this.

// https://github.com/maxGraph/maxGraph/issues/102
// https://github.com/jgraph/mxgraph/blob/master/javascript/src/js/io/mxCodec.js#L423
mxCodec.prototype.decode = function (node, into) {
  this.updateElements();
  let obj: unknown = null;
  if (node && node.nodeType == mxConstants.NODETYPE_ELEMENT) {
    let ctor: unknown = null;
    try {
      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
      // @ts-ignore // Because the `XML Node` that needs to be processed may not be on the `Window`
      ctor = mx[node.nodeName] || window[node.nodeName];
    } catch (error) {
      console.log(`NODE ${node.nodeName} IS NOT FOUND`, error);
    }
    const dec = mx.mxCodecRegistry.getCodec(ctor);
    if (dec) {
      obj = dec.decode(this, node, into);
    } else {
      obj = node.cloneNode(true);
      obj && (obj as Element).removeAttribute("as");
    }
  }
  return obj;
};

The "Sketch" issue is not valid. If we open the latest online link of the "Graph Editor," we can see that the "Sketch" style is invalid. Since "mxGraph" is no longer maintained, reporting the bug is ineffective. In fact, dealing with this issue is relatively simple, we can just use git to revert to the version where the functionality is normal.

aa11697fbd5ba9f4bb https://github.com/jgraph/mxgraph-js

There is a problem with the mounting sub-container of "Scroll" and the menu. This problem is quite embarrassing because "mxGraph" has always been designed as a whole application. However, when we need to embed it into another application, since our scroll container may be the body, when we have already scrolled the page down, if we then open the flowchart editor, we will find that we cannot drag the canvas or select shapes normally. Moreover, the menu position calculation is also incorrect. Therefore, it is necessary to ensure that the relevant position calculation is correct here.

mxUtils.getScrollOrigin = function (node, includeAncestors, includeDocument) {
  includeAncestors = includeAncestors != null ? includeAncestors : false;
  includeDocument = includeDocument != null ? includeDocument : false;
  const doc = node != null ? node.ownerDocument : document;
  const b = doc.body;
  const d = doc.documentElement;
  const result = new mxPoint();
  let fixed = false;
  while (node != null && node != b && node != d) {
    if (!isNaN(node.scrollLeft) && !isNaN(node.scrollTop)) {
      result.x += node.scrollLeft;
      result.y += node.scrollTop;
    }
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    const style = mxUtils.getCurrentStyle(node);
    if (style != null) {
      fixed = fixed || style.position == "fixed";
    }
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    node = includeAncestors ? node.parentNode : null;
  }
  if (!fixed && includeDocument) {
    const origin = mxUtils.getDocumentScrollOrigin(doc);
    result.x += origin.x;
    result.y += origin.y;
  }
  return result;
};

// Handling the mounting container of the menu
mxPopupMenu.prototype.showMenu = function () {
  container.appendChild(this.div);
  mxUtils.fit(this.div);
};
// Handling the mounting sub-container of the menu
mxPopupMenu.prototype.showSubmenu = function (parent, row) {
  if (row.div != null) {
    row.div.style.left = parent.div.offsetLeft + row.offsetLeft + row.offsetWidth - 1 + "px";
    row.div.style.top = parent.div.offsetTop + row.offsetTop + "px";
    container.appendChild(row.div);
    const left = parseInt(row.div.offsetLeft);
    const width = parseInt(row.div.offsetWidth);
    const offset = mxUtils.getDocumentScrollOrigin(document);
    const b = document.body;
    const d = document.documentElement;
    const right = offset.x + (b.clientWidth || d.clientWidth);
    if (left + width > right) {
      row.div.style.left =
        Math.max(0, parent.div.offsetLeft - width + (mxClient.IS_IE ? 6 : -6)) + "px";
    }
    mxUtils.fit(row.div);
  }
};

Finally, in fact, due to the lack of TreeShaking, and the need for dynamic loading of graphics, our entire package has a relatively large volume. Therefore, in order not to affect the core ability of the application, we still recommend using lazy loading to load the editor. Specifically, you can import types using import type and then load modules using import().

import type * as DiagramEditor from "embed-drawio/dist/packages/core/diagram-editor";
import type * as DiagramViewer from "embed-drawio/dist/packages/core/diagram-viewer";

let editor: typeof DiagramEditor | null = null;
export const diagramEditorLoader = (): Promise<typeof DiagramEditor> => {
  if (editor) return Promise.resolve(editor);
  return Promise.all([
    import(
      /* webpackChunkName: "embed-drawio-editor" */ "embed-drawio/dist/packages/core/diagram-editor"
    ),
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    import(/* webpackChunkName: "embed-drawio-css" */ "embed-drawio/dist/index.css"),
  ]).then(res => (editor = res[0]));
};

let viewer: typeof DiagramViewer | null = null;
export const diagramViewerLoader = (): Promise<typeof DiagramViewer> => {
  if (viewer) return Promise.resolve(viewer);
  return Promise.all([
    import(
      /* webpackChunkName: "embed-drawio-viewer" */ "embed-drawio/dist/packages/core/diagram-viewer"
    ),
  ]).then(res => (viewer = res[0]));
};

Embedding draw.io

As we finished the flowchart editor NPM package based on mxGraph Example, we noticed that mxGraph is no longer maintained, and JGraph has further developed drawio based on mxGraph Example. This is a long-term maintained project. Even though drawio does not accept contributions, it is still active. You can experience the deployment version of drawio here: https://app.diagrams.net/.

What we are more concerned about here is how to embed drawio into our application. drawio provides an embed way to help us integrate it into our own application. By using iframe and postMessage for communication, we won't be subject to cross-domain restrictions, thus achieving a series of functions such as editing, importing, and exporting.

https://www.drawio.com/blog/embedding-walkthrough https://desk.draw.io/support/solutions/articles/16000042544

Here, we will implement the embedding of drawio through simple encapsulated communication. Specifically, we will load drawio using the iframe. Of course, due to network issues, it is still necessary to deploy a private set for actual production environments. After private deployment, customization is also feasible. Of course, if the network supports it, using the deployment version of drawio directly is also feasible. Ultimately, the data storage will be in our own application.

import { EditorEvents } from "./event";
import { Config, DEFAULT_URL, ExportMsg, MESSAGE_EVENT, SaveMsg } from "./interface";

export class EditorBus extends EditorEvents {
  private lock: boolean;
  protected url: string;
  private config: Config;
  protected iframe: HTMLIFrameElement | null;

  constructor(config: Config = { format: "xml" }) {
    super();
    this.lock = false;
    this.config = config;
    this.url = config.url || DEFAULT_URL;
    this.iframe = document.createElement("iframe");
  }
public startEdit = () => {
    if (this.lock || !this.iframe) return void 0;
    this.lock = true;
    const iframe = this.iframe;
    const url =
      `${this.url}?` +
      [
        "embed=1",
        "spin=1",
        "proto=json",
        "configure=1",
        "noSaveBtn=1",
        "stealth=1",
        "libraries=0",
      ].join("&");
    iframe.setAttribute("src", url);
    iframe.setAttribute("frameborder", "0");
    iframe.setAttribute(
      "style",
      "position:fixed;top:0;left:0;width:100%;height:100%;background-color:#fff;z-index:999999;"
    );
    iframe.className = "drawio-iframe-container";
    document.body.style.overflow = "hidden";
    document.body.appendChild(iframe);
    window.addEventListener(MESSAGE_EVENT, this.handleMessageEvent);
  };

  public exitEdit = () => {
    this.lock = false;
    this.iframe && document.body.removeChild(this.iframe);
    this.iframe = null;
    document.body.style.overflow = "";
    window.removeEventListener(MESSAGE_EVENT, this.handleMessageEvent);
  };
onConfig(): void {
  this.config.onConfig
    ? this.config.onConfig()
    : this.postMessage({
        action: "configure",
        config: {
          compressXml: this.config.compress ?? false,
          css: ".geTabContainer{display:none !important;}",
        },
      });
}
onInit(): void {
  this.config.onInit
    ? this.config.onInit()
    : this.postMessage({
        action: "load",
        autosave: 1,
        saveAndExit: "1",
        modified: "unsavedChanges",
        xml: this.config.data,
        title: this.config.title || "Flowchart",
      });
}
onLoad(): void {
  this.config.onLoad && this.config.onLoad();
}
onAutoSave(msg: SaveMsg): void {
  this.config.onAutoSave && this.config.onAutoSave(msg.xml);
}
onSave(msg: SaveMsg): void {
  this.config.onSave && this.config.onSave(msg.xml);
  if (this.config.onExport) {
    this.postMessage({
      action: "export",
      format: this.config.format,
      xml: msg.xml,
    });
  } else {
    if (msg.exit) this.exitEdit();
  }
}
onExit(msg: SaveMsg): void {
  this.config.onExit && this.config.onExit(msg.xml);
  this.exitEdit();
}
onExport(msg: ExportMsg): void {
  if (!this.config.onExport) return void 0;
  this.config.onExport(msg.data, this.config.format);
  this.exitEdit();
}

When we use it, we just need to instantiate the object and enter the editing mode directly. In addition, drawio supports exporting multiple types of data, but here we still recommend xmlsvg. Simply put, this data structure is based on the svg tag and carries xml data. In this way, some redundant fields can be directly displayed as svg or imported into drawio for re-editing. If exported as svg, it cannot be re-imported for editing. If only xml is exported, it can be edited again, but if you want to display it as svg, you need to use viewer.min.js for rendering, which depends on the suitable export type according to the demand.

const bus = new diagram.EditorBus({
  data: svgExample,
  format: "xmlsvg",
  onExport: (svg: string) => {
    const svgStr = base64ToSvgString(svg);
    if (svgStr) {
      setSVGExample(svgStr);
    }
  },
});
bus.startEdit();

Daily Question

https://github.com/WindrunnerMax/EveryDay

References

https://github.com/jgraph/drawio https://github.com/jgraph/mxgraph https://github.com/maxGraph/maxGraph https://github.com/jgraph/mxgraph-js https://en.wikipedia.org/wiki/Draw.io https://juejin.cn/post/7017686432009420808 https://github.com/jgraph/drawio-integration https://jgraph.github.io/mxgraph/javascript/index.html