鸿蒙 PC Markdown 编辑器原生右键弹层兼容

仓库地址:https://gitcode.com/VON-/codex_md_oh

代码基线:G3-09 PC 完整交互提交 b11519c 与后续设备收口修复。

右键菜单不是一套技术

OhMarkdown 的主窗口同时包含 ArkUI 原生文件树与标签栏,以及 ArkWeb 内的 CodeMirror 编辑器。用户看到的都是“右键菜单”,实现却至少有两条完全不同的链路:Web 编辑面监听 contextmenu,根据命中位置展示受限 HTML 菜单;原生区域接收 HarmonyOS MouseEvent,再由 ArkUI 生成弹层并调用工作台命令。

如果为了视觉统一强行把两条链路塞进同一个 Web 菜单,就需要把文件树、文档会话和系统文件操作暴露给 ArkWeb,破坏现有 Bridge 白名单。如果全部交给原生,又要把 CodeMirror 选择区、链接命中和编辑命令跨 Bridge 序列化,增加焦点与撤销风险。正确的统一层不是 UI 技术,而是命令语义:保存、另存为、关闭、查找、打开等动作最终仍走既有受限方法。

G3-09 初始实现使用 ArkUI bindContextMenu(ResponseType.RightClick) 绑定标签与文件条目。在静态代码、编译和常见 API 语义上都合理,但 MateBook Pro 2in1 模拟器的真实虚拟鼠标暴露了差异:onMouse 能收到右键 Press 和 Release,系统 context menu 子窗却没有可见显示。若只看构建或普通点击,这个问题不会出现。

先证明事件存在,再替换呈现层

设备诊断的第一步不是立即重写业务菜单,而是确认右键事件有没有到达组件。通过虚拟鼠标在标签栏和文件树触发操作,可以看到目标行确实收到 MouseButton.RightMouseAction.Release。这排除了窗口未激活、坐标错误、控件被遮挡和输入设备不支持等假设。

随后把问题范围收敛到 bindContextMenu 的呈现行为:构建器没有异常,命令内容可以编译,Web 内部右键也正常,只有原生子窗不可见。因此修复选择“保留命令、替换显示机制”,而不是重写保存和关闭逻辑。受控 bindPopup 由明确的 State 驱动,右键 Release 只设置目标和可见状态;弹层仍在 ArkUI 树中渲染,更容易在模拟器和自由窗口中观察。

这个决策符合增量开发边界。项目仍是单 entry、ArkUI+ArkWeb 混合架构,没有引入新的菜单服务、事件总线或跨模块状态框架。变化只发生在 G3-09 已确认的 PC 交互层,不触碰 DocumentService、恢复记录或保存事实来源。

受控弹层的状态模型

标签菜单需要知道当前目标会话和它在可见标签序列中的位置;工作区菜单需要知道条目 URI 与列表位置。实现增加明确状态,而不是依赖“当前激活标签”猜测右键目标:

@State private workspaceContextMenuVisible: boolean = false;
@State private workspaceContextMenuUri: string = '';
@State private workspaceContextMenuIndex: number = 0;
@State private documentTabContextMenuVisible: boolean = false;
@State private documentTabContextMenuSessionId: string = '';
@State private documentTabContextMenuIndex: number = 0;

可见状态控制 popup 生命周期,稳定身份用于记录目标,索引用于计算当前布局偏移。右键不会先执行打开或保存,只选择目标并显示操作集。弹层自动取消后清空可见状态与身份,避免下一次打开短暂显示旧目标。

受控状态还有一个测试优势:设备用例可以把“事件已到达”和“弹层已可见”分开观察。若事件收到但状态未变化,问题在手势条件;状态变化但弹层不可见,问题在布局或 Popup;菜单出现但命令错误,问题在目标或业务路由。相比系统黑盒子窗,诊断层次更清楚。

标签右键的真实实现

标签行在右键释放时记录 session 和 index:

