From 23d9f5d369965e41f1ef6cdda3c10bf6c43508fd Mon Sep 17 00:00:00 2001 From: lewis Date: Fri, 17 Apr 2026 22:36:04 +0800 Subject: [PATCH] fix(presenter): rewrite as popup window with CSS scale() rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: S key no longer overlays on the same page. It now opens a separate browser window (window.open) for the presenter. Audience window (original page): - Stays completely untouched — normal slide view - S key opens the presenter popup - ← → / T / F / O all work as before - Navigation syncs to presenter via BroadcastChannel Presenter window (popup): - Current slide rendered at 1920×1080 then CSS transform:scale() to fit - Next slide preview also at 1920×1080 scale() — layout never breaks - Large-font speaker script (18px, scrollable) - Elapsed timer + page counter + current slide title - ← → navigates (syncs back to audience window) - R resets timer, Esc closes popup Technical: - runtime.js builds presenter HTML dynamically (buildPresenterHTML) - Collects all slide outerHTML + notes + all stylesheets from host page - Injects into popup via document.write() - BroadcastChannel keyed by pathname for multi-deck isolation - base.css: removed all old inline .pv-* / .presenter-view styles (presenter styles now self-contained in popup HTML) Docs updated: - SKILL.md: describes popup behavior, separate keyboard sections - references/presenter-mode.md: new dual-window diagram, updated flow - presenter-mode-reveal/README.md: updated S key and dual-screen guide --- SKILL.md | 13 +- assets/base.css | 43 +-- assets/runtime.js | 345 +++++++++++++----- references/presenter-mode.md | 44 ++- .../presenter-mode-reveal/README.md | 4 +- 5 files changed, 284 insertions(+), 165 deletions(-) diff --git a/SKILL.md b/SKILL.md index 144572e..fd9b211 100644 --- a/SKILL.md +++ b/SKILL.md @@ -43,9 +43,12 @@ See [references/presenter-mode.md](references/presenter-mode.md) for the full au 2. **每页 150–300 字** — 2–3 分钟/页的节奏 3. **用口语,不用书面语** — "因此"→"所以","该方案"→"这个方案" -All full-deck templates technically support the S key presenter view (it's built into `runtime.js`), but only `presenter-mode-reveal` is designed from the ground up around the feature with proper example 逐字稿 on every slide. +All full-deck templates support the S key presenter view (it's built into `runtime.js`). **S opens a separate popup window** — the original page stays as the audience view, and the popup shows current/next slide (CSS scale at 1920×1080 design resolution, pixel-perfect) + large speaker script + timer. The two windows sync navigation via BroadcastChannel. -Keyboard in presenter mode: `S` toggle · `T` cycle theme · `← →` navigate · `R` reset timer · `Esc` close. +Only `presenter-mode-reveal` is designed from the ground up around the feature with proper example 逐字稿 on every slide. + +Keyboard in presenter window: `← →` navigate (syncs audience) · `R` reset timer · `Esc` close popup. +Keyboard in audience window: `S` open presenter · `T` cycle theme · `← →` navigate (syncs presenter) · `F` fullscreen · `O` overview. ## Before you author anything — ALWAYS ask or recommend @@ -194,9 +197,9 @@ capture, runtime.js exposes `#/N` deep-links, and render.sh iterates 1..N. ``` ← → Space PgUp PgDn Home End navigate F fullscreen -S presenter view (current + next + script + timer) -N quick notes drawer (bottom, legacy) -R reset timer (only in presenter view) +S open presenter window (new popup: current + next + script + timer) +N quick notes drawer (bottom overlay) +R reset timer (in presenter window) O slide overview grid T cycle themes (reads data-themes attr) A cycle demo animation on current slide diff --git a/assets/base.css b/assets/base.css index 6efa679..b8c1107 100644 --- a/assets/base.css +++ b/assets/base.css @@ -132,45 +132,10 @@ h4,.h4{font-size:22px;line-height:1.3;font-weight:600;margin:0 0 8px} .overview .thumb .t{position:absolute;bottom:10px;left:14px;right:14px;font-weight:600;color:var(--text-1)} /* ================= PRESENTER VIEW ================= */ -/* Split-view presenter mode: current slide + next preview + speaker script + timer. - * Toggle with S key. Hides the audience deck, shows this view only to presenter. */ -.presenter-view{position:fixed;inset:0;z-index:80;display:none; - background:#0a0e1a;color:#e6edf3;font-family:var(--font-sans); - grid-template-columns:1.35fr 1fr;gap:20px;padding:20px} -.presenter-view.open{display:grid} -body.presenter-mode .deck, -body.presenter-mode .progress-bar, -body.presenter-mode .deck-header, -body.presenter-mode .deck-footer, -body.presenter-mode .notes-overlay{display:none!important} -.pv-label{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:#8b949e; - margin-bottom:8px;font-weight:600} -.pv-main{display:flex;flex-direction:column;min-height:0} -.pv-stage{flex:1;background:var(--bg,#0d1117);border:1px solid rgba(255,255,255,.08); - border-radius:14px;overflow:hidden;position:relative} -.pv-stage .slide{position:absolute;inset:0;transform:none!important;opacity:1!important; - width:100%;height:100%} -.pv-side{display:flex;flex-direction:column;gap:14px;min-height:0} -.pv-next{flex:0 0 30%;display:flex;flex-direction:column;min-height:0} -.pv-next-stage{border:1px solid rgba(255,255,255,.06);border-radius:10px;opacity:.85} -.pv-next .pv-end{display:flex;align-items:center;justify-content:center;height:100%; - font-size:18px;color:#8b949e;letter-spacing:.12em} -.pv-notes{flex:1;display:flex;flex-direction:column;min-height:0; - background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.06); - border-radius:12px;padding:16px 20px} -.pv-notes-body{flex:1;overflow:auto;font-size:18px;line-height:1.75;color:#d0d7de; - padding-right:6px;font-family:var(--font-sans)} -.pv-notes-body p{margin:0 0 .8em 0} -.pv-notes-body strong{color:#f0883e} -.pv-notes-body em{color:#58a6ff;font-style:normal} -.pv-empty{color:#484f58;font-style:italic} -.pv-bar{display:flex;align-items:center;gap:16px;padding:10px 14px; - background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.06); - border-radius:10px;font-size:13px;color:#8b949e} -.pv-timer{font-family:var(--font-mono,monospace);font-size:22px;font-weight:700;color:#3fb950; - letter-spacing:.04em} -.pv-count{font-weight:600;color:#e6edf3} -.pv-hint{margin-left:auto;font-size:11px;color:#6e7681;letter-spacing:.08em} +/* Presenter view opens in a separate popup window (S key). + * All presenter styles are self-contained in the popup HTML generated by runtime.js. + * The audience window (this file) is NOT affected — it stays as normal deck view. + * Only the .notes class below is needed to hide speaker notes from audience. */ /* ================= UTILITY ================= */ .hidden{display:none!important} diff --git a/assets/runtime.js b/assets/runtime.js index e706d3f..1afea73 100644 --- a/assets/runtime.js +++ b/assets/runtime.js @@ -4,8 +4,10 @@ * Features: * ← → / space / PgUp PgDn / Home End navigation * F fullscreen - * S presenter mode (split view: current + next + notes + timer) - * N quick notes overlay (bottom drawer, legacy) + * 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 @@ -32,7 +34,15 @@ let idx = 0; const total = slides.length; - // progress bar + /* ===== 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'); @@ -42,7 +52,7 @@ } const barFill = bar.querySelector('span'); - // notes overlay + /* ===== notes overlay (N key) ===== */ let notes = document.querySelector('.notes-overlay'); if (!notes) { notes = document.createElement('div'); @@ -50,7 +60,7 @@ document.body.appendChild(notes); } - // overview + /* ===== overview grid (O key) ===== */ let overview = document.querySelector('.overview'); if (!overview) { overview = document.createElement('div'); @@ -67,7 +77,8 @@ document.body.appendChild(overview); } - function go(n){ + /* ===== 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); @@ -77,13 +88,17 @@ barFill.style.width = ((n+1)/total*100)+'%'; const numEl = document.querySelector('.slide-number'); if (numEl) { numEl.setAttribute('data-current', n+1); numEl.setAttribute('data-total', total); } - // notes (bottom overlay, legacy N key) + + // notes (bottom overlay) const note = slides[n].querySelector('.notes, aside.notes, .speaker-notes'); notes.innerHTML = note ? note.innerHTML : ''; - // presenter view live update - renderPresenter(n); + // hash - if (location.hash !== '#/'+(n+1)) history.replaceState(null,'','#/'+(n+1)); + const hashTarget = '#/'+(n+1); + if (location.hash !== hashTarget && !isPresenterWindow) { + history.replaceState(null,'', hashTarget); + } + // re-trigger entry animations slides[n].querySelectorAll('[data-anim]').forEach(el => { const a = el.getAttribute('data-anim'); @@ -91,6 +106,7 @@ 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); @@ -105,102 +121,235 @@ } 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 ========== - // Split view: current slide (left) + next slide thumb + large speaker notes + timer - let presenter = document.querySelector('.presenter-view'); - if (!presenter) { - presenter = document.createElement('div'); - presenter.className = 'presenter-view'; - presenter.innerHTML = '' - + '
' - + '
CURRENT
' - + '
' - + '
' - + '
' - + '
' - + '
NEXT
' - + '
' - + '
' - + '
' - + '
SPEAKER SCRIPT · 逐字稿
' - + '
' - + '
' - + '
' - + '
00:00
' - + '
1 / ' + total + '
' - + '
S 退出 · ← → 翻页 · R 重置计时
' - + '
' - + '
'; - document.body.appendChild(presenter); - } - const pvStage = presenter.querySelector('#pv-stage'); - const pvNext = presenter.querySelector('#pv-next'); - const pvBody = presenter.querySelector('#pv-notes-body'); - const pvTimer = presenter.querySelector('#pv-timer'); - const pvCount = presenter.querySelector('#pv-count'); + /* ========== PRESENTER MODE (new window) ========== */ + let presenterWin = null; - let timerStart = 0, timerHandle = null; - function fmtTime(ms){ - const s = Math.floor(ms/1000); - const mm = String(Math.floor(s/60)).padStart(2,'0'); - const ss = String(s%60).padStart(2,'0'); - return mm + ':' + ss; - } - function startTimer(){ - if (timerHandle) return; - timerStart = Date.now(); - timerHandle = setInterval(() => { - pvTimer.textContent = fmtTime(Date.now() - timerStart); - }, 1000); - } - function stopTimer(){ if (timerHandle) { clearInterval(timerHandle); timerHandle = null; } } - function resetTimer(){ timerStart = Date.now(); pvTimer.textContent = '00:00'; } - - function renderPresenter(n){ - if (!presenter.classList.contains('open')) return; - // Clone current + next slides for mini display - pvStage.innerHTML = ''; - pvNext.innerHTML = ''; - const curClone = slides[n].cloneNode(true); - curClone.style.position = 'relative'; - curClone.style.opacity = '1'; - curClone.style.transform = 'none'; - curClone.classList.add('is-active'); - pvStage.appendChild(curClone); - - if (n + 1 < total) { - const nxtClone = slides[n+1].cloneNode(true); - nxtClone.style.position = 'relative'; - nxtClone.style.opacity = '1'; - nxtClone.style.transform = 'none'; - nxtClone.classList.add('is-active'); - pvNext.appendChild(nxtClone); - } else { - pvNext.innerHTML = '
— END —
'; + function openPresenterWindow() { + if (presenterWin && !presenterWin.closed) { + presenterWin.focus(); + return; } - // Notes - const note = slides[n].querySelector('.notes, aside.notes, .speaker-notes'); - pvBody.innerHTML = note ? note.innerHTML : '(这一页还没有逐字稿)'; - pvCount.textContent = (n+1) + ' / ' + total; + // 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 from current document + const styleSheets = Array.from(document.querySelectorAll('link[rel="stylesheet"], style')).map(el => { + if (el.tagName === 'LINK') return ''; + return ''; + }).join('\n'); + + const presenterHTML = buildPresenterHTML(slideData, styleSheets, total, idx); + + 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 togglePresenter(force){ - const willOpen = force !== undefined ? force : !presenter.classList.contains('open'); - presenter.classList.toggle('open', willOpen); - document.body.classList.toggle('presenter-mode', willOpen); - if (willOpen) { - startTimer(); - renderPresenter(idx); - } else { - stopTimer(); - } + function buildPresenterHTML(slideData, styleSheets, total, startIdx) { + // Escape backticks and ${ in slide HTML for template literal safety + const slidesJSON = JSON.stringify(slideData); + + return '\n' ++ '\n\n\n' ++ 'Presenter View\n' ++ styleSheets + '\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 重置计时 · T 主题 · Esc 关闭
\n' ++ '
\n' ++ '
\n' ++ '