/* 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);} /* ========== Parse URL for preview-only mode ========== * When loaded as iframe.src = "index.html?preview=3", runtime enters a * locked single-slide mode: only slide N is visible, no chrome, no keys, * no hash updates. This is how the presenter window shows pixel-perfect * previews — by loading the actual deck file in an iframe and telling it * to display only a specific slide. */ function getPreviewIdx() { const m = /[?&]preview=(\d+)/.exec(location.search || ''); return m ? parseInt(m[1], 10) - 1 : -1; } ready(function () { const deck = document.querySelector('.deck'); if (!deck) return; const slides = Array.from(deck.querySelectorAll('.slide')); if (!slides.length) return; const previewOnlyIdx = getPreviewIdx(); const isPreviewMode = previewOnlyIdx >= 0 && previewOnlyIdx < slides.length; /* ===== Preview-only mode: show one slide, hide everything else ===== */ if (isPreviewMode) { slides.forEach((s, i) => { s.classList.toggle('is-active', i === previewOnlyIdx); if (i !== previewOnlyIdx) { s.style.display = 'none'; } else { s.style.opacity = '1'; s.style.transform = 'none'; s.style.pointerEvents = 'auto'; } }); /* Hide chrome that the presenter shouldn't see in preview */ const hideSel = '.progress-bar, .notes-overlay, .overview, .notes, aside.notes, .speaker-notes'; document.querySelectorAll(hideSel).forEach(el => { el.style.display = 'none'; }); /* Also add a data attr so templates can style preview mode if needed */ document.documentElement.setAttribute('data-preview', '1'); document.body.setAttribute('data-preview', '1'); /* Don't register key handlers, don't start broadcast channel, don't auto-hash */ return; } let idx = 0; const total = slides.length; /* ===== BroadcastChannel for presenter sync ===== */ const CHANNEL_NAME = 'html-ppt-presenter-' + location.pathname; let bc; try { bc = new BroadcastChannel(CHANNEL_NAME); } catch(e) { bc = null; } // Are we running inside the presenter popup? (legacy flag, now unused) const isPresenterWindow = false; /* ===== 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 — Magnetic-card popup window ========== */ /* Opens a new window with 4 draggable, resizable cards: * CURRENT — iframe(?preview=N) pixel-perfect preview of current slide * NEXT — iframe(?preview=N+1) pixel-perfect preview of next slide * SCRIPT — large speaker notes (逐字稿) * TIMER — elapsed timer + page counter + controls * Cards remember position/size in localStorage. * Two windows sync via BroadcastChannel. */ let presenterWin = null; function openPresenterWindow() { if (presenterWin && !presenterWin.closed) { presenterWin.focus(); return; } // Build absolute URL of THIS deck file (without hash/query) const deckUrl = location.protocol + '//' + location.host + location.pathname; // Collect slide titles + notes (HTML strings) const slideMeta = slides.map((s, i) => { const note = s.querySelector('.notes, aside.notes, .speaker-notes'); return { title: s.getAttribute('data-title') || (s.querySelector('h1,h2,h3')||{}).textContent || ('Slide '+(i+1)), notes: note ? note.innerHTML : '' }; }); const presenterHTML = buildPresenterHTML(deckUrl, slideMeta, total, idx, CHANNEL_NAME); presenterWin = window.open('', 'html-ppt-presenter', 'width=1280,height=820,menubar=no,toolbar=no'); if (!presenterWin) { alert('请允许弹出窗口以使用演讲者视图'); return; } presenterWin.document.open(); presenterWin.document.write(presenterHTML); presenterWin.document.close(); } function buildPresenterHTML(deckUrl, slideMeta, total, startIdx, channelName) { const metaJSON = JSON.stringify(slideMeta); const deckUrlJSON = JSON.stringify(deckUrl); const channelJSON = JSON.stringify(channelName); const storageKey = 'html-ppt-presenter:' + location.pathname; // Build the document as a single template string for clarity return ` Presenter View
CURRENT
NEXT
SPEAKER SCRIPT · 逐字稿
TIMER
00:00
Slide 1 / ${total}
← → 翻页 R 重置计时 Esc 关闭 拖动卡片头部移动 · 拖动右下角调整大小