鸿蒙 PC Markdown 编辑器右键命令系统:统一路由与焦点闭环
鸿蒙 PC Markdown 编辑器右键命令系统:统一路由与焦点闭环
桌面编辑器里的右键菜单很容易被低估。它看起来只是鼠标右键后出现的一列操作,实际上同时触碰编辑事务、系统剪贴板、命令路由、焦点管理、多文档会话、国际化和无障碍。若每个区域各写一组回调,几个月后就会出现典型分裂:工具栏保存的是当前标签,右键保存的却是旧标签;命令面板会检查大文档模式,右键预览却绕过检查;关闭其他标签时遗漏脏标记;菜单消失后键盘焦点留在不可见节点。右键菜单数量不多,但非常适合暴露桌面软件是否真的拥有统一交互内核。
本文讨论 OhMarkdown 在 HarmonyOS PC / 2in1 上实现右键系统时采用的工程方法。代码来自公开仓库 https://gitcode.com/VON-/codex_md_oh,对应提交为 b11519c。当前 Web 右键菜单已经通过 Playwright,ArkUI 标签与文件树菜单已经通过 API 24 编译;由于本轮 Mac 桌面锁定、hdc 没有在线目标,原生菜单和系统剪贴板仍保留设备验收项。技术结论会把“实现与自动化完成”和“设备已经通过”严格分开。
右键菜单首先是命令入口
一个稳定的桌面编辑器不应该把菜单文案直接绑定到业务实现。菜单项只是入口,真正的行为应该由命令定义决定。以“查找与替换”为例,它可能从 Ctrl+F、命令面板、编辑区右键、菜单栏或触控板动作进入;这些入口最终都应打开同一个 ArkUI 搜索面板,复用相同的查找选项和焦点策略。
OhMarkdown 已有 EditorCommand 注册表,包含命令 ID、标题、类别、关键字、可用条件和执行函数。右键菜单没有重新写 find、undo、insertLink 的业务逻辑,而是只保存 commandId。执行时先在注册表中解析,再进入原来的 executeCommand:
function findEditorCommand(commandId: string): EditorCommand | undefined {
return editorCommands.find((command) => command.id === commandId);
}
function executeEditorCommand(commandId: string): void {
const command = findEditorCommand(commandId);
if (command) {
executeCommand(command);
}
}
这段代码的价值不在于少写几行,而在于可用条件只有一份。例如大文档保护模式会禁用链接助手和高成本预览;右键菜单查询同一个命令的 enabled 语义,就不会出现命令面板禁用而右键仍然执行的漏洞。多语言切换时注册表标题更新,菜单下次打开自然获得新文案,不需要另外维护静态数组。
哪些操作属于编辑器事务
撤销和重做直接调用 CodeMirror 的历史命令,查找与插入链接走命令注册表;剪切、复制、粘贴和全选则与当前选区强相关。它们不能只向 ArkTS 发送“cut”字符串,因为选区和撤销历史的事实来源在 Web 编辑器状态中。
全选通过一次 CodeMirror selection transaction 完成,删除、替换和后续撤销都会进入编辑器正常历史:
function selectAllEditorContent(): void {
editor.dispatch({
selection: { anchor: 0, head: editor.state.doc.length }
});
editor.focus();
}
剪切先读取当前主选区,只有剪贴板写入确认成功才删除正文。这个顺序避免系统剪贴板失败时内容已经从编辑器消失。删除同样使用 CodeMirror transaction,而不是修改 contentDOM.textContent。后者会绕过语法树、选择、历史、脏标记和恢复快照,是典型的“界面看起来动了,数据模型已经坏了”。
粘贴优先使用 navigator.clipboard.readText()。ArkWeb 不允许该能力时,再尝试用户点击上下文中的 document.execCommand('paste');两者都失败会给出可见状态,不修改正文。最终 HarmonyOS PC 设备验收仍要覆盖系统剪贴板权限和中文内容,因为 Chromium 自动化只能证明菜单事务,不足以证明原生桌面的授权策略。
选区与右键位置不是同一个概念
用户在一段文字上完成选择后,通常会移动鼠标到选区内部再右键。菜单位置来自指针坐标,操作目标来自 CodeMirror selection。实现不能用 clientX/clientY 重新计算一个光标并覆盖选区,否则“复制”可能只复制空字符串,“剪切”可能删除右键位置附近的字符。
OhMarkdown 的 contextmenu 监听只做三件事:阻止浏览器默认菜单、记录来源区域、把客户端坐标交给菜单布局。它不会改变编辑器选区:
editorHost.addEventListener('contextmenu', (event) => {
event.preventDefault();
openEditorContextMenu('editor', event.clientX, event.clientY);
});
preview.addEventListener('contextmenu', (event) => {
event.preventDefault();
openEditorContextMenu('preview', event.clientX, event.clientY);
});
源码区和预览区共享容器实现,却使用不同操作集合。预览是只读阅读表面,不显示剪切、粘贴和全选源码,而提供选区复制、打印或保存 PDF、命令面板。通过显式 source 参数区分,比根据 DOM 层级临时猜测更容易测试,也防止未来分栏布局调整后菜单语义漂移。
菜单必须被限制在窗口内部
浏览器会自动处理原生菜单的屏幕边缘,而自绘菜单必须自行约束。若直接把 left 和 top 设置为指针坐标,用户在窗口右下角右键时会看到半个菜单,最下面的命令无法点击。自由窗口越窄,这个问题越常见。
实现先渲染菜单并设置初始位置,再读取 getBoundingClientRect(),最后按窗口宽高和 8 像素安全边距夹紧。菜单宽度稳定为 210 像素,最大高度为 100vh - 16px,内容超出时内部滚动。布局不会因禁用态或语言切换改变外层尺寸,从而减少指针下方突然跳动。
这个几何过程必须在菜单从 hidden 变成可见之后执行,否则测得宽高为零。也不能用预估的“九项乘以三十二像素”,因为分隔线、系统字体和中文无障碍字号会改变真实高度。读取最终几何是更可靠的桌面窗口策略。
焦点闭环决定键盘用户能否继续工作
右键菜单打开后,第一个可用按钮获得焦点。ArrowDown 和 ArrowUp 在可用项之间循环,Home 和 End 到达首尾,Escape 关闭菜单。禁用项不会进入焦点数组,因此没有选区时“剪切”和“复制”虽然可见但不会成为键盘陷阱。
关闭有两类语义:点击某个编辑命令后,该命令自行决定焦点;按 Escape 取消时,菜单显式请求 CodeMirror 焦点。预览模式下不强制把焦点拉回隐藏的源码编辑器。这个细节能避免阅读模式右键后键盘输入意外切换到源码。
页面还在 capture 阶段监听外部 pointerdown。点击菜单外侧会关闭,点击菜单内部不会在按钮 click 之前销毁节点。使用 capture 是为了在 CodeMirror 或预览自己的指针处理之前结束浮层状态,但必须通过 contains(event.target) 保留内部点击。
Escape 的优先级是一种状态协议
OhMarkdown 同时可能出现冲突比较、链接助手、命令面板、快捷键设置和右键菜单。所有浮层都监听 Escape 会导致一次按键关闭多个层,甚至把焦点送到错误区域。全局键盘路由按可见状态逐级处理,每个分支处理后立即 return。
快捷键录制拥有最高优先级,因为 Escape 在录制中表示取消当前录制,而不是关闭整个设置。随后才是快捷键设置、右键菜单、三方差异、链接助手和命令面板。这个顺序不是视觉 z-index 的偶然结果,而是用户任务的退出层级。后续新增浮层必须明确插入该协议,不能再注册一个互不知情的 window.keydown。
ArkUI 标签菜单为什么不能复制 Web 实现
标签位于原生 ArkUI,不在 ArkWeb DOM 中。若把标签也绘制到 Web,系统拖放、文件选择、窗口响应和多文档会话都要穿过更宽的 Bridge,破坏混合架构边界。正确做法是使用 ArkUI 的 bindContextMenu,但菜单动作仍调用工作台已有会话函数。
标签菜单包括保存、另存为、关闭和“关闭其他已保存标签”。最后一个动作故意没有叫“关闭其他标签”,因为未保存标签需要逐个询问,批量弹多个对话框既容易误操作也很难恢复。当前实现保留目标标签和所有脏会话,只清理其他已保存会话:
private async closeOtherSavedDocumentSessions(sessionId: string): Promise<void> {
if (sessionId !== this.activeDocumentSessionId) {
await this.activateDocumentSession(sessionId);
}
const retainedSessions = this.documentSessions.filter((session: DocumentSession): boolean =>
session.id === sessionId || session.dirty);
this.documentSessions.forEach((session: DocumentSession) => {
if (session.id !== sessionId && !session.dirty) {
this.externalConflicts.delete(session.id);
this.closeEditorSession(session.id);
}
});
this.documentSessions = retainedSessions;
}
先激活右键目标标签是为了让“保存”和“另存为”处理正确正文。激活过程会捕获原活动会话的最新 Web 缓冲区,再切换目标会话,不会只依赖 ArkUI 中可能滞后的内容副本。批量关闭同时清理外部冲突映射和 Web 会话缓存,避免标签消失后仍保留不可达状态。
文件树菜单服务于定位而不是炫技
文件树的 P0 任务是打开文档、展开目录和在工作区中定位。右键菜单因此只提供与当前能力匹配的操作:文档打开、目录展开或折叠,以及把条目名称送入快速打开。它没有提前加入永久删除、重命名和移动,因为这些能力涉及回收站、引用更新和失败恢复,属于独立文件操作设计,不能用一个 fileIo.rename 回调草率补齐。
“在工作区中查找”切换到快速打开模式,把条目名称作为查询,然后复用 TaskPool 搜索控制器。文件树没有自己扫描目录,右键也不会越过已授权工作区。这维持了搜索取消、结果上限、模糊排序和错误状态的一致性。
目录菜单根据 expanded 动态显示“展开文件夹”或“折叠文件夹”。执行仍进入 toggleWorkspaceDirectory,沿用惰性加载和状态保持。菜单只是为鼠标用户增加一个符合 PC 习惯的入口,不改变文件树模型。
国际化不应留下英文孤岛
Web 右键文案属于 EditorMessages,ArkUI 标签和文件树文案属于资源文件。两层分别跟随应用语言,但命令标题仍由统一命令翻译表提供。简体中文下显示“撤销、重做、查找与替换、插入链接”,英文下恢复桌面软件常见术语。
动态菜单在每次打开时读取当前 messages,而不是启动时生成永久节点。这样用户切换语言后不需要刷新页面或重启应用。ArkUI 的 $r('app.string.*') 在系统语言更新后由组件重建,右键菜单同样获得新资源。
中文长度也是布局测试的一部分。“关闭其他已保存标签”明显长于英文简写,ArkUI 系统菜单负责测量;Web 菜单固定宽度并允许完整文案单行显示。若未来增加更长语言,应该通过实际资源测量决定宽度,而不是缩小字体。
应用内部证据
下图来自当前生产 Web 单页的真实运行画面,已经切换简体中文并加载 Markdown 正文。菜单展示编辑事务分组、禁用态、查找、插入链接和命令面板;它不是设计稿,也不是静态 HTML 模拟。

