基於drawio構建流程圖編輯器

基於drawio構建流程圖編輯器

drawio是一款非常強大的開源在線的流程圖編輯器,支持繪製各種形式的圖表,提供了Web端與客戶端支持,同時也支持多種資源類型的導出。

描述

在我們平時寫論文、文檔時,爲了更好地闡述具體的步驟和流程,我們經常會有繪製流程圖的需求,這時我們可能會想到Visio,可能會想到ProcessOn,同時我們也許會因爲Visio其龐大的體積望而卻步,也會因爲ProcessOn只有免費的幾張圖而處處掣肘,那麼在此時我們就要請出我們的主角drawio了,對於單純的使用人員,使用drawio可以獲得一個簡單免費無限空間的高級繪圖工具,而對於進階的開發人員,可以爲自己和團隊非常簡單快速的搭建一個免費無限空間且功能強大的繪圖工具,何樂而不爲。

drawio項目的歷史可以追溯至2005年,當時JGraph團隊開始開發mxGraph,這是一個基於JavaScriptSVG的圖表庫,用於在Web應用程序中創建交互式圖表,支持了Firefox 1.5Internet Explorer 5.52012年,JGraph團隊將已有的程序刪除了Java applet相關的部分,並且從域名diagram.ly改爲draw.io,這是因爲創始人覺得ioly更酷,而drawio則成爲了一個基於mxGraph的圖表編輯器,可以在瀏覽器中運行並創建圖表,最初是一個內部工具,而後來mxGraph團隊決定將其作爲一個開源項目發佈。在2020JGraph團隊處於安全和版權的考慮,將draw.io移至diagrams.net域,diagrams.net目前仍然是一個活躍的開源項目,擁有大量的用戶和貢獻者,支持多種圖表類型,包括流程圖、組織結構圖、UML圖等,同時還支持多種文件格式,包括XMLPNGJPEGPDF等。

集成drawio到我們自己的項目有很多優點,包括但不限於 開箱即用的能力、應用於生產環境的非常成熟的項目、開源項目、支持二次開發、強大的社區等等,但是同樣的drawio也存在一些不足,從上邊簡單的概括實際上可以看出來這個項目的歷史實際上是非常久遠了,本身也沒有支持ESM,有大量的原型鏈修改,如果看過相關源碼可以發現實際上是非常複雜的,代碼的可讀性和可維護性都不是很好,同時也沒有支持TypeScript,這些都是我們需要解決的問題。實際上,現代瀏覽器中更加流行的方案應該是完全基於Canvas繪製的畫板,當然這種方式的成本會相當高,如果我們想以低成本的方式集成一個流程圖編輯器到我們自己的項目,那麼drawio是最好的選擇之一。

那麼問題來了,我們應該如何將drawio集成到自己的項目當中,我們在這裏提供了兩種方案,一種是獨立編輯器,這種方式是將Npm包打包到自己的項目當中,另一種是嵌入drawio,這種方式是通過iframe與部署好的drawio項目進行通信,這兩種方式都可以用來完成流程圖的集成,文中描述的相關內容都在 GithubEditor DEMO 中。

獨立編輯器

首先我們來研究下作爲獨立編輯器集成到我們自己項目當中的方式,我們先來看一下mxGraph項目,文檔地址爲https://jgraph.github.io/mxgraph/,可以看到mxGraph.NETJavaJavaScript三種語言的支持,在這裏我們主要關注的是JavaScript的支持,在文檔中實際上我們是可以找到相當多的Example,在這裏我們需要關注的是Graph Editor這個示例。當我們打開這個示例https://jgraph.github.io/mxgraph/javascript/examples/grapheditor/www/index.html之後,可以發現這實際上是一個非常完整的編輯器項目,而且我們可以看到這個鏈接的地址是以.html結尾並且是部署在GithubGit Pages上的,這就意味着這個.html後綴不是由後端輸出的而是一個完整的純前端項目,那麼在理論上我們就可以將其作爲純前端的包集成到我們自己的項目中。

當前我們開發前端都離不開Npm包,我們也更希望將這個包作爲依賴直接集成到我們的項目當中,但是當我們查閱相關的代碼之後,發現這並不是一個簡單的工作,例如當我們打開Graph.js這個文件,可以驚奇地發現僅這一個文件的代碼行數就高達11941行,更不用說實際上核心部分是包括如下10個核心類的。

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

而且如果我們仔細觀察相關的變量命名可以發現,這十個核心類並不是打包或者混淆之後的代碼,也就是說其本身就是以這種形式編寫的,在我們進行二次開發的時候也會感覺到比較難以維護,至於TS的支持我們本身也不能奢求,畢竟這確實是個年代非常久遠的項目,畢竟在最初開發的時候TypeScript可能都還沒開始。另外可以說句題外話,如果目前有需要使用mxGraph作爲基礎從零開發新項目而不是想集成已有的項目,目前更推薦使用maxGraph來完成,mxGraph早已停止維護,而maxGraph儘可能提供與mxGraph相同的功能,是支持TypeScriptTree ShakingES Module的現代化矢量圖形庫。

