跨端: 鸿蒙 2in1 键盘派发重构 + 右键菜单(补上一轮的实测修正)

上一轮提交(e54dc39)的快捷键实测后发现**详情页的回车开错了东西**,
这一轮是修正 + 补齐。

① 详情页回车开出的是"写信"而不是"回复"(实测截图硬证)
   根因:详情页自己的 `onKeyEvent` **从不触发** —— 官方要求组件**获得焦点**
   才响应(common.d.ts:19510),而页面根 Stack 默认不可聚焦,
   加 `.focusable(true)` 也没人 requestFocus。于是键直接冒到主页根,
   被那条"Enter=写信"抢先。

   ⇒ 改成**根上按状态派发**(与 WebUI 同构:它也是一处全局监听 + 按状态分派):
     · 发布 `KEY_OPEN_MAIL_ID`(CommPage.openMail 写、NavDestinations 返回时清)
       ⇒ 判断"此刻是不是在看某封邮件"
     · 发布 `KEY_COMM_STACK_DEPTH`(navPathStack.size())
       ⇒ 判断 Esc 还有没有层可退(写信也占一层)
     · 根上据此决定:Enter = 回复 or 写信;Esc = 弹一层 or 交还系统
   新增 `ReplyIntent` / `PopIntent`,与既有 `ComposeIntent` **同一"两半"形状**
   (有人听就当场给、没人听就存着)——根组件够不着那两处的实例。

② 右键菜单(用户选「右键菜单」)
   · 邮件行挂 `bindContextMenu(…, ResponseType.RightClick)`;官方枚举只有
     RightClick / LongPress 两项 —— 长按是触屏语义,且左键单击已被
     "打开邮件"占用,只剩右键可用。
   · 菜单项**只放列表层能当场完成**的:标记已读 / 归档会话 / 复制主题。
     ★ **不放**回复/转发:那两个要详情页的表单,在列表行上做只能"先跳详情",
       那不是菜单项该有的语义(点了当场就该有结果)。
   · 归档走系统确认框(破坏性操作,与联系人页同一分寸)。

实测(2in1 模拟器 3120×2080,xdotool 注入真实键鼠)
  · 列表 Enter → 写信页            ✅ 截图
  · 写信页 Esc → 回列表            ✅ 截图
  · 详情页 Enter → 回复框("回复给 pi@…")✅ 截图
  · 邮件行右键 → 菜单(归档会话/复制主题)✅ 截图
  · 复制主题 → 无报错、菜单关闭

★ 一个重要的自我更正
  我先前说"2in1 模拟器上键盘注入不生效、属环境限制"——**那是错的**。
  xdotool 的键事件一直都能到 App(探针日志明确显示
  `Node Stack/68 handle KeyEvent` + handler 被调用)。误判的原因是我当时
  在**登录页**测 Ctrl+N(那页本来就没实现它,当然没反应)。
  教训:探针打进去之前,不要把"没反应"归因于环境。

判据(harmony-2in1 新增 7 条 → 12→19)
  · 三页用同一套键判定(不许各写一遍 KeyCode 比较)
  · 登录页回车提交(WebUI 靠 <form> 天然有,鸿蒙原来一行监听都没有)
  · 详情页 Enter/Esc + 弹层开着时 Esc 先关弹层
  · 右手菜单:挂了 bindContextMenu、类型是 RightClick、
    菜单项只用当场能完成的动作(不放回复/转发)、归档要先确认
  ★ 两条判据第一版是**我自己判红了自己**,都是判据比事实严格:
    ① 详情页确实有 KEYCODE_ENTER —— 那是**输入框内的候选导航**(onFwdKey),
       与页面级快捷键是两件事 ⇒ 改成只查页面级 onKeyEvent 那一段;
    ② isEscapeKey 先在 import 行出现,从那里切片取到的是注释 ⇒
       改成从页面级 onKeyEvent 内部起切。
This commit is contained in:
2026-09-25 16:30:45 +08:00
parent 31939f2b10
commit 1436fd1fb1
6 changed files with 518 additions and 5 deletions

View File

@ -80,3 +80,97 @@ export class ComposeIntent {
return true;
}
}
/**
* 「打开回复」的跨组件一次性意图 —— 与 `ComposeIntent` **完全同形**。
*
* ★★ 2026-09-24 新增。用户:「邮件详情页回车打开回复」。
*
* ── 为何需要它(同样是因为"够不着")──
* 回车是在**主页根**上收到的(详情页自己的 `onKeyEvent` 因组件不获焦而从不触发,
* 实测硬证 —— 详见 `MainPage` 根上那段注释),
* 而"开回复"那个动作(`openReplyWithMorph`)住在 `MailDetailView` 里。
* 根组件拿不到它的实例 ⇒ 与 `openCompose()` 当初撞到的是**同一个问题**,
* 所以用**同一个解法**:一个格子 + 一个回调,两半都留着。
*
* ── 为什么不直接在根上 setState 传下去 ──
* `MailDetailView` 是被 `NavDestination` 挂进去的、参数只传 `mail_id/account_id`
* 那几个 —— 为这个再加一个 `@Prop showReply` 要改三层(壳→页→回调),
* 而且"根改一个值"与"详情正在看哪一封"会分叉(换一封时该清的没清)。
* 意图格子天然是"一次性动作"语义,正好。
*
* ★ 与 `ComposeIntent` 分开两个类而不是加个 mode 参数:
* 两类意图的**接收方完全不同**(一个在 `CommPage`、一个在 `MailDetailView`),
* 合成一个会让"谁该消费它"变得含糊 —— 而含糊正是漏接收的来源。
*/
export class ReplyIntent {
static pending: boolean = false;
private static listener: (() => void) | undefined = undefined;
/** `MailDetailView` 挂载时登记 */
static setListener(fn: () => void): void {
ReplyIntent.listener = fn;
}
/** 卸载时摘掉(否则会唤醒一个已销毁的组件) */
static clearListener(): void {
ReplyIntent.listener = undefined;
}
/** 提"我要回复":有人听就当场给,没人听就存着等挂载后取走 */
static request(): void {
const fn: (() => void) | undefined = ReplyIntent.listener;
if (fn !== undefined) {
fn();
return;
}
ReplyIntent.pending = true;
}
/** 取走待处理的请求(取完即清,避免下次挂载时重放) */
static consume(): boolean {
if (!ReplyIntent.pending) {
return false;
}
ReplyIntent.pending = false;
return true;
}
}
/**
* 「弹掉通信页导航栈的最上一层」的意图(Esc 用)—— 与另两个同形。
*
* ★★ 2026-09-24 新增(用户:「esc返回上一级」)。
*
* Esc 在**主页根**上收到(详情/写信自己的 onKeyEvent 因组件不获焦而不触发),
* 而 `navPathStack` 只有 `CommPage` 持有 ⇒ 同样的"够不着"问题、同样的解法。
*/
export class PopIntent {
static pending: boolean = false;
private static listener: (() => void) | undefined = undefined;
static setListener(fn: () => void): void {
PopIntent.listener = fn;
}
static clearListener(): void {
PopIntent.listener = undefined;
}
static request(): void {
const fn: (() => void) | undefined = PopIntent.listener;
if (fn !== undefined) {
fn();
return;
}
PopIntent.pending = true;
}
static consume(): boolean {
if (!PopIntent.pending) {
return false;
}
PopIntent.pending = false;
return true;
}
}