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:
327
20251012/commitee-list/background.js
Normal file
327
20251012/commitee-list/background.js
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
(function () {
|
||||||
|
// --- DOM & 元素 ---
|
||||||
|
const video = document.getElementById("video");
|
||||||
|
const mainCanvas = document.getElementById("mainCanvas");
|
||||||
|
const ctxMain = mainCanvas.getContext("2d", { alpha: true });
|
||||||
|
|
||||||
|
// temp canvas 用于视频帧处理
|
||||||
|
const tempCanvas = document.createElement("canvas");
|
||||||
|
const ctxTemp = tempCanvas.getContext("2d");
|
||||||
|
|
||||||
|
// 控件
|
||||||
|
const controlsPanel = document.getElementById("controlsPanel");
|
||||||
|
const closePanel = document.getElementById("closePanel");
|
||||||
|
|
||||||
|
const thresholdInput = document.getElementById("threshold");
|
||||||
|
const softnessInput = document.getElementById("softness");
|
||||||
|
const leftMarginInput = document.getElementById("leftMargin");
|
||||||
|
const topOffsetInput = document.getElementById("topOffset");
|
||||||
|
const scaleInput = document.getElementById("scale");
|
||||||
|
const rotationInput = document.getElementById("rotation");
|
||||||
|
const opacityInput = document.getElementById("opacity");
|
||||||
|
|
||||||
|
// labels
|
||||||
|
const thVal = document.getElementById("thVal");
|
||||||
|
const sfVal = document.getElementById("sfVal");
|
||||||
|
const marginVal = document.getElementById("marginVal");
|
||||||
|
const topVal = document.getElementById("topVal");
|
||||||
|
const scaleVal = document.getElementById("scaleVal");
|
||||||
|
const rotVal = document.getElementById("rotVal");
|
||||||
|
const opaVal = document.getElementById("opaVal");
|
||||||
|
|
||||||
|
// reset / help
|
||||||
|
const resetBtn = document.getElementById("resetBtn");
|
||||||
|
const applyHint = document.getElementById("applyHint");
|
||||||
|
|
||||||
|
// 默认参数
|
||||||
|
const defaults = {
|
||||||
|
threshold: 30,
|
||||||
|
softness: 10,
|
||||||
|
// leftMargin: 0,
|
||||||
|
// topOffset: -200,
|
||||||
|
// scalePercent: 125
|
||||||
|
leftMargin: 0,
|
||||||
|
topOffset: -200,
|
||||||
|
scalePercent: 125,
|
||||||
|
rotationDeg: 0,
|
||||||
|
opacityPercent: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当前参数(init to defaults)
|
||||||
|
let THRESHOLD = defaults.threshold;
|
||||||
|
let SOFTNESS = defaults.softness;
|
||||||
|
let LEFT_MARGIN = defaults.leftMargin;
|
||||||
|
let TOP_OFFSET = defaults.topOffset;
|
||||||
|
let SCALE = defaults.scalePercent / 100;
|
||||||
|
let ROTATION = defaults.rotationDeg;
|
||||||
|
let OPACITY = defaults.opacityPercent / 100;
|
||||||
|
|
||||||
|
// 工具函数:同步控制值到 UI
|
||||||
|
function syncUI() {
|
||||||
|
thresholdInput.value = THRESHOLD;
|
||||||
|
softnessInput.value = SOFTNESS;
|
||||||
|
leftMarginInput.value = LEFT_MARGIN;
|
||||||
|
topOffsetInput.value = TOP_OFFSET;
|
||||||
|
scaleInput.value = Math.round(SCALE * 100);
|
||||||
|
rotationInput.value = ROTATION;
|
||||||
|
opacityInput.value = Math.round(OPACITY * 100);
|
||||||
|
|
||||||
|
thVal.textContent = THRESHOLD;
|
||||||
|
sfVal.textContent = SOFTNESS;
|
||||||
|
marginVal.textContent = LEFT_MARGIN;
|
||||||
|
topVal.textContent = TOP_OFFSET;
|
||||||
|
scaleVal.textContent = Math.round(SCALE * 100);
|
||||||
|
rotVal.textContent = ROTATION;
|
||||||
|
opaVal.textContent = Math.round(OPACITY * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化 UI
|
||||||
|
syncUI();
|
||||||
|
|
||||||
|
// 事件监听:控件变化
|
||||||
|
thresholdInput.addEventListener("input", (e) => {
|
||||||
|
THRESHOLD = +e.target.value;
|
||||||
|
thVal.textContent = THRESHOLD;
|
||||||
|
});
|
||||||
|
softnessInput.addEventListener("input", (e) => {
|
||||||
|
SOFTNESS = +e.target.value;
|
||||||
|
sfVal.textContent = SOFTNESS;
|
||||||
|
});
|
||||||
|
leftMarginInput.addEventListener("input", (e) => {
|
||||||
|
LEFT_MARGIN = +e.target.value;
|
||||||
|
marginVal.textContent = LEFT_MARGIN;
|
||||||
|
});
|
||||||
|
topOffsetInput.addEventListener("input", (e) => {
|
||||||
|
TOP_OFFSET = +e.target.value;
|
||||||
|
topVal.textContent = TOP_OFFSET;
|
||||||
|
});
|
||||||
|
scaleInput.addEventListener("input", (e) => {
|
||||||
|
SCALE = +e.target.value / 100;
|
||||||
|
scaleVal.textContent = Math.round(SCALE * 100);
|
||||||
|
});
|
||||||
|
rotationInput.addEventListener("input", (e) => {
|
||||||
|
ROTATION = +e.target.value;
|
||||||
|
rotVal.textContent = ROTATION;
|
||||||
|
});
|
||||||
|
opacityInput.addEventListener("input", (e) => {
|
||||||
|
OPACITY = +e.target.value / 100;
|
||||||
|
opaVal.textContent = Math.round(OPACITY * 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
function resetParams() {
|
||||||
|
THRESHOLD = defaults.threshold;
|
||||||
|
SOFTNESS = defaults.softness;
|
||||||
|
LEFT_MARGIN = defaults.leftMargin;
|
||||||
|
TOP_OFFSET = defaults.topOffset;
|
||||||
|
SCALE = defaults.scalePercent / 100;
|
||||||
|
ROTATION = defaults.rotationDeg;
|
||||||
|
OPACITY = defaults.opacityPercent / 100;
|
||||||
|
syncUI();
|
||||||
|
}
|
||||||
|
resetBtn.addEventListener("click", resetParams);
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key.toLowerCase() === "r") {
|
||||||
|
resetParams();
|
||||||
|
}
|
||||||
|
if (e.key.toLowerCase() === "c") {
|
||||||
|
toggleControls();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
applyHint.addEventListener("click", () => {
|
||||||
|
alert(
|
||||||
|
"提示:\n• 若画面卡顿,请降低“缩放”或在代码中把 tempCanvas 缩小(注释内说明)。\n• 跨域资源需要 CORS。"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 控件开关(可关闭/打开)
|
||||||
|
function hideControls() {
|
||||||
|
controlsPanel.style.display = "none";
|
||||||
|
}
|
||||||
|
function showControls() {
|
||||||
|
controlsPanel.style.display = "block";
|
||||||
|
}
|
||||||
|
function toggleControls() {
|
||||||
|
if (
|
||||||
|
controlsPanel.style.display === "none" ||
|
||||||
|
getComputedStyle(controlsPanel).display === "none"
|
||||||
|
)
|
||||||
|
showControls();
|
||||||
|
else hideControls();
|
||||||
|
}
|
||||||
|
closePanel.addEventListener("click", hideControls);
|
||||||
|
|
||||||
|
// 载入背景图(使用背景图做最终合成;如果你希望使用 CSS 背景而不是图片文件,可改这里)
|
||||||
|
const bgImg = new Image();
|
||||||
|
bgImg.src = "../assets/background.png";
|
||||||
|
bgImg.onload = () => {
|
||||||
|
// 等背景加载后设置主 canvas 大小
|
||||||
|
const w = bgImg.naturalWidth || 1080;
|
||||||
|
const h = bgImg.naturalHeight || Math.floor((w * 9) / 16);
|
||||||
|
mainCanvas.width = w;
|
||||||
|
mainCanvas.height = h;
|
||||||
|
mainCanvas.style.width = "100%";
|
||||||
|
mainCanvas.style.height = "auto";
|
||||||
|
// tempCanvas 尺寸以视频为准(或缩放以提高性能)
|
||||||
|
const vW = video.videoWidth || 720;
|
||||||
|
const vH = video.videoHeight || 1280;
|
||||||
|
// 性能提示:如果你的原始视频非常大,建议把 tempCanvas 设为更小的尺寸(比如 /2 或 /3)
|
||||||
|
tempCanvas.width = vW;
|
||||||
|
tempCanvas.height = vH;
|
||||||
|
|
||||||
|
// 如果 video 元数据还没准备好,等待 loadedmetadata 事件
|
||||||
|
if (video.readyState < 1) {
|
||||||
|
video.addEventListener(
|
||||||
|
"loadedmetadata",
|
||||||
|
() => {
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
bgImg.onerror = () => {
|
||||||
|
console.error("背景图片加载失败,请检查 background.png 路径与 CORS 设置。");
|
||||||
|
// 给 canvas 一个安全的大小以便继续
|
||||||
|
mainCanvas.width = 960;
|
||||||
|
mainCanvas.height = 540;
|
||||||
|
tempCanvas.width = 720;
|
||||||
|
tempCanvas.height = 1280;
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 主渲染循环
|
||||||
|
let lastTime = performance.now();
|
||||||
|
function loop(now) {
|
||||||
|
// 绘制背景
|
||||||
|
ctxMain.clearRect(0, 0, mainCanvas.width, mainCanvas.height);
|
||||||
|
if (bgImg.complete && bgImg.naturalWidth) {
|
||||||
|
ctxMain.drawImage(bgImg, 0, 0, mainCanvas.width, mainCanvas.height);
|
||||||
|
} else {
|
||||||
|
// fallback background
|
||||||
|
ctxMain.fillStyle = "#222";
|
||||||
|
ctxMain.fillRect(0, 0, mainCanvas.width, mainCanvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有当视频可用时继续
|
||||||
|
if (!video.paused && !video.ended && video.readyState >= 2) {
|
||||||
|
// 将视频帧绘制到 tempCanvas(此处使用视频自身分辨率)
|
||||||
|
ctxTemp.clearRect(0, 0, tempCanvas.width, tempCanvas.height);
|
||||||
|
ctxTemp.drawImage(video, 0, 0, tempCanvas.width, tempCanvas.height);
|
||||||
|
|
||||||
|
// 处理像素(抠像)
|
||||||
|
let processed = false;
|
||||||
|
try {
|
||||||
|
const img = ctxTemp.getImageData(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
tempCanvas.width,
|
||||||
|
tempCanvas.height
|
||||||
|
);
|
||||||
|
const data = img.data;
|
||||||
|
const len = data.length;
|
||||||
|
const threshold = THRESHOLD;
|
||||||
|
const softness = Math.max(1, SOFTNESS);
|
||||||
|
|
||||||
|
// // 基于平均亮度的简单抠像:黑色 -> 透明
|
||||||
|
// for (let i = 0; i < len; i += 4) {
|
||||||
|
// const r = data[i],
|
||||||
|
// g = data[i + 1],
|
||||||
|
// b = data[i + 2];
|
||||||
|
// const lum = (r + g + b) / 3;
|
||||||
|
// if (lum < threshold) {
|
||||||
|
// data[i + 3] = 0;
|
||||||
|
// } else if (lum < threshold + softness) {
|
||||||
|
// const t = (lum - threshold) / softness;
|
||||||
|
// data[i + 3] = Math.round(255 * t);
|
||||||
|
// } // else keep alpha
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 基于绿色背景的抠像(Green Screen / Chroma Key)
|
||||||
|
for (let i = 0; i < len; i += 4) {
|
||||||
|
const r = data[i];
|
||||||
|
const g = data[i + 1];
|
||||||
|
const b = data[i + 2];
|
||||||
|
|
||||||
|
// “绿色程度”与“非绿色程度”的差距
|
||||||
|
const greenDiff = g - Math.max(r, b);
|
||||||
|
|
||||||
|
// 以 threshold / softness 控制去除强度和平滑边缘
|
||||||
|
if (greenDiff > threshold) {
|
||||||
|
data[i + 3] = 0; // 透明
|
||||||
|
} else if (greenDiff > threshold - softness) {
|
||||||
|
const t = (threshold - greenDiff) / softness;
|
||||||
|
data[i + 3] = Math.round(255 * t);
|
||||||
|
} // 其他保持原样
|
||||||
|
}
|
||||||
|
|
||||||
|
ctxTemp.putImageData(img, 0, 0);
|
||||||
|
processed = true;
|
||||||
|
} catch (err) {
|
||||||
|
// 可能因为跨域导致 SecurityError — 降级处理:不处理像素,直接绘制视频帧(但看不到透明)
|
||||||
|
if (err && err.name === "SecurityError") {
|
||||||
|
// 记录一次性警告到控制台
|
||||||
|
if (!window._crossOriginWarned) {
|
||||||
|
console.warn(
|
||||||
|
'无法读取像素(可能为跨域资源),请确认 video/background 的 CORS 设置(use crossorigin="anonymous" + Access-Control-Allow-Origin)。'
|
||||||
|
);
|
||||||
|
window._crossOriginWarned = true;
|
||||||
|
}
|
||||||
|
processed = false;
|
||||||
|
} else {
|
||||||
|
console.error("处理像素时发生错误:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把(处理后或原始的)tempCanvas 绘制到主 canvas
|
||||||
|
// 计算目标大小(以主 canvas 的高度比例为基准)
|
||||||
|
const desiredHeightRatio = 0.9; // 视频占主画布高度的比例(可改)
|
||||||
|
const destH = Math.floor(mainCanvas.height * desiredHeightRatio);
|
||||||
|
const baseScale = destH / tempCanvas.height;
|
||||||
|
const destW = Math.floor(tempCanvas.width * baseScale);
|
||||||
|
const destX = LEFT_MARGIN;
|
||||||
|
const destY = Math.floor((mainCanvas.height - destH) / 2) + TOP_OFFSET;
|
||||||
|
|
||||||
|
// 应用用户控制的缩放、旋转、不透明度
|
||||||
|
const scaledW = destW * SCALE;
|
||||||
|
const scaledH = destH * SCALE;
|
||||||
|
const centerX = destX + scaledW / 2;
|
||||||
|
const centerY = destY + scaledH / 2;
|
||||||
|
const rotRad = (ROTATION * Math.PI) / 180;
|
||||||
|
|
||||||
|
ctxMain.save();
|
||||||
|
ctxMain.globalAlpha = OPACITY;
|
||||||
|
|
||||||
|
// translate -> rotate -> draw centered
|
||||||
|
ctxMain.translate(centerX, centerY);
|
||||||
|
ctxMain.rotate(rotRad);
|
||||||
|
// drawImage 参数:绘制 tempCanvas 的内容到以中心为原点的位置
|
||||||
|
ctxMain.drawImage(
|
||||||
|
tempCanvas,
|
||||||
|
-scaledW / 2,
|
||||||
|
-scaledH / 2,
|
||||||
|
scaledW,
|
||||||
|
scaledH
|
||||||
|
);
|
||||||
|
|
||||||
|
ctxMain.restore();
|
||||||
|
|
||||||
|
// 如果你想在无法处理像素(跨域)时仍然保持 alpha,必须确保视频资源启用 CORS 和服务器返回允许头
|
||||||
|
// 可在这里根据 processed 变量显示调试信息(未开启)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下一帧
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动播放策略:尝试播放视频(静音大多数浏览器允许)
|
||||||
|
video.play().catch(() => {
|
||||||
|
/* 静音应该可以自动播放,否则需用户交互触发 */
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始 UI 状态
|
||||||
|
syncUI();
|
||||||
|
})();
|
||||||
86
20251012/commitee-list/commitee.js
Normal file
86
20251012/commitee-list/commitee.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// 🎯 筹委会主要名单(简体)
|
||||||
|
const committeeData = [
|
||||||
|
{
|
||||||
|
role: "主席",
|
||||||
|
name: "黄祖全(小龙)",
|
||||||
|
subRole: "副主席",
|
||||||
|
subName: "周秉聪",
|
||||||
|
},
|
||||||
|
{ role: "总务", name: "江德景", subRole: "副总务", subName: "林泓明" },
|
||||||
|
{ role: "文书", name: "苏俊文", subRole: "副文书", subName: "谢进勇" },
|
||||||
|
{ role: "财政", name: "陈祖平", subRole: "副财政", subName: "郑礼靖" },
|
||||||
|
{ role: "查账", name: "黄杨顺" },
|
||||||
|
{ role: "交通", name: "胡千鸿", subRole: "副交通", subName: "颜楷庆" },
|
||||||
|
{ role: "交际", name: "黄伟汉", subRole: "副交际", subName: "陈俊宇" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 🎯 工委名单
|
||||||
|
const members = [
|
||||||
|
"蓝皓贤",
|
||||||
|
"黄寿琪",
|
||||||
|
"赵汶德",
|
||||||
|
"Dinesh",
|
||||||
|
"李善业",
|
||||||
|
"郑杰瑞",
|
||||||
|
"颜俊宇",
|
||||||
|
"黄靖杰",
|
||||||
|
"潘俊轩",
|
||||||
|
"张祥明",
|
||||||
|
"黄升文",
|
||||||
|
"陈照明",
|
||||||
|
"赵汶德",
|
||||||
|
"陈世勇",
|
||||||
|
"王畯杰",
|
||||||
|
"黄伟宏",
|
||||||
|
"杜恩浩",
|
||||||
|
"林育森",
|
||||||
|
"陈仲旺",
|
||||||
|
"刘伟杰",
|
||||||
|
"刘伟鸿",
|
||||||
|
"林鸿旺",
|
||||||
|
];
|
||||||
|
|
||||||
|
const body = document.getElementById("committeeBody");
|
||||||
|
const membersGrid = document.getElementById("committeeMembers");
|
||||||
|
|
||||||
|
// 🧩 动态生成主要职位(四列)
|
||||||
|
committeeData.forEach((item) => {
|
||||||
|
// 正职
|
||||||
|
const roleEl = document.createElement("div");
|
||||||
|
roleEl.className = "text-yellow-300 font-semibold text-right pr-2";
|
||||||
|
roleEl.textContent = item.role;
|
||||||
|
body.appendChild(roleEl);
|
||||||
|
|
||||||
|
const nameEl = document.createElement("div");
|
||||||
|
nameEl.className = "text-left";
|
||||||
|
nameEl.textContent = item.name;
|
||||||
|
body.appendChild(nameEl);
|
||||||
|
|
||||||
|
// 副职(若存在)
|
||||||
|
if (item.subRole && item.subName) {
|
||||||
|
const subRoleEl = document.createElement("div");
|
||||||
|
subRoleEl.className = "text-yellow-300 font-semibold text-right pr-2";
|
||||||
|
subRoleEl.textContent = item.subRole;
|
||||||
|
body.appendChild(subRoleEl);
|
||||||
|
|
||||||
|
const subNameEl = document.createElement("div");
|
||||||
|
subNameEl.className = "text-left";
|
||||||
|
subNameEl.textContent = item.subName;
|
||||||
|
body.appendChild(subNameEl);
|
||||||
|
} else {
|
||||||
|
// 没有副职则补空格,保持四列对齐
|
||||||
|
const empty1 = document.createElement("div");
|
||||||
|
empty1.textContent = "";
|
||||||
|
const empty2 = document.createElement("div");
|
||||||
|
empty2.textContent = "";
|
||||||
|
body.appendChild(empty1);
|
||||||
|
body.appendChild(empty2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🧩 工委名单生成(六列)
|
||||||
|
members.forEach((name) => {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = name;
|
||||||
|
membersGrid.appendChild(div);
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>Canvas 实时抠像(可控面板)</title>
|
<title>Canvas 实时抠像(可控面板)</title>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||||
|
<script src="../scripts/nav.js"></script>
|
||||||
<style type="text/tailwindcss">
|
<style type="text/tailwindcss">
|
||||||
@theme {
|
@theme {
|
||||||
/* 🎨 定义自定义字体族变量 */
|
/* 🎨 定义自定义字体族变量 */
|
||||||
@@ -41,64 +42,58 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen flex items-center justify-center bg-black text-white">
|
<body
|
||||||
<div class="stage relative w-full h-screen overflow-hidden">
|
class="min-h-screen flex items-center justify-center bg-black text-white"
|
||||||
<canvas id="mainCanvas" class="block w-full h-auto"></canvas>
|
>
|
||||||
|
<div class="stage relative w-full h-screen overflow-hidden">
|
||||||
|
<canvas id="mainCanvas" class="block w-full h-auto"></canvas>
|
||||||
|
|
||||||
<!-- 标题 -->
|
<!-- 标题 -->
|
||||||
<h1 class="absolute top-4 text-center w-full font-bai-ge text-8xl text-yellow-300">
|
<h1
|
||||||
永平赵子龙庙
|
class="absolute top-4 text-center w-full font-bai-ge text-8xl text-yellow-300"
|
||||||
</h1>
|
|
||||||
|
|
||||||
<!-- 副标题描边层 -->
|
|
||||||
<h2
|
|
||||||
class="absolute top-28 w-full text-center font-bai-ge text-6xl text-transparent [-webkit-text-stroke:4px_yellow]"
|
|
||||||
>
|
|
||||||
庆祝赵子龙元帅暨众神圣诞千秋
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<!-- 副标题实色层 -->
|
|
||||||
<h2 class="absolute top-28 text-center w-full font-bai-ge text-6xl text-red-500">
|
|
||||||
庆祝赵子龙元帅暨众神圣诞千秋
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<!-- 四句诗边框容器 -->
|
|
||||||
<div
|
|
||||||
class="absolute inset-0 flex items-center justify-between px-32 pointer-events-none"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
class="font-gu-huang text-7xl leading-loose text-center text-transparent
|
|
||||||
[writing-mode:vertical-lr] [text-orientation:upright]
|
|
||||||
[-webkit-text-stroke:8px_black]"
|
|
||||||
>
|
>
|
||||||
血染征袍透甲红<br>当阳谁敢与争锋
|
永平赵子龙庙
|
||||||
</p>
|
</h1>
|
||||||
|
|
||||||
<p
|
<!-- 副标题描边层 -->
|
||||||
class="font-gu-huang text-7xl leading-loose text-center text-transparent
|
<h2
|
||||||
[writing-mode:vertical-rl] [text-orientation:upright]
|
class="absolute top-28 w-full text-center font-bai-ge text-6xl text-transparent [-webkit-text-stroke:4px_yellow]"
|
||||||
[-webkit-text-stroke:8px_black]"
|
|
||||||
>
|
>
|
||||||
古来冲锋扶危主<br>唯有常山赵子龙
|
庆祝赵子龙元帅暨众神圣诞千秋
|
||||||
</p>
|
</h2>
|
||||||
|
|
||||||
<!-- 四句诗容器 -->
|
<!-- 副标题实色层 -->
|
||||||
<div
|
<h2
|
||||||
class="absolute inset-0 flex items-center justify-between px-32 pointer-events-none"
|
class="absolute top-28 text-center w-full font-bai-ge text-6xl text-red-500"
|
||||||
>
|
|
||||||
<p
|
|
||||||
class="font-gu-huang text-7xl leading-loose text-center text-white
|
|
||||||
[writing-mode:vertical-lr] [text-orientation:upright]"
|
|
||||||
>
|
>
|
||||||
血染征袍透甲红<br>当阳谁敢与争锋
|
庆祝赵子龙元帅暨众神圣诞千秋
|
||||||
</p>
|
</h2>
|
||||||
|
|
||||||
<p
|
<!-- 筹委会名单(右侧) -->
|
||||||
class="font-gu-huang text-7xl leading-loose text-center text-white
|
<div
|
||||||
[writing-mode:vertical-rl] [text-orientation:upright]"
|
id="committeeTable"
|
||||||
|
class="absolute right-8 top-48 bg-white/20 text-black rounded-3xl shadow-2xl backdrop-blur-xl p-6 font-tang-ying w-[1100px] border border-yellow-400/40"
|
||||||
>
|
>
|
||||||
古来冲锋扶危主<br>唯有常山赵子龙
|
<h3
|
||||||
</p>
|
class="text-6xl text-yellow-400 font-bai-ge mb-6 text-center drop-shadow-[0_0_8px_rgba(255,255,0,0.5)]"
|
||||||
|
>
|
||||||
|
筹委会名单
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div id="committeeBody" class="grid grid-cols-4 text-4xl leading-relaxed"></div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<h4
|
||||||
|
class="text-4xl text-yellow-300 font-bai-ge mb-3 text-center drop-shadow-[0_0_6px_rgba(255,255,0,0.4)]"
|
||||||
|
>
|
||||||
|
工委
|
||||||
|
</h4>
|
||||||
|
<div
|
||||||
|
id="committeeMembers"
|
||||||
|
class="grid grid-cols-6 gap-x-4 gap-y-2 text-3xl leading-relaxed text-center"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 来源资源:替换为你自己的文件,若跨域请加 crossorigin="anonymous" 并确保服务器允许 CORS -->
|
<!-- 来源资源:替换为你自己的文件,若跨域请加 crossorigin="anonymous" 并确保服务器允许 CORS -->
|
||||||
<video
|
<video
|
||||||
@@ -168,6 +163,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="commitee.js"></script>
|
||||||
<script src="background.js"></script>
|
<script src="background.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
124
20251012/index.html
Normal file
124
20251012/index.html
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Canvas 实时抠像(可控面板)</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||||
|
<script src="scripts/nav.js"></script>
|
||||||
|
<style type="text/tailwindcss">
|
||||||
|
@theme {
|
||||||
|
--font-bai-ge: "BaiGeTianXing", sans-serif;
|
||||||
|
--font-gu-huang: "ShangShouGuHuang", sans-serif;
|
||||||
|
--font-tang-ying: "YeZiTangYingHei", sans-serif;
|
||||||
|
|
||||||
|
--color-default: #4f46e5;
|
||||||
|
--color-success: #10b981;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-danger: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "BaiGeTianXing";
|
||||||
|
src: url("fonts/字魂白鸽天行体.ttf") format("truetype");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "ShangShouGuHuang";
|
||||||
|
src: url("fonts/ShangShouGuHuangTi-2.ttf") format("truetype");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "YeZiTangYingHei";
|
||||||
|
src: url("fonts/YeZiGongChangTangYingHei-2.ttf") format("truetype");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas#mainCanvas {
|
||||||
|
image-rendering: optimizeQuality;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✨ 按钮样式 */
|
||||||
|
.nav-button {
|
||||||
|
@apply px-12 py-6 rounded-2xl text-4xl font-bai-ge transition-all duration-300 shadow-lg;
|
||||||
|
background: linear-gradient(to right, #f59e0b, #facc15);
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.nav-button:hover {
|
||||||
|
@apply scale-105 shadow-2xl;
|
||||||
|
background: linear-gradient(to right, #fde68a, #fbbf24);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body
|
||||||
|
class="min-h-screen flex items-center justify-center bg-black text-white"
|
||||||
|
>
|
||||||
|
<div class="stage relative w-full h-screen overflow-hidden">
|
||||||
|
<canvas id="mainCanvas" class="block w-full h-auto"></canvas>
|
||||||
|
|
||||||
|
<img
|
||||||
|
src="assets/background.png"
|
||||||
|
alt="Background"
|
||||||
|
class="absolute top-0 left-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 标题 -->
|
||||||
|
<h1
|
||||||
|
class="absolute top-4 text-center w-full font-bai-ge text-8xl text-yellow-300"
|
||||||
|
>
|
||||||
|
永平赵子龙庙
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- 副标题描边层 -->
|
||||||
|
<h2
|
||||||
|
class="absolute top-28 w-full text-center font-bai-ge text-6xl text-transparent [-webkit-text-stroke:4px_yellow]"
|
||||||
|
>
|
||||||
|
庆祝赵子龙元帅暨众神圣诞千秋
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- 副标题实色层 -->
|
||||||
|
<h2
|
||||||
|
class="absolute top-28 text-center w-full font-bai-ge text-6xl text-red-500"
|
||||||
|
>
|
||||||
|
庆祝赵子龙元帅暨众神圣诞千秋
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- 按钮居中区 -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex flex-col items-center justify-center gap-16"
|
||||||
|
>
|
||||||
|
<button class="nav-button" onclick="goFullscreenAndRedirect('./main/')">
|
||||||
|
🏮 封面图展示
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="nav-button"
|
||||||
|
onclick="goFullscreenAndRedirect('./commitee-list/')"
|
||||||
|
>
|
||||||
|
👥 筹委会名单
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 🌕 点击按钮 → 请求全屏 → 跳转
|
||||||
|
function goFullscreenAndRedirect(url) {
|
||||||
|
const docEl = document.documentElement;
|
||||||
|
if (docEl.requestFullscreen) {
|
||||||
|
docEl.requestFullscreen();
|
||||||
|
} else if (docEl.webkitRequestFullscreen) {
|
||||||
|
docEl.webkitRequestFullscreen();
|
||||||
|
} else if (docEl.msRequestFullscreen) {
|
||||||
|
docEl.msRequestFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = url;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
20251012/main.css
Normal file
1
20251012/main.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
175
20251012/main/index.html
Normal file
175
20251012/main/index.html
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Canvas 实时抠像(可控面板)</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||||
|
<script src="../scripts/nav.js"></script>
|
||||||
|
<style type="text/tailwindcss">
|
||||||
|
@theme {
|
||||||
|
/* 🎨 定义自定义字体族变量 */
|
||||||
|
--font-bai-ge: "BaiGeTianXing", sans-serif;
|
||||||
|
--font-gu-huang: "ShangShouGuHuang", sans-serif;
|
||||||
|
--font-tang-ying: "YeZiTangYingHei", sans-serif;
|
||||||
|
|
||||||
|
--color-default: #4f46e5;
|
||||||
|
--color-success: #10b981;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-danger: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "BaiGeTianXing";
|
||||||
|
src: url("../fonts/字魂白鸽天行体.ttf") format("truetype");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "ShangShouGuHuang";
|
||||||
|
src: url("../fonts/ShangShouGuHuangTi-2.ttf") format("truetype");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "YeZiTangYingHei";
|
||||||
|
src: url("../fonts/YeZiGongChangTangYingHei-2.ttf") format("truetype");
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas#mainCanvas {
|
||||||
|
image-rendering: optimizeQuality;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
class="min-h-screen flex items-center justify-center bg-black text-white"
|
||||||
|
>
|
||||||
|
<div class="stage relative w-full h-screen overflow-hidden">
|
||||||
|
<canvas id="mainCanvas" class="block w-full h-auto"></canvas>
|
||||||
|
|
||||||
|
<!-- 标题 -->
|
||||||
|
<h1
|
||||||
|
class="absolute top-4 text-center w-full font-bai-ge text-8xl text-yellow-300"
|
||||||
|
>
|
||||||
|
永平赵子龙庙
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- 副标题描边层 -->
|
||||||
|
<h2
|
||||||
|
class="absolute top-28 w-full text-center font-bai-ge text-6xl text-transparent [-webkit-text-stroke:4px_yellow]"
|
||||||
|
>
|
||||||
|
庆祝赵子龙元帅暨众神圣诞千秋
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- 副标题实色层 -->
|
||||||
|
<h2
|
||||||
|
class="absolute top-28 text-center w-full font-bai-ge text-6xl text-red-500"
|
||||||
|
>
|
||||||
|
庆祝赵子龙元帅暨众神圣诞千秋
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- 四句诗边框容器 -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex items-center justify-between px-32 pointer-events-none"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="font-gu-huang text-7xl leading-loose text-center text-transparent [writing-mode:vertical-lr] [text-orientation:upright] [-webkit-text-stroke:8px_black]"
|
||||||
|
>
|
||||||
|
血染征袍透甲红<br />当阳谁敢与争锋
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="font-gu-huang text-7xl leading-loose text-center text-transparent [writing-mode:vertical-rl] [text-orientation:upright] [-webkit-text-stroke:8px_black]"
|
||||||
|
>
|
||||||
|
古来冲锋扶危主<br />唯有常山赵子龙
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- 四句诗容器 -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex items-center justify-between px-32 pointer-events-none"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="font-gu-huang text-7xl leading-loose text-center text-white [writing-mode:vertical-lr] [text-orientation:upright]"
|
||||||
|
>
|
||||||
|
血染征袍透甲红<br />当阳谁敢与争锋
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="font-gu-huang text-7xl leading-loose text-center text-white [writing-mode:vertical-rl] [text-orientation:upright]"
|
||||||
|
>
|
||||||
|
古来冲锋扶危主<br />唯有常山赵子龙
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 来源资源:替换为你自己的文件,若跨域请加 crossorigin="anonymous" 并确保服务器允许 CORS -->
|
||||||
|
<video
|
||||||
|
id="video"
|
||||||
|
class="hidden"
|
||||||
|
playsinline
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
autoplay
|
||||||
|
src="../assets/赵云绿幕.mp4"
|
||||||
|
></video>
|
||||||
|
|
||||||
|
<!-- Controls 面板(可关闭) -->
|
||||||
|
<div
|
||||||
|
class="absolute right-2 top-2 w-[320px] bg-white p-2 rounded-md hidden"
|
||||||
|
id="controlsPanel"
|
||||||
|
role="region"
|
||||||
|
aria-label="视频抠像控制面板"
|
||||||
|
>
|
||||||
|
<h4>
|
||||||
|
控制面板
|
||||||
|
<button class="close-btn" id="closePanel" title="关闭面板">✕</button>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 grid-rows-7">
|
||||||
|
<label>黑色阈值: <span id="thVal">30</span></label>
|
||||||
|
<input type="range" id="threshold" min="0" max="120" value="30" />
|
||||||
|
|
||||||
|
<label>羽化(softness): <span id="sfVal">30</span></label>
|
||||||
|
<input type="range" id="softness" min="0" max="120" value="30" />
|
||||||
|
|
||||||
|
<label>左侧偏移 X (px): <span id="marginVal">40</span></label>
|
||||||
|
<input type="range" id="leftMargin" min="0" max="800" value="40" />
|
||||||
|
|
||||||
|
<label>顶部偏移 Y (px): <span id="topVal">0</span></label>
|
||||||
|
<input type="range" id="topOffset" min="-400" max="400" value="0" />
|
||||||
|
|
||||||
|
<label>缩放(%): <span id="scaleVal">90</span></label>
|
||||||
|
<input type="range" id="scale" min="20" max="200" value="90" />
|
||||||
|
|
||||||
|
<label>旋转(deg): <span id="rotVal">0</span></label>
|
||||||
|
<input type="range" id="rotation" min="-45" max="45" value="0" />
|
||||||
|
|
||||||
|
<label>不透明度: <span id="opaVal">100</span>%</label>
|
||||||
|
<input type="range" id="opacity" min="0" max="100" value="100" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 6px; margin-top: 8px">
|
||||||
|
<button
|
||||||
|
id="resetBtn"
|
||||||
|
class="flex-1 bg-danger rounded-md cursor-pointer"
|
||||||
|
>
|
||||||
|
重置 (R)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="applyHint"
|
||||||
|
class="flex-1 bg-warning rounded-md cursor-pointer"
|
||||||
|
>
|
||||||
|
帮助
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 当面板关闭时显示的打开按钮 -->
|
||||||
|
<button id="openBtn" class="open-controls" style="display: none">
|
||||||
|
打开控制面板
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="background.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3
20251012/main/main.css
Normal file
3
20251012/main/main.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
|
||||||
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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
13
20251012/speech/1.html
Normal file
13
20251012/speech/1.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>筹委会主席</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||||
|
<script src="../scripts/nav.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<video src="../assets/筹委会主席.mp4" class="w-full" autoplay loop muted></video>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13
20251012/speech/2.html
Normal file
13
20251012/speech/2.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>林添顺</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||||
|
<script src="../scripts/nav.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<video src="../assets/林添顺.mp4" class="w-full" autoplay loop muted></video>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user