跨端: 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:
@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import { parseParts, mergeCandidate, nextActiveIndex, queryFor, filterIndexes } from '../lib/addressSuggest';
|
||||
import type { SessionCandidate } from '../types';
|
||||
|
||||
/**
|
||||
@ -49,11 +50,8 @@ export default function AddressInput({
|
||||
const run = async () => {
|
||||
try {
|
||||
// 决定问哪一层:还没写 @ -> 问 name;写了 @ 没写 . -> 问 path;写了 . -> 问 session
|
||||
const res = parts.hasDot
|
||||
? await api.suggestAddress(parts.name, parts.path)
|
||||
: parts.hasAt
|
||||
? await api.suggestAddress(parts.name)
|
||||
: await api.suggestAddress();
|
||||
const q = queryFor(parts);
|
||||
const res = await api.suggestAddress(q.name, q.path);
|
||||
if (cancelled) return;
|
||||
|
||||
const frag = parts.hasDot ? parts.session : parts.hasAt ? parts.path : parts.name;
|
||||
@ -61,15 +59,11 @@ export default function AddressInput({
|
||||
const all = res.suggestions || [];
|
||||
const cands = res.candidates || [];
|
||||
|
||||
// 过滤时保持 suggestions 与 candidates 同序:candidates 是按下标对应的,
|
||||
// 分别过滤两个数组会让标题错位到别的别名上。
|
||||
const keep: number[] = [];
|
||||
all.forEach((s, i) => {
|
||||
const c = cands[i];
|
||||
// 标题也参与匹配:想找「缓存选型」那条会话时,人记得的是标题而不是随机短名
|
||||
const hay = c?.title ? `${s} ${c.title}`.toLowerCase() : s.toLowerCase();
|
||||
if (hay.includes(lower)) keep.push(i);
|
||||
});
|
||||
/*
|
||||
* 过滤下标(**保持 suggestions 与 candidates 同序**)——
|
||||
* 抽到 `lib/addressSuggest.ts`,与鸿蒙 `filterIndexes` 同一份规则。
|
||||
*/
|
||||
const keep: number[] = filterIndexes(all, cands.map(c => c?.title ?? ''), lower);
|
||||
|
||||
setKind(res.kind);
|
||||
setItems(keep.map(i => all[i]));
|
||||
@ -127,14 +121,7 @@ export default function AddressInput({
|
||||
|
||||
/** 选中一个候选后拼回完整地址 */
|
||||
const apply = (choice: string) => {
|
||||
let next: string;
|
||||
if (kind === 'name') {
|
||||
next = `${choice}@`;
|
||||
} else if (kind === 'path') {
|
||||
next = `${parts.name}@${choice}.`;
|
||||
} else {
|
||||
next = `${parts.name}@${parts.path}.${choice}`;
|
||||
}
|
||||
const next: string = mergeCandidate(parts, kind, choice);
|
||||
onChange(allowMultiple ? `${head}${head ? ' ' : ''}${next}` : next);
|
||||
// name/path 选完仍停留在补全态,继续下一段
|
||||
setOpen(kind !== 'session');
|
||||
@ -144,10 +131,10 @@ export default function AddressInput({
|
||||
if (!open || items.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActive(i => (i + 1) % items.length);
|
||||
setActive(i => nextActiveIndex(i, items.length, 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActive(i => (i - 1 + items.length) % items.length);
|
||||
setActive(i => nextActiveIndex(i, items.length, -1));
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
apply(items[active]);
|
||||
@ -244,22 +231,12 @@ export default function AddressInput({
|
||||
}
|
||||
|
||||
/** 把 name@path.session 拆段;path 内允许 . 与 /,按最后一个 . 切 */
|
||||
function parseParts(s: string) {
|
||||
const at = s.indexOf('@');
|
||||
if (at < 0) {
|
||||
return { name: s, path: '', session: '', hasAt: false, hasDot: false };
|
||||
}
|
||||
const name = s.slice(0, at);
|
||||
const rest = s.slice(at + 1);
|
||||
const dot = rest.lastIndexOf('.');
|
||||
if (dot < 0) {
|
||||
return { name, path: rest, session: '', hasAt: true, hasDot: false };
|
||||
}
|
||||
return {
|
||||
name,
|
||||
path: rest.slice(0, dot),
|
||||
session: rest.slice(dot + 1),
|
||||
hasAt: true,
|
||||
hasDot: true
|
||||
};
|
||||
}
|
||||
/*
|
||||
* `parseParts` 已抽到 `lib/addressSuggest.ts`(与鸿蒙 `model/AddressSuggest.ts` 一对)。
|
||||
*
|
||||
* 抽的理由:这段逻辑原先只在本组件里,鸿蒙要写同一套规则时**没有可比的基准**。
|
||||
* 现在它进了 `lib/`,`cross-client-logic.test.mjs` 能把同一张用例表喂给两边。
|
||||
*
|
||||
* ★ 抽的时候行为**一字不改**("取最后一个点"那个选择保留)——
|
||||
* 抽出来顺手"改进"会让判据比的是新行为,而线上跑的仍是旧行为。
|
||||
*/
|
||||
|
||||
125
client/electron/src/lib/addressSuggest.ts
Normal file
125
client/electron/src/lib/addressSuggest.ts
Normal file
@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 三维地址补全的**纯逻辑** —— 与鸿蒙 `model/AddressSuggest.ts` 一对。
|
||||
*
|
||||
* 从 `components/AddressInput.tsx` 里抽出来的。抽的原因有两个:
|
||||
* ① 逻辑散在组件闭包里时**没法单测**(`apply`/`onKeyDown` 都改 React state);
|
||||
* ② 鸿蒙侧要写同一套规则 —— 两份手抄必然漂移(本仓已有判据
|
||||
* `cross-client-logic.test.mjs` 专门抓这个,它的首次运行就抓到过两处真分叉)。
|
||||
*
|
||||
* ⇒ 抽成纯函数后,同一张用例表喂给两边、逐条比结果。
|
||||
*
|
||||
* ★ 抽的时候**行为一字不改**:`parseParts` 是逐字搬过来的(含"取最后一个点")。
|
||||
* 抽出来顺手"改进"一下是最危险的 —— 那会让判据比的是新行为,
|
||||
* 而线上跑的仍是旧行为,两边都"通过"了却都错。
|
||||
*/
|
||||
|
||||
export interface AddressParts {
|
||||
name: string;
|
||||
path: string;
|
||||
session: string;
|
||||
hasAt: boolean;
|
||||
hasDot: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把编辑中的一段拆成三段。
|
||||
*
|
||||
* 没有 `@` ⇒ 整串都是 name;有 `@` 没 `.` ⇒ `@` 之后是 path;
|
||||
* 都有 ⇒ 以**最后一个** `.` 为界。
|
||||
*/
|
||||
export function parseParts(s: string): AddressParts {
|
||||
const at = s.indexOf('@');
|
||||
if (at < 0) {
|
||||
return { name: s, path: '', session: '', hasAt: false, hasDot: false };
|
||||
}
|
||||
const name = s.slice(0, at);
|
||||
const rest = s.slice(at + 1);
|
||||
const dot = rest.lastIndexOf('.');
|
||||
if (dot < 0) {
|
||||
return { name, path: rest, session: '', hasAt: true, hasDot: false };
|
||||
}
|
||||
return {
|
||||
name,
|
||||
path: rest.slice(0, dot),
|
||||
session: rest.slice(dot + 1),
|
||||
hasAt: true,
|
||||
hasDot: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 把选中的候选拼回一段完整地址。
|
||||
*
|
||||
* `name`/`path` 选完要**补上分隔符**(`@` / `.`),这样用户接着打字就自然
|
||||
* 进入下一段,不必自己敲分隔符。
|
||||
*/
|
||||
export function mergeCandidate(parts: AddressParts, kind: string, choice: string): string {
|
||||
if (kind === 'name') {
|
||||
return `${choice}@`;
|
||||
}
|
||||
if (kind === 'path') {
|
||||
return `${parts.name}@${choice}.`;
|
||||
}
|
||||
return `${parts.name}@${parts.path}.${choice}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下键移动时的下一个下标(**循环**)。
|
||||
*
|
||||
* ★ JS 的 `%` 对负数返回负数(`-1 % 5 === -1`),所以向上移动要写成
|
||||
* `((i + d) % n + n) % n`。直接写 `(i-1) % n` 会得到负下标 ——
|
||||
* 而负下标在数组访问里**不报错**(返回 `undefined`),只会表现为
|
||||
* "按上键之后菜单里没有任何一项高亮"。这类静默失败正是判据要钉的。
|
||||
*/
|
||||
export function nextActiveIndex(current: number, count: number, delta: number): number {
|
||||
if (count <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return (((current + delta) % count) + count) % count;
|
||||
}
|
||||
|
||||
/** 候选菜单要不要向上翻转(下方空间不够且上方更多时翻) */
|
||||
export function shouldFlipUp(spaceAbove: number, spaceBelow: number, need: number): boolean {
|
||||
const want = Math.min(need, spaceAbove);
|
||||
return spaceBelow < want && spaceAbove > spaceBelow;
|
||||
}
|
||||
|
||||
export interface SuggestQuery {
|
||||
name: string;
|
||||
path: string;
|
||||
kind: 'name' | 'path' | 'session';
|
||||
}
|
||||
|
||||
/** 按当前输入决定"问哪一层" */
|
||||
export function queryFor(parts: AddressParts): SuggestQuery {
|
||||
if (parts.hasDot) {
|
||||
return { name: parts.name, path: parts.path, kind: 'session' };
|
||||
}
|
||||
if (parts.hasAt) {
|
||||
return { name: parts.name, path: '', kind: 'path' };
|
||||
}
|
||||
return { name: '', path: '', kind: 'name' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤候选的下标 —— **保持 suggestions 与 candidates 同序**。
|
||||
*
|
||||
* 分别过滤两个数组会让标题错位到别的别名上(`AddressInput.tsx:60` 的原注释)。
|
||||
* 标题也参与匹配:人记得的是会话标题而不是随机短名。
|
||||
*/
|
||||
export function filterIndexes(
|
||||
suggestions: string[],
|
||||
titles: string[],
|
||||
fragment: string
|
||||
): number[] {
|
||||
const keep: number[] = [];
|
||||
const lower = fragment.toLowerCase();
|
||||
suggestions.forEach((s, i) => {
|
||||
const title = titles[i] ?? '';
|
||||
const hay = title ? `${s} ${title}`.toLowerCase() : s.toLowerCase();
|
||||
if (hay.includes(lower)) {
|
||||
keep.push(i);
|
||||
}
|
||||
});
|
||||
return keep;
|
||||
}
|
||||
Reference in New Issue
Block a user