鸿蒙PC平台码驰代码编辑器适配实战:基于 Electron 壳方案的纯 JavaScript 语法高亮引擎与多标签编辑器开发
码驰代码编辑器是一款面向鸿蒙 PC 平台的轻量级代码编辑工具,提供文件树浏览、多标签编辑、自研语法高亮、查找替换、命令面板与行操作等完整编辑器能力。语法高亮引擎完全使用 JavaScript 独立实现,支持 JavaScript、TypeScript、Python、C、C++、JSON、CSS、HTML、Markdown、纯文本共 10 种语言的词法分析,采用手写字符扫描器分词方案——不依赖 CodeMirror、Monaco 或任何第三方高亮库。编辑器 UI 使用 CSS Grid 叠加方案(textarea 透明输入层 + div 高亮层同格叠放),实现输入与着色视觉同步。本文记录在鸿蒙 PC 平台上,基于 Electron 壳方案从零构建一个具备多标签管理、10 语言语法高亮、括号匹配、缩进参考线、查找替换(实时出现次数统计)、命令面板(18 条命令)、行移动/删除/注释切换、文件树递归扫描与 CRUD 操作的完整代码编辑器的全过程——涵盖自研分词引擎、Grid 叠加编辑器、文件树安全扫描、以及鸿蒙适配等核心实现细节与踩坑经验。
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_Zed
一、技术架构分析
1.1 整体架构
本项目采用 Electron Web 层 + 鸿蒙 HAP 壳工程的分层架构:
- Electron Web 层:运行于 ArkWeb 引擎,提供完整的代码编辑能力——文件树浏览、多标签管理、自研语法高亮(10 种语言)、查找替换、命令面板、行操作、文件 CRUD 等
- 鸿蒙 HAP 壳工程:通过 web_engine 模块加载 Web 应用,提供窗口管理、文件系统访问(真实 fs/path)、原生对话框等系统能力
这种架构的核心优势在于:业务代码与平台完全解耦,Electron 部分可以在任何平台独立运行,鸿蒙壳仅负责适配和打包。与采用 contextBridge 安全模型的 Electron 应用不同,本工程采用 nodeIntegration:true + contextIsolation:false 的直通模式——渲染进程直接访问 Node.js 的 fs、path、electron 的 ipcRenderer 等 API,主进程负责 dialog 对话框与文件读写 IPC,语法高亮与编辑器逻辑全部在渲染进程内完成。
1.2 技术方案
| 对比项 | 传统代码编辑器(VS Code / Sublime / Notepad++) | 鸿蒙适配版(码驰代码编辑器) |
|---|---|---|
| 语法高亮 | Tree-sitter / TextMate 语法 / Monaco 引擎 | 纯 JavaScript 手写字符扫描器分词(10 语言) |
| 编辑器渲染 | Canvas / iframe 沙箱 / Shadow DOM | CSS Grid 叠加(textarea 透明输入 + div 高亮) |
| 文件访问 | Node.js fs / VS Code FileSystemProvider | 8 个 IPC 通道(dialog/fs:tree/read/write/…) |
| 插件体系 | 扩展市场 / Lua 脚本 / Python API | 命令面板(18 条内置命令,无第三方扩展) |
| 部署方式 | 安装包 / 便携版 | HAP 包(鸿蒙应用) |
| 运行环境 | Windows / Linux / macOS | 鸿蒙 PC(ArkWeb 引擎) |
1.3 核心功能清单
| 功能模块 | 具体能力 |
|---|---|
| 语法高亮引擎 | 10 语言定义表(JS/TS/Python/C/C++/JSON/CSS/HTML/MD/TXT)+ 手写字符扫描器分词 |
| 分词器核心 | 块注释跨行状态 + 三引号字符串 + 预处理指令 + 行注释 + 字符串(单/双/反引号)+ 数字(含 0x 十六进制)+ 标识符/关键字/常量/函数名 + 运算符 |
| HTML 分词 | 标签匹配 + HTML 注释(跨行状态机) |
| Markdown 分词 | 标题 / 代码块 / 引用 / 列表 行级识别 |
| CSS 特殊处理 | cssIdent 模式允许连字符(background-color 等属性名) |
| Grid 叠加编辑器 | textarea 透明输入层 + div 高亮层 CSS Grid 同格叠加 + 滚动同步 |
| 括号匹配 | 双向查找(正向/反向)+ 深度计数 + 视觉高亮 |
| 缩进参考线 | 每 tabSize 个空格插入 │ 字符的 span 覆盖层 |
| 行注释切换 | 检测全部已注释 → 移除前缀 / 否则 → 添加前缀(支持块注释回退) |
| 文件树扫描 | 递归扫描(MAX_DEPTH=4, MAX_ENTRIES=2000)+ IGNORE_DIRS 过滤 |
| 多标签管理 | 打开/切换/关闭 + dirty 标记(●)+ 内容缓存 |
| 查找替换 | 大小写切换 + 实时出现次数统计 + 逐个替换 + 全部替换 |
| 命令面板 | Ctrl+Shift+P 唤起 + 模糊过滤 + 上下箭头选择 + Enter 执行 |
| 行操作 | 上移/下移/删除当前行 + Ctrl+Up/Down/Ctrl+Shift+K |
| 文件 CRUD | 新建文件/文件夹 + 重命名(三重校验)+ 删除(二次确认 + 5 秒超时) |
| 文件 IPC | 8 个通道(dialog:openFolder / fs:tree / fs:read / fs:write / fs:createFile / fs:createDir / fs:rename / fs:delete) |
| 鸿蒙安全 | 菜单双保险移除 + 禁用硬件加速 + 2 MB 文件大小上限 |
| 深色主题 | Catppuccin Mocha 配色 + CSS 变量体系(17 色) |
二、环境准备



