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.
This commit is contained in:
xiaomai
2025-10-13 08:00:15 +08:00
parent ea4ccec42d
commit eb4ca98763
12 changed files with 1166 additions and 50 deletions

378
20251012/scripts/nav.js Normal file
View File

@@ -0,0 +1,378 @@
/* 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,
};
})();