图匠流程图编辑器是一款基于 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 APIIPC 通道 + 主进程系统对话框
下拉选择原生 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.jsdraw.io 官方编辑器核心(菜单/工具栏/属性面板/文件管理)
extensions.min.jsdraw.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 鸿蒙三防窗口配置

配置项值作用
disableHardwareAccelerationtrue防止 GPU 渲染白屏
frametrue使用系统原生标题栏
transparentfalse禁用透明,防止渲染异常
resizabletrue允许窗口缩放
autoHideMenuBartrue隐藏菜单栏(配合 removeMenu 双保险)
webSecurityfalse放行跨域资源加载
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 惰性化

适配过程中最关键的三个点:

  1. 原型链 hook 注入机制——不能修改 draw.io 官方代码,所有适配通过外部注入和原型链覆盖实现,且 hook 时序必须精确(目标对象未就绪时 hook 静默失败,轮询必须等到真正成功才停止)
  2. 实例级覆盖的应对——app.min.js 在构造函数中对 prototype 方法做了实例级捕获覆盖,只 hook prototype 无效,必须在拿到编辑器实例后覆盖实例方法
  3. 原生浏览器接口的全面替换——select 下拉、alert/confirm/prompt、window.open、showSaveFilePicker 等原生接口在鸿蒙适配层均会触发 SubWindow 崩溃,必须逐一替换为纯 DOM 实现或 IPC 通道

这些问题在标准 Electron 环境中不会出现或表现不同,是鸿蒙 ArkWeb 引擎特有的兼容性挑战。通过系统性的 hook 机制设计和时序控制,最终实现了稳定可用的鸿蒙版全功能离线图表编辑器。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