fix(presenter): rewrite as popup window with CSS scale() rendering

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
This commit is contained in:
lewis 2026-04-17 22:36:04 +08:00
parent 43c4a74f63
commit 23d9f5d369
5 changed files with 284 additions and 165 deletions

View File

@ -43,9 +43,12 @@ See [references/presenter-mode.md](references/presenter-mode.md) for the full au
2. **每页 150300 字** — 23 分钟/页的节奏 2. **每页 150300 字** — 23 分钟/页的节奏
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 ## 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 ← → Space PgUp PgDn Home End navigate
F fullscreen F fullscreen
S presenter view (current + next + script + timer) S open presenter window (new popup: current + next + script + timer)
N quick notes drawer (bottom, legacy) N quick notes drawer (bottom overlay)
R reset timer (only in presenter view) R reset timer (in presenter window)
O slide overview grid O slide overview grid
T cycle themes (reads data-themes attr) T cycle themes (reads data-themes attr)
A cycle demo animation on current slide A cycle demo animation on current slide

View File

@ -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)} .overview .thumb .t{position:absolute;bottom:10px;left:14px;right:14px;font-weight:600;color:var(--text-1)}
/* ================= PRESENTER VIEW ================= */ /* ================= PRESENTER VIEW ================= */
/* Split-view presenter mode: current slide + next preview + speaker script + timer. /* Presenter view opens in a separate popup window (S key).
* Toggle with S key. Hides the audience deck, shows this view only to presenter. */ * All presenter styles are self-contained in the popup HTML generated by runtime.js.
.presenter-view{position:fixed;inset:0;z-index:80;display:none; * The audience window (this file) is NOT affected it stays as normal deck view.
background:#0a0e1a;color:#e6edf3;font-family:var(--font-sans); * Only the .notes class below is needed to hide speaker notes from audience. */
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}
/* ================= UTILITY ================= */ /* ================= UTILITY ================= */
.hidden{display:none!important} .hidden{display:none!important}

View File

