前言

在将 MarkText(一款 Electron 桌面 Markdown 编辑器)适配到鸿蒙 PC 平台的过程中,我们遇到了一个严重的技术障碍:鸿蒙系统不允许从子窗口再创建子窗口,导致 Electron 原生菜单最多只能支持 2-3 级嵌套。对于功能丰富的桌面应用来说,这个限制是致命的。

本文将详细记录我们如何通过完全自定义的 HTML/CSS/JavaScript 菜单栏来突破这一限制,实现了支持任意层级嵌套的菜单系统,完美解决了 MarkText 在鸿蒙 PC 上的菜单问题。

关键词:鸿蒙PC、Electron适配、自定义菜单、子窗口限制、跨平台

在这里插入图片描述

欢迎加入开源鸿蒙PC社区:https://harmonypc.csdn.net/

目录

  1. 鸿蒙PC平台的菜单限制
  2. 问题分析与技术选型
  3. 自定义菜单栏完整实现
  4. 与原生菜单的对比
  5. 遇到的坑与解决方案
  6. 总结与展望

鸿蒙PC平台的菜单限制

1.1 错误现象

当我们首次在鸿蒙 PC 上运行 MarkText 时,控制台出现了这样的错误:

[ERROR:ohos_popup.cc(129)] Cannot create subwindow from another subwindow

表现

  • 一级菜单可以正常显示(如"文件"、“编辑”)
  • 二级菜单勉强可以显示(如"文件"下的"打开文件")
  • 三级及以上菜单完全无法显示(如"文件" → “最近打开” → 具体文件列表)

1.2 技术原因

根据 Electron 官方文档,Electron 的原生菜单实现原理:

主窗口 (BrowserWindow)
  └─> 菜单栏 (Menu)
       └─> 一级菜单项点击 → 创建子窗口 (Popup Window)
            └─> 二级菜单项点击 → 创建子窗口的子窗口 (Popup Window)
                 └─> 三级菜单 → ❌ 鸿蒙系统禁止!

鸿蒙系统限制

  • 窗口层级限制:不允许"子窗口的子窗口"
  • 这是操作系统级别的限制,Electron 无法绕过
  • 其他平台(Windows、macOS、Linux)没有这个问题

1.3 MarkText 的菜单需求

MarkText 作为功能丰富的 Markdown 编辑器,菜单结构复杂:

一级菜单 二级菜单项数量 三级菜单 是否受影响
文件(File) 10+ ✅ 有(最近打开) ❌ 无法显示
编辑(Edit) 9 ✅ 有(查找替换) ❌ 无法显示
段落(Paragraph) 8 ✅ 有(标题层级) ❌ 无法显示
格式(Format) 11 ❌ 无 ✅ 正常
视图(View) 5 ❌ 无 ✅ 正常

结论:约 40% 的菜单功能无法使用,严重影响用户体验。


问题分析与技术选型

2.1 可选方案对比

方案 可行性 优点 缺点
方案1:简化菜单结构 ✅ 可行 简单 ❌ 功能缺失,用户体验差
方案2:等待鸿蒙系统更新 ⚠️ 不确定 无需改动 ❌ 时间不可控
方案3:使用 Electron 对话框 ⚠️ 勉强 原生 API ❌ 交互体验差
方案4:自定义 HTML 菜单栏 ✅ 可行 完全控制,无限制 ⚠️ 开发工作量大

最终选择:方案4 - 自定义 HTML 菜单栏

理由

  1. ✅ 完全不依赖系统窗口,无层级限制
  2. ✅ 可以实现任意层级的菜单嵌套
  3. ✅ 样式完全可控,可以做得比原生更美观
  4. ✅ 一次开发,所有平台通用

2.2 技术架构设计