.onMouse((event: MouseEvent) => {
  if (event.button === MouseButton.Right && event.action === MouseAction.Release) {
    this.documentTabContextMenuSessionId = session.id;
    this.documentTabContextMenuIndex = index;
    this.documentTabContextMenuVisible = true;
  }
})

把动作放在 Release 而不是 Press,可以避免按下右键时立即抢占后续系统手势,也符合桌面菜单通常在释放后确认的节奏。左键 onClick 仍负责激活标签,右键不会先把未激活标签强制切成当前编辑文档。菜单命令通过显式 session id 执行,保存和关闭的目标与用户指向一致。

标签栏父 Row 绑定 Popup,而不是每个滚动标签都创建独立子窗。偏移以固定 156 vp 标签宽度计算,弹层放在标签栏下方;菜单宽 220 vp,四个动作使用 32 vp 稳定行高。固定格式避免文本、脏标记和关闭按钮变化时导致弹层跳动。

未保存保护不能因右键而降级

标签菜单提供“保存”“另存为”“关闭”“关闭其他已保存标签”。其中最后一项故意不叫“关闭其他标签”,因为批量关闭脏文档会连续弹出确认或造成误操作。真实命令继续调用 closeOtherSavedDocumentSessions(session.id),只移除其他已保存会话,保留目标标签和任何未保存标签。

设备验收创建第二个名为 unsaved-g3 的脏标签,在另一个标签上右键执行“关闭其他已保存标签”。操作后未保存标签仍存在,证明弹层没有绕过原状态机。单独“关闭”继续调用 requestCloseDocumentSession,脏标签仍进入保存、不保存、取消对话框;操作进行中时按钮禁用。

菜单代码只是业务入口,数据安全仍由会话层掌握。这是桌面应用一个重要设计准则:增加更快的操作方式,不能复制一份弱化后的关闭逻辑。工具栏、快捷键、命令面板、标签关闭按钮和右键最终必须汇聚到同一状态机。

文件树菜单的目标语义

文件条目菜单提供“打开文档”和“在工作区中查找”,目录条目则提供“展开/折叠”和“在工作区中查找”。构建器根据 entry.isDirectoryentry.expanded 生成第一项,不给目录显示无意义的“打开文档”,也不让文件显示“展开”。

真实按钮代码继续复用现有方法:

Button(entry.isDirectory ?
  (entry.expanded ? $r('app.string.collapse_directory') : $r('app.string.expand_directory')) :
  $r('app.string.open_document'))
  .onClick(() => {
    this.workspaceContextMenuVisible = false;
    if (entry.isDirectory) {
      this.toggleWorkspaceDirectory(entry);
    } else {
      this.requestOpenWorkspaceDocument(entry);
    }
  })

“在工作区中查找”不是直接扫描文件系统,而是把目标名称填入快速打开查询,打开搜索面板并调用现有搜索流程。工作区授权、过滤规则、TaskPool 取消和结果上限仍由 G3-05 服务处理。菜单没有新增路径读取能力,也不会越过已授权根目录。

应用内部设备证据

下图是最终 Debug HAP 在 MateBook Pro 2in1 模拟器中显示的标签右键弹层,包含保存、另存为、关闭和关闭其他已保存标签。它不是浏览器预览,也不是设计稿。

在这里插入图片描述

同一设备轮次还保留文件、目录、未保存标签保护和编辑器 Web 菜单截图,存放在仓库 docs/test/ohmarkdown/2026-07-20-g3-09-pc-interaction/evidence/。文章只选一张最能解释原生 Popup 的图,测试报告负责完整证据矩阵。

设备图证明弹层可见、文案本地化和主窗口布局没有被遮挡。命令是否正确由后续操作结果证明:文件菜单能打开或查找;目录菜单能展开或折叠;关闭其他已保存标签保留 unsaved-g3。静态截图和行为验证组合起来,才构成可审计结论。

