/* html-ppt :: runtime.js * Keyboard-driven deck runtime. Zero dependencies. * * Features: * ← → / space / PgUp PgDn / Home End navigation * F fullscreen * S presenter mode (opens a NEW WINDOW with current/next slide preview + notes + timer) * The original window stays as audience view, synced via BroadcastChannel. * Slide previews use CSS transform:scale() at design resolution for pixel-perfect layout. * N quick notes overlay (bottom drawer) * O slide overview grid * T cycle themes (reads data-themes on or ) * A cycle demo animation on current slide * URL hash #/N deep-link to slide N (1-based) * Progress bar auto-managed */ (function () { 'use strict'; const ANIMS = ['fade-up','fade-down','fade-left','fade-right','rise-in','drop-in', 'zoom-pop','blur-in','glitch-in','typewriter','neon-glow','shimmer-sweep', 'gradient-flow','stagger-list','counter-up','path-draw','parallax-tilt', 'card-flip-3d','cube-rotate-3d','page-turn-3d','perspective-zoom', 'marquee-scroll','kenburns','confetti-burst','spotlight','morph-shape','ripple-reveal']; function ready(fn){ if(document.readyState!='loading')fn(); else document.addEventListener('DOMContentLoaded',fn);} ready(function () { const deck = document.querySelector('.deck'); if (!deck) return; const slides = Array.from(deck.querySelectorAll('.slide')); if (!slides.length) return; let idx = 0; const total = slides.length; /* ===== BroadcastChannel for presenter sync ===== */ const CHANNEL_NAME = 'html-ppt-presenter-' + (location.pathname + location.search); let bc; try { bc = new BroadcastChannel(CHANNEL_NAME); } catch(e) { bc = null; } // Are we running inside the presenter popup? const isPresenterWindow = location.hash.indexOf('__presenter__') !== -1; /* ===== progress bar ===== */ let bar = document.querySelector('.progress-bar'); if (!bar) { bar = document.createElement('div'); bar.className = 'progress-bar'; bar.innerHTML = ''; document.body.appendChild(bar); } const barFill = bar.querySelector('span'); /* ===== notes overlay (N key) ===== */ let notes = document.querySelector('.notes-overlay'); if (!notes) { notes = document.createElement('div'); notes.className = 'notes-overlay'; document.body.appendChild(notes); } /* ===== overview grid (O key) ===== */ let overview = document.querySelector('.overview'); if (!overview) { overview = document.createElement('div'); overview.className = 'overview'; slides.forEach((s, i) => { const t = document.createElement('div'); t.className = 'thumb'; const title = s.getAttribute('data-title') || (s.querySelector('h1,h2,h3')||{}).textContent || ('Slide '+(i+1)); t.innerHTML = '
'+(i+1)+'
'+title.trim().slice(0,80)+'
'; t.addEventListener('click', () => { go(i); toggleOverview(false); }); overview.appendChild(t); }); document.body.appendChild(overview); } /* ===== navigation ===== */ function go(n, fromRemote){ n = Math.max(0, Math.min(total-1, n)); slides.forEach((s,i) => { s.classList.toggle('is-active', i===n); s.classList.toggle('is-prev', i { const a = el.getAttribute('data-anim'); el.classList.remove('anim-'+a); void el.offsetWidth; el.classList.add('anim-'+a); }); // counter-up slides[n].querySelectorAll('.counter').forEach(el => { const target = parseFloat(el.getAttribute('data-to')||el.textContent); const dur = parseInt(el.getAttribute('data-dur')||'1200',10); const start = performance.now(); const from = 0; function tick(now){ const t = Math.min(1,(now-start)/dur); const v = from + (target-from)*(1-Math.pow(1-t,3)); el.textContent = (target % 1 === 0) ? Math.round(v) : v.toFixed(1); if (t<1) requestAnimationFrame(tick); } requestAnimationFrame(tick); }); // Broadcast to other window (audience ↔ presenter) if (!fromRemote && bc) { bc.postMessage({ type: 'go', idx: n }); } } /* ===== listen for remote navigation ===== */ if (bc) { bc.onmessage = function(e) { if (e.data && e.data.type === 'go' && typeof e.data.idx === 'number') { go(e.data.idx, true); } }; } function toggleNotes(force){ notes.classList.toggle('open', force!==undefined?force:!notes.classList.contains('open')); } function toggleOverview(force){ overview.classList.toggle('open', force!==undefined?force:!overview.classList.contains('open')); } /* ========== PRESENTER MODE (new window) ========== */ let presenterWin = null; function openPresenterWindow() { if (presenterWin && !presenterWin.closed) { presenterWin.focus(); return; } // Collect all slides' HTML and notes const slideData = slides.map((s, i) => { const note = s.querySelector('.notes, aside.notes, .speaker-notes'); return { html: s.outerHTML, notes: note ? note.innerHTML : '', title: s.getAttribute('data-title') || (s.querySelector('h1,h2,h3')||{}).textContent || ('Slide '+(i+1)) }; }); // Collect all stylesheets — use absolute URLs so popup can resolve them const styleSheets = Array.from(document.querySelectorAll('link[rel="stylesheet"], style')).map(el => { if (el.tagName === 'LINK') return ''; return ''; }).join('\n'); // Collect body classes (e.g. tpl-presenter-mode-reveal) so scoped CSS works const bodyClasses = document.body.className || ''; // Collect attributes for theme variables const htmlAttrs = Array.from(root.attributes).map(a => a.name+'="'+a.value+'"').join(' '); const presenterHTML = buildPresenterHTML(slideData, styleSheets, total, idx, bodyClasses, htmlAttrs); presenterWin = window.open('', 'html-ppt-presenter', 'width=1200,height=800,menubar=no,toolbar=no'); if (!presenterWin) { alert('请允许弹出窗口以使用演讲者视图'); return; } presenterWin.document.open(); presenterWin.document.write(presenterHTML); presenterWin.document.close(); } function buildPresenterHTML(slideData, styleSheets, total, startIdx, bodyClasses, htmlAttrs) { const slidesJSON = JSON.stringify(slideData); // Build iframe document template. Each iframe gets its own viewport // at 1920x1080 so vw/vh/clamp all resolve exactly like the audience view. // We inject via contentDocument.write() so there's ZERO HTML escaping issues. // Template is a JS string (not embedded HTML attribute), so quotes stay raw. const iframeDocTemplate = '' + '' + '' + styleSheets + '' + '' + '
%%SLIDE_HTML%%
' + ''; return '\n' + '\n\n\n' + 'Presenter View\n' + '\n' + '\n\n' + '
\n' + '
\n' + '
CURRENT
\n' + '
\n' + '
\n' + '
\n' + '
\n' + '
NEXT
\n' + '
\n' + '
\n' + '
\n' + '
SPEAKER SCRIPT · 逐字稿
\n' + '
\n' + '
\n' + '
\n' + '
\n' + '
00:00
\n' + '
1 / ' + total + '
\n' + '
\n' + '
← → 翻页 · R 重置计时 · Esc 关闭
\n' + '
\n' + '
\n' + '