perf(presenter): smooth navigation via postMessage (no reload, no flicker)

Problem: Switching slides reset iframe.src each time, causing visible
flicker (white flash → load → render) on every navigation.

Solution: load each preview iframe ONCE, then use postMessage for all
subsequent slide changes. The iframe stays mounted; only .is-active
class toggles between slides inside it.

Changes:
- runtime.js preview mode: now exposes a showSlide() function and
  listens for window.message events of type 'preview-goto'. Also
  posts 'preview-ready' to parent on init so the presenter window
  knows when each iframe is loaded.
- presenter window: tracks iframeReady state per iframe; first frame
  renders via iframe.src = ?preview=N (one-time), all subsequent
  navigation goes through postPreviewGoto() → postMessage. Notes,
  meta, and timer update directly without touching iframes.
- Init no longer calls update(idx) which used to reset iframe.src;
  instead inits notes/meta/count once and lets the load→ready flow
  populate previews.

Docs synced to match new architecture:
- SKILL.md: describes 4 magnetic cards, draggable/resizable, with
  the iframe ?preview=N pattern explained for AI consumers
- references/presenter-mode.md: updated ASCII diagram to show 4
  cards, removed old 58%/35% layout description, added explanation
  for why previews are pixel-perfect (iframe loads same HTML) and
  why navigation is flicker-free (postMessage, not reload)
- presenter-mode-reveal README: updated 4-card description
This commit is contained in:
lewis 2026-04-17 23:27:05 +08:00
parent 832f5be212
commit b64ce0f832
4 changed files with 132 additions and 45 deletions

View File

