鸿蒙 PC Markdown 编辑器命令面板:把桌面高频操作收束到一个入口
在桌面编辑器里,命令面板不是一个"搜索按钮集合"的弹窗。它真正解决的是功能增长之后的入口失控:菜单越来越深、工具栏越来越宽、快捷键越来越难记,而用户的注意力仍然应该停留在文档上。鸿蒙 PC Markdown 编辑器需要同时服务键盘型用户、触控板用户和偶尔触屏操作的用户,因此命令面板必须成为一种稳定的任务入口,而不是另一套与界面状态脱节的功能清单。
本文只讨论已经进入项目主分支并完成模拟器验证的实现。对应仓库为 https://gitcode.com/VON-/codex_md_oh,主要代码基线是提交 ad1e31a,后续工作区搜索命令在 2ca99e9 中接入同一模型。文中不会把尚未完成的插件系统、用户自定义命令或全局索引包装成现有能力。
从桌面任务而不是按钮数量出发
Markdown 编辑器的常用任务通常横跨文件、编辑、导航、视图和导出。用户可能正在源代码视图里输入,下一秒要打开文件夹,再下一秒要切换分栏或导出 HTML。如果每类任务只在一个固定区域提供入口,就会出现两个问题:第一,鼠标行程和视觉搜索成本随功能增加而增加;第二,熟练用户无法形成统一的键盘操作节奏。
命令面板的产品目标因此被定义为"在不离开当前上下文的前提下定位并执行可用命令"。这里有三个关键词。当前上下文意味着面板覆盖编辑区但不销毁文档会话;定位意味着标题、类别和关键词都能参与筛选;可用命令意味着大文档模式等运行时约束必须反映在命令项状态上,不能允许用户执行必然失败的操作。
这也解释了为什么实现没有直接把工具栏按钮克隆一份。按钮只表达视觉入口,命令对象还需要稳定标识、检索元数据、启用条件与执行函数。只有把这些信息收敛成模型,菜单、快捷键、自动化测试以及未来的右键菜单才有机会共享同一事实来源。
命令模型是最小而明确的契约
Web 编辑器中的命令接口位于 web-editor/src/main.ts。当前模型刻意保持扁平,没有引入通用依赖注入容器或复杂总线:
ts
interface EditorCommand {
id: string;
title: string;
category: string;
keywords: string;
enabled: () => boolean;
execute: () => void;
}
id 是自动化和 DOM 标识的稳定键,不能使用会被本地化改变的标题;title 面向用户;category 用于扫描;keywords 补充用户可能想到但标题中没有出现的表达;enabled 在每次渲染时读取运行状态;execute 只描述动作,不把面板关闭、焦点恢复等展示细节塞进每个命令。
这份接口看起来简单,但边界非常重要。它没有让命令直接访问 ArkUI 文件 API,也没有把原生 URI 暴露给 ArkWeb。文件打开、保存、选择工作区仍然由原生层完成。编辑器内部的撤销、重做可以直接执行,文件和窗口类命令则通过受限 Bridge 发给原生壳层。命令面板统一的是入口,不是抹平权限边界。
注册表同时表达能力与降级规则
命令注册表是一组静态对象。下面是其中一部分真实代码:
ts
const editorCommands: Array<EditorCommand> = [
{
id: 'file.openWorkspace', title: 'Open Folder', category: 'File',
keywords: 'workspace directory',
enabled: () => true,
execute: () => requestNativeCommand('openWorkspace')
},
{
id: 'edit.undo', title: 'Undo', category: 'Edit',
keywords: 'revert history',
enabled: () => true,
execute: () => { undo(editor); }
},
{
id: 'view.split', title: 'Show Split View', category: 'View',
keywords: 'editor preview side by side',
enabled: () => !largeDocumentMode,
execute: () => requestNativeCommand('viewSplit')
}
];
分栏预览在大文档模式下被禁用,不是因为按钮需要变灰,而是因为大文档模式以保持输入响应为首要目标。把条件写在命令对象里之后,命令面板不会绕过这条性能规则。未来同一命令若出现在菜单中,也应该读取同一个 enabled 判断,避免某个入口能执行、另一个入口不能执行的状态分裂。
注册表还为后续能力保留了自然扩展点。提交 2ca99e9 新增 Search Workspace 与 Quick Open 时,只增加命令对象和受限命令名,没有重做面板结构。这个结果证明模型的粒度基本合适:扩展一个真实任务时,修改集中在命令声明、Bridge 白名单和原生路由,而不是散落地复制 UI。
检索不追求炫技而追求可解释
第一版筛选使用规范化后的包含匹配:
ts
function normalizeCommandQuery(value: string): string {
return value.trim().toLocaleLowerCase();
}
function commandMatchesQuery(command: EditorCommand, query: string): boolean {
if (query.length === 0) {
return true;
}
return `${command.title} ${command.category} ${command.keywords}`
.toLocaleLowerCase()
.includes(query);
}
这不是一个模糊搜索算法,但它有三个工程优点。其一,命令数量当前很小,线性扫描的成本可以忽略;其二,用户输入为什么命中某项是可解释的,不会因为隐蔽权重让列表跳动;其三,代码不依赖网络、索引服务或额外运行库,符合编辑器离线优先的约束。
如果未来命令数量增加到数百项,可以在不改变 EditorCommand 契约的前提下替换排序函数,例如加入标题前缀、词边界、最近使用频率和连续字符奖励。但升级前需要先记录真实任务数据,因为过早引入复杂评分会制造不可预测排序,也会让键盘用户依赖的位置发生变化。当前实现选择的是与规模相称的复杂度,而不是把"模糊搜索"作为宣传标签。
DOM 构建必须把文本当文本
命令结果没有通过拼接 innerHTML 生成。每一项使用 DOM API 创建按钮、标题和类别,并通过 textContent 写入文本:
ts
function renderCommandPalette(): void {
const query = normalizeCommandQuery(commandQuery.value);
filteredCommands = editorCommands.filter((command) => commandMatchesQuery(command, query));
commandResults.replaceChildren();
filteredCommands.forEach((command, index) => {
const option = document.createElement('button');
option.type = 'button';
option.className = 'command-palette__option';
option.id = `command-option-${command.id}`;
option.dataset.commandId = command.id;
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', index === selectedCommandIndex ? 'true' : 'false');
option.disabled = !command.enabled();
const title = document.createElement('span');
title.textContent = command.title;
const category = document.createElement('span');
category.textContent = command.category;
option.append(title, category);
commandResults.append(option);
});
}
即使当前命令由开发者静态注册,仍然值得坚持文本节点而不是 HTML 字符串。这样做减少未来接入本地化、插件元数据或用户配置时的注入风险,也让 CSP 策略保持简单。按钮元素还能天然获得禁用、焦点和点击语义,比在普通 div 上模拟交互更可靠。
渲染函数会在每次查询变化后重建有限数量的结果。当前命令规模下,这比维护复杂的增量虚拟列表更稳。命令面板不是工作区全文搜索结果,不应该为了理论上的海量数据引入不必要的状态同步成本。
键盘导航是一套状态机
面板内部维护 filteredCommands 和 selectedCommandIndex。输入变化后重新筛选,如果旧索引超过新结果长度,就把索引收敛到有效范围;上下方向键只改变选中项;Enter 执行当前项;Escape 关闭面板。索引更新还同步 aria-selected 和输入框的 aria-activedescendant,使视觉选择与辅助技术感知保持一致。
这里最容易出现的缺陷不是"方向键没反应",而是结果变少后仍引用旧索引,或鼠标点击与键盘选择维护两份状态。实现通过统一的 applyCommandSelection 和 executeCommand 减少分叉。鼠标悬停、点击和键盘导航最终都落到同一个命令对象上,禁用判断也在执行前再次检查。
执行顺序同样有要求:先确认命令可用,再关闭面板,最后执行动作。这样原生命令打开系统选择器时不会被遗留遮罩挡住;编辑器内部命令完成后,焦点可以回到编辑区。若先执行再关闭,在异步原生窗口出现时可能产生焦点竞争;若关闭后不执行可用性复核,运行状态刚刚改变时会触发过期命令。
快捷键需要明确作用域
当前面板使用 Ctrl+Shift+P,同时兼容 macOS 风格的 Meta 修饰键。全局监听先处理三方差异视图和已打开的命令面板,再判断修饰键:
ts
window.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !conflictComparison.hidden) {
event.preventDefault();
closeThreeWayDiff();
return;
}
if (!conflictComparison.hidden) {
return;
}
if (!(event.ctrlKey || event.metaKey) || event.altKey) {
return;
}
const key = event.key.toLowerCase();
if (key === 'p' && event.shiftKey) {
event.preventDefault();
commandPalette.hidden ? openCommandPalette() : closeCommandPalette();
}
});
三方差异界面优先于命令面板,是一个明确的模态规则。冲突比较正在显示时,不应该再叠加第二层全局操作界面。Alt 组合被排除,避免抢占输入法或系统级组合键。只有确认命中应用快捷键时才调用 preventDefault,普通输入和浏览器自身未占用的键不会被无差别吞掉。
工作区快速打开使用 Ctrl+P,命令面板使用 Ctrl+Shift+P。二者在监听顺序中明确区分,不依赖字符串拼接或模糊判断。这种细节决定了桌面应用长期扩展时快捷键是否可维护。后续若支持用户重映射,仍需先建立冲突检测和作用域优先级,而不是简单地把事件监听器继续堆叠。
焦点恢复决定面板是否顺手
打开面板时先取消隐藏状态、清空查询、重建结果,然后在下一帧聚焦输入框。下一帧而非同步聚焦,是为了确保元素已经参与布局。关闭时清理查询状态并把焦点交还编辑器。这个焦点闭环对 PC 用户比动画更重要:连续执行"打开面板、输入、回车、继续打字"时,不应该额外点击正文。
焦点处理还与 ArkWeb 宿主有关。命令面板位于 Web 编辑器内部,原生 ArkUI 仍管理窗口、侧栏和文件能力。执行 Open Folder 后系统选择器取得焦点;返回应用时,原生层更新工作区状态,Web 层不能在错误时机强制夺回焦点。因此面板只在本地关闭动作后恢复编辑器焦点,涉及系统窗口的最终焦点由宿主和操作结果共同决定。
这是一种克制的焦点策略。为了追求"始终聚焦编辑器"而在多个异步回调里调用 focus(),会导致搜索输入框、设置控件或系统对话框被抢焦点,最终损害键盘操作。桌面交互的稳定感往往来自少做一次错误聚焦。
ArkWeb 不接触文件权限
文件类命令通过 requestNativeCommand 进入白名单:
ts
type NativeCommand = 'new' | 'open' | 'openWorkspace' | 'save' | 'saveAs' |
'autoSave' | 'find' | 'findWorkspace' | 'quickOpen' |
'viewSource' | 'viewSplit' | 'viewPreview' | 'exportHtml' | 'print';
function requestNativeCommand(command: NativeCommand): void {
window.OhMarkdownEditor?.requestCommand(command);
}
类型联合限制 Web 侧能发出的命令名称,原生层的 onEditorCommand 再做一次显式分支。传输内容只有命令名和必要正文,不传任意函数名,不允许 Web 层构造文件系统路径,也没有"执行任意脚本"一类后门。系统文件选择、URI 授权、CoreFileKit 读写继续留在 ArkTS。
这条边界让命令面板即使未来显示更多动作,也不会自动扩大权限。新增文件能力必须同时经过产品入口、类型白名单、Bridge 接口、原生路由和测试,形成可审查的变更链。对于本地优先编辑器,统一入口不能以统一权限为代价。
大文档模式的可用性表达
大文档模式下,分栏、预览和导出命令会禁用。禁用项仍可显示,是因为它向用户解释"功能存在但当前不可用",也保持命令列表结构稳定。若直接过滤掉,用户会误以为功能消失,并且在文件大小跨越阈值时列表位置发生剧烈变化。
不过禁用状态不能只有颜色差异。真实按钮的 disabled 属性阻止点击和键盘激活,视觉样式再作为补充。命令执行函数仍复核 enabled(),用于防止自动化或状态竞态绕过 DOM 禁用。状态源是编辑器已有的 largeDocumentMode,没有为命令面板复制第二份"大文件"判断。
这种降级也体现了产品优先级:输入可靠性高于实时预览。用户可以继续编辑、保存和查找,只是暂时关闭高成本能力。命令面板把这个决策一致地呈现出来,而不是让不同入口给出相互矛盾的结果。
真实界面与设备证据
下图来自 HarmonyOS MateBook Pro 2in1 模拟器中的实际应用。面板显示筛选输入、类别和当前选中项,背景文档会话仍然存在。

