| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291 |
- /* ============================================================
- * lib/player/deck.js · 混沌AI院演示播放器核心
- *
- * 职责:收集 .screen → 每屏状态机 idle→playing→done;
- * 进入新屏自动播放入场动画/视频(首屏不黑屏);↓ 先演完当前动画再切屏(门控);
- * 动画运行中 ↓ 快进到结尾;上下键/翻页器全兼容;进度条;两级导航(读 deck-config)。
- * 鼠标支持:滚轮上/下 = 上/下一屏;侧键 前进=下章、后退=上章(防抖,兼容翻页笔)。
- *
- * 页面接入:
- * <section class="screen" data-tl="s2">...</section>
- * <script src=".../deck.js"></script>
- * <script>
- * window.SCREEN_TIMELINES = { s2: function(ctx){ var tl=gsap.timeline(); ...; return tl; } };
- * deck.init();
- * </script>
- * 普通 script,非 module,file:// 可用。禁止页面自写 keydown/scroll。
- * ============================================================ */
- (function () {
- var screens = [];
- var current = 0;
- var total = 0;
- var cfg = null;
- var inited = false;
- /* ---------- 总控容器模式(shell)状态 ---------- */
- var shellMode = !!(window.parent && window.parent !== window); // 页面被 iframe 装载
- var shellPlan = null; // 编排配置(deck-plan)
- var shellCur = 0; // 当前页序号
- var shellFrames = []; // iframe 元素(不销毁,翻回保留状态)
- var shellShown = []; // 是否已展示过(首次展示重演入场,无白屏)
- var shellLast = []; // 各页最近上报的进度 {index,total,state}
- var pendingGo = null; // 容器注册前的翻章请求缓冲(防止注册竞态丢失)
- var shellFrom = 0; // 容器模式:切片起始屏(0-based,总控 deck-range 下发)
- var shellTo = -1; // 容器模式:切片结束屏(-1 = 整节)
- /* ---------- embed(JS 注入)模式状态 ---------- */
- var embedMode = false; // 总控用 JS 注入单页 HTML(非 iframe)
- var embedPages = []; // 注入后的屏信息 {page, screens, tlName}
- var embedReady = false;
- /* ---------- 工具 ---------- */
- function readConfig() {
- var el = document.querySelector('script.deck-config');
- if (!el) return null;
- try { return JSON.parse(el.textContent); } catch (e) { return null; }
- }
- /* 与总控容器(deck-shell)通信:postMessage 跨源/跨 file:// 均可用 */
- function postShell(msg) {
- try { window.parent.postMessage(msg, '*'); } catch (e) {}
- }
- function fmt(n, dec, sep) {
- var s = n.toFixed(dec == null ? 0 : dec);
- if (sep) s = s.replace(/\B(?=(\d{3})+(?!\d))/g, sep);
- return s;
- }
- function makeCtx(s) {
- var el = s.el;
- return {
- el: el,
- q: function (sel) { return el.querySelector(sel); },
- qAll: function (sel) { return el.querySelectorAll(sel); },
- // 数字滚动,返回 gsap tween,可用 tl.add(ctx.count(...), pos)
- count: function (sel, to, opts) {
- opts = opts || {};
- var node = el.querySelector(sel);
- if (!node || !window.gsap) return null;
- var obj = { val: 0 };
- var target = (opts.to != null) ? opts.to : to;
- var tw = window.gsap.to(obj, {
- val: target,
- duration: opts.duration || 1.2,
- ease: opts.ease || 'power1.out',
- onUpdate: function () { node.textContent = fmt(obj.val, opts.dec || 0, opts.sep); }
- });
- return tw;
- }
- };
- }
- /* ---------- 状态 ---------- */
- function collect() {
- var els = Array.prototype.slice.call(document.querySelectorAll('.screen'));
- screens = els.map(function (el) {
- var tlKey = el.getAttribute('data-tl');
- return {
- el: el,
- tlKey: tlKey,
- hasTL: !!(tlKey && window.SCREEN_TIMELINES && typeof window.SCREEN_TIMELINES[tlKey] === 'function'),
- video: el.querySelector('video[data-gate]'),
- auto: el.hasAttribute('data-gate-autoplay'),
- state: 'idle',
- tl: null
- };
- });
- total = screens.length;
- }
- /* ---------- 展示 ---------- */
- function showScreen(i) {
- screens.forEach(function (s, j) {
- s.el.style.visibility = (j === i) ? 'visible' : 'hidden';
- // 根因修复:`.screen[data-tl].in > .s-wrap { visibility: visible }`(base.css)是 .s-wrap 的
- // 直接声明,会覆盖从屏级继承的 hidden。动画播完 .in 类残留 → 旧屏屏级 hidden 但 .s-wrap
- // 仍 visible,内容叠在新屏上(旧屏盖新屏的根因)。此处对 .s-wrap 直接强制隐藏/恢复。
- var wrap = s.el.querySelector('.s-wrap');
- if (wrap) wrap.style.visibility = (j === i) ? '' : 'hidden';
- });
- current = i;
- updateProgress();
- highlightTab();
- }
- // 屏激活:进入新一页/新屏直接走入场动画加载(不黑屏)。
- // 有时间线 → 自动播放;autoplay 视频 → 自动播;data-gate 视频 → 保持 idle 等 ↓;
- // 无动画 → 直接视为完成。↓ 在动画运行中可快进(见 next)。
- function activate(s) {
- if (s.state !== 'idle') return;
- if (s.hasTL) { playTimeline(s); return; }
- if (s.video && s.auto) { playVideo(s); return; }
- if (s.video) return; // data-gate 视频:保持 idle,等 ↓ 播放
- s.state = 'done'; updateProgress();
- }
- /* ---------- 动画 / 视频门控 ---------- */
- function playTimeline(s) {
- if (!s.hasTL) return false;
- s.el.classList.add('in');
- var fn = window.SCREEN_TIMELINES[s.tlKey];
- var ctx = makeCtx(s);
- var tl = null;
- try { tl = fn(ctx); } catch (e) { tl = null; }
- if (!tl || !window.gsap) { s.state = 'done'; updateProgress(); return false; }
- s.tl = tl;
- s.state = 'playing';
- tl.eventCallback('onComplete', function () {
- // 有时间线 + data-gate-autoplay:时间线播完自动接播视频(再按 ↓ 才切屏)
- if (s.video && s.auto) { playVideo(s); return; }
- s.state = 'done'; updateProgress();
- });
- tl.play();
- updateProgress();
- return true;
- }
- function playVideo(s) {
- var v = s.video;
- if (!v) return;
- var ended = function () { s.state = 'done'; updateProgress(); };
- v.removeEventListener('ended', ended);
- v.addEventListener('ended', ended);
- var p = v.play();
- if (p && p.catch) p.catch(function () { s.state = 'done'; updateProgress(); });
- s.state = 'playing';
- updateProgress();
- }
- function resetScreen(s) {
- if (s.tl) {
- // 先播到结尾让 from-tween 渲染到最终态(opacity 1),再 kill:
- // 直接 kill 一个播放中的 timeline 会残留内联 opacity:0(immediateRender),
- // 且后续新建的 timeline 无法推进 → 重演 / jump / prev 时整屏空白(CBEC p0/p1 实测)。
- try { s.tl.progress(1); } catch (e) {}
- try { s.tl.kill(); } catch (e) {}
- s.tl = null;
- }
- s.el.classList.remove('in');
- s.state = 'idle';
- if (s.video) { s.video.pause(); s.video.currentTime = 0; }
- }
- /* ---------- 导航动作 ---------- */
- function next() {
- if (current >= total) return;
- var s = screens[current];
- if (s.state === 'done') { advance(); return; }
- if (s.state === 'playing') {
- // 入场动画运行中按 ↓:快进到结尾并切下一屏(新屏入场自动播放);视频保持本屏待重播
- if (s.video && !s.video.paused) { s.video.currentTime = s.video.duration; return; }
- if (s.tl) s.tl.progress(1);
- advance();
- return;
- }
- // idle(正常已被 activate 自动播放;防御 data-gate 视频屏)
- if (s.hasTL && playTimeline(s)) return;
- if (s.video) { playVideo(s); return; }
- s.state = 'done';
- advance();
- }
- function advance() {
- // 容器切片:当前屏已是切片结束屏 → 直接翻章(交给总控切下一节),不进入切片外屏
- if (shellTo >= 0 && current >= shellTo) { nextChapter(); return; }
- if (current < total - 1) {
- showScreen(current + 1);
- activate(screens[current]); // showScreen 已把 current 指向新屏 → 自动播放入场
- } else {
- nextChapter();
- }
- }
- function prev() {
- // 容器切片:当前屏已是切片起始屏 → 直接翻上一章
- if (shellFrom > 0 && current <= shellFrom) { prevChapter(); return; }
- if (current > 0) {
- resetScreen(screens[current]);
- current--;
- resetScreen(screens[current]);
- showScreen(current);
- activate(screens[current]); // 上一屏重演入场(可重演)
- } else {
- prevChapter();
- }
- }
- function jump(i) {
- // 容器切片:跳转范围钳制在 [from, to]
- if (shellTo >= 0) i = Math.max(shellFrom, Math.min(i, shellTo));
- if (i < 0 || i >= total) return;
- resetScreen(screens[current]);
- current = i;
- resetScreen(screens[current]);
- showScreen(current);
- activate(screens[current]);
- }
- /* ---------- 章节导航 ---------- */
- function goFile(file, dir) {
- if (!file) return;
- if (shellMode) {
- // 容器模式:翻章请求交给总控(总控按 plan 顺序编排),不在 iframe 内跳文件
- pendingGo = { type: 'deck-go', file: file, dir: dir };
- postShell(pendingGo);
- return;
- }
- var cur = location.pathname.split('/').pop();
- if (file === cur) { dir > 0 ? jump(0) : jump(total - 1); return; }
- location.href = file;
- }
- function nextChapter() {
- if (embedMode && embedReady) { embedJumpSection(1); return; }
- goFile(cfg ? cfg.next : null, 1);
- }
- function prevChapter() {
- if (embedMode && embedReady) { embedJumpSection(-1); return; }
- goFile(cfg ? cfg.prev : null, -1);
- }
- /* embed(JS注入)模式:节级跳转统一走这里(←→/侧键/触摸横滑/末屏翻章)。
- * 节 = plan.pages 一项;embed 下所有屏 flat 注入,按节累计屏数定位「该节第一屏」。
- * 不用 next()/prev()(那在 embed 下是切屏,不是切节)。 */
- function embedSectionIndex() {
- var acc = 0;
- for (var q = 0; q < shellPlan.pages.length; q++) {
- var cnt = (embedPages[q] && embedPages[q].count) || 0;
- if (current < acc + cnt) return q;
- acc += cnt;
- }
- return shellPlan.pages.length - 1;
- }
- function embedJumpSection(dir) {
- var sec = embedSectionIndex() + dir;
- if (sec < 0 || sec >= shellPlan.pages.length) return; // 边界节不翻,与 iframe showPage 一致
- var target = 0;
- for (var q = 0; q < sec; q++) target += (embedPages[q] && embedPages[q].count) || 0;
- jump(target);
- }
- /* ---------- 界面 ---------- */
- function ensureThemeBtn() {
- if (document.querySelector('.theme-toggle')) return;
- var topbar = document.querySelector('.deck-topbar');
- if (!topbar) return;
- var btn = null;
- // 优先 theme.js 的图标按钮(太阳/月亮),缺失时退化为文字兜底
- if (window.HDTheme && window.HDTheme.createToggleBtn) {
- btn = window.HDTheme.createToggleBtn();
- } else {
- btn = document.createElement('button');
- btn.className = 'theme-toggle';
- btn.type = 'button';
- btn.textContent = '主题';
- btn.addEventListener('click', function () { if (window.HDTheme) window.HDTheme.toggle(); });
- }
- var left = topbar.querySelector('.deck-left');
- if (!left) { left = document.createElement('div'); left.className = 'deck-left'; topbar.insertBefore(left, topbar.firstChild); }
- left.appendChild(btn);
- }
- function highlightTab() {
- if (!cfg) return;
- var tabs = document.querySelectorAll('.deck-tab');
- tabs.forEach(function (b) {
- b.classList.toggle('cur', b.getAttribute('data-ch') === cfg.page.id);
- });
- scrollActiveTabIntoView();
- }
- /* 顶栏章节/页面 tab 自动横向滚动:把高亮当前 tab 滚动到居中(不触边)。
- * 溢出检测用 scrollWidth(横向不溢出时忽略,含窄屏折叠面板纵向列)。 */
- function scrollActiveTabIntoView() {
- var nav = document.querySelector('.deck-tabs');
- var cur = document.querySelector('.deck-tab.cur');
- if (!nav || !cur) return;
- if (nav.scrollWidth <= nav.clientWidth) return; // 无需滚动
- var navRect = nav.getBoundingClientRect();
- var curRect = cur.getBoundingClientRect();
- var target = nav.scrollLeft + (curRect.left + curRect.width / 2) - (navRect.left + navRect.width / 2);
- var max = nav.scrollWidth - nav.clientWidth;
- target = Math.max(0, Math.min(target, max)); // 不触边:钳制在可滚范围
- if (Math.abs(nav.scrollLeft - target) > 1) {
- nav.scrollTo({ left: target, behavior: 'smooth' });
- }
- }
- function buildChrome() {
- if (shellMode) return; // 容器模式:顶栏/进度条由总控渲染,页面只保留 .screen 内容
- var topbar = document.querySelector('.deck-topbar');
- if (!topbar) { topbar = document.createElement('div'); topbar.className = 'deck-topbar'; document.body.appendChild(topbar); }
- if (cfg && cfg.chapters && !topbar.querySelector('.deck-tabs')) {
- var nav = document.createElement('nav');
- nav.className = 'deck-tabs';
- cfg.chapters.forEach(function (ch) {
- var b = document.createElement('button');
- b.className = 'deck-tab' + (ch.id === cfg.page.id ? ' cur' : '');
- b.type = 'button';
- b.setAttribute('data-ch', ch.id);
- b.textContent = ch.title;
- b.addEventListener('click', function () { goFile(ch.file, 1); });
- nav.appendChild(b);
- });
- topbar.appendChild(nav);
- }
- ensureThemeBtn();
- var right = topbar.querySelector('.deck-right');
- if (!right) { right = document.createElement('div'); right.className = 'deck-right'; topbar.appendChild(right); }
- // 右上角品牌 logo(theme.js 注入,随主题切换暗/浅图)
- if (window.HDTheme && window.HDTheme.ensureLogo) window.HDTheme.ensureLogo(right);
- var bottom = document.querySelector('.deck-bottom');
- if (!bottom) {
- bottom = document.createElement('div');
- bottom.className = 'deck-bottom';
- document.body.appendChild(bottom);
- var prog = document.createElement('div');
- prog.className = 'deck-progress';
- var bar = document.createElement('div');
- bar.className = 'bar';
- prog.appendChild(bar);
- bottom.appendChild(prog);
- var pos = document.createElement('span');
- pos.className = 'deck-pos';
- bottom.appendChild(pos);
- if (cfg && cfg.page) {
- var pn = document.createElement('span');
- pn.className = 'deck-pagename';
- pn.textContent = cfg.page.title;
- bottom.appendChild(pn);
- }
- }
- }
- function updateProgress() {
- var pct = total ? (current / total) * 100 : 0;
- var bar = document.querySelector('.deck-progress .bar');
- if (bar) bar.style.width = pct + '%';
- var pos = document.querySelector('.deck-pos');
- if (pos) pos.textContent = (current + 1) + ' / ' + total;
- // 容器模式:把本页进度上报总控(总控据此算全局进度条)
- if (shellMode) {
- var s = screens[current] || null;
- postShell({ type: 'deck-progress', index: current, total: total, state: s ? s.state : 'done' });
- }
- // embed 模式:屏进度变化同步总控 chrome
- if (embedMode && embedReady) renderShellChrome();
- }
- /* ---------- 输入 ---------- */
- function bindKeys() {
- document.addEventListener('keydown', function (e) {
- var t = e.target;
- if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
- var k = e.key;
- if (k === 'ArrowDown' || k === 's' || k === 'S' || k === 'PageDown' || k === ' ') {
- e.preventDefault(); next();
- } else if (k === 'ArrowUp' || k === 'w' || k === 'W' || k === 'PageUp') {
- e.preventDefault(); prev();
- } else if (k === 'ArrowRight' || k === 'd' || k === 'D') {
- e.preventDefault(); nextChapter();
- } else if (k === 'ArrowLeft' || k === 'a' || k === 'A') {
- e.preventDefault(); prevChapter();
- } else if (k === 'Home') {
- e.preventDefault(); jump(0);
- } else if (k === 'End') {
- e.preventDefault(); jump(total - 1);
- }
- });
- }
- function bindClick() {
- document.addEventListener('click', function (e) {
- var t = e.target;
- if (t && t.closest && (t.closest('a') || t.closest('button') || t.closest('.deck-topbar') || t.closest('.deck-bottom'))) return;
- if (e.clientX > window.innerWidth * 0.78 && e.clientX < window.innerWidth) next();
- });
- }
- function bindMouse() {
- // 滚轮:累计方向,超过阈值触发一次,冷却期防连击(轨迹板/鼠标均稳定)
- var wheelAcc = 0, lastDir = 0, wheelLock = 0;
- var STEP = 60, COOLDOWN = 550;
- document.addEventListener('wheel', function (e) {
- var t = e.target;
- // 顶栏章节/页面 tab 上滚轮 → 横向滚动 tabs(顶栏 hover 本就不翻屏;横向不溢出时忽略)
- if (t && t.closest && t.closest('.deck-tabs')) {
- var nav = t.closest('.deck-tabs');
- if (nav.scrollWidth > nav.clientWidth) {
- e.preventDefault();
- var step = e.deltaMode === 0 ? (e.deltaY || e.deltaX) : e.deltaY * 40;
- nav.scrollLeft += step;
- }
- return;
- }
- if (t && t.closest && (t.closest('a') || t.closest('button') || t.closest('.deck-topbar') || t.closest('.deck-bottom'))) return;
- var now = Date.now();
- if (now - wheelLock < COOLDOWN) return;
- if (e.deltaY === 0) return;
- var dir = e.deltaY > 0 ? 1 : -1; // 下滚 = 下一屏,上滚 = 上一屏
- wheelAcc = (lastDir === dir) ? wheelAcc + e.deltaY : e.deltaY;
- lastDir = dir;
- if (Math.abs(wheelAcc) < STEP) return; // 未到阈值先攒着
- wheelAcc = 0; wheelLock = now;
- e.preventDefault();
- if (dir > 0) next(); else prev();
- }, { passive: false });
- // 鼠标侧键:button 3(前进) → 下章,button 4(后退) → 上章(auxclick/mouseup 双绑兼容,防抖)
- var lastSide = 0;
- function side(e) {
- if (e.button !== 3 && e.button !== 4) return;
- var t = e.target;
- if (t && t.closest && (t.closest('a') || t.closest('button') || t.closest('.deck-topbar') || t.closest('.deck-bottom'))) return;
- var now = Date.now();
- if (now - lastSide < 450) return;
- lastSide = now;
- e.preventDefault();
- if (e.button === 3) nextChapter(); else prevChapter();
- }
- document.addEventListener('auxclick', side);
- document.addEventListener('mouseup', side);
- }
- function bindTouch() {
- // 触摸手势:上滑/下滑 = 下/上一屏(触屏习惯:上滑进下一页),左滑/右滑 = 下/上一章(与方向键一致)。
- // 用 Touch Events 而非 Pointer Events:touchmove + passive:false 才能真正 preventDefault
- // 阻止浏览器原生滚动/缩放;pointermove 的 preventDefault 对滚动无效(需 touch-action CSS,
- // 而全局 touch-action:none 会破坏 chat-window/group-chat 内部滚动)。
- if (!('ontouchstart' in window)) return; // 无触摸能力(桌面)直接跳过,不打扰鼠标
- var startX = 0, startY = 0, axis = 0; // axis: 0=未锁定 1=水平 2=垂直
- var tracking = false;
- var touchLock = 0;
- var THRESHOLD = 50, COOLDOWN = 550; // 阈值 50px、冷却 550ms(与滚轮一致)
- function isIgnored(t) { // 与 wheel/click 相同的忽略集,避免误触
- return !!(t && t.closest &&
- (t.closest('a') || t.closest('button') || t.closest('.deck-topbar') || t.closest('.deck-bottom')));
- }
- document.addEventListener('touchstart', function (e) {
- if (isIgnored(e.target)) return; // 交互元素不接管
- if (e.touches.length > 1) return; // 多点(捏合缩放)不接管
- tracking = true;
- axis = 0;
- startX = e.touches[0].clientX;
- startY = e.touches[0].clientY;
- });
- document.addEventListener('touchmove', function (e) {
- if (!tracking) return;
- if (e.touches.length > 1) { tracking = false; return; } // 双指出现 = 放弃手势
- var t = e.touches[0];
- var dx = t.clientX - startX;
- var dy = t.clientY - startY;
- if (axis === 0) {
- // 方向锁定:位移超 8px 判定轴向(水平/垂直二选一),防斜滑误触发
- if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
- axis = Math.abs(dx) > Math.abs(dy) ? 1 : 2;
- }
- e.preventDefault(); // 轴向锁定后才阻断原生滚动/缩放(passive:false)
- }, { passive: false });
- function finish(e) {
- if (!tracking) return;
- tracking = false;
- if (axis === 0) return; // 未到锁定阈值 = tap,放行 click
- var now = Date.now();
- if (now - touchLock < COOLDOWN) return; // 防连击
- var t = e.changedTouches[0];
- var dx = t.clientX - startX;
- var dy = t.clientY - startY;
- if (axis === 1) { // 水平:左滑=下章,右滑=上章
- if (Math.abs(dx) < THRESHOLD) return;
- touchLock = now;
- if (dx < 0) nextChapter(); else prevChapter();
- } else { // 垂直:上滑=下一屏,下滑=上一屏
- if (Math.abs(dy) < THRESHOLD) return;
- touchLock = now;
- if (dy < 0) next(); else prev();
- }
- }
- document.addEventListener('touchend', finish);
- document.addEventListener('touchcancel', function () { tracking = false; });
- }
- /* ---------- 入口 ---------- */
- function init(opts) {
- if (inited) return;
- inited = true;
- opts = opts || {};
- window.addEventListener('message', onPageMessage);
- if (opts.mode === 'shell') { initShell(opts); return; }
- cfg = readConfig();
- collect();
- if (total === 0) return;
- screens.forEach(function (s) {
- s.el.style.position = 'absolute';
- s.el.style.inset = '0';
- });
- buildChrome();
- bindKeys();
- bindClick();
- bindMouse();
- bindTouch();
- showScreen(0);
- activate(screens[0]); // 首屏自动播放入场,不黑屏
- }
- // 安全网:若页面未显式 init,DOM 就绪后自动启动
- function auto() {
- if (inited) return;
- if (document.querySelectorAll('.screen').length) init();
- }
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', auto);
- } else {
- setTimeout(auto, 0);
- }
- /* ============================================================
- * 总控容器模式(shell):总控页用 iframe 加载多页 + 预加载无缝衔接 + 导航可编排。
- *
- * 用法(总控页,如 course/deck-shell.html):
- * <script type="application/json" class="deck-plan">{ "title": "...", "pages":[ {"file":"demo/p1.html","title":"01 ..."}, ... ] }</script>
- * <script src=".../deck.js"></script>
- * <script> deck.init({ mode: 'shell', planEl: '.deck-plan' }); </script>
- *
- * - plan.pages[].file 相对总控页;iframe 不销毁,翻回保留状态;预加载下一页(隐藏)→ 翻页无白屏。
- * - 页内 deck 保持单页模式不变(可独立打开);容器内通过 postMessage 上报进度、转发翻章请求。
- * - file:// 双击读内嵌 JSON(零 fetch);http 下可加 ?plan=plans/xxx.json 切换编排(fetch 失败回退内嵌)。
- * ============================================================ */
- function urlParam(name) {
- try {
- var m = location.search.match(new RegExp('[?&]' + name + '=([^&]*)'));
- return m ? decodeURIComponent(m[1]) : null;
- } catch (e) { return null; }
- }
- function readPlan(opts) {
- if (opts.plan && typeof opts.plan === 'object' && opts.plan.pages) return opts.plan;
- var url = opts.planUrl || urlParam('plan');
- if (url && location.protocol !== 'file:') {
- try {
- var xhr = new XMLHttpRequest();
- xhr.open('GET', url, false); // 同步,保持零构建的确定性(仅 http 生效)
- xhr.send(null);
- if (xhr.status >= 200 && xhr.status < 300) {
- var p = JSON.parse(xhr.responseText);
- if (p && p.pages) return p;
- }
- } catch (e) {}
- }
- var el = opts.planEl ? document.querySelector(opts.planEl) : document.querySelector('script.deck-plan');
- if (el) {
- try {
- var p2 = JSON.parse(el.textContent);
- if (p2 && p2.pages) return p2;
- } catch (e) {}
- }
- return null;
- }
- function initShell(opts) {
- var plan = readPlan(opts);
- if (!plan) {
- var err = document.createElement('div');
- err.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;'
- + 'font:16px var(--font-mono);color:var(--text-3);background:var(--bg-0);text-align:center;padding:40px;';
- err.textContent = '缺少编排配置:请在总控页内嵌 <script class="deck-plan"> JSON,或 http 下加 ?plan=plans/xxx.json。';
- document.body.appendChild(err);
- return;
- }
- shellPlan = plan;
- window.addEventListener('message', onShellMsg);
- buildShellChrome(plan);
- bindShellKeys();
- if (isMobileUA() || urlParam('mobile') === '1') buildFullscreenBtn(); // 手机端:右下角全屏切换(?mobile=1 供桌面调试)
- // embed 模式:plan 任一页带 picks(精确选屏)→ 走 JS 注入,不走 iframe
- var useEmbed = plan.pages.some(function (p) { return p && p.picks; });
- if (useEmbed) { initEmbed(); return; }
- showPage(0);
- }
- function buildShellChrome(plan) {
- var isNarrow = isMobileUA() || urlParam('mobile') === '1' || (window.matchMedia && window.matchMedia('(max-width: 768px)').matches);
- var topbar = document.createElement('div');
- topbar.className = 'deck-topbar shell-topbar' + (isNarrow ? ' shell-collapsed' : '');
- var left = document.createElement('div');
- left.className = 'deck-left';
- var title = document.createElement('span');
- title.className = 'shell-title';
- title.textContent = plan.title || '演示';
- left.appendChild(title);
- var btn = null;
- if (window.HDTheme && window.HDTheme.createToggleBtn) {
- btn = window.HDTheme.createToggleBtn();
- btn.addEventListener('click', function () { setTimeout(broadcastTheme, 0); });
- } else {
- btn = document.createElement('button');
- btn.className = 'theme-toggle';
- btn.type = 'button';
- btn.textContent = '主题';
- btn.addEventListener('click', function () {
- var cur = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
- document.documentElement.setAttribute('data-theme', cur);
- broadcastTheme();
- });
- }
- left.insertBefore(btn, title); // 主题按钮最左
- // 窄屏:汉堡按钮(折叠导航),点击展开/收起节列表
- if (isNarrow) {
- var burger = document.createElement('button');
- burger.className = 'shell-burger';
- burger.type = 'button';
- burger.setAttribute('aria-label', '展开页面导航');
- burger.innerHTML = '<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M2.5 5h15M2.5 10h15M2.5 15h15"/></svg>';
- burger.addEventListener('click', function (ev) {
- ev.stopPropagation();
- topbar.classList.toggle('shell-open');
- });
- left.insertBefore(burger, left.firstChild);
- // 点击非折叠区关闭
- document.addEventListener('click', function (e) {
- if (!topbar.classList.contains('shell-open')) return;
- if (!topbar.contains(e.target)) topbar.classList.remove('shell-open');
- });
- }
- topbar.appendChild(left);
- var nav = document.createElement('nav');
- nav.className = 'deck-tabs';
- nav.setAttribute('aria-label', '页面导航');
- plan.pages.forEach(function (p, i) {
- var b = document.createElement('button');
- b.className = 'deck-tab shell-tab' + (i === 0 ? ' cur' : '');
- b.type = 'button';
- b.textContent = p.title || ('第 ' + (i + 1) + ' 页');
- b.title = p.file || '';
- b.addEventListener('click', function () {
- if (embedMode && embedReady) {
- // embed:跳到该节第一屏
- var acc = 0, target = 0;
- for (var q = 0; q < i; q++) { acc += (embedPages[q] && embedPages[q].count) || 0; }
- target = acc;
- jump(target);
- } else {
- showPage(i);
- }
- topbar.classList.remove('shell-open'); // 选完收起(窄屏)
- });
- nav.appendChild(b);
- });
- topbar.appendChild(nav);
- var right = document.createElement('div');
- right.className = 'deck-right';
- topbar.appendChild(right);
- document.body.appendChild(topbar);
- if (window.HDTheme && window.HDTheme.ensureLogo) window.HDTheme.ensureLogo(right);
- var bottom = document.createElement('div');
- bottom.className = 'deck-bottom shell-bottom';
- var prog = document.createElement('div');
- prog.className = 'deck-progress';
- var bar = document.createElement('div');
- bar.className = 'bar';
- prog.appendChild(bar);
- bottom.appendChild(prog);
- var pos = document.createElement('span');
- pos.className = 'deck-pos shell-pos';
- bottom.appendChild(pos);
- var pn = document.createElement('span');
- pn.className = 'deck-pagename shell-pagename';
- bottom.appendChild(pn);
- var prevB = document.createElement('button');
- prevB.className = 'shell-navbtn';
- prevB.type = 'button';
- prevB.textContent = '‹ 上一页';
- prevB.addEventListener('click', function () {
- if (embedMode && embedReady) prev(); else showPage(shellCur - 1);
- });
- bottom.appendChild(prevB);
- var nextB = document.createElement('button');
- nextB.className = 'shell-navbtn';
- nextB.type = 'button';
- nextB.textContent = '下一页 ›';
- nextB.addEventListener('click', function () {
- if (embedMode && embedReady) next(); else showPage(shellCur + 1);
- });
- bottom.appendChild(nextB);
- document.body.appendChild(bottom);
- }
- function postToFrame(fr, msg) {
- try { fr.contentWindow.postMessage(msg, '*'); } catch (e) {}
- }
- /* ============================================================
- * embed(JS 注入)模式:把单页 HTML 拉为字符串,DOMParser 解析,
- * 按 picks 精确选屏注入总控 DOM;timeline 合并进全局;节样式注入。
- * 相比 iframe:可精确到任意屏组合、可跨节跳着选屏、预渲染可控。
- * ============================================================ */
- function fetchText(url, cb) {
- var xhr = new XMLHttpRequest();
- xhr.open('GET', url, true);
- xhr.onreadystatechange = function () {
- if (xhr.readyState !== 4) return;
- if (xhr.status >= 200 && xhr.status < 300) cb(null, xhr.responseText);
- else cb('HTTP ' + xhr.status);
- };
- xhr.onerror = function () { cb('net'); };
- xhr.send(null);
- }
- // 把节 HTML 的相对资源路径转绝对(相对该节文件所在目录;用 URL 解析避免拼接错误)
- function absolutize(baseURL, el, attr) {
- var v = el.getAttribute(attr);
- if (!v || /^(https?:|data:|blob:|#)/.test(v)) return;
- // 页面内锚点 href="#s1-..." 保留
- if (v.charAt(0) === '#') return;
- try {
- var abs = new URL(v, baseURL).href;
- el.setAttribute(attr, abs);
- } catch (e) {}
- }
- // 提取节内联 <style>(含 style.css 内容合并)
- function extractStyles(doc, baseURL, out) {
- var links = doc.querySelectorAll('link[rel="stylesheet"]');
- var inlines = doc.querySelectorAll('style');
- links.forEach(function (l) {
- var href = l.getAttribute('href') || '';
- absolutize(baseURL, l, 'href');
- out.push(l.outerHTML);
- });
- inlines.forEach(function (s) { out.push(s.outerHTML); });
- }
- // 提取 SCREEN_TIMELINES(把 IIFE 的 TL 注册名改成节级命名,避免全局覆盖)
- function extractTimeline(html, ns) {
- // 找出 window.SCREEN_TIMELINES = (function(){...})(); 整段
- var m = html.match(/window\.SCREEN_TIMELINES\s*=\s*\(function[\s\S]*?\}\)\(\);/);
- if (!m) return '';
- return m[0].replace(/window\.SCREEN_TIMELINES\s*=\s*/, 'window["' + ns + '"] = ');
- }
- // 执行节的内联脚本(数据/渲染/初始化),让动态屏在 shell 里也能渲染。
- // 跳过:外链 src、deck-config JSON、SCREEN_TIMELINES 块、deck.init 引导块。
- // 数据与函数挂到 window(与单页一致);脚本在 DOMParser 解析后的 doc 上查询 DOM
- // 时可能拿到 doc 而非总控 DOM——因此这些脚本里的 DOM 初始化统一改由 timeline
- // 在总控 DOM 上触发(页内已有的 window 数据声明与纯函数保持原样执行即可)。
- function execInlineScripts(doc, pageFile) {
- var scripts = doc.querySelectorAll('script:not([src])');
- Array.prototype.forEach.call(scripts, function (sc) {
- if (sc.getAttribute('type') === 'application/json') return; // JSON 数据块,非 JS
- var code = sc.textContent || '';
- if (!code.trim()) return;
- if (/deck-config/.test(code)) return; // 页内导航 JSON,不执行
- // SCREEN_TIMELINES 定义段已由 extractTimeline 改名执行(window["__EMB_TL_N"])。
- // 本函数执行节的数据/渲染脚本,但做两处安全化:
- // 1) 挖掉 SCREEN_TIMELINES 段与 deck.init 引导(embed 由总控接管)
- // 2) 把 document.getElementById( 替换为安全版本:找不到返回 null(让
- // 立即渲染用 if(el) 守卫自然跳过,不抛错);把 document.addEventListener(
- // 替换为空操作(embed 键盘由总控接管,避免页面级监听冲突)
- // 这样数据声明、window.X 赋值、函数定义都正常执行挂到全局,供 timeline 渲染。
- if (!window.__embGEL) {
- // 安全 getElementById:找不到返回「幽灵元素」(属性/方法访问一律 noop),
- // 让 embed 下找不到容器的立即渲染不抛错(数据声明仍执行,渲染由 timeline 接管)。
- window.__embGEL = function (id) {
- var el = document.getElementById(id);
- if (el) return el;
- if (window.__embGhost) return window.__embGhost;
- window.__embGhost = new Proxy({}, {
- get: function (t, k) {
- if (k === 'children') return [];
- if (k === 'style') return new Proxy({}, { get: function () { return ''; }, set: function () { return true; } });
- return function () {};
- },
- set: function () { return true; }
- });
- return window.__embGhost;
- };
- window.__embNoop = function () {};
- }
- var execCode = code.replace(/window\.SCREEN_TIMELINES\s*=\s*\(function[\s\S]*?\}\)\(\);/g, '')
- .replace(/deck\.init\s*\(\s*\{?\s*\}?\s*\)\s*;/g, '')
- .replace(/document\.documentElement\.classList\.add\([^)]*\)\s*;?/g, '')
- .replace(/document\.getElementById\s*\(/g, 'window.__embGEL(')
- .replace(/document\.addEventListener\s*\(/g, 'window.__embNoop(');
- if (!execCode.trim()) return;
- try {
- // 间接 eval 全局作用域执行:数据声明与函数声明挂到 window,
- // 供 SCREEN_TIMELINES 里的渲染函数调用。
- (0, eval)(execCode);
- } catch (e) {
- console.error('[embed] inline ' + (pageFile || ''), e);
- }
- });
- }
- // 收集一节的屏 DOM(按 picks 或整节),并注入总控 stage
- function embedChapter(page, i, done) {
- var file = page.file;
- var baseURL = null;
- try { baseURL = new URL(file, location.href).href; } catch (e) {}
- fetchText(file, function (err, html) {
- if (err) { console.error('[embed] ' + file + ' ' + err); if (done) done(); return; }
- var doc = new DOMParser().parseFromString(html, 'text/html');
- var ns = '__EMB_TL_' + i;
- // 1. 注入节样式(link + style,基于节绝对 URL 解析相对路径)
- var styles = [];
- extractStyles(doc, baseURL, styles);
- if (styles.length) {
- var styleBox = document.getElementById('emb-styles');
- if (!styleBox) {
- styleBox = document.createElement('div');
- styleBox.id = 'emb-styles';
- styleBox.style.display = 'none';
- document.head.appendChild(styleBox);
- }
- styleBox.insertAdjacentHTML('beforeend', styles.join('\n'));
- }
- // 2. 注册节 timeline(节级命名空间,避免不同节同名 data-tl 互相覆盖)
- var tlCode = extractTimeline(html, ns);
- if (tlCode) {
- try {
- var fn = new Function(tlCode);
- fn();
- } catch (e) { console.error('[embed] TL ' + file, e); }
- // 合并进全局 SCREEN_TIMELINES:key = ns + '_' + 原key(如 __EMB_TL_11_s1)
- if (window[ns]) {
- window.SCREEN_TIMELINES = window.SCREEN_TIMELINES || {};
- var tlObj = window[ns];
- Object.keys(tlObj).forEach(function (k) {
- window.SCREEN_TIMELINES[ns + '_' + k] = tlObj[k];
- });
- delete window[ns];
- }
- }
- // 3. 按 picks 选屏注入 stage(屏 data-tl 改为节前缀命名,避免全局 timeline key 冲突)
- var all = doc.querySelectorAll('section.screen');
- var picks = page.picks || null;
- var chosen = [];
- if (picks && picks.length) {
- picks.forEach(function (idx) { if (all[idx]) chosen.push(all[idx]); });
- } else {
- chosen = Array.prototype.slice.call(all);
- }
- var stage = document.querySelector('.shell-stage') || document.body;
- chosen.forEach(function (sec) {
- // 克隆到总控 DOM(保留原样,动画/计时靠 SCREEN_TIMELINES + gsap)
- var clone = doc.importNode(sec, true);
- // data-tl 加节前缀:__E<i>_<原tl>,对应下方 timeline 的节级命名
- var t = clone.getAttribute('data-tl');
- if (t) clone.setAttribute('data-tl', ns + '_' + t);
- // 资源绝对化(基于节 HTML 绝对 URL)——video 直接 src 与 source 子元素都处理
- clone.querySelectorAll('img').forEach(function (im) { absolutize(baseURL, im, 'src'); });
- clone.querySelectorAll('video[src]').forEach(function (vd) { absolutize(baseURL, vd, 'src'); });
- clone.querySelectorAll('video source').forEach(function (im) { absolutize(baseURL, im, 'src'); });
- clone.querySelectorAll('a[href]').forEach(function (a) { absolutize(baseURL, a, 'href'); });
- stage.appendChild(clone);
- });
- // 3b. 注入页面级 JSON 数据块(如 tt-data / 商品数据),供动态屏 IIFE 读取。
- // deck-config 是页内导航,不注入;id 冲突时第一个生效(跨节数据各自独立)。
- doc.querySelectorAll('script[type="application/json"]').forEach(function (jb) {
- if (/deck-config/.test(jb.textContent || '')) return;
- var jid = jb.getAttribute('id');
- if (jid && document.getElementById(jid)) return; // 已注入(首章优先)
- stage.appendChild(doc.importNode(jb, true));
- });
- // 4. 注入完成后执行节的其他内联脚本(数据/渲染函数/初始化)。
- // 此刻页面的 JSON 数据块与各 grid/详情容器已在总控 DOM,页内 IIFE
- // (KOC 矩阵 / 商品墙 / 六国商品等立即渲染)能正常找到目标元素。
- // 排除:deck-config JSON、SCREEN_TIMELINES 块、外链脚本、deck.init 引导块。
- execInlineScripts(doc, file);
- embedPages[i] = { page: page, count: chosen.length };
- if (done) done();
- });
- }
- function checkEmbedReady() {
- var loaded = embedPages.filter(function (p) { return p; }).length;
- if (loaded === shellPlan.pages.length) {
- // 全部注入完成 → 用单页模式播放(屏状态机),进度按所有注入屏
- embedReady = true;
- collect();
- if (total === 0) return;
- screens.forEach(function (s) {
- s.el.style.position = 'absolute';
- s.el.style.inset = '0';
- });
- // 复用单页导航(无 topbar 页内导航,进度用总控 renderShellChrome)。
- // 键盘不放这里绑:embed 模式下 bindShellKeys 已在 document 上监听 keydown,
- // 再 bindKeys 会对同一次 ↓/↑ 触发两次 next/prev(每按一次跳两屏,
- // 播放位置与页码/章节错位 —— 2026-08-30 回归修复)。点击/滚轮/触摸无此冲突。
- bindClick();
- bindMouse();
- bindTouch();
- showScreen(0);
- activate(screens[0]);
- renderShellChrome();
- }
- }
- function initEmbed() {
- embedMode = true;
- embedPages = [];
- // 若 http 可用 fetch 则注入;file:// 下 fetch 受限 → 提示用 http
- if (location.protocol === 'file:') {
- var msg = document.createElement('div');
- msg.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;font:14px var(--font-mono);color:var(--text-3);text-align:center;padding:40px;';
- msg.textContent = 'embed(JS注入)模式需要 http 访问(file:// 下 fetch 受限)。请用 http 打开总控页。';
- document.body.appendChild(msg);
- return;
- }
- // 串行加载:保证注入 DOM 顺序 = plan.pages 顺序(并发 fetch 会乱序导致故事线错位)
- var embIdx = 0;
- function nextEmb() {
- if (embIdx >= shellPlan.pages.length) { checkEmbedReady(); return; }
- embedChapter(shellPlan.pages[embIdx], embIdx, nextEmb);
- embIdx++;
- }
- nextEmb();
- }
- function loadPage(i, visible) {
- if (i < 0 || i >= shellPlan.pages.length) return;
- if (shellFrames[i]) return; // 不重复创建;不销毁(翻回保留状态)
- var p = shellPlan.pages[i];
- var fr = document.createElement('iframe');
- fr.className = 'shell-frame';
- fr.setAttribute('data-page', String(i));
- fr.setAttribute('aria-label', p.title || ('第 ' + (i + 1) + ' 页'));
- fr.title = p.file || '';
- // 隐藏用 visibility:hidden 而非 display:none:保留布局尺寸,避免 mermaid/logic-flow
- // 在 0×0 上下文渲染产生 NaN transform;不可见也不可交互。
- fr.style.visibility = visible ? 'visible' : 'hidden';
- fr.__createdVisible = !!visible;
- fr.src = p.file; // 相对总控页
- var stage = document.querySelector('.shell-stage') || document.body;
- stage.appendChild(fr);
- shellFrames[i] = fr;
- fr.addEventListener('load', function () {
- postToFrame(fr, { type: 'deck-register' }); // 确认容器模式(页收到后才接管翻章)
- });
- }
- function showPage(i) {
- if (i < 0 || i >= shellPlan.pages.length) return;
- if (!shellFrames[i]) loadPage(i, true); // 未预加载 → 直接创建并可见(加载时自然播放入场)
- shellFrames.forEach(function (f, j) {
- if (f) f.style.visibility = (j === i) ? 'visible' : 'hidden';
- });
- var first = !shellShown[i];
- shellShown[i] = true;
- shellCur = i;
- var p = shellPlan.pages[i];
- if (first && !shellFrames[i].__createdVisible) {
- // 预加载(隐藏)期间入场已悄悄放完 → 首次展示重演入场,翻页无白屏
- // 若编排定义了屏级切片 [from,to] → 下发 range 让页内从起始屏开始并在边界自动翻章
- if (typeof p.from === 'number' || typeof p.to === 'number') {
- postToFrame(shellFrames[i], { type: 'deck-cmd', cmd: 'range',
- from: (typeof p.from === 'number' ? p.from : 0),
- to: (typeof p.to === 'number' ? p.to : -1) });
- } else {
- postToFrame(shellFrames[i], { type: 'deck-cmd', cmd: 'jump', index: 0 });
- }
- }
- loadPage(i + 1, false); // 预加载下一页(隐藏)
- try { shellFrames[i].contentWindow.focus(); } catch (e) {} // 键盘焦点进 iframe
- renderShellChrome();
- }
- // 有效屏数/已完成屏数(支持 plan 屏级切片 from/to)
- function effTotal(p, st) {
- var raw = p.screens || (st && st.total) || 0;
- if (!raw) return 0;
- var from = (typeof p.from === 'number') ? p.from : 0;
- var to = (typeof p.to === 'number') ? Math.min(p.to, raw - 1) : raw - 1;
- if (to < from) return 0;
- return to - from + 1;
- }
- function effDone(p, st) {
- var raw = p.screens || (st && st.total) || 0;
- if (!raw) return 0;
- var idx = st ? st.index : 0;
- var from = (typeof p.from === 'number') ? p.from : 0;
- var to = (typeof p.to === 'number') ? Math.min(p.to, raw - 1) : raw - 1;
- if (idx < from) return 0;
- return Math.min(to - from + 1, idx - from + 1);
- }
- function renderShellChrome() {
- var pages = shellPlan.pages;
- var tabs = document.querySelectorAll('.shell-tab');
- // embed 模式:注入后所有屏 flat 排列,按当前屏反推所在节高亮
- if (embedMode && embedReady) {
- var curScreen = current;
- var acc = 0, curPage = 0;
- for (var q = 0; q < pages.length; q++) {
- var cnt = (embedPages[q] && embedPages[q].count) || 0;
- if (curScreen < acc + cnt) { curPage = q; break; }
- acc += cnt;
- curPage = q + 1;
- }
- tabs.forEach(function (b, j) { b.classList.toggle('cur', j === curPage); });
- var totalE = total, doneE = Math.min(totalE, curScreen + 1);
- var bar = document.querySelector('.shell-bottom .deck-progress .bar');
- var pos = document.querySelector('.shell-pos');
- var pn = document.querySelector('.shell-pagename');
- if (bar) bar.style.width = Math.min(100, (doneE / totalE) * 100) + '%';
- if (pos) pos.textContent = (curPage + 1) + '/' + pages.length + ' 节 · ' + doneE + '/' + totalE + ' 屏';
- if (pn) pn.textContent = pages[curPage].title || '';
- scrollActiveTabIntoView();
- return;
- }
- tabs.forEach(function (b, j) { b.classList.toggle('cur', j === shellCur); });
- // 进度按「屏幕(screen)」颗粒度计算:每节=1个 iframe,内含多屏(每屏≈30s)。
- // 屏数优先级:plan.pages[i].screens(编排权威标注)> iframe 上报 total(实测量);
- // plan.pages[i].from/to(屏级切片)只计切片内的有效屏。
- var totalScreens = 0, doneScreens = 0, loadedAny = false;
- pages.forEach(function (p, i) {
- var st = shellLast[i];
- var total = effTotal(p, st);
- if (!total) return;
- loadedAny = true;
- totalScreens += total;
- if (i < shellCur) doneScreens += total; // 已翻完的节:全屏计入
- else if (i === shellCur) doneScreens += effDone(p, st);
- });
- var bar = document.querySelector('.shell-bottom .deck-progress .bar');
- var pos = document.querySelector('.shell-pos');
- var pn = document.querySelector('.shell-pagename');
- if (loadedAny && totalScreens > 0) {
- if (bar) bar.style.width = Math.min(100, (doneScreens / totalScreens) * 100) + '%';
- if (pos) pos.textContent = (shellCur + 1) + '/' + pages.length + ' 节 · ' + Math.min(doneScreens, totalScreens) + '/' + totalScreens + ' 屏';
- } else {
- // 完全未知 → 按节回退(首屏上报前短暂过渡)
- var done = 0;
- pages.forEach(function (p, i) {
- if (i < shellCur) { done += 1; return; }
- if (i === shellCur) {
- var st2 = shellLast[i];
- if (st2 && st2.total) done += Math.min(1, ((st2.index || 0) + 1) / st2.total);
- }
- });
- if (bar) bar.style.width = ((done / pages.length) * 100) + '%';
- if (pos) pos.textContent = (shellCur + 1) + ' / ' + pages.length + ' 页';
- }
- if (pn) pn.textContent = pages[shellCur].title || '';
- scrollActiveTabIntoView();
- }
- function broadcastTheme() {
- var t = window.HDTheme ? window.HDTheme.get() : (document.documentElement.getAttribute('data-theme') || 'dark');
- shellFrames.forEach(function (f) { if (f) postToFrame(f, { type: 'deck-cmd', cmd: 'theme', theme: t }); });
- }
- function bindShellKeys() {
- document.addEventListener('keydown', function (e) {
- var t = e.target;
- if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
- if (t && t.closest && (t.closest('button') || t.closest('a'))) return; // 焦点在按钮上交给原生
- var k = e.key;
- if (k === 'ArrowDown' || k === 's' || k === 'S' || k === 'PageDown' || k === ' ') {
- e.preventDefault(); if (embedMode && embedReady) next(); else cmdActive('next');
- } else if (k === 'ArrowUp' || k === 'w' || k === 'W' || k === 'PageUp') {
- e.preventDefault(); if (embedMode && embedReady) prev(); else cmdActive('prev');
- } else if (k === 'ArrowRight' || k === 'd' || k === 'D') {
- if (embedMode && embedReady) nextChapter(); else showPage(shellCur + 1);
- } else if (k === 'ArrowLeft' || k === 'a' || k === 'A') {
- if (embedMode && embedReady) prevChapter(); else showPage(shellCur - 1);
- } else if (k === 'Home') {
- e.preventDefault();
- if (embedMode && embedReady) jump(0); else cmdActive('home');
- } else if (k === 'End') {
- e.preventDefault();
- if (embedMode && embedReady) jump(total - 1); else cmdActive('end');
- }
- });
- }
- /* ---------- 手机端:右下角「全屏」切换(不旋转——手机自带横屏) ---------- */
- function isMobileUA() {
- return /Android|iPhone|iPad|iPod|Mobile|Mobi|Opera Mini|IEMobile/i.test(navigator.userAgent)
- || (navigator.platform && /iPhone|iPad|iPod|Android/i.test(navigator.platform));
- }
- var fullscreenBtn = null;
- function ensureFullscreenStyle() {
- if (document.getElementById('shell-fullscreen-style')) return;
- var st = document.createElement('style');
- st.id = 'shell-fullscreen-style';
- st.textContent =
- // ============ 全屏:不旋转舞台,16:9 等比 fit 视口居中(letterbox) ============
- // 手机系统自带横屏:用户转手机即横屏。全屏模式只负责放大 + 隐藏系统 UI,
- // 舞台始终按 16:9 contain(--fs-w/--fs-h 由 JS 计算),避免拉伸变形。
- 'html.shell-fullscreen .shell-stage{'
- + 'position:fixed;top:50%;left:50%;'
- + 'transform:translate(-50%,-50%);'
- + 'width:var(--fs-w,100vw);height:var(--fs-h,100vh);'
- + 'background:var(--bg-0);'
- + 'box-shadow:0 0 0 2000px var(--bg-0);}'
- // 顶栏/底栏:全屏时保持可用,收窄/半透明,不遮挡内容
- + 'html.shell-fullscreen .shell-topbar{'
- + 'opacity:.9;}'
- + 'html.shell-fullscreen .shell-bottom{'
- + 'opacity:.9;}'
- // 常规右下角全屏按钮
- + '.shell-fs-btn{position:fixed;right:14px;bottom:56px;z-index:80;'
- + 'pointer-events:auto;cursor:pointer;'
- + 'font:600 12px var(--font-mono);color:var(--text-3);'
- + 'background:var(--glass-bg);border:1px solid var(--border-subtle);'
- + 'border-radius:var(--radius-pill);padding:5px 12px;'
- + 'backdrop-filter:blur(var(--glass-blur));opacity:.82;'
- + 'box-shadow:0 2px 8px rgba(0,0,0,.18);}'
- + '.shell-fs-btn:active{opacity:1;}'
- // 窄屏折叠导航:汉堡按钮 + 下拉面板
- + '.shell-collapsed .deck-tabs{'
- + 'position:fixed;top:52px;left:12px;right:12px;'
- + 'flex-direction:column;align-items:stretch;gap:4px;'
- + 'background:var(--bg-1);border:1px solid var(--border-subtle);'
- + 'border-radius:var(--radius-md);padding:8px;'
- + 'display:none;max-height:62vh;overflow-y:auto;'
- + 'box-shadow:0 8px 28px rgba(0,0,0,.35);z-index:60;}'
- + '.shell-collapsed .deck-tab{width:100%;text-align:left;padding:8px 12px;border-radius:var(--radius-sm);}'
- + '.shell-collapsed.shell-open .deck-tabs{display:flex;}'
- + '.shell-burger{pointer-events:auto;display:inline-flex;align-items:center;justify-content:center;'
- + 'width:34px;height:30px;padding:0;margin-right:4px;flex-shrink:0;'
- + 'color:var(--text-3);border:1px solid var(--border-subtle);'
- + 'border-radius:var(--radius-pill);background:var(--glass-bg);cursor:pointer;}'
- + '.shell-burger:hover{color:var(--brand-1);border-color:var(--border-strong);}';
- document.head.appendChild(st);
- }
- function applyFullscreenSize() {
- // 舞台 16:9 contain 于视口(不旋转):宽高按视口 fit,letterbox 留黑边
- var vw = window.innerWidth, vh = window.innerHeight;
- var w, h;
- if (vw / vh > 16 / 9) { h = vh; w = h * 16 / 9; }
- else { w = vw; h = w * 9 / 16; }
- var el = document.documentElement;
- el.style.setProperty('--fs-w', Math.round(w) + 'px');
- el.style.setProperty('--fs-h', Math.round(h) + 'px');
- }
- function toggleFullscreen() {
- var el = document.documentElement;
- var inFs = el.classList.contains('shell-fullscreen');
- if (!inFs) {
- applyFullscreenSize();
- el.classList.add('shell-fullscreen');
- var fs = el.requestFullscreen || el.webkitRequestFullscreen || el.msRequestFullscreen;
- if (fs) { try { fs.call(el); } catch (e) {} }
- if (fullscreenBtn) fullscreenBtn.textContent = '退出全屏';
- } else {
- el.classList.remove('shell-fullscreen');
- var ex = document.exitFullscreen || document.webkitExitFullscreen || document.msExitFullscreen;
- if (ex && (document.fullscreenElement || document.webkitFullscreenElement)) {
- try { ex.call(document); } catch (e) {}
- }
- if (fullscreenBtn) fullscreenBtn.textContent = '全屏';
- }
- }
- function buildFullscreenBtn() {
- if (fullscreenBtn) return;
- ensureFullscreenStyle();
- var b = document.createElement('button');
- b.className = 'shell-fs-btn';
- b.type = 'button';
- b.textContent = '全屏';
- b.title = '切换全屏(手机端)';
- b.addEventListener('click', toggleFullscreen);
- document.body.appendChild(b);
- fullscreenBtn = b;
- // 全屏状态变化时同步按钮文案(浏览器返回键退出全屏等)
- document.addEventListener('fullscreenchange', syncFullscreenBtn);
- document.addEventListener('webkitfullscreenchange', syncFullscreenBtn);
- // 尺寸变化时重算 16:9(转屏、全屏高度变化)
- window.addEventListener('resize', function () {
- if (document.documentElement.classList.contains('shell-fullscreen')) applyFullscreenSize();
- });
- window.addEventListener('orientationchange', function () {
- setTimeout(function () {
- if (document.documentElement.classList.contains('shell-fullscreen')) applyFullscreenSize();
- }, 300);
- });
- }
- function syncFullscreenBtn() {
- if (!fullscreenBtn) return;
- var inFs = !!(
- document.fullscreenElement || document.webkitFullscreenElement || document.msFullscreenElement
- );
- fullscreenBtn.textContent = inFs ? '退出全屏' : '全屏';
- }
- function cmdActive(cmd) {
- var f = shellFrames[shellCur];
- if (f) postToFrame(f, { type: 'deck-cmd', cmd: cmd });
- }
- /* 总控侧:接收页面上报 */
- function onShellMsg(e) {
- var d = e.data;
- if (!d || typeof d !== 'object') return;
- var idx = -1; // e.source 是 iframe 的 window,需与 contentWindow 比对
- for (var k = 0; k < shellFrames.length; k++) {
- if (shellFrames[k] && shellFrames[k].contentWindow === e.source) { idx = k; break; }
- }
- if (idx < 0) return; // 只认自己管理的 iframe
- if (d.type === 'deck-progress') {
- shellLast[idx] = { index: d.index, total: d.total, state: d.state };
- if (idx === shellCur) renderShellChrome();
- } else if (d.type === 'deck-go') {
- // 页内翻章(或最后一屏 ↓):按 plan 顺序切页,忽略页内 deck-config 的文件跳转
- showPage(idx + (d.dir >= 0 ? 1 : -1));
- }
- }
- /* 页侧:接收总控指令(总控页自身也挂此监听,靠 source 过滤互不干扰) */
- function onPageMessage(e) {
- var d = e.data;
- if (!d || typeof d !== 'object') return;
- if (e.source !== window.parent) return; // 只接受父窗口指令
- if (d.type === 'deck-register') {
- shellMode = true; // 父窗口确认为总控容器
- if (pendingGo) { postShell(pendingGo); pendingGo = null; }
- return;
- }
- if (d.type !== 'deck-cmd') return;
- if (d.cmd === 'next') next();
- else if (d.cmd === 'prev') prev();
- else if (d.cmd === 'jump') { if (typeof d.index === 'number') jump(d.index); }
- else if (d.cmd === 'home') jump(0);
- else if (d.cmd === 'end') jump(total - 1);
- else if (d.cmd === 'range') {
- // 屏级切片:总控下发 [from,to](0-based),页内翻屏被钳制在该范围,两端自动翻章
- shellFrom = (typeof d.from === 'number') ? d.from : 0;
- shellTo = (typeof d.to === 'number') ? d.to : -1;
- jump(shellFrom);
- }
- else if (d.cmd === 'theme') { if (window.HDTheme && d.theme) window.HDTheme.set(d.theme); }
- }
- /* 字体/logo 加载后 tab 宽度变化,窗口 load 时校正一次居中(直接打开深层页的场景) */
- window.addEventListener('load', function () { scrollActiveTabIntoView(); });
- window.deck = {
- init: init,
- next: next,
- prev: prev,
- jump: jump,
- nextChapter: nextChapter,
- prevChapter: prevChapter,
- goFile: goFile,
- // 总控容器模式
- showPage: showPage,
- loadPage: loadPage
- };
- })();
|