deck_index.html 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>Deck · Multi-file Slide Index</title>
  6. <!--
  7. deck_index.html — 多文件 slide deck 的拼接器(双模式)
  8. 配合「每页一个独立 HTML」架构使用。与单文件 deck_stage.js 对比:
  9. · 每页独立作用域(CSS/JS 都隔离),一页出 bug 不影响其他页
  10. · 单页可直接在浏览器打开验证,不依赖 JS goTo()
  11. · 多 agent 可并行做不同页,merge 时零冲突
  12. · 适合 ≥15 页的讲座/课件/长 deck
  13. 两种模式(body[data-mode] 控制):
  14. · 概览墙 overview(默认进入):所有页缩略成 3D 透视卡片墙,景深斜铺、悬浮投影。
  15. 点任意卡片 → 从该页进入演示;右下角「▶ 开始演示」从第 1 页(或记忆页)进入。
  16. · 全屏演示 present:单页 fit 缩放翻页。ESC 或左上「⊞ 概览」回到概览墙。
  17. 用法:
  18. 1. 把本文件复制到 deck 根目录,重命名 index.html
  19. 2. 在同目录建 slides/ 子目录,放每一页独立 HTML
  20. 3. 编辑下方 MANIFEST 数组,按顺序列出文件名和人类可读标签
  21. 4. 每张 slide HTML 建议尺寸 1920×1080,自带背景/字体;不要依赖外层 CSS
  22. 共享资源(如果需要):
  23. · shared/tokens.css — 跨页 CSS 变量(色板/字号)
  24. · shared/chrome.html — 页眉页脚可复用片段
  25. · 每页 HTML 自己 <link> 进去即可
  26. 键盘(演示模式):← / → / Space / PgUp / PgDown / Home / End / 1-9 跳页 / P 打印 / ESC 回概览
  27. -->
  28. <!-- ═══════════════════════════════════════════════════════ -->
  29. <!-- EDIT THIS — deck 所有页按顺序列出 -->
  30. <!-- ═══════════════════════════════════════════════════════ -->
  31. <script>
  32. window.DECK_MANIFEST = [
  33. { file: "slides/01-cover.html", label: "Cover" },
  34. { file: "slides/02-quote.html", label: "Opening Quote" },
  35. { file: "slides/03-intro.html", label: "Self-intro" },
  36. // 继续往下加。file 是相对本文件的路径,label 用于计数器
  37. ];
  38. // 固定 canvas 尺寸。每页 HTML 都应该按这个尺寸设计。
  39. window.DECK_WIDTH = 1920;
  40. window.DECK_HEIGHT = 1080;
  41. </script>
  42. <style>
  43. * { box-sizing: border-box; margin: 0; padding: 0; }
  44. html, body {
  45. height: 100%;
  46. background: #0a0a0a;
  47. overflow: hidden;
  48. font-family: -apple-system, "PingFang SC", sans-serif;
  49. }
  50. /* ── 模式显隐:默认 overview ───────────────────────────── */
  51. body[data-mode="overview"] { background: #f0eee9; overflow: auto; }
  52. body[data-mode="overview"] #present-ui { display: none; }
  53. body[data-mode="present"] #overview { display: none; }
  54. body[data-mode="present"] .start-btn { display: none; }
  55. /* ════════════════════════════════════════════════════════ */
  56. /* 模式 A · 概览墙(3D 透视卡片墙) */
  57. /* ════════════════════════════════════════════════════════ */
  58. #overview {
  59. min-height: 100%;
  60. padding: 7vw 5vw 14vw;
  61. /* 透视容器:景深 */
  62. perspective: 1600px;
  63. perspective-origin: 50% 38%;
  64. }
  65. .wall {
  66. display: grid;
  67. grid-template-columns: repeat(4, 1fr);
  68. gap: 2.4vw;
  69. max-width: 1500px;
  70. margin: 0 auto;
  71. transform-style: preserve-3d;
  72. /* 近处向左下、远处向右上铺开 */
  73. transform: rotateX(28deg) rotateZ(-14deg);
  74. }
  75. .card {
  76. position: relative;
  77. aspect-ratio: 16 / 9;
  78. border-radius: 10px;
  79. overflow: hidden;
  80. background: #fff;
  81. cursor: pointer;
  82. box-shadow: 0 18px 40px rgba(40, 34, 24, 0.22),
  83. 0 4px 12px rgba(40, 34, 24, 0.14);
  84. transition: transform 0.45s cubic-bezier(.2,.7,.2,1),
  85. box-shadow 0.45s cubic-bezier(.2,.7,.2,1);
  86. transform-style: preserve-3d;
  87. will-change: transform;
  88. }
  89. .card:hover {
  90. /* 抬起 + 微微「正过来」给预览感 */
  91. transform: translateZ(80px) rotateX(-14deg) rotateZ(14deg);
  92. box-shadow: 0 40px 80px rgba(40, 34, 24, 0.32),
  93. 0 10px 24px rgba(40, 34, 24, 0.20);
  94. z-index: 2;
  95. }
  96. .card .thumb {
  97. position: absolute;
  98. top: 0; left: 0;
  99. width: 1920px;
  100. height: 1080px;
  101. transform-origin: top left;
  102. /* scale 由 JS 按卡片实际宽度设置 */
  103. pointer-events: none;
  104. }
  105. .card .thumb iframe {
  106. width: 1920px;
  107. height: 1080px;
  108. border: 0;
  109. display: block;
  110. background: #fff;
  111. pointer-events: none;
  112. }
  113. .card .num {
  114. position: absolute;
  115. bottom: 8px; left: 10px;
  116. background: rgba(20, 16, 10, 0.72);
  117. color: #fff;
  118. font-size: 12px;
  119. line-height: 1;
  120. padding: 5px 9px;
  121. border-radius: 6px;
  122. font-variant-numeric: tabular-nums;
  123. letter-spacing: 0.04em;
  124. z-index: 3;
  125. pointer-events: none;
  126. }
  127. #overview-title {
  128. text-align: center;
  129. color: #5a5346;
  130. font-size: 15px;
  131. letter-spacing: 0.18em;
  132. margin: 0 auto 5vw;
  133. text-transform: uppercase;
  134. opacity: 0.7;
  135. }
  136. .start-btn {
  137. position: fixed;
  138. bottom: 28px; right: 28px;
  139. background: #1a1712;
  140. color: #fff;
  141. border: 0;
  142. padding: 14px 26px;
  143. border-radius: 999px;
  144. font-size: 15px;
  145. font-family: inherit;
  146. letter-spacing: 0.04em;
  147. cursor: pointer;
  148. z-index: 200;
  149. box-shadow: 0 8px 28px rgba(40, 34, 24, 0.28);
  150. transition: transform 0.18s, box-shadow 0.18s;
  151. }
  152. .start-btn:hover { transform: translateY(-2px); box-shadow: 0 12px 34px rgba(40, 34, 24, 0.36); }
  153. /* ════════════════════════════════════════════════════════ */
  154. /* 模式 B · 全屏演示 */
  155. /* ════════════════════════════════════════════════════════ */
  156. #stage {
  157. position: fixed;
  158. top: 50%; left: 50%;
  159. transform-origin: top left;
  160. will-change: transform;
  161. background: #fff;
  162. box-shadow: 0 10px 60px rgba(0,0,0,0.4);
  163. /* size set by JS from DECK_WIDTH/HEIGHT */
  164. }
  165. #stage iframe {
  166. width: 100%;
  167. height: 100%;
  168. border: 0;
  169. display: block;
  170. background: #fff;
  171. }
  172. .counter {
  173. position: fixed;
  174. bottom: 20px;
  175. right: 20px;
  176. background: rgba(0,0,0,0.65);
  177. color: #fff;
  178. padding: 6px 14px;
  179. border-radius: 999px;
  180. font-size: 13px;
  181. letter-spacing: 0.05em;
  182. font-variant-numeric: tabular-nums;
  183. z-index: 100;
  184. user-select: none;
  185. opacity: 0.7;
  186. transition: opacity 0.2s;
  187. }
  188. .counter:hover { opacity: 1; }
  189. .counter .label { color: rgba(255,255,255,0.7); margin-left: 8px; }
  190. .overview-btn {
  191. position: fixed;
  192. top: 20px; left: 20px;
  193. background: rgba(0,0,0,0.55);
  194. color: rgba(255,255,255,0.85);
  195. border: 0;
  196. padding: 7px 14px;
  197. border-radius: 999px;
  198. font-size: 13px;
  199. font-family: inherit;
  200. letter-spacing: 0.04em;
  201. cursor: pointer;
  202. z-index: 100;
  203. opacity: 0.6;
  204. transition: opacity 0.2s;
  205. }
  206. .overview-btn:hover { opacity: 1; }
  207. .nav-zone {
  208. position: fixed;
  209. top: 0; bottom: 0;
  210. width: 15%;
  211. cursor: pointer;
  212. z-index: 50;
  213. }
  214. .nav-zone.left { left: 0; }
  215. .nav-zone.right { right: 0; }
  216. .nav-hint {
  217. position: absolute;
  218. top: 50%; transform: translateY(-50%);
  219. width: 44px; height: 44px;
  220. border-radius: 999px;
  221. background: rgba(255,255,255,0.08);
  222. color: rgba(255,255,255,0.6);
  223. display: flex;
  224. align-items: center;
  225. justify-content: center;
  226. font-size: 22px;
  227. opacity: 0;
  228. transition: opacity 0.2s;
  229. }
  230. .nav-zone.left .nav-hint { left: 20px; }
  231. .nav-zone.right .nav-hint { right: 20px; }
  232. .nav-zone:hover .nav-hint { opacity: 1; }
  233. /* Print: one slide per page, no navigation UI */
  234. @media print {
  235. @page { size: 1920px 1080px; margin: 0; }
  236. html, body { background: #fff; overflow: visible; height: auto; }
  237. body[data-mode] #overview { display: none !important; }
  238. #stage { position: static; transform: none !important; box-shadow: none; }
  239. .counter, .nav-zone, .overview-btn, .start-btn { display: none !important; }
  240. /* In print mode we render all slides sequentially — see JS */
  241. .print-stack { display: block; }
  242. .print-stack iframe {
  243. width: 1920px;
  244. height: 1080px;
  245. page-break-after: always;
  246. display: block;
  247. }
  248. }
  249. </style>
  250. </head>
  251. <body data-mode="overview">
  252. <!-- ── 模式 A · 概览墙 ─────────────────────────────────────── -->
  253. <div id="overview">
  254. <div id="overview-title">Overview · 点击任意页进入演示</div>
  255. <div class="wall" id="wall"></div>
  256. </div>
  257. <button class="start-btn" id="startBtn">▶ 开始演示</button>
  258. <!-- ── 模式 B · 全屏演示 ───────────────────────────────────── -->
  259. <div id="present-ui">
  260. <div id="stage">
  261. <iframe id="frame" src="about:blank"></iframe>
  262. </div>
  263. <button class="overview-btn" id="overviewBtn">⊞ 概览</button>
  264. <div class="nav-zone left" id="navL"><div class="nav-hint">‹</div></div>
  265. <div class="nav-zone right" id="navR"><div class="nav-hint">›</div></div>
  266. <div class="counter" id="counter">1 / 1</div>
  267. </div>
  268. <!-- Print-only stack: populated on beforeprint, stripped on afterprint -->
  269. <div class="print-stack" id="printStack" style="display:none;"></div>
  270. <script>
  271. (function () {
  272. const W = window.DECK_WIDTH || 1920;
  273. const H = window.DECK_HEIGHT || 1080;
  274. const deck = window.DECK_MANIFEST || [];
  275. const stage = document.getElementById('stage');
  276. const frame = document.getElementById('frame');
  277. const counter = document.getElementById('counter');
  278. const printStack = document.getElementById('printStack');
  279. const wall = document.getElementById('wall');
  280. const storageKey = 'deck-index-' + location.pathname;
  281. let current = 0;
  282. stage.style.width = W + 'px';
  283. stage.style.height = H + 'px';
  284. /* ── 模式切换 ─────────────────────────────────────────── */
  285. function setMode(mode) {
  286. document.body.setAttribute('data-mode', mode);
  287. if (mode === 'present') { fit(); show(current); }
  288. }
  289. /* ════════ 模式 A · 概览墙 ════════ */
  290. function buildWall() {
  291. wall.innerHTML = '';
  292. deck.forEach((item, i) => {
  293. const card = document.createElement('div');
  294. card.className = 'card';
  295. card.dataset.idx = i;
  296. const thumb = document.createElement('div');
  297. thumb.className = 'thumb';
  298. const ifr = document.createElement('iframe');
  299. ifr.src = item.file;
  300. ifr.setAttribute('scrolling', 'no');
  301. thumb.appendChild(ifr);
  302. const num = document.createElement('div');
  303. num.className = 'num';
  304. num.textContent = (i + 1) + (item.label ? ' · ' + item.label : '');
  305. card.appendChild(thumb);
  306. card.appendChild(num);
  307. card.addEventListener('click', () => { current = i; setMode('present'); });
  308. wall.appendChild(card);
  309. });
  310. scaleThumbs();
  311. }
  312. // 把 1920×1080 的缩略 iframe 缩放到卡片实际宽度
  313. function scaleThumbs() {
  314. document.querySelectorAll('.card').forEach(card => {
  315. const thumb = card.querySelector('.thumb');
  316. if (!thumb) return;
  317. const w = card.clientWidth;
  318. if (!w) return;
  319. thumb.style.transform = 'scale(' + (w / W) + ')';
  320. });
  321. }
  322. /* ════════ 模式 B · 全屏演示 ════════ */
  323. function fit() {
  324. const s = Math.min(window.innerWidth / W, window.innerHeight / H);
  325. const x = (window.innerWidth - W * s) / 2;
  326. const y = (window.innerHeight - H * s) / 2;
  327. stage.style.transform = `translate(${x}px, ${y}px) scale(${s})`;
  328. stage.style.top = '0';
  329. stage.style.left = '0';
  330. }
  331. function show(idx) {
  332. if (idx < 0 || idx >= deck.length) return;
  333. current = idx;
  334. const wanted = deck[idx].file;
  335. if (frame.getAttribute('src') !== wanted) frame.src = wanted;
  336. counter.innerHTML = `${idx + 1} / ${deck.length} <span class="label">${deck[idx].label || ''}</span>`;
  337. try { localStorage.setItem(storageKey, String(idx)); } catch (_) {}
  338. if (location.hash !== '#' + (idx + 1)) {
  339. history.replaceState(null, '', '#' + (idx + 1));
  340. }
  341. }
  342. function next() { show(Math.min(current + 1, deck.length - 1)); }
  343. function prev() { show(Math.max(current - 1, 0)); }
  344. // Keyboard
  345. document.addEventListener('keydown', (e) => {
  346. if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
  347. // ESC 回概览(演示模式下)
  348. if (e.key === 'Escape') {
  349. if (document.body.getAttribute('data-mode') === 'present') { e.preventDefault(); setMode('overview'); }
  350. return;
  351. }
  352. if (e.key === 'p' || e.key === 'P') { window.print(); return; }
  353. // 其余翻页键仅在演示模式生效
  354. if (document.body.getAttribute('data-mode') !== 'present') return;
  355. switch (e.key) {
  356. case 'ArrowRight': case ' ': case 'PageDown': e.preventDefault(); next(); break;
  357. case 'ArrowLeft': case 'PageUp': e.preventDefault(); prev(); break;
  358. case 'Home': e.preventDefault(); show(0); break;
  359. case 'End': e.preventDefault(); show(deck.length - 1); break;
  360. default:
  361. if (e.key >= '1' && e.key <= '9') {
  362. const i = parseInt(e.key, 10) - 1;
  363. if (i < deck.length) { e.preventDefault(); show(i); }
  364. }
  365. }
  366. });
  367. document.getElementById('navL').addEventListener('click', prev);
  368. document.getElementById('navR').addEventListener('click', next);
  369. document.getElementById('startBtn').addEventListener('click', () => setMode('present'));
  370. document.getElementById('overviewBtn').addEventListener('click', () => setMode('overview'));
  371. window.addEventListener('resize', () => { fit(); scaleThumbs(); });
  372. window.addEventListener('hashchange', () => {
  373. const m = location.hash.match(/^#(\d+)$/);
  374. if (m) { current = parseInt(m[1], 10) - 1; setMode('present'); }
  375. });
  376. // 起始页:hash > localStorage > 0(决定 current;hash 还会直接进演示)
  377. const hashMatch = location.hash.match(/^#(\d+)$/);
  378. if (hashMatch) {
  379. current = Math.min(parseInt(hashMatch[1], 10) - 1, deck.length - 1);
  380. } else {
  381. try {
  382. const v = parseInt(localStorage.getItem(storageKey), 10);
  383. if (!isNaN(v) && v >= 0 && v < deck.length) current = v;
  384. } catch (_) {}
  385. }
  386. // 先把演示舞台准备好(即便默认在概览,CSS 隐藏它)
  387. fit();
  388. show(current);
  389. buildWall();
  390. // 默认进概览;但若 URL 带 #N,直接进演示从该页开始
  391. if (hashMatch) setMode('present');
  392. else setMode('overview');
  393. // Print: build a stack of all iframes so browser prints every slide
  394. window.addEventListener('beforeprint', () => {
  395. printStack.innerHTML = '';
  396. deck.forEach(item => {
  397. const f = document.createElement('iframe');
  398. f.src = item.file;
  399. printStack.appendChild(f);
  400. });
  401. printStack.style.display = 'block';
  402. stage.style.display = 'none';
  403. });
  404. window.addEventListener('afterprint', () => {
  405. printStack.innerHTML = '';
  406. printStack.style.display = 'none';
  407. stage.style.display = '';
  408. });
  409. })();
  410. </script>
  411. </body>
  412. </html>