鸿蒙PC平台图匠流程图编辑器适配实战:基于 draw.io 开源引擎与 Electron 壳方案的全功能离线图表编辑器开发
图匠流程图编辑器是一款基于 draw.io(diagrams.net)开源引擎构建的全功能离线图表编辑器,支持流程图、UML 图、ER 图、BPMN、网络拓扑、组织架构等数十种图表类型。本文记录在鸿蒙 PC 平台上,将 draw.io 官方 Web 应用完整移植到 Electron 壳工程并解决 9 类鸿蒙特有崩溃问题的全过程——涵盖原型链 hook 注入、原生 select 替换、对话框三件套接管、保存/打开 IPC 通道重建、颜色选择器模态化、关闭拦截旁路、菜单裁剪与右键菜单改造等核心适配机制的实现细节与踩坑经验。
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_drawio
一、技术架构分析
1.1 整体架构
本项目与从零构建的 Web 应用不同,核心挑战在于将一个已有的、高度依赖浏览器原生能力的复杂 Web 应用适配到鸿蒙 ArkWeb 引擎。整体采用三层架构:
- draw.io 官方 Web 层:基于 mxGraph 图形引擎,包含完整的图表编辑器、模板库、样式面板、文件管理等功能,代码量超过 20 万行(app.min.js + extensions.min.js + mxGraph 库)
- 鸿蒙壳适配层(shell-adapter.js):自研的 1093 行适配脚本,通过原型链 hook、DOM 替换、IPC 通道重建等机制,将 draw.io 对浏览器原生能力的 9 类依赖逐一旁路
- 鸿蒙 HAP 壳工程:通过 web_engine 模块加载 Web 应用,提供窗口管理、文件系统访问、屏幕自适应等系统能力
这种架构的核心难点在于:不能修改 draw.io 官方代码(否则升级维护成本不可接受),所有适配必须通过外部注入和原型链覆盖实现,且 hook 时序必须精确——目标全局对象尚未就绪时 hook 会静默失败。
1.2 技术方案
| 对比项 | draw.io 官方 Web 版 | 鸿蒙适配版(图匠) |
|---|---|---|
| 运行环境 | 浏览器(Chrome/Firefox/Safari) | 鸿蒙 PC(ArkWeb 引擎 + Electron 壳) |
| 文件保存 | 表单提交到 _blank 新窗口 | IPC 通道 + 主进程系统对话框 |
| 文件打开 | input[type=file] + File API | IPC 通道 + 主进程系统对话框 |
| 下拉选择 | 原生 select 元素 | 自研 div 下拉(保留 select 语义) |
| 弹窗交互 | 原生 alert/confirm/prompt | 自研 DOM 对话框(复刻官方样式) |
| 颜色选择 | mxWindow 非模态窗口 | CustomDialog 模态路径 |
| 关闭拦截 | window.onbeforeunload | 属性定义吞写 + 主进程兜底 |
| 部署方式 | 服务端部署 | HAP 包(鸿蒙应用,完全离线) |
1.3 核心功能清单
| 功能模块 | 具体能力 |
|---|---|
| 图表编辑 | 流程图/UML/ER/BPMN/网络拓扑/组织架构等数十种图表类型 |
| 图形引擎 | mxGraph 完整库:拖拽、连线、对齐、分组、样式、图层 |
| 形状库 | 100+ 预置形状库(AWS/Azure/GCP/Cisco/电气/流体等) |
| 文件操作 | 新建(含模板选择)、打开 .drawio/.xml、保存、导出 SVG/PNG/PDF |
| 样式编辑 | 格式面板、颜色选择器、线条样式、字体设置 |
| 高级功能 | PlantUML 集成、Mermaid 渲染、Visio 导入、自由手绘 |
| 鸿蒙适配 | 9 类 hook 机制:保存/打开/下拉/对话框/颜色/关闭/菜单/便签本/帮助图标 |
| 完全离线 | 零外部网络依赖,所有资源本地化 |
二、环境准备
2.1 开发环境要求
| 项目 | 版本/信息 |
|---|---|
| 操作系统 | Windows 10/11 |
| 核心框架 | Electron (Node.js + Chromium) |
| 基础引擎 | draw.io 官方开源包 + mxGraph 图形库 |
| 适配层 | 自研 shell-adapter.js(1093 行)+ PreConfig.js |
| 目标设备 | 鸿蒙 PC |
| 目标架构 | arm64-v8a |
| 开发工具 | DevEco Studio(鸿蒙官方 IDE) |
2.2 项目结构
ohos_hap/
├── electron/ # Electron 壳
├── web_engine/ # 鸿蒙壳工程
│ └── src/main/resources/resfile/resources/app/
│ ├── main.js # 主进程(窗口管理 + 文件操作 IPC)
│ ├── index.html # 入口页面(加载 draw.io 编辑器)
│ ├── styles/
│ │ └── grapheditor.css # draw.io 官方样式表(4086 行)
│ ├── js/
│ │ ├── bootstrap.js # URL 参数解析与启动引导
│ │ ├── PreConfig.js # 预配置(菜单裁剪 + 语言固定 + 主题锁定)
│ │ ├── main.js # draw.io 启动入口
│ │ ├── shell-adapter.js # 鸿蒙壳适配脚本(9 大 hook 机制)
│ │ ├── app.min.js # draw.io 核心代码(压缩版)
│ │ ├── extensions.min.js # draw.io 扩展功能
│ │ ├── shapes-14-6-5.min.js # 形状库
│ │ └── ... # 其他 draw.io 依赖
│ └── mxgraph/ # mxGraph 图形引擎库
└── AppScope/ # 应用配置(bundleName: com.wei.drawiowar)
2.3 依赖说明
本项目的依赖结构与从零构建的应用截然不同——核心功能全部来自 draw.io 官方开源包,自研部分仅集中在适配层:
| 模块 | 来源 | 用途 |
|---|---|---|
| app.min.js | draw.io 官方 | 编辑器核心(菜单/工具栏/属性面板/文件管理) |
| extensions.min.js | draw.io 官方 | 扩展功能(PlantUML/Mermaid/Visio 导入等) |
| mxGraph 库 | draw.io 官方 | 图形引擎(画布/图形/连线/布局/编解码) |
| shell-adapter.js | 自研 | 鸿蒙适配层:9 类 hook 机制,接管原生浏览器交互 |
| PreConfig.js | 自研 + 官方 | 预配置:菜单裁剪、语言固定、主题锁定 |
| main.js(根目录) | 自研 | Electron 主进程:窗口管理 + 文件 IPC |
三、核心适配流程
3.1 主进程实现(main.js)
主进程负责窗口创建和文件操作 IPC。与 ApE 等项目相比,DrawIOWar 的主进程有两个特殊之处:一是拦截新窗口创建(防止 draw.io 的 _blank 表单提交触发 SubWindow 崩溃),二是疏通窗口关闭(draw.io 注册的 beforeunload 拦截器在鸿蒙壳下会导致叉叉无响应)。
// DrawIOWar 主进程
var electron = require('electron');
var app = electron.app;
var BrowserWindow = electron.BrowserWindow;
var screen = electron.screen;
var ipcMain = electron.ipcMain;
var Menu = electron.Menu;
var dialog = electron.dialog;
var fs = require('fs');
var pathMod = require('path');
app.disableHardwareAcceleration();
var mainWindow = null;
function createWindow() {
try {
var display = screen.getPrimaryDisplay();
mainWindow = new BrowserWindow({
width: Math.floor(display.workAreaSize.width * 0.92),
height: Math.floor(display.workAreaSize.height * 0.88),
frame: true,
transparent: false,
resizable: true,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
webSecurity: false
}
});
// 拦截所有新窗口创建(表单 target=_blank / 链接 / 脚本触发),
// 避免鸿蒙适配层创建 SubWindow 导致 SIGABRT 崩溃
if (mainWindow.webContents != null
&& typeof mainWindow.webContents.setWindowOpenHandler === 'function') {
mainWindow.webContents.setWindowOpenHandler(function() {
return { action: 'deny' };
});
}
// 疏通窗口关闭:webapp 注册的关闭拦截器会让 beforeunload 阻止卸载,
// 表现为点叉叉无响应;此处忽略阻止、放行关闭
if (mainWindow.webContents != null
&& typeof mainWindow.webContents.on === 'function') {
mainWindow.webContents.on('will-prevent-unload', function(event) {
try { event.preventDefault(); } catch (e) { }
});
}
if (typeof mainWindow.removeMenu === 'function') { mainWindow.removeMenu(); }
mainWindow.loadFile('index.html');
} catch (e) {
console.warn('[DrawIOWar] 创建窗口失败:', e.message);
}
}
关键配置说明:
- webSecurity: false——draw.io 的某些资源加载需要跨域放行
- autoHideMenuBar: true——配合 removeMenu 双重保障菜单栏不可见
- setWindowOpenHandler deny——拦截 draw.io 官方保存流程的 _blank 表单提交,防止 SubWindow 崩溃
- will-prevent-unload 兜底——与 shell-adapter.js 的属性定义吞写配合,确保叉叉可正常关闭
IPC 通道注册:
主进程注册了 2 个 IPC 通道,分别用于保存和打开文件。保存通道支持对话框不可用时回退桌面直存:
// 保存通道:弹出鸿蒙系统保存对话框,用户确认后写入所选路径
ipcMain.handle('war:save-file', async function(event, payload) {
try {
var name = payload != null && payload.name != null
? String(payload.name) : '未命名图表.drawio';
var data = payload != null && payload.data != null
? String(payload.data) : '';
var desktop = app.getPath('desktop');
if (desktop == null || desktop.length === 0) {
return { ok: false, error: '无法定位桌面目录' };
}
var suggested = pathMod.join(desktop, name);
try {
var result = await dialog.showSaveDialog(mainWindow, {
defaultPath: suggested,
filters: [
{ name: 'draw.io 绘图', extensions: ['drawio'] },
{ name: 'XML 文件', extensions: ['xml'] },
{ name: '所有文件', extensions: ['*'] }
]
});
if (result != null && result.canceled) { return { ok: false, canceled: true }; }
if (result != null && result.filePath != null && result.filePath.length > 0) {
fs.writeFileSync(result.filePath, data, 'utf8');
return { ok: true, path: result.filePath };
}
} catch (eDlg) {
console.warn('[DrawIOWar] 保存对话框不可用,回退桌面直存:', eDlg.message);
}
// 回退:对话框异常时直写桌面
fs.writeFileSync(suggested, data, 'utf8');
return { ok: true, path: suggested };
} catch (e) {
return { ok: false, error: e != null && e.message != null ? e.message : String(e) };
}
});
// 打开通道:弹出鸿蒙系统文件选择框,选中后读取内容返回渲染层
ipcMain.handle('war:open-file', async function(event) {
try {
var desktop = app.getPath('desktop');
var options = {
properties: ['openFile'],
filters: [
{ name: 'draw.io 绘图', extensions: ['drawio', 'xml'] },
{ name: '所有文件', extensions: ['*'] }
]
};
if (desktop != null && desktop.length > 0) { options.defaultPath = desktop; }
try {
var result = await dialog.showOpenDialog(mainWindow, options);
if (result == null || result.canceled === true
|| result.filePaths == null || result.filePaths.length === 0) {
return { ok: false, canceled: true };
}
var file = result.filePaths[0];
var data = fs.readFileSync(file, 'utf8');
return { ok: true, path: file, name: pathMod.basename(file), data: data };
} catch (eDlg) {
return { ok: false, error: '打开对话框不可用' };
}
} catch (e) {
return { ok: false, error: e != null && e.message != null ? e.message : String(e) };
}
});
3.2 预配置与菜单裁剪(PreConfig.js)
PreConfig.js 在 draw.io 启动脚本加载之前执行,负责固定界面语言、锁定主题、裁剪不适用的菜单项。draw.io 官方提供了 DRAWIO_CONFIG 配置机制,本项目在此基础上增加了鸿蒙专属的菜单裁剪:
// draw.io 官方配置项
window.DRAWIO_PUBLIC_BUILD = true;
window.EXPORT_URL = null; // 无服务端导出,PDF 走打印对话框
window.DRAWIO_BASE_URL = null;
window.DRAWIO_VIEWER_URL = null;
window.DRAWIO_LIGHTBOX_URL = null;
window.DRAW_MATH_URL = 'math4/es5';
// 鸿蒙专属配置
urlParams['sync'] = 'manual'; // 同步模式固定为手动
urlParams['lang'] = urlParams['lang'] || 'zh'; // 默认简体中文
window.DRAWIO_CONFIG = {
hideMenus: ['help'], // 移除"帮助"菜单(含外链与联网子项)
hideMenuItems: [
// "其它"菜单:Electron 环境专属项
'googleFonts', 'spellCheck', 'autoBkp', 'drafts',
// 主题/语言子菜单整项移除
'theme', 'language',
// 默认主题分支其余项
'mathematicalTypesetting', 'autosave', 'collapseExpand',
'diagramLanguage', 'editDiagram', 'plugins', 'configuration',
// 文件菜单:嵌入/发布/新增库
'embed', 'publish', 'newLibrary',
// 便签本入口
'scratchpad', 'addToScratchpad'
]
};
// 主题固定:清除 localStorage 中的历史主题值,保证每次启动均为默认外观
(function() {
try {
var raw = localStorage.getItem('.drawio-config');
if (raw != null) {
var cfg = JSON.parse(raw);
if (cfg && cfg.ui) {
delete cfg.ui;
localStorage.setItem('.drawio-config', JSON.stringify(cfg));
}
}
} catch (e) { }
})();
// Service Worker 惰性化:file:// 协议下无法注册 SW,替换为空实现避免异常
(function() {
try {
if (location.protocol === 'file:'
&& window.navigator && window.navigator.serviceWorker) {
var noopReg = {
scope: './', active: null, waiting: null, installing: null,
update: function() { return Promise.resolve(); },
unregister: function() { return Promise.resolve(false); }
};
window.navigator.serviceWorker.register = function() {
return Promise.resolve(noopReg);
};
}
} catch (e) { }
})();
3.3 鸿蒙壳适配脚本(shell-adapter.js)
shell-adapter.js 是整个适配工作的核心,共 1093 行,实现了 9 类 hook 机制。脚本以 IIFE 形式在 index.html 底部同步执行,通过 500ms 轮询等待 draw.io 全局对象就绪后逐一注入 hook,所有 hook 成功后清除轮询定时器。
hook 轮询框架:
(function() {
'use strict';
var ipcRenderer = null;
try { ipcRenderer = require('electron').ipcRenderer; } catch (e) { }
var capturedUi = null; // 菜单构建链捕获的编辑器实例
// 状态反馈:在菜单栏状态位显示结果(不使用任何弹窗)
function setStatus(msg) {
try {
var st = document.querySelector('.geStatus');
if (st == null) { st = document.getElementById('geStatus'); }
if (st != null) {
while (st.firstChild) { st.removeChild(st.firstChild); }
st.appendChild(document.createTextNode(msg));
}
} catch (e) { }
}
// 轮询等待全局对象就绪,所有 hook 成功后清除定时器
var timer = setInterval(function() {
var saveOk = hookSave();
var trimOk = hookDeviceOnlySubmenus();
var iconOk = replaceGitHubIcon();
var padOk = hookScratchpadOff();
var dlgOk = hookNativeDialogs();
var helpOk = hookHelpIcons();
var newOk = hookNewAction();
var colorOk = hookColorPicker();
var ctxOk = hookContextMenu();
// ... 兜底 hook ...
if (saveOk && trimOk && iconOk && padOk && dlgOk
&& helpOk && newOk && colorOk && ctxOk) {
clearInterval(timer);
window.setTimeout(hookScratchpadOff, 3000);
}
}, 500);
window.setTimeout(function() { clearInterval(timer); }, 120000);
})();
轮询机制的关键设计:
- 500ms 间隔:draw.io 的 app.min.js 经 bootstrap.js 以 defer 异步加载,全局对象(EditorUi/App/Menus/mxUtils 等)在脚本执行时尚未就绪
- 每个 hook 函数独立返回布尔值,只有全部成功才清除定时器
- 120 秒超时兜底:防止某个 hook 永远失败导致轮询永不终止
- 便签本 hook 在 3 秒后再校验一次:customLibraries 直载为异步,可能晚于轮询完成
第 1 类 hook:接管保存入口(hookSave)
draw.io 官方保存流程最终会提交表单到 _blank 新窗口(mxXmlRequest.simulate),在鸿蒙适配层会触发 SubWindow 创建导致崩溃。hookSave 覆盖 EditorUi 与 App 两层原型的 save/saveFile 方法,将所有保存入口收口到 IPC 通道:
function hookSave() {
if (window.__warSaveHooked === true) { return true; }
try {
if (window.EditorUi != null && window.EditorUi.prototype != null) {
window.EditorUi.prototype.saveFile = function() { saveViaShell(this); };
window.EditorUi.prototype.save = function() { saveViaShell(this); };
}
if (window.App != null && window.App.prototype != null
&& typeof window.App.prototype.saveFile === 'function'
&& typeof window.App.prototype.save === 'function') {
window.App.prototype.saveFile = function() { saveViaShell(this); };
window.App.prototype.save = function() { saveViaShell(this); };
window.__warSaveHooked = true;
}
} catch (e) { }
return window.__warSaveHooked === true;
}
// 保存实现:经 IPC 由主进程弹出鸿蒙系统保存对话框
function saveViaShell(ui) {
if (ipcRenderer == null) { setStatus('保存失败:壳环境未连接'); return; }
try {
if (ui.editor.graph.isEditing()) { ui.editor.graph.stopEditing(); }
var raw = ui.editor.getOrCreateFilename
? ui.editor.getOrCreateFilename() : null;
var name = sanitizeName(raw);
var data = ui.getFileData(true);
ipcRenderer.invoke('war:save-file', { name: name, data: data })
.then(function(res) {
if (res != null && res.ok) {
var saved = res.path != null && String(res.path).length > 0
? String(res.path).split(/[\\\/]/).pop() : name;
setStatus('已保存: ' + saved);
try { ui.editor.setModified(false); } catch (e1) { }
try { ui.editor.setFilename(saved); } catch (e2) { }
} else if (res != null && res.canceled) {
setStatus('已取消保存');
} else {
setStatus('保存失败: ' + (res != null && res.error ? res.error : '未知错误'));
}
}, function(err) {
setStatus('保存失败: ' + (err != null && err.message ? err.message : '未知错误'));
});
} catch (e) {
setStatus('保存失败: ' + (e != null && e.message ? e.message : '未知错误'));
}
}
第 2 类 hook:裁剪子菜单仅保留设备项(hookDeviceOnlySubmenus)
draw.io 的"从…打开"/“导入自”/"从…打开库"子菜单包含浏览器、URL、Google Drive、OneDrive 等云端入口,在离线壳环境下全部不可用。hook 替换 Menus.prototype.get 方法,在首次读取目标菜单时将构建函数替换为仅含"设备…"项的版本:
function hookDeviceOnlySubmenus() {
if (window.Menus == null || window.Menus.prototype == null) { return false; }
if (window.__warMenuTrimmed === true) { return true; }
try {
var origGet = window.Menus.prototype.get;
window.Menus.prototype.get = function(name) {
var menu = origGet.apply(this, arguments);
if (capturedUi == null && this.editorUi != null) {
capturedUi = this.editorUi;
}
if ((name === 'importFrom' || name === 'openLibraryFrom'
|| name === 'openFrom') && menu != null && menu.__warDeviceOnly !== true) {
menu.__warDeviceOnly = true;
var ui = this.editorUi;
if (ui != null) {
if (name === 'importFrom') {
menu.funct = function(pm, parent) {
pm.addItem(mxResources.get('device') + '...', null,
function() { ui.importLocalFile(true); }, parent);
};
} else if (name === 'openFrom') {
menu.funct = function(pm, parent) {
pm.addItem(mxResources.get('device') + '...', null,
function() { openViaShell(ui); }, parent);
};
} else {
menu.funct = function(pm, parent) {
pm.addItem(mxResources.get('device') + '...', null,
function() { ui.pickLibrary(App.MODE_DEVICE); }, parent);
};
}
}
}
return menu;
};
window.__warMenuTrimmed = true;
} catch (e) { }
return window.__warMenuTrimmed === true;
}
第 3 类 hook:原生 select 替换(installSelectReplacement)
鸿蒙适配层把原生 select 元素的下拉弹层实现为 SubWindow,第一次展开可成功,第二次展开时 XComponent 等待超时直接 SIGABRT。替换策略是:将官方 select 隐藏(display:none)继续承载 value/options/change 语义,视觉与交互接管为自研 div 下拉:
function warSelectWrap(sel) {
if (sel.__warSelectWrapped === true || sel.parentNode == null) { return; }
sel.__warSelectWrapped = true;
var wrapper = document.createElement('div');
wrapper.style.position = 'relative';
wrapper.style.display = 'inline-block';
sel.parentNode.insertBefore(wrapper, sel);
var btn = document.createElement('div');
var label = document.createElement('span');
var arrow = document.createElement('span');
arrow.textContent = ' \u25BE';
btn.appendChild(label);
btn.appendChild(arrow);
// 视觉贴近官方 select 的行内样式
btn.style.padding = '2px 8px';
btn.style.border = '1px solid #c9c9c9';
btn.style.borderRadius = '4px';
btn.style.background = '#fff';
btn.style.cursor = 'pointer';
var popup = document.createElement('div');
popup.style.display = 'none';
popup.style.position = 'absolute';
popup.style.maxHeight = '260px';
popup.style.overflowY = 'auto';
var list = document.createElement('div');
popup.appendChild(list);
wrapper.appendChild(btn);
wrapper.appendChild(popup);
wrapper.appendChild(sel);
sel.style.display = 'none';
// 点击按钮展开/收起,弹层挂 body 绝对定位避免被父容器 overflow 裁剪
btn.addEventListener('click', function(ev) {
ev.preventDefault(); ev.stopPropagation();
warSelectToggle(entry);
});
}
// 安装替换:启动扫描 + MutationObserver 持续接管动态插入的 select
function installSelectReplacement() {
warSelectScan(document.body);
var mo = new MutationObserver(function(muts) {
for (var i = 0; i < muts.length; i++) {
var added = muts[i].addedNodes;
for (var j = 0; j < added.length; j++) { warSelectScan(added[j]); }
}
});
mo.observe(document.documentElement, { childList: true, subtree: true });
// 官方程序化设值不触发 change,轮询比对缓存同步按钮文字
window.setInterval(warSelectRefreshAll, 800);
}
第 4 类 hook:原生对话框三件套替换(hookNativeDialogs)
鸿蒙适配层把原生弹窗类接口实现为 SubWindow,调用即闪退。替换分两层:功能层将 EditorUi.prototype.confirm 替换为回调式自研对话框,兜底层将 mxUtils 的 alert/confirm/prompt 成员替换为自研实现或安全返回值:
function hookNativeDialogs() {
if (window.__warDialogsHooked === true) { return true; }
try {
var proto = window.EditorUi != null ? window.EditorUi.prototype : null;
// 确认框替换:回调式签名完整保留
if (proto != null && proto.__warConfirmHooked !== true
&& typeof proto.confirm === 'function') {
proto.confirm = function(msg, okFn, cancelFn) {
warShowDialogBox(msg, [
{ text: warBtnText('ok', '确定'), fn: okFn },
{ text: warBtnText('cancel', '取消'), fn: cancelFn }
]);
};
proto.__warConfirmHooked = true;
}
// 兜底层:mxUtils 三成员替换
if (window.mxUtils != null && window.mxUtils.__warReplaced !== true) {
window.mxUtils.alert = function(msg) {
warShowDialogBox(msg, [{ text: warBtnText('ok', '确定'), fn: null }]);
};
window.mxUtils.confirm = function() { return false; };
window.mxUtils.prompt = function() { return null; };
window.mxUtils.__warReplaced = true;
}
// ...
} catch (e) { }
return window.__warDialogsHooked === true;
}
第 5 类 hook:颜色选择器模态化(hookColorPicker)
官方 pickColor 在非模态路径下创建 ColorWindow(继承自 mxWindow),mxWindow 在鸿蒙适配层触发 SubWindow 创建导致崩溃。修复方案是覆盖 pickColor 强制走模态路径 pickColorModal(使用 CustomDialog,纯 DOM 实现):
function hookColorPicker() {
if (window.__warColorHooked === true) { return true; }
try {
var proto = window.EditorUi != null ? window.EditorUi.prototype : null;
if (proto == null || proto.__warColorHooked === true) { return false; }
if (typeof proto.pickColor !== 'function'
|| typeof proto.pickColorModal !== 'function') { return false; }
proto.pickColor = function(a, b, c, e, f, h, g, n) {
this.pickColorModal(a, b, c, e, f, h, g, n);
};
proto.__warColorHooked = true;
window.__warColorHooked = true;
} catch (e) { }
return window.__warColorHooked === true;
}
第 6 类 hook:关闭拦截旁路
draw.io 官方在启动时注册 window.onbeforeunload 关闭拦截器(文档已修改时返回提示字符串),Electron 收到 truthy 返回值即取消窗口关闭。鸿蒙适配层未实现"再点一次强制关闭"逻辑,表现为点叉叉无响应。shell-adapter.js 先于官方代码执行,把 onbeforeunload 定义为只写空值:
try {
Object.defineProperty(window, 'onbeforeunload', {
configurable: true,
get: function() { return null; },
set: function() {
try {
window.__warUnloadSwallows = (window.__warUnloadSwallows || 0) + 1;
} catch (eC) { }
}
});
window.__warUnloadGuard = true;
} catch (e) { }
第 7 类 hook:新建动作修复(hookNewAction)
draw.io 的新建流程最终调用 App.prototype.createFile,该方法在设备模式下调用 showSaveFilePicker 唤起系统文件选择器,在鸿蒙壳中不可用。修复方案是覆盖 createFile,跳过选择器直接创建 LocalFile 并在当前窗口装载:
function hookNewAction() {
if (window.__warNewHooked === true) { return true; }
try {
if (window.App != null && window.App.prototype != null
&& typeof window.App.prototype.createFile === 'function'
&& window.App.prototype.__warCreateFileHooked !== true) {
window.App.prototype.createFile = function(title, data, libs, mode) {
try {
if (this.spinner != null) {
try { this.spinner.stop(); } catch (eSp) { }
}
var xml = (data != null && data.length > 0)
? data : this.diagramXml;
if (typeof window.LocalFile === 'function') {
var file = new window.LocalFile(
this, xml, title || this.defaultFilename, null == mode);
if (typeof this.fileCreated === 'function') {
this.fileCreated(file, libs);
return;
}
}
} catch (eCf) {
if (typeof this.handleError === 'function') {
this.handleError(eCf);
}
}
};
window.App.prototype.__warCreateFileHooked = true;
}
} catch (eCF) { }
// 屏蔽系统文件选择器接口
try { delete window.showSaveFilePicker; } catch (e) { }
try { delete window.showOpenFilePicker; } catch (e) { }
try { window.showSaveFilePicker = undefined; } catch (e) { }
try { window.showOpenFilePicker = undefined; } catch (e) { }
// ...
}
第 8、9 类 hook:便签本停用 + 问号帮助图标根除
便签本功能通过覆盖 EditorUi.prototype.libraryLoaded 断根(.scratchpad 不装载、不渲染、不留设置记录),toggleScratchpad 替换为空函数双保险。问号帮助图标通过覆盖 createHelpIcon 工厂返回不可见节点全量根除,另以 MutationObserver 清扫不走工厂的裸问号图。
3.4 入口页面(index.html)
index.html 是 draw.io 编辑器的入口,加载官方样式表和启动脚本。鸿蒙专属的 CSS 覆盖通过内联 style 实现:
<!DOCTYPE html>
<html>
<head>
<title>图匠 流程图编辑器</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0,
maximum-scale=1.0, user-scalable=no">
<link rel="stylesheet" type="text/css" href="styles/grapheditor.css">
<script src="js/bootstrap.js"></script>
<style type="text/css">
#geInfo { display:none !important; }
#geStatus:empty { display:none; }
.geSplashLogo { width:72px; height:72px; }
</style>
</head>
<body class="geEditor geClassic">
<div id="geInfo">
<svg class="geSplashLogo" viewBox="0 0 100 100" role="img">
<rect fill="#4A90D9" width="100" height="100" rx="16" ry="16"/>
<text x="50" y="62" font-size="36" font-weight="bold"
fill="white" text-anchor="middle">图</text>
</svg>
<div id="geStatus"></div>
</div>
<script src="js/main.js"></script>
<script src="js/shell-adapter.js"></script>
</body>
</html>
加载顺序说明:
- bootstrap.js:解析 URL 参数,为 PreConfig.js 的配置生效做准备
- main.js:draw.io 官方启动入口,调用 App.main() 初始化编辑器
- shell-adapter.js:在所有官方脚本加载完成后执行,开始 hook 注入
3.5 关于入口与品牌替换
draw.io 官方在右下角放置 GitHub 图标链接。shell-adapter.js 将其移除并替换为"关于"文字入口,点击弹出自研对话框(复刻官方 geDialog 样式),包含开源声明、免责申明和完全开源说明:
function replaceGitHubIcon() {
// 移除 GitHub 图标链接
var ghLinks = [];
try {
var allA = document.querySelectorAll('a[href*="github.com/jgraph"]');
for (var i = 0; i < allA.length; i++) { ghLinks.push(allA[i]); }
} catch (e) { }
for (var k = 0; k < ghLinks.length; k++) {
try {
if (ghLinks[k].parentNode) { ghLinks[k].parentNode.removeChild(ghLinks[k]); }
} catch (e3) { }
}
// 在右下角固定放置"关于"入口
if (document.getElementById('warAboutLink')) { return true; }
try {
var about = document.createElement('a');
about.id = 'warAboutLink';
about.textContent = '关于';
about.style.cssText = 'position:fixed;right:12px;bottom:10px;z-index:9999;'
+ 'font-size:12px;color:#888;cursor:pointer;text-decoration:none;';
about.addEventListener('click', function(evt) {
if (evt != null) { evt.preventDefault(); }
showAboutPanel();
});
document.body.appendChild(about);
return true;
} catch (e4) { return false; }
}
3.6 右键菜单改造(hookContextMenu)
AGC 审核反馈粘贴功能提示"去系统设置"但无开关,右键菜单的"在此粘贴"同样有此问题。由于 app.min.js 在 EditorUi 构造函数中对 menus 做了实例级覆盖,只 hook Menus.prototype 无效,必须在拿到 EditorUi 实例后覆盖实例方法:
function hookContextMenu() {
if (window.__warCtxHooked === true) { return true; }
if (capturedUi == null || capturedUi.menus == null) { return false; }
try {
var menus = capturedUi.menus;
if (typeof menus.addPopupMenuEditItems !== 'function') { return false; }
if (menus.__warCtxHooked === true) { return false; }
var origAddPopupEdit = menus.addPopupMenuEditItems;
menus.addPopupMenuEditItems = function(menu, cell, evt) {
var ui = capturedUi;
if (ui != null && ui.editor != null && ui.editor.graph != null
&& ui.editor.graph.isSelectionEmpty()) {
// 空画布右键:不添加 pasteHere,改为添加"导入"菜单项
var label = (window.mxResources != null)
? (window.mxResources.get('import') || 'Import') + '...'
: 'Import...';
menu.addItem(label, null, function() {
try {
if (typeof ui.importLocalFile === 'function') {
ui.importLocalFile(true);
}
} catch (eImp) {
setStatus('导入失败: ' + (eImp != null ? eImp.message : ''));
}
});
} else {
origAddPopupEdit.apply(this, arguments);
}
};
menus.__warCtxHooked = true;
window.__warCtxHooked = true;
} catch (e) { }
return window.__warCtxHooked === true;
}
四、鸿蒙稳定性适配
4.1 禁用默认菜单栏
与 ApE 等项目一致,在 app.whenReady 回调中清空应用菜单,并在窗口创建时设置 autoHideMenuBar: true 双重保障:
app.whenReady().then(function() {
if (Menu != null && typeof Menu.setApplicationMenu === 'function') {
Menu.setApplicationMenu(null);
}
createWindow();
});
4.2 鸿蒙三防窗口配置
| 配置项 | 值 | 作用 |
|---|---|---|
| disableHardwareAcceleration | true | 防止 GPU 渲染白屏 |
| frame | true | 使用系统原生标题栏 |
| transparent | false | 禁用透明,防止渲染异常 |
| resizable | true | 允许窗口缩放 |
| autoHideMenuBar | true | 隐藏菜单栏(配合 removeMenu 双保险) |
| webSecurity | false | 放行跨域资源加载 |
| setWindowOpenHandler deny | 拦截 _blank | 防止 SubWindow 创建导致崩溃 |
| will-prevent-unload 兜底 | 忽略关闭阻止 | 确保叉叉可正常关闭 |
| try-catch | 包裹 createWindow | 防止单点故障导致进程退出 |
4.3 hook 时序陷阱与解决
shell-adapter.js 的 hook 轮询存在一个关键的时序陷阱:脚本在 index.html 底部同步执行,而 app.min.js 经 bootstrap.js 以 defer 异步加载。如果 hook 函数在目标全局对象尚未就绪时就返回 true,会导致轮询提前停止、hook 永不生效。
解决方案是每个 hook 函数必须严格验证目标对象存在且替换成功后才返回 true:
function hookSave() {
// 仅当 App.prototype 存在且 saveFile/save 方法可覆盖时才置位
if (window.App != null && window.App.prototype != null
&& typeof window.App.prototype.saveFile === 'function'
&& typeof window.App.prototype.save === 'function') {
window.App.prototype.saveFile = function() { saveViaShell(this); };
window.App.prototype.save = function() { saveViaShell(this); };
window.__warSaveHooked = true;
}
return window.__warSaveHooked === true;
}
4.4 实例级覆盖的应对
app.min.js 在 EditorUi 构造函数中对 Menus.prototype.addPopupMenuEditItems 做了实例级覆盖(捕获 prototype 引用后再赋值到 this.menus),因此只 hook prototype 无效。必须在拿到 EditorUi 实例后覆盖实例方法——capturedUi 变量在 Menus.prototype.get 被调用时捕获。
五、构建与部署



