/* nav-20251012.js 作者:ChatGPT(为你量身定制) 功能:按键/触摸导航 —— 支持:入口 /main/ /commitee-list/ /speech/x.html 将此文件在 /20251012/ 相关页面引入即可。 */ (function () { "use strict"; // ========== 配置 ========== const CONFIG = { // 如果没有在 URL 中检测到 8 位日期段,可以在这里硬编码入口(以 '/' 开头并以 '/' 结尾) // 例如 '/20251012/'。若保留 null 会自动从 location.pathname 中寻找第一个 8 位数字段。 FORCE_BASE: null, // '/20251012/', or null to auto-detect // 如果页面中没有可被自动发现的 speech/x.html 链接,设置一个最大页数(整数 > 0) FALLBACK_SPEECH_COUNT: 3, // 当在最后一页按 "下一页" 时的行为: 'wrap' | 'toRoot' | 'noop' ON_FINAL_NEXT: "toRoot", // 推荐 'toRoot'(回到入口)或 'wrap'(回到第一篇) // 是否使用 history.pushState 做平滑跳转(默认 true),设 false 则使用 location.href 真正跳转 USE_PUSHSTATE: true, // 显示键位提示面板(true/false) SHOW_HELP_OVERLAY: true, }; // ========== 工具函数 ========== function normalizePath(p) { if (!p) return "/"; // ensure starts with / if (!p.startsWith("/")) p = "/" + p; // ensure trailing slash for directory-like paths, except for filenames like .html if (!p.endsWith("/") && !p.match(/\.\w+$/)) p = p + "/"; return p; } function detectBaseFromPath() { if (CONFIG.FORCE_BASE) return normalizePath(CONFIG.FORCE_BASE); // find first /YYYYMMDD/ in pathname const m = location.pathname.match(/\/(\d{8})(?:\/|$)/); if (m) return `/${m[1]}/`; // fallback: try first path segment if looks like a date const segs = location.pathname.split("/").filter(Boolean); if (segs.length && /^\d{8}$/.test(segs[0])) return `/${segs[0]}/`; // last resort return "/20251012/"; } const BASE = detectBaseFromPath(); const MAIN = normalizePath(BASE + "main"); const COMMITEE = normalizePath(BASE + "commitee-list"); const SPEECH_PREFIX = normalizePath(BASE + "speech"); // like '/20251012/speech/' function isRoot(pathname) { const p = normalizePath(pathname); return ( p === normalizePath(BASE) || p === normalizePath(BASE.replace(/\/$/, "")) ); } function isMain(pathname) { return normalizePath(pathname) === MAIN; } function isCommitee(pathname) { return normalizePath(pathname) === COMMITEE; } function isSpeech(pathname) { // match /.../speech/x.html or /.../speech/x (with or without trailing slash) const m = pathname.match(/\/speech\/(\d+)(?:\.html)?(?:\/|$)/); return m ? parseInt(m[1], 10) : null; } // detect available speech pages by scanning tags in the current document function detectSpeechPagesFromLinks() { const anchors = Array.from(document.querySelectorAll("a[href]")); const nums = new Set(); const basePattern = new RegExp( "^" + SPEECH_PREFIX.replace(/\/$/, "\\/") + "(\\d+)(?:\\.html)?(?:\\/?$)" ); anchors.forEach((a) => { try { const href = a.getAttribute("href"); // convert relative -> absolute path (only pathname part) const tmp = new URL(href, location.href); const path = tmp.pathname; const m = path.match(/\/speech\/(\d+)(?:\.html)?(?:\/|$)/); if (m) nums.add(parseInt(m[1], 10)); } catch (e) { // ignore } }); if (nums.size === 0) return null; return Array.from(nums).sort((a, b) => a - b); } // Determine speech pages list const detectedSpeechList = detectSpeechPagesFromLinks(); let SPEECH_LIST = null; // array of numbers like [1,2,3] if (detectedSpeechList && detectedSpeechList.length > 0) { SPEECH_LIST = detectedSpeechList; } else { // fallback to 1..FALLBACK_SPEECH_COUNT const n = Math.max(1, Math.floor(CONFIG.FALLBACK_SPEECH_COUNT)); SPEECH_LIST = Array.from({ length: n }, (_, i) => i + 1); } function speechUrl(n) { // return '/20251012/speech/1.html' return `${SPEECH_PREFIX}${n}.html`; } function goTo(url, opts = {}) { const usePush = CONFIG.USE_PUSHSTATE && opts.push !== false; if (usePush) { try { history.pushState({}, "", url); // optionally fetch and replace content (here we just reload to keep things simple) // best practice: make this an SPA; for now do a full location.assign to ensure page loads. location.assign(url); } catch (e) { location.href = url; } } else { location.href = url; } } // ========== Navigation logic ========== function handleNext() { const pathname = location.pathname; const curSpeech = isSpeech(pathname); if (isRoot(pathname)) { // entry -> main goTo(MAIN); return; } if (isMain(pathname)) { goTo(COMMITEE); return; } if (isCommitee(pathname)) { // go to first speech (if exists) else go to first fallback const first = SPEECH_LIST[0]; goTo(speechUrl(first)); return; } if (curSpeech) { // find index in SPEECH_LIST const idx = SPEECH_LIST.indexOf(curSpeech); if (idx >= 0 && idx < SPEECH_LIST.length - 1) { goTo(speechUrl(SPEECH_LIST[idx + 1])); } else { // at final speech if (CONFIG.ON_FINAL_NEXT === "wrap") { goTo(speechUrl(SPEECH_LIST[0])); } else if (CONFIG.ON_FINAL_NEXT === "toRoot") { goTo(BASE); } else { // noop } } return; } // default fallback: go to main goTo(MAIN); } function handlePrev() { const pathname = location.pathname; const curSpeech = isSpeech(pathname); if (isRoot(pathname)) { // nothing to do return; } if (isMain(pathname)) { goTo(BASE); return; } if (isCommitee(pathname)) { goTo(MAIN); return; } if (curSpeech) { const idx = SPEECH_LIST.indexOf(curSpeech); if (idx > 0) { goTo(speechUrl(SPEECH_LIST[idx - 1])); } else { // first speech -> back to commitee-list goTo(COMMITEE); } return; } // default fallback: go to base goTo(BASE); } // ========== Key & Touch handling ========== function onKey(e) { // Normalize some keys: // Enter -> Next // ArrowRight / '>' / '.' -> Next // ArrowLeft / '<' / ',' -> Prev const k = e.key; if (k === "Enter") { e.preventDefault(); handleNext(); return; } if (k === "ArrowRight" || k === "Right") { e.preventDefault(); handleNext(); return; } if (k === "ArrowLeft" || k === "Left") { e.preventDefault(); handlePrev(); return; } // characters '>' and '<' (depending on keyboard) if (k === ">" || k === ".") { e.preventDefault(); handleNext(); return; } if (k === "<" || k === ",") { e.preventDefault(); handlePrev(); return; } // optional: 'h' toggles help overlay if (k === "h" || k === "H") { toggleHelpOverlay(); } } // Basic touch swipe (horizontal) let touchStartX = null; function onTouchStart(e) { if (e.touches && e.touches.length === 1) { touchStartX = e.touches[0].clientX; } } function onTouchEnd(e) { if (!touchStartX) return; const endX = (e.changedTouches && e.changedTouches[0].clientX) || null; if (endX === null) { touchStartX = null; return; } const dx = endX - touchStartX; const threshold = 50; // pixels if (dx > threshold) { // swipe right -> Prev handlePrev(); } else if (dx < -threshold) { // swipe left -> Next handleNext(); } touchStartX = null; } // ========== UI overlay/help ========== let overlayEl = null; function createHelpOverlay() { if (!CONFIG.SHOW_HELP_OVERLAY) return; overlayEl = document.createElement("div"); overlayEl.id = "nav-help-overlay"; overlayEl.style.position = "fixed"; overlayEl.style.right = "16px"; overlayEl.style.bottom = "16px"; overlayEl.style.zIndex = 9999; overlayEl.style.padding = "10px 12px"; overlayEl.style.borderRadius = "8px"; overlayEl.style.boxShadow = "0 6px 20px rgba(0,0,0,0.2)"; overlayEl.style.background = "rgba(255,255,255,0.95)"; overlayEl.style.fontFamily = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue"'; overlayEl.style.fontSize = "13px"; overlayEl.style.color = "#111"; overlayEl.style.border = "1px solid rgba(0,0,0,0.06)"; overlayEl.style.maxWidth = "260px"; overlayEl.style.lineHeight = "1.3"; overlayEl.innerHTML = ` 导航提示
Enter / → / '>' : 下一页
← / '<' : 上一页
H : 显示/隐藏本提示
支持触摸左右滑动(移动端) `; overlayEl.style.cursor = "pointer"; overlayEl.title = "点击隐藏/显示"; overlayEl.addEventListener("click", toggleHelpOverlay); document.body.appendChild(overlayEl); } function toggleHelpOverlay() { if (!overlayEl) { createHelpOverlay(); return; } if (overlayEl.style.display === "none") overlayEl.style.display = ""; else overlayEl.style.display = "none"; } // ========== Init ========== function init() { // Attach handlers window.addEventListener("keydown", onKey, { passive: false }); window.addEventListener("touchstart", onTouchStart, { passive: true }); window.addEventListener("touchend", onTouchEnd, { passive: true }); // pushState popstate handling: when user clicks back/forward, allow normal navigation (no extra handling needed) window.addEventListener("popstate", () => { // no-op: browser handles location; you could re-run UI update hooks here if needed }); // create help overlay if (CONFIG.SHOW_HELP_OVERLAY) createHelpOverlay(); // Add small visual helper arrows (optional) addOnScreenArrows(); } function addOnScreenArrows() { // minimal left/right arrow buttons for mouse users const left = document.createElement("button"); left.textContent = "←"; left.setAttribute("aria-label", "上一页"); styleTinyArrow(left, "left"); left.addEventListener("click", handlePrev); const right = document.createElement("button"); right.textContent = "→"; right.setAttribute("aria-label", "下一页"); styleTinyArrow(right, "right"); right.addEventListener("click", handleNext); document.body.appendChild(left); document.body.appendChild(right); } function styleTinyArrow(btn, which) { btn.style.position = "fixed"; btn.style.zIndex = 9998; btn.style.top = "50%"; btn.style.transform = "translateY(-50%)"; btn.style[which === "left" ? "left" : "right"] = "8px"; btn.style.width = "40px"; btn.style.height = "40px"; btn.style.borderRadius = "6px"; btn.style.border = "1px solid rgba(0,0,0,0.08)"; btn.style.background = "rgba(255,255,255,0.9)"; btn.style.boxShadow = "0 6px 20px rgba(0,0,0,0.08)"; btn.style.cursor = "pointer"; btn.style.fontSize = "18px"; btn.style.lineHeight = "36px"; btn.style.padding = "0"; btn.style.display = "flex"; btn.style.alignItems = "center"; btn.style.justifyContent = "center"; } // run init(); // expose for debugging window._eventNav = { BASE, MAIN, COMMITEE, SPEECH_PREFIX, SPEECH_LIST, handleNext, handlePrev, }; })();