基於drawio構建流程圖編輯器
drawio
是一款非常強大的開源在線的流程圖編輯器,支持繪製各種形式的圖表,提供了Web
端與客戶端支持,同時也支持多種資源類型的導出。
描述
在我們平時寫論文、文檔時,爲了更好地闡述具體的步驟和流程,我們經常會有繪製流程圖的需求,這時我們可能會想到Visio
,可能會想到ProcessOn
,同時我們也許會因爲Visio
其龐大的體積望而卻步,也會因爲ProcessOn
只有免費的幾張圖而處處掣肘,那麼在此時我們就要請出我們的主角drawio
了,對於單純的使用人員,使用drawio
可以獲得一個簡單免費無限空間的高級繪圖工具,而對於進階的開發人員,可以爲自己和團隊非常簡單快速的搭建一個免費無限空間且功能強大的繪圖工具,何樂而不爲。
drawio
項目的歷史可以追溯至2005
年,當時JGraph
團隊開始開發mxGraph
,這是一個基於JavaScript
與SVG
的圖表庫,用於在Web
應用程序中創建交互式圖表,支持了Firefox 1.5
和Internet Explorer 5.5
。2012
年,JGraph
團隊將已有的程序刪除了Java applet
相關的部分,並且從域名diagram.ly
改爲draw.io
,這是因爲創始人覺得io
比ly
更酷,而drawio
則成爲了一個基於mxGraph
的圖表編輯器,可以在瀏覽器中運行並創建圖表,最初是一個內部工具,而後來mxGraph
團隊決定將其作爲一個開源項目發佈。在2020
年JGraph
團隊處於安全和版權的考慮,將draw.io
移至diagrams.net
域,diagrams.net
目前仍然是一個活躍的開源項目,擁有大量的用戶和貢獻者,支持多種圖表類型,包括流程圖、組織結構圖、UML
圖等,同時還支持多種文件格式,包括XML
、PNG
、JPEG
、PDF
等。
集成drawio
到我們自己的項目有很多優點,包括但不限於 開箱即用的能力、應用於生產環境的非常成熟的項目、開源項目、支持二次開發、強大的社區等等,但是同樣的drawio
也存在一些不足,從上邊簡單的概括實際上可以看出來這個項目的歷史實際上是非常久遠了,本身也沒有支持ESM
,有大量的原型鏈修改,如果看過相關源碼可以發現實際上是非常複雜的,代碼的可讀性和可維護性都不是很好,同時也沒有支持TypeScript
,這些都是我們需要解決的問題。實際上,現代瀏覽器中更加流行的方案應該是完全基於Canvas
繪製的畫板,當然這種方式的成本會相當高,如果我們想以低成本的方式集成一個流程圖編輯器到我們自己的項目,那麼drawio
是最好的選擇之一。
那麼問題來了,我們應該如何將drawio
集成到自己的項目當中,我們在這裏提供了兩種方案,一種是獨立編輯器,這種方式是將Npm
包打包到自己的項目當中,另一種是嵌入drawio
,這種方式是通過iframe
與部署好的drawio
項目進行通信,這兩種方式都可以用來完成流程圖的集成,文中描述的相關內容都在 Github | Editor DEMO 中。
獨立編輯器
首先我們來研究下作爲獨立編輯器集成到我們自己項目當中的方式,我們先來看一下mxGraph
項目,文檔地址爲https://jgraph.github.io/mxgraph/
,可以看到mxGraph
有.NET
、Java
、JavaScript
三種語言的支持,在這裏我們主要關注的是JavaScript
的支持,在文檔中實際上我們是可以找到相當多的Example
,在這裏我們需要關注的是Graph Editor
這個示例。當我們打開這個示例https://jgraph.github.io/mxgraph/javascript/examples/grapheditor/www/index.html
之後,可以發現這實際上是一個非常完整的編輯器項目,而且我們可以看到這個鏈接的地址是以.html
結尾並且是部署在Github
的Git 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
相同的功能,是支持TypeScript
、Tree Shaking
、ES Module
的現代化矢量圖形庫。
回到集成獨立編輯器的問題上來,我們的目標是要Graph Editor
,而這個編輯器又是以mxGraph
爲基礎完成的,所以我們當前的第一步就是將mxGraph
作爲依賴安裝,mxGraph
是有npm
包的,所以直接安裝這個依賴就可以了,對於TS
項目也是有@typed-mxgraph/typed-mxgraph
包,再指定一下tsconfig.json
的typeRoots
配置項即可,實際上在這裏我們並不是很關心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
實際上是有很多外部的資源引用的,包括多語言、圖片等,而實際上我們在上邊配置的諸如mxBasePath
、mxResourceExtension
等都是爲了要處理外部資源,但是由於我們目前是更希望作爲npm
包來引用的,處理資源路徑問題又相對比較麻煩,所以在這裏我們採取的方案是將所有的圖片資源都處理成了Base64
直接集成進去,當然在這個過程中也修改了相關代碼使其不會發起請求去加載外部資源,另外由於一些修改過程中的客觀原因,在項目中圖片資源分爲了兩種,一種是轉換成了Base64
的TS
文件,一種是藉助loader
加載的資源,當然本質上是都是Base64
的資源,在這裏實現的目標就是不再發起外部資源的請求。 - 藉助
ESLint
精簡部分代碼,去除部分IE
瀏覽器的支持,Prettier
格式化各個模塊的代碼。這部分工作是個比較複雜的,首先是藉助ESLint
精簡代碼,在這裏就是對核心模塊逐步放開ESLint
規則,依據這些規則修改相關代碼,例如藉助no-undef
就可以找到所有未定義的模塊,然後再處理這些模塊的引用,通過no-unused-vars
規則找到未使用的變量,由此來精簡代碼。我們現在都更加聚焦於現代瀏覽器,對於IE
瀏覽器不希望再做額外的支持,於是在這裏我們也去除了部分兼容IE
的代碼。藉助於Prettier
以及prettier/prettier
規則我們可以將代碼格式化,在格式化代碼之後可以看到相關模塊的實現會比較舒服,而且也解決了一些隱式的問題,並且以Graph.js
核心類爲例,代碼量從11941
行精簡到了10637
行。 - 處理多語言,目前支持
EN
和ZH-CN
兩種語言的加載。這部分工作主要是多語言的支持,目前我們希望的是不再加載外部資源,那麼多語言當然也不例外,在這裏我們已經將相關的語言定義好,要加載哪種語言之需要在啓動編輯器的時候,將語言模塊的配置傳入即可,此外由於所有的語言模塊並不是都必須要加載的,在這裏是通過按需加載的方式實現的,以減少包的體積,實際上我們的主包也更推薦以懶加載的方式載入到自己的項目當中。
在完成了上述的集成之後,我們就可以成功地將項目完整的啓動了,但是在實際使用的過程中發現還是有一些BUG
,比如我們打開Graph Editor
最新的在線鏈接,可以發現Sketch
樣式是無效的,所以我們還需要對整個包做一些BUG
的修復,在這裏主要列舉了三個BUG
的修改,僅作參考。
外部加載模塊問題,衆所周知(或者沒那麼周知)mxGraph
的很多模塊都是掛載到window
上的,這裏的模塊有多種類型,比如圖形模塊mxGraphModel
、mxGeometry
、mxCell
等等,工具模塊mxUtils
、mxEvent
、mxCodec
等等,但是在這裏我們是作爲npm
包引進的,我們是不希望污染全局變量的,而且我們通過xml
來加載圖形的時候是需要找到這些圖形模塊,否則是無法呈現出圖形的,經過分析源碼我們可以知道動態加載在mxCodec
的decode
方法上,於是我們需要在這裏處理好模塊這個加載函數,當然可能通過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
已經不再維護,而JGraph
在mxGraph 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