联想词键盘选词彻底修复:上下键 / 逗号句号 / 输入法组合上屏四条路径全通

背景:用户反馈「上下键和逗号句号都无法选择候选词,只能用鼠标」。逐条查下去
发现是**三个各自独立的原因**叠在一起,任何一个没修都会让人觉得「还是坏的」。

① 单组按键模式让另一组键静默失效
   线上 settings.suggest_keys 停在 'punct'(只认 , 和 .)—— 是该模式的正确
   行为,但用户不知道那是设置项在起作用,只当坏了。
   改法:默认值改为 'both'(↓/↑ 与 , . 都认)。'both' 是 'arrows' 与 'punct'
   各自行为的**严格超集**,升级不会让任何人的习惯失效,只是多了一组。
   老库用**一次性迁移**(哨兵键 mig_suggest_keys_both 把关)把已知单组值并成
   'both'。⚠️ 迁移必须只跑一次:db_init() 每次请求都执行,无条件 UPDATE 会把
   用户在后台特意选回的 'arrows' 于下一个请求改回去 —— 设置项直接失效。
   同时同步三处默认值:index.php / admin/index.php / admin/actions.php。

② 【元凶】beforeinput 只认 insertText,漏了 insertCompositionText
   输入法把标点当**组合内容**提交时,beforeinput 的 inputType 是
   insertCompositionText,旧代码直接 return → 这条路径完全失效。
   修法:两种 inputType 都认。

③ 【真机实测的硬限制】insertCompositionText 的 beforeinput cancelable = false
   Chromium 里这条事件**不允许** preventDefault(CDP 实测记录:
   beforeinput it:insertCompositionText comp:true data:"," pd:false cc:false),
   所以「命中就拦」对这条路根本无效 —— 标点会被真的插进搜索框。
   修法:加「事后收口」——beforeinput 记下快照与该移到的目标下标,
   等这一帧事件洪峰过去(setTimeout 0)再把标点从输入框里撤掉、并补上选词移动。
   目标下标必须**当场算好**:收口跑在宏任务里,那时 input 的 `active = -1`
   已经把选中清掉了,再从 active 起算会每次都回到第一条(不累加)。
   顺带把标点判定抽成共用函数 punctDir/keyPunctDir(e.key 与 e.code 互为兜底),
   并修掉「方向键也无差别置去重闸门」——方向键不产生 beforeinput,置了纯属副作用,
   反而会误挡紧随其后的真实标点。

测试与验证(全部实跑,非推断):
- jsdom 交互测试 89 项全过,新增「真实浏览器形态」场景(cancelable=false +
  组合上屏),并**纠正了旧版一条在断言 bug 本身的断言**(原写「组合上屏不该被拦」)。
- 反向验证 6 变体全部精确挂掉对应断言(含新增的「只认 insertText」与
  「方向键置闸门」两个变体);旧变体引用的 isComma/isPeriod 已随重构更新。
- **真实浏览器**(Playwright + msedge + CDP IME)21/21:上下键 / 半角 / 全角
  insertText / IME 组合上屏四条路径全部能选词。
