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.
379 lines
11 KiB
JavaScript
379 lines
11 KiB
JavaScript
/* 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,
|
||
};
|
||
})();
|