diff --git a/20251012/commitee-list/background.js b/20251012/commitee-list/background.js new file mode 100644 index 0000000..9f35fe7 --- /dev/null +++ b/20251012/commitee-list/background.js @@ -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(); +})(); diff --git a/20251012/commitee-list/commitee.js b/20251012/commitee-list/commitee.js new file mode 100644 index 0000000..3f90a73 --- /dev/null +++ b/20251012/commitee-list/commitee.js @@ -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); +}); diff --git a/20251012/sponsor-list/index.html b/20251012/commitee-list/index.html similarity index 65% rename from 20251012/sponsor-list/index.html rename to 20251012/commitee-list/index.html index c3b3d03..54cabe1 100644 --- a/20251012/sponsor-list/index.html +++ b/20251012/commitee-list/index.html @@ -5,6 +5,7 @@ Canvas 实时抠像(可控面板) + - -
- + +
+ - -

- 永平赵子龙庙 -

- - -

- 庆祝赵子龙元帅暨众神圣诞千秋 -

- - -

- 庆祝赵子龙元帅暨众神圣诞千秋 -

- - -
-

+

- 血染征袍透甲红
当阳谁敢与争锋 -

+ 永平赵子龙庙 +

-

+

- 古来冲锋扶危主
唯有常山赵子龙 -

+ 庆祝赵子龙元帅暨众神圣诞千秋 +

- -
-

+

- 血染征袍透甲红
当阳谁敢与争锋 -

+ 庆祝赵子龙元帅暨众神圣诞千秋 +

-

+

- 古来冲锋扶危主
唯有常山赵子龙 -

+

+ 筹委会名单 +

+ +
+ +
+

+ 工委 +

+
+
+
+ diff --git a/20251012/sponsor-list/main.css b/20251012/commitee-list/main.css similarity index 100% rename from 20251012/sponsor-list/main.css rename to 20251012/commitee-list/main.css diff --git a/20251012/index.html b/20251012/index.html new file mode 100644 index 0000000..1df7417 --- /dev/null +++ b/20251012/index.html @@ -0,0 +1,124 @@ + + + + + + Canvas 实时抠像(可控面板) + + + + + + +
+ + + Background + + +

+ 永平赵子龙庙 +

+ + +

+ 庆祝赵子龙元帅暨众神圣诞千秋 +

+ + +

+ 庆祝赵子龙元帅暨众神圣诞千秋 +

+ + +
+ + + +
+
+ + + + diff --git a/20251012/main.css b/20251012/main.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/20251012/main.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/20251012/sponsor-list/background.js b/20251012/main/background.js similarity index 100% rename from 20251012/sponsor-list/background.js rename to 20251012/main/background.js diff --git a/20251012/main/index.html b/20251012/main/index.html new file mode 100644 index 0000000..e1a550c --- /dev/null +++ b/20251012/main/index.html @@ -0,0 +1,175 @@ + + + + + + Canvas 实时抠像(可控面板) + + + + + +
+ + + +

+ 永平赵子龙庙 +

+ + +

+ 庆祝赵子龙元帅暨众神圣诞千秋 +

+ + +

+ 庆祝赵子龙元帅暨众神圣诞千秋 +

+ + +
+

+ 血染征袍透甲红
当阳谁敢与争锋 +

+ +

+ 古来冲锋扶危主
唯有常山赵子龙 +

+
+ +
+

+ 血染征袍透甲红
当阳谁敢与争锋 +

+ +

+ 古来冲锋扶危主
唯有常山赵子龙 +

+
+ + + + + + + + + +
+ + + + diff --git a/20251012/main/main.css b/20251012/main/main.css new file mode 100644 index 0000000..a041a74 --- /dev/null +++ b/20251012/main/main.css @@ -0,0 +1,3 @@ +@import "tailwindcss"; + + diff --git a/20251012/scripts/nav.js b/20251012/scripts/nav.js new file mode 100644 index 0000000..54f99d3 --- /dev/null +++ b/20251012/scripts/nav.js @@ -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 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 = ` + 导航提示
+ Enter / → / '>' : 下一页
+ ← / '<' : 上一页
+ H : 显示/隐藏本提示
+ 支持触摸左右滑动(移动端) + `; + 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, + }; +})(); diff --git a/20251012/speech/1.html b/20251012/speech/1.html new file mode 100644 index 0000000..ce3ceed --- /dev/null +++ b/20251012/speech/1.html @@ -0,0 +1,13 @@ + + + + + + 筹委会主席 + + + + + + + \ No newline at end of file diff --git a/20251012/speech/2.html b/20251012/speech/2.html new file mode 100644 index 0000000..0371154 --- /dev/null +++ b/20251012/speech/2.html @@ -0,0 +1,13 @@ + + + + + + 林添顺 + + + + + + + \ No newline at end of file