2.1 开发环境要求
| 项目 | 版本/信息 |
|---|---|
| 操作系统 | Windows 10/11 |
| 核心框架 | Electron (Node.js + Chromium) |
| 技术栈 | HTML5/Vanilla JavaScript |
| 高亮引擎 | 纯 JavaScript 自研(503 行) |
| 编辑器 | renderer.js(1056 行) |
| 主进程 | main.js(206 行) |
| 样式 | zed.css(672 行) |
| 目标设备 | 鸿蒙 PC |
| 目标架构 | arm64-v8a |
| 开发工具 | DevEco Studio(鸿蒙官方 IDE) |
2.2 项目结构
ohos_hap/
├── electron/ # Electron 壳
├── web_engine/ # 鸿蒙壳工程
│ └── src/main/resources/resfile/resources/app/ # 部署目录(业务代码平铺)
│ ├── main.js # 主进程(窗口管理 + 8 个文件 IPC 通道)
│ ├── renderer.js # 渲染进程(文件树 + 多标签 + 编辑器 + 查找 + 命令面板)
│ ├── editor-engine.js # 自研高亮引擎(语言定义 + 分词器 + 括号匹配 + 注释切换)
│ ├── index.html # 页面布局(工具栏 + 侧栏 + 编辑区 + 状态栏 + 关于对话框)
│ ├── package.json # 应用元数据(name: machi, license: Apache-2.0)
│ └── styles/
│ └── zed.css # Catppuccin Mocha 主题 + Grid 叠加编辑器样式
├── AppScope/
│ └── app.json5 # bundleName: com.wei.machi, vendor: wei
└── build-profile.json5
2.3 部署流程
开发时在 electron/src/ 目录下编写代码,调试完成后同步到鸿蒙壳工程的部署目录:
Remove-Item "web_engine/src/main/resources/resfile/resources/app/*" -Recurse -Force
Copy-Item "electron/src/*" "web_engine/src/main/resources/resfile/resources/app/" -Recurse -Force
然后在 DevEco Studio 中构建 HAP 包,安装到鸿蒙 PC 设备即可运行。
三、核心适配流程
3.1 主进程入口与窗口创建
main.js 负责窗口创建和 IPC 通道注册。应用启动时先禁用硬件加速、移除默认菜单,再创建主窗口:
// main.js —— 应用启动与窗口创建
var electron = require('electron');
var app = electron.app;
var BrowserWindow = electron.BrowserWindow;
var Menu = electron.Menu;
app.disableHardwareAcceleration();
Menu.setApplicationMenu(null); // 应用级菜单移除
function createWindow() {
var display = screen.getPrimaryDisplay();
mainWindow = new BrowserWindow({
width: Math.floor(display.workAreaSize.width * 0.95),
height: Math.floor(display.workAreaSize.height * 0.90),
webPreferences: { nodeIntegration: true, contextIsolation: false }
});
mainWindow.loadFile('index.html');
mainWindow.setMenu(null); // 窗口级菜单移除(双保险)
}
窗口尺寸设置为工作区的 95%×90%,留出适当的边距。nodeIntegration:true + contextIsolation:false 直通模式让渲染进程可以直接 require(‘electron’).ipcRenderer 和 require(‘fs’),无需 preload 脚本桥接。
菜单移除采用双保险策略:Menu.setApplicationMenu(null) 移除应用级默认菜单栏(File/Edit/View/Window/Help),mainWindow.setMenu(null) 移除窗口级菜单。两者缺一不可——在鸿蒙 ArkWeb 环境下,仅移除其中一级可能导致另一级的默认菜单仍然显示。
3.2 文件树递归扫描
scanTree 函数递归扫描目录结构,内置三重保护机制防止扫描失控:
// main.js —— 文件树递归扫描
var IGNORE_DIRS = [
'node_modules', '.git', 'oh_modules', '.hvigor', '.idea',
'.ohpm', 'build', '.gradle', 'dist', 'out', '__pycache__'
];
var MAX_DEPTH = 4;
var MAX_ENTRIES = 2000;
function scanTree(dirPath, depth) {
var result = { name: path.basename(dirPath), path: dirPath, isDir: true, children: [] };
if (depth > MAX_DEPTH) return result;
var entries;
try {
entries = fs.readdirSync(dirPath, { withFileTypes: true });
} catch (e) {
result.readError = e.message;
return result;
}
// 排序:文件夹优先 + 字母序
entries.sort(function(a, b) {
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (var i = 0; i < entries.length; i++) {
if (scanTree.count >= MAX_ENTRIES) break; // 总量限制
var ent = entries[i];
if (ent.isDirectory()) {
if (IGNORE_DIRS.indexOf(ent.name) !== -1) continue; // 忽略构建/依赖目录
scanTree.count++;
result.children.push(scanTree(path.join(dirPath, ent.name), depth + 1));
} else if (ent.isFile()) {
scanTree.count++;
result.children.push({
name: ent.name, path: path.join(dirPath, ent.name),
isDir: false, children: []
});
}
}
return result;
}
三重保护机制:
- MAX_DEPTH=4:最大递归深度 4 层,避免深层嵌套目录导致栈溢出
- MAX_ENTRIES=2000:全局计数器限制扫描总量,防止超大项目卡死主进程
- IGNORE_DIRS:过滤 node_modules、.git、oh_modules、.hvigor、build 等构建产物和依赖目录,减少无效扫描
目录排序策略:文件夹始终排在文件前面(a.isDirectory() ? -1 : 1),同级条目按 localeCompare 字母序排列,保证文件树展示稳定有序。
3.3 八个 IPC 通道
主进程注册 8 个 IPC 通道,覆盖文件管理的全部操作:
| 通道名 | 功能 | 关键逻辑 |
|---|---|---|
| dialog:openFolder | 选择文件夹 | dialog.showOpenDialog 原生对话框 |
| fs:tree | 扫描文件树 | scanTree 递归 + 三重保护 |
| fs:read | 读取文件 | utf-8 编码 + 2 MB 大小上限 |
| fs:write | 写入文件 | writeFileSync + 返回 size/mtime |
| fs:createFile | 新建文件 | 文件名合法性校验 + 存在性检查 |
| fs:createDir | 新建文件夹 | recursive: true + 存在性检查 |
| fs:rename | 重命名 | 三重校验(源存在 + 目标目录存在 + 目标不占用) |
| fs:delete | 删除 | rmRecursiveSync 兼容旧版 Node |
文件读取设置了 2 MB 的大小上限,超过限制直接返回错误,避免大文件加载导致渲染进程内存溢出:
// main.js —— 文件读取(2 MB 上限)
ipcMain.handle('fs:read', function(event, filePath) {
try {
var stat = fs.statSync(filePath);
if (stat.size > 2 * 1024 * 1024) {
return { success: false, error: '文件超过 2 MB,编辑器不支持' };
}
var content = fs.readFileSync(filePath, 'utf-8');
return { success: true, content: content, size: stat.size };
} catch (e) {
return { success: false, error: e.message };
}
});
重命名操作的三重校验确保不会误覆盖已有文件:源文件必须存在、目标目录必须存在、目标路径不能已被占用。任何一项不满足都会返回明确的错误信息。
删除操作使用自定义的 rmRecursiveSync 函数而非 fs.rmSync 的 recursive 选项,原因是鸿蒙 ArkWeb 内置的 Node.js 版本可能较旧,不支持 fs.rmSync 的 recursive 参数:
// main.js —— 兼容旧版 Node 的递归删除
function rmRecursiveSync(targetPath) {
if (fs.existsSync(targetPath)) {
var stat = fs.statSync(targetPath);
if (stat.isDirectory()) {
var entries = fs.readdirSync(targetPath);
for (var i = 0; i < entries.length; i++) {
rmRecursiveSync(path.join(targetPath, entries[i]));
}
fs.rmdirSync(targetPath);
} else {
fs.unlinkSync(targetPath);
}
}
}
递归删除子条目,最后 rmdirSync 删除空目录——这是 Node.js 早期版本删除非空目录的标准做法。
四、自研语法高亮引擎
4.1 语言定义表
editor-engine.js 的核心是一张 LANGUAGES 语言定义表,为 10 种语言分别定义了扩展名、关键字列表、常量列表、行注释前缀、块注释对、以及特殊模式标志:
// editor-engine.js —— 语言定义表(10 种语言)
var LANGUAGES = {
javascript: {
extensions: ['.js', '.mjs', '.cjs', '.jsx'],
keywords: ['var', 'let', 'const', 'function', 'return', 'if', 'else', ...],
constants: ['true', 'false', 'null', 'undefined', 'NaN', 'Infinity'],
lineComment: '//', blockComment: ['/*', '*/'], templateString: true
},
typescript: { extensions: ['.ts', '.tsx'], ... },
python: { extensions: ['.py', '.pyw'], tripleStrings: ['"""', "'''"], ... },
c: { extensions: ['.c', '.h'], preprocessPrefix: '#', ... },
cpp: { extensions: ['.cpp', '.cc', '.cxx', '.hpp', '.hh'], ... },
json: { extensions: ['.json', '.json5', '.jsonc'], ... },
css: { extensions: ['.css'], cssIdent: true, ... },
html: { extensions: ['.html', '.htm', '.xml', '.svg'], htmlMode: true },
markdown: { extensions: ['.md', '.markdown'], markdownMode: true },
plain: { extensions: ['.txt', '.log', '.ini', ...], ... }
};
detectLanguage 函数根据文件扩展名匹配语言 ID,未命中时返回 plain。CSS 语言有一个特殊的 cssIdent:true 标志,允许标识符中包含连字符(如 background-color、font-size),这在其他语言中会被误判为减法运算符。
Python 语言定义了 tripleStrings 字段支持三引号多行字符串,C/C++ 定义了 preprocessPrefix:‘#’ 识别预处理指令,JavaScript/TypeScript 定义了 templateString:true 支持反引号模板字符串。
4.2 通用行扫描器
tokenizeGenericLine 是 JS/TS/Python/C/C++/JSON/CSS 共用的分词核心。它逐字符扫描一行源码,维护跨行状态(inBlockComment / inTripleString),按优先级匹配不同的词法单元:
// editor-engine.js —— 通用行扫描器(简化)
function tokenizeGenericLine(line, lang, st) {
var tokens = [];
var i = 0;
while (i < line.length) {
// 1. 跨行块注释:查找 */ 结束标记
if (st.inBlockComment) { ... continue; }
// 2. 跨行三引号字符串(Python)
if (st.inTripleString) { ... continue; }
// 3. 预处理指令(C/C++ 行首 #)
if (lang.preprocessPrefix && ...) { ... continue; }
// 4. 行注释(// 或 #)
if (lang.lineComment && line.startsWith(lang.lineComment, i)) { ... continue; }
// 5. 块注释开始(/* ... */)
if (lang.blockComment && line.startsWith(lang.blockComment[0], i)) { ... continue; }
// 6. 三引号字符串开始(""" 或 ''')
if (lang.tripleStrings) { ... continue; }
// 7. 普通字符串(单引号/双引号/反引号)
if (ch === '"' || ch === "'" || ...) { ... continue; }
// 8. 数字(含 0x 十六进制)
if (/[0-9]/.test(ch) || ...) { ... continue; }
// 9. 标识符 → 关键字 / 常量 / 函数名 / 普通
if (/[A-Za-z_$]/.test(ch)) { ... continue; }
// 10. 运算符/标点(连续合并)
if (/[+\-*/%=<>!&|^~?:;,(){}\[\]]/.test(ch)) { ... continue; }
}
return tokens;
}
扫描器的状态机设计要点:
- 跨行状态通过 st 对象在行与行之间传递:inBlockComment 标记是否处于块注释内,inTripleString 记录当前未闭合的三引号类型
- 字符串扫描处理转义字符:遇到反斜杠时跳过下一个字符(cj === ‘\’ → j += 2),避免被转义的引号提前截断
- 数字扫描支持十六进制前缀 0x 和后缀(L/U/F),scanNumber 函数独立处理
- 标识符分类:先查 keywords 表匹配关键字,再查 constants 表匹配常量,然后检查下一个字符是否为 ( 判断函数调用,其余归为 plain
- 运算符连续合并:多个运算符字符连续出现时合并为一个 operator token,例如 >= 不会被拆成 > 和 =
4.3 HTML 与 Markdown 专用扫描器
HTML 和 Markdown 的词法规则与通用语言差异较大,因此分别实现了专用扫描器。
tokenizeHtmlLine 处理 HTML 标签和注释:
// editor-engine.js —— HTML 行扫描器
function tokenizeHtmlLine(line, st) {
var tokens = [];
var i = 0;
while (i < line.length) {
// 跨行 HTML 注释
if (st.inHtmlComment) { ... }
// 开始注释 <!--
if (line.startsWith('<!--', i)) { ... }
// HTML 标签 <...>
if (line.charAt(i) === '<') { ... }
// 普通文本
var nxt = line.indexOf('<', i);
...
}
return tokens;
}
HTML 扫描器维护 inHtmlComment 跨行状态,识别 注释对。标签匹配采用简单策略:从 < 开始到最近的 > 结束,整段作为一个 tag token。这足以实现基本的标签着色,不需要完整的 HTML 解析器。
tokenizeMarkdownLine 按行首特征分类:
// editor-engine.js —— Markdown 行扫描器
function tokenizeMarkdownLine(line) {
var trimmed = line.trimStart();
if (trimmed.startsWith('#')) return [{ type: 'keyword', text: line }]; // 标题
if (trimmed.startsWith('```')) return [{ type: 'comment', text: line }]; // 代码块
if (trimmed.startsWith('>')) return [{ type: 'string', text: line }]; // 引用
if (trimmed.startsWith('- ') || ...) return [{ type: 'function', text: line }]; // 列表
return [{ type: 'plain', text: line }];
}
Markdown 扫描器不做行内级解析(如加粗、链接),仅按行首标记区分段落类型,复用已有的 CSS 类名着色——标题用 keyword 色(mauve)、代码块用 comment 色(overlay0)、引用用 string 色(green)、列表用 function 色(blue)。
4.4 括号匹配与行注释切换
findMatchingBracket 实现双向括号匹配,支持圆括号、方括号、花括号三组:
// editor-engine.js —— 双向括号匹配
var BRACKET_PAIRS = { '(': ')', '[': ']', '{': '}', ')': '(', ']': '[', '}': '{' };
var BRACKET_OPEN = { '(': 1, '[': 1, '{': 1 };
function findMatchingBracket(code, pos) {
var ch = code.charAt(pos);
var isForward = !!BRACKET_OPEN[ch];
var target = BRACKET_PAIRS[ch];
var depth = 1;
if (isForward) {
for (var i = pos + 1; i < code.length; i++) {
var c = code.charAt(i);
if (c === ch) depth++;
else if (c === target) { depth--; if (depth === 0) return i; }
}
} else {
for (var j = pos - 1; j >= 0; j--) {
var c2 = code.charAt(j);
if (c2 === ch) depth++;
else if (c2 === target) { depth--; if (depth === 0) return j; }
}
}
return -1;
}
光标位于括号上时,渲染进程检测光标处和光标前一个字符是否为括号,如果是则调用 findMatchingBracket 查找配对位置,返回的偏移量传给 renderHighlight 进行高亮着色。
toggleLineComment 实现行注释切换,逻辑为:先检测目标行是否全部已注释,如果是则移除注释前缀,否则添加前缀:
// editor-engine.js —— 行注释切换
function toggleLineComment(text, startLine, endLine, langId) {
var lang = LANGUAGES[langId] || LANGUAGES.plain;
var prefix = lang.lineComment;
if (!prefix && lang.blockComment) prefix = lang.blockComment[0] + ' ';
if (!prefix) return text; // JSON 等无注释语法的语言直接返回
var lines = text.split('\n');
// 检测是否全部已注释
var allCommented = true;
for (var i = startLine; i <= endLine; i++) {
var trimmed = lines[i].trimStart();
if (trimmed.length > 0 && !trimmed.startsWith(prefix)) { allCommented = false; break; }
}
// 添加或移除
for (var k = startLine; k <= endLine; k++) {
if (lines[k].trim() === '') continue;
if (allCommented) {
var idx = lines[k].indexOf(prefix);
if (idx !== -1) lines[k] = lines[k].substring(0, idx) + lines[k].substring(idx + prefix.length);
} else {
var indent = lines[k].match(/^(\s*)/)[1];
lines[k] = indent + prefix + lines[k].substring(indent.length);
}
}
return lines.join('\n');
}
当语言没有行注释(如 CSS、JSON)但有块注释时,回退使用块注释起始符加空格作为前缀(例如 CSS 使用 /* )。
4.5 缩进参考线与出现次数统计
renderIndentGuides 在行首缩进区域每隔 tabSize 个空格插入一个 │ 字符(Unicode U+2502),用 span.indent-guide 包裹后覆盖在原始 HTML 上:
// editor-engine.js —— 缩进参考线
function renderIndentGuides(htmlLine, rawLine, tabSize) {
var match = rawLine.match(/^( +)/);
if (!match) return htmlLine;
var indentLen = match[1].length;
var guided = '';
for (var i = 0; i < indentLen && i < htmlLine.length; i++) {
if (i > 0 && i % tabSize === 0) {
guided += '<span class="indent-guide">\u2502</span>';
} else {
guided += htmlLine.charAt(i);
}
}
return guided + htmlLine.substring(indentLen);
}
参考线使用 pointer-events:none 确保不干扰鼠标事件,颜色为 surface1(深灰色),视觉上仅起引导作用。
countOccurrences 使用 indexOf 循环统计子串在全文中的真实出现次数,支持大小写敏感/不敏感两种模式。这个函数被查找替换功能调用,实时显示匹配数量:
// editor-engine.js —— 出现次数统计
function countOccurrences(text, query, caseSensitive) {
if (!query) return 0;
var hay = caseSensitive ? String(text) : String(text).toLowerCase();
var needle = caseSensitive ? query : query.toLowerCase();
var count = 0;
var pos = 0;
while ((pos = hay.indexOf(needle, pos)) !== -1) {
count++;
pos += needle.length;
}
return count;
}
五、Grid 叠加编辑器
5.1 CSS Grid 同格叠加方案
码驰编辑器的核心 UI 创新是使用 CSS Grid 将 textarea(输入层)和 div(高亮层)放置在同一个网格单元中,实现精确的视觉叠加:
/* zed.css —— Grid 叠加布局 */
.editor-stack {
flex: 1;
display: grid;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.highlight-layer,
.input-layer {
grid-area: 1 / 1; /* 同一网格单元 */
font-family: var(--font-mono);
font-size: var(--editor-font-size);
line-height: var(--editor-line-height);
padding: 12px 16px;
tab-size: 2;
white-space: pre;
border: none;
}
.highlight-layer {
margin: 0;
overflow: hidden;
color: var(--text);
background: var(--base);
pointer-events: none; /* 不拦截鼠标事件 */
}
.input-layer {
overflow: auto;
background: transparent;
color: transparent; /* 文字透明 */
caret-color: var(--text); /* 光标可见 */
outline: none;
resize: none;
}
两层共享完全相同的 font-family、font-size、line-height、padding、tab-size、white-space 属性,确保高亮层的着色文字与输入层的不可见文字精确对齐。输入层的 color:transparent 让原生文字不可见,但 caret-color 保持光标可见;pointer-events:none 让高亮层不拦截鼠标事件,所有交互由输入层处理。
5.2 滚动同步与防抖渲染
syncScroll 函数在输入层滚动时同步高亮层和行号 gutter 的滚动位置:
// renderer.js —— 滚动同步
function syncScroll() {
el.highlightLayer.scrollTop = el.inputLayer.scrollTop;
el.highlightLayer.scrollLeft = el.inputLayer.scrollLeft;
el.gutter.scrollTop = el.inputLayer.scrollTop;
}
三个元素共享同一个滚动源(inputLayer),保证高亮层、行号列与输入区域始终对齐。
输入事件的防抖使用 requestAnimationFrame:
// renderer.js —— 防抖高亮
var highlightPending = false;
function onInput() {
var tab = findTab(state.activePath);
if (tab) tab.dirty = true;
renderTabs();
scheduleHighlight();
updateCursor();
if (state.findVisible) recalcFind();
}
function scheduleHighlight() {
if (highlightPending) return;
highlightPending = true;
requestAnimationFrame(function() {
highlightPending = false;
renderHighlight();
});
}
每次 onInput 只调度一次 requestAnimationFrame,同一帧内的多次输入合并为一次高亮渲染,避免频繁 DOM 操作导致卡顿。
5.3 大文件优化
renderHighlight 对超过 500 KB 的文件跳过语法高亮,直接输出纯文本 HTML:
// renderer.js —— 大文件优化
function renderHighlight() {
var code = el.inputLayer.value;
var rawLines = code.split('\n');
var hlHtml;
if (code.length > 500000) {
// 超过 500 KB:跳过语法高亮,仅做 HTML 转义
hlHtml = rawLines.map(function(l) { return escapeText(l); });
} else {
var tokenLines = tokenize(code, langId);
hlHtml = tokenLines.map(function(toks) {
// ... 正常高亮渲染
});
}
// 缩进参考线 + 当前行 + 括号匹配 + 查找匹配 + 行号 gutter
// ...
}
500 KB 阈值是一个经验值:在此之下,分词 + DOM 更新的总耗时通常在 16 ms 以内(一帧),不会引起可感知的卡顿;超过此阈值则跳过词法分析,仅做 HTML 转义后直接渲染,保证编辑流畅性。配合主进程 2 MB 的文件读取上限,编辑器可安全处理 0~2 MB 范围内的任意文件。
5.4 当前行与括号匹配高亮
renderHighlight 在生成每行 HTML 时,检查行号是否等于光标所在行,如果是则添加 current 类名实现当前行高亮:
// renderer.js —— 当前行高亮
var cls = (i + 1 === curLine) ? 'eline current' : 'eline';
out.push('<div class="' + cls + '">' + (lineHtml || ' ') + '</div>');
/* zed.css —— 当前行样式 */
.eline.current {
background: var(--surface0);
}
括号匹配高亮在 renderHighlight 中通过 insertHighlightSpan 函数实现:根据 state.bracketMatch 中保存的 open/close 偏移量,定位到对应行和字符位置,插入 span.bracket-match 包裹匹配括号。CSS 使用 peach 半透明背景加 outline 边框,视觉上清晰但不刺眼。
查找匹配高亮同理:state.findMatchStart/End 记录当前匹配的字符范围,renderHighlight 逐行检查是否跨越该范围,如果是则插入 span.find-match(yellow 半透明背景)。
六、文件树与多标签管理
6.1 文件树渲染
renderTree 将主进程返回的文件树数据结构递归渲染为 DOM:
// renderer.js —— 文件树递归渲染
function renderTreeNode(node, depth, container) {
var row = document.createElement('div');
row.className = 'tree-row';
row.style.paddingLeft = (8 + depth * 14) + 'px'; // 深度控制缩进
if (node.isDir) {
var isOpen = !!state.expanded[node.path];
row.innerHTML = '<span class="tree-icon">' + (isOpen ? '📂' : '📁') + '</span>' +
'<span class="tree-name">' + escapeText(node.name) + '</span>';
row.addEventListener('click', function() {
state.expanded[node.path] = !state.expanded[node.path];
renderTree();
});
container.appendChild(row);
if (isOpen && node.children) {
for (var i = 0; i < node.children.length; i++) {
renderTreeNode(node.children[i], depth + 1, container);
}
}
} else {
var isActive = node.path === state.activePath;
if (isActive) row.classList.add('active');
row.innerHTML = '<span class="tree-icon">📄</span>' +
'<span class="tree-name">' + escapeText(node.name) + '</span>';
row.addEventListener('click', function() { openFile(node.path); });
container.appendChild(row);
}
}
文件树通过 state.expanded 对象记录每个目录的展开/折叠状态,点击目录行切换状态后重新渲染整棵树。目录行显示文件夹图标(展开为 📂,折叠为 📁),文件行显示 📄。当前激活的文件行添加 active 类名高亮。
paddingLeft 按深度递增(8 + depth × 14 px),实现树形缩进视觉效果。
6.2 多标签管理
标签系统使用 state.tabs 数组存储所有打开的文件标签,state.activePath 记录当前激活标签的路径:
// renderer.js —— 标签管理核心
function openFile(path) {
var existing = findTab(path);
if (existing) { activateTab(path); return; } // 已打开则直接切换
ipcRenderer.invoke('fs:read', path).then(function(r) {
if (!r.success) { setStatus(r.error); return; }
var tab = {
path: path,
name: path.split(/[/\\]/).pop(),
lang: detectLanguage(path), // 按扩展名检测语言
content: r.content,
dirty: false,
size: r.size
};
state.tabs.push(tab);
updateRecent(path); // 更新最近打开列表
activateTab(path);
});
}
function activateTab(path) {
// 保存当前标签内容到 state
if (state.activePath) {
var cur = findTab(state.activePath);
if (cur) cur.content = el.inputLayer.value;
}
state.activePath = path;
var tab = findTab(path);
el.inputLayer.value = tab.content; // 恢复标签内容
showEditor(true);
renderTabs();
renderHighlight();
updateCursor();
renderBreadcrumb();
el.inputLayer.focus();
}
切换标签时,先将当前输入框的内容保存回 state.tabs 中对应标签的 content 字段,再从目标标签的 content 恢复到输入框。这样每个标签的内容始终保存在内存中,不依赖文件系统。
renderTabs 生成标签栏 DOM,未保存的标签在名称后显示 ● 标记(dirty 状态):
// renderer.js —— 标签栏渲染
function renderTabs() {
el.tabbar.innerHTML = '';
if (state.tabs.length === 0) {
el.tabbar.appendChild(empty); // "无打开的文件"
return;
}
state.tabs.forEach(function(tab) {
var t = document.createElement('div');
t.className = 'tab' + (tab.path === state.activePath ? ' active' : '');
var title = tab.name + (tab.dirty ? ' ●' : '');
t.innerHTML = '<span class="tab-label">' + escapeText(title) + '</span>' +
'<span class="tab-close" title="关闭 (Ctrl+W)">×</span>';
// ... 点击事件绑定
el.tabbar.appendChild(t);
});
}
关闭标签时,如果该标签有未保存的修改(dirty:true),会在状态栏提示"已关闭未保存文件"。关闭当前激活的标签后,自动切换到相邻标签(优先左侧);如果所有标签都被关闭,则显示欢迎页。
6.3 面包屑导航
renderBreadcrumb 将当前文件路径相对于根目录的部分拆分为段,用 › 分隔符连接:
// renderer.js —— 面包屑导航
function renderBreadcrumb() {
if (!state.activePath || !state.rootPath) {
el.breadcrumb.classList.add('hidden');
return;
}
el.breadcrumb.classList.remove('hidden');
var rel = state.activePath;
if (rel.startsWith(state.rootPath)) {
rel = rel.substring(state.rootPath.length);
if (rel.startsWith('\\') || rel.startsWith('/')) rel = rel.substring(1);
}
var parts = rel.split('\\');
var html = '';
for (var i = 0; i < parts.length; i++) {
if (i > 0) html += '<span class="breadcrumb-sep">›</span>';
html += '<span class="breadcrumb-seg">' + escapeText(parts[i]) + '</span>';
}
el.breadcrumb.innerHTML = html;
}
面包屑位于标签栏下方、编辑区上方,帮助用户快速了解当前文件在项目目录中的位置。
6.4 最近打开与欢迎页
updateRecent 维护一个 LRU(最近最少使用)列表,最多保存 10 条记录:
// renderer.js —— 最近打开(LRU 10 条)
function updateRecent(path) {
var idx = state.recentFiles.indexOf(path);
if (idx !== -1) state.recentFiles.splice(idx, 1); // 移除旧记录
state.recentFiles.unshift(path); // 插入队首
if (state.recentFiles.length > 10) state.recentFiles.pop(); // 超出上限
renderRecent();
}
当没有打开任何文件时,编辑器显示欢迎页,包含品牌标识、操作提示和最近打开列表。打开文件后欢迎页隐藏,显示编辑区。
七、查找替换与命令面板
7.1 查找栏与大小写切换
showFindBar 打开查找栏,可选显示替换输入框:
// renderer.js —— 查找栏
function showFindBar(withReplace) {
hideAllBars();
state.findVisible = true;
el.findBar.classList.remove('hidden');
el.replaceInput.classList.toggle('hidden', !withReplace);
document.getElementById('btnReplaceOne').classList.toggle('hidden', !withReplace);
document.getElementById('btnReplaceAll').classList.toggle('hidden', !withReplace);
el.findInput.focus();
el.findInput.select();
recalcFind();
}
toggleCase 切换大小写敏感模式,按钮添加 active 类名视觉反馈:
// renderer.js —— 大小写切换
function toggleCase() {
state.caseSensitive = !state.caseSensitive;
el.btnCaseToggle.classList.toggle('active', state.caseSensitive);
recalcFind();
}
recalcFind 在每次输入变化时调用 countOccurrences 统计匹配数量,实时显示"N 个结果"或"无匹配"。
7.2 查找导航与替换
findNext/findPrev 使用 indexOf/lastIndexOf 实现向前/向后查找,到达文件末尾/开头时自动回绕:
// renderer.js —— 查找下一个(支持回绕)
function findNext() {
var q = el.findInput.value;
if (!q || state.activePath === null) return;
var text = el.inputLayer.value;
var hay = state.caseSensitive ? text : text.toLowerCase();
var needle = state.caseSensitive ? q : q.toLowerCase();
var from = state.findMatchEnd > 0 ? state.findMatchEnd : el.inputLayer.selectionStart;
var idx = hay.indexOf(needle, from);
if (idx === -1) idx = hay.indexOf(needle, 0); // 回绕到开头
if (idx === -1) { el.findCount.textContent = '无匹配'; return; }
locateMatch(idx, q);
}
locateMatch 将光标定位到匹配位置,更新 state.findMatchStart/End 供 renderHighlight 绘制半透明黄色背景高亮。注意:只定位光标(setSelectionRange(idx, idx)),不选中文字——因为选中会触发浏览器默认的灰色选中背景,覆盖源码的语法高亮颜色。
replaceAll 支持大小写敏感和不敏感两种模式。不敏感模式手动循环 indexOf 逐段拼接替换结果,避免正则表达式的特殊字符转义问题。
7.3 命令面板
命令面板通过 Ctrl+Shift+P 唤起,提供 18 条内置命令的快速入口:
// renderer.js —— 命令定义(18 条)
var COMMAND_DEFS = [
{ id: 'openFolder', label: '打开文件夹', shortcut: 'Ctrl+O', action: openFolder },
{ id: 'save', label: '保存文件', shortcut: 'Ctrl+S', action: saveActive },
{ id: 'newFile', label: '新建文件', action: function() { showCreateInput('file'); } },
{ id: 'newFolder', label: '新建文件夹', action: function() { showCreateInput('folder'); } },
{ id: 'rename', label: '重命名文件', shortcut: 'F2', action: startRename },
{ id: 'delete', label: '删除文件', action: confirmDelete },
{ id: 'toggleSidebar', label: '切换侧边栏', action: toggleSidebar },
{ id: 'toggleWrap', label: '切换自动换行', action: toggleWordWrap },
{ id: 'fontUp', label: '增大字号', shortcut: 'Ctrl+=', action: ... },
{ id: 'fontDown', label: '减小字号', shortcut: 'Ctrl+-', action: ... },
{ id: 'goToLine', label: '跳转到行', shortcut: 'Ctrl+G', action: showGoToLine },
{ id: 'find', label: '查找', shortcut: 'Ctrl+F', action: ... },
{ id: 'replace', label: '替换', shortcut: 'Ctrl+H', action: ... },
{ id: 'closeTab', label: '关闭标签', shortcut: 'Ctrl+W', action: ... },
{ id: 'comment', label: '切换行注释', shortcut: 'Ctrl+/', action: toggleComment },
{ id: 'moveLineUp', label: '上移当前行', shortcut: 'Ctrl+Up', action: moveLineUp },
{ id: 'moveLineDown', label: '下移当前行', shortcut: 'Ctrl+Down', action: moveLineDown },
{ id: 'deleteLine', label: '删除当前行', shortcut: 'Ctrl+Shift+K', action: deleteCurrentLine }
];
命令面板的交互逻辑:
- 输入框实时过滤:filterCommands 将输入文字转小写后,对每条命令的 label 做 indexOf 模糊匹配
- 上下箭头选择:onCommandKey 监听 ArrowUp/ArrowDown 键,移动 state.commandIdx 索引并刷新选中样式
- Enter 执行:选中当前高亮的命令后调用其 action 函数
- Esc 关闭:hideCommandPalette 恢复编辑器焦点
命令面板使用 hidden 类控制显隐,打开时先调用 hideAllBars() 关闭其他浮层(查找栏/跳转栏/创建栏),保证同一时刻只有一个浮层可见。
7.4 浮层统一管理
hideAllBars 是全局浮层关闭函数,统一关闭查找栏、跳转栏、创建栏,并重置查找匹配状态:
// renderer.js —— 统一关闭所有浮层
function hideAllBars() {
el.findBar.classList.add('hidden');
el.goToLineBar.classList.add('hidden');
el.createBar.classList.add('hidden');
state.findVisible = false;
state.findMatchStart = -1;
state.findMatchEnd = -1;
el.inputLayer.focus();
scheduleHighlight();
}
关闭浮层后自动将焦点返回编辑器输入层,并触发一次高亮刷新(清除查找匹配着色)。
八、行操作与快捷键体系
8.1 行移动与删除
moveLineUp/moveLineDown 通过交换当前行与相邻行的数组元素实现行移动:
// renderer.js —— 上移当前行
function moveLineUp() {
if (!state.activePath) return;
var info = getLineInfo();
if (info.lineIdx === 0) return; // 已是第一行
var tmp = info.lines[info.lineIdx - 1];
info.lines[info.lineIdx - 1] = info.lines[info.lineIdx];
info.lines[info.lineIdx] = tmp;
setEditorContent(info.lines, info.lineIdx - 1, 0);
}
function deleteCurrentLine() {
if (!state.activePath) return;
var info = getLineInfo();
info.lines.splice(info.lineIdx, 1);
if (info.lines.length === 0) info.lines.push(''); // 至少保留一行
var newIdx = Math.min(info.lineIdx, info.lines.length - 1);
setEditorContent(info.lines, newIdx, 0);
}
getLineInfo 是一个通用工具函数,将编辑器内容按 \n 拆分为数组,根据光标位置计算当前行索引。setEditorContent 将修改后的行数组重新拼接为字符串,恢复编辑器内容并设置光标位置。
8.2 Tab 与 Enter 智能处理
onEditorKeyDown 拦截 Tab 和 Enter 键,实现缩进和自动缩进:
// renderer.js —— Tab 缩进/反缩进
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
var ta = el.inputLayer;
var start = ta.selectionStart;
if (e.shiftKey) {
// 反缩进:移除行首最多 TAB_SIZE 个空格
var lineStart = val.lastIndexOf('\n', start - 1) + 1;
var lineText = val.substring(lineStart, start);
var spaces = 0;
while (spaces < TAB_SIZE && lineText.charAt(spaces) === ' ') spaces++;
if (spaces > 0) {
ta.value = val.substring(0, lineStart) + val.substring(lineStart + spaces);
ta.selectionStart = ta.selectionEnd = start - spaces;
}
} else {
// 缩进:插入 TAB_SIZE 个空格
var indent = '';
for (var s = 0; s < TAB_SIZE; s++) indent += ' ';
ta.value = val.substring(0, start) + indent + val.substring(end);
ta.selectionStart = ta.selectionEnd = start + TABSize;
}
}
Enter 键的自动缩进逻辑:继承当前行的前导空白,如果行末字符是 {、( 或 : 则额外增加一级缩进:
// renderer.js —— Enter 自动缩进
if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
var pos = ta2.selectionStart;
var lineBegin = v.lastIndexOf('\n', pos - 1) + 1;
var currentLine = v.substring(lineBegin, pos);
var indent = currentLine.match(/^(\s*)/)[1]; // 继承前导空白
var lastChar = currentLine.trimEnd().slice(-1);
if (lastChar === '{' || lastChar === '(' || lastChar === ':') indent += ' '; // 额外缩进
ta2.value = v.substring(0, pos) + '\n' + indent + v.substring(ta2.selectionEnd);
}
8.3 全局快捷键体系
onGlobalKey 统一管理全局快捷键,优先级从高到低:
- Esc:关闭命令面板 → 关闭查找/跳转/创建栏
- 命令面板内部快捷键(ArrowUp/Down/Enter/Esc)
- Ctrl+Shift+P:命令面板
- Ctrl+/:切换行注释
- Ctrl+G:跳转行
- Ctrl+Up/Down:移动行
- Ctrl+Shift+K:删除行
- Ctrl+=/-:字号缩放
- F2:重命名
- Ctrl+S/O/F/H/W:保存/打开/查找/替换/关闭标签
// renderer.js —— 全局快捷键(部分)
function onGlobalKey(e) {
var mod = e.ctrlKey || e.metaKey;
// Esc 关闭所有浮层
if (e.key === 'Escape') {
if (state.commandVisible) { hideCommandPalette(); return; }
if (state.findVisible || ...) { hideAllBars(); return; }
}
// 命令面板打开时拦截所有其他快捷键
if (state.commandVisible) return;
// Ctrl+Shift+P 命令面板
if (mod && e.shiftKey && e.key === 'P') { e.preventDefault(); showCommandPalette(); return; }
// Ctrl+/ 注释
if (mod && e.key === '/') { e.preventDefault(); toggleComment(); return; }
// ...
}
命令面板打开时,所有全局快捷键被拦截(if (state.commandVisible) return),确保上下箭头和 Enter 键用于命令选择而非触发其他功能。
8.4 字号缩放与自动换行
changeFontSize 通过修改 CSS 变量 --editor-font-size 实现全局字号调整,范围限制在 10~24 px:
// renderer.js —— 字号缩放
function changeFontSize(delta) {
state.fontSize = Math.max(10, Math.min(24, state.fontSize + delta));
document.documentElement.style.setProperty('--editor-font-size', state.fontSize + 'px');
setStatus('字号: ' + state.fontSize + 'px');
}
由于高亮层和输入层都使用 var(–editor-font-size) 引用字号,修改 CSS 变量后两层自动同步更新,无需手动遍历 DOM。
toggleWordWrap 切换自动换行模式,通过添加/移除 editorStack 上的 wrap-mode 类名实现:
/* zed.css —— 自动换行 */
.wrap-mode .highlight-layer,
.wrap-mode .input-layer {
white-space: pre-wrap;
word-wrap: break-word;
}
8.5 关于对话框
关于对话框使用 hidden 类控制显隐(而非 window.open 弹窗),通过 fixed 定位覆盖整个视口:
/* zed.css —— 关于对话框 */
#aboutDialogLayer {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(17, 17, 27, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
对话框内容分三段:开源声明(致谢 Zed 等开源编辑器,明确说明不包含其任何源代码)、免责声明(不包含任何网络传输功能,编辑内容仅保存在本地设备)、许可证信息(Apache-2.0)。这是鸿蒙应用上架审核的标准格式。
CSS 文件中特别注释了关于对话框使用 fixed 覆盖的原因:“常规区域不使用 fixed/叠放/形变/动画;关于对话框在 Electron 壳内使用 fixed 覆盖(Chromium 支持)”——在鸿蒙 ArkWeb 环境下,只有对话框这类临时覆盖层才使用 fixed 定位,常规布局区域严格遵循 Flexbox 流式布局。
九、常见问题与解决方案
Q1:语法高亮在输入时卡顿,尤其是大文件
问题现象: 用户在编辑器中输入文字时,每次按键都会触发语法高亮重绘,文件超过数百行时明显卡顿。
根因分析: 早期实现中 onInput 直接调用 renderHighlight,每次输入都同步执行分词 + DOM 更新。当文件较大时,tokenize 遍历所有行的开销超过 16 ms,导致掉帧。
解决: 使用 requestAnimationFrame 防抖,同一帧内的多次输入合并为一次高亮渲染:
// renderer.js —— 防抖高亮
var highlightPending = false;
function scheduleHighlight() {
if (highlightPending) return;
highlightPending = true;
requestAnimationFrame(function() {
highlightPending = false;
renderHighlight();
});
}
对于超过 500 KB 的文件,直接跳过语法高亮,仅做 HTML 转义后渲染纯文本,保证编辑流畅性。
Q2:CSS 属性名被错误拆分为关键字和运算符
问题现象: 编辑 CSS 文件时,background-color 被高亮为 background(关键字)+ -(运算符)+ color(关键字),而不是整体着色。
根因分析: 通用标识符扫描器的正则 /[A-Za-z0-9_$]/ 不包含连字符 -,遇到 background-color 时在 g 和 - 之间断开,将 - 识别为减法运算符。
解决: 为 CSS 语言定义增加 cssIdent:true 标志,标识符扫描时切换到 /[A-Za-z0-9_$-]/ 正则,允许连字符作为标识符的一部分:
// editor-engine.js —— CSS 标识符特殊处理
if (/[A-Za-z_$]/.test(ch)) {
var w = i;
var identRe = lang.cssIdent ? /[A-Za-z0-9_$-]/ : /[A-Za-z0-9_$]/;
while (w < n && identRe.test(line.charAt(w))) w++;
var word = line.substring(i, w);
// ... 关键字/常量/函数名判断
}
其他语言不使用 cssIdent 模式,避免 count-1 这样的表达式被误识别为一个标识符。
Q3:查找替换在大小写不敏感模式下结果不正确
问题现象: 关闭大小写敏感后,查找 “Hello” 无法匹配文档中的 “hello”。
根因分析: 早期实现直接使用 String.indexOf 查找,没有对搜索文本和文档内容同时做 toLowerCase 转换。
解决: 查找时将文档和查询都转为小写后匹配,但替换时使用原始文档的文本片段,保持原有大小写:
// renderer.js —— 大小写不敏感查找
function findNext() {
var q = el.findInput.value;
var text = el.inputLayer.value;
var hay = state.caseSensitive ? text : text.toLowerCase();
var needle = state.caseSensitive ? q : q.toLowerCase();
var from = state.findMatchEnd > 0 ? state.findMatchEnd : el.inputLayer.selectionStart;
var idx = hay.indexOf(needle, from);
if (idx === -1) idx = hay.indexOf(needle, 0); // 回绕
if (idx === -1) { el.findCount.textContent = '无匹配'; return; }
locateMatch(idx, q); // 用原始 q 的长度定位匹配区域
}
replaceAll 同样区分大小写/不敏感两种路径,不敏感模式手动循环 indexOf 逐段拼接,避免正则特殊字符转义问题。
Q4:文件树扫描大项目时主进程卡死
问题现象: 打开包含 node_modules 的大型前端项目时,应用无响应数秒。
根因分析: 早期实现没有 IGNORE_DIRS 过滤和 MAX_ENTRIES 限制,scanTree 递归扫描 node_modules 下数万个文件,主进程被 fs.readdirSync 阻塞。
解决: 三重保护机制:
// main.js —— 文件树扫描保护
var IGNORE_DIRS = ['node_modules', '.git', 'oh_modules', '.hvigor', '.idea',
'.ohpm', 'build', '.gradle', 'dist', 'out', '__pycache__'];
var MAX_DEPTH = 4;
var MAX_ENTRIES = 2000;
function scanTree(dirPath, depth) {
if (depth > MAX_DEPTH) return result; // 深度限制
// ...
for (var i = 0; i < entries.length; i++) {
if (scanTree.count >= MAX_ENTRIES) break; // 总量限制
if (ent.isDirectory() && IGNORE_DIRS.indexOf(ent.name) !== -1) continue; // 目录过滤
// ...
}
}
IGNORE_DIRS 过滤掉常见的构建产物和依赖目录,MAX_DEPTH 限制递归深度,MAX_ENTRIES 限制全局扫描总量。三者共同确保扫描在合理时间内完成。
Q5:删除文件时误操作导致数据丢失
问题现象: 用户点击删除按钮后文件立即被删除,无法恢复。
根因分析: 早期实现单击删除按钮直接调用 fs.unlinkSync,没有二次确认机制。
解决: 实现二次确认 + 5 秒超时取消机制:
// renderer.js —— 删除二次确认
function confirmDelete() {
if (!state.activePath) { setStatus('请先打开文件'); return; }
var tab = findTab(state.activePath);
if (!tab) return;
// 第二次点击同一文件:执行删除
if (state.deleteConfirmPath === state.activePath) {
doDelete();
return;
}
// 第一次点击:提示确认
state.deleteConfirmPath = state.activePath;
setStatus('确认删除 ' + tab.name + '?再次点击删除按钮');
// 5 秒超时自动取消
if (state.deleteConfirmTimer) clearTimeout(state.deleteConfirmTimer);
state.deleteConfirmTimer = setTimeout(function() {
state.deleteConfirmPath = null;
setStatus('删除已取消');
}, 5000);
}
第一次点击删除按钮时,状态栏显示确认提示并记录目标路径;5 秒内再次点击同一文件的删除按钮才真正执行删除。超过 5 秒未确认则自动取消,防止误操作。
Q6:修改了代码,但 HAP 安装后没有生效
问题现象: 修改了 editor-engine.js、renderer.js 等文件,重新构建 HAP 安装后,应用行为没有任何变化。
根因分析: 鸿蒙壳工程的实际运行文件从 web_engine/src/main/resources/resfile/resources/app/ 部署目录加载,而非开发目录。修改后必须手动同步。
解决: 每次修改后执行同步命令:
Remove-Item "web_engine/src/main/resources/resfile/resources/app/*" -Recurse -Force
Copy-Item "electron/src/*" "web_engine/src/main/resources/resfile/resources/app/" -Recurse -Force
验证方法: 同步后用文件哈希对比确认一致性。
Q7:AGC 审核驳回——requestPermissions 声明了受限 ACL 权限
问题现象: AGC 审核驳回,指出 web_engine/src/main/module.json5 中声明了受限 ACL 权限,但应用隐私政策中未声明对应用途。
根因分析: Electron 壳工程模板默认声明了大量系统权限,码驰代码编辑器作为纯本地编辑工具不需要位置、相机、麦克风、剪贴板等权限。
解决: 精简权限声明,只保留应用实际使用的系统权限,移除 FILE_ACCESS_PERSIST、READ_WRITE_DESKTOP_DIRECTORY、READ_PASTEBOARD、CAMERA、MICROPHONE 等不需要的权限。码驰代码编辑器无剪贴板功能,READ_PASTEBOARD 旁路不适用。
十、总结
本文完整记录了在鸿蒙 PC 平台上适配码驰代码编辑器的全过程。通过 Electron 壳方案,我们实现了一个纯 JavaScript 的轻量级代码编辑器,核心特性包括:
| 特性 | 实现方案 |
|---|---|
| 语法高亮引擎 | 10 语言定义表 + 手写字符扫描器分词(无第三方库依赖) |
| 分词器核心 | 块注释跨行状态 + 三引号字符串 + 预处理指令 + 字符串/数字/标识符 |
| HTML/MD 分词 | 专用扫描器(标签+注释 / 标题+代码块+引用+列表) |
| CSS 特殊处理 | cssIdent 模式允许连字符(background-color 等属性名) |
| Grid 叠加编辑器 | textarea 透明输入层 + div 高亮层 CSS Grid 同格叠加 |
| 括号匹配 | 双向查找 + 深度计数 + peach 半透明背景高亮 |
| 缩进参考线 | 每 tabSize 个空格插入 │ 字符 span 覆盖层 |
| 行注释切换 | 检测全部已注释 → 移除 / 否则 → 添加(支持块注释回退) |
| 文件树扫描 | 递归扫描 + MAX_DEPTH=4 + MAX_ENTRIES=2000 + IGNORE_DIRS |
| 多标签管理 | 打开/切换/关闭 + dirty 标记(●)+ 内容缓存 |
| 查找替换 | 大小写切换 + 实时出现次数统计 + 回绕查找 + 逐个/全部替换 |
| 命令面板 | 18 条命令 + 模糊过滤 + 键盘导航(上下箭头 + Enter + Esc) |
| 行操作 | 上移/下移/删除当前行 + Tab/Shift+Tab 缩进 + Enter 自动缩进 |
| 文件 CRUD | 新建/重命名(三重校验)/删除(二次确认 + 5 秒超时) |
| 文件 IPC | 8 个通道(dialog/tree/read/write/createFile/createDir/rename/delete) |
| 大文件优化 | >500 KB 跳过语法高亮 + 2 MB 文件读取上限 |
| 鸿蒙安全 | 菜单双保险移除 + 禁用硬件加速 + fixed 覆盖仅限对话框 |
| 深色主题 | Catppuccin Mocha 配色 + CSS 变量体系(17 色) |
适配过程中最关键的三个点:
- 自研分词器与状态机设计——语法高亮引擎的核心是一个手写字符扫描器,逐字符扫描源码行并根据当前状态(是否在块注释内、是否在三引号字符串内)选择匹配规则。跨行状态通过 st 对象在行与行之间传递:inBlockComment 标记块注释、inTripleString 记录三引号类型。每种语言的关键字、常量、注释符等差异通过 LANGUAGES 定义表配置,扫描器本身是通用的。CSS 语言的 cssIdent 标志让标识符扫描器允许连字符,解决了 background-color 等属性名被误拆的问题
- CSS Grid 叠加编辑器方案——编辑器的核心创新是将 textarea(输入层)和 div(高亮层)放在同一个 CSS Grid 单元中(grid-area: 1/1),两层共享完全相同的字体、行高、内边距、tab-size、white-space 属性,确保高亮层的着色文字与输入层的不可见文字精确对齐。输入层设置 color:transparent 隐藏原生文字但保留 caret-color 光标可见,pointer-events:none 让高亮层不拦截鼠标事件。滚动同步通过 syncScroll 函数将 inputLayer 的 scrollTop/scrollLeft 实时复制到 highlightLayer 和 gutter
- 文件树安全扫描与防抖渲染——文件树扫描通过三重保护(MAX_DEPTH=4 限制递归深度、MAX_ENTRIES=2000 限制全局总量、IGNORE_DIRS 过滤构建/依赖目录)避免大项目卡死主进程。编辑器输入采用 requestAnimationFrame 防抖,同一帧内的多次按键合并为一次高亮渲染;超过 500 KB 的文件直接跳过语法高亮仅做 HTML 转义。删除操作实现二次确认 + 5 秒超时机制,防止误操作导致数据丢失
这些问题在标准 Electron 环境中不会出现或表现不同,是鸿蒙 ArkWeb 引擎特有的兼容性挑战。通过系统性的排查和修复,最终实现了稳定可用的鸿蒙版纯 JavaScript 代码编辑器。
更多推荐


所有评论(0)