回到集成獨立編輯器的問題上來,我們的目標是要Graph Editor,而這個編輯器又是以mxGraph爲基礎完成的,所以我們當前的第一步就是將mxGraph作爲依賴安裝,mxGraph是有npm包的,所以直接安裝這個依賴就可以了,對於TS項目也是有@typed-mxgraph/typed-mxgraph包,再指定一下tsconfig.jsontypeRoots配置項即可,實際上在這裏我們並不是很關心TS定義,因爲我們上邊描述的主體模塊都是JS定義的,當然在修一些BUG的時候還是很有用的。那麼在安裝好mxGraph主包以及TS定義之後,我們先定義好將要引用的模塊,當然實際上在這裏因爲mxGraph並沒有ESM所以沒有Tree Shaking的支持,在這裏主要的目的就是方便後續的模塊引用以及初始化模塊的配置。

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",
});

// 需要用到的模塊再引用 
// 實際上所有的模塊依然都會被打包
export const {
  mxGraph,
  // ...
} = mx;

在編寫這個引用模塊時,由於mxGraph並沒有ESM的支持,我考慮到使用maxGraph來作爲平替,嘗試一番最後還是失敗了,應該是兩個包之間依然存在一定的GAP,最終還是選擇使用mxGraph,另外如果有必要的話可以配置externals來避免需要完整打包mxGraph,這方面配置在這裏就不再贅述了。那麼接下來的主要工作就是將Graph Editor部分引入進來,這一部分是最耗時也是最麻煩的一部分,在集成的過程中我們主要做了如下幾件事:

  • 將主模塊拆離並集成到我們當前的項目中。這部分工作實際上比較簡單,就是將需要用到的代碼全部下載到我們自己的項目當中,當然一開始也是沒什麼頭緒的,因爲在不瞭解的情況下還是比較難以組織起來這部分代碼的,另外項目用到了大量的window對象上的值,如果不借助一些工具很難去查找到這麼多未定義的變量,我們只是把代碼拷貝過來也是無法直接運行起來的,需要解決所有這些諸如undef的問題,以及外部資源引用的問題纔行。
  • 處理所有資源文件,包括圖片、樣式模塊,去除所有依賴路徑的資源引用。這部分工作是要處理外部的資源引用,Graph Editor實際上是有很多外部的資源引用的,包括多語言、圖片等,而實際上我們在上邊配置的諸如mxBasePathmxResourceExtension等都是爲了要處理外部資源,但是由於我們目前是更希望作爲npm包來引用的,處理資源路徑問題又相對比較麻煩,所以在這裏我們採取的方案是將所有的圖片資源都處理成了Base64直接集成進去,當然在這個過程中也修改了相關代碼使其不會發起請求去加載外部資源,另外由於一些修改過程中的客觀原因,在項目中圖片資源分爲了兩種,一種是轉換成了Base64TS文件,一種是藉助loader加載的資源,當然本質上是都是Base64的資源,在這裏實現的目標就是不再發起外部資源的請求。
  • 藉助ESLint精簡部分代碼,去除部分IE瀏覽器的支持,Prettier格式化各個模塊的代碼。這部分工作是個比較複雜的,首先是藉助ESLint精簡代碼,在這裏就是對核心模塊逐步放開ESLint規則,依據這些規則修改相關代碼,例如藉助no-undef就可以找到所有未定義的模塊,然後再處理這些模塊的引用,通過no-unused-vars規則找到未使用的變量,由此來精簡代碼。我們現在都更加聚焦於現代瀏覽器,對於IE瀏覽器不希望再做額外的支持,於是在這裏我們也去除了部分兼容IE的代碼。藉助於Prettier以及prettier/prettier規則我們可以將代碼格式化,在格式化代碼之後可以看到相關模塊的實現會比較舒服,而且也解決了一些隱式的問題,並且以Graph.js核心類爲例,代碼量從11941行精簡到了10637行。
  • 處理多語言,目前支持ENZH-CN兩種語言的加載。這部分工作主要是多語言的支持,目前我們希望的是不再加載外部資源,那麼多語言當然也不例外,在這裏我們已經將相關的語言定義好,要加載哪種語言之需要在啓動編輯器的時候,將語言模塊的配置傳入即可,此外由於所有的語言模塊並不是都必須要加載的,在這裏是通過按需加載的方式實現的,以減少包的體積,實際上我們的主包也更推薦以懶加載的方式載入到自己的項目當中。

