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:
parent
43c4a74f63
commit
23d9f5d369
13
SKILL.md
13
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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 <html> or <body>)
|
||||
* 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 = ''
|
||||
+ '<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');
|
||||
/* ========== 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 = '<div class="pv-end">— END —</div>';
|
||||
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 : '<span class="pv-empty">(这一页还没有逐字稿)</span>';
|
||||
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 '<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){
|
||||
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 '<!DOCTYPE html>\n'
|
||||
+ '<html lang="zh-CN">\n<head>\n<meta charset="utf-8">\n'
|
||||
+ '<title>Presenter View</title>\n'
|
||||
+ styleSheets + '\n'
|
||||
+ '<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;
|
||||
if (!document.fullscreenElement) el.requestFullscreen&&el.requestFullscreen();
|
||||
else document.exitFullscreen&&document.exitFullscreen();
|
||||
|
|
@ -222,7 +371,6 @@
|
|||
link.id = 'theme-link';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
// resolve relative to runtime's location
|
||||
const themePath = (root.getAttribute('data-theme-base') || 'assets/themes/') + name + '.css';
|
||||
link.href = themePath;
|
||||
root.setAttribute('data-theme', name);
|
||||
|
|
@ -252,13 +400,12 @@
|
|||
case 'Home': go(0); break;
|
||||
case 'End': go(total-1); 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 'r': case 'R': if (presenter.classList.contains('open')) resetTimer(); break;
|
||||
case 'o': case 'O': toggleOverview(); break;
|
||||
case 't': case 'T': cycleTheme(); break;
|
||||
case 'a': case 'A': cycleAnim(); break;
|
||||
case 'Escape': toggleOverview(false); toggleNotes(false); togglePresenter(false); break;
|
||||
case 'Escape': toggleOverview(false); toggleNotes(false); break;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -126,31 +126,34 @@ html-ppt 的 **S 键演讲者视图是 `runtime.js` 内置的,所有 full-deck
|
|||
|
||||
## 演讲者视图显示的内容
|
||||
|
||||
按 `S` 键后,屏幕分成两部分:
|
||||
按 `S` 键后,**弹出一个独立的演讲者窗口**(原页面保持观众视图不变):
|
||||
|
||||
```
|
||||
┌────────────────────────┬──────────────────────┐
|
||||
│ │ NEXT │
|
||||
│ CURRENT │ [下一页缩略图] │
|
||||
│ [当前页大图] ├──────────────────────┤
|
||||
│ │ SPEAKER SCRIPT │
|
||||
│ │ [大字号逐字稿] │
|
||||
│ │ [可滚动] │
|
||||
│ ├──────────────────────┤
|
||||
│ │ ⏱ 12:34 3 / 8 💡 │
|
||||
└────────────────────────┴──────────────────────┘
|
||||
观众窗口(原页面) 演讲者窗口(新弹窗)
|
||||
┌──────────────────┐ ┌────────────────────────┬──────────────────────┐
|
||||
│ │ │ │ NEXT │
|
||||
│ 正常 slide │ │ CURRENT │ [下一页缩放预览] │
|
||||
│ 全屏展示 │◄──►│ [当前页缩放预览] ├──────────────────────┤
|
||||
│ │sync│ 1920×1080 → scale() │ SPEAKER SCRIPT │
|
||||
│ │ │ │ [大字号逐字稿] │
|
||||
│ │ │ │ [可滚动] │
|
||||
│ │ │ ├──────────────────────┤
|
||||
│ │ │ │ ⏱ 12:34 3 / 8 💡 │
|
||||
└──────────────────┘ └────────────────────────┴──────────────────────┘
|
||||
↑ BroadcastChannel 双向同步翻页 ↑
|
||||
```
|
||||
|
||||
- **左侧 55%**:当前页实时预览
|
||||
- **右上 30%**:下一页预览(帮助过渡)
|
||||
- **左侧 58%**:当前页 — 在 1920×1080 设计尺寸下渲染后 CSS scale 缩放,排版与观众端一致
|
||||
- **右上 35%**:下一页预览 — 同样 scale 缩放,帮助准备过渡
|
||||
- **右中**:逐字稿,字号 18px,高对比度,可滚动
|
||||
- **右下**:计时器 + 页码 + 键位提示
|
||||
- **右下**:计时器 + 页码 + 当前页标题 + 键位提示
|
||||
- **两窗口同步**:在任一窗口按 ← → 翻页,另一个窗口自动同步
|
||||
|
||||
## 键盘快捷键(演讲者模式)
|
||||
|
||||
| 键 | 动作 |
|
||||
|---|---|
|
||||
| `S` | 进入 / 退出演讲者视图 |
|
||||
| `S` | 打开演讲者窗口(弹出新窗口,原页面保持观众视图) |
|
||||
| `←` `→` / Space / PgDn | 翻页(即使在演讲者视图里) |
|
||||
| `T` | 切换主题 |
|
||||
| `R` | 重置计时器(仅演讲者视图下) |
|
||||
|
|
@ -160,12 +163,13 @@ html-ppt 的 **S 键演讲者视图是 `runtime.js` 内置的,所有 full-deck
|
|||
|
||||
## 双屏演讲的标准流程
|
||||
|
||||
1. 副屏(你看的屏幕):打开 HTML,按 `F` 全屏
|
||||
2. 主屏(观众看的):Cmd+Tab 或投屏软件把全屏窗口发到主屏
|
||||
3. 副屏上按 `S` → 你看演讲者视图,观众看干净的 slide
|
||||
4. 你翻页,主屏同步
|
||||
1. 打开 `index.html`,按 `S` → 弹出演讲者窗口
|
||||
2. 把**观众窗口**(原页面)拖到投影 / 外接屏,按 `F` 全屏
|
||||
3. 把**演讲者窗口**(弹窗)留在你面前的屏幕
|
||||
4. 在任一窗口按 ← → 翻页,两边自动同步
|
||||
5. 演讲者窗口里看逐字稿 + 下一页 + 计时器
|
||||
|
||||
> 💡 html-ppt 目前不支持"真正的 dual-screen 独立窗口"——演讲者视图和主视图在同一个 HTML 里切换。如果需要双屏独立显示,推荐用 reveal.js 的原生 speaker view(`/speaker.html`)。
|
||||
> 💡 html-ppt 的演讲者模式会**弹出一个独立的新窗口**。原页面保持观众视图不变,只受翻页控制。两个窗口通过 BroadcastChannel 双向同步。演讲者窗口中的当前页/下一页预览使用 CSS `transform: scale()` 在 1920×1080 设计分辨率下缩放渲染,保证排版与观众端完全一致。
|
||||
|
||||
## 常见错误
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ open examples/my-talk/index.html
|
|||
|
||||
| 键 | 动作 |
|
||||
|---|---|
|
||||
| `S` | 进入 / 退出演讲者视图 |
|
||||
| `S` | 打开演讲者窗口(弹出新窗口,原页面不动) |
|
||||
| `T` | 切换主题(5 种预设) |
|
||||
| `←` `→` | 翻页 |
|
||||
| `Space` / `PgDn` | 下一页 |
|
||||
|
|
@ -82,4 +82,4 @@ presenter-mode-reveal/
|
|||
|
||||
- **观众永远看不到 `.notes` 内容** — CSS 默认 `display:none`,只在演讲者视图里可见
|
||||
- **别把只给自己看的话写在 slide 本体上** — 所有提词必须在 `<aside class="notes">` 里
|
||||
- **双屏演讲**:把 `index.html` 用 `file://` 打开,主屏全屏、副屏按 S 进演讲者视图
|
||||
- **双屏演讲**:打开 `index.html` 按 S 弹出演讲者窗口,把观众窗口拖到投影/外接屏 F 全屏,演讲者窗口留在自己屏幕
|
||||
|
|
|
|||
Loading…
Reference in New Issue