- 真实浏览器反向验证:基准全绿,删掉「事后收口」或改回只认 insertText 都精确报红。
- 迁移语义测试 16 项全过(真实 db_init,含「用户改回 arrows 后不再被覆盖」)。
- 静态断言空跑 155 条全过;线上部署 0 FAIL,诊断页 57 条断言全过。
- 线上真机直连 n.yangyq.net 21/21。
This commit is contained in:
2026-09-29 08:10:22 +08:00
parent 5f38df80b7
commit aac6d3e0f4
5 changed files with 174 additions and 56 deletions
+136 -38
View File
@@ -33,24 +33,37 @@
var items = []; // 当前展示的建议词
var active = -1; // 选中项下标(键盘 ↓/↑、,/. 与鼠标划过共用);-1 = 没选中
var punctByKeyDown = false; // 本次标点是否已由 keydown 处理(防 keydown+beforeinput 双触发跳两格)
var pendingPunct = null; // 「拦不住的标点」信息 {ch, dir, before}(见下),null = 无
var pendingTimer = null; // 收口定时器(去抖:组合上屏会连发两次 input)
/* 联想词的选中按键(后台「设置 → 联想词按键」):
arrows(默认,等同改造前)/ punct / both */
var keyMode = cfg.suggestKeys || 'arrows';
both(默认:两组都认)/ arrows(只认 ↓/↑)/ punct(只认 , 和 .)
⚠️ 默认已从 arrows 改为 both(2026-09-29):用户反馈「上下键和逗号句号都选不了词」,
而线上设置恰好是单组 punct —— 单组模式天然让**另一组键静默失效**,极易被当成 bug。
两组都认是它们的**严格超集**:想用哪组都行,没有任何损失。 */
var keyMode = cfg.suggestKeys || 'both';
function acceptsArrows() { return keyMode === 'arrows' || keyMode === 'both'; }
function acceptsPunct() { return keyMode === 'punct' || keyMode === 'both'; }
/* ⚠️ 中文输入法的标点坑(真实用户反馈,2026-09-28):
中文输入法下按逗号/句号,上屏的是**全角** `,`(U+FF0C) / `。`(U+3002),
/* ⚠️ 中文输入法的标点坑(真实用户反馈,2026-09-28 / 2026-09-29):
中文输入法下按逗号/句号,上屏的常是**全角** `,`(U+FF0C) / `。`(U+3002),
而且此时 e.code **不一定**是 'Comma'/'Period'(取决于浏览器与输入法)——
早先只认 `e.code === 'Comma' || e.key === ','`(半角)就是因此漏判,
结果标点被当普通字符输进了搜索框、没当成选词键。
修法:以 e.key 的**字符**为主判据,半角/全角都认,e.code 仅作辅助。
全角逗号 = `,`(U+FF0C);全角句号常见是 `。`(U+3002),另兼容全角句点 `.`(U+FF0E)。 */
function isComma(e) {
return e.key === ',' || e.key === '\uFF0C' || e.code === 'Comma';
早先只认半角的 e.code / e.key(半角逗号点号)就是因此漏判,
结果标点被当普通字符输进搜索框、没当成选词键。
修法:**e.key 与 e.code 两条都认**(互为兜底)—— 有的环境 e.key 是全角而 code 缺,
有的环境 e.key 仍半角而 code 稳;只取其一都会漏。
全角逗号 `,`(U+FF0C);全角句号常见 `。`(U+3002),另兼容全角句点 `.`(U+FF0E)。
返回:-1 = 逗号(上一条,同 ↑);+1 = 句号(下一条,同 ↓);0 = 不是标点。 */
function punctDir(ch) {
if (ch === ',' || ch === '\uFF0C') { return -1; }
if (ch === '.' || ch === '\u3002' || ch === '\uFF0E') { return 1; }
return 0;
}
function isPeriod(e) {
return e.key === '.' || e.key === '\u3002' || e.key === '\uFF0E' || e.code === 'Period';
function keyPunctDir(e) {
var d = punctDir(e.key);
if (d !== 0) { return d; }
if (e.code === 'Comma') { return -1; }
if (e.code === 'Period') { return 1; }
return 0;
}
function hideWord() {
@@ -106,6 +119,9 @@
// ⚠️ IME 组合态保护:用中文输入法**打拼音**时也会触发 keydown(isComposing=true,
// 或 keyCode=229),那些按键属于输入法自己的候选选择,不能当成选联想词的键拦下来。
// 组合结束(拼音上屏)后 isComposing=false,下面的 , / . 选词才生效。
// ⚠️ 这里的「返回」**不是**把这次标点丢掉 —— 输入法上屏标点会再走 beforeinput,
// 那条路径会接住(见下面 beforeinput 的 insertCompositionText 分支)。
// 所以「组合态里按标点」在用户看来照样能选词。
if (e.isComposing || e.keyCode === 229) { return; }
var open = !!word && word.style.display === 'block' && items.length > 0;
@@ -113,12 +129,12 @@
// 移动选中项:只动高亮、不动输入框(回车时才落到输入框)。
// 用哪组键由后台「联想词按键」决定:arrows = ↓/↑ / punct = , 和 . / both = 都行。
var dir = 0;
var isPunct = false; // 这次移动是否由**标点**触发(决定要不要标去重闸门)
if (acceptsArrows() && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
dir = e.key === 'ArrowDown' ? 1 : -1;
} else if (acceptsPunct() && isComma(e)) {
dir = -1; // , = 上一条(同 ↑)
} else if (acceptsPunct() && isPeriod(e)) {
dir = 1; // . = 下一条(同 ↓)
} else if (acceptsPunct()) {
dir = keyPunctDir(e); // , = 上一条(同 ↑)/ . = 下一条(同 ↓),半角全角都认
isPunct = dir !== 0;
}
if (dir !== 0) {
@@ -126,10 +142,13 @@
if (!open) { return; }
e.preventDefault(); // 别让光标跳到文本首尾、也别让页面跟着滚
moveSelection(dir);
// 同一次按键 keydown 已经处理过标点 → 标记一下,让紧随其后的 beforeinput 别再移一次,
// 否则会「跳两格」(keydown 与 beforeinput 的真实触发组合因浏览器/输入法而异,jsdom 测不全)。
punctByKeyDown = true;
setTimeout(function () { punctByKeyDown = false; }, 0);
// ⚠️ 只有**标点**才需要标闸门:同一次按键会先走 keydown、再走 beforeinput,
// 不标就会「跳两格」。方向键压根不产生 beforeinput,标了纯属副作用
// —— 反而会把紧随其后的真实标点误挡掉(两者相隔可能不足 1ms)。
if (isPunct) {
punctByKeyDown = true;
setTimeout(function () { punctByKeyDown = false; }, 0);
}
return;
}
@@ -150,37 +169,116 @@
}
});
/* ⚠️ beforeinput 兜底 —— 专治中文输入法的全角标点(用户实测反馈,2026-09-28)。
某些输入法下,按逗号/句号上屏的是全角「,」「。」,这时:
· keydown 的 e.key 可能不是半角,e.code 也可能不是 'Comma'/'Period' → 漏判;
· 或者根本在 isComposing 组合态里被上面那道保护挡掉 → 也漏判。
beforeinput 在**字符即将插入文本框之前**触发,e.data 就是要插入的字符,
是拦全角标点最可靠的一道。命中就 preventDefault(不插入)并移动选中项。
只在「下拉打开 + punct/both 模式」下生效;没有下拉时不拦,逗号句号照常输入。 */
/* ⚠️ beforeinput 兜底 —— 专治输入法的标点(用户实测反馈,2026-09-28)。
为什么需要它:按逗号/句号时,keydown 那一路可能整条接不住 ——
· 输入法上屏的是**全角**「,」「。」,e.key 不是半角、e.code 也不可靠 → 漏判;
· 或者按键发生在 isComposing 组合态里,被上面那道 IME 保护挡掉 → 漏判;
· 甚至**根本没有 keydown**(输入法把标点作为组合内容直接提交)→ 更接不住。
beforeinput 在**字符即将插入文本框之前**触发,e.data 就是要插入的字符,是最可靠的一道。
⚠️ 两种 inputType 都要认(2026-09-29 实测补齐,这正是「选了标点模式也没反应」的元凶):
· `insertText` —— 普通输入 / 部分输入法上屏标点;
· `insertCompositionText` —— 输入法把标点当**组合内容**提交(CDP `imeSetComposition` 复现)。
旧代码只认 insertText,于是这条路径完全失效。
命中就 preventDefault(不让它插入)并移动选中项。
只在「下拉打开 + punct/both 模式」下生效;没有下拉时不拦,逗号句号照常输入。
🔴🔴 【2026-09-29 真实浏览器实测】`insertCompositionText` 的 beforeinput 在 Chromium 里
**cancelable = false** —— preventDefault() 根本无效!所以「组合上屏标点」这条路
**只能事后撤销**:这里记住标点,等紧随的 input 事件把字符插进输入框后,再把它删掉
并移动选中项(见 undoPendingPunct)。只靠 beforeinput 是拦不住的。
(CDP 实测记录:beforeinput it:insertCompositionText comp:true data:"," pd:false cc:false) */
input.addEventListener('beforeinput', function (e) {
if (e.inputType !== 'insertText') { return; }
var it = e.inputType;
if (it !== 'insertText' && it !== 'insertCompositionText') { return; }
if (punctByKeyDown) { return; } // keydown 已处理过本次标点,别再移一次(防跳两格)
var d = e.data;
if (d === null || d === undefined) { return; }
if (typeof d !== 'string' || d.length === 0) { return; }
// 只在**恰好是一个标点字符**时当选词键。带别的内容(如整串组合文本)一律放行,
// 免得把输入法正常上屏的字吃掉。
var dir = punctDir(d);
if (dir === 0) { return; }
var open = !!word && word.style.display === 'block' && items.length > 0;
if (!open || !acceptsPunct()) { return; }
var dir = 0;
if (d === ',' || d === '\uFF0C') { dir = -1; } // 半角/全角逗号
else if (d === '.' || d === '\u3002' || d === '\uFF0E') { dir = 1; } // 半角/全角句号
if (dir !== 0) { e.preventDefault(); moveSelection(dir); }
if (e.cancelable) {
// —— 能拦就拦:insertText 路径,preventDefault 立刻生效,字符根本不会进输入框。
e.preventDefault();
moveSelection(dir);
return;
}
// —— 拦不住:insertCompositionText 路径(Chromium 实测 cancelable=false)。
// 只能**事后收口**:先记下「这次标点进来之前」的输入框值,等事件洪峰过去
// 再统一撤销(见 flushPending)。用快照而不是记字符,是因为这条路径的
// 事件序列是「先插入标点 → 再回滚成原样」,只记字符会撤错。
// ⚠️ 目标下标必须**当场算好**:收口跑在宏任务里,那时 input 的 `active = -1`
// 已经把选中清掉了,再从 active 起算就会每次都从第一条重新开始(不累加)。
pendingPunct = {
ch: d,
before: input.value,
target: nextActive(active, dir),
};
schedulePendingFlush();
});
}
/** 安排一次「拦不住的标点」收口(去抖:组合上屏会在一帧内连发两次 input) */
function schedulePendingFlush() {
clearTimeout(pendingTimer);
pendingTimer = setTimeout(flushPending, 0);
}
/**
* 收口「拦不住的标点」(beforeinput 里 cancelable=false 的那条路径)。
*
* 为什么必须**延迟到宏任务**而不是当场处理:这条路径的真实事件序列是
* ① beforeinput(insertCompositionText, cancelable=false) ← 拦不住
* ② input 值 = 原文 + 「,」 ← 标点真的进去了
* ③ input 值 = 原文(回滚) ← 又被自己撤掉了
* 若在 ② 当场处理,会被 ③ 冲掉。等这一帧的事件洪峰结束(setTimeout 0)再收口才稳。
*
* 处理方式:把输入框值**恢复成标点进来之前的快照**(若此刻值确实被标点污染了),
* 再把选中项设到 beforeinput 时算好的目标下标。
*/
function flushPending() {
if (!pendingPunct) { return; }
var info = pendingPunct;
pendingPunct = null;
if (!input) { return; }
// 恢复快照:抹掉「拦不住的标点」留下的痕迹。
// ⚠️ 只在值确实与快照不同、且**差值就是那个标点**时才动,避免抹掉用户真实输入。
var tv = input.value;
if (tv !== info.before && tv.indexOf(info.ch) >= 0) {
var tb = info.before;
if (tv.length > tb.length && tv.slice(0, tb.length) === tb) {
input.value = tb; // 标点被追加在末尾
} else if (tv.length === tb.length) {
input.value = tb; // 等长(插入后再回滚的中间态)
} else {
var idx = tv.lastIndexOf(info.ch);
if (idx >= 0) { input.value = tv.slice(0, idx) + tv.slice(idx + info.ch.length); }
}
// ⚠️ 这里**不要**重置 lastQ:值被恢复成「标点进来之前」的原文,而 lastQ 记的正是那份原文,
// 两者本来就相等 → 180ms 后的 askSuggest 会走 `q === lastQ` 直接 return,
// 不会重新请求、也就不会 showWord 把刚设好的选中清掉。重置反而会自找麻烦。
}
active = info.target;
paintWord();
}
/** 移动联想下拉的选中项(keydown 与 beforeinput 共用;dir=-1 上一条 / +1 下一条) */
function moveSelection(dir) {
if (active < 0) {
active = dir > 0 ? 0 : items.length - 1;
} else {
active = (active + dir + items.length) % items.length;
}
active = nextActive(active, dir);
paintWord();
}
/** 算出「从 base 再按 dir 一步」应落在哪个下标(items 为空时返回 -1)。
* 独立出来是为了「拦不住的标点」那条路:它要在 beforeinput 时就算好目标下标,
* 因为收口跑在宏任务里,那时 active 已被 input 的 `active = -1` 清掉了。 */
function nextActive(base, dir) {
if (!items.length) { return -1; }
if (base < 0) { return dir > 0 ? 0 : items.length - 1; }
return (base + dir + items.length) % items.length;
}
/* 鼠标划过某条建议 → 同步高亮(与 ↓/↑ 共用 active,回车即可确认) */
document.addEventListener('mouseover', function (e) {
if (!word || !e.target.closest) { return; }