Files
SimpleNav/assets/js/app.js
T
laoyang 257c38e16a 修复中文输入法下 , / . 选词失效:认全角标点 + beforeinput 兜底
用户实测:切到「, 和 .」模式后,中文输入法下按逗号/句号,仍被当普通字符输进
搜索框,没能选词。根因是上一版判定只认半角 —— `e.key === ','` 或
`e.code === 'Comma'`,而中文输入法上屏的是全角「,」(U+FF0C) /「。」(U+3002),
且此时 e.code 不一定是 'Comma'/'Period'(取决于浏览器与输入法),于是漏判。

- isComma / isPeriod 改为以 e.key 的字符为主判据,半角 + 全角都认
  (全角句号另兼容全角句点 .U+FF0E),e.code 仅作辅助。
- 新增 beforeinput 兜底:字符即将插入文本框前触发,e.data 就是要插入的字符,
  是拦全角标点最可靠的一道 —— 有些输入法下标点根本不走可靠的 keydown。
  命中就 preventDefault(不插入)并移动选中项;仅「下拉打开 + punct/both」下生效,
  没有下拉时放行(逗号句号照常输入)。
- 新增 IME 组合态保护:打拼音时 keydown 带 isComposing(或 keyCode=229),
  这些按键属于输入法自己,不能当选词键拦下。
- keydown 与 beforeinput 可能对同一次按键都触发 → 加 punctByKeyDown 去重闸门,
  防止「跳两格」;抽出 moveSelection() 供两条路径共用。

测试(jsdom 59/59,arrows/punct/both + IME 专项各一遍):
- 核心③用「全角字符 + code 为空」的事件形态断言(上一版测试人为把 code 设成
  'Comma',恰好掩盖了这个 bug —— 本次一并修正测试盲区)。
- 覆盖 beforeinput 兜底、IME 组合态不误判、keydown+beforeinput 去重不跳两格。
反向验证三变体:A 回车套第一条 → 挂核心①;B 判定改回只认半角 → 挂核心③全角;
C 删 beforeinput 兜底 → 挂兜底断言。三者均精确命中对应断言。
e2e 128/128;线上部署 0 FAIL(82 条静态断言);线上几何 25/25;直连核验 app.js 就位。
2026-09-28 21:01:22 +08:00

242 lines
11 KiB
JavaScript
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.
/* 简航 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 双触发跳两格)
/* 联想词的选中按键(后台「设置 → 联想词按键」):
arrows(默认,等同改造前)/ punct / both */
var keyMode = cfg.suggestKeys || 'arrows';
function acceptsArrows() { return keyMode === 'arrows' || keyMode === 'both'; }
function acceptsPunct() { return keyMode === 'punct' || keyMode === 'both'; }
/* ⚠️ 中文输入法的标点坑(真实用户反馈,2026-09-28):
中文输入法下按逗号/句号,上屏的是**全角** `,`(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';
}
function isPeriod(e) {
return e.key === '.' || e.key === '\u3002' || e.key === '\uFF0E' || e.code === 'Period';
}
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,下面的 , / . 选词才生效。
if (e.isComposing || e.keyCode === 229) { return; }
var open = !!word && word.style.display === 'block' && items.length > 0;
// 移动选中项:只动高亮、不动输入框(回车时才落到输入框)。
// 用哪组键由后台「联想词按键」决定:arrows = ↓/↑ / punct = , 和 . / both = 都行。
var dir = 0;
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; // . = 下一条(同 ↓)
}
if (dir !== 0) {
// ⚠️ 没有下拉时**必须放行** —— 这时 , 和 . 是正常输入字符(如搜「python, pandas」)。
if (!open) { return; }
e.preventDefault(); // 别让光标跳到文本首尾、也别让页面跟着滚
moveSelection(dir);
// 同一次按键 keydown 已经处理过标点 → 标记一下,让紧随其后的 beforeinput 别再移一次,
// 否则会「跳两格」(keydown 与 beforeinput 的真实触发组合因浏览器/输入法而异,jsdom 测不全)。
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 也可能不是 'Comma'/'Period' → 漏判;
· 或者根本在 isComposing 组合态里被上面那道保护挡掉 → 也漏判。
beforeinput 在**字符即将插入文本框之前**触发,e.data 就是要插入的字符,
是拦全角标点最可靠的一道。命中就 preventDefault(不插入)并移动选中项。
只在「下拉打开 + punct/both 模式」下生效;没有下拉时不拦,逗号句号照常输入。 */
input.addEventListener('beforeinput', function (e) {
if (e.inputType !== 'insertText') { return; }
if (punctByKeyDown) { return; } // keydown 已处理过本次标点,别再移一次(防跳两格)
var d = e.data;
if (d === null || d === undefined) { 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); }
});
}
/** 移动联想下拉的选中项(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;
}
paintWord();
}
/* 鼠标划过某条建议 → 同步高亮(与 ↓/↑ 共用 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 });
});
})();