deck.js 56 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291
  1. /* ============================================================
  2. * lib/player/deck.js · 混沌AI院演示播放器核心
  3. *
  4. * 职责:收集 .screen → 每屏状态机 idle→playing→done;
  5. * 进入新屏自动播放入场动画/视频(首屏不黑屏);↓ 先演完当前动画再切屏(门控);
  6. * 动画运行中 ↓ 快进到结尾;上下键/翻页器全兼容;进度条;两级导航(读 deck-config)。
  7. * 鼠标支持:滚轮上/下 = 上/下一屏;侧键 前进=下章、后退=上章(防抖,兼容翻页笔)。
  8. *
  9. * 页面接入:
  10. * <section class="screen" data-tl="s2">...</section>
  11. * <script src=".../deck.js"></script>
  12. * <script>
  13. * window.SCREEN_TIMELINES = { s2: function(ctx){ var tl=gsap.timeline(); ...; return tl; } };
  14. * deck.init();
  15. * </script>
  16. * 普通 script,非 module,file:// 可用。禁止页面自写 keydown/scroll。
  17. * ============================================================ */
  18. (function () {
  19. var screens = [];
  20. var current = 0;
  21. var total = 0;
  22. var cfg = null;
  23. var inited = false;
  24. /* ---------- 总控容器模式(shell)状态 ---------- */
  25. var shellMode = !!(window.parent && window.parent !== window); // 页面被 iframe 装载
  26. var shellPlan = null; // 编排配置(deck-plan)
  27. var shellCur = 0; // 当前页序号
  28. var shellFrames = []; // iframe 元素(不销毁,翻回保留状态)
  29. var shellShown = []; // 是否已展示过(首次展示重演入场,无白屏)
  30. var shellLast = []; // 各页最近上报的进度 {index,total,state}
  31. var pendingGo = null; // 容器注册前的翻章请求缓冲(防止注册竞态丢失)
  32. var shellFrom = 0; // 容器模式:切片起始屏(0-based,总控 deck-range 下发)
  33. var shellTo = -1; // 容器模式:切片结束屏(-1 = 整节)
  34. /* ---------- embed(JS 注入)模式状态 ---------- */
  35. var embedMode = false; // 总控用 JS 注入单页 HTML(非 iframe)
  36. var embedPages = []; // 注入后的屏信息 {page, screens, tlName}
  37. var embedReady = false;
  38. /* ---------- 工具 ---------- */
  39. function readConfig() {
  40. var el = document.querySelector('script.deck-config');
  41. if (!el) return null;
  42. try { return JSON.parse(el.textContent); } catch (e) { return null; }
  43. }
  44. /* 与总控容器(deck-shell)通信:postMessage 跨源/跨 file:// 均可用 */
  45. function postShell(msg) {
  46. try { window.parent.postMessage(msg, '*'); } catch (e) {}
  47. }
  48. function fmt(n, dec, sep) {
  49. var s = n.toFixed(dec == null ? 0 : dec);
  50. if (sep) s = s.replace(/\B(?=(\d{3})+(?!\d))/g, sep);
  51. return s;
  52. }
  53. function makeCtx(s) {
  54. var el = s.el;
  55. return {
  56. el: el,
  57. q: function (sel) { return el.querySelector(sel); },
  58. qAll: function (sel) { return el.querySelectorAll(sel); },
  59. // 数字滚动,返回 gsap tween,可用 tl.add(ctx.count(...), pos)
  60. count: function (sel, to, opts) {
  61. opts = opts || {};
  62. var node = el.querySelector(sel);
  63. if (!node || !window.gsap) return null;
  64. var obj = { val: 0 };
  65. var target = (opts.to != null) ? opts.to : to;
  66. var tw = window.gsap.to(obj, {
  67. val: target,
  68. duration: opts.duration || 1.2,
  69. ease: opts.ease || 'power1.out',
  70. onUpdate: function () { node.textContent = fmt(obj.val, opts.dec || 0, opts.sep); }
  71. });
  72. return tw;
  73. }
  74. };
  75. }
  76. /* ---------- 状态 ---------- */
  77. function collect() {
  78. var els = Array.prototype.slice.call(document.querySelectorAll('.screen'));
  79. screens = els.map(function (el) {
  80. var tlKey = el.getAttribute('data-tl');
  81. return {
  82. el: el,
  83. tlKey: tlKey,
  84. hasTL: !!(tlKey && window.SCREEN_TIMELINES && typeof window.SCREEN_TIMELINES[tlKey] === 'function'),
  85. video: el.querySelector('video[data-gate]'),
  86. auto: el.hasAttribute('data-gate-autoplay'),
  87. state: 'idle',
  88. tl: null
  89. };
  90. });
  91. total = screens.length;
  92. }
  93. /* ---------- 展示 ---------- */
  94. function showScreen(i) {
  95. screens.forEach(function (s, j) {
  96. s.el.style.visibility = (j === i) ? 'visible' : 'hidden';
  97. // 根因修复:`.screen[data-tl].in > .s-wrap { visibility: visible }`(base.css)是 .s-wrap 的
  98. // 直接声明,会覆盖从屏级继承的 hidden。动画播完 .in 类残留 → 旧屏屏级 hidden 但 .s-wrap
  99. // 仍 visible,内容叠在新屏上(旧屏盖新屏的根因)。此处对 .s-wrap 直接强制隐藏/恢复。
  100. var wrap = s.el.querySelector('.s-wrap');
  101. if (wrap) wrap.style.visibility = (j === i) ? '' : 'hidden';
  102. });
  103. current = i;
  104. updateProgress();
  105. highlightTab();
  106. }
  107. // 屏激活:进入新一页/新屏直接走入场动画加载(不黑屏)。
  108. // 有时间线 → 自动播放;autoplay 视频 → 自动播;data-gate 视频 → 保持 idle 等 ↓;
  109. // 无动画 → 直接视为完成。↓ 在动画运行中可快进(见 next)。
  110. function activate(s) {
  111. if (s.state !== 'idle') return;
  112. if (s.hasTL) { playTimeline(s); return; }
  113. if (s.video && s.auto) { playVideo(s); return; }
  114. if (s.video) return; // data-gate 视频:保持 idle,等 ↓ 播放
  115. s.state = 'done'; updateProgress();
  116. }
  117. /* ---------- 动画 / 视频门控 ---------- */
  118. function playTimeline(s) {
  119. if (!s.hasTL) return false;
  120. s.el.classList.add('in');
  121. var fn = window.SCREEN_TIMELINES[s.tlKey];
  122. var ctx = makeCtx(s);
  123. var tl = null;
  124. try { tl = fn(ctx); } catch (e) { tl = null; }
  125. if (!tl || !window.gsap) { s.state = 'done'; updateProgress(); return false; }
  126. s.tl = tl;
  127. s.state = 'playing';
  128. tl.eventCallback('onComplete', function () {
  129. // 有时间线 + data-gate-autoplay:时间线播完自动接播视频(再按 ↓ 才切屏)
  130. if (s.video && s.auto) { playVideo(s); return; }
  131. s.state = 'done'; updateProgress();
  132. });
  133. tl.play();
  134. updateProgress();
  135. return true;
  136. }
  137. function playVideo(s) {
  138. var v = s.video;
  139. if (!v) return;
  140. var ended = function () { s.state = 'done'; updateProgress(); };
  141. v.removeEventListener('ended', ended);
  142. v.addEventListener('ended', ended);
  143. var p = v.play();
  144. if (p && p.catch) p.catch(function () { s.state = 'done'; updateProgress(); });
  145. s.state = 'playing';
  146. updateProgress();
  147. }
  148. function resetScreen(s) {
  149. if (s.tl) {
  150. // 先播到结尾让 from-tween 渲染到最终态(opacity 1),再 kill:
  151. // 直接 kill 一个播放中的 timeline 会残留内联 opacity:0(immediateRender),
  152. // 且后续新建的 timeline 无法推进 → 重演 / jump / prev 时整屏空白(CBEC p0/p1 实测)。
  153. try { s.tl.progress(1); } catch (e) {}
  154. try { s.tl.kill(); } catch (e) {}
  155. s.tl = null;
  156. }
  157. s.el.classList.remove('in');
  158. s.state = 'idle';
  159. if (s.video) { s.video.pause(); s.video.currentTime = 0; }
  160. }
  161. /* ---------- 导航动作 ---------- */
  162. function next() {
  163. if (current >= total) return;
  164. var s = screens[current];
  165. if (s.state === 'done') { advance(); return; }
  166. if (s.state === 'playing') {
  167. // 入场动画运行中按 ↓:快进到结尾并切下一屏(新屏入场自动播放);视频保持本屏待重播
  168. if (s.video && !s.video.paused) { s.video.currentTime = s.video.duration; return; }
  169. if (s.tl) s.tl.progress(1);
  170. advance();
  171. return;
  172. }
  173. // idle(正常已被 activate 自动播放;防御 data-gate 视频屏)
  174. if (s.hasTL && playTimeline(s)) return;
  175. if (s.video) { playVideo(s); return; }
  176. s.state = 'done';
  177. advance();
  178. }
  179. function advance() {
  180. // 容器切片:当前屏已是切片结束屏 → 直接翻章(交给总控切下一节),不进入切片外屏
  181. if (shellTo >= 0 && current >= shellTo) { nextChapter(); return; }
  182. if (current < total - 1) {
  183. showScreen(current + 1);
  184. activate(screens[current]); // showScreen 已把 current 指向新屏 → 自动播放入场
  185. } else {
  186. nextChapter();
  187. }
  188. }
  189. function prev() {
  190. // 容器切片:当前屏已是切片起始屏 → 直接翻上一章
  191. if (shellFrom > 0 && current <= shellFrom) { prevChapter(); return; }
  192. if (current > 0) {
  193. resetScreen(screens[current]);
  194. current--;
  195. resetScreen(screens[current]);
  196. showScreen(current);
  197. activate(screens[current]); // 上一屏重演入场(可重演)
  198. } else {
  199. prevChapter();
  200. }
  201. }
  202. function jump(i) {
  203. // 容器切片:跳转范围钳制在 [from, to]
  204. if (shellTo >= 0) i = Math.max(shellFrom, Math.min(i, shellTo));
  205. if (i < 0 || i >= total) return;
  206. resetScreen(screens[current]);
  207. current = i;
  208. resetScreen(screens[current]);
  209. showScreen(current);
  210. activate(screens[current]);
  211. }
  212. /* ---------- 章节导航 ---------- */
  213. function goFile(file, dir) {
  214. if (!file) return;
  215. if (shellMode) {
  216. // 容器模式:翻章请求交给总控(总控按 plan 顺序编排),不在 iframe 内跳文件
  217. pendingGo = { type: 'deck-go', file: file, dir: dir };
  218. postShell(pendingGo);
  219. return;
  220. }
  221. var cur = location.pathname.split('/').pop();
  222. if (file === cur) { dir > 0 ? jump(0) : jump(total - 1); return; }
  223. location.href = file;
  224. }
  225. function nextChapter() {
  226. if (embedMode && embedReady) { embedJumpSection(1); return; }
  227. goFile(cfg ? cfg.next : null, 1);
  228. }
  229. function prevChapter() {
  230. if (embedMode && embedReady) { embedJumpSection(-1); return; }
  231. goFile(cfg ? cfg.prev : null, -1);
  232. }
  233. /* embed(JS注入)模式:节级跳转统一走这里(←→/侧键/触摸横滑/末屏翻章)。
  234. * 节 = plan.pages 一项;embed 下所有屏 flat 注入,按节累计屏数定位「该节第一屏」。
  235. * 不用 next()/prev()(那在 embed 下是切屏,不是切节)。 */
  236. function embedSectionIndex() {
  237. var acc = 0;
  238. for (var q = 0; q < shellPlan.pages.length; q++) {
  239. var cnt = (embedPages[q] && embedPages[q].count) || 0;
  240. if (current < acc + cnt) return q;
  241. acc += cnt;
  242. }
  243. return shellPlan.pages.length - 1;
  244. }
  245. function embedJumpSection(dir) {
  246. var sec = embedSectionIndex() + dir;
  247. if (sec < 0 || sec >= shellPlan.pages.length) return; // 边界节不翻,与 iframe showPage 一致
  248. var target = 0;
  249. for (var q = 0; q < sec; q++) target += (embedPages[q] && embedPages[q].count) || 0;
  250. jump(target);
  251. }
  252. /* ---------- 界面 ---------- */
  253. function ensureThemeBtn() {
  254. if (document.querySelector('.theme-toggle')) return;
  255. var topbar = document.querySelector('.deck-topbar');
  256. if (!topbar) return;
  257. var btn = null;
  258. // 优先 theme.js 的图标按钮(太阳/月亮),缺失时退化为文字兜底
  259. if (window.HDTheme && window.HDTheme.createToggleBtn) {
  260. btn = window.HDTheme.createToggleBtn();
  261. } else {
  262. btn = document.createElement('button');
  263. btn.className = 'theme-toggle';
  264. btn.type = 'button';
  265. btn.textContent = '主题';
  266. btn.addEventListener('click', function () { if (window.HDTheme) window.HDTheme.toggle(); });
  267. }
  268. var left = topbar.querySelector('.deck-left');
  269. if (!left) { left = document.createElement('div'); left.className = 'deck-left'; topbar.insertBefore(left, topbar.firstChild); }
  270. left.appendChild(btn);
  271. }
  272. function highlightTab() {
  273. if (!cfg) return;
  274. var tabs = document.querySelectorAll('.deck-tab');
  275. tabs.forEach(function (b) {
  276. b.classList.toggle('cur', b.getAttribute('data-ch') === cfg.page.id);
  277. });
  278. scrollActiveTabIntoView();
  279. }
  280. /* 顶栏章节/页面 tab 自动横向滚动:把高亮当前 tab 滚动到居中(不触边)。
  281. * 溢出检测用 scrollWidth(横向不溢出时忽略,含窄屏折叠面板纵向列)。 */
  282. function scrollActiveTabIntoView() {
  283. var nav = document.querySelector('.deck-tabs');
  284. var cur = document.querySelector('.deck-tab.cur');
  285. if (!nav || !cur) return;
  286. if (nav.scrollWidth <= nav.clientWidth) return; // 无需滚动
  287. var navRect = nav.getBoundingClientRect();
  288. var curRect = cur.getBoundingClientRect();
  289. var target = nav.scrollLeft + (curRect.left + curRect.width / 2) - (navRect.left + navRect.width / 2);
  290. var max = nav.scrollWidth - nav.clientWidth;
  291. target = Math.max(0, Math.min(target, max)); // 不触边:钳制在可滚范围
  292. if (Math.abs(nav.scrollLeft - target) > 1) {
  293. nav.scrollTo({ left: target, behavior: 'smooth' });
  294. }
  295. }
  296. function buildChrome() {
  297. if (shellMode) return; // 容器模式:顶栏/进度条由总控渲染,页面只保留 .screen 内容
  298. var topbar = document.querySelector('.deck-topbar');
  299. if (!topbar) { topbar = document.createElement('div'); topbar.className = 'deck-topbar'; document.body.appendChild(topbar); }
  300. if (cfg && cfg.chapters && !topbar.querySelector('.deck-tabs')) {
  301. var nav = document.createElement('nav');
  302. nav.className = 'deck-tabs';
  303. cfg.chapters.forEach(function (ch) {
  304. var b = document.createElement('button');
  305. b.className = 'deck-tab' + (ch.id === cfg.page.id ? ' cur' : '');
  306. b.type = 'button';
  307. b.setAttribute('data-ch', ch.id);
  308. b.textContent = ch.title;
  309. b.addEventListener('click', function () { goFile(ch.file, 1); });
  310. nav.appendChild(b);
  311. });
  312. topbar.appendChild(nav);
  313. }
  314. ensureThemeBtn();
  315. var right = topbar.querySelector('.deck-right');
  316. if (!right) { right = document.createElement('div'); right.className = 'deck-right'; topbar.appendChild(right); }
  317. // 右上角品牌 logo(theme.js 注入,随主题切换暗/浅图)
  318. if (window.HDTheme && window.HDTheme.ensureLogo) window.HDTheme.ensureLogo(right);
  319. var bottom = document.querySelector('.deck-bottom');
  320. if (!bottom) {
  321. bottom = document.createElement('div');
  322. bottom.className = 'deck-bottom';
  323. document.body.appendChild(bottom);
  324. var prog = document.createElement('div');
  325. prog.className = 'deck-progress';
  326. var bar = document.createElement('div');
  327. bar.className = 'bar';
  328. prog.appendChild(bar);
  329. bottom.appendChild(prog);
  330. var pos = document.createElement('span');
  331. pos.className = 'deck-pos';
  332. bottom.appendChild(pos);
  333. if (cfg && cfg.page) {
  334. var pn = document.createElement('span');
  335. pn.className = 'deck-pagename';
  336. pn.textContent = cfg.page.title;
  337. bottom.appendChild(pn);
  338. }
  339. }
  340. }
  341. function updateProgress() {
  342. var pct = total ? (current / total) * 100 : 0;
  343. var bar = document.querySelector('.deck-progress .bar');
  344. if (bar) bar.style.width = pct + '%';
  345. var pos = document.querySelector('.deck-pos');
  346. if (pos) pos.textContent = (current + 1) + ' / ' + total;
  347. // 容器模式:把本页进度上报总控(总控据此算全局进度条)
  348. if (shellMode) {
  349. var s = screens[current] || null;
  350. postShell({ type: 'deck-progress', index: current, total: total, state: s ? s.state : 'done' });
  351. }
  352. // embed 模式:屏进度变化同步总控 chrome
  353. if (embedMode && embedReady) renderShellChrome();
  354. }
  355. /* ---------- 输入 ---------- */
  356. function bindKeys() {
  357. document.addEventListener('keydown', function (e) {
  358. var t = e.target;
  359. if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
  360. var k = e.key;
  361. if (k === 'ArrowDown' || k === 's' || k === 'S' || k === 'PageDown' || k === ' ') {
  362. e.preventDefault(); next();
  363. } else if (k === 'ArrowUp' || k === 'w' || k === 'W' || k === 'PageUp') {
  364. e.preventDefault(); prev();
  365. } else if (k === 'ArrowRight' || k === 'd' || k === 'D') {
  366. e.preventDefault(); nextChapter();
  367. } else if (k === 'ArrowLeft' || k === 'a' || k === 'A') {
  368. e.preventDefault(); prevChapter();
  369. } else if (k === 'Home') {
  370. e.preventDefault(); jump(0);
  371. } else if (k === 'End') {
  372. e.preventDefault(); jump(total - 1);
  373. }
  374. });
  375. }
  376. function bindClick() {
  377. document.addEventListener('click', function (e) {
  378. var t = e.target;
  379. if (t && t.closest && (t.closest('a') || t.closest('button') || t.closest('.deck-topbar') || t.closest('.deck-bottom'))) return;
  380. if (e.clientX > window.innerWidth * 0.78 && e.clientX < window.innerWidth) next();
  381. });
  382. }
  383. function bindMouse() {
  384. // 滚轮:累计方向,超过阈值触发一次,冷却期防连击(轨迹板/鼠标均稳定)
  385. var wheelAcc = 0, lastDir = 0, wheelLock = 0;
  386. var STEP = 60, COOLDOWN = 550;
  387. document.addEventListener('wheel', function (e) {
  388. var t = e.target;
  389. // 顶栏章节/页面 tab 上滚轮 → 横向滚动 tabs(顶栏 hover 本就不翻屏;横向不溢出时忽略)
  390. if (t && t.closest && t.closest('.deck-tabs')) {
  391. var nav = t.closest('.deck-tabs');
  392. if (nav.scrollWidth > nav.clientWidth) {
  393. e.preventDefault();
  394. var step = e.deltaMode === 0 ? (e.deltaY || e.deltaX) : e.deltaY * 40;
  395. nav.scrollLeft += step;
  396. }
  397. return;
  398. }
  399. if (t && t.closest && (t.closest('a') || t.closest('button') || t.closest('.deck-topbar') || t.closest('.deck-bottom'))) return;
  400. var now = Date.now();
  401. if (now - wheelLock < COOLDOWN) return;
  402. if (e.deltaY === 0) return;
  403. var dir = e.deltaY > 0 ? 1 : -1; // 下滚 = 下一屏,上滚 = 上一屏
  404. wheelAcc = (lastDir === dir) ? wheelAcc + e.deltaY : e.deltaY;
  405. lastDir = dir;
  406. if (Math.abs(wheelAcc) < STEP) return; // 未到阈值先攒着
  407. wheelAcc = 0; wheelLock = now;
  408. e.preventDefault();
  409. if (dir > 0) next(); else prev();
  410. }, { passive: false });
  411. // 鼠标侧键:button 3(前进) → 下章,button 4(后退) → 上章(auxclick/mouseup 双绑兼容,防抖)
  412. var lastSide = 0;
  413. function side(e) {
  414. if (e.button !== 3 && e.button !== 4) return;
  415. var t = e.target;
  416. if (t && t.closest && (t.closest('a') || t.closest('button') || t.closest('.deck-topbar') || t.closest('.deck-bottom'))) return;
  417. var now = Date.now();
  418. if (now - lastSide < 450) return;
  419. lastSide = now;
  420. e.preventDefault();
  421. if (e.button === 3) nextChapter(); else prevChapter();
  422. }
  423. document.addEventListener('auxclick', side);
  424. document.addEventListener('mouseup', side);
  425. }
  426. function bindTouch() {
  427. // 触摸手势:上滑/下滑 = 下/上一屏(触屏习惯:上滑进下一页),左滑/右滑 = 下/上一章(与方向键一致)。
  428. // 用 Touch Events 而非 Pointer Events:touchmove + passive:false 才能真正 preventDefault
  429. // 阻止浏览器原生滚动/缩放;pointermove 的 preventDefault 对滚动无效(需 touch-action CSS,
  430. // 而全局 touch-action:none 会破坏 chat-window/group-chat 内部滚动)。
  431. if (!('ontouchstart' in window)) return; // 无触摸能力(桌面)直接跳过,不打扰鼠标
  432. var startX = 0, startY = 0, axis = 0; // axis: 0=未锁定 1=水平 2=垂直
  433. var tracking = false;
  434. var touchLock = 0;
  435. var THRESHOLD = 50, COOLDOWN = 550; // 阈值 50px、冷却 550ms(与滚轮一致)
  436. function isIgnored(t) { // 与 wheel/click 相同的忽略集,避免误触
  437. return !!(t && t.closest &&
  438. (t.closest('a') || t.closest('button') || t.closest('.deck-topbar') || t.closest('.deck-bottom')));
  439. }
  440. document.addEventListener('touchstart', function (e) {
  441. if (isIgnored(e.target)) return; // 交互元素不接管
  442. if (e.touches.length > 1) return; // 多点(捏合缩放)不接管
  443. tracking = true;
  444. axis = 0;
  445. startX = e.touches[0].clientX;
  446. startY = e.touches[0].clientY;
  447. });
  448. document.addEventListener('touchmove', function (e) {
  449. if (!tracking) return;
  450. if (e.touches.length > 1) { tracking = false; return; } // 双指出现 = 放弃手势
  451. var t = e.touches[0];
  452. var dx = t.clientX - startX;
  453. var dy = t.clientY - startY;
  454. if (axis === 0) {
  455. // 方向锁定:位移超 8px 判定轴向(水平/垂直二选一),防斜滑误触发
  456. if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
  457. axis = Math.abs(dx) > Math.abs(dy) ? 1 : 2;
  458. }
  459. e.preventDefault(); // 轴向锁定后才阻断原生滚动/缩放(passive:false)
  460. }, { passive: false });
  461. function finish(e) {
  462. if (!tracking) return;
  463. tracking = false;
  464. if (axis === 0) return; // 未到锁定阈值 = tap,放行 click
  465. var now = Date.now();
  466. if (now - touchLock < COOLDOWN) return; // 防连击
  467. var t = e.changedTouches[0];
  468. var dx = t.clientX - startX;
  469. var dy = t.clientY - startY;
  470. if (axis === 1) { // 水平:左滑=下章,右滑=上章
  471. if (Math.abs(dx) < THRESHOLD) return;
  472. touchLock = now;
  473. if (dx < 0) nextChapter(); else prevChapter();
  474. } else { // 垂直:上滑=下一屏,下滑=上一屏
  475. if (Math.abs(dy) < THRESHOLD) return;
  476. touchLock = now;
  477. if (dy < 0) next(); else prev();
  478. }
  479. }
  480. document.addEventListener('touchend', finish);
  481. document.addEventListener('touchcancel', function () { tracking = false; });
  482. }
  483. /* ---------- 入口 ---------- */
  484. function init(opts) {
  485. if (inited) return;
  486. inited = true;
  487. opts = opts || {};
  488. window.addEventListener('message', onPageMessage);
  489. if (opts.mode === 'shell') { initShell(opts); return; }
  490. cfg = readConfig();
  491. collect();
  492. if (total === 0) return;
  493. screens.forEach(function (s) {
  494. s.el.style.position = 'absolute';
  495. s.el.style.inset = '0';
  496. });
  497. buildChrome();
  498. bindKeys();
  499. bindClick();
  500. bindMouse();
  501. bindTouch();
  502. showScreen(0);
  503. activate(screens[0]); // 首屏自动播放入场,不黑屏
  504. }
  505. // 安全网:若页面未显式 init,DOM 就绪后自动启动
  506. function auto() {
  507. if (inited) return;
  508. if (document.querySelectorAll('.screen').length) init();
  509. }
  510. if (document.readyState === 'loading') {
  511. document.addEventListener('DOMContentLoaded', auto);
  512. } else {
  513. setTimeout(auto, 0);
  514. }
  515. /* ============================================================
  516. * 总控容器模式(shell):总控页用 iframe 加载多页 + 预加载无缝衔接 + 导航可编排。
  517. *
  518. * 用法(总控页,如 course/deck-shell.html):
  519. * <script type="application/json" class="deck-plan">{ "title": "...", "pages":[ {"file":"demo/p1.html","title":"01 ..."}, ... ] }</script>
  520. * <script src=".../deck.js"></script>
  521. * <script> deck.init({ mode: 'shell', planEl: '.deck-plan' }); </script>
  522. *
  523. * - plan.pages[].file 相对总控页;iframe 不销毁,翻回保留状态;预加载下一页(隐藏)→ 翻页无白屏。
  524. * - 页内 deck 保持单页模式不变(可独立打开);容器内通过 postMessage 上报进度、转发翻章请求。
  525. * - file:// 双击读内嵌 JSON(零 fetch);http 下可加 ?plan=plans/xxx.json 切换编排(fetch 失败回退内嵌)。
  526. * ============================================================ */
  527. function urlParam(name) {
  528. try {
  529. var m = location.search.match(new RegExp('[?&]' + name + '=([^&]*)'));
  530. return m ? decodeURIComponent(m[1]) : null;
  531. } catch (e) { return null; }
  532. }
  533. function readPlan(opts) {
  534. if (opts.plan && typeof opts.plan === 'object' && opts.plan.pages) return opts.plan;
  535. var url = opts.planUrl || urlParam('plan');
  536. if (url && location.protocol !== 'file:') {
  537. try {
  538. var xhr = new XMLHttpRequest();
  539. xhr.open('GET', url, false); // 同步,保持零构建的确定性(仅 http 生效)
  540. xhr.send(null);
  541. if (xhr.status >= 200 && xhr.status < 300) {
  542. var p = JSON.parse(xhr.responseText);
  543. if (p && p.pages) return p;
  544. }
  545. } catch (e) {}
  546. }
  547. var el = opts.planEl ? document.querySelector(opts.planEl) : document.querySelector('script.deck-plan');
  548. if (el) {
  549. try {
  550. var p2 = JSON.parse(el.textContent);
  551. if (p2 && p2.pages) return p2;
  552. } catch (e) {}
  553. }
  554. return null;
  555. }
  556. function initShell(opts) {
  557. var plan = readPlan(opts);
  558. if (!plan) {
  559. var err = document.createElement('div');
  560. err.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;'
  561. + 'font:16px var(--font-mono);color:var(--text-3);background:var(--bg-0);text-align:center;padding:40px;';
  562. err.textContent = '缺少编排配置:请在总控页内嵌 <script class="deck-plan"> JSON,或 http 下加 ?plan=plans/xxx.json。';
  563. document.body.appendChild(err);
  564. return;
  565. }
  566. shellPlan = plan;
  567. window.addEventListener('message', onShellMsg);
  568. buildShellChrome(plan);
  569. bindShellKeys();
  570. if (isMobileUA() || urlParam('mobile') === '1') buildFullscreenBtn(); // 手机端:右下角全屏切换(?mobile=1 供桌面调试)
  571. // embed 模式:plan 任一页带 picks(精确选屏)→ 走 JS 注入,不走 iframe
  572. var useEmbed = plan.pages.some(function (p) { return p && p.picks; });
  573. if (useEmbed) { initEmbed(); return; }
  574. showPage(0);
  575. }
  576. function buildShellChrome(plan) {
  577. var isNarrow = isMobileUA() || urlParam('mobile') === '1' || (window.matchMedia && window.matchMedia('(max-width: 768px)').matches);
  578. var topbar = document.createElement('div');
  579. topbar.className = 'deck-topbar shell-topbar' + (isNarrow ? ' shell-collapsed' : '');
  580. var left = document.createElement('div');
  581. left.className = 'deck-left';
  582. var title = document.createElement('span');
  583. title.className = 'shell-title';
  584. title.textContent = plan.title || '演示';
  585. left.appendChild(title);
  586. var btn = null;
  587. if (window.HDTheme && window.HDTheme.createToggleBtn) {
  588. btn = window.HDTheme.createToggleBtn();
  589. btn.addEventListener('click', function () { setTimeout(broadcastTheme, 0); });
  590. } else {
  591. btn = document.createElement('button');
  592. btn.className = 'theme-toggle';
  593. btn.type = 'button';
  594. btn.textContent = '主题';
  595. btn.addEventListener('click', function () {
  596. var cur = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
  597. document.documentElement.setAttribute('data-theme', cur);
  598. broadcastTheme();
  599. });
  600. }
  601. left.insertBefore(btn, title); // 主题按钮最左
  602. // 窄屏:汉堡按钮(折叠导航),点击展开/收起节列表
  603. if (isNarrow) {
  604. var burger = document.createElement('button');
  605. burger.className = 'shell-burger';
  606. burger.type = 'button';
  607. burger.setAttribute('aria-label', '展开页面导航');
  608. 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>';
  609. burger.addEventListener('click', function (ev) {
  610. ev.stopPropagation();
  611. topbar.classList.toggle('shell-open');
  612. });
  613. left.insertBefore(burger, left.firstChild);
  614. // 点击非折叠区关闭
  615. document.addEventListener('click', function (e) {
  616. if (!topbar.classList.contains('shell-open')) return;
  617. if (!topbar.contains(e.target)) topbar.classList.remove('shell-open');
  618. });
  619. }
  620. topbar.appendChild(left);
  621. var nav = document.createElement('nav');
  622. nav.className = 'deck-tabs';
  623. nav.setAttribute('aria-label', '页面导航');
  624. plan.pages.forEach(function (p, i) {
  625. var b = document.createElement('button');
  626. b.className = 'deck-tab shell-tab' + (i === 0 ? ' cur' : '');
  627. b.type = 'button';
  628. b.textContent = p.title || ('第 ' + (i + 1) + ' 页');
  629. b.title = p.file || '';
  630. b.addEventListener('click', function () {
  631. if (embedMode && embedReady) {
  632. // embed:跳到该节第一屏
  633. var acc = 0, target = 0;
  634. for (var q = 0; q < i; q++) { acc += (embedPages[q] && embedPages[q].count) || 0; }
  635. target = acc;
  636. jump(target);
  637. } else {
  638. showPage(i);
  639. }
  640. topbar.classList.remove('shell-open'); // 选完收起(窄屏)
  641. });
  642. nav.appendChild(b);
  643. });
  644. topbar.appendChild(nav);
  645. var right = document.createElement('div');
  646. right.className = 'deck-right';
  647. topbar.appendChild(right);
  648. document.body.appendChild(topbar);
  649. if (window.HDTheme && window.HDTheme.ensureLogo) window.HDTheme.ensureLogo(right);
  650. var bottom = document.createElement('div');
  651. bottom.className = 'deck-bottom shell-bottom';
  652. var prog = document.createElement('div');
  653. prog.className = 'deck-progress';
  654. var bar = document.createElement('div');
  655. bar.className = 'bar';
  656. prog.appendChild(bar);
  657. bottom.appendChild(prog);
  658. var pos = document.createElement('span');
  659. pos.className = 'deck-pos shell-pos';
  660. bottom.appendChild(pos);
  661. var pn = document.createElement('span');
  662. pn.className = 'deck-pagename shell-pagename';
  663. bottom.appendChild(pn);
  664. var prevB = document.createElement('button');
  665. prevB.className = 'shell-navbtn';
  666. prevB.type = 'button';
  667. prevB.textContent = '‹ 上一页';
  668. prevB.addEventListener('click', function () {
  669. if (embedMode && embedReady) prev(); else showPage(shellCur - 1);
  670. });
  671. bottom.appendChild(prevB);
  672. var nextB = document.createElement('button');
  673. nextB.className = 'shell-navbtn';
  674. nextB.type = 'button';
  675. nextB.textContent = '下一页 ›';
  676. nextB.addEventListener('click', function () {
  677. if (embedMode && embedReady) next(); else showPage(shellCur + 1);
  678. });
  679. bottom.appendChild(nextB);
  680. document.body.appendChild(bottom);
  681. }
  682. function postToFrame(fr, msg) {
  683. try { fr.contentWindow.postMessage(msg, '*'); } catch (e) {}
  684. }
  685. /* ============================================================
  686. * embed(JS 注入)模式:把单页 HTML 拉为字符串,DOMParser 解析,
  687. * 按 picks 精确选屏注入总控 DOM;timeline 合并进全局;节样式注入。
  688. * 相比 iframe:可精确到任意屏组合、可跨节跳着选屏、预渲染可控。
  689. * ============================================================ */
  690. function fetchText(url, cb) {
  691. var xhr = new XMLHttpRequest();
  692. xhr.open('GET', url, true);
  693. xhr.onreadystatechange = function () {
  694. if (xhr.readyState !== 4) return;
  695. if (xhr.status >= 200 && xhr.status < 300) cb(null, xhr.responseText);
  696. else cb('HTTP ' + xhr.status);
  697. };
  698. xhr.onerror = function () { cb('net'); };
  699. xhr.send(null);
  700. }
  701. // 把节 HTML 的相对资源路径转绝对(相对该节文件所在目录;用 URL 解析避免拼接错误)
  702. function absolutize(baseURL, el, attr) {
  703. var v = el.getAttribute(attr);
  704. if (!v || /^(https?:|data:|blob:|#)/.test(v)) return;
  705. // 页面内锚点 href="#s1-..." 保留
  706. if (v.charAt(0) === '#') return;
  707. try {
  708. var abs = new URL(v, baseURL).href;
  709. el.setAttribute(attr, abs);
  710. } catch (e) {}
  711. }
  712. // 提取节内联 <style>(含 style.css 内容合并)
  713. function extractStyles(doc, baseURL, out) {
  714. var links = doc.querySelectorAll('link[rel="stylesheet"]');
  715. var inlines = doc.querySelectorAll('style');
  716. links.forEach(function (l) {
  717. var href = l.getAttribute('href') || '';
  718. absolutize(baseURL, l, 'href');
  719. out.push(l.outerHTML);
  720. });
  721. inlines.forEach(function (s) { out.push(s.outerHTML); });
  722. }
  723. // 提取 SCREEN_TIMELINES(把 IIFE 的 TL 注册名改成节级命名,避免全局覆盖)
  724. function extractTimeline(html, ns) {
  725. // 找出 window.SCREEN_TIMELINES = (function(){...})(); 整段
  726. var m = html.match(/window\.SCREEN_TIMELINES\s*=\s*\(function[\s\S]*?\}\)\(\);/);
  727. if (!m) return '';
  728. return m[0].replace(/window\.SCREEN_TIMELINES\s*=\s*/, 'window["' + ns + '"] = ');
  729. }
  730. // 执行节的内联脚本(数据/渲染/初始化),让动态屏在 shell 里也能渲染。
  731. // 跳过:外链 src、deck-config JSON、SCREEN_TIMELINES 块、deck.init 引导块。
  732. // 数据与函数挂到 window(与单页一致);脚本在 DOMParser 解析后的 doc 上查询 DOM
  733. // 时可能拿到 doc 而非总控 DOM——因此这些脚本里的 DOM 初始化统一改由 timeline
  734. // 在总控 DOM 上触发(页内已有的 window 数据声明与纯函数保持原样执行即可)。
  735. function execInlineScripts(doc, pageFile) {
  736. var scripts = doc.querySelectorAll('script:not([src])');
  737. Array.prototype.forEach.call(scripts, function (sc) {
  738. if (sc.getAttribute('type') === 'application/json') return; // JSON 数据块,非 JS
  739. var code = sc.textContent || '';
  740. if (!code.trim()) return;
  741. if (/deck-config/.test(code)) return; // 页内导航 JSON,不执行
  742. // SCREEN_TIMELINES 定义段已由 extractTimeline 改名执行(window["__EMB_TL_N"])。
  743. // 本函数执行节的数据/渲染脚本,但做两处安全化:
  744. // 1) 挖掉 SCREEN_TIMELINES 段与 deck.init 引导(embed 由总控接管)
  745. // 2) 把 document.getElementById( 替换为安全版本:找不到返回 null(让
  746. // 立即渲染用 if(el) 守卫自然跳过,不抛错);把 document.addEventListener(
  747. // 替换为空操作(embed 键盘由总控接管,避免页面级监听冲突)
  748. // 这样数据声明、window.X 赋值、函数定义都正常执行挂到全局,供 timeline 渲染。
  749. if (!window.__embGEL) {
  750. // 安全 getElementById:找不到返回「幽灵元素」(属性/方法访问一律 noop),
  751. // 让 embed 下找不到容器的立即渲染不抛错(数据声明仍执行,渲染由 timeline 接管)。
  752. window.__embGEL = function (id) {
  753. var el = document.getElementById(id);
  754. if (el) return el;
  755. if (window.__embGhost) return window.__embGhost;
  756. window.__embGhost = new Proxy({}, {
  757. get: function (t, k) {
  758. if (k === 'children') return [];
  759. if (k === 'style') return new Proxy({}, { get: function () { return ''; }, set: function () { return true; } });
  760. return function () {};
  761. },
  762. set: function () { return true; }
  763. });
  764. return window.__embGhost;
  765. };
  766. window.__embNoop = function () {};
  767. }
  768. var execCode = code.replace(/window\.SCREEN_TIMELINES\s*=\s*\(function[\s\S]*?\}\)\(\);/g, '')
  769. .replace(/deck\.init\s*\(\s*\{?\s*\}?\s*\)\s*;/g, '')
  770. .replace(/document\.documentElement\.classList\.add\([^)]*\)\s*;?/g, '')
  771. .replace(/document\.getElementById\s*\(/g, 'window.__embGEL(')
  772. .replace(/document\.addEventListener\s*\(/g, 'window.__embNoop(');
  773. if (!execCode.trim()) return;
  774. try {
  775. // 间接 eval 全局作用域执行:数据声明与函数声明挂到 window,
  776. // 供 SCREEN_TIMELINES 里的渲染函数调用。
  777. (0, eval)(execCode);
  778. } catch (e) {
  779. console.error('[embed] inline ' + (pageFile || ''), e);
  780. }
  781. });
  782. }
  783. // 收集一节的屏 DOM(按 picks 或整节),并注入总控 stage
  784. function embedChapter(page, i, done) {
  785. var file = page.file;
  786. var baseURL = null;
  787. try { baseURL = new URL(file, location.href).href; } catch (e) {}
  788. fetchText(file, function (err, html) {
  789. if (err) { console.error('[embed] ' + file + ' ' + err); if (done) done(); return; }
  790. var doc = new DOMParser().parseFromString(html, 'text/html');
  791. var ns = '__EMB_TL_' + i;
  792. // 1. 注入节样式(link + style,基于节绝对 URL 解析相对路径)
  793. var styles = [];
  794. extractStyles(doc, baseURL, styles);
  795. if (styles.length) {
  796. var styleBox = document.getElementById('emb-styles');
  797. if (!styleBox) {
  798. styleBox = document.createElement('div');
  799. styleBox.id = 'emb-styles';
  800. styleBox.style.display = 'none';
  801. document.head.appendChild(styleBox);
  802. }
  803. styleBox.insertAdjacentHTML('beforeend', styles.join('\n'));
  804. }
  805. // 2. 注册节 timeline(节级命名空间,避免不同节同名 data-tl 互相覆盖)
  806. var tlCode = extractTimeline(html, ns);
  807. if (tlCode) {
  808. try {
  809. var fn = new Function(tlCode);
  810. fn();
  811. } catch (e) { console.error('[embed] TL ' + file, e); }
  812. // 合并进全局 SCREEN_TIMELINES:key = ns + '_' + 原key(如 __EMB_TL_11_s1)
  813. if (window[ns]) {
  814. window.SCREEN_TIMELINES = window.SCREEN_TIMELINES || {};
  815. var tlObj = window[ns];
  816. Object.keys(tlObj).forEach(function (k) {
  817. window.SCREEN_TIMELINES[ns + '_' + k] = tlObj[k];
  818. });
  819. delete window[ns];
  820. }
  821. }
  822. // 3. 按 picks 选屏注入 stage(屏 data-tl 改为节前缀命名,避免全局 timeline key 冲突)
  823. var all = doc.querySelectorAll('section.screen');
  824. var picks = page.picks || null;
  825. var chosen = [];
  826. if (picks && picks.length) {
  827. picks.forEach(function (idx) { if (all[idx]) chosen.push(all[idx]); });
  828. } else {
  829. chosen = Array.prototype.slice.call(all);
  830. }
  831. var stage = document.querySelector('.shell-stage') || document.body;
  832. chosen.forEach(function (sec) {
  833. // 克隆到总控 DOM(保留原样,动画/计时靠 SCREEN_TIMELINES + gsap)
  834. var clone = doc.importNode(sec, true);
  835. // data-tl 加节前缀:__E<i>_<原tl>,对应下方 timeline 的节级命名
  836. var t = clone.getAttribute('data-tl');
  837. if (t) clone.setAttribute('data-tl', ns + '_' + t);
  838. // 资源绝对化(基于节 HTML 绝对 URL)——video 直接 src 与 source 子元素都处理
  839. clone.querySelectorAll('img').forEach(function (im) { absolutize(baseURL, im, 'src'); });
  840. clone.querySelectorAll('video[src]').forEach(function (vd) { absolutize(baseURL, vd, 'src'); });
  841. clone.querySelectorAll('video source').forEach(function (im) { absolutize(baseURL, im, 'src'); });
  842. clone.querySelectorAll('a[href]').forEach(function (a) { absolutize(baseURL, a, 'href'); });
  843. stage.appendChild(clone);
  844. });
  845. // 3b. 注入页面级 JSON 数据块(如 tt-data / 商品数据),供动态屏 IIFE 读取。
  846. // deck-config 是页内导航,不注入;id 冲突时第一个生效(跨节数据各自独立)。
  847. doc.querySelectorAll('script[type="application/json"]').forEach(function (jb) {
  848. if (/deck-config/.test(jb.textContent || '')) return;
  849. var jid = jb.getAttribute('id');
  850. if (jid && document.getElementById(jid)) return; // 已注入(首章优先)
  851. stage.appendChild(doc.importNode(jb, true));
  852. });
  853. // 4. 注入完成后执行节的其他内联脚本(数据/渲染函数/初始化)。
  854. // 此刻页面的 JSON 数据块与各 grid/详情容器已在总控 DOM,页内 IIFE
  855. // (KOC 矩阵 / 商品墙 / 六国商品等立即渲染)能正常找到目标元素。
  856. // 排除:deck-config JSON、SCREEN_TIMELINES 块、外链脚本、deck.init 引导块。
  857. execInlineScripts(doc, file);
  858. embedPages[i] = { page: page, count: chosen.length };
  859. if (done) done();
  860. });
  861. }
  862. function checkEmbedReady() {
  863. var loaded = embedPages.filter(function (p) { return p; }).length;
  864. if (loaded === shellPlan.pages.length) {
  865. // 全部注入完成 → 用单页模式播放(屏状态机),进度按所有注入屏
  866. embedReady = true;
  867. collect();
  868. if (total === 0) return;
  869. screens.forEach(function (s) {
  870. s.el.style.position = 'absolute';
  871. s.el.style.inset = '0';
  872. });
  873. // 复用单页导航(无 topbar 页内导航,进度用总控 renderShellChrome)。
  874. // 键盘不放这里绑:embed 模式下 bindShellKeys 已在 document 上监听 keydown,
  875. // 再 bindKeys 会对同一次 ↓/↑ 触发两次 next/prev(每按一次跳两屏,
  876. // 播放位置与页码/章节错位 —— 2026-08-30 回归修复)。点击/滚轮/触摸无此冲突。
  877. bindClick();
  878. bindMouse();
  879. bindTouch();
  880. showScreen(0);
  881. activate(screens[0]);
  882. renderShellChrome();
  883. }
  884. }
  885. function initEmbed() {
  886. embedMode = true;
  887. embedPages = [];
  888. // 若 http 可用 fetch 则注入;file:// 下 fetch 受限 → 提示用 http
  889. if (location.protocol === 'file:') {
  890. var msg = document.createElement('div');
  891. 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;';
  892. msg.textContent = 'embed(JS注入)模式需要 http 访问(file:// 下 fetch 受限)。请用 http 打开总控页。';
  893. document.body.appendChild(msg);
  894. return;
  895. }
  896. // 串行加载:保证注入 DOM 顺序 = plan.pages 顺序(并发 fetch 会乱序导致故事线错位)
  897. var embIdx = 0;
  898. function nextEmb() {
  899. if (embIdx >= shellPlan.pages.length) { checkEmbedReady(); return; }
  900. embedChapter(shellPlan.pages[embIdx], embIdx, nextEmb);
  901. embIdx++;
  902. }
  903. nextEmb();
  904. }
  905. function loadPage(i, visible) {
  906. if (i < 0 || i >= shellPlan.pages.length) return;
  907. if (shellFrames[i]) return; // 不重复创建;不销毁(翻回保留状态)
  908. var p = shellPlan.pages[i];
  909. var fr = document.createElement('iframe');
  910. fr.className = 'shell-frame';
  911. fr.setAttribute('data-page', String(i));
  912. fr.setAttribute('aria-label', p.title || ('第 ' + (i + 1) + ' 页'));
  913. fr.title = p.file || '';
  914. // 隐藏用 visibility:hidden 而非 display:none:保留布局尺寸,避免 mermaid/logic-flow
  915. // 在 0×0 上下文渲染产生 NaN transform;不可见也不可交互。
  916. fr.style.visibility = visible ? 'visible' : 'hidden';
  917. fr.__createdVisible = !!visible;
  918. fr.src = p.file; // 相对总控页
  919. var stage = document.querySelector('.shell-stage') || document.body;
  920. stage.appendChild(fr);
  921. shellFrames[i] = fr;
  922. fr.addEventListener('load', function () {
  923. postToFrame(fr, { type: 'deck-register' }); // 确认容器模式(页收到后才接管翻章)
  924. });
  925. }
  926. function showPage(i) {
  927. if (i < 0 || i >= shellPlan.pages.length) return;
  928. if (!shellFrames[i]) loadPage(i, true); // 未预加载 → 直接创建并可见(加载时自然播放入场)
  929. shellFrames.forEach(function (f, j) {
  930. if (f) f.style.visibility = (j === i) ? 'visible' : 'hidden';
  931. });
  932. var first = !shellShown[i];
  933. shellShown[i] = true;
  934. shellCur = i;
  935. var p = shellPlan.pages[i];
  936. if (first && !shellFrames[i].__createdVisible) {
  937. // 预加载(隐藏)期间入场已悄悄放完 → 首次展示重演入场,翻页无白屏
  938. // 若编排定义了屏级切片 [from,to] → 下发 range 让页内从起始屏开始并在边界自动翻章
  939. if (typeof p.from === 'number' || typeof p.to === 'number') {
  940. postToFrame(shellFrames[i], { type: 'deck-cmd', cmd: 'range',
  941. from: (typeof p.from === 'number' ? p.from : 0),
  942. to: (typeof p.to === 'number' ? p.to : -1) });
  943. } else {
  944. postToFrame(shellFrames[i], { type: 'deck-cmd', cmd: 'jump', index: 0 });
  945. }
  946. }
  947. loadPage(i + 1, false); // 预加载下一页(隐藏)
  948. try { shellFrames[i].contentWindow.focus(); } catch (e) {} // 键盘焦点进 iframe
  949. renderShellChrome();
  950. }
  951. // 有效屏数/已完成屏数(支持 plan 屏级切片 from/to)
  952. function effTotal(p, st) {
  953. var raw = p.screens || (st && st.total) || 0;
  954. if (!raw) return 0;
  955. var from = (typeof p.from === 'number') ? p.from : 0;
  956. var to = (typeof p.to === 'number') ? Math.min(p.to, raw - 1) : raw - 1;
  957. if (to < from) return 0;
  958. return to - from + 1;
  959. }
  960. function effDone(p, st) {
  961. var raw = p.screens || (st && st.total) || 0;
  962. if (!raw) return 0;
  963. var idx = st ? st.index : 0;
  964. var from = (typeof p.from === 'number') ? p.from : 0;
  965. var to = (typeof p.to === 'number') ? Math.min(p.to, raw - 1) : raw - 1;
  966. if (idx < from) return 0;
  967. return Math.min(to - from + 1, idx - from + 1);
  968. }
  969. function renderShellChrome() {
  970. var pages = shellPlan.pages;
  971. var tabs = document.querySelectorAll('.shell-tab');
  972. // embed 模式:注入后所有屏 flat 排列,按当前屏反推所在节高亮
  973. if (embedMode && embedReady) {
  974. var curScreen = current;
  975. var acc = 0, curPage = 0;
  976. for (var q = 0; q < pages.length; q++) {
  977. var cnt = (embedPages[q] && embedPages[q].count) || 0;
  978. if (curScreen < acc + cnt) { curPage = q; break; }
  979. acc += cnt;
  980. curPage = q + 1;
  981. }
  982. tabs.forEach(function (b, j) { b.classList.toggle('cur', j === curPage); });
  983. var totalE = total, doneE = Math.min(totalE, curScreen + 1);
  984. var bar = document.querySelector('.shell-bottom .deck-progress .bar');
  985. var pos = document.querySelector('.shell-pos');
  986. var pn = document.querySelector('.shell-pagename');
  987. if (bar) bar.style.width = Math.min(100, (doneE / totalE) * 100) + '%';
  988. if (pos) pos.textContent = (curPage + 1) + '/' + pages.length + ' 节 · ' + doneE + '/' + totalE + ' 屏';
  989. if (pn) pn.textContent = pages[curPage].title || '';
  990. scrollActiveTabIntoView();
  991. return;
  992. }
  993. tabs.forEach(function (b, j) { b.classList.toggle('cur', j === shellCur); });
  994. // 进度按「屏幕(screen)」颗粒度计算:每节=1个 iframe,内含多屏(每屏≈30s)。
  995. // 屏数优先级:plan.pages[i].screens(编排权威标注)> iframe 上报 total(实测量);
  996. // plan.pages[i].from/to(屏级切片)只计切片内的有效屏。
  997. var totalScreens = 0, doneScreens = 0, loadedAny = false;
  998. pages.forEach(function (p, i) {
  999. var st = shellLast[i];
  1000. var total = effTotal(p, st);
  1001. if (!total) return;
  1002. loadedAny = true;
  1003. totalScreens += total;
  1004. if (i < shellCur) doneScreens += total; // 已翻完的节:全屏计入
  1005. else if (i === shellCur) doneScreens += effDone(p, st);
  1006. });
  1007. var bar = document.querySelector('.shell-bottom .deck-progress .bar');
  1008. var pos = document.querySelector('.shell-pos');
  1009. var pn = document.querySelector('.shell-pagename');
  1010. if (loadedAny && totalScreens > 0) {
  1011. if (bar) bar.style.width = Math.min(100, (doneScreens / totalScreens) * 100) + '%';
  1012. if (pos) pos.textContent = (shellCur + 1) + '/' + pages.length + ' 节 · ' + Math.min(doneScreens, totalScreens) + '/' + totalScreens + ' 屏';
  1013. } else {
  1014. // 完全未知 → 按节回退(首屏上报前短暂过渡)
  1015. var done = 0;
  1016. pages.forEach(function (p, i) {
  1017. if (i < shellCur) { done += 1; return; }
  1018. if (i === shellCur) {
  1019. var st2 = shellLast[i];
  1020. if (st2 && st2.total) done += Math.min(1, ((st2.index || 0) + 1) / st2.total);
  1021. }
  1022. });
  1023. if (bar) bar.style.width = ((done / pages.length) * 100) + '%';
  1024. if (pos) pos.textContent = (shellCur + 1) + ' / ' + pages.length + ' 页';
  1025. }
  1026. if (pn) pn.textContent = pages[shellCur].title || '';
  1027. scrollActiveTabIntoView();
  1028. }
  1029. function broadcastTheme() {
  1030. var t = window.HDTheme ? window.HDTheme.get() : (document.documentElement.getAttribute('data-theme') || 'dark');
  1031. shellFrames.forEach(function (f) { if (f) postToFrame(f, { type: 'deck-cmd', cmd: 'theme', theme: t }); });
  1032. }
  1033. function bindShellKeys() {
  1034. document.addEventListener('keydown', function (e) {
  1035. var t = e.target;
  1036. if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
  1037. if (t && t.closest && (t.closest('button') || t.closest('a'))) return; // 焦点在按钮上交给原生
  1038. var k = e.key;
  1039. if (k === 'ArrowDown' || k === 's' || k === 'S' || k === 'PageDown' || k === ' ') {
  1040. e.preventDefault(); if (embedMode && embedReady) next(); else cmdActive('next');
  1041. } else if (k === 'ArrowUp' || k === 'w' || k === 'W' || k === 'PageUp') {
  1042. e.preventDefault(); if (embedMode && embedReady) prev(); else cmdActive('prev');
  1043. } else if (k === 'ArrowRight' || k === 'd' || k === 'D') {
  1044. if (embedMode && embedReady) nextChapter(); else showPage(shellCur + 1);
  1045. } else if (k === 'ArrowLeft' || k === 'a' || k === 'A') {
  1046. if (embedMode && embedReady) prevChapter(); else showPage(shellCur - 1);
  1047. } else if (k === 'Home') {
  1048. e.preventDefault();
  1049. if (embedMode && embedReady) jump(0); else cmdActive('home');
  1050. } else if (k === 'End') {
  1051. e.preventDefault();
  1052. if (embedMode && embedReady) jump(total - 1); else cmdActive('end');
  1053. }
  1054. });
  1055. }
  1056. /* ---------- 手机端:右下角「全屏」切换(不旋转——手机自带横屏) ---------- */
  1057. function isMobileUA() {
  1058. return /Android|iPhone|iPad|iPod|Mobile|Mobi|Opera Mini|IEMobile/i.test(navigator.userAgent)
  1059. || (navigator.platform && /iPhone|iPad|iPod|Android/i.test(navigator.platform));
  1060. }
  1061. var fullscreenBtn = null;
  1062. function ensureFullscreenStyle() {
  1063. if (document.getElementById('shell-fullscreen-style')) return;
  1064. var st = document.createElement('style');
  1065. st.id = 'shell-fullscreen-style';
  1066. st.textContent =
  1067. // ============ 全屏:不旋转舞台,16:9 等比 fit 视口居中(letterbox) ============
  1068. // 手机系统自带横屏:用户转手机即横屏。全屏模式只负责放大 + 隐藏系统 UI,
  1069. // 舞台始终按 16:9 contain(--fs-w/--fs-h 由 JS 计算),避免拉伸变形。
  1070. 'html.shell-fullscreen .shell-stage{'
  1071. + 'position:fixed;top:50%;left:50%;'
  1072. + 'transform:translate(-50%,-50%);'
  1073. + 'width:var(--fs-w,100vw);height:var(--fs-h,100vh);'
  1074. + 'background:var(--bg-0);'
  1075. + 'box-shadow:0 0 0 2000px var(--bg-0);}'
  1076. // 顶栏/底栏:全屏时保持可用,收窄/半透明,不遮挡内容
  1077. + 'html.shell-fullscreen .shell-topbar{'
  1078. + 'opacity:.9;}'
  1079. + 'html.shell-fullscreen .shell-bottom{'
  1080. + 'opacity:.9;}'
  1081. // 常规右下角全屏按钮
  1082. + '.shell-fs-btn{position:fixed;right:14px;bottom:56px;z-index:80;'
  1083. + 'pointer-events:auto;cursor:pointer;'
  1084. + 'font:600 12px var(--font-mono);color:var(--text-3);'
  1085. + 'background:var(--glass-bg);border:1px solid var(--border-subtle);'
  1086. + 'border-radius:var(--radius-pill);padding:5px 12px;'
  1087. + 'backdrop-filter:blur(var(--glass-blur));opacity:.82;'
  1088. + 'box-shadow:0 2px 8px rgba(0,0,0,.18);}'
  1089. + '.shell-fs-btn:active{opacity:1;}'
  1090. // 窄屏折叠导航:汉堡按钮 + 下拉面板
  1091. + '.shell-collapsed .deck-tabs{'
  1092. + 'position:fixed;top:52px;left:12px;right:12px;'
  1093. + 'flex-direction:column;align-items:stretch;gap:4px;'
  1094. + 'background:var(--bg-1);border:1px solid var(--border-subtle);'
  1095. + 'border-radius:var(--radius-md);padding:8px;'
  1096. + 'display:none;max-height:62vh;overflow-y:auto;'
  1097. + 'box-shadow:0 8px 28px rgba(0,0,0,.35);z-index:60;}'
  1098. + '.shell-collapsed .deck-tab{width:100%;text-align:left;padding:8px 12px;border-radius:var(--radius-sm);}'
  1099. + '.shell-collapsed.shell-open .deck-tabs{display:flex;}'
  1100. + '.shell-burger{pointer-events:auto;display:inline-flex;align-items:center;justify-content:center;'
  1101. + 'width:34px;height:30px;padding:0;margin-right:4px;flex-shrink:0;'
  1102. + 'color:var(--text-3);border:1px solid var(--border-subtle);'
  1103. + 'border-radius:var(--radius-pill);background:var(--glass-bg);cursor:pointer;}'
  1104. + '.shell-burger:hover{color:var(--brand-1);border-color:var(--border-strong);}';
  1105. document.head.appendChild(st);
  1106. }
  1107. function applyFullscreenSize() {
  1108. // 舞台 16:9 contain 于视口(不旋转):宽高按视口 fit,letterbox 留黑边
  1109. var vw = window.innerWidth, vh = window.innerHeight;
  1110. var w, h;
  1111. if (vw / vh > 16 / 9) { h = vh; w = h * 16 / 9; }
  1112. else { w = vw; h = w * 9 / 16; }
  1113. var el = document.documentElement;
  1114. el.style.setProperty('--fs-w', Math.round(w) + 'px');
  1115. el.style.setProperty('--fs-h', Math.round(h) + 'px');
  1116. }
  1117. function toggleFullscreen() {
  1118. var el = document.documentElement;
  1119. var inFs = el.classList.contains('shell-fullscreen');
  1120. if (!inFs) {
  1121. applyFullscreenSize();
  1122. el.classList.add('shell-fullscreen');
  1123. var fs = el.requestFullscreen || el.webkitRequestFullscreen || el.msRequestFullscreen;
  1124. if (fs) { try { fs.call(el); } catch (e) {} }
  1125. if (fullscreenBtn) fullscreenBtn.textContent = '退出全屏';
  1126. } else {
  1127. el.classList.remove('shell-fullscreen');
  1128. var ex = document.exitFullscreen || document.webkitExitFullscreen || document.msExitFullscreen;
  1129. if (ex && (document.fullscreenElement || document.webkitFullscreenElement)) {
  1130. try { ex.call(document); } catch (e) {}
  1131. }
  1132. if (fullscreenBtn) fullscreenBtn.textContent = '全屏';
  1133. }
  1134. }
  1135. function buildFullscreenBtn() {
  1136. if (fullscreenBtn) return;
  1137. ensureFullscreenStyle();
  1138. var b = document.createElement('button');
  1139. b.className = 'shell-fs-btn';
  1140. b.type = 'button';
  1141. b.textContent = '全屏';
  1142. b.title = '切换全屏(手机端)';
  1143. b.addEventListener('click', toggleFullscreen);
  1144. document.body.appendChild(b);
  1145. fullscreenBtn = b;
  1146. // 全屏状态变化时同步按钮文案(浏览器返回键退出全屏等)
  1147. document.addEventListener('fullscreenchange', syncFullscreenBtn);
  1148. document.addEventListener('webkitfullscreenchange', syncFullscreenBtn);
  1149. // 尺寸变化时重算 16:9(转屏、全屏高度变化)
  1150. window.addEventListener('resize', function () {
  1151. if (document.documentElement.classList.contains('shell-fullscreen')) applyFullscreenSize();
  1152. });
  1153. window.addEventListener('orientationchange', function () {
  1154. setTimeout(function () {
  1155. if (document.documentElement.classList.contains('shell-fullscreen')) applyFullscreenSize();
  1156. }, 300);
  1157. });
  1158. }
  1159. function syncFullscreenBtn() {
  1160. if (!fullscreenBtn) return;
  1161. var inFs = !!(
  1162. document.fullscreenElement || document.webkitFullscreenElement || document.msFullscreenElement
  1163. );
  1164. fullscreenBtn.textContent = inFs ? '退出全屏' : '全屏';
  1165. }
  1166. function cmdActive(cmd) {
  1167. var f = shellFrames[shellCur];
  1168. if (f) postToFrame(f, { type: 'deck-cmd', cmd: cmd });
  1169. }
  1170. /* 总控侧:接收页面上报 */
  1171. function onShellMsg(e) {
  1172. var d = e.data;
  1173. if (!d || typeof d !== 'object') return;
  1174. var idx = -1; // e.source 是 iframe 的 window,需与 contentWindow 比对
  1175. for (var k = 0; k < shellFrames.length; k++) {
  1176. if (shellFrames[k] && shellFrames[k].contentWindow === e.source) { idx = k; break; }
  1177. }
  1178. if (idx < 0) return; // 只认自己管理的 iframe
  1179. if (d.type === 'deck-progress') {
  1180. shellLast[idx] = { index: d.index, total: d.total, state: d.state };
  1181. if (idx === shellCur) renderShellChrome();
  1182. } else if (d.type === 'deck-go') {
  1183. // 页内翻章(或最后一屏 ↓):按 plan 顺序切页,忽略页内 deck-config 的文件跳转
  1184. showPage(idx + (d.dir >= 0 ? 1 : -1));
  1185. }
  1186. }
  1187. /* 页侧:接收总控指令(总控页自身也挂此监听,靠 source 过滤互不干扰) */
  1188. function onPageMessage(e) {
  1189. var d = e.data;
  1190. if (!d || typeof d !== 'object') return;
  1191. if (e.source !== window.parent) return; // 只接受父窗口指令
  1192. if (d.type === 'deck-register') {
  1193. shellMode = true; // 父窗口确认为总控容器
  1194. if (pendingGo) { postShell(pendingGo); pendingGo = null; }
  1195. return;
  1196. }
  1197. if (d.type !== 'deck-cmd') return;
  1198. if (d.cmd === 'next') next();
  1199. else if (d.cmd === 'prev') prev();
  1200. else if (d.cmd === 'jump') { if (typeof d.index === 'number') jump(d.index); }
  1201. else if (d.cmd === 'home') jump(0);
  1202. else if (d.cmd === 'end') jump(total - 1);
  1203. else if (d.cmd === 'range') {
  1204. // 屏级切片:总控下发 [from,to](0-based),页内翻屏被钳制在该范围,两端自动翻章
  1205. shellFrom = (typeof d.from === 'number') ? d.from : 0;
  1206. shellTo = (typeof d.to === 'number') ? d.to : -1;
  1207. jump(shellFrom);
  1208. }
  1209. else if (d.cmd === 'theme') { if (window.HDTheme && d.theme) window.HDTheme.set(d.theme); }
  1210. }
  1211. /* 字体/logo 加载后 tab 宽度变化,窗口 load 时校正一次居中(直接打开深层页的场景) */
  1212. window.addEventListener('load', function () { scrollActiveTabIntoView(); });
  1213. window.deck = {
  1214. init: init,
  1215. next: next,
  1216. prev: prev,
  1217. jump: jump,
  1218. nextChapter: nextChapter,
  1219. prevChapter: prevChapter,
  1220. goFile: goFile,
  1221. // 总控容器模式
  1222. showPage: showPage,
  1223. loadPage: loadPage
  1224. };
  1225. })();