@ -4,8 +4,10 @@
* Features: * Features:
* / space / PgUp PgDn / Home End navigation * / space / PgUp PgDn / Home End navigation
* F fullscreen * F fullscreen
* S presenter mode (split view: current + next + notes + timer) * S presenter mode (opens a NEW WINDOW with current/next slide preview + notes + timer)
* N quick notes overlay (bottom drawer, legacy) * 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 * O slide overview grid
* T cycle themes (reads data-themes on <html> or <body>) * T cycle themes (reads data-themes on <html> or <body>)
* A cycle demo animation on current slide * A cycle demo animation on current slide
@ -32,7 +34,15 @@
let idx = 0; let idx = 0;
const total = slides.length; 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'); let bar = document.querySelector('.progress-bar');
if (!bar) { if (!bar) {
bar = document.createElement('div'); bar = document.createElement('div');
@ -42,7 +52,7 @@
} }
const barFill = bar.querySelector('span'); const barFill = bar.querySelector('span');
// notes overlay /* ===== notes overlay (N key) ===== */
let notes = document.querySelector('.notes-overlay'); let notes = document.querySelector('.notes-overlay');
if (!notes) { if (!notes) {
notes = document.createElement('div'); notes = document.createElement('div');
@ -50,7 +60,7 @@
document.body.appendChild(notes); document.body.appendChild(notes);
} }
// overview /* ===== overview grid (O key) ===== */
let overview = document.querySelector('.overview'); let overview = document.querySelector('.overview');
if (!overview) { if (!overview) {
overview = document.createElement('div'); overview = document.createElement('div');
@ -67,7 +77,8 @@
document.body.appendChild(overview); document.body.appendChild(overview);
} }
function go(n){ /* ===== navigation ===== */
function go(n, fromRemote){
n = Math.max(0, Math.min(total-1, n)); n = Math.max(0, Math.min(total-1, n));
slides.forEach((s,i) => { slides.forEach((s,i) => {
s.classList.toggle('is-active', i===n); s.classList.toggle('is-active', i===n);
@ -77,13 +88,17 @@
barFill.style.width = ((n+1)/total*100)+'%'; barFill.style.width = ((n+1)/total*100)+'%';
const numEl = document.querySelector('.slide-number'); const numEl = document.querySelector('.slide-number');
if (numEl) { numEl.setAttribute('data-current', n+1); numEl.setAttribute('data-total', total); } 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'); const note = slides[n].querySelector('.notes, aside.notes, .speaker-notes');
notes.innerHTML = note ? note.innerHTML : ''; notes.innerHTML = note ? note.innerHTML : '';
// presenter view live update
renderPresenter(n);
// hash // 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 // re-trigger entry animations
slides[n].querySelectorAll('[data-anim]').forEach(el => { slides[n].querySelectorAll('[data-anim]').forEach(el => {
const a = el.getAttribute('data-anim'); const a = el.getAttribute('data-anim');
@ -91,6 +106,7 @@
void el.offsetWidth; void el.offsetWidth;
el.classList.add('anim-'+a); el.classList.add('anim-'+a);
}); });
// counter-up // counter-up
slides[n].querySelectorAll('.counter').forEach(el => { slides[n].querySelectorAll('.counter').forEach(el => {
const target = parseFloat(el.getAttribute('data-to')||el.textContent); const target = parseFloat(el.getAttribute('data-to')||el.textContent);
@ -105,102 +121,235 @@
} }
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 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')); } function toggleOverview(force){ overview.classList.toggle('open', force!==undefined?force:!overview.classList.contains('open')); }
// ========== PRESENTER MODE ========== /* ========== PRESENTER MODE (new window) ========== */
// Split view: current slide (left) + next slide thumb + large speaker notes + timer let presenterWin = null;
let presenter = document.querySelector('.presenter-view');
if (!presenter) {
presenter = document.createElement('div');
presenter.className = 'presenter-view';
presenter.innerHTML = ''
+ '<div class="pv-main">'
+ '<div class="pv-label">CURRENT</div>'
+ '<div class="pv-stage" id="pv-stage"></div>'
+ '</div>'
+ '<div class="pv-side">'
+ '<div class="pv-next">'
+ '<div class="pv-label">NEXT</div>'
+ '<div class="pv-stage pv-next-stage" id="pv-next"></div>'
+ '</div>'
+ '<div class="pv-notes">'
+ '<div class="pv-label">SPEAKER SCRIPT · 逐字稿</div>'
+ '<div class="pv-notes-body" id="pv-notes-body"></div>'
+ '</div>'
+ '<div class="pv-bar">'
+ '<div class="pv-timer" id="pv-timer">00:00</div>'
+ '<div class="pv-count" id="pv-count">1 / ' + total + '</div>'
+ '<div class="pv-hint">S 退出 · ← → 翻页 · R 重置计时</div>'
+ '</div>'
+ '</div>';
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');
let timerStart = 0, timerHandle = null; function openPresenterWindow() {
function fmtTime(ms){ if (presenterWin && !presenterWin.closed) {
const s = Math.floor(ms/1000); presenterWin.focus();
const mm = String(Math.floor(s/60)).padStart(2,'0'); return;
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 = '<div class="pv-end">— END —</div>';
} }
// Notes // Collect all slides' HTML and notes
const note = slides[n].querySelector('.notes, aside.notes, .speaker-notes'); const slideData = slides.map((s, i) => {
pvBody.innerHTML = note ? note.innerHTML : '<span class="pv-empty">(这一页还没有逐字稿)</span>'; const note = s.querySelector('.notes, aside.notes, .speaker-notes');
pvCount.textContent = (n+1) + ' / ' + total; 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 '<link rel="stylesheet" href="' + el.href + '">';
return '<style>' + el.textContent + '</style>';
}).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){ function buildPresenterHTML(slideData, styleSheets, total, startIdx) {
const willOpen = force !== undefined ? force : !presenter.classList.contains('open'); // Escape backticks and ${ in slide HTML for template literal safety
presenter.classList.toggle('open', willOpen); const slidesJSON = JSON.stringify(slideData);
document.body.classList.toggle('presenter-mode', willOpen);
if (willOpen) { return '<!DOCTYPE html>\n'
startTimer(); + '<html lang="zh-CN">\n<head>\n<meta charset="utf-8">\n'
renderPresenter(idx); + '<title>Presenter View</title>\n'
} else { + styleSheets + '\n'
stopTimer(); + '<style>\n'
} + ' * { margin: 0; padding: 0; box-sizing: border-box; }\n'
+ ' html, body { width: 100%; height: 100%; overflow: hidden; background: #0d0d0d; color: #e6edf3; font-family: "Noto Sans SC", -apple-system, sans-serif; }\n'
+ ' .pv-grid { display: grid; grid-template-columns: 1.4fr 1fr; grid-template-rows: 1fr auto; height: 100vh; gap: 12px; padding: 12px; }\n'
+ ' .pv-current-wrap { grid-row: 1; grid-column: 1; display: flex; flex-direction: column; min-height: 0; }\n'
+ ' .pv-right { grid-row: 1; grid-column: 2; display: flex; flex-direction: column; gap: 10px; min-height: 0; }\n'
+ ' .pv-bar { grid-row: 2; grid-column: 1 / -1; display: flex; align-items: center; gap: 16px; padding: 8px 16px; background: rgba(255,255,255,.04); border-radius: 8px; font-size: 13px; }\n'
+ ' .pv-label { font-size: 10px; letter-spacing: .18em; text-transform: uppercase; color: #6e7681; font-weight: 700; margin-bottom: 6px; padding-left: 2px; flex-shrink: 0; }\n'
+ '\n'
+ ' /* Slide stage: fixed aspect ratio container with CSS scale */\n'
+ ' .pv-stage { flex: 1; position: relative; border: 1px solid rgba(255,255,255,.08); border-radius: 10px; overflow: hidden; background: var(--bg, #0d1117); min-height: 0; }\n'
+ ' .pv-stage-inner { position: absolute; top: 0; left: 0; width: 1920px; height: 1080px; transform-origin: top left; pointer-events: none; }\n'
+ ' .pv-stage-inner .slide { position: absolute; inset: 0; opacity: 1 !important; transform: none !important; display: block !important; }\n'
+ ' .pv-stage-inner .slide .notes, .pv-stage-inner .slide aside.notes, .pv-stage-inner .slide .speaker-notes { display: none !important; }\n'
+ '\n'
+ ' .pv-next-wrap { flex: 0 0 35%; display: flex; flex-direction: column; min-height: 0; }\n'
+ ' .pv-next-stage { opacity: .8; }\n'
+ ' .pv-next-end { display: flex; align-items: center; justify-content: center; height: 100%; font-size: 16px; color: #484f58; letter-spacing: .1em; }\n'
+ '\n'
+ ' /* Notes panel */\n'
+ ' .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: 10px; padding: 12px 16px; }\n'
+ ' .pv-notes-body { flex: 1; overflow-y: auto; font-size: 18px; line-height: 1.75; color: #d0d7de; }\n'
+ ' .pv-notes-body p { margin: 0 0 .7em 0; }\n'
+ ' .pv-notes-body strong { color: #f0883e; }\n'
+ ' .pv-notes-body em { color: #58a6ff; font-style: normal; }\n'
+ ' .pv-notes-body code { font-family: monospace; font-size: .9em; background: rgba(255,255,255,.08); padding: 1px 6px; border-radius: 4px; }\n'
+ ' .pv-empty { color: #484f58; font-style: italic; }\n'
+ '\n'
+ ' .pv-timer { font-family: "SF Mono","JetBrains Mono",monospace; font-size: 26px; font-weight: 700; color: #3fb950; letter-spacing: .04em; }\n'
+ ' .pv-count { font-weight: 600; color: #e6edf3; font-size: 15px; }\n'
+ ' .pv-title { color: #8b949e; font-size: 13px; flex: 1; text-align: right; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }\n'
+ ' .pv-hint { font-size: 11px; color: #484f58; margin-left: auto; }\n'
+ '</style>\n'
+ '</head>\n<body>\n'
+ '<div class="pv-grid">\n'
+ ' <div class="pv-current-wrap">\n'
+ ' <div class="pv-label">CURRENT</div>\n'
+ ' <div class="pv-stage" id="pv-current"><div class="pv-stage-inner" id="pv-cur-inner"></div></div>\n'
+ ' </div>\n'
+ ' <div class="pv-right">\n'
+ ' <div class="pv-next-wrap">\n'
+ ' <div class="pv-label">NEXT</div>\n'
+ ' <div class="pv-stage pv-next-stage" id="pv-next"><div class="pv-stage-inner" id="pv-nxt-inner"></div></div>\n'
+ ' </div>\n'
+ ' <div class="pv-notes">\n'
+ ' <div class="pv-label">SPEAKER SCRIPT · 逐字稿</div>\n'
+ ' <div class="pv-notes-body" id="pv-notes"></div>\n'
+ ' </div>\n'
+ ' </div>\n'
+ ' <div class="pv-bar">\n'
+ ' <div class="pv-timer" id="pv-timer">00:00</div>\n'
+ ' <div class="pv-count" id="pv-count">1 / ' + total + '</div>\n'
+ ' <div class="pv-title" id="pv-title"></div>\n'
+ ' <div class="pv-hint">← → 翻页 · R 重置计时 · T 主题 · Esc 关闭</div>\n'
+ ' </div>\n'
+ '</div>\n'
+ '<script>\n'
+ '(function(){\n'
+ ' var slideData = ' + slidesJSON + ';\n'
+ ' var total = ' + total + ';\n'
+ ' var idx = ' + startIdx + ';\n'
+ ' var CHANNEL_NAME = ' + JSON.stringify(CHANNEL_NAME) + ';\n'
+ ' var bc; try { bc = new BroadcastChannel(CHANNEL_NAME); } catch(e) {}\n'
+ '\n'
+ ' var curInner = document.getElementById("pv-cur-inner");\n'
+ ' var nxtInner = document.getElementById("pv-nxt-inner");\n'
+ ' var pvNotes = document.getElementById("pv-notes");\n'
+ ' var pvCount = document.getElementById("pv-count");\n'
+ ' var pvTitle = document.getElementById("pv-title");\n'
+ ' var pvTimer = document.getElementById("pv-timer");\n'
+ '\n'
+ ' /* Timer */\n'
+ ' var timerStart = Date.now();\n'
+ ' setInterval(function(){\n'
+ ' var s = Math.floor((Date.now() - timerStart) / 1000);\n'
+ ' pvTimer.textContent = String(Math.floor(s/60)).padStart(2,"0") + ":" + String(s%60).padStart(2,"0");\n'
+ ' }, 1000);\n'
+ '\n'
+ ' /* Compute scale to fit 1920x1080 into actual container size */\n'
+ ' function fitScale(container) {\n'
+ ' var cw = container.clientWidth, ch = container.clientHeight;\n'
+ ' if (!cw || !ch) return 0.3;\n'
+ ' return Math.min(cw / 1920, ch / 1080);\n'
+ ' }\n'
+ '\n'
+ ' function renderSlide(container, html) {\n'
+ ' container.innerHTML = html;\n'
+ ' /* Force the slide visible */\n'
+ ' var sl = container.querySelector(".slide");\n'
+ ' if (sl) {\n'
+ ' sl.style.position = "absolute";\n'
+ ' sl.style.inset = "0";\n'
+ ' sl.style.opacity = "1";\n'
+ ' sl.style.transform = "none";\n'
+ ' sl.style.display = "block";\n'
+ ' sl.classList.add("is-active");\n'
+ ' }\n'
+ ' }\n'
+ '\n'
+ ' function update(n) {\n'
+ ' n = Math.max(0, Math.min(total - 1, n));\n'
+ ' idx = n;\n'
+ ' /* Current slide */\n'
+ ' renderSlide(curInner, slideData[n].html);\n'
+ ' /* Next slide */\n'
+ ' if (n + 1 < total) {\n'
+ ' nxtInner.parentElement.querySelector(".pv-next-end") && nxtInner.parentElement.querySelector(".pv-next-end").remove();\n'
+ ' nxtInner.style.display = "";\n'
+ ' renderSlide(nxtInner, slideData[n + 1].html);\n'
+ ' } else {\n'
+ ' nxtInner.style.display = "none";\n'
+ ' if (!nxtInner.parentElement.querySelector(".pv-next-end")) {\n'
+ ' var end = document.createElement("div");\n'
+ ' end.className = "pv-next-end";\n'
+ ' end.textContent = "— END —";\n'
+ ' nxtInner.parentElement.appendChild(end);\n'
+ ' }\n'
+ ' }\n'
+ ' /* Notes */\n'
+ ' pvNotes.innerHTML = slideData[n].notes || "<span class=\\"pv-empty\\">(这一页还没有逐字稿)</span>";\n'
+ ' pvCount.textContent = (n + 1) + " / " + total;\n'
+ ' pvTitle.textContent = slideData[n].title;\n'
+ ' /* Recompute scale */\n'
+ ' reScale();\n'
+ ' }\n'
+ '\n'
+ ' function reScale() {\n'
+ ' var cs = fitScale(document.getElementById("pv-current"));\n'
+ ' curInner.style.transform = "scale(" + cs + ")";\n'
+ ' var ns = fitScale(document.getElementById("pv-next"));\n'
+ ' nxtInner.style.transform = "scale(" + ns + ")";\n'
+ ' }\n'
+ '\n'
+ ' /* Sync from audience window */\n'
+ ' if (bc) {\n'
+ ' bc.onmessage = function(e) {\n'
+ ' if (e.data && e.data.type === "go") update(e.data.idx);\n'
+ ' };\n'
+ ' }\n'
+ '\n'
+ ' /* Keyboard in presenter window */\n'
+ ' function go(n) {\n'
+ ' update(n);\n'
+ ' if (bc) bc.postMessage({ type: "go", idx: idx });\n'
+ ' }\n'
+ ' document.addEventListener("keydown", function(e) {\n'
+ ' switch(e.key) {\n'
+ ' case "ArrowRight": case " ": case "PageDown": go(idx + 1); e.preventDefault(); break;\n'
+ ' case "ArrowLeft": case "PageUp": go(idx - 1); e.preventDefault(); break;\n'
+ ' case "Home": go(0); break;\n'
+ ' case "End": go(total - 1); break;\n'
+ ' case "r": case "R": timerStart = Date.now(); pvTimer.textContent = "00:00"; break;\n'
+ ' case "Escape": window.close(); break;\n'
+ ' }\n'
+ ' });\n'
+ '\n'
+ ' window.addEventListener("resize", reScale);\n'
+ ' /* Initial render */\n'
+ ' setTimeout(function(){ update(idx); }, 50);\n'
+ '})();\n'
+ '</' + 'script>\n'
+ '</body></html>';
} }
function fullscreen(){ const el=document.documentElement; function fullscreen(){ const el=document.documentElement;
if (!document.fullscreenElement) el.requestFullscreen&&el.requestFullscreen(); if (!document.fullscreenElement) el.requestFullscreen&&el.requestFullscreen();
else document.exitFullscreen&&document.exitFullscreen(); else document.exitFullscreen&&document.exitFullscreen();
@ -222,7 +371,6 @@
link.id = 'theme-link'; link.id = 'theme-link';
document.head.appendChild(link); document.head.appendChild(link);
} }
// resolve relative to runtime's location
const themePath = (root.getAttribute('data-theme-base') || 'assets/themes/') + name + '.css'; const themePath = (root.getAttribute('data-theme-base') || 'assets/themes/') + name + '.css';
link.href = themePath; link.href = themePath;
root.setAttribute('data-theme', name); root.setAttribute('data-theme', name);
@ -252,13 +400,12 @@
case 'Home': go(0); break; case 'Home': go(0); break;
case 'End': go(total-1); break; case 'End': go(total-1); break;
case 'f': case 'F': fullscreen(); break; case 'f': case 'F': fullscreen(); break;
case 's': case 'S': togglePresenter(); break; case 's': case 'S': openPresenterWindow(); break;
case 'n': case 'N': toggleNotes(); break; case 'n': case 'N': toggleNotes(); break;
case 'r': case 'R': if (presenter.classList.contains('open')) resetTimer(); break;
case 'o': case 'O': toggleOverview(); break; case 'o': case 'O': toggleOverview(); break;
case 't': case 'T': cycleTheme(); break; case 't': case 'T': cycleTheme(); break;
case 'a': case 'A': cycleAnim(); break; case 'a': case 'A': cycleAnim(); break;
case 'Escape': toggleOverview(false); toggleNotes(false); togglePresenter(false); break; case 'Escape': toggleOverview(false); toggleNotes(false); break;
} }
}); });

View File

@ -126,31 +126,34 @@ html-ppt 的 **S 键演讲者视图是 `runtime.js` 内置的,所有 full-deck
## 演讲者视图显示的内容 ## 演讲者视图显示的内容
`S` 键后,屏幕分成两部分 `S` 键后,**弹出一个独立的演讲者窗口**(原页面保持观众视图不变)
``` ```
┌────────────────────────┬──────────────────────┐ 观众窗口(原页面) 演讲者窗口(新弹窗)
│ │ NEXT │ ┌──────────────────┐ ┌────────────────────────┬──────────────────────┐
│ CURRENT │ [下一页缩略图] │ │ │ │ │ NEXT │
│ [当前页大图] ├──────────────────────┤ │ 正常 slide │ │ CURRENT │ [下一页缩放预览] │
│ │ SPEAKER SCRIPT │ │ 全屏展示 │◄──►│ [当前页缩放预览] ├──────────────────────┤
│ │ [大字号逐字稿] │ │ │sync│ 1920×1080 → scale() │ SPEAKER SCRIPT │
│ │ [可滚动] │ │ │ │ │ [大字号逐字稿] │
│ ├──────────────────────┤ │ │ │ │ [可滚动] │
│ │ ⏱ 12:34 3 / 8 💡 │ │ │ │ ├──────────────────────┤
└────────────────────────┴──────────────────────┘ │ │ │ │ ⏱ 12:34 3 / 8 💡 │
└──────────────────┘ └────────────────────────┴──────────────────────┘
↑ BroadcastChannel 双向同步翻页 ↑
``` ```
- **左侧 55%**:当前页实时预览 - **左侧 58%**:当前页 — 在 1920×1080 设计尺寸下渲染后 CSS scale 缩放,排版与观众端一致
- **右上 30%**:下一页预览(帮助过渡) - **右上 35%**:下一页预览 — 同样 scale 缩放,帮助准备过渡
- **右中**:逐字稿,字号 18px高对比度可滚动 - **右中**:逐字稿,字号 18px高对比度可滚动
- **右下**:计时器 + 页码 + 键位提示 - **右下**:计时器 + 页码 + 当前页标题 + 键位提示
- **两窗口同步**:在任一窗口按 ← → 翻页,另一个窗口自动同步
## 键盘快捷键(演讲者模式) ## 键盘快捷键(演讲者模式)
| 键 | 动作 | | 键 | 动作 |
|---|---| |---|---|
| `S` | 进入 / 退出演讲者视图 | | `S` | 打开演讲者窗口(弹出新窗口,原页面保持观众视图) |
| `←` `→` / Space / PgDn | 翻页(即使在演讲者视图里) | | `←` `→` / Space / PgDn | 翻页(即使在演讲者视图里) |
| `T` | 切换主题 | | `T` | 切换主题 |
| `R` | 重置计时器(仅演讲者视图下) | | `R` | 重置计时器(仅演讲者视图下) |
@ -160,12 +163,13 @@ html-ppt 的 **S 键演讲者视图是 `runtime.js` 内置的,所有 full-deck
## 双屏演讲的标准流程 ## 双屏演讲的标准流程
1. 副屏(你看的屏幕):打开 HTML`F` 全屏 1. 打开 `index.html`,按 `S` → 弹出演讲者窗口
2. 主屏观众看的Cmd+Tab 或投屏软件把全屏窗口发到主屏 2. 把**观众窗口**(原页面)拖到投影 / 外接屏,按 `F` 全屏
3. 副屏上按 `S` → 你看演讲者视图,观众看干净的 slide 3. 把**演讲者窗口**(弹窗)留在你面前的屏幕
4. 你翻页,主屏同步 4. 在任一窗口按 ← → 翻页,两边自动同步
5. 演讲者窗口里看逐字稿 + 下一页 + 计时器
> 💡 html-ppt 目前不支持"真正的 dual-screen 独立窗口"——演讲者视图和主视图在同一个 HTML 里切换。如果需要双屏独立显示,推荐用 reveal.js 的原生 speaker view`/speaker.html` > 💡 html-ppt 的演讲者模式会**弹出一个独立的新窗口**。原页面保持观众视图不变,只受翻页控制。两个窗口通过 BroadcastChannel 双向同步。演讲者窗口中的当前页/下一页预览使用 CSS `transform: scale()` 在 1920×1080 设计分辨率下缩放渲染,保证排版与观众端完全一致
## 常见错误 ## 常见错误

View File

@ -20,7 +20,7 @@ open examples/my-talk/index.html
| 键 | 动作 | | 键 | 动作 |
|---|---| |---|---|
| `S` | 进入 / 退出演讲者视图 | | `S` | 打开演讲者窗口(弹出新窗口,原页面不动) |
| `T` | 切换主题5 种预设) | | `T` | 切换主题5 种预设) |
| `←` `→` | 翻页 | | `←` `→` | 翻页 |
| `Space` / `PgDn` | 下一页 | | `Space` / `PgDn` | 下一页 |
@ -82,4 +82,4 @@ presenter-mode-reveal/
- **观众永远看不到 `.notes` 内容** — CSS 默认 `display:none`,只在演讲者视图里可见 - **观众永远看不到 `.notes` 内容** — CSS 默认 `display:none`,只在演讲者视图里可见
- **别把只给自己看的话写在 slide 本体上** — 所有提词必须在 `<aside class="notes">` - **别把只给自己看的话写在 slide 本体上** — 所有提词必须在 `<aside class="notes">`
- **双屏演讲**`index.html``file://` 打开,主屏全屏、副屏按 S 进演讲者视图 - **双屏演讲**打开 `index.html` 按 S 弹出演讲者窗口,把观众窗口拖到投影/外接屏 F 全屏,演讲者窗口留在自己屏幕