Files
MailUI4Agents/client/harmony/entry/src/main/ets/model/KeyboardShortcuts.ts
JianFeeeee e54dc39f8f 跨端: 鸿蒙 2in1 键盘可达 + 悬停 + 沉浸顶栏 + 三键避让 + 修叠栈
用户四条:
①「2in1 手势」(选了 悬停/右键菜单/触控板 + 快捷键:主页回车写信、
   详情页回车回复、Esc 返回)
②「宽屏状态一个邮件被反复点击会被多次填充到右侧」
③「你在登陆页是不是没有做 enter 等键的监听」——**确实漏了**
④「app 顶栏为什么不沉浸」+「右侧三键应当有独立避让」

② 叠栈(实测复现 → 修 → 实测通过)
   根因是框架语义用错:pushPath 默认 LaunchMode.STANDARD 每次入栈 ⇒
   重建详情组件 + 重拉数据 + 重放入场动画;返回还要按多次。
   而 WebUI 是 `set({currentMail})` 幂等赋值(mailStore.ts:115)。
   改用 LaunchMode.MOVE_TO_TOP_SINGLETON(官方:同名已在栈里就移上去、
   不新建),MainPage + ContactsTab 两处 push 点都改(只改一边=换栏点
   又不正常)。
   实测:连点同一封 3 次 → **点一次返回就回占位**(修复前要按 3 次)。

③ 登录页回车(用户点出来的真实缺失)
   WebUI 是 `<form onSubmit={submit}>`(LoginPage.tsx:92)——浏览器里
   输入框按回车就提交;鸿蒙登录页**一行键盘监听都没有**。
   补上,走**已有的** doLogin()(不另写一条登录路,免得与按钮的条件分叉)。

① 快捷键:新增 model/KeyboardShortcuts.ts(规则集中一处,三页共用)
   · 主页根 Stack:Enter → 写信(与 Ctrl+N 同一个 ComposeIntent.request)
   · 详情页:Enter → 开回复(复用 openReplyWithMorph,连动画都不另开);
     Esc → 返回,且**弹层开着时先关弹层**再按才返回(否则用户想关回复框
     却被踢回列表,输入到一半的内容全没)
   · 用键事件**冒泡**:子组件先拿到、未消费才到页面根 ⇒ "详情优先、
     主页兜底"由框架保证,不是我自己排的优先级
   ★ 为何不用 keyboardShortcut:它只收组合键;不带修饰键时只认 FunctionKey,
     而 FunctionKey 枚举(enums.d.ts:3444)**没有 Enter**(只有 ESC/F1-F12/
     TAB/方向键)⇒ 单按回车表达不出来。

① 悬停反馈:MailRow/SentRow 挂 onHover + Theme.surfaceMuted
   (该令牌此前**零使用**,注释本就写着"列表行 hover",正好归位)。
   不用 .hoverEffect():系统叠层会与选中/未读的 accentSoft 叠成第三种颜色。
   ★ 状态存 mail_id 而不是布尔:行本体是 @Builder(无自身状态),
     布尔会变成"悬停一行、同栏全亮",所以状态放栏上、存"是哪一封"。

④ 沉浸顶栏:EntryAbility 加 setWindowDecorVisible(false)
   实测(2in1 截图硬证):标题栏(AgentMailHarmony)下面**还有一条白条**,
   内容从第二条下面才开始。根因是**从未调过装饰接口**⇒用系统默认(PC 带标题栏)。
   setWindowLayoutFullScreen(true) 管的是"内容铺到**屏幕**四边",
   **不包含**"窗口自己的标题栏是否隐藏"——两件不同的事。

④ 三键避让:Insets 加 windowDecor + getWindowDecorHeight()
   隐掉标题栏白条后,系统仍在右上角**浮着**三键(官方:全屏悬浮态固定 37vp)。
   而 2in1 **没有状态栏** ⇒ TYPE_SYSTEM.topRect 是 0 ⇒ 只看 statusBar 就
   认定"顶部无需避让",内容(右上是「授权」页签)被三键压住。
   AvoidAreaType 六种里**没有**"标题栏/三键"这一类,只能单独读
   getWindowDecorHeight()(它直接返回 vp)。
   避让取**较大者**不加:两者互斥(有状态栏的形态没装饰,反之亦然)。
   实测日志:`statusBar=0 navIndicator=0 windowDecor=37`,页签条下移。