截图证明 ArkWeb 内部菜单的布局、中文和状态,但不替代 HarmonyOS 模拟器上的 ArkUI 标签菜单、文件树菜单和系统剪贴板。对应设备用例已经写入 docs/test/ohmarkdown/2026-07-20-g3-09-pc-interaction/test-cases.md,待桌面解锁后补图和结果。
自动化如何验证菜单不是摆设
Playwright 用例先写入“鸿蒙 PC 右键菜单”,右键打开源码区,确认全选项可见;随后点击查找并检查 Bridge 最后一条消息必须是固定 find 命令。再次打开菜单执行全选和 Backspace,正文应为空;再从菜单执行撤销,原正文必须完整恢复。这条链同时证明菜单命令、CodeMirror transaction 和历史状态连接正确。
最后重新打开菜单并按 Escape,断言菜单隐藏。键盘焦点和 Arrow/Home/End 由事件逻辑覆盖,设备验收进一步检查视觉焦点是否清楚。完整 Web 回归已经增长到 43 项,说明新菜单没有破坏恢复、图片、搜索、链接、专业渲染和导出。
ArkTS 编译负责发现 bindContextMenu、MenuItem 和 Builder 参数是否符合 API 24。早期实现使用 Function.bind,编译给出 arkts-no-func-bind 警告;最终改为 Builder lambda,消除了不受支持的动态函数绑定。这个小修正说明“能打包”与“符合 ArkTS 静态语义”应同时关注。
安全边界仍然比菜单数量重要
右键菜单不能成为任意命令执行入口。Web 菜单只引用已注册 ID,Bridge 仍传递固定字符串;ArkTS 的 onEditorCommand 使用显式分支,不执行字符串拼接出的系统调用。文件树菜单只处理已有 WorkspaceEntry,不接受用户输入路径;标签菜单只处理当前会话数组中的 ID。
复制和剪切仅发送选中文本给剪贴板,不读取工作区其他文件。预览链接依旧由原生 LinkService 重解析,菜单不会开放外部 URL。关闭其他标签只清理已保存会话,不触碰磁盘文件。所有这些限制让右键成为既有安全模型的入口,而不是逃逸口。
失败恢复要让用户继续写
剪贴板不可用时,菜单关闭后编辑器仍保留正文和选区,状态提示说明当前无法访问系统剪贴板。保存或另存为失败继续沿用 DocumentService 的错误处理和脏标记;关闭未保存标签继续出现保存、丢弃、取消三态对话框。快速查找失败只影响搜索结果,不折叠文件树。
菜单节点每次关闭都会 replaceChildren(),不会随着反复右键积累事件监听和不可见按钮。专业桌面应用可能一天触发几百次右键,这类生命周期细节比一次演示成功更重要。
性能预算与长文档
菜单打开不重新渲染 Markdown,不扫描工作区,也不读取文件。它只读取当前 selection、命令 enabled 状态和少量翻译,创建不到十个按钮。几何测量发生一次,关闭即销毁。大文档模式下禁用链接助手,但撤销、复制、全选和查找仍遵循各自现有降级策略。
ArkUI 标签菜单同样不读取正文;只有用户执行保存时才激活目标会话并捕获当前 Web 缓冲区。文件树菜单的快速查找进入已有防抖和可取消搜索。把昂贵工作放在命令执行之后,而不是菜单构建期间,是鼠标交互保持即时的关键。
竞争优势应该如何衡量
拥有右键菜单不等于形成优势,许多桌面编辑器早已具备。OhMarkdown 的目标是让右键、命令面板、快捷键、工具栏和恢复逻辑共享一份语义,尤其不丢失未保存会话、不绕过大文档和授权边界。真正的对比任务应记录:从文件树找到文档并打开、在标签上保存或安全关闭、在源码选区复制粘贴、从右键进入查找所需的操作数和失败率。
当前记分卡只能保持 2 分:实现、自动化、编译和 Web 视觉证据完整,但 HarmonyOS PC 原生右键、系统剪贴板、触控板点击和焦点环仍待设备执行。达到 3 分需要模拟器或真机完成全部键鼠路径;达到 4 分还需要与 Typora、Obsidian、VS Code 等在统一设备与语料上计时,并证明任务效率或失败恢复有可量化优势。
结语
右键菜单真正考验的不是菜单组件,而是编辑器是否拥有统一命令、清晰事实来源和可预测焦点。OhMarkdown 把 Web 编辑事务留在 CodeMirror,把文件和标签状态留在 ArkUI,把跨层动作限制在命令白名单内,再用各平台原生菜单能力承载入口。这样增加右键不会复制业务逻辑,也不会削弱文件安全。
提交 b11519c 已经完成工程实现并推送公开仓库;完整回归发现快捷命令打开链接助手后被通用焦点归还覆盖,修复提交 7410227 将链接、校验和快捷键设置列为自行管理焦点的命令,最终 43 项全部通过。下一步设备验收要关注三件事:系统剪贴板是否在权限与中文文本下可靠,ArkUI 菜单是否在自由窗口边缘完整显示,菜单关闭后键盘是否始终回到用户预期的位置。只有这些真实桌面行为通过,G3-09 才能从“功能完成”进入“阶段完成”。
设备记录还应保留窗口尺寸、当前语言、输入法、右键来源区域、执行命令和焦点落点。这样后续发现问题时能区分是系统菜单、ArkWeb 剪贴板、命令路由还是窗口几何,而不是只留一句“右键不好用”。
模拟器收口:右键事件到了,系统菜单却没有出现
MateBook Pro 2in1 模拟器恢复后,底层 uinput -M -c 1 能稳定把右键送进 ArkWeb,编辑器菜单立即显示;相同事件落到 ArkUI 标签时,onMouse 收到了 MouseAction.Release,但 bindContextMenu(ResponseType.RightClick) 没有创建可见子窗。这个差异不能靠“Web 能右键”推断原生标签也正确。
最终实现把右键识别和弹层显示拆开:行组件只记录目标,标签栏或文件列表的稳定父容器使用状态驱动 bindPopup。弹层里的按钮仍只调用原有会话和工作区命令:
.onMouse((event: MouseEvent) => {
if (event.button === MouseButton.Right && event.action === MouseAction.Release) {
this.documentTabContextMenuIndex = index;
this.documentTabContextMenuVisible = true;
}
})
这个修复不是把菜单业务重写成另一套。保存 继续调用 runDocumentSessionCommand,关闭其他已保存标签 继续只清理非脏会话,文件和目录继续调用 requestOpenWorkspaceDocument、toggleWorkspaceDirectory 与 findWorkspaceEntry。模拟器实测在第二标签写入 unsaved-g3 后,从第一标签执行批量关闭,两个标签仍然存在,脏正文没有被顺手丢弃。

编辑器中文剪贴板首次复验还暴露出另一个真实问题:清单缺少 ohos.permission.READ_PASTEBOARD,剪切能写入系统剪贴板,粘贴却无法读回。补齐权限和中英文用途说明后,鸿蒙PC剪贴板验证 完成剪切、粘贴并恢复为 8 字。因此右键小阶段的结论更新为模拟器 3 分:Web 与 ArkUI 菜单、中文剪贴板、未保存保护和完整回归都有设备证据;真机触控板、系统字体放大和竞品计时仍未完成,不提升到 4 分。
更多推荐



所有评论(0)