背景:用户反馈「上下键和逗号句号都无法选择候选词,只能用鼠标」。逐条查下去
发现是**三个各自独立的原因**叠在一起,任何一个没修都会让人觉得「还是坏的」。
① 单组按键模式让另一组键静默失效
线上 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。
340 lines
17 KiB
JavaScript
340 lines
17 KiB
JavaScript
/* 简航 SimpleNav —— 前台交互:搜索引擎切换 + 联想下拉 + 合集标签页 */
|
||
(function () {
|
||
'use strict';
|
||
var cfg = window.SIMPLENAV || {};
|
||
|
||
/* ---------- 搜索引擎切换 ---------- */
|
||
var tabs = document.querySelectorAll('#engine-tabs .s-engine');
|
||
var form = document.getElementById('search-form');
|
||
var input = document.getElementById('search-text');
|
||
|
||
function activateEngine(el) {
|
||
tabs.forEach(function (t) { t.classList.remove('active'); });
|
||
el.classList.add('active');
|
||
var search = el.getAttribute('data-search');
|
||
var param = el.getAttribute('data-param');
|
||
var slug = el.getAttribute('data-slug');
|
||
var ph = el.getAttribute('data-placeholder') || '搜索';
|
||
if (form) { form.setAttribute('action', search); form.setAttribute('data-slug', slug); }
|
||
if (input) { input.setAttribute('name', param); input.setAttribute('placeholder', ph); }
|
||
hideWord();
|
||
// 已经输了字就顺手换一份建议(auto 模式下各引擎来源不同)
|
||
if (input && input.value.trim() !== '') { askSuggest(); }
|
||
}
|
||
tabs.forEach(function (el) {
|
||
el.addEventListener('click', function () { activateEngine(el); input && input.focus(); });
|
||
});
|
||
|
||
/* ---------- 联想下拉 ---------- */
|
||
var word = document.getElementById('word');
|
||
var timer = null;
|
||
var lastQ = null;
|
||
var seq = 0; // 请求序号:防止先发的慢响应覆盖后发的快响应
|
||
var items = []; // 当前展示的建议词
|
||
var active = -1; // 选中项下标(键盘 ↓/↑、,/. 与鼠标划过共用);-1 = 没选中
|
||
var punctByKeyDown = false; // 本次标点是否已由 keydown 处理(防 keydown+beforeinput 双触发跳两格)
|
||
var pendingPunct = null; // 「拦不住的标点」信息 {ch, dir, before}(见下),null = 无
|
||
var pendingTimer = null; // 收口定时器(去抖:组合上屏会连发两次 input)
|
||
|
||
/* 联想词的选中按键(后台「设置 → 联想词按键」):
|
||
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 / 2026-09-29):
|
||
中文输入法下按逗号/句号,上屏的常是**全角** `,`(U+FF0C) / `。`(U+3002),
|
||
而且此时 e.code **不一定**是 'Comma'/'Period'(取决于浏览器与输入法)——
|
||
早先只认半角的 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 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() {
|
||
if (word) { word.innerHTML = ''; word.style.display = 'none'; }
|
||
items = [];
|
||
active = -1;
|
||
}
|
||
|
||
function showWord(list) {
|
||
if (!word) return;
|
||
items = (list || []).slice();
|
||
active = -1; // 换了新建议就取消选中:此时回车应当用输入框原文
|
||
if (!items.length) { hideWord(); return; }
|
||
word.innerHTML = items.map(function (t) {
|
||
var d = document.createElement('li');
|
||
d.textContent = t;
|
||
return d.outerHTML;
|
||
}).join('');
|
||
word.style.display = 'block';
|
||
}
|
||
|
||
/** 把 .active 标到当前选中项上(键盘与鼠标共用这一个状态) */
|
||
function paintWord() {
|
||
if (!word) return;
|
||
Array.prototype.forEach.call(word.children, function (li, i) {
|
||
if (i === active) { li.classList.add('active'); } else { li.classList.remove('active'); }
|
||
});
|
||
}
|
||
|
||
function askSuggest() {
|
||
if (!input) return;
|
||
var q = input.value.trim();
|
||
if (q === '') { lastQ = null; hideWord(); return; }
|
||
if (q === lastQ) { return; }
|
||
lastQ = q;
|
||
var mine = ++seq;
|
||
var slug = form ? (form.getAttribute('data-slug') || '') : '';
|
||
fetch(cfg.suggest + '?slug=' + encodeURIComponent(slug) + '&q=' + encodeURIComponent(q))
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) { if (mine === seq) { showWord(data && data.items); } })
|
||
.catch(function () { if (mine === seq) { hideWord(); } });
|
||
}
|
||
|
||
if (input) {
|
||
input.addEventListener('input', function () {
|
||
clearTimeout(timer);
|
||
active = -1; // 一开始打字就取消选中:之后回车应当用输入框原文
|
||
paintWord();
|
||
timer = setTimeout(askSuggest, 180);
|
||
});
|
||
|
||
input.addEventListener('keydown', function (e) {
|
||
// ⚠️ 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;
|
||
|
||
// 移动选中项:只动高亮、不动输入框(回车时才落到输入框)。
|
||
// 用哪组键由后台「联想词按键」决定: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()) {
|
||
dir = keyPunctDir(e); // , = 上一条(同 ↑)/ . = 下一条(同 ↓),半角全角都认
|
||
isPunct = dir !== 0;
|
||
}
|
||
|
||
if (dir !== 0) {
|
||
// ⚠️ 没有下拉时**必须放行** —— 这时 , 和 . 是正常输入字符(如搜「python, pandas」)。
|
||
if (!open) { return; }
|
||
e.preventDefault(); // 别让光标跳到文本首尾、也别让页面跟着滚
|
||
moveSelection(dir);
|
||
// ⚠️ 只有**标点**才需要标闸门:同一次按键会先走 keydown、再走 beforeinput,
|
||
// 不标就会「跳两格」。方向键压根不产生 beforeinput,标了纯属副作用
|
||
// —— 反而会把紧随其后的真实标点误挡掉(两者相隔可能不足 1ms)。
|
||
if (isPunct) {
|
||
punctByKeyDown = true;
|
||
setTimeout(function () { punctByKeyDown = false; }, 0);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (e.key === 'Escape') { hideWord(); return; }
|
||
|
||
if (e.key === 'Enter') {
|
||
// 只有**用 ↓/↑ 选中**了某条建议,回车才按建议词搜索。
|
||
// 没选中就一律用输入框里的原文 —— 老实现是「回车就套用第一条建议」,
|
||
// 会把用户自己输入、而建议里没有的词悄悄换掉。
|
||
if (open && active >= 0) {
|
||
input.value = items[active];
|
||
hideWord();
|
||
e.preventDefault();
|
||
if (form) { form.submit(); }
|
||
}
|
||
// active < 0:不拦截,交给浏览器默认的表单提交(即用输入框原文搜)
|
||
return;
|
||
}
|
||
});
|
||
|
||
/* ⚠️ 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) {
|
||
var it = e.inputType;
|
||
if (it !== 'insertText' && it !== 'insertCompositionText') { return; }
|
||
if (punctByKeyDown) { return; } // keydown 已处理过本次标点,别再移一次(防跳两格)
|
||
var d = e.data;
|
||
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; }
|
||
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) {
|
||
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; }
|
||
var hovered = e.target.closest('#word li');
|
||
if (!hovered) { return; }
|
||
Array.prototype.forEach.call(word.children, function (li, i) {
|
||
if (li === hovered) { active = i; }
|
||
});
|
||
paintWord();
|
||
});
|
||
|
||
document.addEventListener('click', function (e) {
|
||
if (word && !word.contains(e.target) && e.target !== input) { hideWord(); }
|
||
var li = e.target.closest ? e.target.closest('#word li') : null;
|
||
if (li && input) {
|
||
input.value = li.textContent;
|
||
hideWord();
|
||
if (form) { form.submit(); }
|
||
}
|
||
});
|
||
|
||
/* ---------- 合集标签页 ---------- */
|
||
var collTabs = document.querySelectorAll('#coll-tabs .tab');
|
||
collTabs.forEach(function (tab) {
|
||
tab.addEventListener('click', function () {
|
||
collTabs.forEach(function (t) {
|
||
t.classList.remove('active');
|
||
t.setAttribute('aria-selected', 'false');
|
||
});
|
||
tab.classList.add('active');
|
||
tab.setAttribute('aria-selected', 'true');
|
||
document.querySelectorAll('.tab-panel').forEach(function (p) { p.classList.remove('active'); });
|
||
var panel = document.getElementById(tab.getAttribute('data-target'));
|
||
if (panel) { panel.classList.add('active'); }
|
||
});
|
||
});
|
||
|
||
/* ---------- 图标加载失败 → 退回首字母块(与 PHP 端配色一致) ---------- */
|
||
var PALETTE = ['#2F6BF7', '#5B8DEF', '#0EA5E9', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316'];
|
||
function colorOf(s) {
|
||
var h = 0;
|
||
for (var i = 0; i < s.length; i++) { h = (h * 31 + s.charCodeAt(i)) | 0; }
|
||
return PALETTE[Math.abs(h) % PALETTE.length];
|
||
}
|
||
document.querySelectorAll('.mylist img.icon').forEach(function (img) {
|
||
img.addEventListener('error', function () {
|
||
var a = img.closest('a');
|
||
if (!a) { img.style.visibility = 'hidden'; return; }
|
||
var nameEl = a.querySelector('.name');
|
||
var label = (nameEl ? nameEl.textContent : '?').trim() || '?';
|
||
var span = document.createElement('span');
|
||
span.className = 'ico-letter';
|
||
span.style.background = colorOf(label);
|
||
span.textContent = label.charAt(0);
|
||
img.parentNode.replaceChild(span, img);
|
||
}, { once: true });
|
||
});
|
||
})();
|