命令执行后,分栏状态由原生与 Web 两层共同更新,截图记录了界面同步结果:

截图的意义不是证明视觉稿完成,而是证明真实链路从快捷键、筛选、执行、Bridge 到视图状态可走通。测试报告保存在仓库的 docs/test/ohmarkdown/2026-07-18-g3-02-command-palette/。后续提交增加命令时仍需复用这条链路,而不是只验证按钮能否点击。
自动化覆盖的是行为契约
Playwright 测试在浏览器环境中加载离线编辑器单页,通过 Bridge mock 记录命令调用。测试覆盖打开与关闭、输入筛选、方向键、Enter 执行、Escape 退出、禁用命令以及快捷键。工作区搜索加入后,自动化还验证 Ctrl+Shift+F 与 Ctrl+P 产生不同命令,避免快捷键回归到同一分支。
DOM 测试不能替代鸿蒙模拟器。它可以稳定检查命令列表和 Bridge 载荷,却无法证明 ArkUI 获得命令后能打开系统选择器,也无法覆盖真实窗口焦点。因此证据分成两层:Playwright 检查 Web 行为,模拟器检查宿主集成。当前统一验证在 2ca99e9 达到 Playwright 29/29,命令面板对应的基础提交 ad1e31a 已在主分支。
测试还应避免依赖列表的偶然顺序。稳定断言应使用 data-command-id、可访问角色和 Bridge 载荷,而不是通过"第三个按钮"定位。只有键盘导航测试需要检查顺序,此时顺序本身就是用户行为契约。
没有采用的方案
没有把命令面板完全做在 ArkUI 中,因为编辑器内部的撤销、重做和视图状态已经由 Web 编辑器掌握;原生面板若直接操作,会增加一套跨层状态同步。也没有全部做成 Web 文件操作,因为那会突破 URI 授权和 CoreFileKit 边界。当前方案让面板与编辑器交互保持低延迟,同时把受保护能力留在原生层。
没有引入第三方命令面板组件。现有需求只需要有限结果、键盘导航和可访问语义,引入完整组件库会增加离线包体、样式冲突与供应链面。也没有实现命令历史学习,因为缺少足够使用数据;未经验证的权重容易让命令位置漂移。
没有在第一版支持用户任意注册脚本命令。Markdown 编辑器处理本地文件,任意脚本会直接改变威胁模型。未来插件能力必须经过独立的权限、隔离、签名和审计设计,不能借命令面板入口偷渡。
性能预算与后续演进
当前筛选复杂度约为命令数量乘查询长度,命令数十余项时远低于一次帧预算。每次输入重建少量 DOM,避免维护复杂 diff。面板打开不触发文件扫描,不读取文档正文,也不启动网络请求。工作区快速打开虽然可从命令面板触发,但实际扫描由 SearchService 的 TaskPool 与取消机制负责,不把重任务塞进命令筛选。
真正需要关注的是功能增长后的治理。新增命令必须提供稳定 id、可检索关键词、启用条件、权限边界和测试;快捷键必须通过统一冲突表;高成本命令必须解释禁用原因;本地化后应保留语言无关的标识。等命令规模和用户数据足够时,再引入最近使用和模糊评分,并保留可预测的类别排序。
命令面板的优势最终不在"按一个快捷键能弹出来",而在它把功能增长变成可治理的模型。鸿蒙 PC 版本用一条受限而清楚的调用链连接 CodeMirror、ArkWeb Bridge 和 ArkUI 文件能力,同时保持离线、焦点和大文档降级规则。这个基础足以支撑后续链接、导出和窗口命令继续进入同一入口,却没有提前承诺尚不存在的插件平台。
验证清单与结论
对命令面板进行回归时,至少需要逐项确认:空查询显示全部命令;标题、类别和关键词都能筛选;上下键不会越界;Enter 不执行禁用项;Escape 只关闭当前最上层界面;执行后焦点回到合理目标;文件命令只产生白名单 Bridge 消息;大文档模式禁用高成本能力;Ctrl+Shift+P 不与 Ctrl+P 混淆;模拟器中的命令结果和原生界面状态一致。
当前实现满足上述基础条件,但仍有明确边界:没有用户自定义快捷键,没有命令历史同步,没有插件命令,也没有屏幕阅读器真机完整验收。这些限制不会被隐藏成"即将完成"的宣传。对现阶段产品而言,可靠、可解释、可测试的统一入口,比功能更多但状态分裂的菜单更有价值。