在完成了上述的集成之後,我們就可以成功地將項目完整的啓動了,但是在實際使用的過程中發現還是有一些BUG,比如我們打開Graph Editor最新的在線鏈接,可以發現Sketch樣式是無效的,所以我們還需要對整個包做一些BUG的修復,在這裏主要列舉了三個BUG的修改,僅作參考。

外部加載模塊問題,衆所周知(或者沒那麼周知)mxGraph的很多模塊都是掛載到window上的,這裏的模塊有多種類型,比如圖形模塊mxGraphModelmxGeometrymxCell等等,工具模塊mxUtilsmxEventmxCodec等等,但是在這裏我們是作爲npm包引進的,我們是不希望污染全局變量的,而且我們通過xml來加載圖形的時候是需要找到這些圖形模塊,否則是無法呈現出圖形的,經過分析源碼我們可以知道動態加載在mxCodecdecode方法上,於是我們需要在這裏處理好模塊這個加載函數,當然可能通過external的方式加載mxGraph模塊包的方式直接掛在window上也是個可行的辦法,但是在這裏我們是重寫了相關模塊來實現的。

// 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 // 因爲需要處理的`XML Node`可能不在`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;
};

Sketch無效問題,如果我們打開Graph Editor最新的在線鏈接,可以發現Sketch樣式是無效的,因爲現在mxGraph是不再繼續維護了,所以反饋BUG是無效的,實際上這個問題處理也比較簡單,我們可以通過git回溯到功能正常的版本就可以了。

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

Scroll與菜單的掛載子容器問題,這個問題比較尷尬,因爲mxGraph一直是以一整個應用來設計的,但是當我們需要將其嵌入到其他應用中的時候,由於我們的滾動容器可能就是body,此時當我們已經將頁面向下滾動了一部分,之後再打開流程圖編輯器的話,就會發現我們沒有辦法正常拖拽畫布或者選中圖形了,並且菜單的位置計算也出現了錯誤,所以在這裏需要保證相關的位置計算正確。

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;
};

// 處理菜單的掛載容器
mxPopupMenu.prototype.showMenu = function () {
  container.appendChild(this.div);
  mxUtils.fit(this.div);
};
// 處理菜單的掛載子容器
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);
  }
};

最後,實際上由於沒有TreeShaking,並且我們可能需要動態地加載圖形,所以我們整個包體積還是比較大的,所以爲了不影響應用的主體能力,我們還是建議使用懶加載的方式去加載編輯器,具體來說就是可以通過import type來引入類型,然後通過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]));
};

嵌入drawio

在上邊我們完成了基於mxGraph Example的流程圖編輯器NPM包,但是畢竟mxGraph已經不再維護,而JGraphmxGraph Example的基礎上又擴展開發了drawio,這是個長期維護的項目,即使drawio不接受貢獻,但是依舊不妨礙他的活躍,可以在這裏體驗drawio的部署版本https://app.diagrams.net/

在這裏我們更要關注的是如何將drawio嵌入到我們的應用當中,drawio提供了embed的方式來幫助我們集成到自己的應用中,通過iframe的方式利用postMessage進行通信,這樣也不會受到跨域的限制,由此來實現編輯、導入導出的一系列功能。

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

我們在這裏通過簡單封裝通信的方式來實現drawio的嵌入,具體來說就是通過iframe的方式來加載drawio,當然因爲網絡問題,真正投入到生產環境的話還是需要私有化部署一套纔可以,私有化部署了之後也可以進行二開,當然如果在網絡可以支持的情況下直接使用drawio的部署版本也是有可行性的,最終的數據存儲都會存儲到我們自己的應用當中。

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 || "流程圖",
        });
  }
  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();
  }
}

而在我們使用的時候,直接實例化對象並且進入編輯模式就可以了,另外drawio支持多種數據的導出,但是在這裏還是推薦xmlsvg,簡單來說就是這種數據結構是在svg標籤的基礎上攜帶了xml數據,這樣的話作爲部分冗餘字段是可以直接展示爲svg也可以直接將其導入到drawio再次編輯的,如果僅導出爲svg則是不能再導入編輯的,如果只導出了xml雖然可以再次編輯,但是想作爲svg展示的話就需要viewer.min.js來渲染,這部分還是看需求來決定導出類型比較合適。

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

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://github.com/jgraph/drawio
https://github.com/jgraph/mxgraph
https://github.com/maxGraph/maxGraph
https://github.com/jgraph/mxgraph-js
https://zh.wikipedia.org/wiki/Draw.io
https://juejin.cn/post/7017686432009420808
https://github.com/jgraph/drawio-integration
https://jgraph.github.io/mxgraph/javascript/index.html
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章