┌─────────────────────────────────────────┐
│      渲染进程 (Renderer Process)        │
│  ┌───────────────────────────────────┐  │
│  │   自定义菜单栏组件                 │  │
│  │   (custom-menu-bar.js)            │  │
│  │                                   │  │
│  │  ┌─────────────────────────────┐  │  │
│  │  │  菜单配置数据                │  │  │
│  │  │  - 层级结构定义              │  │  │
│  │  │  - 动作标识                  │  │  │
│  │  └─────────────────────────────┘  │  │
│  │  ┌─────────────────────────────┐  │  │
│  │  │  DOM 生成器                  │  │  │
│  │  │  - 动态生成菜单 HTML         │  │  │
│  │  │  - 递归处理子菜单            │  │  │
│  │  └─────────────────────────────┘  │  │
│  │  ┌─────────────────────────────┐  │  │
│  │  │  事件处理系统                │  │  │
│  │  │  - 点击、悬停、键盘          │  │  │
│  │  └─────────────────────────────┘  │  │
│  └───────────────────────────────────┘  │
│                  ↕ IPC                  │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│       主进程 (Main Process)             │
│  ┌───────────────────────────────────┐  │
│  │   菜单动作处理器                   │  │
│  │   - 文件操作                       │  │
│  │   - 编辑操作                       │  │
│  │   - 窗口管理                       │  │
│  └───────────────────────────────────┘  │
└─────────────────────────────────────────┘

核心思路

  • 在渲染进程用 HTML/CSS 绘制菜单
  • 通过 IPC 与主进程通信执行实际操作
  • 完全绕过系统窗口限制

自定义菜单栏完整实现

3.1 菜单数据结构设计

// custom-menu-bar.js

/**
 * 菜单配置 - 完整还原 MarkText 原有菜单结构
 */
const menuConfig = [
  {
    label: '文件',
    items: [
      { label: '新建文件', action: 'file-new-file', accelerator: 'CmdOrCtrl+N' },
      { label: '新建窗口', action: 'file-new-tab', accelerator: 'CmdOrCtrl+Shift+N' },
      { type: 'separator' },
      { label: '打开文件', action: 'file-open-file', accelerator: 'CmdOrCtrl+O' },
      { label: '打开文件夹', action: 'file-open-folder', accelerator: 'CmdOrCtrl+Shift+O' },
      { 
        label: '最近打开',  // 三级菜单!
        items: [
          { label: '清空最近打开', action: 'file-clear-recently-used' },
          { type: 'separator' },
          // 动态生成的最近文件列表
        ]
      },
      { type: 'separator' },
      { label: '保存', action: 'file-save', accelerator: 'CmdOrCtrl+S' },
      { label: '另存为', action: 'file-save-as', accelerator: 'CmdOrCtrl+Shift+S' },
      { type: 'separator' },
      { label: '导出', 
        items: [  // 三级菜单!
          { label: '导出为 HTML', action: 'file-export-html' },
          { label: '导出为 PDF', action: 'file-export-pdf' },
        ]
      },
      { type: 'separator' },
      { label: '退出', action: 'file-quit', accelerator: 'CmdOrCtrl+Q' }
    ]
  },
  {
    label: '编辑',
    items: [
      { label: '撤销', action: 'edit-undo', accelerator: 'CmdOrCtrl+Z' },
      { label: '重做', action: 'edit-redo', accelerator: 'CmdOrCtrl+Shift+Z' },
      { type: 'separator' },
      { label: '剪切', action: 'edit-cut', accelerator: 'CmdOrCtrl+X' },
      { label: '复制', action: 'edit-copy', accelerator: 'CmdOrCtrl+C' },
      { label: '粘贴', action: 'edit-paste', accelerator: 'CmdOrCtrl+V' },
      { type: 'separator' },
      { label: '查找', action: 'edit-find', accelerator: 'CmdOrCtrl+F' },
      { 
        label: '查找替换',  // 三级菜单!
        items: [
          { label: '查找下一个', action: 'edit-find-next', accelerator: 'F3' },
          { label: '查找上一个', action: 'edit-find-previous', accelerator: 'Shift+F3' },
          { label: '替换', action: 'edit-replace', accelerator: 'CmdOrCtrl+H' }
        ]
      }
    ]
  },
  {
    label: '段落',
    items: [
      { 
        label: '标题',  // 三级菜单!
        items: [
          { label: '一级标题', action: 'paragraph-heading-1' },
          { label: '二级标题', action: 'paragraph-heading-2' },
          { label: '三级标题', action: 'paragraph-heading-3' },
          { label: '四级标题', action: 'paragraph-heading-4' },
          { label: '五级标题', action: 'paragraph-heading-5' },
          { label: '六级标题', action: 'paragraph-heading-6' }
        ]
      },
      { label: '段落', action: 'paragraph-paragraph' },
      { label: '水平线', action: 'paragraph-horizontal-line' },
      { type: 'separator' },
      { label: '表格', action: 'paragraph-table' },
      { label: '代码块', action: 'paragraph-code-fence' },
      { label: '引用', action: 'paragraph-quote-block' },
      { label: '列表', action: 'paragraph-ul-list' }
    ]
  },
  {
    label: '格式',
    items: [
      { label: '加粗', action: 'format-strong', accelerator: 'CmdOrCtrl+B' },
      { label: '斜体', action: 'format-emphasis', accelerator: 'CmdOrCtrl+I' },
      { label: '下划线', action: 'format-underline', accelerator: 'CmdOrCtrl+U' },
      { label: '删除线', action: 'format-strikethrough', accelerator: 'CmdOrCtrl+D' },
      { type: 'separator' },
      { label: '行内代码', action: 'format-inline-code', accelerator: 'CmdOrCtrl+`' },
      { label: '行内数学公式', action: 'format-inline-math' },
      { type: 'separator' },
      { label: '清除格式', action: 'format-clear-format' }
    ]
  },
  {
    label: '视图',
    items: [
      { label: '全屏', action: 'view-toggle-full-screen', accelerator: 'F11' },
      { label: '源代码模式', action: 'view-source-code-mode' },
      { label: '专注模式', action: 'view-focus-mode' },
      { label: '打字机模式', action: 'view-typewriter-mode' },
      { type: 'separator' },
      { label: '重新加载', action: 'view-reload', accelerator: 'CmdOrCtrl+R' }
    ]
  },
  {
    label: '帮助',
    items: [
      { label: '关于 MarkText', action: 'help-about' },
      { label: '检查更新', action: 'help-check-updates' }
    ]
  }
]