@ -24,7 +24,7 @@ One command, no build. Pure static HTML/CSS/JS with only CDN webfonts.
- **31 layouts** (`templates/single-page/*.html`) with realistic demo data
- **27 CSS animations** (`assets/animations/animations.css`) via `data-anim`
- **20 canvas FX animations** (`assets/animations/fx/*.js`) via `data-fx` — particle-burst, confetti-cannon, firework, starfield, matrix-rain, knowledge-graph (force-directed), neural-net (pulses), constellation, orbit-ring, galaxy-swirl, word-cascade, letter-explode, chain-react, magnetic-field, data-stream, gradient-blob, sparkle-trail, shockwave, typewriter-multi, counter-explosion
- **Keyboard runtime** (`assets/runtime.js`) — arrows, T (theme), A (anim), F/O, **S (presenter view: current + next + large speaker script + timer)**, N (legacy notes drawer), R (reset timer)
- **Keyboard runtime** (`assets/runtime.js`) — arrows, T (theme), A (anim), F/O, **S (presenter mode: magnetic-card popup with CURRENT / NEXT / SCRIPT / TIMER cards)**, N (notes drawer), R (reset timer in presenter)
- **FX runtime** (`assets/animations/fx-runtime.js`) — auto-inits `[data-fx]` on slide enter, cleans up on leave
- **Showcase decks** for themes / layouts / animations / full-decks gallery
- **Headless Chrome render script** for PNG export
@ -43,7 +43,17 @@ See [references/presenter-mode.md](references/presenter-mode.md) for the full au
2. **每页 150300 字** — 23 分钟/页的节奏
3. **用口语,不用书面语** — "因此"→"所以""该方案"→"这个方案"
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.
All full-deck templates support the S key presenter mode (it's built into `runtime.js`). **S opens a new popup window with 4 magnetic cards**:
- 🔵 **CURRENT** — pixel-perfect iframe preview of the current slide
- 🟣 **NEXT** — pixel-perfect iframe preview of the next slide
- 🟠 **SPEAKER SCRIPT** — large-font 逐字稿 (scrollable)
- 🟢 **TIMER** — elapsed time + slide counter + prev/next/reset buttons
Each card is **draggable by its header** and **resizable by the bottom-right corner handle**. Card positions/sizes persist to `localStorage` per deck. A "Reset layout" button restores the default arrangement.
**Why the previews are pixel-perfect**: each preview is an `<iframe>` that loads the actual deck HTML with a `?preview=N` query param; `runtime.js` detects this and renders only slide N with no chrome. So the preview uses the **same CSS, theme, fonts, and viewport as the audience view** — colors and layout are guaranteed identical.
**Smooth navigation**: on slide change, the presenter window sends `postMessage({type:'preview-goto', idx:N})` to each iframe. The iframe just toggles `.is-active` between slides — **no reload, no flicker**. The two windows also stay in sync via `BroadcastChannel`.
Only `presenter-mode-reveal` is designed from the ground up around the feature with proper example 逐字稿 on every slide.
@ -197,9 +207,10 @@ capture, runtime.js exposes `#/N` deep-links, and render.sh iterates 1..N.
```
← → Space PgUp PgDn Home End navigate
F fullscreen
S open presenter window (new popup: current + next + script + timer)
S open presenter window (magnetic cards: current/next/script/timer)
N quick notes drawer (bottom overlay)
R reset timer (in presenter window)
?preview=N URL param — force preview-only mode (single slide, no chrome)
O slide overview grid
T cycle themes (reads data-themes attr)
A cycle demo animation on current slide

View File

@ -48,23 +48,33 @@
/* ===== 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';
}
});
function showSlide(i) {
slides.forEach((s, j) => {
const active = (j === i);
s.classList.toggle('is-active', active);
s.style.display = active ? '' : 'none';
if (active) {
s.style.opacity = '1';
s.style.transform = 'none';
s.style.pointerEvents = 'auto';
}
});
}
showSlide(previewOnlyIdx);
/* 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 */
/* Listen for postMessage from parent presenter window to switch slides
* WITHOUT reloading this eliminates flicker during navigation. */
window.addEventListener('message', function(e) {
if (!e.data || e.data.type !== 'preview-goto') return;
const n = parseInt(e.data.idx, 10);
if (n >= 0 && n < slides.length) showSlide(n);
});
/* Signal to parent that preview iframe is ready */
try { window.parent && window.parent.postMessage({ type: 'preview-ready' }, '*'); } catch(e) {}
return;
}
@ -596,22 +606,49 @@
});
});
/* ===== Update content ===== */
/* ===== Preview iframe ready tracking =====
* Each iframe loads the deck ONCE with ?preview=1 on init. Subsequent
* slide changes are sent via postMessage('preview-goto') so the iframe
* just toggles visibility of a different .slide no reload, no flicker.
*/
var iframeReady = { cur: false, nxt: false };
window.addEventListener('message', function(e) {
if (!e.data || e.data.type !== 'preview-ready') return;
if (e.source === iframeCur.contentWindow) {
iframeReady.cur = true;
postPreviewGoto(iframeCur, idx);
rescaleIframe(iframeCur);
} else if (e.source === iframeNxt.contentWindow) {
iframeReady.nxt = true;
postPreviewGoto(iframeNxt, idx + 1 < total ? idx + 1 : idx);
rescaleIframe(iframeNxt);
}
});
function postPreviewGoto(iframe, n) {
try {
iframe.contentWindow.postMessage({ type: 'preview-goto', idx: n }, '*');
} catch(e) {}
}
/* ===== Update content =====
* Smooth (no-reload) navigation: send postMessage to iframes instead of
* resetting src. Iframes stay loaded, just switch visible .slide.
*/
function update(n) {
n = Math.max(0, Math.min(total - 1, n));
idx = n;
/* Current preview iframe */
var curUrl = deckUrl + '?preview=' + (n + 1) + '&_ts=' + Date.now();
iframeCur.src = curUrl;
/* Current preview — postMessage (smooth) */
if (iframeReady.cur) postPreviewGoto(iframeCur, n);
curMeta.textContent = (n + 1) + '/' + total;
/* Next preview iframe */
/* Next preview */
if (n + 1 < total) {
iframeNxt.style.display = '';
var endEl = document.querySelector('#card-nxt .preview-end');
if (endEl) endEl.remove();
iframeNxt.src = deckUrl + '?preview=' + (n + 2) + '&_ts=' + Date.now();
if (iframeReady.nxt) postPreviewGoto(iframeNxt, n + 1);
nxtMeta.textContent = (n + 2) + '/' + total;
} else {
iframeNxt.style.display = 'none';
@ -631,9 +668,6 @@
/* Timer count */
timerCount.textContent = (n + 1) + ' / ' + total;
/* Re-fit after src change */
setTimeout(rescaleAll, 200);
}
/* ===== Timer ===== */
@ -685,9 +719,19 @@
iframeCur.addEventListener('load', function(){ rescaleIframe(iframeCur); });
iframeNxt.addEventListener('load', function(){ rescaleIframe(iframeNxt); });
/* ===== Init ===== */
/* ===== Init =====
* Load each iframe ONCE with the deck file. After they post
* 'preview-ready', all subsequent navigation is via postMessage
* (smooth, no reload, no flicker).
*/
applyLayout(readLayout());
update(idx);
iframeCur.src = deckUrl + '?preview=' + (idx + 1);
if (idx + 1 < total) iframeNxt.src = deckUrl + '?preview=' + (idx + 2);
/* Initialize notes/timer/count without touching iframes */
notesBody.innerHTML = slideMeta[idx].notes || '<span class="empty">(这一页还没有逐字稿)</span>';
curMeta.textContent = (idx + 1) + '/' + total;
nxtMeta.textContent = (idx + 2) + '/' + total;
timerCount.textContent = (idx + 1) + ' / ' + total;
})();
</` + `script>
</body></html>`;

View File

@ -126,28 +126,41 @@ html-ppt 的 **S 键演讲者视图是 `runtime.js` 内置的,所有 full-deck
## 演讲者视图显示的内容
`S` 键后,**弹出一个独立的演讲者窗口**(原页面保持观众视图不变):
`S` 键后,**弹出一个独立的演讲者窗口**(原页面保持观众视图不变)。演讲者窗口是 **4 个独立的磁吸卡片**
```
观众窗口(原页面) 演讲者窗口(新弹窗)
┌──────────────────┐ ┌────────────────────────┬──────────────────────┐
│ │ │ │ NEXT │
│ 正常 slide │ │ CURRENT │ [下一页缩放预览] │
│ 全屏展示 │◄──►│ [当前页缩放预览] ├──────────────────────┤
│ │sync│ 1920×1080 → scale() │ SPEAKER SCRIPT │
│ │ │ │ [大字号逐字稿] │
│ │ │ │ [可滚动] │
│ │ │ ├──────────────────────┤
│ │ │ │ ⏱ 12:34 3 / 8 💡 │
└──────────────────┘ └────────────────────────┴──────────────────────┘
观众窗口(原页面) 演讲者窗口(磁吸卡片)
┌─────────────────┐ ┌─────────────────────┬──────────────────┐
│ │ │ 🔵 CURRENT │ 🟣 NEXT │
│ 正常 slide │ │ ━━━━━━━━━━━━━━━━ │ ━━━━━━━━━━━━━ │
│ 全屏展示 │◄►│ │ iframe preview │
│ │ │ iframe preview │ (下一页) │
│ │ │ (当前页) ├──────────────────┤
│ │ │ │ 🟠 SPEAKER SCRIPT │
│ │ │ │ ━━━━━━━━━━━━━ │
│ │ ├─────────────────────┤ [大字号逐字稿] │
│ │ │ 🟢 TIMER │ [可滚动] │
│ │ │ ⏱ 12:34 3 / 8 │ │
│ │ │ [← Prev][Next →] │ │
└─────────────────┘ └─────────────────────┴──────────────────┘
↑ BroadcastChannel 双向同步翻页 ↑
```
- **左侧 58%**:当前页 — 在 1920×1080 设计尺寸下渲染后 CSS scale 缩放,排版与观众端一致
- **右上 35%**:下一页预览 — 同样 scale 缩放,帮助准备过渡
- **右中**:逐字稿,字号 18px高对比度可滚动
- **右下**:计时器 + 页码 + 当前页标题 + 键位提示
- **两窗口同步**:在任一窗口按 ← → 翻页,另一个窗口自动同步
卡片交互规则:
- **拖动卡片 header**(带彩色圆点和标题的顶部条)→ 移动卡片位置
- **拖动卡片右下角的三角手柄** → 调整卡片大小
- **位置/尺寸自动保存到 localStorage**,下次打开恢复
- 底部 "重置布局" 按钮恢复默认排列
卡片内容:
- 🔵 **CURRENT** — 当前页 **像素级完美预览**iframe 加载原 HTML 文件的 `?preview=N` 模式,错色不可能)
- 🟣 **NEXT** — 下一页预览,同样像素级完美
- 🟠 **SPEAKER SCRIPT** — 逐字稿,字号 18px支持 `<strong>` (橘色加粗)、`<em>` (蓝色强调)、`<code>` 等 inline 样式
- 🟢 **TIMER** — 计时器不会丢失焦点,带切页按钮
两窗口同步:在任一窗口按 ← → 翻页另一个窗口自动同步BroadcastChannel
丝滑翻页iframe 只加载一次,后续翻页用 `postMessage` 切换可见的 slide**不重新加载、不闪烁**。
## 键盘快捷键(演讲者模式)
@ -169,7 +182,9 @@ html-ppt 的 **S 键演讲者视图是 `runtime.js` 内置的,所有 full-deck
4. 在任一窗口按 ← → 翻页,两边自动同步
5. 演讲者窗口里看逐字稿 + 下一页 + 计时器
> 💡 html-ppt 的演讲者模式会**弹出一个独立的新窗口**。原页面保持观众视图不变,只受翻页控制。两个窗口通过 BroadcastChannel 双向同步。演讲者窗口中的当前页/下一页预览使用 CSS `transform: scale()` 在 1920×1080 设计分辨率下缩放渲染,保证排版与观众端完全一致。
> 💡 **为什么预览像素级完美**:每个预览是一个 `<iframe>`,它加载的就是同一个 deck HTML 文件,只是 URL 多了 `?preview=N` 参数。`runtime.js` 检测到这个参数时只渲染第 N 页、隐藏所有 chrome。**iframe 使用与观众视图完全相同的 CSS、主题、字体和 viewport**——颜色和排版保证一致。外层用 CSS `transform: scale()` 把 1920×1080 缩到卡片宽高,等比缩放不变形。
> 💡 **为什么不闪烁**iframe 初次加载后就常驻,翻页时 presenter 窗口通过 `postMessage({type:'preview-goto', idx:N})` 告诉 iframe 切换到第 N 页。iframe 内的 runtime.js 只切换 `.is-active` class**不重新加载、不渲染白屏**。
## 常见错误