判据(13 条新增/改,全部变异验证过)
- 新增 5 条「2in1 快捷键」:单一出处(三页都不得自己比 KEYCODE_ENTER)、
  登录页回车、详情页 Enter/Esc + 先关弹层、窗口装饰必须隐掉。
  ★ 两条第一版是**我自己判红了自己**,都是判据比事实严格:
    ① 详情页确实有 KEYCODE_ENTER —— 那是**输入框内的候选导航**(onFwdKey),
       与页面级快捷键是两件事 ⇒ 改成只查页面级 onKeyEvent 那一段;
    ② isEscapeKey 先在 import 行出现,从那里切片取到的是注释 ⇒ 改成
       从页面级 onKeyEvent 内部起切。
  ★ 窗口装饰那条第一版写 `/setWindowDecorVisible\(false\)/` —— 紧邻两行**日志**
    也含这个串,删掉真正的调用后判据**照样绿**(变异实测没红)。
    改成匹配调用形态 `win.setWindowDecorVisible(false)` 后才真会红。
    这是"判据匹配到的是关于这件事的文字、不是这件事"的形状。
- harmony-widescreen ⑥ 原来钉精确串
  `pushPath({ name: MAIL_DETAIL_ROUTE, param: params })`,加了 launchMode
  参数后判红 —— 那是**判据写死了写法**。改成按结构匹配(不变式:选中邮件
  要经 navPathStack.pushPath 进 MAIL_DETAIL_ROUTE,带不带 options 是实现细节)。
- harmony-2in1 登记数 12 → 16。