5.1 本地调试
在开发目录下直接运行:
npx electron .
5.2 部署到鸿蒙壳工程
将 draw.io 官方包与自研适配层全量同步到 web_engine 部署目录:
# 清空部署目录
Remove-Item "web_engine/src/main/resources/resfile/resources/app/*" -Recurse -Force
# 复制 draw.io 官方包 + 自研适配层
Copy-Item "electron-apps/DrawIOWar/*" "web_engine/src/main/resources/resfile/resources/app/" -Recurse -Force
5.3 构建 HAP 包
在 DevEco Studio 中打开项目,选择 Build → Build Hap(s)/APP(s) → Build Hap(s),即可生成可安装的 HAP 包。
六、常见问题与解决方案
Q1:改了 base/string.json 的应用名,设备上仍然显示旧名
问题现象: 将 EntryAbility_label 改为"图匠 流程图编辑器"后,桌面图标仍显示 Electron。
根因分析: 鸿蒙资源系统存在本地化目录优先级覆盖机制——en_US/zh_CN 的 string.json 覆盖 base。
解决: 必须同时修改三套资源文件:
// electron/src/main/resources/base/element/string.json
{
"string": [
{ "name": "EntryAbility_label", "value": "图匠 流程图编辑器" }
]
}
// en_US 和 zh_CN 同格式同步修改
Q2:构建签名报错 ERROR: 11014001 key alias not found
问题现象: SignHap 阶段报错签名密钥别名找不到。
解决: 使用 DevEco 内置 JBR 的 keytool 查看 p12 内真实别名,填入 build-profile.json5:
"signingConfigs": [{
"name": "default",
"type": "HarmonyOS",
"material": {
"storeFile": "C:/path/to/DrawIOWar.p12",
"keyAlias": "drawiowar1", // 必须与 p12 内真实别名完全一致
"signAlg": "SHA256withECDSA",
"profile": "C:/path/to/DrawIOWarRelease.p7b",
"certpath": "C:/path/to/DrawIOWar.cer"
}
}]
Q3:AGC 审核驳回——应用名与 draw.io 商标混淆
问题现象: 提交 AGC 审核后收到驳回意见,认为应用名称与 draw.io 相似,存在商标混淆风险。
根因分析: 原名称"DrawIOWar 流程图编辑器"中 DrawIO 与知名品牌 draw.io 高度相似,AGC 商标审查不通过。
解决: 更换为完全原创的中文名"图匠 流程图编辑器",同步替换 shell-adapter.js 关于面板中的品牌字样、index.html 的 title 和 meta 标签、所有 console 日志前缀。
Q4:保存时点击无反应,状态栏不更新
问题现象: 点击保存或按 Ctrl+S,应用无任何响应。
根因分析: draw.io 官方保存流程最终调用 mxXmlRequest.simulate 提交表单到 _blank 新窗口,在鸿蒙适配层触发 SubWindow 创建导致崩溃。由于崩溃被内部捕获,表现为静默无响应。
解决: hookSave 覆盖 EditorUi 与 App 两层原型的 save/saveFile 方法,将所有保存入口收口到 IPC 通道 war:save-file,由主进程弹出鸿蒙系统保存对话框。同时屏蔽 window.showSaveFilePicker 接口,防止官方代码走原生文件选择器路径。
Q5:连续切换纸张尺寸后应用崩溃
问题现象: 在格式面板中连续点击纸张尺寸下拉,第二次展开时应用闪退。
根因分析: 鸿蒙适配层把原生 select 元素的下拉弹层实现为 SubWindow,第二次展开时 XComponent 等待超时直接 SIGABRT。
解决: installSelectReplacement 将官方 select 隐藏(display:none)继续承载语义,视觉与交互接管为自研 div 下拉。弹层挂 body 绝对定位避免被父容器 overflow 裁剪,MutationObserver 持续接管动态插入的 select,800ms 轮询同步官方程序化设值。
Q6:样式编辑时颜色选择器导致闪退
问题现象: 点击颜色按钮打开颜色选择器,连续操作时应用崩溃。
根因分析: 官方 pickColor 在非模态路径下创建 ColorWindow(继承自 mxWindow),mxWindow 在鸿蒙适配层触发 SubWindow 创建导致崩溃。
解决: hookColorPicker 覆盖 EditorUi.prototype.pickColor,强制走模态路径 pickColorModal。模态路径使用 CustomDialog(纯 DOM 实现,不创建 mxWindow),功能完整保留。
Q7:点右上角叉叉无响应
问题现象: 文档已修改状态下,点击窗口右上角关闭按钮无反应。
根因分析: draw.io 官方注册 window.onbeforeunload 关闭拦截器,返回提示字符串阻止关闭。鸿蒙适配层未实现"再点一次强制关闭"逻辑。
解决: 双重保障——shell-adapter.js 将 onbeforeunload 定义为只写空值(吞掉官方注册),主进程监听 will-prevent-unload 事件并调用 event.preventDefault() 放行关闭。
七、总结
本文完整记录了在鸿蒙 PC 平台上适配图匠流程图编辑器的全过程。通过将 draw.io 官方开源 Web 应用移植到 Electron 壳工程并自研 1093 行适配脚本,我们实现了一个功能完整的离线图表编辑器,核心特性包括:
| 特性 | 实现方案 |
|---|---|
| 图表引擎 | draw.io 官方开源包 + mxGraph 图形引擎,支持数十种图表类型 |
| 文件操作 | IPC 通道 + 主进程系统对话框,支持 .drawio/.xml 格式 |
| 9 类 hook | 保存/打开/下拉/对话框/颜色/关闭/菜单/便签本/帮助图标 |
| select 替换 | 隐藏官方 select + 自研 div 下拉 + MutationObserver 持续接管 |
| 对话框替换 | 自研 geDialog 样式对话框 + mxUtils 三成员兜底 |
| 关闭疏通 | 属性定义吞写 + will-prevent-unload 主进程兜底 |
| 菜单裁剪 | PreConfig.js hideMenuItems + Menus.prototype.get 运行时替换 |
| 品牌替换 | 原创中文名"图匠" + GitHub 图标替换为关于入口 |
| 完全离线 | 零外部网络依赖,Service Worker 惰性化 |
适配过程中最关键的三个点:
- 原型链 hook 注入机制——不能修改 draw.io 官方代码,所有适配通过外部注入和原型链覆盖实现,且 hook 时序必须精确(目标对象未就绪时 hook 静默失败,轮询必须等到真正成功才停止)
- 实例级覆盖的应对——app.min.js 在构造函数中对 prototype 方法做了实例级捕获覆盖,只 hook prototype 无效,必须在拿到编辑器实例后覆盖实例方法
- 原生浏览器接口的全面替换——select 下拉、alert/confirm/prompt、window.open、showSaveFilePicker 等原生接口在鸿蒙适配层均会触发 SubWindow 崩溃,必须逐一替换为纯 DOM 实现或 IPC 通道
这些问题在标准 Electron 环境中不会出现或表现不同,是鸿蒙 ArkWeb 引擎特有的兼容性挑战。通过系统性的 hook 机制设计和时序控制,最终实现了稳定可用的鸿蒙版全功能离线图表编辑器。
更多推荐


所有评论(0)