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:
378
20251012/scripts/nav.js
Normal file
378
20251012/scripts/nav.js
Normal 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,
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user