/* ============================================================ 导航展示页脚本(v24 第 24 批 用户第 4 条) —— **从 SimpleNav 的 assets/js/app.js 整份搬运**,逻辑一行未改。 它提供:搜索引擎切换、联想词下拉(↓/↑ 与 , . 两组按键)、合集标签页切换、 站点链接打开方式(新标签页 / 当前页,由 body[data-open] 决定)。 ⚠️ 联想接口地址来自 window.SIMPLENAV.suggest(服务端按当前短语生成), 不在这个文件里写死。 ============================================================ */ /* 简航 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 }); }); })();