关键点

  • ✅ 完整保留了原有菜单结构
  • ✅ 支持任意层级嵌套(三级、四级都可以)
  • ✅ 保留了快捷键提示(accelerator
  • ✅ 每个菜单项都有唯一的 action 标识

3.2 动态生成菜单 HTML

/**
 * 生成菜单栏 HTML
 */
function createMenuBarHTML(config) {
  let html = '<div class="custom-menu-bar">'
  
  config.forEach((menu, index) => {
    html += `
      <div class="menu-item" data-menu="${index}">
        <span class="menu-label">${menu.label}</span>
        ${createSubmenuHTML(menu.items, index, 1)}
      </div>
    `
  })
  
  html += '</div>'
  return html
}

/**
 * 递归生成子菜单 HTML(核心!支持无限层级)
 */
function createSubmenuHTML(items, parentIndex, level) {
  if (!items || items.length === 0) return ''
  
  let html = `<div class="submenu" data-level="${level}">`
  
  items.forEach((item, index) => {
    if (item.type === 'separator') {
      // 分隔线
      html += '<div class="menu-separator"></div>'
    } else if (item.items) {
      // 有子菜单 - 递归生成!
      html += `
        <div class="submenu-item has-children" data-action="${item.action || ''}">
          <span class="item-label">${item.label}</span>
          <span class="submenu-arrow">▶</span>
          ${createSubmenuHTML(item.items, parentIndex, level + 1)}
        </div>
      `
    } else {
      // 普通菜单项
      html += `
        <div class="submenu-item" data-action="${item.action}">
          <span class="item-label">${item.label}</span>
          ${item.accelerator ? `<span class="accelerator">${item.accelerator}</span>` : ''}
        </div>
      `
    }
  })
  
  html += '</div>'
  return html
}

核心亮点

  • 递归生成createSubmenuHTML 函数递归调用自己,支持无限层级
  • 层级标记data-level 属性记录当前层级,便于样式控制
  • 子菜单标识.has-children 类标记有子菜单的项

3.3 CSS 样式实现

/**
 * 注入菜单栏样式
 */
function injectMenuStyles() {
  const style = document.createElement('style')
  style.id = 'custom-menu-styles'
  style.textContent = `
    /* === 菜单栏容器 === */
    .custom-menu-bar {
      position: fixed;
      top: 0;
      left: 0;
      right: 0;
      height: 32px;
      background: #2c2c2c;
      color: #ffffff;
      display: flex;
      align-items: center;
      padding: 0 10px;
      z-index: 10000;
      user-select: none;
      -webkit-app-region: drag;  /* 允许拖动窗口 */
      font-size: 13px;
    }
  
    /* === 顶级菜单项 === */
    .menu-item {
      position: relative;
      padding: 0 12px;
      height: 100%;
      display: flex;
      align-items: center;
      cursor: pointer;
      -webkit-app-region: no-drag;  /* 菜单项不可拖动 */
      transition: background 0.15s ease;
    }
  
    .menu-item:hover,
    .menu-item.active {
      background: #404040;
    }
  
    /* === 子菜单容器 === */
    .submenu {
      display: none;  /* 默认隐藏 */
      position: absolute;
      min-width: 220px;
      background: #2c2c2c;
      border: 1px solid #404040;
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
      padding: 4px 0;
      z-index: 10001;
    }
  
    /* 一级子菜单定位(在顶级菜单下方) */
    .menu-item > .submenu {
      top: 100%;
      left: 0;
    }
  
    /* 多级子菜单定位(在父菜单右侧) */
    .submenu .submenu {
      top: -4px;
      left: 100%;
    }
  
    /* === 子菜单项 === */
    .submenu-item {
      padding: 8px 20px;
      cursor: pointer;
      display: flex;
      justify-content: space-between;
      align-items: center;
      position: relative;
      transition: background 0.15s ease;
    }
  
    .submenu-item:hover {
      background: #404040;
    }
  
    /* 有子菜单的项:悬停时显示子菜单 */
    .submenu-item.has-children:hover > .submenu {
      display: block;
    }
  
    /* === 快捷键提示 === */
    .accelerator {
      margin-left: 40px;
      opacity: 0.6;
      font-size: 11px;
    }
  
    /* === 子菜单箭头 === */
    .submenu-arrow {
      margin-left: 20px;
      font-size: 10px;
      opacity: 0.6;
    }
  
    /* === 分隔线 === */
    .menu-separator {
      height: 1px;
      background: #404040;
      margin: 4px 0;
    }
  
    /* === 显示子菜单(点击激活) === */
    .menu-item.active > .submenu {
      display: block;
    }
  
    /* === 动画效果 === */
    .submenu {
      opacity: 0;
      transform: translateY(-5px);
      transition: opacity 0.15s ease, transform 0.15s ease;
    }
  
    .menu-item.active > .submenu,
    .submenu-item.has-children:hover > .submenu {
      opacity: 1;
      transform: translateY(0);
    }
  `
  
  document.head.appendChild(style)
  console.log('[MenuBar] 样式注入完成')
}

CSS 关键技巧

  • 嵌套定位.submenu .submenu 选择器处理多级菜单定位
  • 悬停显示:hover > .submenu 实现鼠标悬停显示子菜单
  • 层级叠加z-index 确保菜单始终在最上层
  • 平滑动画transition 实现淡入淡出效果

3.4 事件处理系统

/**
 * 绑定菜单事件
 */
function bindMenuEvents() {
  const menuBar = document.querySelector('.custom-menu-bar')
  if (!menuBar) return
  
  // === 1. 顶级菜单点击事件 ===
  menuBar.querySelectorAll('.menu-item').forEach(item => {
    item.addEventListener('click', function(e) {
      e.stopPropagation()
    
      // 关闭其他菜单
      menuBar.querySelectorAll('.menu-item').forEach(m => {
        if (m !== this) m.classList.remove('active')
      })
    
      // 切换当前菜单
      this.classList.toggle('active')
    })
  })
  
  // === 2. 子菜单项点击事件 ===
  menuBar.querySelectorAll('.submenu-item:not(.has-children)').forEach(item => {
    item.addEventListener('click', function(e) {
      const action = this.getAttribute('data-action')
      if (!action) return
    
      e.stopPropagation()
    
      console.log('[MenuBar] 菜单动作:', action)
    
      // 关闭所有菜单
      menuBar.querySelectorAll('.menu-item').forEach(m => {
        m.classList.remove('active')
      })
    
      // 发送 IPC 消息到主进程
      if (window.ipcRenderer) {
        window.ipcRenderer.send('menu-action', action)
      } else {
        console.warn('[MenuBar] ipcRenderer 不可用')
      }
    })
  })
  
  // === 3. 点击页面其他地方关闭菜单 ===
  document.addEventListener('click', function(e) {
    if (!menuBar.contains(e.target)) {
      menuBar.querySelectorAll('.menu-item').forEach(m => {
        m.classList.remove('active')
      })
    }
  })
  
  // === 4. ESC 键关闭菜单 ===
  document.addEventListener('keydown', function(e) {
    if (e.key === 'Escape') {
      menuBar.querySelectorAll('.menu-item').forEach(m => {
        m.classList.remove('active')
      })
    }
  })
  
  console.log('[MenuBar] 事件绑定完成')
}

3.5 主进程 IPC 处理

// main.js (主进程)
const { ipcMain, dialog, app } = require('electron')

/**
 * 处理菜单动作
 */
ipcMain.on('menu-action', (event, action) => {
  const win = BrowserWindow.fromWebContents(event.sender)
  
  console.log('[Main] 收到菜单动作:', action)
  
  switch (action) {
    case 'file-new-file':
      win.webContents.send('mt::new-untitled-tab')
      break
    
    case 'file-open-file':
      dialog.showOpenDialog(win, {
        properties: ['openFile'],
        filters: [
          { name: 'Markdown', extensions: ['md', 'markdown', 'mdown', 'mkd'] },
          { name: 'All Files', extensions: ['*'] }
        ]
      }).then(result => {
        if (!result.canceled && result.filePaths.length > 0) {
          win.webContents.send('mt::open-file', result.filePaths[0])
        }
      })
      break
    
    case 'file-save':
      win.webContents.send('mt::save-file')
      break
    
    case 'file-quit':
      app.quit()
      break
    
    case 'edit-undo':
      win.webContents.undo()
      break
    
    case 'edit-redo':
      win.webContents.redo()
      break
    
    case 'view-toggle-full-screen':
      win.setFullScreen(!win.isFullScreen())
      break
    
    // ... 更多动作处理
    
    default:
      console.warn('[Main] 未处理的菜单动作:', action)
  }
})

与原生菜单的对比

4.1 功能对比

功能 原生菜单(鸿蒙PC) 自定义菜单
菜单层级 ❌ 最多 2-3 层 ✅ 无限制
样式定制 ❌ 不可定制 ✅ 完全控制
动画效果 ⚠️ 系统默认 ✅ 自定义动画
跨平台一致性 ❌ 各平台不同 ✅ 完全一致
开发复杂度 ✅ 简单 ⚠️ 较复杂
性能 ✅ 原生性能 ✅ 优秀(CSS 硬件加速)
内存占用 ✅ 低 ✅ 低(纯 HTML/CSS)

4.2 实际效果对比

原生菜单(鸿蒙PC)

文件
├─ 新建文件 ✅
├─ 打开文件 ✅
├─ 最近打开
│  └─ (无法显示)❌
└─ 保存 ✅

自定义菜单

文件
├─ 新建文件 ✅
├─ 打开文件 ✅
├─ 最近打开 ✅
│  ├─ 清空最近打开 ✅
│  ├─ document1.md ✅
│  └─ document2.md ✅
├─ 导出 ✅
│  ├─ 导出为 HTML ✅
│  └─ 导出为 PDF ✅
└─ 保存 ✅

4.3 性能测试

测试环境:鸿蒙PC、MarkText 应用

指标 原生菜单 自定义菜单
首次渲染时间 ~50ms ~80ms
点击响应时间 ~10ms ~15ms
内存占用 +2MB +3MB
CPU 占用(悬停) ~1% ~1.5%

结论:性能差异可忽略不计,用户无感知。


遇到的坑与解决方案

5.1 坑1:菜单栏遮挡页面内容

问题:菜单栏固定在顶部,遮挡了原有内容。

解决方案

// 初始化时调整页面布局
function adjustPageLayout() {
  document.body.style.paddingTop = '32px'  // 菜单栏高度
  console.log('[MenuBar] 页面布局已调整')
}

5.2 坑2:子菜单超出屏幕边界

问题:靠近屏幕右侧的菜单,子菜单会超出屏幕。

解决方案

// 动态调整子菜单位置
function adjustSubmenuPosition(submenu) {
  const rect = submenu.getBoundingClientRect()
  const windowWidth = window.innerWidth
  
  if (rect.right > windowWidth) {
    // 超出右边界,改为向左展开
    submenu.style.left = 'auto'
    submenu.style.right = '100%'
  }
}

5.3 坑3:菜单栏可拖动区域冲突

问题:整个菜单栏设置了 -webkit-app-region: drag,导致菜单项无法点击。

解决方案

.custom-menu-bar {
  -webkit-app-region: drag;  /* 整体可拖动 */
}

.menu-item {
  -webkit-app-region: no-drag;  /* 菜单项不可拖动 */
}

5.4 坑4:快捷键不生效

问题:自定义菜单只显示快捷键,但不会触发。

解决方案

// 主进程注册全局快捷键
const { globalShortcut } = require('electron')

app.whenReady().then(() => {
  globalShortcut.register('CommandOrControl+N', () => {
    mainWindow.webContents.send('mt::new-untitled-tab')
  })
  
  globalShortcut.register('CommandOrControl+O', () => {
    // 触发打开文件
  })
  
  // ... 注册更多快捷键
})

5.5 坑5:初始化时机问题

问题:页面加载时 DOM 未准备好,菜单栏注入失败。

解决方案:参考我们的另一篇文章《Electron for 鸿蒙PC - DOM初始化时机与多重保险机制》,使用多重保险机制:

// 多重初始化机制
if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', initMenuBar)
} else {
  initMenuBar()
}

window.addEventListener('load', () => {
  if (!menuBarInitialized) {
    initMenuBar()
  }
})

setTimeout(() => {
  if (!menuBarInitialized) {
    initMenuBar()
  }
}, 1000)

总结与展望

6.1 成果总结

通过实现自定义菜单栏,我们成功解决了 MarkText 在鸿蒙 PC 上的菜单问题:

完全突破了鸿蒙系统的子窗口限制
支持任意层级的菜单嵌套(3级、4级、5级都可以)
保留了所有原有菜单功能(100% 功能可用)
跨平台统一体验(Windows、macOS、Linux、鸿蒙PC 完全一致)
性能优异(用户无感知的性能差异)
代码可维护(1169 行,结构清晰)

6.2 关键技术点

  1. 递归 HTML 生成:支持无限层级菜单
  2. CSS 嵌套定位.submenu .submenu 实现多级菜单
  3. 事件委托:高效的事件处理
  4. IPC 通信:渲染进程与主进程协作
  5. 多重初始化保险:确保可靠加载

6.3 适用场景

这套方案不仅适用于鸿蒙 PC,也适用于:

  • ✅ 需要高度自定义菜单样式的应用
  • ✅ 需要复杂菜单层级的应用
  • ✅ 需要跨平台统一体验的应用
  • ✅ 原生菜单无法满足需求的场景

6.4 后续优化方向

  1. 键盘导航:支持方向键导航菜单
  2. 无障碍支持:添加 ARIA 属性
  3. 主题切换:支持浅色/深色主题
  4. 菜单搜索:快速查找菜单项
  5. 性能优化:虚拟滚动处理超长菜单

6.5 源码地址

完整代码已开源在 MarkText for HarmonyOS 项目中:

  • 项目地址:https://gitcode.com/szkygc/marktext
  • 文件路径web_engine/src/main/resources/resfile/resources/app/custom-menu-bar.js
  • 代码行数:1169 行
  • 许可证:MIT

相关资源

Electron 官方文档

鸿蒙PC开发资源


技术难度:⭐⭐⭐⭐ 中高级

实战价值:⭐⭐⭐⭐⭐ 解决鸿蒙PC适配核心问题

推荐指数:⭐⭐⭐⭐⭐ 鸿蒙PC Electron应用必备方案

Logo

赋能鸿蒙PC开发者,共建全场景原生生态,共享一次开发多端部署创新价值。

更多推荐