Popup 布局与自由窗口

桌面菜单必须在窗口边缘、滚动标签和可调侧栏中保持可达。标签栏 Popup 使用 BottomLeft,避免覆盖标签文本;文件树 Popup 使用 TopLeft 并按当前条目位置偏移。两者关闭箭头,减少在紧凑工具面中的装饰占位,背景复用工作台 surface 色。

G3-09 同轮验证了 640、720、760、900 和 1280 vp。最窄窗口由系统最小外宽约束覆盖 640 vp 内容预算;720 vp 精简状态栏;760 vp 显示同步滚动;900 vp 从覆盖侧栏切回停靠;1280 vp 同时放下五个标签、设置面板、分栏和完整状态。右键弹层在这些布局规则之上,不能通过增加固定空白扩大窗口最小宽度。

固定索引偏移适合当前每个标签 156 vp、文件行 30 vp 的稳定格式。未来若引入可变标签宽度、分组或大字体,应改用实际点击坐标或组件几何,而不是继续乘固定值。这个限制已经作为后续兼容风险记录,当前不提前引入复杂测量框架。

Web 右键与原生右键怎样保持一致

编辑区右键菜单由 Web 层命中选择区和文档状态,提供 Cut、Copy、Paste、Select All 等标准动作;原生标签和文件树菜单围绕文档会话与工作区。两类菜单不追求逐项相同,却遵守共同规则:只展示当前上下文有意义的动作;禁用不能执行的动作;执行后恢复正确焦点;不接受任意命令字符串。

Web 命令注册表把允许动作映射到固定函数,ArkUI 菜单直接调用受类型约束的方法。它们都不使用 eval,也不把菜单文本反向解析成命令。中文或英文切换只改变资源文案,不改变命令 ID。这样本地化不会影响业务路由,自动化也能按稳定 ID 验证。

视觉上两类菜单可以有平台差异。Web 菜单跟随编辑器颜色和行高,ArkUI Popup 跟随原生主题资源。用户更关心位置、可读性、键鼠语义和结果一致,而不是两个运行时逐像素相同。强行统一成一套 HTML 反而会牺牲系统字体与原生可访问性。

焦点和关闭时机

Popup 打开时,右键目标可能不是当前活动会话。菜单执行保存或关闭后,runDocumentSessionCommand 负责必要的会话激活、编辑内容同步和命令执行。不能只把 activeDocumentSessionId 改掉就立即保存,因为 ArkWeb 当前正文需要先与旧会话完成同步。

按钮点击的第一步关闭 popup,随后调用业务方法。这样保存选择器或未保存确认对话框出现时,旧菜单不会继续遮挡,也不会截获键盘。自动取消、点击外部和命令执行都把 Visible 设回 false;下次右键重新记录目标。

命令完成后的焦点规则与 G3-09 快捷键修复一致。打开文件或切换标签最终把焦点归还编辑器;打开工作区查找则把焦点交给查询输入;系统选择器返回后恢复工作台。菜单本身不是焦点终点。

失败模式与防御

受控 Popup 仍有需要监控的失败模式。列表在弹层打开期间刷新,索引可能改变;标签滚动后固定偏移可能离开实际目标;系统字体放大可能让 32 vp 行高不够;右键 Press 后窗口失焦可能没有 Release;连续右键不同目标要替换而不是叠加弹层。

当前业务操作和模态行为让列表在菜单展示期间保持稳定,设备验证覆盖常规文件、目录与多标签。G3-10 应补充滚动到第十二标签右键、搜索刷新时菜单、工作区树长列表、150% 字体、窗口边缘和触控板 secondary click。发现实际失败后再把索引定位升级为坐标或稳定身份查找,避免没有证据时增加复杂度。

一个重要防御是所有菜单动作继续执行目标合法性检查。文件打开经过授权范围与文件类型;关闭经过会话存在性与未保存状态;保存经过 URI、格式和原子写入。Popup 目标状态不能成为绕过服务边界的凭证。