环境(这次为了真验 2in1 专门搭的)
- 下载 2in1 镜像 HarmonyOS 6.1.0(23)(与 target 一致),建实例 HA2in1
  (3120×2080,14.2" 笔记本),设备 127.0.0.1:5557,形态确认为 `2in1`。
- 带窗口启动要 Qt xcb:补了 5 个 xcb 库 + Xvfb :99(`-noWindow` 下 2in1 起不了 App)。
- ★ 多设备并存时设备判据会自己挑目标 ⇒ 必须 `AGENTMAIL_HARMONY_TARGET=127.0.0.1:5555`
  才跑手机那台;不指定时判据连到未登录的 2in1 上会假红。
  这正是 harmony-device.mjs 里 targetKey() 注释写明的已知行为。

★ 未验(要说清楚,不能算过)
- Enter/Esc/Ctrl+N 三个快捷键**仍未在设备上端到端验过**:2in1 模拟器 + Xvfb 下
  键盘事件送不进 App(xdotool 的文本能进 TextInput 走输入法通道,但键事件不达;
  hdc 的 uinput/uitest keyEvent 同样不生效)。日志显示 SubscribeKeyEvent
  被调用 ⇒ 订阅注册成功,纯粹是键送达不了。属环境限制。
- 悬停同理(要有鼠标 hover 事件注入,xdotool mousemove 到窗口不一定转成
  ArkUI 的 onHover)。
- 登录页回车:同一限制。
  沉浸顶栏与三键避让是**截图硬证过**的(不依赖键盘)。
2026-09-25 12:21:00 +08:00

102 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* AgentMail 鸿蒙客户端 — 2in1(PC / 平板键盘态)键盘快捷键
*
* ★★ 2026-09-24 新增。用户:「还有快捷键,比如主页回车打开写信。
* 邮件详情页回车打开回复。esc返回上一级」。
*
* ## 为什么单独一个模块
*
* 这三条键各自要落在**不同的组件**上(主页在 `MainPage` 根、详情在
* `MailDetailView`),而它们的**判定规则是同一套**(哪些键、什么条件、
* 要不要吞掉事件)。规则写两遍必然分叉 —— 本仓反复出现的形状。
* 所以规则住在这里,两处只做"接线"。
*
* ## 为什么不用 `keyboardShortcut`(Ctrl+N 那条用了)
*
* `keyboardShortcut` 只接受**组合键**:
* · 字符键必须配修饰键(Ctrl / Shift / Alt);
* · 不配修饰键时只接受 `FunctionKey`。
* 而框架的 `FunctionKey`(`component/enums.d.ts:3444`)只有
* ESC / F1–F12 / TAB / DPAD_UP / DPAD_DOWN / DPAD_LEFT / DPAD_RIGHT
* —— **没有 Enter**。所以"单按回车"用它表达不出来。
*
* 官方给单键的路是 `onKeyEvent`(`common.d.ts:19510`),而它
* 「Triggered when a key operation is performed on the bound component
* **after it obtains focus**」—— 需要焦点。
*
* ## 焦点问题怎么解(这是本条能力的关键)
*
* 邮件列表里焦点会随点击乱跑,所以"绑在某个列表项上等它获焦"不可靠。
* 但官方同时给了**冒泡**语义(`common.d.ts:19560-19562`):
* 「If the callback returns **true**, the key event is marked as consumed
* and will not **bubble up** to parent components.」
* 反过来说:**回调返回 false(或不处理)时,事件会冒泡到父组件**。
*
* ⇒ 于是有了一条可靠的路:
* · 绑在**页面根容器**上(它不是 `Text`/`Image` 那种默认不可聚焦的节点,
* 而是布局容器 —— 见 `isConsumableKey` 的注释);
* · 子组件里真正的输入框(收件人输入、回复框)先拿到键,
* 它们自己消费掉(`ComposePage.onToKey` 就是这么做的);
* · 没被子组件消费的键**冒泡到页面根**,由这里处理。
*
* 这样"详情页优先、主页兜底"是**框架保证**的,不是我自己排的优先级。
*
* ## 与 WebUI 的关系(这条不是对齐项)
*
* WebUI **没有**这三条快捷键(`grep` 过全部 `onKeyDown`:只有
* `AccountSwitcher.tsx:40` 的 Esc 关下拉、`AddressInput.tsx` 的候选导航)。
* 所以它们是**鸿蒙侧新增的 2in1 能力**,不是"WebUI 有而鸿蒙没有"。
* 这一点要写明,免得后面审计时被当成缺失项去"补"。
*/
import { KeyCode } from '@kit.InputKit';
/**
* 一个键事件该不该由本模块处理。
*
* ★ 只认 `KeyType.Down`:`onKeyEvent` 对**按下与抬起**各触发一次,
* 两条都处理会让一次按键走两步(`ComposePage.onToKey` 头一行就是这道门,
* 本仓 2in1 判据也钉过)。
*/
export function isKeyDown(type: KeyType): boolean {
return type === KeyType.Down;
}
/** 回车:打开发信 / 打开回复 —— 两条路都要它 */
export function isEnterKey(keyCode: number): boolean {
return keyCode === KeyCode.KEYCODE_ENTER
|| keyCode === KeyCode.KEYCODE_NUMPAD_ENTER
|| keyCode === KeyCode.KEYCODE_DPAD_CENTER;
}
/** Esc:返回上一级 */
export function isEscapeKey(keyCode: number): boolean {
return keyCode === KeyCode.KEYCODE_ESCAPE;
}
/**
* 这次按键是不是"文本框该自己管的"。
*
* ★ 为什么需要这道门(真实后果,不是理论):
* 回复框、收件人输入框里按回车是**换行 / 提交**,不该被页面级
* "回车 = 打开回复"抢走。抢走的后果是:用户想换行,结果又开了一层回复框,
* 而且 Esc 想取消输入却返回到列表 —— 输入到一半的内容全没了。
*
* 判定用键本身而不是"焦点在哪":焦点位置要读 `FocusController`,
* 而它与"这个键是否已被消费"是两件事 —— 官方给的冒泡机制已经表达了
* "子组件消费掉了就别往上冒",这里只再兜一层**文本输入语义**的键。
*/
export function isTextEditingKey(keyCode: number): boolean {
/*
* 只列真正属于"文字输入"的键。
* ★ **不含 Enter** —— 故意的:
* 本页没有"回车换行"的多行输入(回复框是单行 `TextInput`,
* 见 `MailDetailPage` 的回复条),回车在这里的语义就是"提交/打开"。
* 若哪天回复框改成多行 `TextArea`,**必须**把 Enter 加进来,
* 否则用户换行会被抢去开新回复框。
* (这一条写在代码里而不是文档里 —— 改的人一定会看到这里。)
*/
return keyCode === KeyCode.KEYCODE_SPACE
|| keyCode === KeyCode.KEYCODE_TAB;
}