跨端: 鸿蒙 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:
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user