View File

@ -1,6 +1,6 @@
# presenter-mode-reveal · 演讲者模式模板
一份专为**带逐字稿的技术分享**设计的 full-deck 模板。核心卖点是真正可用的**演讲者视图 (Presenter View)**:当前页 + 下页预览 + 大字号逐字稿 + 计时器,全部集成在 `runtime.js` 里,零依赖。
一份专为**带逐字稿的技术分享**设计的 full-deck 模板。核心卖点是真正可用的**磁吸卡片式演讲者视图**:当前页 iframe 预览 + 下页 iframe 预览 + 大字号逐字稿 + 计时器4 个卡片可任意拖拽/缩放,全部集成在 `runtime.js` 里,零依赖。
## 使用场景
@ -78,6 +78,23 @@ presenter-mode-reveal/
- **改样式**:只动 `style.css`,不要碰根目录的 `assets/base.css`
- **加动效**:在元素上加 `data-anim="fade-up"` 等(参考 `references/animations.md`
## 演讲者窗口的 4 个卡片
`S` 后弹出的窗口里有:
- 🔵 **CURRENT** — 当前页 iframe 预览(加载 `?preview=N` 模式,像素级完美,与观众端同 CSS/主题/字体)
- 🟣 **NEXT** — 下一页预览,帮助准备过渡
- 🟠 **SPEAKER SCRIPT** — 大字号逐字稿,可滚动
- 🟢 **TIMER** — 经过时间 + 页码 + Prev/Next/Reset 按钮
卡片操作:
- **拖卡片头**(彩色圆点 + 标题的顶部条)→ 移动卡片
- **拖卡片右下角** → 调整大小
- 位置 + 尺寸自动存 localStorage下次打开恢复
- 底部 "重置布局" 按钮可恢复默认卡片排列
翻页丝滑iframe 只加载一次,后续翻页通过 `postMessage` 切换内部 slide**不重新加载不闪烁**。两窗口通过 `BroadcastChannel` 双向同步。
## 注意事项
- **观众永远看不到 `.notes` 内容** — CSS 默认 `display:none`,只在演讲者视图里可见