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, '<').replace(/>/g, '>');
}
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 = '';
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
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, '<').replace(/>/g, '>');
}
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/对话框 + 三防窗口配置 |
适配过程中最关键的三个坑:
- marked.js v12 API 变更导致渲染失败------需要兼容 token 对象参数格式
- 原生 title 属性触发 SubWindow 崩溃------必须用 CSS tooltip 替代
- 原生 confirm/alert 创建模态窗口崩溃------必须用自定义 div 对话框替代
这些问题在标准 Electron 环境中不会出现,是鸿蒙 ArkWeb 引擎特有的兼容性挑战。通过系统性的排查和修复,最终实现了稳定可用的鸿蒙版 Markdown 编辑器。