runtime.js 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960
  1. /* html-ppt :: runtime.js
  2. * Keyboard-driven deck runtime. Zero dependencies.
  3. *
  4. * Features:
  5. * ← → / space / PgUp PgDn / Home End navigation
  6. * F fullscreen
  7. * S presenter mode (opens a NEW WINDOW with current/next slide preview + notes + timer)
  8. * The original window stays as audience view, synced via BroadcastChannel.
  9. * Slide previews use CSS transform:scale() at design resolution for pixel-perfect layout.
  10. * N quick notes overlay (bottom drawer)
  11. * O slide overview grid
  12. * T cycle themes (reads data-themes on <html> or <body>)
  13. * A cycle demo animation on current slide
  14. * URL hash #/N deep-link to slide N (1-based)
  15. * Progress bar auto-managed
  16. */
  17. (function () {
  18. 'use strict';
  19. const ANIMS = ['fade-up','fade-down','fade-left','fade-right','rise-in','drop-in',
  20. 'zoom-pop','blur-in','glitch-in','typewriter','neon-glow','shimmer-sweep',
  21. 'gradient-flow','stagger-list','counter-up','path-draw','parallax-tilt',
  22. 'card-flip-3d','cube-rotate-3d','page-turn-3d','perspective-zoom',
  23. 'marquee-scroll','kenburns','confetti-burst','spotlight','morph-shape','ripple-reveal'];
  24. function ready(fn){ if(document.readyState!='loading')fn(); else document.addEventListener('DOMContentLoaded',fn);}
  25. /* ========== Parse URL for preview-only mode ==========
  26. * When loaded as iframe.src = "index.html?preview=3", runtime enters a
  27. * locked single-slide mode: only slide N is visible, no chrome, no keys,
  28. * no hash updates. This is how the presenter window shows pixel-perfect
  29. * previews — by loading the actual deck file in an iframe and telling it
  30. * to display only a specific slide.
  31. */
  32. function getPreviewIdx() {
  33. const m = /[?&]preview=(\d+)/.exec(location.search || '');
  34. return m ? parseInt(m[1], 10) - 1 : -1;
  35. }
  36. ready(function () {
  37. const deck = document.querySelector('.deck');
  38. if (!deck) return;
  39. const slides = Array.from(deck.querySelectorAll('.slide'));
  40. if (!slides.length) return;
  41. const previewOnlyIdx = getPreviewIdx();
  42. const isPreviewMode = previewOnlyIdx >= 0 && previewOnlyIdx < slides.length;
  43. /* ===== Preview-only mode: show one slide, hide everything else ===== */
  44. if (isPreviewMode) {
  45. function showSlide(i) {
  46. slides.forEach((s, j) => {
  47. const active = (j === i);
  48. s.classList.toggle('is-active', active);
  49. s.style.display = active ? '' : 'none';
  50. if (active) {
  51. s.style.opacity = '1';
  52. s.style.transform = 'none';
  53. s.style.pointerEvents = 'auto';
  54. }
  55. });
  56. }
  57. showSlide(previewOnlyIdx);
  58. /* Hide chrome that the presenter shouldn't see in preview */
  59. const hideSel = '.progress-bar, .notes-overlay, .overview, .notes, aside.notes, .speaker-notes';
  60. document.querySelectorAll(hideSel).forEach(el => { el.style.display = 'none'; });
  61. document.documentElement.setAttribute('data-preview', '1');
  62. document.body.setAttribute('data-preview', '1');
  63. /* Auto-detect theme base path for theme switching in preview mode */
  64. function getPreviewThemeBase() {
  65. const base = document.documentElement.getAttribute('data-theme-base');
  66. if (base) return base;
  67. const tl = document.getElementById('theme-link');
  68. if (tl) {
  69. const raw = tl.getAttribute('href') || '';
  70. const ls = raw.lastIndexOf('/');
  71. if (ls >= 0) return raw.substring(0, ls + 1);
  72. }
  73. return 'assets/themes/';
  74. }
  75. const previewThemeBase = getPreviewThemeBase();
  76. /* Listen for postMessage from parent presenter window:
  77. * - preview-goto: switch visible slide WITHOUT reloading
  78. * - preview-theme: switch theme CSS link to match audience window */
  79. window.addEventListener('message', function(e) {
  80. if (!e.data) return;
  81. if (e.data.type === 'preview-goto') {
  82. const n = parseInt(e.data.idx, 10);
  83. if (n >= 0 && n < slides.length) showSlide(n);
  84. } else if (e.data.type === 'preview-theme' && e.data.name) {
  85. let link = document.getElementById('theme-link');
  86. if (!link) {
  87. link = document.createElement('link');
  88. link.rel = 'stylesheet';
  89. link.id = 'theme-link';
  90. document.head.appendChild(link);
  91. }
  92. link.href = previewThemeBase + e.data.name + '.css';
  93. document.documentElement.setAttribute('data-theme', e.data.name);
  94. }
  95. });
  96. /* Signal to parent that preview iframe is ready */
  97. try { window.parent && window.parent.postMessage({ type: 'preview-ready' }, '*'); } catch(e) {}
  98. return;
  99. }
  100. let idx = 0;
  101. const total = slides.length;
  102. /* ===== BroadcastChannel for presenter sync ===== */
  103. const CHANNEL_NAME = 'html-ppt-presenter-' + location.pathname;
  104. let bc;
  105. try { bc = new BroadcastChannel(CHANNEL_NAME); } catch(e) { bc = null; }
  106. // Are we running inside the presenter popup? (legacy flag, now unused)
  107. const isPresenterWindow = false;
  108. /* ===== progress bar ===== */
  109. let bar = document.querySelector('.progress-bar');
  110. if (!bar) {
  111. bar = document.createElement('div');
  112. bar.className = 'progress-bar';
  113. bar.innerHTML = '<span></span>';
  114. document.body.appendChild(bar);
  115. }
  116. const barFill = bar.querySelector('span');
  117. /* ===== notes overlay (N key) ===== */
  118. let notes = document.querySelector('.notes-overlay');
  119. if (!notes) {
  120. notes = document.createElement('div');
  121. notes.className = 'notes-overlay';
  122. document.body.appendChild(notes);
  123. }
  124. /* ===== overview grid (O key) ===== */
  125. let overview = document.querySelector('.overview');
  126. if (!overview) {
  127. overview = document.createElement('div');
  128. overview.className = 'overview';
  129. slides.forEach((s, i) => {
  130. const t = document.createElement('div');
  131. t.className = 'thumb';
  132. // Force 16:9 aspect ratio robustly
  133. t.style.padding = '0 0 56.25% 0';
  134. t.style.height = '0';
  135. t.style.position = 'relative';
  136. t.style.overflow = 'hidden';
  137. const title = s.getAttribute('data-title') ||
  138. (s.querySelector('h1,h2,h3')||{}).textContent || ('Slide '+(i+1));
  139. // Create a container for the mini-slide
  140. const mini = document.createElement('div');
  141. mini.className = 'mini-slide';
  142. mini.style.position = 'absolute';
  143. mini.style.top = '0';
  144. mini.style.left = '0';
  145. mini.style.width = '1920px';
  146. mini.style.height = '1080px';
  147. mini.style.transformOrigin = 'top left';
  148. mini.style.pointerEvents = 'none';
  149. mini.style.background = 'var(--bg)';
  150. // Clone the slide content
  151. const clone = s.cloneNode(true);
  152. clone.className = 'slide is-active'; // force active styles
  153. clone.style.position = 'absolute';
  154. clone.style.inset = '0';
  155. clone.style.transform = 'none';
  156. clone.style.opacity = '1';
  157. clone.style.padding = '72px 96px'; // ensure padding is kept
  158. mini.appendChild(clone);
  159. t.appendChild(mini);
  160. // Add the number and title overlay
  161. const overlay = document.createElement('div');
  162. overlay.style.position = 'absolute';
  163. overlay.style.inset = '0';
  164. overlay.style.background = 'linear-gradient(to bottom, rgba(0,0,0,0.2) 0%, transparent 40%, transparent 60%, rgba(0,0,0,0.8) 100%)';
  165. overlay.style.color = '#fff';
  166. overlay.style.zIndex = '10';
  167. overlay.style.pointerEvents = 'none';
  168. const n = document.createElement('div');
  169. n.className = 'n';
  170. n.textContent = i + 1;
  171. n.style.position = 'absolute';
  172. n.style.top = '12px';
  173. n.style.left = '16px';
  174. n.style.fontWeight = '700';
  175. n.style.fontSize = '16px';
  176. n.style.color = '#fff';
  177. n.style.textShadow = '0 1px 4px rgba(0,0,0,0.8)';
  178. const text = document.createElement('div');
  179. text.className = 't';
  180. text.textContent = title.trim().slice(0,80);
  181. text.style.position = 'absolute';
  182. text.style.bottom = '12px';
  183. text.style.left = '16px';
  184. text.style.right = '16px';
  185. text.style.fontWeight = '600';
  186. text.style.fontSize = '14px';
  187. text.style.color = '#fff';
  188. text.style.textShadow = '0 1px 4px rgba(0,0,0,0.8)';
  189. overlay.appendChild(n);
  190. overlay.appendChild(text);
  191. t.appendChild(overlay);
  192. t.addEventListener('click', () => { go(i); toggleOverview(false); });
  193. overview.appendChild(t);
  194. });
  195. document.body.appendChild(overview);
  196. }
  197. /* ===== navigation ===== */
  198. function go(n, fromRemote){
  199. n = Math.max(0, Math.min(total-1, n));
  200. slides.forEach((s,i) => {
  201. s.classList.toggle('is-active', i===n);
  202. s.classList.toggle('is-prev', i<n);
  203. });
  204. idx = n;
  205. barFill.style.width = ((n+1)/total*100)+'%';
  206. const numEl = document.querySelector('.slide-number');
  207. if (numEl) { numEl.setAttribute('data-current', n+1); numEl.setAttribute('data-total', total); }
  208. // notes (bottom overlay)
  209. const note = slides[n].querySelector('.notes, aside.notes, .speaker-notes');
  210. notes.innerHTML = note ? note.innerHTML : '';
  211. // hash
  212. const hashTarget = '#/'+(n+1);
  213. if (location.hash !== hashTarget && !isPresenterWindow) {
  214. history.replaceState(null,'', hashTarget);
  215. }
  216. // re-trigger entry animations
  217. slides[n].querySelectorAll('[data-anim]').forEach(el => {
  218. const a = el.getAttribute('data-anim');
  219. el.classList.remove('anim-'+a);
  220. void el.offsetWidth;
  221. el.classList.add('anim-'+a);
  222. });
  223. // counter-up
  224. slides[n].querySelectorAll('.counter').forEach(el => {
  225. const target = parseFloat(el.getAttribute('data-to')||el.textContent);
  226. const dur = parseInt(el.getAttribute('data-dur')||'1200',10);
  227. const start = performance.now();
  228. const from = 0;
  229. function tick(now){
  230. const t = Math.min(1,(now-start)/dur);
  231. const v = from + (target-from)*(1-Math.pow(1-t,3));
  232. el.textContent = (target % 1 === 0) ? Math.round(v) : v.toFixed(1);
  233. if (t<1) requestAnimationFrame(tick);
  234. }
  235. requestAnimationFrame(tick);
  236. });
  237. // Broadcast to other window (audience ↔ presenter)
  238. if (!fromRemote && bc) {
  239. bc.postMessage({ type: 'go', idx: n });
  240. }
  241. }
  242. /* ===== listen for remote navigation / theme changes ===== */
  243. if (bc) {
  244. bc.onmessage = function(e) {
  245. if (!e.data) return;
  246. if (e.data.type === 'go' && typeof e.data.idx === 'number') {
  247. go(e.data.idx, true);
  248. } else if (e.data.type === 'theme' && e.data.name) {
  249. /* Sync theme across windows */
  250. const i = themes.indexOf(e.data.name);
  251. if (i >= 0) themeIdx = i;
  252. applyTheme(e.data.name);
  253. }
  254. };
  255. }
  256. function toggleNotes(force){ notes.classList.toggle('open', force!==undefined?force:!notes.classList.contains('open')); }
  257. function toggleOverview(force){
  258. const isOpen = force!==undefined ? force : !overview.classList.contains('open');
  259. overview.classList.toggle('open', isOpen);
  260. if (isOpen) {
  261. requestAnimationFrame(() => {
  262. const thumbs = overview.querySelectorAll('.thumb');
  263. if (thumbs.length) {
  264. const scale = thumbs[0].clientWidth / 1920;
  265. overview.querySelectorAll('.mini-slide').forEach(m => {
  266. m.style.transform = 'scale(' + scale + ')';
  267. });
  268. }
  269. });
  270. }
  271. }
  272. /* ========== PRESENTER MODE — Magnetic-card popup window ========== */
  273. /* Opens a new window with 4 draggable, resizable cards:
  274. * CURRENT — iframe(?preview=N) pixel-perfect preview of current slide
  275. * NEXT — iframe(?preview=N+1) pixel-perfect preview of next slide
  276. * SCRIPT — large speaker notes (逐字稿)
  277. * TIMER — elapsed timer + page counter + controls
  278. * Cards remember position/size in localStorage.
  279. * Two windows sync via BroadcastChannel.
  280. */
  281. let presenterWin = null;
  282. function openPresenterWindow() {
  283. if (presenterWin && !presenterWin.closed) {
  284. presenterWin.focus();
  285. return;
  286. }
  287. // Build absolute URL of THIS deck file (without hash/query)
  288. const deckUrl = location.protocol + '//' + location.host + location.pathname;
  289. // Collect slide titles + notes (HTML strings)
  290. const slideMeta = slides.map((s, i) => {
  291. const note = s.querySelector('.notes, aside.notes, .speaker-notes');
  292. return {
  293. title: s.getAttribute('data-title') ||
  294. (s.querySelector('h1,h2,h3')||{}).textContent || ('Slide '+(i+1)),
  295. notes: note ? note.innerHTML : ''
  296. };
  297. });
  298. /* Capture current theme so presenter previews match the audience */
  299. const currentTheme = root.getAttribute('data-theme') || (themes[themeIdx] || '');
  300. const presenterHTML = buildPresenterHTML(deckUrl, slideMeta, total, idx, CHANNEL_NAME, currentTheme);
  301. presenterWin = window.open('', 'html-ppt-presenter', 'width=1280,height=820,menubar=no,toolbar=no');
  302. if (!presenterWin) {
  303. alert('请允许弹出窗口以使用演讲者视图');
  304. return;
  305. }
  306. presenterWin.document.open();
  307. presenterWin.document.write(presenterHTML);
  308. presenterWin.document.close();
  309. }
  310. function buildPresenterHTML(deckUrl, slideMeta, total, startIdx, channelName, currentTheme) {
  311. const metaJSON = JSON.stringify(slideMeta);
  312. const deckUrlJSON = JSON.stringify(deckUrl);
  313. const channelJSON = JSON.stringify(channelName);
  314. const themeJSON = JSON.stringify(currentTheme || '');
  315. const storageKey = 'html-ppt-presenter:' + location.pathname;
  316. // Build the document as a single template string for clarity
  317. return `<!DOCTYPE html>
  318. <html lang="zh-CN">
  319. <head>
  320. <meta charset="utf-8">
  321. <title>Presenter View</title>
  322. <style>
  323. * { margin: 0; padding: 0; box-sizing: border-box; }
  324. html, body {
  325. width: 100%; height: 100%; overflow: hidden;
  326. background: #1a1d24;
  327. background-image:
  328. radial-gradient(circle at 20% 30%, rgba(88,166,255,.04), transparent 50%),
  329. radial-gradient(circle at 80% 70%, rgba(188,140,255,.04), transparent 50%);
  330. color: #e6edf3;
  331. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif;
  332. }
  333. /* Stage: positioned area where cards live */
  334. #stage { position: absolute; inset: 0; overflow: hidden; }
  335. /* Magnetic card */
  336. .pcard {
  337. position: absolute;
  338. background: #0d1117;
  339. border: 1px solid rgba(255,255,255,.1);
  340. border-radius: 12px;
  341. box-shadow: 0 8px 32px rgba(0,0,0,.45), 0 0 0 1px rgba(255,255,255,.02);
  342. display: flex; flex-direction: column;
  343. overflow: hidden;
  344. min-width: 180px; min-height: 100px;
  345. transition: box-shadow .2s, border-color .2s;
  346. }
  347. .pcard.dragging { box-shadow: 0 16px 48px rgba(0,0,0,.6), 0 0 0 2px rgba(88,166,255,.5); border-color: #58a6ff; transition: none; z-index: 9999; }
  348. .pcard.resizing { box-shadow: 0 16px 48px rgba(0,0,0,.6), 0 0 0 2px rgba(63,185,80,.5); border-color: #3fb950; transition: none; z-index: 9999; }
  349. .pcard:hover { border-color: rgba(88,166,255,.3); }
  350. /* Card header (drag handle) */
  351. .pcard-head {
  352. display: flex; align-items: center; gap: 10px;
  353. padding: 8px 12px;
  354. background: rgba(255,255,255,.04);
  355. border-bottom: 1px solid rgba(255,255,255,.06);
  356. cursor: move;
  357. user-select: none;
  358. flex-shrink: 0;
  359. }
  360. .pcard-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dot-color, #58a6ff); flex-shrink: 0; }
  361. .pcard-title {
  362. font-size: 11px; letter-spacing: .15em; text-transform: uppercase;
  363. font-weight: 700; color: #8b949e; flex: 1;
  364. }
  365. .pcard-meta { font-size: 11px; color: #6e7681; }
  366. /* Card body */
  367. .pcard-body { flex: 1; position: relative; overflow: hidden; min-height: 0; }
  368. /* Preview cards (CURRENT/NEXT) — iframe-based pixel-perfect render */
  369. .pcard-preview .pcard-body { background: #000; }
  370. .pcard-preview iframe {
  371. position: absolute; top: 0; left: 0;
  372. width: 1920px; height: 1080px;
  373. border: none;
  374. transform-origin: top left;
  375. pointer-events: none;
  376. background: transparent;
  377. }
  378. .pcard-preview .preview-end {
  379. position: absolute; inset: 0;
  380. display: flex; align-items: center; justify-content: center;
  381. color: #484f58; font-size: 14px; letter-spacing: .12em;
  382. }
  383. /* Notes card */
  384. .pcard-notes .pcard-body {
  385. padding: 14px 18px;
  386. overflow-y: auto;
  387. font-size: 18px; line-height: 1.75;
  388. color: #d0d7de;
  389. font-family: "Noto Sans SC", -apple-system, sans-serif;
  390. }
  391. .pcard-notes .pcard-body p { margin: 0 0 .7em 0; }
  392. .pcard-notes .pcard-body strong { color: #f0883e; }
  393. .pcard-notes .pcard-body em { color: #58a6ff; font-style: normal; }
  394. .pcard-notes .pcard-body code {
  395. font-family: "SF Mono", monospace; font-size: .9em;
  396. background: rgba(255,255,255,.08); padding: 1px 6px; border-radius: 4px;
  397. }
  398. .pcard-notes .empty { color: #484f58; font-style: italic; }
  399. /* Timer card */
  400. .pcard-timer .pcard-body {
  401. display: flex; flex-direction: column; gap: 14px;
  402. padding: 18px 20px; justify-content: center;
  403. }
  404. .timer-display {
  405. font-family: "SF Mono", "JetBrains Mono", monospace;
  406. font-size: 42px; font-weight: 700;
  407. color: #3fb950;
  408. letter-spacing: .04em;
  409. line-height: 1;
  410. }
  411. .timer-row {
  412. display: flex; align-items: center; gap: 12px;
  413. font-size: 14px; color: #8b949e;
  414. }
  415. .timer-row .label { font-size: 10px; letter-spacing: .15em; text-transform: uppercase; color: #6e7681; }
  416. .timer-row .val { color: #e6edf3; font-weight: 600; font-family: "SF Mono", monospace; }
  417. .timer-controls { display: flex; gap: 8px; flex-wrap: wrap; }
  418. .timer-btn {
  419. background: rgba(255,255,255,.06);
  420. border: 1px solid rgba(255,255,255,.1);
  421. color: #e6edf3;
  422. padding: 6px 12px;
  423. border-radius: 6px;
  424. font-size: 12px;
  425. cursor: pointer;
  426. font-family: inherit;
  427. }
  428. .timer-btn:hover { background: rgba(88,166,255,.15); border-color: #58a6ff; }
  429. .timer-btn:active { transform: translateY(1px); }
  430. /* Resize handle */
  431. .pcard-resize {
  432. position: absolute; right: 0; bottom: 0;
  433. width: 18px; height: 18px;
  434. cursor: nwse-resize;
  435. background: linear-gradient(135deg, transparent 50%, rgba(255,255,255,.25) 50%, rgba(255,255,255,.25) 60%, transparent 60%, transparent 70%, rgba(255,255,255,.25) 70%, rgba(255,255,255,.25) 80%, transparent 80%);
  436. z-index: 5;
  437. }
  438. .pcard-resize:hover { background: linear-gradient(135deg, transparent 50%, #58a6ff 50%, #58a6ff 60%, transparent 60%, transparent 70%, #58a6ff 70%, #58a6ff 80%, transparent 80%); }
  439. /* Bottom hint bar */
  440. .hint-bar {
  441. position: fixed; bottom: 0; left: 0; right: 0;
  442. background: rgba(0,0,0,.6);
  443. backdrop-filter: blur(10px);
  444. border-top: 1px solid rgba(255,255,255,.08);
  445. padding: 6px 16px;
  446. font-size: 11px; color: #8b949e;
  447. display: flex; gap: 18px; align-items: center;
  448. z-index: 1000;
  449. }
  450. .hint-bar kbd {
  451. background: rgba(255,255,255,.08);
  452. padding: 1px 6px; border-radius: 3px;
  453. font-family: "SF Mono", monospace;
  454. font-size: 10px;
  455. border: 1px solid rgba(255,255,255,.1);
  456. color: #e6edf3;
  457. }
  458. .hint-bar .reset-layout {
  459. margin-left: auto;
  460. background: transparent; border: 1px solid rgba(255,255,255,.15);
  461. color: #8b949e; padding: 3px 10px; border-radius: 4px;
  462. font-size: 11px; cursor: pointer; font-family: inherit;
  463. }
  464. .hint-bar .reset-layout:hover { background: rgba(248,81,73,.15); border-color: #f85149; color: #f85149; }
  465. body.is-dragging-card * { user-select: none !important; }
  466. body.is-dragging-card iframe { pointer-events: none !important; }
  467. </style>
  468. </head>
  469. <body>
  470. <div id="stage">
  471. <div class="pcard pcard-preview" id="card-cur" style="--dot-color:#58a6ff">
  472. <div class="pcard-head" data-drag>
  473. <span class="pcard-dot"></span>
  474. <span class="pcard-title">CURRENT</span>
  475. <span class="pcard-meta" id="cur-meta">—</span>
  476. </div>
  477. <div class="pcard-body"><iframe id="iframe-cur"></iframe></div>
  478. <div class="pcard-resize" data-resize></div>
  479. </div>
  480. <div class="pcard pcard-preview" id="card-nxt" style="--dot-color:#bc8cff">
  481. <div class="pcard-head" data-drag>
  482. <span class="pcard-dot"></span>
  483. <span class="pcard-title">NEXT</span>
  484. <span class="pcard-meta" id="nxt-meta">—</span>
  485. </div>
  486. <div class="pcard-body"><iframe id="iframe-nxt"></iframe></div>
  487. <div class="pcard-resize" data-resize></div>
  488. </div>
  489. <div class="pcard pcard-notes" id="card-notes" style="--dot-color:#f0883e">
  490. <div class="pcard-head" data-drag>
  491. <span class="pcard-dot"></span>
  492. <span class="pcard-title">SPEAKER SCRIPT · 逐字稿</span>
  493. </div>
  494. <div class="pcard-body" id="notes-body"></div>
  495. <div class="pcard-resize" data-resize></div>
  496. </div>
  497. <div class="pcard pcard-timer" id="card-timer" style="--dot-color:#3fb950">
  498. <div class="pcard-head" data-drag>
  499. <span class="pcard-dot"></span>
  500. <span class="pcard-title">TIMER</span>
  501. </div>
  502. <div class="pcard-body">
  503. <div class="timer-display" id="timer-display">00:00</div>
  504. <div class="timer-row">
  505. <span class="label">Slide</span>
  506. <span class="val" id="timer-count">1 / ${total}</span>
  507. </div>
  508. <div class="timer-controls">
  509. <button class="timer-btn" id="btn-prev">← Prev</button>
  510. <button class="timer-btn" id="btn-next">Next →</button>
  511. <button class="timer-btn" id="btn-reset">⏱ Reset</button>
  512. </div>
  513. </div>
  514. <div class="pcard-resize" data-resize></div>
  515. </div>
  516. </div>
  517. <div class="hint-bar">
  518. <span><kbd>← →</kbd> 翻页</span>
  519. <span><kbd>R</kbd> 重置计时</span>
  520. <span><kbd>Esc</kbd> 关闭</span>
  521. <span style="color:#6e7681">拖动卡片头部移动 · 拖动右下角调整大小</span>
  522. <button class="reset-layout" id="reset-layout">重置布局</button>
  523. </div>
  524. <script>
  525. (function(){
  526. var slideMeta = ${metaJSON};
  527. var total = ${total};
  528. var idx = ${startIdx};
  529. var deckUrl = ${deckUrlJSON};
  530. var STORAGE_KEY = ${JSON.stringify(storageKey)};
  531. var bc;
  532. try { bc = new BroadcastChannel(${channelJSON}); } catch(e) {}
  533. var iframeCur = document.getElementById('iframe-cur');
  534. var iframeNxt = document.getElementById('iframe-nxt');
  535. var notesBody = document.getElementById('notes-body');
  536. var curMeta = document.getElementById('cur-meta');
  537. var nxtMeta = document.getElementById('nxt-meta');
  538. var timerDisplay = document.getElementById('timer-display');
  539. var timerCount = document.getElementById('timer-count');
  540. /* ===== Default card layout ===== */
  541. function defaultLayout() {
  542. var w = window.innerWidth;
  543. var h = window.innerHeight - 36; /* leave room for hint bar */
  544. return {
  545. 'card-cur': { x: 16, y: 16, w: Math.round(w*0.55) - 24, h: Math.round(h*0.62) - 16 },
  546. 'card-nxt': { x: Math.round(w*0.55) + 8, y: 16, w: w - Math.round(w*0.55) - 24, h: Math.round(h*0.42) - 16 },
  547. 'card-notes': { x: Math.round(w*0.55) + 8, y: Math.round(h*0.42) + 8, w: w - Math.round(w*0.55) - 24, h: h - Math.round(h*0.42) - 16 },
  548. 'card-timer': { x: 16, y: Math.round(h*0.62) + 8, w: Math.round(w*0.55) - 24, h: h - Math.round(h*0.62) - 16 }
  549. };
  550. }
  551. /* ===== Apply / save / restore layout ===== */
  552. function applyLayout(layout) {
  553. Object.keys(layout).forEach(function(id){
  554. var el = document.getElementById(id);
  555. var l = layout[id];
  556. if (el && l) {
  557. el.style.left = l.x + 'px';
  558. el.style.top = l.y + 'px';
  559. el.style.width = l.w + 'px';
  560. el.style.height = l.h + 'px';
  561. }
  562. });
  563. rescaleAll();
  564. }
  565. function readLayout() {
  566. try {
  567. var saved = localStorage.getItem(STORAGE_KEY);
  568. if (saved) return JSON.parse(saved);
  569. } catch(e) {}
  570. return defaultLayout();
  571. }
  572. function saveLayout() {
  573. var layout = {};
  574. ['card-cur','card-nxt','card-notes','card-timer'].forEach(function(id){
  575. var el = document.getElementById(id);
  576. if (el) {
  577. layout[id] = {
  578. x: parseInt(el.style.left,10) || 0,
  579. y: parseInt(el.style.top,10) || 0,
  580. w: parseInt(el.style.width,10) || 300,
  581. h: parseInt(el.style.height,10) || 200
  582. };
  583. }
  584. });
  585. try { localStorage.setItem(STORAGE_KEY, JSON.stringify(layout)); } catch(e) {}
  586. }
  587. /* ===== iframe rescale to fit card body ===== */
  588. function rescaleIframe(iframe) {
  589. if (!iframe || iframe.style.display === 'none') return;
  590. var body = iframe.parentElement;
  591. var cw = body.clientWidth, ch = body.clientHeight;
  592. if (!cw || !ch) return;
  593. var s = Math.min(cw / 1920, ch / 1080);
  594. iframe.style.transform = 'scale(' + s + ')';
  595. /* Center the scaled iframe in the body */
  596. var sw = 1920 * s, sh = 1080 * s;
  597. iframe.style.left = Math.max(0, (cw - sw) / 2) + 'px';
  598. iframe.style.top = Math.max(0, (ch - sh) / 2) + 'px';
  599. }
  600. function rescaleAll() {
  601. rescaleIframe(iframeCur);
  602. rescaleIframe(iframeNxt);
  603. }
  604. window.addEventListener('resize', rescaleAll);
  605. /* ===== Drag (move card by header) ===== */
  606. document.querySelectorAll('[data-drag]').forEach(function(handle){
  607. handle.addEventListener('mousedown', function(e){
  608. if (e.button !== 0) return;
  609. var card = handle.closest('.pcard');
  610. if (!card) return;
  611. e.preventDefault();
  612. card.classList.add('dragging');
  613. document.body.classList.add('is-dragging-card');
  614. var startX = e.clientX, startY = e.clientY;
  615. var startL = parseInt(card.style.left,10) || 0;
  616. var startT = parseInt(card.style.top,10) || 0;
  617. function onMove(ev){
  618. var nx = Math.max(0, Math.min(window.innerWidth - 100, startL + ev.clientX - startX));
  619. var ny = Math.max(0, Math.min(window.innerHeight - 50, startT + ev.clientY - startY));
  620. card.style.left = nx + 'px';
  621. card.style.top = ny + 'px';
  622. }
  623. function onUp(){
  624. card.classList.remove('dragging');
  625. document.body.classList.remove('is-dragging-card');
  626. document.removeEventListener('mousemove', onMove);
  627. document.removeEventListener('mouseup', onUp);
  628. saveLayout();
  629. }
  630. document.addEventListener('mousemove', onMove);
  631. document.addEventListener('mouseup', onUp);
  632. });
  633. });
  634. /* ===== Resize (drag bottom-right corner) ===== */
  635. document.querySelectorAll('[data-resize]').forEach(function(handle){
  636. handle.addEventListener('mousedown', function(e){
  637. if (e.button !== 0) return;
  638. var card = handle.closest('.pcard');
  639. if (!card) return;
  640. e.preventDefault(); e.stopPropagation();
  641. card.classList.add('resizing');
  642. document.body.classList.add('is-dragging-card');
  643. var startX = e.clientX, startY = e.clientY;
  644. var startW = parseInt(card.style.width,10) || card.offsetWidth;
  645. var startH = parseInt(card.style.height,10) || card.offsetHeight;
  646. function onMove(ev){
  647. var nw = Math.max(180, startW + ev.clientX - startX);
  648. var nh = Math.max(100, startH + ev.clientY - startY);
  649. card.style.width = nw + 'px';
  650. card.style.height = nh + 'px';
  651. if (card.querySelector('iframe')) rescaleIframe(card.querySelector('iframe'));
  652. }
  653. function onUp(){
  654. card.classList.remove('resizing');
  655. document.body.classList.remove('is-dragging-card');
  656. document.removeEventListener('mousemove', onMove);
  657. document.removeEventListener('mouseup', onUp);
  658. rescaleAll();
  659. saveLayout();
  660. }
  661. document.addEventListener('mousemove', onMove);
  662. document.addEventListener('mouseup', onUp);
  663. });
  664. });
  665. /* ===== Preview iframe ready tracking =====
  666. * Each iframe loads the deck ONCE with ?preview=1 on init. Subsequent
  667. * slide changes are sent via postMessage('preview-goto') so the iframe
  668. * just toggles visibility of a different .slide — no reload, no flicker.
  669. */
  670. var iframeReady = { cur: false, nxt: false };
  671. var currentTheme = ${themeJSON};
  672. window.addEventListener('message', function(e) {
  673. if (!e.data || e.data.type !== 'preview-ready') return;
  674. var iframe = null;
  675. if (e.source === iframeCur.contentWindow) {
  676. iframeReady.cur = true;
  677. iframe = iframeCur;
  678. postPreviewGoto(iframeCur, idx);
  679. } else if (e.source === iframeNxt.contentWindow) {
  680. iframeReady.nxt = true;
  681. iframe = iframeNxt;
  682. postPreviewGoto(iframeNxt, idx + 1 < total ? idx + 1 : idx);
  683. }
  684. /* Sync current theme to the iframe */
  685. if (iframe && currentTheme) {
  686. try { iframe.contentWindow.postMessage({ type: 'preview-theme', name: currentTheme }, '*'); } catch(err) {}
  687. }
  688. if (iframe) rescaleIframe(iframe);
  689. });
  690. function postPreviewGoto(iframe, n) {
  691. try {
  692. iframe.contentWindow.postMessage({ type: 'preview-goto', idx: n }, '*');
  693. } catch(e) {}
  694. }
  695. /* ===== Update content =====
  696. * Smooth (no-reload) navigation: send postMessage to iframes instead of
  697. * resetting src. Iframes stay loaded, just switch visible .slide.
  698. */
  699. function update(n) {
  700. n = Math.max(0, Math.min(total - 1, n));
  701. idx = n;
  702. /* Current preview — postMessage (smooth) */
  703. if (iframeReady.cur) postPreviewGoto(iframeCur, n);
  704. curMeta.textContent = (n + 1) + '/' + total;
  705. /* Next preview */
  706. if (n + 1 < total) {
  707. iframeNxt.style.display = '';
  708. var endEl = document.querySelector('#card-nxt .preview-end');
  709. if (endEl) endEl.remove();
  710. if (iframeReady.nxt) postPreviewGoto(iframeNxt, n + 1);
  711. nxtMeta.textContent = (n + 2) + '/' + total;
  712. } else {
  713. iframeNxt.style.display = 'none';
  714. var body = document.querySelector('#card-nxt .pcard-body');
  715. if (body && !body.querySelector('.preview-end')) {
  716. var end = document.createElement('div');
  717. end.className = 'preview-end';
  718. end.textContent = '— END OF DECK —';
  719. body.appendChild(end);
  720. }
  721. nxtMeta.textContent = 'END';
  722. }
  723. /* Notes */
  724. var note = slideMeta[n].notes;
  725. notesBody.innerHTML = note || '<span class="empty">(这一页还没有逐字稿)</span>';
  726. /* Timer count */
  727. timerCount.textContent = (n + 1) + ' / ' + total;
  728. }
  729. /* ===== Timer ===== */
  730. var tStart = Date.now();
  731. setInterval(function(){
  732. var s = Math.floor((Date.now() - tStart) / 1000);
  733. var mm = String(Math.floor(s/60)).padStart(2,'0');
  734. var ss = String(s%60).padStart(2,'0');
  735. timerDisplay.textContent = mm + ':' + ss;
  736. }, 1000);
  737. function resetTimer(){ tStart = Date.now(); timerDisplay.textContent = '00:00'; }
  738. /* ===== BroadcastChannel sync ===== */
  739. if (bc) {
  740. bc.onmessage = function(e){
  741. if (!e.data) return;
  742. if (e.data.type === 'go') update(e.data.idx);
  743. else if (e.data.type === 'theme' && e.data.name) {
  744. currentTheme = e.data.name;
  745. /* Forward theme change to preview iframes */
  746. [iframeCur, iframeNxt].forEach(function(iframe){
  747. try {
  748. iframe.contentWindow.postMessage({ type: 'preview-theme', name: e.data.name }, '*');
  749. } catch(err) {}
  750. });
  751. }
  752. };
  753. }
  754. function go(n) {
  755. update(n);
  756. if (bc) bc.postMessage({ type: 'go', idx: idx });
  757. }
  758. /* ===== Buttons ===== */
  759. document.getElementById('btn-prev').addEventListener('click', function(){ go(idx - 1); });
  760. document.getElementById('btn-next').addEventListener('click', function(){ go(idx + 1); });
  761. document.getElementById('btn-reset').addEventListener('click', resetTimer);
  762. document.getElementById('reset-layout').addEventListener('click', function(){
  763. if (confirm('恢复默认卡片布局?')) {
  764. try { localStorage.removeItem(STORAGE_KEY); } catch(e){}
  765. applyLayout(defaultLayout());
  766. }
  767. });
  768. /* ===== Keyboard ===== */
  769. document.addEventListener('keydown', function(e){
  770. if (e.metaKey || e.ctrlKey || e.altKey) return;
  771. switch(e.key) {
  772. case 'ArrowRight': case ' ': case 'PageDown': go(idx + 1); e.preventDefault(); break;
  773. case 'ArrowLeft': case 'PageUp': go(idx - 1); e.preventDefault(); break;
  774. case 'Home': go(0); break;
  775. case 'End': go(total - 1); break;
  776. case 'r': case 'R': resetTimer(); break;
  777. case 'Escape': window.close(); break;
  778. }
  779. });
  780. /* ===== Iframe load → rescale (catches initial size) ===== */
  781. iframeCur.addEventListener('load', function(){ rescaleIframe(iframeCur); });
  782. iframeNxt.addEventListener('load', function(){ rescaleIframe(iframeNxt); });
  783. /* ===== Init =====
  784. * Load each iframe ONCE with the deck file. After they post
  785. * 'preview-ready', all subsequent navigation is via postMessage
  786. * (smooth, no reload, no flicker).
  787. */
  788. applyLayout(readLayout());
  789. iframeCur.src = deckUrl + '?preview=' + (idx + 1);
  790. if (idx + 1 < total) iframeNxt.src = deckUrl + '?preview=' + (idx + 2);
  791. /* Initialize notes/timer/count without touching iframes */
  792. notesBody.innerHTML = slideMeta[idx].notes || '<span class="empty">(这一页还没有逐字稿)</span>';
  793. curMeta.textContent = (idx + 1) + '/' + total;
  794. nxtMeta.textContent = (idx + 2) + '/' + total;
  795. timerCount.textContent = (idx + 1) + ' / ' + total;
  796. })();
  797. </` + `script>
  798. </body></html>`;
  799. }
  800. function fullscreen(){ const el=document.documentElement;
  801. if (!document.fullscreenElement) el.requestFullscreen&&el.requestFullscreen();
  802. else document.exitFullscreen&&document.exitFullscreen();
  803. }
  804. // theme cycling
  805. const root = document.documentElement;
  806. const themesAttr = root.getAttribute('data-themes') || document.body.getAttribute('data-themes');
  807. const themes = themesAttr ? themesAttr.split(',').map(s=>s.trim()).filter(Boolean) : [];
  808. let themeIdx = 0;
  809. // Auto-detect theme base path from existing <link id="theme-link">
  810. let themeBase = root.getAttribute('data-theme-base');
  811. if (!themeBase) {
  812. const existingLink = document.getElementById('theme-link');
  813. if (existingLink) {
  814. // el.getAttribute('href') gives the raw relative path written in HTML
  815. const rawHref = existingLink.getAttribute('href') || '';
  816. const lastSlash = rawHref.lastIndexOf('/');
  817. themeBase = lastSlash >= 0 ? rawHref.substring(0, lastSlash + 1) : 'assets/themes/';
  818. } else {
  819. themeBase = 'assets/themes/';
  820. }
  821. }
  822. function applyTheme(name) {
  823. let link = document.getElementById('theme-link');
  824. if (!link) {
  825. link = document.createElement('link');
  826. link.rel = 'stylesheet';
  827. link.id = 'theme-link';
  828. document.head.appendChild(link);
  829. }
  830. link.href = themeBase + name + '.css';
  831. root.setAttribute('data-theme', name);
  832. const ind = document.querySelector('.theme-indicator');
  833. if (ind) ind.textContent = name;
  834. }
  835. function cycleTheme(fromRemote){
  836. if (!themes.length) return;
  837. themeIdx = (themeIdx+1) % themes.length;
  838. const name = themes[themeIdx];
  839. applyTheme(name);
  840. /* Broadcast to other window (audience ↔ presenter) */
  841. if (!fromRemote && bc) bc.postMessage({ type: 'theme', name: name });
  842. }
  843. // animation cycling on current slide
  844. let animIdx = 0;
  845. function cycleAnim(){
  846. animIdx = (animIdx+1) % ANIMS.length;
  847. const a = ANIMS[animIdx];
  848. const target = slides[idx].querySelector('[data-anim-target]') || slides[idx];
  849. ANIMS.forEach(x => target.classList.remove('anim-'+x));
  850. void target.offsetWidth;
  851. target.classList.add('anim-'+a);
  852. target.setAttribute('data-anim', a);
  853. const ind = document.querySelector('.anim-indicator');
  854. if (ind) ind.textContent = a;
  855. }
  856. document.addEventListener('keydown', function (e) {
  857. if (e.metaKey||e.ctrlKey||e.altKey) return;
  858. switch (e.key) {
  859. case 'ArrowRight': case ' ': case 'PageDown': case 'Enter': go(idx+1); e.preventDefault(); break;
  860. case 'ArrowLeft': case 'PageUp': case 'Backspace': go(idx-1); e.preventDefault(); break;
  861. case 'Home': go(0); break;
  862. case 'End': go(total-1); break;
  863. case 'f': case 'F': fullscreen(); break;
  864. case 's': case 'S': openPresenterWindow(); break;
  865. case 'n': case 'N': toggleNotes(); break;
  866. case 'o': case 'O': toggleOverview(); break;
  867. case 't': case 'T': cycleTheme(); break;
  868. case 'a': case 'A': cycleAnim(); break;
  869. case 'Escape': toggleOverview(false); toggleNotes(false); break;
  870. }
  871. });
  872. // hash deep-link
  873. function fromHash(){
  874. const m = /^#\/(\d+)/.exec(location.hash||'');
  875. if (m) go(Math.max(0, parseInt(m[1],10)-1));
  876. }
  877. window.addEventListener('hashchange', fromHash);
  878. fromHash();
  879. go(idx);
  880. });
  881. })();