Files
dinner.tootaio.com/20251012/scripts/nav.js
xiaomai eb4ca98763 feat(presentation): add multi-page event presentation with navigation
This commit introduces a complete multi-page presentation system for the 20251012 event.

Key features include:
- **Page Structure:** Establishes the page flow with an entry point, a main cover scene, a committee list, and
individual speech video pages.
- **Real-time Chroma Key:** Implements a canvas-based green screen effect to composite a video onto a background image.
A control panel allows for real-time adjustment of keying parameters, position, and scale.
- **Unified Navigation:** A new `nav.js` script enables seamless navigation between pages using keyboard (arrow keys,
Enter), touch swipes, and on-screen buttons. The navigation logic follows a predefined sequence (entry -> main ->
committee -> speeches).
- **Dynamic Content:** The committee list page dynamically generates its content from a JavaScript data source.
2025-10-13 08:00:15 +08:00

379 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.
/* 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 <a> 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 = `
<strong>导航提示</strong><br/>
Enter / → / '>' : 下一页<br/>
← / '<' : 上一页<br/>
H : 显示/隐藏本提示<br/>
支持触摸左右滑动(移动端)
`;
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,
};
})();