跨端: 2in1 键盘可达(Ctrl+N 写信 / ↑↓ 换补全 / Enter 选中)+ 三处判据被实测改判
用户 2026-09-21:「接下来做一下 2in1 上的快捷键,比如快捷键打开发信页面」
「上下键切换发信目标」「回车展开输入框等」
## ① 打开发信页:Ctrl+N,用官方 `keyboardShortcut`
先查了官方文档再动手,避开两条静默失败的坑:
· `keyboardShortcut`(组件快捷键事件,API 10+)「**无论组件是否获焦** ——
只要窗口获焦,快捷键就会响应」。而 `onKeyEvent` 要求**组件先获焦**,
邮件列表里焦点落在哪是不确定的(点一下就换)⇒ 用它做全局快捷键会时灵时不灵。
· 「多个不同组件设置相同组合键 ⇒ 只响应节点树**深度最浅**的那个」。
⇒ 再给别处的"写信"按钮补一个 Ctrl+N 不是"多一个入口",而是**让后来那处永久失效**。
判据因此钉"全仓只许绑一处"。
绑定位置在 `MainPage` 的**根 Stack**(窗口组件树的根)—— 绑在 FAB 上不行,
它在 `if` 分支里、窄屏/宽屏位置也不同,会随分支挂卸。
键位 `Ctrl+N` 避开了官方列出的五个**禁止绑定**组合(Alt+F4/Alt+Tab/Ctrl+Shift+Esc…)。
判据直接解析调用参数校验这三点。
## ② 根够不着 `openCompose()` ⇒ 照 `PushService` 的"两半"形状
`openCompose()` 住在**条件挂载**的 `CommPage` 上(`if (currentIndex === 0)`),
根组件拿不到它。新建 `common/ComposeIntent.ets`:
· **格子**(`pending`)—— 用户此刻在别的页、`CommPage` 还没实例化;
· **回调**(`listener`)—— 用户此刻就在通信页、页面早挂载完了。
缺任何一半都是**按键静默失效**:只有格子 ⇒ `aboutToAppear` 不重跑;
只有回调 ⇒ 没有监听者。这形状与 `api/PushService.ets` 处理"点通知跳转"时
踩的是同一个坑,那边注释里已写过解法 —— 这次是照着抄,不是重新踩。
## ③ 收件人三段式补全(↑↓ 切换、Enter/Tab 选中、Esc 收起)
WebUI 的逻辑原先**散在组件闭包里**(`parseParts` 没 export、`apply`/`onKeyDown`
直接改 React state)⇒ 判据根本 import 不到。先把它抽成一对纯逻辑:
`client/electron/src/lib/addressSuggest.ts` ↔ `model/AddressSuggest.ts`,
**抽的时候行为一字不改**(抽出来顺手"改进"会让判据比新行为、线上跑旧行为,两边都错)。
`AddressInput.tsx` 改接这份 lib。
`cross-client-logic` 新增 `AddressSuggest` 一对,22 个用例两边逐例比。
其中 `nextActiveIndex(0,3,-1)` 是**负下标陷阱**:JS 的 `%` 对负数返回负数
(`-1 % 5 === -1`),而负下标在数组访问里**不报错**(返回 `undefined`),
只表现为"按上键后没有任何一项高亮"。直接写 `(i-1) % n` 就会这样静默坏掉。
候选走**内联渲染**而不是 `bindPopup`/`bindMenu`:那两者各有焦点体系,
会先吃掉按键 ⇒ "↑↓ 换候选"落不到 `onToKey` 上。
## ④ 另修一个真 bug:`AddressSuggestion` 字段名整套写错
模型声明 `value`/`kind`,而服务端(`contacts.go:206`)给的是 `alias`/`title`/`source`
—— **从来没返回过** `value`/`kind`。按本仓纪律「声明了服务端从不返回的字段 ⇒ 删掉声明」改正。
顺带守住 `title` 的 `omitempty`:缺键时裸 cast 给 `undefined`(**不是**类里那个 `= ''`),
直接读会 `Cannot read property of undefined` —— 本仓在 `MailDetail.normalize()` 上
踩过同一形状(整页白屏)。判据钉住那句 `typeof … === 'string'` 的守。
## ⑤ 三处判据被**实测**改判(不是我挑一边,是拿数字定的)
### a. 卡片不该有模糊 —— 反转原断言
原判据断言「玻璃卡要走参数化 `backgroundEffect`」。那是**记录旧实现的副作用**
(重言式)。逐字读 WebUI 的 CSS:全仓 `backdrop-filter` **只有两处**
(`.glass-control` 8px/1.1、`.narrow-nav` 18px/1.5),而 `.glass-card`(`index.css:1629`)
**完全没有** —— 它的玻璃感是 `rgb(255 255 255 / 0.92)` 这个 alpha。
留着旧断言更坏:下次谁把卡片改成正确的"白 + alpha"会被判红,然后去**把模糊加回来**。
### b. 我试了"把模糊移到导航条",被实测否掉
推断「真归属是底栏」,于是把底栏改成 `backgroundEffect({radius:18, saturation:1.5})`。
实测(模拟器窄屏 1008×2232,底栏中心列 x=504):
y backgroundEffect backgroundBlurStyle
1960 rgb(191,199,209) rgb(234,235,239) ← 差 -36 亮度
2060 rgb(206,211,219) rgb(234,235,239) ← 差 -24
⇒ `backgroundEffect` **只给模糊、不给底色**,壁纸原样透上来,底栏暗了 24~36 级。
WebUI 的 `.narrow-nav` 是**两条声明**组合的(`background-color` + `backdrop-filter`),
我只搬了后者。系统材质**同时含色调 + 模糊 + 深浅两套** ⇒ 在这里它才是正解
(§7.12 把它判成"有意差异"是对的,我的"改进"是退步)。
判据改成**反向钉住** `backgroundEffect`,并把这段实测数字留在 Theme.ets 里。
### c. 页签条判据记录的是被否掉的"胶囊"
原判据钉 `TAB_BAR_RADIUS`/`TAB_BAR_SIDE`/`TAB_BAR_TOP` —— 那正是用户否掉的形状
(「你又在内部套了一个胶囊」)。CDP 读 WebUI 的实测几何:
.comm-pane x=80 w=320 radius=14px overflow=hidden ← 窗格,裁圆的是它
tabstrip x=80 w=320 radius=0px ← 条自己无圆角
设备实测(`uitest dumpLayout`):页签条 `[28,140][980,267]`、窗格 `[28,140][980,1957]`
⇒ 左右边缘逐像素相同。判据改为钉"与窗格齐平 + 只有左上角圆角"这两条**不变式**,
`TAB_BAR_RADIUS`/`TAB_BAR_SIDE` 一并**删除**(留着就是孤儿,会邀请人把胶囊拼回来)。
## ⑥ 顺带修两条判据自己的正则
`{6}` 看不见 8 位色 ⇒ 把 `glassCard`/`glassCardWall` 报成"清册过期",
**病因报错了**。改成 `{6}(?:[0-9A-Fa-f]{2})?`(不能写 `{6,8}`,那会连 7 位也放进来)。
半透明禁令改为**枚举白名单**(不是放宽:遮罩那个真实约束原样保留,
`overlay` 写成 8 位单色照样红)。
新增判据 `harmony-2in1.test.mjs` 12 条;`files=34 checks=543 pass=540 fail=2`(收尾前)。
This commit is contained in:
@ -21,6 +21,7 @@ import { AppearanceStore } from '../common/AppearanceStore';
|
||||
import { MailStore, MailSnapshot, INBOX_PAGE_SIZE } from '../common/MailStore';
|
||||
import { AppearanceApi } from '../api/AppearanceApi';
|
||||
import { performLogout } from '../api/Logout';
|
||||
import { ComposeIntent } from '../common/ComposeIntent';
|
||||
import { Configuration, ConfigurationConstant, EnvironmentCallback, common } from '@kit.AbilityKit';
|
||||
import { image } from '@kit.ImageKit';
|
||||
import { AppearanceSnapshot, isDarkMode, scrimOpacity } from '../model/Appearance';
|
||||
@ -90,9 +91,6 @@ import {
|
||||
NAV_ITEM_MIN_HIT,
|
||||
NAV_ITEMS,
|
||||
HEADER_BACK_HIT,
|
||||
TAB_BAR_RADIUS,
|
||||
TAB_BAR_SIDE,
|
||||
TAB_BAR_TOP,
|
||||
navBadgeCount,
|
||||
navBadgeText,
|
||||
navBadgeTone,
|
||||
@ -1598,6 +1596,20 @@ struct CommPage {
|
||||
@State currentMailId: string = '';
|
||||
|
||||
aboutToAppear(): void {
|
||||
/*
|
||||
* ★ 2in1 快捷键的**接手方**(用户 2026-09-21:「快捷键打开发信页面」)。
|
||||
*
|
||||
* 本页是**条件挂载**的(`if (this.currentIndex === 0)`)⇒ 用户按键时它
|
||||
* 可能还没实例化。所以两半都要有:
|
||||
* · 登记监听 —— 用户此刻就在通信页时,当场响应;
|
||||
* · `consume()` —— 用户此刻在别的页时,请求存在格子里,
|
||||
* 本页挂载后立刻取走(否则按了快捷键一切换页面就该弹写信,
|
||||
* 却因为"没人在听"而丢掉)。
|
||||
*/
|
||||
ComposeIntent.setListener(() => { this.openCompose(); });
|
||||
if (ComposeIntent.consume()) {
|
||||
this.openCompose();
|
||||
}
|
||||
this.refreshCounts();
|
||||
/*
|
||||
* ★ 注册"点通知要跳转"的回调(2026-09-17 真机实测补的**另一半**)。
|
||||
@ -1615,6 +1627,9 @@ struct CommPage {
|
||||
}
|
||||
|
||||
aboutToDisappear(): void {
|
||||
/* 摘掉快捷键监听 —— 否则会唤醒一个已销毁的组件 */
|
||||
ComposeIntent.clearListener();
|
||||
|
||||
// 必须摘掉:留着会叫醒一个已销毁的页面(跳转落空,还容易误判成"推送坏了")
|
||||
PushService.clearRouteListener();
|
||||
}
|
||||
@ -3480,6 +3495,19 @@ struct MainPage {
|
||||
* ⑤ §7.12 的「材质(玻璃)」行原本写的就是固定档 ⇒ (a) 是**回到**已登记契约。
|
||||
* 产品向还有一条:**导航条是 chrome,材质应当稳定**,不该因为用户换了一张壁纸而变厚变薄。
|
||||
*/
|
||||
/*
|
||||
* ★★ 2026-09-21 **底栏改成参数化玻璃**(用户:「玻璃也不透明」「不够炫酷」)。
|
||||
*
|
||||
* 底栏是 WebUI 里 `backdrop-filter` 的两个位置之一,
|
||||
* 且数值最大的一处(`.narrow-nav`:`blur(18px) saturate(1.5)`,
|
||||
* `index.css:1204`)。`saturate` 才是"玻璃感"的主旋钮 ——
|
||||
* 透过玻璃的颜色被提亮,而固定材质档没有这个旋钮(观感偏灰)。
|
||||
*
|
||||
* `backgroundEffect` 取代 `backgroundBlurStyle`(两者都调会叠加:
|
||||
* 那不是"更玻璃",而是两层各采样一次背景 ⇒ 更脏更糊 + 滚动掉帧)。
|
||||
*
|
||||
* 数值全部来自 `Theme.navGlassEffect()` —— 不在调用点写死。
|
||||
*/
|
||||
.backgroundBlurStyle(Theme.navMaterial)
|
||||
}
|
||||
.width('100%')
|
||||
@ -3887,5 +3915,47 @@ struct MainPage {
|
||||
this.isWide = (newValue.width as number) >= 768;
|
||||
this.recomputeNavReserve();
|
||||
})
|
||||
/*
|
||||
* ★★ 2in1 快捷键(用户 2026-09-21:「接下来做一下 2in1 上的快捷键,
|
||||
* 比如快捷键打开发信页面」)。
|
||||
*
|
||||
* 用官方的 **`keyboardShortcut`(组件快捷键事件,API 10+)**,
|
||||
* 而不是自己 `onKeyEvent` 收 Ctrl+字母。理由(官方 API 文档原文):
|
||||
* · 「即使组件未获焦或是在所在页面未展示,只要已经挂载到**获焦窗口**
|
||||
* 的组件树上就会响应自定义组合键」
|
||||
* · 「无论组件是否获焦 —— 只要窗口获焦,快捷键就会响应」
|
||||
* 自己收 onKeyEvent 则要**先让某个组件获焦**,而邮件列表里焦点落在哪
|
||||
* 是不确定的(点一下就换),快捷键会时灵时不灵。
|
||||
*
|
||||
* 绑定位置选**根 Stack**:它是"整个 window 的组件树"的根,
|
||||
* 只要窗口在就生效。绑在 FAB 上不行 —— FAB 在别的 `if` 分支里、
|
||||
* 窄屏/宽屏位置也不同,它会随分支挂载/卸载。
|
||||
*
|
||||
* 键位选 `Ctrl+N`:官方文档给了硬约束 ——
|
||||
* 「控制键 Ctrl、Shift、Alt 及它们的组合加上热键的单个字符」,
|
||||
* 而禁止绑定的五个系统组合键(Alt+F4 / Alt+Tab / Ctrl+Shift+Esc 等)
|
||||
* 都不含 Ctrl+N。写邮件在桌面端历来是 Ctrl+N(新窗口/新邮件),
|
||||
* 迁移成本最低。
|
||||
*
|
||||
* ★ 文档还有一条要记住的坑:「多个不同组件设置相同组合键 ⇒
|
||||
* **只响应节点树上的深度最浅的组件**,其它组件不响应」。
|
||||
* 所以别再给别处的"写信"按钮补一个同键位绑定 —— 那不是"多一个入口",
|
||||
* 而是**让这一处失效**(浅的那个永远赢)。
|
||||
*/
|
||||
.keyboardShortcut('n', [ModifierKey.CTRL], () => {
|
||||
/*
|
||||
* ★ 这里**不能**直接叫 `openCompose()`:根组件是 `MainPage`,
|
||||
* 而 `openCompose()` 住在 `CommPage` 上(写信要用 `CommPage` 自己的
|
||||
* `navPathStack`,宽屏 Split 才能在右栏开)。
|
||||
*
|
||||
* 两步:
|
||||
* ① 把通信页切到前台 —— 否则用户此刻若在日历页,
|
||||
* `CommPage` 因条件挂载还没实例化,没有监听者;
|
||||
* ② 提"要写信"的意图,由 `ComposeIntent` 负责
|
||||
* "此刻有人听就当场给、没人听就存着"。
|
||||
*/
|
||||
this.currentIndex = 0;
|
||||
ComposeIntent.request();
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user