自动化与设备验证分工

Playwright 43/43 覆盖 Web 编辑器右键、命令注册、快捷键冲突和普通编辑回归。ArkTS 构建证明 Popup Builder、MouseEvent 枚举、资源和业务方法类型正确。模拟器则验证原生虚拟鼠标事件、Popup 可见性、位置、中文文案和命令结果。三层缺一不可。

ohosTest 9/9 主要覆盖文件、恢复、资源、链接和导出服务,它不能点击 UI,但能确保右键调用的底层服务没有在本轮改坏。最终 Debug HAP 为 8,542,985 字节,SHA-256 8c344874f74f178aade4ff6405e1fe5a3f7a5f48ec41e2b1bb1a0d1ee0641990;测试 HAP 为 9,311,431 字节,SHA-256 e8adf08a939b5be09dfd96a5856deced6c328e1c94adee4150b1ae6df8f82f0f

测试报告把原 bindContextMenu 不可见现象和最终 bindPopup 通过结果同时保留。失败过程不是需要删除的噪声,它解释了为什么代码采用受控状态,也能防止将来“简化”为原实现时复发。

为什么这项改动能改善产品优势

Markdown PC 用户会频繁在文件树、标签和正文之间切换。没有右键时,保存、关闭其他标签、查找同名内容等动作只能移动到顶部或记快捷键;右键可把动作成本缩短到目标附近。但效率优势必须建立在安全上:批量关闭只处理已保存标签,文件操作不越权,未保存标签仍受保护。

与竞品比较时应使用任务而不是功能清单。例如“在十二个标签中保留两个未保存标签并关闭其余”“从深层目录文件右键进入工作区查找”“在未激活标签上另存为”。记录鼠标移动距离、点击数、完成率和误操作恢复。模拟器证明任务可执行,真机与竞品统一计时才能证明领先 20%。

本轮竞争优势记分保持 3 分:完整实现、自动化和鸿蒙 2in1 模拟器设备证据已具备,但物理触控板、系统字体放大、1440 vp 和竞品中位数还未完成。保守计分比一句“右键体验领先”更可信。

工程边界与后续演进

本次修复没有引入通用菜单框架,因为当前只有文件树和标签两处原生上下文。两个 Builder 共享的是设计规则,不足以证明需要配置驱动抽象。过早创建“UniversalContextMenuService”会把不同目标类型、启用条件和业务命令塞进动态结构,降低 ArkTS 类型约束。

如果 G4 出现大纲、历史版本和多窗口三个以上同类菜单,再评估抽取视觉组件与目标定位协议。即使抽取,业务方法仍应由各领域拥有,菜单层只组合 Action 描述。多窗口还需要把目标身份扩展为 window+session,不能沿用单窗口索引。

当前已知的固定宽度与索引偏移应在无障碍专项中复测。需要升级时优先使用实际事件坐标、组件区域和屏幕边界夹取,而不是扩大弹层或窗口最小尺寸。目标是自由窗口仍可完成任务,不为菜单牺牲编辑面积。

结论

鸿蒙 PC Markdown 编辑器的原生右键兼容问题说明,API 能编译与设备上可见是两种事实。OhMarkdown 先在模拟器确认右键 MouseEvent 已到达,再把不可见的 bindContextMenu 呈现替换为受控 ArkUI bindPopup,同时完整复用保存、关闭保护、工作区搜索和文件授权逻辑。

最终标签、文件和目录菜单都在真实 HAP 显示,“关闭其他已保存标签”保留未保存会话,Web 编辑菜单与原生菜单各守自己的运行时边界。这个小阶段没有扩张架构,却把高频桌面动作从代码存在推进到鸿蒙 PC 模拟器可操作,并留下了截图、行为结果、测试报告和产物哈希四类证据。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