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:
parent
832f5be212
commit
b64ce0f832
17
SKILL.md
17
SKILL.md
|
|
@ -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
|
- **31 layouts** (`templates/single-page/*.html`) with realistic demo data
|
||||||
- **27 CSS animations** (`assets/animations/animations.css`) via `data-anim`
|
- **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
|
- **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
|
- **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
|
- **Showcase decks** for themes / layouts / animations / full-decks gallery
|
||||||
- **Headless Chrome render script** for PNG export
|
- **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. **每页 150–300 字** — 2–3 分钟/页的节奏
|
2. **每页 150–300 字** — 2–3 分钟/页的节奏
|
||||||
3. **用口语,不用书面语** — "因此"→"所以","该方案"→"这个方案"
|
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.
|
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
|
← → Space PgUp PgDn Home End navigate
|
||||||
F fullscreen
|
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)
|
N quick notes drawer (bottom overlay)
|
||||||
R reset timer (in presenter window)
|
R reset timer (in presenter window)
|
||||||
|
?preview=N URL param — force preview-only mode (single slide, no chrome)
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -48,23 +48,33 @@
|
||||||
|
|
||||||
/* ===== Preview-only mode: show one slide, hide everything else ===== */
|
/* ===== Preview-only mode: show one slide, hide everything else ===== */
|
||||||
if (isPreviewMode) {
|
if (isPreviewMode) {
|
||||||
slides.forEach((s, i) => {
|
function showSlide(i) {
|
||||||
s.classList.toggle('is-active', i === previewOnlyIdx);
|
slides.forEach((s, j) => {
|
||||||
if (i !== previewOnlyIdx) {
|
const active = (j === i);
|
||||||
s.style.display = 'none';
|
s.classList.toggle('is-active', active);
|
||||||
} else {
|
s.style.display = active ? '' : 'none';
|
||||||
s.style.opacity = '1';
|
if (active) {
|
||||||
s.style.transform = 'none';
|
s.style.opacity = '1';
|
||||||
s.style.pointerEvents = 'auto';
|
s.style.transform = 'none';
|
||||||
}
|
s.style.pointerEvents = 'auto';
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showSlide(previewOnlyIdx);
|
||||||
/* Hide chrome that the presenter shouldn't see in preview */
|
/* Hide chrome that the presenter shouldn't see in preview */
|
||||||
const hideSel = '.progress-bar, .notes-overlay, .overview, .notes, aside.notes, .speaker-notes';
|
const hideSel = '.progress-bar, .notes-overlay, .overview, .notes, aside.notes, .speaker-notes';
|
||||||
document.querySelectorAll(hideSel).forEach(el => { el.style.display = 'none'; });
|
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.documentElement.setAttribute('data-preview', '1');
|
||||||
document.body.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;
|
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) {
|
function update(n) {
|
||||||
n = Math.max(0, Math.min(total - 1, n));
|
n = Math.max(0, Math.min(total - 1, n));
|
||||||
idx = n;
|
idx = n;
|
||||||
|
|
||||||
/* Current preview iframe */
|
/* Current preview — postMessage (smooth) */
|
||||||
var curUrl = deckUrl + '?preview=' + (n + 1) + '&_ts=' + Date.now();
|
if (iframeReady.cur) postPreviewGoto(iframeCur, n);
|
||||||
iframeCur.src = curUrl;
|
|
||||||
curMeta.textContent = (n + 1) + '/' + total;
|
curMeta.textContent = (n + 1) + '/' + total;
|
||||||
|
|
||||||
/* Next preview iframe */
|
/* Next preview */
|
||||||
if (n + 1 < total) {
|
if (n + 1 < total) {
|
||||||
iframeNxt.style.display = '';
|
iframeNxt.style.display = '';
|
||||||
var endEl = document.querySelector('#card-nxt .preview-end');
|
var endEl = document.querySelector('#card-nxt .preview-end');
|
||||||
if (endEl) endEl.remove();
|
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;
|
nxtMeta.textContent = (n + 2) + '/' + total;
|
||||||
} else {
|
} else {
|
||||||
iframeNxt.style.display = 'none';
|
iframeNxt.style.display = 'none';
|
||||||
|
|
@ -631,9 +668,6 @@
|
||||||
|
|
||||||
/* Timer count */
|
/* Timer count */
|
||||||
timerCount.textContent = (n + 1) + ' / ' + total;
|
timerCount.textContent = (n + 1) + ' / ' + total;
|
||||||
|
|
||||||
/* Re-fit after src change */
|
|
||||||
setTimeout(rescaleAll, 200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Timer ===== */
|
/* ===== Timer ===== */
|
||||||
|
|
@ -685,9 +719,19 @@
|
||||||
iframeCur.addEventListener('load', function(){ rescaleIframe(iframeCur); });
|
iframeCur.addEventListener('load', function(){ rescaleIframe(iframeCur); });
|
||||||
iframeNxt.addEventListener('load', function(){ rescaleIframe(iframeNxt); });
|
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());
|
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>
|
</` + `script>
|
||||||
</body></html>`;
|
</body></html>`;
|
||||||
|
|
|
||||||
|
|
@ -126,28 +126,41 @@ html-ppt 的 **S 键演讲者视图是 `runtime.js` 内置的,所有 full-deck
|
||||||
|
|
||||||
## 演讲者视图显示的内容
|
## 演讲者视图显示的内容
|
||||||
|
|
||||||
按 `S` 键后,**弹出一个独立的演讲者窗口**(原页面保持观众视图不变):
|
按 `S` 键后,**弹出一个独立的演讲者窗口**(原页面保持观众视图不变)。演讲者窗口是 **4 个独立的磁吸卡片**:
|
||||||
|
|
||||||
```
|
```
|
||||||
观众窗口(原页面) 演讲者窗口(新弹窗)
|
观众窗口(原页面) 演讲者窗口(磁吸卡片)
|
||||||
┌──────────────────┐ ┌────────────────────────┬──────────────────────┐
|
┌─────────────────┐ ┌─────────────────────┬──────────────────┐
|
||||||
│ │ │ │ NEXT │
|
│ │ │ 🔵 CURRENT │ 🟣 NEXT │
|
||||||
│ 正常 slide │ │ CURRENT │ [下一页缩放预览] │
|
│ 正常 slide │ │ ━━━━━━━━━━━━━━━━ │ ━━━━━━━━━━━━━ │
|
||||||
│ 全屏展示 │◄──►│ [当前页缩放预览] ├──────────────────────┤
|
│ 全屏展示 │◄►│ │ iframe preview │
|
||||||
│ │sync│ 1920×1080 → scale() │ SPEAKER SCRIPT │
|
│ │ │ iframe preview │ (下一页) │
|
||||||
│ │ │ │ [大字号逐字稿] │
|
│ │ │ (当前页) ├──────────────────┤
|
||||||
│ │ │ │ [可滚动] │
|
│ │ │ │ 🟠 SPEAKER SCRIPT │
|
||||||
│ │ │ ├──────────────────────┤
|
│ │ │ │ ━━━━━━━━━━━━━ │
|
||||||
│ │ │ │ ⏱ 12:34 3 / 8 💡 │
|
│ │ ├─────────────────────┤ [大字号逐字稿] │
|
||||||
└──────────────────┘ └────────────────────────┴──────────────────────┘
|
│ │ │ 🟢 TIMER │ [可滚动] │
|
||||||
|
│ │ │ ⏱ 12:34 3 / 8 │ │
|
||||||
|
│ │ │ [← Prev][Next →] │ │
|
||||||
|
└─────────────────┘ └─────────────────────┴──────────────────┘
|
||||||
↑ BroadcastChannel 双向同步翻页 ↑
|
↑ BroadcastChannel 双向同步翻页 ↑
|
||||||
```
|
```
|
||||||
|
|
||||||
- **左侧 58%**:当前页 — 在 1920×1080 设计尺寸下渲染后 CSS scale 缩放,排版与观众端一致
|
卡片交互规则:
|
||||||
- **右上 35%**:下一页预览 — 同样 scale 缩放,帮助准备过渡
|
- **拖动卡片 header**(带彩色圆点和标题的顶部条)→ 移动卡片位置
|
||||||
- **右中**:逐字稿,字号 18px,高对比度,可滚动
|
- **拖动卡片右下角的三角手柄** → 调整卡片大小
|
||||||
- **右下**:计时器 + 页码 + 当前页标题 + 键位提示
|
- **位置/尺寸自动保存到 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. 在任一窗口按 ← → 翻页,两边自动同步
|
4. 在任一窗口按 ← → 翻页,两边自动同步
|
||||||
5. 演讲者窗口里看逐字稿 + 下一页 + 计时器
|
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,**不重新加载、不渲染白屏**。
|
||||||
|
|
||||||
## 常见错误
|
## 常见错误
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# presenter-mode-reveal · 演讲者模式模板
|
# 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`
|
- **改样式**:只动 `style.css`,不要碰根目录的 `assets/base.css`
|
||||||
- **加动效**:在元素上加 `data-anim="fade-up"` 等(参考 `references/animations.md`)
|
- **加动效**:在元素上加 `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`,只在演讲者视图里可见
|
- **观众永远看不到 `.notes` 内容** — CSS 默认 `display:none`,只在演讲者视图里可见
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue