鸿蒙平台 Haroopad Markdown 编辑器适配实战:基于 Electron 壳方案的实时预览写作工具

Haroopad 是一款以实时预览著称的 Markdown 编辑器,以轻量高效、所见即所得的体验深受开发者喜爱。本文记录在鸿蒙 PC 平台上,基于 Electron 壳方案从零构建一个支持分栏实时预览的 Markdown 编辑器的完整过程------涵盖 CodeMirror Markdown 语法高亮、marked.js 实时渲染、highlight.js 代码高亮、工具栏快捷插入、本地图片插入、HTML 导出、可拖动分栏、鸿蒙稳定性适配等核心功能的实现细节与踩坑经验。

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

欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_haroopad

一、技术架构分析

1.1 整体架构

本项目采用 Electron Web 层 + 鸿蒙 HAP 壳工程 的分层架构:

  • Electron Web 层:运行于 ArkWeb 引擎,提供完整的 Markdown 编辑与实时预览能力
  • 鸿蒙 HAP 壳工程:通过 web_engine 模块加载 Web 应用,提供窗口管理、文件系统访问、屏幕自适应等系统能力

这种架构的核心优势在于:业务代码与平台完全解耦,Electron 部分可以在任何平台独立运行,鸿蒙壳仅负责适配和打包。

1.2 技术方案

对比项 Haroopad 原版 鸿蒙适配版
编辑器内核 CodeMirror / 自定义 CodeMirror 5(Markdown 模式)
渲染引擎 自定义解析器 marked.js v12 + highlight.js
开发语言 JavaScript/HTML/CSS HTML5/CSS3/Vanilla JavaScript
文件操作 Node.js fs Node.js fs 模块 + IPC
部署方式 各平台安装包 HAP 包(鸿蒙应用)
运行环境 Windows/macOS/Linux 鸿蒙 PC(ArkWeb 引擎)

1.3 核心功能清单

功能模块 具体能力
Markdown 编辑 CodeMirror Markdown 语法高亮、代码折叠、自动补全括号/标签
实时预览 300ms 防抖渲染,左侧编辑右侧即时显示
工具栏 粗体/斜体/删除线/标题/列表/引用/代码块/链接/图片/表格/分割线
文件操作 打开/保存/另存为、Markdown 文件类型过滤、未保存提醒
图片插入 本地图片文件选择,自动生成 file:// URI 的 Markdown 图片语法
HTML 导出 将 Markdown 渲染为完整 HTML 文件(含样式),一键导出
分栏拖动 可拖动分隔条调整编辑器/预览区宽度比例
快捷键 Ctrl+B 粗体、Ctrl+I 斜体、Ctrl+K 链接、Ctrl+S 保存、Ctrl+N 新建
状态栏 文件名、修改状态、总行数、字数统计、光标位置

二、环境准备

2.1 开发环境要求

项目 版本/信息
操作系统 Windows 10/11
核心框架 Electron (Node.js + Chromium)
技术栈 HTML5/CSS3/Vanilla JavaScript
编辑器内核 CodeMirror 5.65.16
Markdown 渲染 marked.js 12.0.0
代码高亮 highlight.js 11.9.0
目标设备 鸿蒙 PC
目标架构 arm64-v8a
开发工具 DevEco Studio(鸿蒙官方 IDE)

2.2 项目结构

bash 复制代码
ohos_hap/
├── electron-apps/
│   └── Haroopad/                  # Haroopad 应用(开发目录)
│       ├── package.json           # 项目配置
│       ├── main.js                # 主进程(窗口管理 + IPC)
│       ├── index.html             # 页面布局
│       ├── renderer.js            # 渲染进程(核心逻辑)
│       └── styles/
│           └── haroopad.css       # 深色主题样式
├── web_engine/                    # 鸿蒙壳工程
│   └── src/main/resources/resfile/resources/app/  # 部署目录
└── electron/                      # Electron 壳

2.3 依赖说明

本项目所有第三方库均通过 CDN 加载,无需 npm 安装:

版本 用途
CodeMirror 5.65.16 Markdown 编辑器内核
marked.js 12.0.0 Markdown 转 HTML
highlight.js 11.9.0 预览区代码语法高亮

三、核心适配流程

3.1 主进程实现(main.js)

主进程负责窗口创建、文件对话框、文件读写等系统级操作。鸿蒙平台需要特殊的稳定性配置。

js 复制代码
// Haroopad - 主进程
const { app, BrowserWindow, ipcMain, dialog, screen } = require('electron');
const fs = require('fs');
const path = require('path');

// 防 GPU 白屏:禁用硬件加速
app.disableHardwareAcceleration();

let mainWindow = null;

// 创建窗口
function createWindow() {
  try {
    const display = screen.getPrimaryDisplay();
    const { width, height } = display.workAreaSize;

    // 防 XComponent 崩溃:frame: true + transparent: false + resizable: true
    mainWindow = new BrowserWindow({
      width: Math.floor(width * 0.9),
      height: Math.floor(height * 0.85),
      frame: true,
      transparent: false,
      resizable: true,
      webPreferences: {
        nodeIntegration: true,
        contextIsolation: false
      }
    });

    mainWindow.loadFile('index.html');
  } catch (e) {
    console.warn('[Haroopad] 创建窗口失败:', e.message);
  }
}

app.whenReady().then(createWindow);

app.on('window-all-closed', () => {
  app.quit();
});

关键配置说明:

  • disableHardwareAcceleration:禁用硬件加速,避免鸿蒙 GPU 渲染白屏
  • frame: true + transparent: false + resizable: true:鸿蒙三防配置,防止 XComponent 创建超时导致 SIGABRT 崩溃
  • try-catch 包裹窗口创建:防止单点故障导致整个进程退出

IPC 通道注册:

主进程注册了 7 个 IPC 通道供渲染进程调用:

js 复制代码
// 打开 Markdown 文件
ipcMain.handle('dialog:openFile', async () => {
  try {
    const result = await dialog.showOpenDialog(mainWindow, {
      properties: ['openFile'],
      filters: [
        { name: 'Markdown 文件', extensions: ['md', 'markdown', 'mdown', 'mkd'] },
        { name: '文本文件', extensions: ['txt', 'text'] },
        { name: '所有文件', extensions: ['*'] }
      ]
    });
    return result;
  } catch (e) {
    return { canceled: true, filePaths: [] };
  }
});

// 保存 Markdown 文件
ipcMain.handle('dialog:saveFile', async () => {
  try {
    const result = await dialog.showSaveDialog(mainWindow, {
      filters: [
        { name: 'Markdown 文件', extensions: ['md'] },
        { name: '所有文件', extensions: ['*'] }
      ]
    });
    return result;
  } catch (e) {
    return { canceled: true, filePath: '' };
  }
});

// 导出 HTML 文件
ipcMain.handle('dialog:exportHtml', async () => {
  try {
    const result = await dialog.showSaveDialog(mainWindow, {
      filters: [
        { name: 'HTML 文件', extensions: ['html', 'htm'] }
      ]
    });
    return result;
  } catch (e) {
    return { canceled: true, filePath: '' };
  }
});

// 选择图片文件(支持 png/jpg/gif/bmp/svg/webp)
ipcMain.handle('dialog:openImage', async () => {
  try {
    const result = await dialog.showOpenDialog(mainWindow, {
      properties: ['openFile'],
      filters: [
        { name: '图片文件', extensions: ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp'] },
        { name: '所有文件', extensions: ['*'] }
      ]
    });
    return result;
  } catch (e) {
    return { canceled: true, filePaths: [] };
  }
});

// 读取文件内容
ipcMain.handle('file:read', async (event, filePath) => {
  try {
    const content = fs.readFileSync(filePath, 'utf-8');
    return { success: true, content };
  } catch (err) {
    return { success: false, error: err.message };
  }
});

// 写入文件内容
ipcMain.handle('file:write', async (event, filePath, content) => {
  try {
    fs.writeFileSync(filePath, content, 'utf-8');
    return { success: true };
  } catch (err) {
    return { success: false, error: err.message };
  }
});

// 获取文件名
ipcMain.handle('file:basename', async (event, filePath) => {
  return path.basename(filePath);
});

3.2 页面布局实现(index.html)

页面采用三段式布局:顶部工具栏 + 中间分栏(编辑器 + 预览)+ 底部状态栏。

工具栏设计: 所有按钮使用 data-tip 属性替代原生 title 属性,通过 CSS ::after 伪元素实现 tooltip 显示。这是因为鸿蒙平台上原生 title 触发的 SubWindow 会导致 XComponent 超时崩溃。

js 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Haroopad - Markdown 编辑器</title>
  <link rel="stylesheet" href="styles/haroopad.css">
  <!-- CodeMirror 核心 -->
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.min.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/theme/material-darker.css">
  <!-- highlight.js 预览区代码高亮 -->
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/atom-one-dark.min.css">
</head>
<body>
  <!-- 工具栏 -->
  <div class="toolbar">
    <div class="toolbar-group">
      <button class="tool-btn" data-action="new" data-tip="新建 (Ctrl+N)">📄</button>
      <button class="tool-btn" data-action="open" data-tip="打开 (Ctrl+O)">📂</button>
      <button class="tool-btn" data-action="save" data-tip="保存 (Ctrl+S)">💾</button>
      <button class="tool-btn" data-action="save-as" data-tip="另存为 (Ctrl+Shift+S)">💾+</button>
    </div>
    <div class="toolbar-separator"></div>
    <div class="toolbar-group">
      <button class="tool-btn" data-action="bold" data-tip="粗体 (Ctrl+B)"><b>B</b></button>
      <button class="tool-btn" data-action="italic" data-tip="斜体 (Ctrl+I)"><i>I</i></button>
      <button class="tool-btn" data-action="strikethrough" data-tip="删除线"><s>S</s></button>
      <button class="tool-btn" data-action="heading" data-tip="标题">H</button>
    </div>
    <div class="toolbar-separator"></div>
    <div class="toolbar-group">
      <button class="tool-btn" data-action="ul" data-tip="无序列表">•≡</button>
      <button class="tool-btn" data-action="ol" data-tip="有序列表">1.</button>
      <button class="tool-btn" data-action="quote" data-tip="引用">❝</button>
      <button class="tool-btn" data-action="code" data-tip="代码块">⟨/⟩</button>
    </div>
    <div class="toolbar-separator"></div>
    <div class="toolbar-group">
      <button class="tool-btn" data-action="link" data-tip="链接 (Ctrl+K)">🔗</button>
      <button class="tool-btn" data-action="image" data-tip="图片">🖼</button>
      <button class="tool-btn" data-action="table" data-tip="表格">▦</button>
      <button class="tool-btn" data-action="hr" data-tip="分割线">─</button>
    </div>
    <div class="toolbar-separator"></div>
    <div class="toolbar-group">
      <button class="tool-btn" data-action="export-html" data-tip="导出 HTML">⬇HTML</button>
      <button class="tool-btn" data-action="toggle-preview" data-tip="切换预览面板">👁</button>
    </div>
  </div>

  <!-- 主内容区:编辑器 + 预览 -->
  <div class="main-area">
    <!-- 编辑器面板 -->
    <div class="editor-panel" id="editorPanel">
      <textarea id="editor"></textarea>
    </div>

    <!-- 可拖动分隔条 -->
    <div class="panel-resizer" id="panelResizer"></div>

    <!-- 预览面板 -->
    <div class="preview-panel" id="previewPanel">
      <div class="preview-header">预览</div>
      <div class="preview-content" id="previewContent"></div>
    </div>
  </div>

  <!-- 状态栏 -->
  <div class="statusbar">
    <div class="status-left">
      <span id="status-file">未保存</span>
      <span id="status-modified"></span>
    </div>
    <div class="status-right">
      <span id="status-lines">共 1 行</span>
      <span id="status-words">0 字</span>
      <span id="status-pos">行 1, 列 1</span>
      <span>Markdown</span>
    </div>
  </div>

  <!-- CodeMirror -->
  <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/markdown/markdown.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/xml/xml.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/javascript/javascript.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/css/css.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/clike/clike.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/python/python.min.js"></script>
  <!-- CodeMirror 插件 -->
  <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/edit/closebrackets.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/edit/closetag.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/fold/foldcode.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/fold/foldgutter.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/fold/markdown-fold.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/selection/active-line.min.js"></script>
  <!-- Markdown 渲染 -->
  <script src="https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/highlight.min.js"></script>

  <!-- 应用逻辑 -->
  <script src="renderer.js"></script>
</body>
</html>

CDN 资源说明:

  • CodeMirror 核心 + Markdown 模式:编辑器内核,支持 Markdown 语法高亮
  • CodeMirror 语言模式(xml/javascript/css/htmlmixed/clike/python):用于 Markdown 内嵌代码块的语法高亮
  • CodeMirror 插件:closebrackets(自动补全括号)、closetag(自动闭合标签)、fold(代码折叠)、active-line(当前行高亮)
  • marked.js:Markdown 转 HTML 的渲染引擎
  • highlight.js:预览区代码块的语法高亮

3.3 渲染进程核心逻辑(renderer.js)

renderer.js 是整个应用的核心,负责编辑器初始化、实时预览、工具栏操作、文件管理和导出功能。

编辑器初始化与 marked.js 配置:

js 复制代码
// Haroopad - 渲染进程
const { ipcRenderer } = require('electron');
const path = require('path');

// ========== 全局状态 ==========
let editor = null;
let filePath = null;
let fileName = '未命名.md';
let modified = false;
let previewVisible = true;
let renderTimer = null;

function initEditor() {
  editor = CodeMirror.fromTextArea(document.getElementById('editor'), {
    mode: 'markdown',
    theme: 'material-darker',
    lineNumbers: true,
    styleActiveLine: true,
    autoCloseBrackets: true,
    autoCloseTags: true,
    foldGutter: true,
    gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
    indentUnit: 2,
    tabSize: 2,
    lineWrapping: true,
    extraKeys: {
      'Ctrl-B': () => insertFormat('bold'),
      'Ctrl-I': () => insertFormat('italic'),
      'Ctrl-K': () => insertFormat('link'),
      'Ctrl-S': () => saveFile(),
      'Ctrl-Shift-S': () => saveFileAs(),
      'Ctrl-N': () => newFile(),
      'Ctrl-O': () => openFile(),
    }
  });

  // 实时预览(防抖 300ms)
  editor.on('change', () => {
    markModified();
    updateStatusBar();
    debounceRender();
  });

  editor.on('cursorActivity', () => {
    updateStatusBar();
  });

  // 初始渲染默认文档
  editor.setValue(DEFAULT_DOC);
  editor.clearHistory();
  modified = false;
  debounceRender();
}

marked.js v12 兼容配置(踩坑重点):

marked v12 的 renderer 方法参数格式发生了重大变化------从独立参数改为 token 对象。如果按旧版写法 function(code, lang) 接收参数,实际拿到的是 object Object,传给 highlight.js 会直接报错导致渲染失败。

js 复制代码
function initMarked() {
  // marked v12 使用 token 对象作为 renderer 方法参数
  const renderer = new marked.Renderer();
  renderer.code = function(token) {
    // 兼容 v12 (token对象) 和旧版本 (独立参数)
    let code, lang;
    if (typeof token === 'object' && token !== null) {
      code = token.text || token.code || '';
      lang = token.lang || '';
    } else {
      code = token || '';
      lang = arguments[1] || '';
    }

    let highlighted;
    try {
      if (lang && hljs.getLanguage(lang)) {
        highlighted = hljs.highlight(code, { language: lang }).value;
      } else {
        highlighted = hljs.highlightAuto(code).value;
      }
    } catch (e) {
      // 高亮失败时使用原始代码
      highlighted = code.replace(/</g, '&lt;').replace(/>/g, '&gt;');
    }
    return '<pre><code class="hljs">' + highlighted + '</code></pre>';
  };

  marked.setOptions({
    renderer: renderer,
    breaks: true,
    gfm: true
  });
}

实时预览渲染(300ms 防抖):

js 复制代码
function debounceRender() {
  if (renderTimer) clearTimeout(renderTimer);
  renderTimer = setTimeout(renderPreview, 300);
}

function renderPreview() {
  const md = editor.getValue();
  const previewEl = document.getElementById('previewContent');
  try {
    previewEl.innerHTML = marked.parse(md);
  } catch (e) {
    previewEl.innerHTML = '<p style="color:#f44;">渲染错误: ' + e.message + '</p>';
  }
}

3.4 工具栏与 Markdown 格式插入

工具栏采用统一的 data-action 属性驱动模式,所有按钮点击后通过 handleToolbarAction 分发到对应处理函数。

格式插入核心逻辑: insertFormat 函数支持三种模式------直接插入(表格、分割线)、选区包裹(粗体、斜体等)、行首插入(标题、列表、引用)。

js 复制代码
function insertFormat(type) {
  editor.focus();
  const cursor = editor.getCursor();
  const selection = editor.getSelection();

  const formats = {
    bold: { before: '**', after: '**', placeholder: '粗体文本' },
    italic: { before: '*', after: '*', placeholder: '斜体文本' },
    strikethrough: { before: '~~', after: '~~', placeholder: '删除线文本' },
    heading: { before: '## ', after: '', placeholder: '标题', lineStart: true },
    ul: { before: '- ', after: '', placeholder: '列表项', lineStart: true },
    ol: { before: '1. ', after: '', placeholder: '列表项', lineStart: true },
    quote: { before: '> ', after: '', placeholder: '引用文本', lineStart: true },
    code: { before: '```\n', after: '\n```', placeholder: '代码内容' },
    link: { before: '[', after: '](url)', placeholder: '链接文本' },
    table: {
      before: '| 列1 | 列2 | 列3 |\n|------|------|------|\n| ',
      after: ' | 内容 | 内容 |',
      placeholder: '内容',
      replace: true
    },
    hr: { before: '\n---\n', after: '', placeholder: '', replace: true }
  };

  const fmt = formats[type];
  if (!fmt) return;

  if (fmt.replace) {
    // 直接插入(不依赖选区)
    editor.replaceSelection(fmt.before + fmt.after);
  } else if (selection) {
    // 有选区时包裹
    editor.replaceSelection(fmt.before + selection + fmt.after);
  } else {
    // 无选区时插入占位
    const text = fmt.lineStart ? fmt.before + fmt.placeholder : fmt.before + fmt.placeholder + fmt.after;
    editor.replaceSelection(text);
    // 选中占位文本
    if (fmt.placeholder) {
      const from = { line: cursor.line, ch: cursor.ch + fmt.before.length };
      const to = { line: cursor.line, ch: cursor.ch + fmt.before.length + fmt.placeholder.length };
      editor.setSelection(from, to);
    }
  }
}

3.5 本地图片插入

图片按钮点击后弹出文件选择对话框,选中图片后自动生成 Markdown 图片语法。路径会自动转换为 file:// URI 格式,兼容鸿蒙本地路径。

js 复制代码
async function insertImage() {
  const result = await ipcRenderer.invoke('dialog:openImage');
  if (result.canceled || result.filePaths.length === 0) return;

  const imgPath = result.filePaths[0];
  const imgName = path.basename(imgPath);
  // 将路径转换为 file:// URI(兼容鸿蒙本地路径)
  const fileUri = 'file://' + imgPath.replace(/\\/g, '/');
  const md = '![' + imgName + '](' + fileUri + ')';

  editor.focus();
  editor.replaceSelection(md);
}

3.6 文件操作

所有文件操作都通过 IPC 与主进程通信,新建和打开前会检查是否有未保存的更改。

js 复制代码
async function newFile() {
  if (modified) {
    const confirmed = await showConfirm('当前文件有未保存的更改,是否新建?');
    if (!confirmed) return;
  }

  filePath = null;
  fileName = '未命名.md';
  modified = false;
  editor.setValue(DEFAULT_DOC);
  editor.clearHistory();
  updateFileStatus();
  renderPreview();
}

async function openFile() {
  if (modified) {
    const confirmed = await showConfirm('当前文件有未保存的更改,是否打开新文件?');
    if (!confirmed) return;
  }

  const result = await ipcRenderer.invoke('dialog:openFile');
  if (result.canceled || result.filePaths.length === 0) return;

  const fp = result.filePaths[0];
  const readResult = await ipcRenderer.invoke('file:read', fp);

  if (!readResult.success) {
    showAlert('打开文件失败: ' + readResult.error);
    return;
  }

  filePath = fp;
  fileName = path.basename(fp);
  modified = false;
  editor.setValue(readResult.content);
  editor.clearHistory();
  updateFileStatus();
  renderPreview();
}

async function saveFile() {
  if (!filePath) {
    return saveFileAs();
  }

  const content = editor.getValue();
  const result = await ipcRenderer.invoke('file:write', filePath, content);

  if (!result.success) {
    showAlert('保存失败: ' + result.error);
    return;
  }

  modified = false;
  updateFileStatus();
}

async function saveFileAs() {
  const result = await ipcRenderer.invoke('dialog:saveFile');
  if (result.canceled || !result.filePath) return;

  const fp = result.filePath;
  const content = editor.getValue();
  const writeResult = await ipcRenderer.invoke('file:write', fp, content);

  if (!writeResult.success) {
    showAlert('保存失败: ' + writeResult.error);
    return;
  }

  filePath = fp;
  fileName = path.basename(fp);
  modified = false;
  updateFileStatus();
}

3.7 HTML 导出

将 Markdown 渲染为完整 HTML 文件,包含内联样式,可直接用浏览器打开。

js 复制代码
async function exportHtml() {
  const result = await ipcRenderer.invoke('dialog:exportHtml');
  if (result.canceled || !result.filePath) return;

  const md = editor.getValue();
  let htmlBody;
  try {
    htmlBody = marked.parse(md);
  } catch (e) {
    showAlert('导出失败: ' + e.message);
    return;
  }

  const fullHtml = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>${escapeHtml(fileName)}</title>
  <style>
    body { font-family: 'Segoe UI', sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; line-height: 1.7; color: #333; }
    h1, h2 { border-bottom: 1px solid #eee; padding-bottom: 8px; }
    code { background: #f5f5f5; padding: 2px 6px; border-radius: 3px; font-size: 14px; }
    pre { background: #f5f5f5; padding: 16px; border-radius: 6px; overflow-x: auto; }
    pre code { background: transparent; padding: 0; }
    blockquote { border-left: 4px solid #ddd; padding: 8px 16px; margin: 12px 0; color: #666; }
    table { border-collapse: collapse; width: 100%; }
    th, td { border: 1px solid #ddd; padding: 8px 12px; }
    th { background: #f5f5f5; }
    img { max-width: 100%; }
  </style>
</head>
<body>
${htmlBody}
</body>
</html>`;

  const writeResult = await ipcRenderer.invoke('file:write', result.filePath, fullHtml);
  if (!writeResult.success) {
    showAlert('导出失败: ' + writeResult.error);
    return;
  }

  showAlert('HTML 导出成功!');
}

function escapeHtml(str) {
  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

3.8 可拖动分栏

编辑器和预览区之间的分隔条支持鼠标拖动调整宽度比例,带有最小宽度限制(200px)防止面板被压缩到不可用。

js 复制代码
function initResizer() {
  const resizer = document.getElementById('panelResizer');
  const editorPanel = document.getElementById('editorPanel');
  const previewPanel = document.getElementById('previewPanel');
  let isDragging = false;
  let startX = 0;
  let startEditorWidth = 0;
  let startPreviewWidth = 0;

  resizer.addEventListener('mousedown', (e) => {
    isDragging = true;
    startX = e.clientX;
    startEditorWidth = editorPanel.offsetWidth;
    startPreviewWidth = previewPanel.offsetWidth;
    e.preventDefault();
    e.stopPropagation();
  });

  document.addEventListener('mousemove', (e) => {
    if (!isDragging) return;
    e.preventDefault();

    const delta = e.clientX - startX;
    const newEditorWidth = startEditorWidth + delta;
    const newPreviewWidth = startPreviewWidth - delta;

    // 最小宽度限制
    if (newEditorWidth < 200 || newPreviewWidth < 200) return;

    editorPanel.style.flex = 'none';
    editorPanel.style.width = newEditorWidth + 'px';
    previewPanel.style.flex = 'none';
    previewPanel.style.width = newPreviewWidth + 'px';
  });

  document.addEventListener('mouseup', () => {
    if (isDragging) {
      isDragging = false;
      editor.refresh();
    }
  });
}

拖动注意事项: mousedown 时必须调用 stopPropagation 和 preventDefault,否则在鸿蒙 ArkWeb 引擎上可能出现拖动事件干扰编辑器的问题。mouseup 后必须无条件重置 isDragging 状态,否则会出现鼠标悬停触发拖动的 bug。

四、鸿蒙稳定性适配

4.1 自定义 Tooltip 替代原生 title

问题现象: 鼠标悬停在工具栏按钮上时,应用闪退。

根因分析: HTML 的 title 属性会触发浏览器原生 tooltip 弹窗,在鸿蒙 ArkWeb 引擎上,这会触发 SubWindow 创建。而鸿蒙适配层的 XComponentManager::WaitForXComponentCreated 存在超时缺陷,SubWindow 等待 XComponent 创建超时后直接 SIGABRT 崩溃。

解决方案: 将所有 title 属性替换为 data-tip 属性,通过 CSS ::after 伪元素实现纯 CSS tooltip,不触发任何原生弹窗。

HTML 修改(所有工具栏按钮):

js 复制代码
<!-- 修改前:使用原生 title 属性 -->
<button class="tool-btn" data-action="image" title="图片">🖼</button>

<!-- 修改后:使用 data-tip + CSS tooltip -->
<button class="tool-btn" data-action="image" data-tip="图片">🖼</button>

CSS 实现:

js 复制代码
/* 自定义 tooltip(替代原生 title,避免鸿蒙 SubWindow 崩溃) */
.tool-btn {
  position: relative;
}

.tool-btn[data-tip]:hover::after {
  content: attr(data-tip);
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  margin-top: 6px;
  padding: 4px 10px;
  background: #1e1e1e;
  color: #cccccc;
  font-size: 12px;
  border: 1px solid #3c3c3c;
  border-radius: 4px;
  white-space: nowrap;
  pointer-events: none;
  z-index: 9999;
}

4.2 自定义对话框替代 confirm/alert

问题现象: 新建文件或打开文件时,如果当前文件有未保存的更改,弹出确认对话框后应用崩溃。

根因分析: 原生 confirm() 和 alert() 函数在鸿蒙平台上会创建模态 SubWindow,同样触发 XComponent 创建超时崩溃。

解决方案: 使用纯 div 实现自定义对话框,完全避免原生弹窗。

js 复制代码
function showConfirm(message) {
  return new Promise((resolve) => {
    const overlay = document.createElement('div');
    overlay.className = 'dialog-overlay';
    overlay.innerHTML = `
      <div class="dialog-box">
        <div class="dialog-message">${message}</div>
        <div class="dialog-buttons">
          <button class="dialog-btn dialog-btn-ok">确定</button>
          <button class="dialog-btn dialog-btn-cancel">取消</button>
        </div>
      </div>
    `;
    document.body.appendChild(overlay);
    const okBtn = overlay.querySelector('.dialog-btn-ok');
    const cancelBtn = overlay.querySelector('.dialog-btn-cancel');
    okBtn.addEventListener('click', () => { document.body.removeChild(overlay); resolve(true); });
    cancelBtn.addEventListener('click', () => { document.body.removeChild(overlay); resolve(false); });
    okBtn.focus();
  });
}

function showAlert(message) {
  return new Promise((resolve) => {
    const overlay = document.createElement('div');
    overlay.className = 'dialog-overlay';
    overlay.innerHTML = `
      <div class="dialog-box">
        <div class="dialog-message">${message}</div>
        <div class="dialog-buttons">
          <button class="dialog-btn dialog-btn-ok">确定</button>
        </div>
      </div>
    `;
    document.body.appendChild(overlay);
    const okBtn = overlay.querySelector('.dialog-btn-ok');
    okBtn.addEventListener('click', () => { document.body.removeChild(overlay); resolve(); });
    okBtn.focus();
  });
}

4.3 鸿蒙三防窗口配置

主进程窗口创建遵循鸿蒙 Electron 壳方案的标准化配置:

配置项 作用
disableHardwareAcceleration true 防止 GPU 渲染白屏
frame true 使用系统原生标题栏
transparent false 禁用透明,防止渲染异常
resizable true 允许窗口缩放
try-catch 包裹 createWindow 防止单点故障导致进程退出

五、构建与部署

5.1 本地调试

在 electron-apps/Haroopad 目录下直接运行:

js 复制代码
cd electron-apps/Haroopad
npm install electron --save-dev
npm start

5.2 部署到鸿蒙壳工程

将开发目录全量同步到 web_engine 部署目录:

js 复制代码
# 清空部署目录
Remove-Item "web_engine/src/main/resources/resfile/resources/app/*" -Recurse -Force

# 复制应用文件
Copy-Item "electron-apps/Haroopad/*" "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:启动后预览区显示"渲染错误"

原因: marked.js v12 的 renderer.code 方法参数格式从独立参数改为 token 对象。旧版写法 function(code, lang) 实际接收到的是 object Object,传给 highlight.js 报错。

解决: 使用 typeof 检测参数类型,兼容新旧两种 API 格式:

js 复制代码
renderer.code = function(token) {
  let code, lang;
  if (typeof token === 'object' && token !== null) {
    code = token.text || token.code || '';
    lang = token.lang || '';
  } else {
    code = token || '';
    lang = arguments[1] || '';
  }

  let highlighted;
  try {
    if (lang && hljs.getLanguage(lang)) {
      highlighted = hljs.highlight(code, { language: lang }).value;
    } else {
      highlighted = hljs.highlightAuto(code).value;
    }
  } catch (e) {
    highlighted = code.replace(/</g, '&lt;').replace(/>/g, '&gt;');
  }
  return '<pre><code class="hljs">' + highlighted + '</code></pre>';
};

Q2:鼠标悬停工具栏按钮闪退

原因: 原生 title 属性触发浏览器 tooltip,在鸿蒙上创建 SubWindow 导致 XComponent 超时崩溃。

解决: 所有 title 属性替换为 data-tip,配合 CSS ::after 伪元素实现纯 CSS tooltip。

Q3:点击新建/打开文件时崩溃

原因: 原生 confirm() 函数创建模态 SubWindow,触发 XComponent 崩溃。

解决: 使用自定义 div 对话框替代所有 confirm/alert 调用。

Q4:拖动分隔条后隐藏预览再显示,布局异常

原因: 拖动时设置了 flex: none 和固定 width,切换预览面板时未重置。

解决: togglePreview 恢复预览时,同时重置 editorPanel 和 previewPanel 的 flex 为 '1' 并清除 width:

js 复制代码
if (previewVisible) {
  editorPanel.style.flex = '1';
  editorPanel.style.width = '';
  previewPanel.style.display = 'flex';
  previewPanel.style.flex = '1';
  previewPanel.style.width = '';
  resizer.style.display = 'block';
}

Q5:预览区代码块没有语法高亮

原因: marked v12 移除了 highlight 选项,需要改用自定义 renderer 实现代码高亮。

解决: 通过 renderer.code 自定义代码块渲染,手动调用 highlight.js 进行语法高亮。

七、总结

本文完整记录了在鸿蒙 PC 平台上适配 Haroopad Markdown 编辑器的全过程。通过 Electron 壳方案,我们实现了一个功能完整的 Markdown 实时预览编辑器,核心特性包括:

特性 实现方案
Markdown 编辑 CodeMirror 5 + Markdown 模式 + 代码折叠
实时预览 marked.js v12 + 300ms 防抖渲染
代码高亮 highlight.js 11.9.0 + 自定义 renderer
工具栏 data-action 属性驱动 + 10 种格式插入
图片插入 文件对话框选择 + file:// URI 自动转换
HTML 导出 marked 渲染 + 内联样式完整 HTML
分栏拖动 mousedown/mousemove/mouseup + 双向宽度同步
鸿蒙稳定 自定义 tooltip/对话框 + 三防窗口配置

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

  1. marked.js v12 API 变更导致渲染失败------需要兼容 token 对象参数格式
  2. 原生 title 属性触发 SubWindow 崩溃------必须用 CSS tooltip 替代
  3. 原生 confirm/alert 创建模态窗口崩溃------必须用自定义 div 对话框替代

这些问题在标准 Electron 环境中不会出现,是鸿蒙 ArkWeb 引擎特有的兼容性挑战。通过系统性的排查和修复,最终实现了稳定可用的鸿蒙版 Markdown 编辑器。

相关推荐
wei_shuo3 小时前
鸿蒙平台 Cypress 前端测试框架适配实战:基于 Electron 壳方案的纯 JavaScript BDD 测试引擎开发
openharmony
wei_shuo3 小时前
鸿蒙平台 ES Agent REST API 测试工具适配实战:基于 Electron 壳方案的跨平台 HTTP 调试客户端开发
openharmony
wei_shuo5 小时前
鸿蒙平台 H2 SQL 工作台适配实战:基于 Electron 壳方案的 Navicat 风格数据库管理工具开发
openharmony
wei_shuo1 天前
Flutter 三方库 OpenHarmony 鸿蒙适配实战:in_app_update 应用内更新插件 ArkTS 原生适配全流程
openharmony·in_app_update
Java的搬运工3 天前
HarmonyOS ArkUI V2 实战:Schema 驱动表单、2in1 适配与实时通信
harmonyos·arkts·openharmony·harmonyos next·表单校验·arkui v2·2in1
熊猫钓鱼>_>4 天前
Flutter app_settings 鸿蒙适配实战:Intent 体系到 Want 的跨越
flutter·华为·harmonyos·openharmony·intent·want
哈__5 天前
Flutter 3.44.9 + OpenHarmony7:image_cropper三方库 图片裁剪的应用
flutter·openharmony
moyh-blog24 天前
OpenHarmony播放音乐start请求从media_service到audio_server的调用流程02
openharmony·audio
Industio_触觉智能1 个月前
在瑞芯微RK平台下的开源鸿蒙OpenHarmony分区镜像/散包固件烧录
嵌入式硬件·openharmony·烧录·开源鸿蒙·瑞芯微·鸿蒙开发板·鸿蒙嵌入式