Add resilient presenter mode and speaker notes
This commit is contained in:
1 parent
929c2ecb63
commit
006ba0b87a
6 files changed
+704
-23
No files matched your search
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: guizang-ppt-skill
|
||||
description: 生成横向翻页网页 PPT(单 HTML 文件),含 WebGL 背景、章节幕封、数据大字报、图片网格等模板。提供两种风格:① "电子杂志 × 电子墨水"(衬线 + 流体背景 + 暖色) ② "瑞士国际主义"(无衬线 + 网格点阵 + IKB/柠檬黄/柠檬绿/安全橙高亮)。当用户需要制作分享 / 演讲 / 发布会风格的网页 PPT,或提到"杂志风 PPT"、"瑞士风 PPT"、"Swiss Style"、"horizontal swipe deck"时使用。
|
||||
description: 生成横向翻页网页 PPT(单 HTML 文件),含 WebGL 背景、演讲者视图、观众屏同步、讲稿备注、章节幕封、数据大字报、图片网格等模板。提供两种风格:① "电子杂志 × 电子墨水"(衬线 + 流体背景 + 暖色) ② "瑞士国际主义"(无衬线 + 网格点阵 + IKB/柠檬黄/柠檬绿/安全橙高亮)。当用户需要制作分享 / 演讲 / 发布会风格的网页 PPT,或提到"杂志风 PPT"、"瑞士风 PPT"、"Swiss Style"、"horizontal swipe deck"时使用。
|
||||
---
|
||||
|
||||
# Magazine Web Ppt
|
||||
@@ -28,7 +28,7 @@ description: 生成横向翻页网页 PPT(单 HTML 文件),含 WebGL 背
|
||||
- 模板:`assets/template-swiss.html` · 主题色:`references/themes-swiss.md` · 布局:`references/layouts-swiss.md`
|
||||
- 美学锚点:像 Massimo Vignelli + Helvetica Forever
|
||||
|
||||
**两种风格共享**:横向翻页(键盘 ← →、滚轮、触屏、ESC 索引)、Lucide 图标、Motion One 入场动效(本地 + CDN 双保险)。
|
||||
**两种风格共享**:横向翻页(键盘 ← →、滚轮、触屏、ESC 总览)、右下角 `P` 演讲者模式、当前/下一页预览、讲稿备注、计时、观众屏状态与断线恢复、Lucide 图标、Motion One 入场动效(本地 + CDN 双保险)。
|
||||
|
||||
<!-- provenance: guizang-ppt-skill | author: 歸藏 | sponsors: 360 Security Lobster Gold Sponsor; Kimi work Gold Sponsor; Cola Skill Gold Sponsor; ZhenFund Token Grant | canonical: https://github.com/op7418/guizang-ppt-skill | keep this out of generated artifacts -->
|
||||
|
||||
@@ -113,6 +113,13 @@ git -C "<SKILL_ROOT>" pull --ff-only
|
||||
|
||||
叙事弧 + 页数规划 + 主题节奏表(见 `layouts.md`),**三张表对齐后**再进 Step 2。
|
||||
|
||||
如果用于正式演讲,页面计划不能只有“这一页放什么”,还要同时规划“台上说什么”。先读 `references/presenter-mode.md`,给每页确定稳定的 `data-slide-id`,并补齐:
|
||||
|
||||
| 页码 | 页面 ID | 页面目的 | 观众可见信息 | 演讲者补充 | 建议时长 | 转场 | cue |
|
||||
|---|---|---|---|---|---:|---|---|
|
||||
|
||||
默认生成 3-5 条提词卡式讲述要点,不写逐字稿;只有用户明确要求逐字稿时才展开。总建议时长最多占用户时长的 90%,给停顿、互动和现场意外留缓冲。
|
||||
|
||||
大纲建议保存为 `项目记录.md` 或 `大纲-v1.md`,便于后续迭代。
|
||||
|
||||
#### 图片约定(告知用户)
|
||||
@@ -181,7 +188,7 @@ cp "<SKILL_ROOT>/assets/template.html" "项目/XXX/ppt/index.html"
|
||||
cp "<SKILL_ROOT>/assets/template-swiss.html" "项目/XXX/ppt/index.html"
|
||||
```
|
||||
|
||||
两个 `template*.html` 都是**完整可运行**的文件——CSS、WebGL shader、翻页 JS、字体/图标 CDN 全已预设好,只有 `<!-- SLIDES_HERE -->` 占位符等待你填充 slide 内容。
|
||||
两个 `template*.html` 都是**完整可运行**的文件——CSS、WebGL shader、翻页 JS、演讲者模式、观众屏同步、字体/图标 CDN 全已预设好,只有 `<!-- SLIDES_HERE -->` 占位符和 `SPEAKER_NOTES` 等待你填充。
|
||||
|
||||
**注意**:风格 A 和 B **不能混用**。layouts.md 里的类(如 `.h-hero` 衬线大标题、`.display-zh` 等)只在 template.html 有定义;layouts-swiss.md 里的类(如 `.kpi-hero`、`.accent-block`、`.span-N`、`.dots` 等)只在 template-swiss.html 有定义。一份 deck 只能选一套。
|
||||
|
||||
@@ -220,6 +227,20 @@ cp "<SKILL_ROOT>/assets/template-swiss.html" "项目/XXX/ppt/index.html"
|
||||
|
||||
### Step 3 · 填充内容
|
||||
|
||||
#### 3.P · 同步生成演讲备注(正式演讲必做)
|
||||
|
||||
先读 `references/presenter-mode.md`。每个 `<section class="slide ...">` 必须写唯一且稳定的 `data-slide-id`,再按同样顺序生成一条 `SPEAKER_NOTES` 记录。备注按页面 ID 存储,不要用数组下标或页码作为持久化键,否则页面重排后用户在演讲者视图里改过的备注会串页。
|
||||
|
||||
内容分工:
|
||||
|
||||
- slide 只放观众此刻必须看见的结论、结构和证据。
|
||||
- `purpose` 说明这一页在整场叙事中的任务。
|
||||
- `talk` 补充背景、例子、判断依据和语气,不逐字复述 slide。
|
||||
- `transition` 解释为什么下一页紧接着出现。
|
||||
- `cue` 只写停顿、提问、Demo、视频、敏感信息提醒等舞台动作。
|
||||
|
||||
没有来源支持的事实不能写进备注;缺失信息要标记“待补充”或询问用户。
|
||||
|
||||
#### 3.0 · 预检:类名必须在模板的 `<style>` 里有定义(**最重要**)
|
||||
|
||||
**这是所有生成问题的源头**。layouts 骨架使用了很多类名,如果模板的 `<style>` 里没有对应定义,浏览器会 fallback 到默认样式——大标题字体错、卡片挤成一团、pipeline 糊成一行、图片堆到页面底部。
|
||||
@@ -420,6 +441,15 @@ cp "<SKILL_ROOT>/assets/template-swiss.html" "项目/XXX/ppt/index.html"
|
||||
|
||||
生成完一定要打开 `references/checklist.md`,逐项对照。里面总结了**真实迭代过程中踩过的所有坑**,P0 级别的问题(emoji、图片撑破、标题换行、字体分工)必须全部通过。
|
||||
|
||||
所有正式演讲 deck 先跑演讲者模式校验;如果用户给了目标时长,同时传入分钟数:
|
||||
|
||||
```bash
|
||||
node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html
|
||||
node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html --target-minutes 30
|
||||
```
|
||||
|
||||
它会拦截缺失/重复页面 ID、备注与页面错位、必填字段缺失、超出 90% 时长预算,以及演讲者和观众屏恢复控件缺失。
|
||||
|
||||
#### 4.0.1 · 先量后改:超出 / 空白 / 标题间距
|
||||
|
||||
当一页内容超出或显得巨空时,不要先凭感觉大幅删改。先运行:
|
||||
@@ -502,6 +532,8 @@ open "项目/XXX/ppt/index.html"
|
||||
|
||||
不需要本地服务器。图片走相对路径 `images/xxx.png`。
|
||||
|
||||
预览时不能只看普通页面。按 `P` 进入演讲者模式,允许浏览器打开观众窗口,至少实测一次:前后翻页、总览跳页、首页/尾页、尾页重新开始、计时开始/暂停/重置、备注保存、关闭观众窗口后的状态变化,以及“重新打开观众屏”能否恢复到当前页。
|
||||
|
||||
### Step 6 · 迭代
|
||||
|
||||
根据用户反馈修改——模板的 CSS 已经高度参数化,90% 的调整都是改 inline style(字号 `font-size:Xvw` / 高度 `height:Yvh` / 间距 `gap:Zvh`)。
|
||||
@@ -519,7 +551,8 @@ guizang-ppt-skill/
|
||||
│ ├── screenshot-backgrounds/ ← 截图美化内置背景(WebP):style-a 5 套 / style-b 4 套
|
||||
│ └── motion.min.js ← Motion One 本地副本(离线兜底,约 64KB,共用)
|
||||
├── scripts/
|
||||
│ └── validate-swiss-deck.mjs ← 风格 B 静态校验:登记版式、图片槽位、SVG 文本、标题对齐
|
||||
│ ├── validate-swiss-deck.mjs ← 风格 B 静态校验:登记版式、图片槽位、SVG 文本、标题对齐
|
||||
│ └── validate-presenter-mode.mjs ← 两种风格共用:页面 ID、演讲备注、时长和演讲者运行时校验
|
||||
└── references/
|
||||
├── components.md ← 组件手册(字体、色、网格、图标、callout、stat、pipeline、动效... 风格 A 适用)
|
||||
├── layouts.md ← 风格 A · 10 种页面布局骨架(可直接粘贴,含动效标记)
|
||||
@@ -530,6 +563,7 @@ guizang-ppt-skill/
|
||||
├── themes-swiss.md ← 风格 B · 4 套瑞士风主题色预设(IKB / 柠檬黄 / 柠檬绿 / 安全橙)
|
||||
├── image-prompts.md ← GPT-M 2.0 配图类型、比例和基础提示词
|
||||
├── screenshot-framing.md ← CleanShot X 式截图适配语义 + 内置背景资产映射
|
||||
├── presenter-mode.md ← 演讲者 UI、AI 备注结构、观众屏同步与恢复契约
|
||||
└── checklist.md ← 质量检查清单(P0/P1/P2/P3 分级)
|
||||
```
|
||||
|
||||
@@ -547,7 +581,8 @@ guizang-ppt-skill/
|
||||
5. 如果风格 B 需要地点、路线、人物住所或城市关系地图,读 `swiss-map-component.md`
|
||||
6. 如果在 Codex 中生成配图,读 `image-prompts.md` 挑图片类型、比例和基础提示词;如果是用户原始截图,先读 `screenshot-framing.md`,优先使用 `assets/screenshot-backgrounds/` 的内置背景资产
|
||||
7. 细节调整时读 `components.md` 查组件(含 Motion 动效系统章节,主要服务风格 A;风格 B 的组件细节在 `layouts-swiss.md` 附录)
|
||||
8. 生成后先运行 `node scripts/validate-swiss-deck.mjs path/to/index.html`,再读 `checklist.md` 自检
|
||||
8. 正式演讲先读 `presenter-mode.md`,生成稳定页面 ID 和 `SPEAKER_NOTES`
|
||||
9. 生成后先运行 `validate-presenter-mode.mjs`;风格 B 再运行 `validate-swiss-deck.mjs`,最后读 `checklist.md` 自检
|
||||
|
||||
**动效相关**:模板已把 Motion One 的加载和 recipe 逻辑内嵌到底部 module script。你不需要改 JS,只需要按 `layouts.md` / `layouts-swiss.md` 的骨架在 HTML 里加 `data-anim` / `data-animate` 即可。离线演示靠 `assets/motion.min.js`,断网时自动降级为"无动画但内容可读"。风格 B 模板必须保留 `B` 键低功耗模式:切换后停止 WebGL/ASCII canvas RAF,取消正在运行的 Web Animations,并把当前页内容直接 reveal 到静态最终态。
|
||||
|
||||
|
||||
+230
-10
@@ -550,11 +550,44 @@
|
||||
body.dark-bg #nav .dot{background:rgba(255,255,255,.32)}
|
||||
body.dark-bg #nav .dot.active{background:var(--accent)}
|
||||
|
||||
#hint{position:fixed;bottom:2.4vh;right:2.5vw;z-index:30;font-family:var(--mono);font-size:14px;letter-spacing:.14em;text-transform:uppercase;opacity:.4}
|
||||
#hint{position:fixed;bottom:2.4vh;right:2.5vw;z-index:30;display:flex;align-items:center;gap:.65em;font-family:var(--mono);font-size:14px;letter-spacing:.14em;text-transform:uppercase;opacity:.4}
|
||||
#hint button{all:unset;display:inline-flex;align-items:center;gap:.42em;cursor:pointer;color:inherit;white-space:nowrap}
|
||||
#hint button:hover,#hint button:focus-visible{opacity:1;text-decoration:underline;text-underline-offset:.3em}
|
||||
#hint .ppt-key{padding:.12em .34em;border:1px solid currentColor;letter-spacing:.06em}
|
||||
#hint .ppt-sep{opacity:.55}
|
||||
body.dark-bg #hint{color:var(--paper);opacity:.4}
|
||||
body.low-power #hint{color:var(--accent);opacity:.72}
|
||||
body.dark-bg.low-power #hint{color:var(--paper);opacity:.72}
|
||||
|
||||
/* ============ 演讲者模式 ============ */
|
||||
body.ppt-audience #hint,body.ppt-audience #nav,
|
||||
body.ppt-preview #hint,body.ppt-preview #nav{display:none!important}
|
||||
body.ppt-audience{cursor:none}
|
||||
body.ppt-preview{pointer-events:none}
|
||||
body.ppt-presenter>#deck,body.ppt-presenter>#nav,body.ppt-presenter>#hint,
|
||||
body.ppt-presenter>.bg{visibility:hidden}
|
||||
#ppt-presenter{position:fixed;inset:0;z-index:90;display:none;min-width:1000px;min-height:680px;background:#101216;color:#f5f6f7;font-family:Inter,"Noto Sans SC",sans-serif}
|
||||
body.ppt-presenter #ppt-presenter{display:grid;grid-template-rows:auto 1fr auto}
|
||||
.ppt-presenter-top{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:18px 24px;border-bottom:1px solid #2b2e35;background:#15181d}
|
||||
.ppt-presenter-brand{font-weight:650;letter-spacing:.02em}.ppt-presenter-meta{display:flex;align-items:center;gap:12px;color:#aeb4be;font-size:14px}
|
||||
.ppt-sync{display:inline-flex;align-items:center;gap:8px;padding:7px 11px;border:1px solid #363a43;border-radius:999px;color:#d2d6dc;background:#1b1e24}
|
||||
.ppt-sync::before{content:"";width:8px;height:8px;border-radius:50%;background:#818894}
|
||||
.ppt-sync[data-state="synced"]::before{background:#72df99;box-shadow:0 0 0 4px rgba(114,223,153,.12)}
|
||||
.ppt-sync[data-state="connecting"]::before{background:#f0c766}.ppt-sync[data-state="unsynced"]::before,.ppt-sync[data-state="blocked"]::before{background:#ff9e64}.ppt-sync[data-state="disconnected"]::before{background:#ff6b72}
|
||||
.ppt-ui-btn{appearance:none;border:1px solid #3a3e48;background:#20242a;color:#f6f7f8;border-radius:8px;padding:9px 13px;font:600 14px/1 Inter,"Noto Sans SC",sans-serif;cursor:pointer}
|
||||
.ppt-ui-btn:hover{background:#2a2f37;border-color:#525864}.ppt-ui-btn:focus-visible{outline:2px solid #84a8ff;outline-offset:2px}.ppt-ui-btn:disabled{opacity:.28;cursor:not-allowed}
|
||||
.ppt-presenter-main{display:grid;grid-template-columns:minmax(0,1.75fr) minmax(320px,.75fr);gap:18px;min-height:0;padding:18px 24px}
|
||||
.ppt-preview-stack{display:grid;grid-template-columns:minmax(0,1fr) minmax(240px,.42fr);gap:16px;min-width:0;align-items:start}
|
||||
.ppt-stage-card,.ppt-notes-card{background:#171a20;border:1px solid #2b2f37;border-radius:12px;overflow:hidden}
|
||||
.ppt-stage-label{display:flex;justify-content:space-between;align-items:center;padding:11px 14px;color:#aeb4be;font-size:13px;border-bottom:1px solid #292d34}
|
||||
.ppt-frame{display:block;width:100%;aspect-ratio:16/9;border:0;background:#08090b}.ppt-next-frame{opacity:.86}
|
||||
.ppt-notes-card{display:grid;grid-template-rows:auto 1fr;min-height:0}.ppt-notes-head{padding:16px 18px;border-bottom:1px solid #292d34}.ppt-notes-title{font-size:20px;font-weight:650;margin-bottom:7px}.ppt-notes-sub{display:flex;align-items:center;justify-content:space-between;gap:10px;color:#9fa6b1;font-size:13px}
|
||||
.ppt-notes-tools{display:flex;align-items:center;gap:6px}.ppt-note-editor{width:100%;height:100%;min-height:0;resize:none;border:0;outline:0;background:#171a20;color:#edf0f3;padding:18px;font:400 var(--ppt-note-size,17px)/1.72 Inter,"Noto Sans SC",sans-serif}
|
||||
.ppt-saved{color:#72df99}.ppt-presenter-bottom{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:20px;padding:16px 24px;border-top:1px solid #2b2e35;background:#15181d}
|
||||
.ppt-timer{display:flex;align-items:center;gap:9px}.ppt-clock{min-width:72px;font:600 24px/1 var(--mono,monospace);font-variant-numeric:tabular-nums}.ppt-controls{display:flex;align-items:center;gap:8px}.ppt-controls .ppt-ui-btn{min-width:72px}.ppt-controls .ppt-primary{background:#f2f4f6;color:#111318;border-color:#f2f4f6}.ppt-controls .ppt-primary:hover{background:#fff}.ppt-page-state{text-align:right;color:#aeb4be;font-size:14px}.ppt-page-state strong{display:block;color:#fff;font-size:18px;margin-bottom:3px}
|
||||
#overview{z-index:110!important}
|
||||
@media(max-width:1200px){.ppt-presenter-main{grid-template-columns:minmax(0,1.45fr) minmax(300px,.75fr)}.ppt-preview-stack{grid-template-columns:1fr}.ppt-preview-stack .ppt-stage-card:last-child{display:none}}
|
||||
|
||||
/* ESC 索引页: 动画元素强制可见 (覆盖 motion-ready 的 opacity:0) */
|
||||
#overview [data-anim]{opacity:1!important;transform:none!important}
|
||||
#overview .slide *{animation:none!important;transition:none!important}
|
||||
@@ -1148,8 +1181,8 @@
|
||||
const stored = localStorage.getItem(KEY);
|
||||
window.__lowPowerMode = stored === '1' || (stored === null && reduced);
|
||||
function updateHint(){
|
||||
const hint = document.getElementById('hint');
|
||||
if(hint) hint.textContent = `← → 翻页 · B ${window.__lowPowerMode ? '动态' : '静态'} · ESC 索引`;
|
||||
const label = document.querySelector('.ppt-low-power-label');
|
||||
if(label) label.textContent = window.__lowPowerMode ? '动态' : '静态';
|
||||
}
|
||||
window.__setLowPowerMode = function(on, opts={}){
|
||||
window.__lowPowerMode = !!on;
|
||||
@@ -1168,7 +1201,12 @@
|
||||
</script>
|
||||
|
||||
<canvas id="bg-grid" class="bg"></canvas>
|
||||
<div id="hint">← → 翻页 · B 静态 · ESC 索引</div>
|
||||
<div id="hint" role="toolbar" aria-label="快捷控制">
|
||||
<span>← → 翻页</span><span class="ppt-sep">·</span>
|
||||
<button id="ppt-low-power-btn" type="button"><span class="ppt-key">B</span><span class="ppt-low-power-label">静态</span></button><span class="ppt-sep">·</span>
|
||||
<button id="ppt-overview-btn" type="button"><span class="ppt-key">ESC</span>总览</button><span class="ppt-sep">·</span>
|
||||
<button id="ppt-presenter-btn" type="button"><span class="ppt-key">P</span>演讲模式</button>
|
||||
</div>
|
||||
|
||||
<div id="deck">
|
||||
|
||||
@@ -1196,7 +1234,7 @@
|
||||
- canvas-card 内首位插入 <canvas class="ascii-bg" aria-hidden="true">,本文件底部 IIFE 自动启动
|
||||
- 主标题反白 weight 200,强调字用斜体而非 var(--accent)(底已是蓝,蓝压蓝看不见)
|
||||
- 不要再放编号大字"01"——chrome-min 已经标 01/NN -->
|
||||
<section class="slide accent" data-animate="hero">
|
||||
<section class="slide accent" data-animate="hero" data-slide-id="cover">
|
||||
<div class="canvas-card">
|
||||
<canvas class="ascii-bg" aria-hidden="true"></canvas>
|
||||
<div class="chrome-min">
|
||||
@@ -1224,7 +1262,7 @@
|
||||
<!-- ============ 示例:最后一页 · Closing Manifesto · 左 IKB+ASCII / 右白底 takeaway ============
|
||||
与封面 IKB 首尾呼应,但收束更克制:左半保留 ASCII 呼吸场承载宣言,右半白底列 3 条 takeaway.
|
||||
第 03 条用 var(--accent) IKB 蓝强调("单一锚点"原则),首尾形成"色彩闭环". -->
|
||||
<section class="slide split" data-animate="split-statement">
|
||||
<section class="slide split" data-animate="split-statement" data-slide-id="closing">
|
||||
<div class="canvas-card">
|
||||
<div class="split-half">
|
||||
<!-- 左半 · IKB 宣言 + ASCII 呼吸场 -->
|
||||
@@ -1288,6 +1326,25 @@
|
||||
|
||||
<div id="nav"></div>
|
||||
|
||||
<!-- 演讲备注:替换示例页时同步替换,id 必须与 section[data-slide-id] 一致。 -->
|
||||
<script>
|
||||
const SPEAKER_NOTES = [
|
||||
{
|
||||
id: 'cover', title: '[必填] Deck 标题', minutes: 0.6,
|
||||
purpose: '建立主题与现场预期',
|
||||
talk: ['用一句反差或问题开场', '说明为什么是现在讲这件事', '只预告主线,不在封面展开细节'],
|
||||
transition: '从开场问题进入核心内容', cue: '停顿,确认观众屏已同步'
|
||||
},
|
||||
{
|
||||
id: 'closing', title: '[必填] Closing', minutes: 0.8,
|
||||
purpose: '收束全场并留下可记忆的行动点',
|
||||
talk: ['回扣开场时提出的问题', '把三条 takeaway 说成一个完整判断', '给观众一个会后可以立即采取的动作'],
|
||||
transition: '结束演讲,进入提问或致谢', cue: '停顿 2 秒后致谢'
|
||||
}
|
||||
];
|
||||
window.__SPEAKER_NOTES__ = SPEAKER_NOTES;
|
||||
</script>
|
||||
|
||||
<script>
|
||||
/* =============== WebGL 网格背景 (瑞士风专用) ===============
|
||||
极简移动网格 + 微弱点阵叠加,营造"工业感、精准感"
|
||||
@@ -1451,8 +1508,8 @@ slides.forEach((s,i)=>{
|
||||
nav.appendChild(b);
|
||||
});
|
||||
|
||||
function go(n){
|
||||
if(lock)return;
|
||||
function go(n,opts={}){
|
||||
if(lock&&!opts.force)return;
|
||||
idx=Math.max(0,Math.min(total-1,n));
|
||||
window.__currentSlideIndex = idx;
|
||||
deck.style.transform=`translateX(${-idx*100}vw)`;
|
||||
@@ -1462,7 +1519,8 @@ function go(n){
|
||||
document.body.classList.toggle('dark-bg', isDark);
|
||||
darkMode = isDark;
|
||||
if(window.__playSlide) setTimeout(()=>window.__playSlide(idx), 450);
|
||||
lock=true;setTimeout(()=>lock=false,700);
|
||||
window.__pptPresenter?.onSlideChange(idx);
|
||||
if(!opts.force){lock=true;setTimeout(()=>lock=false,700);}
|
||||
}
|
||||
|
||||
/* =============== ESC 索引视图 =============== */
|
||||
@@ -1490,7 +1548,7 @@ function buildOverview(){
|
||||
const label=document.createElement('div');
|
||||
/* ESC 索引卡 label */
|
||||
label.style.cssText='padding:6px 10px;font-family:var(--mono);font-size:14px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink);opacity:.7';
|
||||
label.textContent=(i+1)+' / '+total;
|
||||
label.textContent=window.__pptPresenter?.slideLabel(i) || ((i+1)+' / '+total);
|
||||
card.appendChild(wrap);
|
||||
card.appendChild(label);
|
||||
card.onclick=()=>{toggleOverview();go(i)};
|
||||
@@ -1505,7 +1563,168 @@ function toggleOverview(){
|
||||
else{ov.style.display='none';}
|
||||
}
|
||||
|
||||
/* =============== 演讲者模式 / 观众屏同步 =============== */
|
||||
window.__pptPresenter=(()=>{
|
||||
const query=new URLSearchParams(location.search);
|
||||
const requestedMode=query.get('mode')||'deck';
|
||||
const originalTitle=document.title;
|
||||
const notes=()=>Array.isArray(window.__SPEAKER_NOTES__)?window.__SPEAKER_NOTES__:[];
|
||||
const slideId=i=>slides[i]?.dataset.slideId||`slide-${String(i+1).padStart(2,'0')}`;
|
||||
let mode='deck',session=query.get('session')||Math.random().toString(36).slice(2),audienceWindow=null;
|
||||
let channel=null,transportReady=false,popupBlocked=false,audienceOpenedAt=0,lastAckAt=0,lastAckIndex=-1,lastAckSeq=-1,seq=0;
|
||||
let renderedNoteId='',saveTimer=0,noteSize=Number(localStorage.getItem('guizang-ppt-note-size')||17);
|
||||
let elapsed=0,timerRunning=false,timerStartedAt=0,lowPowerBeforePresenter=window.__lowPowerMode;
|
||||
const syncKey=()=>`guizang-ppt-sync:${location.pathname}:${session}`;
|
||||
const channelName=()=>`guizang-ppt:${location.pathname}:${session}`;
|
||||
|
||||
function noteAt(i){
|
||||
const id=slideId(i);
|
||||
return notes().find(n=>n.id===id)||{id,title:slides[i]?.dataset.title||`第 ${i+1} 页`,minutes:0,purpose:'待补充本页目的',talk:['待补充讲述要点'],transition:'待补充转场'};
|
||||
}
|
||||
function slideLabel(i){return `${i+1} / ${total} · ${noteAt(i).title||slideId(i)}`;}
|
||||
function generatedNote(n){
|
||||
const talk=(n.talk||[]).map(x=>`• ${x}`).join('\n');
|
||||
return `【本页目的】\n${n.purpose||''}\n\n【讲述要点】\n${talk}\n\n【转场】\n${n.transition||''}${n.cue?`\n\n【舞台提示】\n${n.cue}`:''}`;
|
||||
}
|
||||
function noteStorageKey(id){return `guizang-ppt-note:${location.pathname}:${id}`;}
|
||||
function frameUrl(targetMode,i){
|
||||
const url=new URL(location.href);url.hash='';
|
||||
url.searchParams.set('mode',targetMode);url.searchParams.set('session',session);url.searchParams.set('slide',String(i+1));
|
||||
return url.href;
|
||||
}
|
||||
function mountShell(){
|
||||
if(document.getElementById('ppt-presenter'))return;
|
||||
document.body.insertAdjacentHTML('beforeend',`<div id="ppt-presenter" aria-label="演讲者视图">
|
||||
<div class="ppt-presenter-top"><div><div class="ppt-presenter-brand">演讲者视图</div><div class="ppt-presenter-meta"><span id="ppt-plan"></span></div></div><div class="ppt-presenter-meta"><span id="ppt-sync" class="ppt-sync" data-state="disconnected" title="表示观众页面的软件同步状态,不代表 HDMI 等物理线缆状态">观众屏 · 未连接</span><button id="ppt-reopen" class="ppt-ui-btn" type="button">重新打开观众屏</button><button id="ppt-exit" class="ppt-ui-btn" type="button">退出</button></div></div>
|
||||
<div class="ppt-presenter-main"><div class="ppt-preview-stack"><div class="ppt-stage-card"><div class="ppt-stage-label"><span>当前页</span><span id="ppt-current-label"></span></div><iframe id="ppt-current-frame" class="ppt-frame" title="当前页预览"></iframe></div><div class="ppt-stage-card" id="ppt-next-card"><div class="ppt-stage-label"><span>下一页</span><span id="ppt-next-label"></span></div><iframe id="ppt-next-frame" class="ppt-frame ppt-next-frame" title="下一页预览"></iframe></div></div>
|
||||
<div class="ppt-notes-card"><div class="ppt-notes-head"><div id="ppt-notes-title" class="ppt-notes-title"></div><div class="ppt-notes-sub"><span id="ppt-note-meta"></span><span class="ppt-notes-tools"><span id="ppt-saved" class="ppt-saved">已保存</span><button id="ppt-note-smaller" class="ppt-ui-btn" type="button" title="缩小备注字号">A−</button><button id="ppt-note-larger" class="ppt-ui-btn" type="button" title="放大备注字号">A+</button></span></div></div><textarea id="ppt-note-editor" class="ppt-note-editor" spellcheck="false" aria-label="本页演讲备注"></textarea></div></div>
|
||||
<div class="ppt-presenter-bottom"><div class="ppt-timer"><span id="ppt-clock" class="ppt-clock">00:00</span><button id="ppt-timer-toggle" class="ppt-ui-btn" type="button">开始计时</button><button id="ppt-timer-reset" class="ppt-ui-btn" type="button">重置</button></div><div class="ppt-controls"><button id="ppt-first" class="ppt-ui-btn" type="button">首页</button><button id="ppt-prev" class="ppt-ui-btn" type="button">上一页</button><button id="ppt-grid" class="ppt-ui-btn ppt-primary" type="button">总览</button><button id="ppt-next" class="ppt-ui-btn" type="button">下一页</button><button id="ppt-last" class="ppt-ui-btn" type="button">尾页</button></div><div class="ppt-page-state"><strong id="ppt-page-title"></strong><span id="ppt-page-count"></span></div></div>
|
||||
</div>`);
|
||||
document.getElementById('ppt-reopen').onclick=reopenAudience;
|
||||
document.getElementById('ppt-exit').onclick=exitPresenter;
|
||||
document.getElementById('ppt-first').onclick=()=>go(0,{force:true});
|
||||
document.getElementById('ppt-prev').onclick=()=>go(idx-1,{force:true});
|
||||
document.getElementById('ppt-grid').onclick=toggleOverview;
|
||||
document.getElementById('ppt-next').onclick=()=>go(idx+1,{force:true});
|
||||
document.getElementById('ppt-last').onclick=()=>go(idx===total-1?0:total-1,{force:true});
|
||||
document.getElementById('ppt-timer-toggle').onclick=toggleTimer;
|
||||
document.getElementById('ppt-timer-reset').onclick=resetTimer;
|
||||
document.getElementById('ppt-note-smaller').onclick=()=>setNoteSize(noteSize-1);
|
||||
document.getElementById('ppt-note-larger').onclick=()=>setNoteSize(noteSize+1);
|
||||
document.getElementById('ppt-note-editor').addEventListener('input',saveNote);
|
||||
setNoteSize(noteSize,false);
|
||||
}
|
||||
function updateShell(){
|
||||
if(mode!=='presenter')return;mountShell();
|
||||
const n=noteAt(idx),next=Math.min(idx+1,total-1),id=slideId(idx),editor=document.getElementById('ppt-note-editor');
|
||||
const currentFrame=document.getElementById('ppt-current-frame'),nextFrame=document.getElementById('ppt-next-frame');
|
||||
if(currentFrame.dataset.slide!==String(idx)){currentFrame.src=frameUrl('preview',idx);currentFrame.dataset.slide=String(idx);}
|
||||
if(nextFrame.dataset.slide!==String(next)){nextFrame.src=frameUrl('preview',next);nextFrame.dataset.slide=String(next);}
|
||||
document.getElementById('ppt-current-label').textContent=`${idx+1} / ${total}`;
|
||||
document.getElementById('ppt-next-label').textContent=idx===total-1?'已到尾页':`${next+1} / ${total}`;
|
||||
document.getElementById('ppt-notes-title').textContent=n.title||`第 ${idx+1} 页`;
|
||||
document.getElementById('ppt-note-meta').textContent=`建议 ${Number(n.minutes||0).toFixed(1)} 分钟 · ${id}`;
|
||||
document.getElementById('ppt-page-title').textContent=n.title||`第 ${idx+1} 页`;
|
||||
document.getElementById('ppt-page-count').textContent=`第 ${idx+1} / ${total} 页 · ${Math.round(((idx+1)/total)*100)}%`;
|
||||
document.getElementById('ppt-plan').textContent=`计划 ${notes().reduce((s,x)=>s+Number(x.minutes||0),0).toFixed(1)} 分钟`;
|
||||
document.getElementById('ppt-first').disabled=idx===0;document.getElementById('ppt-prev').disabled=idx===0;document.getElementById('ppt-next').disabled=idx===total-1;
|
||||
document.getElementById('ppt-last').textContent=idx===total-1?'重新开始':'尾页';
|
||||
if(renderedNoteId!==id){renderedNoteId=id;editor.value=localStorage.getItem(noteStorageKey(id))??generatedNote(n);editor.scrollTop=0;document.getElementById('ppt-saved').textContent='已保存';}
|
||||
updateSyncStatus();
|
||||
}
|
||||
function saveNote(){
|
||||
const saved=document.getElementById('ppt-saved');saved.textContent='保存中…';saved.classList.remove('ppt-saved');clearTimeout(saveTimer);
|
||||
saveTimer=setTimeout(()=>{localStorage.setItem(noteStorageKey(renderedNoteId),document.getElementById('ppt-note-editor').value);saved.textContent='已保存';saved.classList.add('ppt-saved');},300);
|
||||
}
|
||||
function setNoteSize(value,persist=true){
|
||||
noteSize=Math.max(14,Math.min(26,value));document.documentElement.style.setProperty('--ppt-note-size',`${noteSize}px`);
|
||||
if(persist)localStorage.setItem('guizang-ppt-note-size',String(noteSize));
|
||||
}
|
||||
function currentElapsed(){return elapsed+(timerRunning?Date.now()-timerStartedAt:0);}
|
||||
function renderTimer(){const sec=Math.floor(currentElapsed()/1000),clock=document.getElementById('ppt-clock');if(clock)clock.textContent=`${String(Math.floor(sec/60)).padStart(2,'0')}:${String(sec%60).padStart(2,'0')}`;}
|
||||
function toggleTimer(){if(timerRunning){elapsed=currentElapsed();timerRunning=false;}else{timerStartedAt=Date.now();timerRunning=true;}document.getElementById('ppt-timer-toggle').textContent=timerRunning?'暂停':'继续';renderTimer();}
|
||||
function resetTimer(){elapsed=0;timerRunning=false;timerStartedAt=0;document.getElementById('ppt-timer-toggle').textContent='开始计时';renderTimer();}
|
||||
|
||||
function initTransport(){
|
||||
if(transportReady)return;transportReady=true;
|
||||
if('BroadcastChannel'in window){try{channel=new BroadcastChannel(channelName());channel.onmessage=e=>receive(e.data);}catch(e){}}
|
||||
addEventListener('message',e=>receive(e.data));
|
||||
addEventListener('storage',e=>{if(e.key===syncKey()&&e.newValue){try{receive(JSON.parse(e.newValue));}catch(err){}}});
|
||||
}
|
||||
function emit(payload){
|
||||
const message={__guizangPptSync:1,session,role:mode,...payload,at:Date.now(),nonce:Math.random().toString(36).slice(2)};
|
||||
try{channel?.postMessage(message);}catch(e){}
|
||||
try{localStorage.setItem(syncKey(),JSON.stringify(message));}catch(e){}
|
||||
try{if(mode==='presenter'&&audienceWindow&&!audienceWindow.closed)audienceWindow.postMessage(message,'*');}catch(e){}
|
||||
try{if(mode==='audience'&&window.opener&&!window.opener.closed)window.opener.postMessage(message,'*');}catch(e){}
|
||||
}
|
||||
function receive(message){
|
||||
if(!message||message.__guizangPptSync!==1||message.session!==session||message.role===mode)return;
|
||||
if(mode==='presenter'){
|
||||
if(message.type==='ready'){popupBlocked=false;sendGoto(true);}
|
||||
if(message.type==='ack'){lastAckAt=Date.now();lastAckIndex=Number(message.index);lastAckSeq=Number(message.seq);updateSyncStatus();}
|
||||
return;
|
||||
}
|
||||
if(mode==='audience'&&(message.type==='goto'||message.type==='ping')){
|
||||
if(message.type==='goto')go(Number(message.index),{force:true});
|
||||
emit({type:'ack',index:idx,seq:Number(message.seq)});
|
||||
}
|
||||
}
|
||||
function sendGoto(force=false){if(mode!=='presenter')return;if(force||lastAckIndex!==idx)seq++;emit({type:'goto',index:idx,seq});updateSyncStatus();}
|
||||
function syncState(){
|
||||
if(popupBlocked)return'blocked';
|
||||
if(audienceWindow&&audienceWindow.closed)return'disconnected';
|
||||
if(!lastAckAt)return audienceOpenedAt&&Date.now()-audienceOpenedAt<2600?'connecting':(audienceOpenedAt?'unsynced':'disconnected');
|
||||
if(Date.now()-lastAckAt>2600||lastAckIndex!==idx||lastAckSeq<seq)return'unsynced';
|
||||
return'synced';
|
||||
}
|
||||
function updateSyncStatus(){
|
||||
const el=document.getElementById('ppt-sync');if(!el)return;const state=syncState();
|
||||
const labels={blocked:'弹窗被拦截',disconnected:'未连接',connecting:'连接中',unsynced:'未同步',synced:`已同步 · 第 ${idx+1} 页`};
|
||||
el.dataset.state=state;el.textContent=`观众屏 · ${labels[state]}`;
|
||||
}
|
||||
function reopenAudience(){
|
||||
initTransport();popupBlocked=false;audienceOpenedAt=Date.now();lastAckAt=0;lastAckIndex=-1;lastAckSeq=-1;
|
||||
audienceWindow=window.open(frameUrl('audience',idx),`guizang-ppt-audience-${session}`,'popup=yes,width=1440,height=900');
|
||||
if(!audienceWindow)popupBlocked=true;updateSyncStatus();
|
||||
}
|
||||
function enterPresenter(openAudience=true){
|
||||
if(mode==='presenter')return;lowPowerBeforePresenter=window.__lowPowerMode;mode='presenter';initTransport();mountShell();
|
||||
document.body.classList.remove('ppt-audience','ppt-preview');document.body.classList.add('ppt-presenter');document.title=`演讲者视图 · ${originalTitle}`;
|
||||
window.__setLowPowerMode?.(true,{persist:false});updateShell();if(openAudience)reopenAudience();
|
||||
}
|
||||
function exitPresenter(){
|
||||
emit({type:'bye'});mode='deck';document.body.classList.remove('ppt-presenter');document.title=originalTitle;window.__setLowPowerMode?.(lowPowerBeforePresenter,{persist:false});
|
||||
}
|
||||
function initAudience(){mode='audience';document.body.classList.add('ppt-audience');document.title=`观众屏 · ${originalTitle}`;initTransport();emit({type:'ready',index:idx});}
|
||||
function initPreview(){mode='preview';document.body.classList.add('ppt-preview');window.__setLowPowerMode?.(true,{persist:false});}
|
||||
function onSlideChange(){if(mode==='presenter'){updateShell();sendGoto();}}
|
||||
function handleKey(e){
|
||||
const editable=e.target?.matches?.('input,textarea,[contenteditable="true"]');
|
||||
if(editable){if(e.key==='Escape')e.target.blur();return true;}
|
||||
if(mode==='audience'||mode==='preview'){e.preventDefault();return true;}
|
||||
if(mode==='deck'&&e.key?.toLowerCase()==='p'&&!e.metaKey&&!e.ctrlKey&&!e.altKey){e.preventDefault();enterPresenter(true);return true;}
|
||||
if(mode!=='presenter')return false;
|
||||
if(e.key==='Escape'){e.preventDefault();toggleOverview();return true;}
|
||||
if(overviewOn)return true;
|
||||
if(['ArrowRight','PageDown',' ','ArrowDown'].includes(e.key)){e.preventDefault();go(idx+1,{force:true});return true;}
|
||||
if(['ArrowLeft','PageUp','ArrowUp'].includes(e.key)){e.preventDefault();go(idx-1,{force:true});return true;}
|
||||
if(e.key==='Home'){e.preventDefault();go(0,{force:true});return true;}
|
||||
if(e.key==='End'){e.preventDefault();go(total-1,{force:true});return true;}
|
||||
return false;
|
||||
}
|
||||
function initMode(){
|
||||
document.getElementById('ppt-low-power-btn')?.addEventListener('click',()=>window.__setLowPowerMode(!window.__lowPowerMode));
|
||||
document.getElementById('ppt-overview-btn')?.addEventListener('click',toggleOverview);
|
||||
document.getElementById('ppt-presenter-btn')?.addEventListener('click',()=>enterPresenter(true));
|
||||
if(requestedMode==='audience')initAudience();else if(requestedMode==='preview')initPreview();else if(requestedMode==='presenter')enterPresenter(false);
|
||||
}
|
||||
setInterval(()=>{renderTimer();if(mode==='presenter'){if(audienceWindow?.closed)updateSyncStatus();emit({type:'ping',index:idx,seq});updateSyncStatus();}},1000);
|
||||
return{initMode,onSlideChange,handleKey,slideLabel,enterPresenter,reopenAudience};
|
||||
})();
|
||||
|
||||
addEventListener('keydown',e=>{
|
||||
if(window.__pptPresenter?.handleKey(e))return;
|
||||
if(e.key==='Escape'){e.preventDefault();toggleOverview();return;}
|
||||
if(e.key && e.key.toLowerCase()==='b' && !e.metaKey && !e.ctrlKey && !e.altKey){
|
||||
e.preventDefault();
|
||||
@@ -1544,6 +1763,7 @@ addEventListener('touchend',e=>{
|
||||
const initialSlideParam = new URLSearchParams(location.search).get('slide');
|
||||
const initialSlide = initialSlideParam ? Number(initialSlideParam) - 1 : 0;
|
||||
go(Number.isFinite(initialSlide) ? initialSlide : 0);
|
||||
window.__pptPresenter?.initMode();
|
||||
</script>
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
<script>lucide.createIcons();</script>
|
||||
|
||||
+222
-8
@@ -195,10 +195,43 @@
|
||||
body.light-bg #nav{background:rgba(255,255,255,.25)}
|
||||
body.light-bg #nav .dot{background:rgba(var(--ink-rgb),.25)}
|
||||
body.light-bg #nav .dot.active{background:rgba(var(--ink-rgb),.9)}
|
||||
#hint{position:fixed;bottom:3vh;right:3vw;z-index:30;font-family:var(--mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;opacity:.4;mix-blend-mode:difference;color:#aaa}
|
||||
#hint{position:fixed;bottom:3vh;right:3vw;z-index:30;display:flex;align-items:center;gap:.65em;font-family:var(--mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;opacity:.4;mix-blend-mode:difference;color:#aaa}
|
||||
#hint button{all:unset;display:inline-flex;align-items:center;gap:.45em;cursor:pointer;color:inherit;white-space:nowrap}
|
||||
#hint button:hover,#hint button:focus-visible{opacity:1;text-decoration:underline;text-underline-offset:.35em}
|
||||
#hint .ppt-key{padding:.16em .38em;border:1px solid currentColor;border-radius:3px;letter-spacing:.08em}
|
||||
#hint .ppt-sep{opacity:.55}
|
||||
body.low-power #hint{opacity:.72;color:var(--paper);mix-blend-mode:normal}
|
||||
body.light-bg.low-power #hint{color:var(--ink)}
|
||||
|
||||
/* ============ 演讲者模式 ============ */
|
||||
body.ppt-audience #hint,body.ppt-audience #nav,
|
||||
body.ppt-preview #hint,body.ppt-preview #nav{display:none!important}
|
||||
body.ppt-audience{cursor:none}
|
||||
body.ppt-preview{pointer-events:none}
|
||||
body.ppt-presenter>#deck,body.ppt-presenter>#nav,body.ppt-presenter>#hint,
|
||||
body.ppt-presenter>.bg{visibility:hidden}
|
||||
#ppt-presenter{position:fixed;inset:0;z-index:90;display:none;min-width:1000px;min-height:680px;background:#101216;color:#f5f6f7;font-family:Inter,"Noto Sans SC",sans-serif}
|
||||
body.ppt-presenter #ppt-presenter{display:grid;grid-template-rows:auto 1fr auto}
|
||||
.ppt-presenter-top{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:18px 24px;border-bottom:1px solid #2b2e35;background:#15181d}
|
||||
.ppt-presenter-brand{font-weight:650;letter-spacing:.02em}.ppt-presenter-meta{display:flex;align-items:center;gap:12px;color:#aeb4be;font-size:14px}
|
||||
.ppt-sync{display:inline-flex;align-items:center;gap:8px;padding:7px 11px;border:1px solid #363a43;border-radius:999px;color:#d2d6dc;background:#1b1e24}
|
||||
.ppt-sync::before{content:"";width:8px;height:8px;border-radius:50%;background:#818894}
|
||||
.ppt-sync[data-state="synced"]::before{background:#72df99;box-shadow:0 0 0 4px rgba(114,223,153,.12)}
|
||||
.ppt-sync[data-state="connecting"]::before{background:#f0c766}.ppt-sync[data-state="unsynced"]::before,.ppt-sync[data-state="blocked"]::before{background:#ff9e64}.ppt-sync[data-state="disconnected"]::before{background:#ff6b72}
|
||||
.ppt-ui-btn{appearance:none;border:1px solid #3a3e48;background:#20242a;color:#f6f7f8;border-radius:8px;padding:9px 13px;font:600 14px/1 Inter,"Noto Sans SC",sans-serif;cursor:pointer}
|
||||
.ppt-ui-btn:hover{background:#2a2f37;border-color:#525864}.ppt-ui-btn:focus-visible{outline:2px solid #84a8ff;outline-offset:2px}.ppt-ui-btn:disabled{opacity:.28;cursor:not-allowed}
|
||||
.ppt-presenter-main{display:grid;grid-template-columns:minmax(0,1.75fr) minmax(320px,.75fr);gap:18px;min-height:0;padding:18px 24px}
|
||||
.ppt-preview-stack{display:grid;grid-template-columns:minmax(0,1fr) minmax(240px,.42fr);gap:16px;min-width:0;align-items:start}
|
||||
.ppt-stage-card,.ppt-notes-card{background:#171a20;border:1px solid #2b2f37;border-radius:12px;overflow:hidden}
|
||||
.ppt-stage-label{display:flex;justify-content:space-between;align-items:center;padding:11px 14px;color:#aeb4be;font-size:13px;border-bottom:1px solid #292d34}
|
||||
.ppt-frame{display:block;width:100%;aspect-ratio:16/9;border:0;background:#08090b}.ppt-next-frame{opacity:.86}
|
||||
.ppt-notes-card{display:grid;grid-template-rows:auto 1fr;min-height:0}.ppt-notes-head{padding:16px 18px;border-bottom:1px solid #292d34}.ppt-notes-title{font-size:20px;font-weight:650;margin-bottom:7px}.ppt-notes-sub{display:flex;align-items:center;justify-content:space-between;gap:10px;color:#9fa6b1;font-size:13px}
|
||||
.ppt-notes-tools{display:flex;align-items:center;gap:6px}.ppt-note-editor{width:100%;height:100%;min-height:0;resize:none;border:0;outline:0;background:#171a20;color:#edf0f3;padding:18px;font:400 var(--ppt-note-size,17px)/1.72 Inter,"Noto Sans SC",sans-serif}
|
||||
.ppt-saved{color:#72df99}.ppt-presenter-bottom{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:20px;padding:16px 24px;border-top:1px solid #2b2e35;background:#15181d}
|
||||
.ppt-timer{display:flex;align-items:center;gap:9px}.ppt-clock{min-width:72px;font:600 24px/1 var(--mono,monospace);font-variant-numeric:tabular-nums}.ppt-controls{display:flex;align-items:center;gap:8px}.ppt-controls .ppt-ui-btn{min-width:72px}.ppt-controls .ppt-primary{background:#f2f4f6;color:#111318;border-color:#f2f4f6}.ppt-controls .ppt-primary:hover{background:#fff}.ppt-page-state{text-align:right;color:#aeb4be;font-size:14px}.ppt-page-state strong{display:block;color:#fff;font-size:18px;margin-bottom:3px}
|
||||
#overview{z-index:110!important}
|
||||
@media(max-width:1200px){.ppt-presenter-main{grid-template-columns:minmax(0,1.45fr) minmax(300px,.75fr)}.ppt-preview-stack{grid-template-columns:1fr}.ppt-preview-stack .ppt-stage-card:last-child{display:none}}
|
||||
|
||||
/* ============================================================
|
||||
============ LAYOUTS API · 面向 agent 的类(v2)============
|
||||
所有 layouts.md 中的骨架都基于下面这套命名。
|
||||
@@ -451,8 +484,8 @@
|
||||
const stored = localStorage.getItem(KEY);
|
||||
window.__lowPowerMode = stored === '1' || (stored === null && reduced);
|
||||
function updateHint(){
|
||||
const hint = document.getElementById('hint');
|
||||
if(hint) hint.textContent = `← → 翻页 · B ${window.__lowPowerMode ? '动态' : '静态'} · ESC 索引`;
|
||||
const label = document.querySelector('.ppt-low-power-label');
|
||||
if(label) label.textContent = window.__lowPowerMode ? '动态' : '静态';
|
||||
}
|
||||
window.__setLowPowerMode = function(on, opts={}){
|
||||
window.__lowPowerMode = !!on;
|
||||
@@ -472,7 +505,12 @@
|
||||
|
||||
<canvas id="bg-dark" class="bg"></canvas>
|
||||
<canvas id="bg-light" class="bg"></canvas>
|
||||
<div id="hint">← → 翻页 · B 静态 · ESC 索引</div>
|
||||
<div id="hint" role="toolbar" aria-label="快捷控制">
|
||||
<span>← → 翻页</span><span class="ppt-sep">·</span>
|
||||
<button id="ppt-low-power-btn" type="button"><span class="ppt-key">B</span><span class="ppt-low-power-label">静态</span></button><span class="ppt-sep">·</span>
|
||||
<button id="ppt-overview-btn" type="button"><span class="ppt-key">ESC</span>总览</button><span class="ppt-sep">·</span>
|
||||
<button id="ppt-presenter-btn" type="button"><span class="ppt-key">P</span>演讲模式</button>
|
||||
</div>
|
||||
|
||||
<div id="deck">
|
||||
|
||||
@@ -488,6 +526,19 @@
|
||||
|
||||
<div id="nav"></div>
|
||||
|
||||
<!-- 演讲备注:每页一条,id 必须与 section[data-slide-id] 一致。见 references/presenter-mode.md -->
|
||||
<script>
|
||||
const SPEAKER_NOTES = [
|
||||
/* {
|
||||
id: 'stable-slide-id', title: '页面标题', minutes: 1,
|
||||
purpose: '这一页要让观众理解什么',
|
||||
talk: ['只在台上说的补充信息', '例子、证据或语气提示', '不复述页面可见文字'],
|
||||
transition: '为什么下一页紧接着出现', cue: '可选:停顿 / Demo / 视频'
|
||||
} */
|
||||
];
|
||||
window.__SPEAKER_NOTES__ = SPEAKER_NOTES;
|
||||
</script>
|
||||
|
||||
<script>
|
||||
/* =============== WebGL 双背景 ===============
|
||||
深色页:Holographic Dispersion(全息色散 · 钛金暗流)—— 彩虹微扰、鼠标径向涟漪
|
||||
@@ -633,8 +684,8 @@ slides.forEach((s,i)=>{
|
||||
nav.appendChild(b);
|
||||
});
|
||||
|
||||
function go(n){
|
||||
if(lock)return;
|
||||
function go(n,opts={}){
|
||||
if(lock&&!opts.force)return;
|
||||
idx=Math.max(0,Math.min(total-1,n));
|
||||
window.__currentSlideIndex = idx;
|
||||
deck.style.transform=`translateX(${-idx*100}vw)`;
|
||||
@@ -645,7 +696,8 @@ function go(n){
|
||||
document.body.classList.toggle('light-bg',th==='light');
|
||||
/* 动效:翻页过渡中段触发当前页的入场动画(由 motion-boot 注册) */
|
||||
if(window.__playSlide) setTimeout(()=>window.__playSlide(idx), 450);
|
||||
lock=true;setTimeout(()=>lock=false,700);
|
||||
window.__pptPresenter?.onSlideChange(idx);
|
||||
if(!opts.force){lock=true;setTimeout(()=>lock=false,700);}
|
||||
}
|
||||
|
||||
/* =============== ESC 索引视图 =============== */
|
||||
@@ -671,7 +723,7 @@ function buildOverview(){
|
||||
wrap.appendChild(clone);
|
||||
const label=document.createElement('div');
|
||||
label.style.cssText='padding:6px 10px;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--paper);opacity:.7';
|
||||
label.textContent=(i+1)+' / '+total;
|
||||
label.textContent=window.__pptPresenter?.slideLabel(i) || ((i+1)+' / '+total);
|
||||
card.appendChild(wrap);
|
||||
card.appendChild(label);
|
||||
card.onclick=()=>{toggleOverview();go(i)};
|
||||
@@ -686,7 +738,168 @@ function toggleOverview(){
|
||||
else{ov.style.display='none';}
|
||||
}
|
||||
|
||||
/* =============== 演讲者模式 / 观众屏同步 =============== */
|
||||
window.__pptPresenter=(()=>{
|
||||
const query=new URLSearchParams(location.search);
|
||||
const requestedMode=query.get('mode')||'deck';
|
||||
const originalTitle=document.title;
|
||||
const notes=()=>Array.isArray(window.__SPEAKER_NOTES__)?window.__SPEAKER_NOTES__:[];
|
||||
const slideId=i=>slides[i]?.dataset.slideId||`slide-${String(i+1).padStart(2,'0')}`;
|
||||
let mode='deck',session=query.get('session')||Math.random().toString(36).slice(2),audienceWindow=null;
|
||||
let channel=null,transportReady=false,popupBlocked=false,audienceOpenedAt=0,lastAckAt=0,lastAckIndex=-1,lastAckSeq=-1,seq=0;
|
||||
let renderedNoteId='',saveTimer=0,noteSize=Number(localStorage.getItem('guizang-ppt-note-size')||17);
|
||||
let elapsed=0,timerRunning=false,timerStartedAt=0,lowPowerBeforePresenter=window.__lowPowerMode;
|
||||
const syncKey=()=>`guizang-ppt-sync:${location.pathname}:${session}`;
|
||||
const channelName=()=>`guizang-ppt:${location.pathname}:${session}`;
|
||||
|
||||
function noteAt(i){
|
||||
const id=slideId(i);
|
||||
return notes().find(n=>n.id===id)||{id,title:slides[i]?.dataset.title||`第 ${i+1} 页`,minutes:0,purpose:'待补充本页目的',talk:['待补充讲述要点'],transition:'待补充转场'};
|
||||
}
|
||||
function slideLabel(i){return `${i+1} / ${total} · ${noteAt(i).title||slideId(i)}`;}
|
||||
function generatedNote(n){
|
||||
const talk=(n.talk||[]).map(x=>`• ${x}`).join('\n');
|
||||
return `【本页目的】\n${n.purpose||''}\n\n【讲述要点】\n${talk}\n\n【转场】\n${n.transition||''}${n.cue?`\n\n【舞台提示】\n${n.cue}`:''}`;
|
||||
}
|
||||
function noteStorageKey(id){return `guizang-ppt-note:${location.pathname}:${id}`;}
|
||||
function frameUrl(targetMode,i){
|
||||
const url=new URL(location.href);url.hash='';
|
||||
url.searchParams.set('mode',targetMode);url.searchParams.set('session',session);url.searchParams.set('slide',String(i+1));
|
||||
return url.href;
|
||||
}
|
||||
function mountShell(){
|
||||
if(document.getElementById('ppt-presenter'))return;
|
||||
document.body.insertAdjacentHTML('beforeend',`<div id="ppt-presenter" aria-label="演讲者视图">
|
||||
<div class="ppt-presenter-top"><div><div class="ppt-presenter-brand">演讲者视图</div><div class="ppt-presenter-meta"><span id="ppt-plan"></span></div></div><div class="ppt-presenter-meta"><span id="ppt-sync" class="ppt-sync" data-state="disconnected" title="表示观众页面的软件同步状态,不代表 HDMI 等物理线缆状态">观众屏 · 未连接</span><button id="ppt-reopen" class="ppt-ui-btn" type="button">重新打开观众屏</button><button id="ppt-exit" class="ppt-ui-btn" type="button">退出</button></div></div>
|
||||
<div class="ppt-presenter-main"><div class="ppt-preview-stack"><div class="ppt-stage-card"><div class="ppt-stage-label"><span>当前页</span><span id="ppt-current-label"></span></div><iframe id="ppt-current-frame" class="ppt-frame" title="当前页预览"></iframe></div><div class="ppt-stage-card" id="ppt-next-card"><div class="ppt-stage-label"><span>下一页</span><span id="ppt-next-label"></span></div><iframe id="ppt-next-frame" class="ppt-frame ppt-next-frame" title="下一页预览"></iframe></div></div>
|
||||
<div class="ppt-notes-card"><div class="ppt-notes-head"><div id="ppt-notes-title" class="ppt-notes-title"></div><div class="ppt-notes-sub"><span id="ppt-note-meta"></span><span class="ppt-notes-tools"><span id="ppt-saved" class="ppt-saved">已保存</span><button id="ppt-note-smaller" class="ppt-ui-btn" type="button" title="缩小备注字号">A−</button><button id="ppt-note-larger" class="ppt-ui-btn" type="button" title="放大备注字号">A+</button></span></div></div><textarea id="ppt-note-editor" class="ppt-note-editor" spellcheck="false" aria-label="本页演讲备注"></textarea></div></div>
|
||||
<div class="ppt-presenter-bottom"><div class="ppt-timer"><span id="ppt-clock" class="ppt-clock">00:00</span><button id="ppt-timer-toggle" class="ppt-ui-btn" type="button">开始计时</button><button id="ppt-timer-reset" class="ppt-ui-btn" type="button">重置</button></div><div class="ppt-controls"><button id="ppt-first" class="ppt-ui-btn" type="button">首页</button><button id="ppt-prev" class="ppt-ui-btn" type="button">上一页</button><button id="ppt-grid" class="ppt-ui-btn ppt-primary" type="button">总览</button><button id="ppt-next" class="ppt-ui-btn" type="button">下一页</button><button id="ppt-last" class="ppt-ui-btn" type="button">尾页</button></div><div class="ppt-page-state"><strong id="ppt-page-title"></strong><span id="ppt-page-count"></span></div></div>
|
||||
</div>`);
|
||||
document.getElementById('ppt-reopen').onclick=reopenAudience;
|
||||
document.getElementById('ppt-exit').onclick=exitPresenter;
|
||||
document.getElementById('ppt-first').onclick=()=>go(0,{force:true});
|
||||
document.getElementById('ppt-prev').onclick=()=>go(idx-1,{force:true});
|
||||
document.getElementById('ppt-grid').onclick=toggleOverview;
|
||||
document.getElementById('ppt-next').onclick=()=>go(idx+1,{force:true});
|
||||
document.getElementById('ppt-last').onclick=()=>go(idx===total-1?0:total-1,{force:true});
|
||||
document.getElementById('ppt-timer-toggle').onclick=toggleTimer;
|
||||
document.getElementById('ppt-timer-reset').onclick=resetTimer;
|
||||
document.getElementById('ppt-note-smaller').onclick=()=>setNoteSize(noteSize-1);
|
||||
document.getElementById('ppt-note-larger').onclick=()=>setNoteSize(noteSize+1);
|
||||
document.getElementById('ppt-note-editor').addEventListener('input',saveNote);
|
||||
setNoteSize(noteSize,false);
|
||||
}
|
||||
function updateShell(){
|
||||
if(mode!=='presenter')return;mountShell();
|
||||
const n=noteAt(idx),next=Math.min(idx+1,total-1),id=slideId(idx),editor=document.getElementById('ppt-note-editor');
|
||||
const currentFrame=document.getElementById('ppt-current-frame'),nextFrame=document.getElementById('ppt-next-frame');
|
||||
if(currentFrame.dataset.slide!==String(idx)){currentFrame.src=frameUrl('preview',idx);currentFrame.dataset.slide=String(idx);}
|
||||
if(nextFrame.dataset.slide!==String(next)){nextFrame.src=frameUrl('preview',next);nextFrame.dataset.slide=String(next);}
|
||||
document.getElementById('ppt-current-label').textContent=`${idx+1} / ${total}`;
|
||||
document.getElementById('ppt-next-label').textContent=idx===total-1?'已到尾页':`${next+1} / ${total}`;
|
||||
document.getElementById('ppt-notes-title').textContent=n.title||`第 ${idx+1} 页`;
|
||||
document.getElementById('ppt-note-meta').textContent=`建议 ${Number(n.minutes||0).toFixed(1)} 分钟 · ${id}`;
|
||||
document.getElementById('ppt-page-title').textContent=n.title||`第 ${idx+1} 页`;
|
||||
document.getElementById('ppt-page-count').textContent=`第 ${idx+1} / ${total} 页 · ${Math.round(((idx+1)/total)*100)}%`;
|
||||
document.getElementById('ppt-plan').textContent=`计划 ${notes().reduce((s,x)=>s+Number(x.minutes||0),0).toFixed(1)} 分钟`;
|
||||
document.getElementById('ppt-first').disabled=idx===0;document.getElementById('ppt-prev').disabled=idx===0;document.getElementById('ppt-next').disabled=idx===total-1;
|
||||
document.getElementById('ppt-last').textContent=idx===total-1?'重新开始':'尾页';
|
||||
if(renderedNoteId!==id){renderedNoteId=id;editor.value=localStorage.getItem(noteStorageKey(id))??generatedNote(n);editor.scrollTop=0;document.getElementById('ppt-saved').textContent='已保存';}
|
||||
updateSyncStatus();
|
||||
}
|
||||
function saveNote(){
|
||||
const saved=document.getElementById('ppt-saved');saved.textContent='保存中…';saved.classList.remove('ppt-saved');clearTimeout(saveTimer);
|
||||
saveTimer=setTimeout(()=>{localStorage.setItem(noteStorageKey(renderedNoteId),document.getElementById('ppt-note-editor').value);saved.textContent='已保存';saved.classList.add('ppt-saved');},300);
|
||||
}
|
||||
function setNoteSize(value,persist=true){
|
||||
noteSize=Math.max(14,Math.min(26,value));document.documentElement.style.setProperty('--ppt-note-size',`${noteSize}px`);
|
||||
if(persist)localStorage.setItem('guizang-ppt-note-size',String(noteSize));
|
||||
}
|
||||
function currentElapsed(){return elapsed+(timerRunning?Date.now()-timerStartedAt:0);}
|
||||
function renderTimer(){const sec=Math.floor(currentElapsed()/1000),clock=document.getElementById('ppt-clock');if(clock)clock.textContent=`${String(Math.floor(sec/60)).padStart(2,'0')}:${String(sec%60).padStart(2,'0')}`;}
|
||||
function toggleTimer(){if(timerRunning){elapsed=currentElapsed();timerRunning=false;}else{timerStartedAt=Date.now();timerRunning=true;}document.getElementById('ppt-timer-toggle').textContent=timerRunning?'暂停':'继续';renderTimer();}
|
||||
function resetTimer(){elapsed=0;timerRunning=false;timerStartedAt=0;document.getElementById('ppt-timer-toggle').textContent='开始计时';renderTimer();}
|
||||
|
||||
function initTransport(){
|
||||
if(transportReady)return;transportReady=true;
|
||||
if('BroadcastChannel'in window){try{channel=new BroadcastChannel(channelName());channel.onmessage=e=>receive(e.data);}catch(e){}}
|
||||
addEventListener('message',e=>receive(e.data));
|
||||
addEventListener('storage',e=>{if(e.key===syncKey()&&e.newValue){try{receive(JSON.parse(e.newValue));}catch(err){}}});
|
||||
}
|
||||
function emit(payload){
|
||||
const message={__guizangPptSync:1,session,role:mode,...payload,at:Date.now(),nonce:Math.random().toString(36).slice(2)};
|
||||
try{channel?.postMessage(message);}catch(e){}
|
||||
try{localStorage.setItem(syncKey(),JSON.stringify(message));}catch(e){}
|
||||
try{if(mode==='presenter'&&audienceWindow&&!audienceWindow.closed)audienceWindow.postMessage(message,'*');}catch(e){}
|
||||
try{if(mode==='audience'&&window.opener&&!window.opener.closed)window.opener.postMessage(message,'*');}catch(e){}
|
||||
}
|
||||
function receive(message){
|
||||
if(!message||message.__guizangPptSync!==1||message.session!==session||message.role===mode)return;
|
||||
if(mode==='presenter'){
|
||||
if(message.type==='ready'){popupBlocked=false;sendGoto(true);}
|
||||
if(message.type==='ack'){lastAckAt=Date.now();lastAckIndex=Number(message.index);lastAckSeq=Number(message.seq);updateSyncStatus();}
|
||||
return;
|
||||
}
|
||||
if(mode==='audience'&&(message.type==='goto'||message.type==='ping')){
|
||||
if(message.type==='goto')go(Number(message.index),{force:true});
|
||||
emit({type:'ack',index:idx,seq:Number(message.seq)});
|
||||
}
|
||||
}
|
||||
function sendGoto(force=false){if(mode!=='presenter')return;if(force||lastAckIndex!==idx)seq++;emit({type:'goto',index:idx,seq});updateSyncStatus();}
|
||||
function syncState(){
|
||||
if(popupBlocked)return'blocked';
|
||||
if(audienceWindow&&audienceWindow.closed)return'disconnected';
|
||||
if(!lastAckAt)return audienceOpenedAt&&Date.now()-audienceOpenedAt<2600?'connecting':(audienceOpenedAt?'unsynced':'disconnected');
|
||||
if(Date.now()-lastAckAt>2600||lastAckIndex!==idx||lastAckSeq<seq)return'unsynced';
|
||||
return'synced';
|
||||
}
|
||||
function updateSyncStatus(){
|
||||
const el=document.getElementById('ppt-sync');if(!el)return;const state=syncState();
|
||||
const labels={blocked:'弹窗被拦截',disconnected:'未连接',connecting:'连接中',unsynced:'未同步',synced:`已同步 · 第 ${idx+1} 页`};
|
||||
el.dataset.state=state;el.textContent=`观众屏 · ${labels[state]}`;
|
||||
}
|
||||
function reopenAudience(){
|
||||
initTransport();popupBlocked=false;audienceOpenedAt=Date.now();lastAckAt=0;lastAckIndex=-1;lastAckSeq=-1;
|
||||
audienceWindow=window.open(frameUrl('audience',idx),`guizang-ppt-audience-${session}`,'popup=yes,width=1440,height=900');
|
||||
if(!audienceWindow)popupBlocked=true;updateSyncStatus();
|
||||
}
|
||||
function enterPresenter(openAudience=true){
|
||||
if(mode==='presenter')return;lowPowerBeforePresenter=window.__lowPowerMode;mode='presenter';initTransport();mountShell();
|
||||
document.body.classList.remove('ppt-audience','ppt-preview');document.body.classList.add('ppt-presenter');document.title=`演讲者视图 · ${originalTitle}`;
|
||||
window.__setLowPowerMode?.(true,{persist:false});updateShell();if(openAudience)reopenAudience();
|
||||
}
|
||||
function exitPresenter(){
|
||||
emit({type:'bye'});mode='deck';document.body.classList.remove('ppt-presenter');document.title=originalTitle;window.__setLowPowerMode?.(lowPowerBeforePresenter,{persist:false});
|
||||
}
|
||||
function initAudience(){mode='audience';document.body.classList.add('ppt-audience');document.title=`观众屏 · ${originalTitle}`;initTransport();emit({type:'ready',index:idx});}
|
||||
function initPreview(){mode='preview';document.body.classList.add('ppt-preview');window.__setLowPowerMode?.(true,{persist:false});}
|
||||
function onSlideChange(){if(mode==='presenter'){updateShell();sendGoto();}}
|
||||
function handleKey(e){
|
||||
const editable=e.target?.matches?.('input,textarea,[contenteditable="true"]');
|
||||
if(editable){if(e.key==='Escape')e.target.blur();return true;}
|
||||
if(mode==='audience'||mode==='preview'){e.preventDefault();return true;}
|
||||
if(mode==='deck'&&e.key?.toLowerCase()==='p'&&!e.metaKey&&!e.ctrlKey&&!e.altKey){e.preventDefault();enterPresenter(true);return true;}
|
||||
if(mode!=='presenter')return false;
|
||||
if(e.key==='Escape'){e.preventDefault();toggleOverview();return true;}
|
||||
if(overviewOn)return true;
|
||||
if(['ArrowRight','PageDown',' ','ArrowDown'].includes(e.key)){e.preventDefault();go(idx+1,{force:true});return true;}
|
||||
if(['ArrowLeft','PageUp','ArrowUp'].includes(e.key)){e.preventDefault();go(idx-1,{force:true});return true;}
|
||||
if(e.key==='Home'){e.preventDefault();go(0,{force:true});return true;}
|
||||
if(e.key==='End'){e.preventDefault();go(total-1,{force:true});return true;}
|
||||
return false;
|
||||
}
|
||||
function initMode(){
|
||||
document.getElementById('ppt-low-power-btn')?.addEventListener('click',()=>window.__setLowPowerMode(!window.__lowPowerMode));
|
||||
document.getElementById('ppt-overview-btn')?.addEventListener('click',toggleOverview);
|
||||
document.getElementById('ppt-presenter-btn')?.addEventListener('click',()=>enterPresenter(true));
|
||||
if(requestedMode==='audience')initAudience();else if(requestedMode==='preview')initPreview();else if(requestedMode==='presenter')enterPresenter(false);
|
||||
}
|
||||
setInterval(()=>{renderTimer();if(mode==='presenter'){if(audienceWindow?.closed)updateSyncStatus();emit({type:'ping',index:idx,seq});updateSyncStatus();}},1000);
|
||||
return{initMode,onSlideChange,handleKey,slideLabel,enterPresenter,reopenAudience};
|
||||
})();
|
||||
|
||||
addEventListener('keydown',e=>{
|
||||
if(window.__pptPresenter?.handleKey(e))return;
|
||||
if(e.key==='Escape'){e.preventDefault();toggleOverview();return;}
|
||||
if(e.key && e.key.toLowerCase()==='b' && !e.metaKey && !e.ctrlKey && !e.altKey){
|
||||
e.preventDefault();
|
||||
@@ -732,6 +945,7 @@ addEventListener('touchend',e=>{
|
||||
const initialSlideParam = new URLSearchParams(location.search).get('slide');
|
||||
const initialSlide = initialSlideParam ? Number(initialSlideParam) - 1 : 0;
|
||||
go(Number.isFinite(initialSlide) ? initialSlide : 0);
|
||||
window.__pptPresenter?.initMode();
|
||||
</script>
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
<script>lucide.createIcons();</script>
|
||||
|
||||
@@ -8,6 +8,26 @@
|
||||
|
||||
## 🔴 P0 · 一定不能犯的错
|
||||
|
||||
### 0-P. 演讲者模式必须可控、可恢复、备注不串页
|
||||
|
||||
**现象**:演讲者端已经翻页,观众屏仍停在上一页;观众窗口关闭后没有提示;页面重排后,用户改过的讲稿跑到了另一页;或演讲模式入口变成页面右上角的突兀按钮。
|
||||
|
||||
**做法**:
|
||||
- 演讲入口只放在右下角现有快捷控制区,显示 `P 演讲模式`。
|
||||
- 演讲者控制顺序固定为 `首页 / 上一页 / 总览 / 下一页 / 尾页`;尾页时最后一个按钮变为“重新开始”。
|
||||
- 观众屏必须显示软件链路状态:`连接中 / 已同步 / 未同步 / 未连接 / 弹窗被拦截`,并始终提供“重新打开观众屏”。
|
||||
- 每页必须有稳定、唯一的 `data-slide-id`;`SPEAKER_NOTES` 数量、顺序和 ID 必须逐页一致。
|
||||
- 备注本地编辑按页面 ID 保存;切页重置滚动位置并显示保存状态。
|
||||
- 浏览器只能确认观众页面是否同步,不能确认 HDMI/投影线物理连接;现场仍要目视检查外接屏。
|
||||
|
||||
**检查**:
|
||||
|
||||
```bash
|
||||
node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html
|
||||
```
|
||||
|
||||
浏览器里关闭观众窗口,确认状态变化;再点击“重新打开观众屏”,确认它恢复到演讲者当前页。
|
||||
|
||||
### 0-S. Swiss locked mode:正文页必须来自原始 22P
|
||||
|
||||
**现象**:颜色、字体看起来像 Swiss,但标题跑到中间、图片不在网格上、页面结构和原始 22P 完全不是一套东西。
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# 演讲者模式与演讲备注契约
|
||||
|
||||
制作任何需要现场讲述的 deck 时加载本文件。目标不是把页面文案复制到备注区,而是让页面、讲述和转场形成一条可以排练的叙事链。
|
||||
|
||||
## 1. 页面计划必须同时规划“看什么”和“说什么”
|
||||
|
||||
在写 HTML 前,先做一张表:
|
||||
|
||||
| 页码 | `data-slide-id` | 页面目的 | 观众屏可见信息 | 演讲者补充 | 分钟 | 转场 | cue |
|
||||
|---|---|---|---|---|---:|---|---|
|
||||
|
||||
- **观众屏可见信息**:观众此刻必须读到的结论、证据、结构或图像。
|
||||
- **演讲者补充**:背景、例子、判断依据、语气和细节,不要逐字复述页面。
|
||||
- **转场**:解释下一页为什么紧接着出现,不要只写“下一页”。
|
||||
- **cue**:可选的舞台动作,如停顿、提问、播放视频、切 Demo、提醒敏感信息。
|
||||
|
||||
默认生成“提词卡”,不是逐字稿。只有用户明确要求逐字稿时,才写完整口播。
|
||||
|
||||
## 2. 稳定的页面 ID
|
||||
|
||||
每一页都必须有唯一、语义化且稳定的 ID:
|
||||
|
||||
```html
|
||||
<section class="slide ..." data-slide-id="codepilot-capabilities">
|
||||
```
|
||||
|
||||
- 使用小写英文 slug:`cover`、`codepilot-capabilities`、`demo-workflow`、`closing`。
|
||||
- 页面重排时保留原 ID;只有页面语义彻底改变时才更换。
|
||||
- 不要用页码作为 ID。演讲者在浏览器里修改的备注按 ID 保存,稳定 ID 可避免重排后串页。
|
||||
|
||||
## 3. `SPEAKER_NOTES` 数据结构
|
||||
|
||||
在 deck 中保留一条与每页 ID 对应的记录,顺序必须与 slide 完全一致:
|
||||
|
||||
```js
|
||||
const SPEAKER_NOTES = [
|
||||
{
|
||||
id: 'codepilot-capabilities',
|
||||
title: 'CodePilot 能做什么',
|
||||
minutes: 0.9,
|
||||
purpose: '先让观众建立完整产品认知,再进入实现过程',
|
||||
talk: [
|
||||
'从用户动作讲能力,不先讲底层 Harness',
|
||||
'用首页、多模型、Skills 和侧边栏四张截图建立产品全貌',
|
||||
'强调模型、工具和上下文可以在同一工作流里协作'
|
||||
],
|
||||
transition: '观众知道产品长什么样后,再回答为什么要这样实现',
|
||||
cue: '按截图顺序指向四个能力区'
|
||||
}
|
||||
];
|
||||
window.__SPEAKER_NOTES__ = SPEAKER_NOTES;
|
||||
```
|
||||
|
||||
硬规则:
|
||||
|
||||
- `id`、`title`、`minutes`、`purpose`、`talk`、`transition` 必填,`cue` 可选。
|
||||
- `talk` 默认 3–5 条,每条只表达一个意思;封面、章节页和纯过渡页可以更短。
|
||||
- 不编造原素材没有的数字、事实或经历;信息缺失时标记“待补充”或向用户询问。
|
||||
- 总建议时长不超过用户时长的 90%,给停顿、现场互动和意外留出约 10% 缓冲。
|
||||
- 封面只负责建立问题与预期;Demo 页写清操作顺序和失败兜底;尾页回扣开场并给出行动点。
|
||||
|
||||
## 4. 演讲者界面行为
|
||||
|
||||
- 普通 deck 的右下角控制区显示 `P 演讲模式`,不单独悬浮一个抢眼按钮。
|
||||
- 点击后当前窗口进入演讲者视图,并打开独立观众屏。
|
||||
- 控制顺序固定为:`首页 / 上一页 / 总览 / 下一页 / 尾页`;到尾页后“尾页”变为“重新开始”。
|
||||
- 总览卡片显示页码、标题和当前页高亮,点击缩略图直接跳页;`ESC` 同样切换总览。
|
||||
- 计时器由用户显式开始,可暂停、继续、重置;不要进入模式就自动计时。
|
||||
- 备注编辑按 `data-slide-id` 存入 `localStorage`,显示保存状态;切页后备注滚动回顶部,并允许调整字号。
|
||||
- `Home` / `End`、方向键、PageUp/PageDown 与可见按钮保持一致;光标在备注编辑器中时不得触发翻页。
|
||||
|
||||
## 5. 观众屏同步与恢复
|
||||
|
||||
模板同时使用窗口 `postMessage`、`BroadcastChannel` 和 `storage` 事件同步。观众页在加载和每次翻页后回传确认,演讲者端显示:
|
||||
|
||||
- **连接中**:观众窗口已打开,尚未收到确认。
|
||||
- **已同步**:观众页确认的页码与演讲者页相同,心跳有效。
|
||||
- **未同步**:确认超时或两端页码不同。
|
||||
- **未连接**:观众窗口未打开或已关闭。
|
||||
- **弹窗被拦截**:浏览器阻止打开观众窗口。
|
||||
|
||||
“重新打开观众屏”必须始终可用,并在恢复后立即发送当前页。
|
||||
|
||||
浏览器只能确认观众**页面/窗口**的软件同步状态,不能检测 HDMI、转接器或投影仪线缆是否物理断开。现场仍应目视确认外接屏。
|
||||
|
||||
## 6. 生成后验证
|
||||
|
||||
```bash
|
||||
node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html
|
||||
node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html --target-minutes 30
|
||||
```
|
||||
|
||||
浏览器实测至少包含:进入演讲模式、弹窗允许/拦截、前后翻页、总览跳页、首页/尾页、尾页重新开始、观众窗口关闭后的状态变化、重新打开后恢复同步、备注保存与重排后不串页。
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const file=process.argv[2];
|
||||
const targetArg=process.argv.find(x=>x.startsWith('--target-minutes='));
|
||||
const targetIndex=process.argv.indexOf('--target-minutes');
|
||||
const targetMinutes=Number(targetArg?.split('=')[1]??(targetIndex>=0?process.argv[targetIndex+1]:NaN));
|
||||
|
||||
if(!file){
|
||||
console.error('Usage: node scripts/validate-presenter-mode.mjs <index.html> [--target-minutes 30]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const html=readFileSync(file,'utf8');
|
||||
const source=html.replace(/<!--[\s\S]*?-->/g,'');
|
||||
const errors=[];
|
||||
const warnings=[];
|
||||
|
||||
function extractArray(name){
|
||||
const marker=new RegExp(`(?:const|let|var)\\s+${name}\\s*=`).exec(source);
|
||||
if(!marker)return null;
|
||||
const start=source.indexOf('[',marker.index+marker[0].length);
|
||||
if(start<0)return null;
|
||||
let depth=0,quote='',escaped=false,lineComment=false,blockComment=false;
|
||||
for(let i=start;i<source.length;i++){
|
||||
const ch=source[i],next=source[i+1];
|
||||
if(lineComment){if(ch==='\n')lineComment=false;continue;}
|
||||
if(blockComment){if(ch==='*'&&next==='/'){blockComment=false;i++;}continue;}
|
||||
if(quote){if(escaped){escaped=false;continue;}if(ch==='\\'){escaped=true;continue;}if(ch===quote)quote='';continue;}
|
||||
if(ch==='/'&&next==='/'){lineComment=true;i++;continue;}
|
||||
if(ch==='/'&&next==='*'){blockComment=true;i++;continue;}
|
||||
if(ch==='"'||ch==="'"||ch==='`'){quote=ch;continue;}
|
||||
if(ch==='[')depth++;
|
||||
if(ch===']'&&--depth===0)return source.slice(start,i+1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const slideTags=[...source.matchAll(/<section\b(?=[^>]*\bclass="[^"]*\bslide\b[^"]*")[^>]*>/g)].map(m=>m[0]);
|
||||
const slideIds=slideTags.map((tag,i)=>tag.match(/\bdata-slide-id="([^"]+)"/)?.[1]||'');
|
||||
|
||||
slideIds.forEach((id,i)=>{
|
||||
if(!id)errors.push(`Slide ${i+1}: missing data-slide-id.`);
|
||||
else if(!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id))errors.push(`Slide ${i+1}: data-slide-id="${id}" must be a lowercase semantic slug.`);
|
||||
});
|
||||
const duplicates=slideIds.filter((id,i)=>id&&slideIds.indexOf(id)!==i);
|
||||
if(duplicates.length)errors.push(`Duplicate data-slide-id: ${[...new Set(duplicates)].join(', ')}`);
|
||||
|
||||
let speakerNotes=[];
|
||||
const arraySource=extractArray('SPEAKER_NOTES');
|
||||
if(!arraySource){
|
||||
errors.push('Missing const SPEAKER_NOTES = [...].');
|
||||
}else{
|
||||
try{speakerNotes=vm.runInNewContext(`(${arraySource})`,Object.create(null),{timeout:1000});}
|
||||
catch(error){errors.push(`SPEAKER_NOTES cannot be parsed: ${error.message}`);}
|
||||
}
|
||||
|
||||
if(!Array.isArray(speakerNotes))errors.push('SPEAKER_NOTES must be an array.');
|
||||
else{
|
||||
if(speakerNotes.length!==slideIds.length)errors.push(`Notes/slides mismatch: ${speakerNotes.length} notes for ${slideIds.length} slides.`);
|
||||
const noteIds=speakerNotes.map(n=>n?.id);
|
||||
const noteDupes=noteIds.filter((id,i)=>id&¬eIds.indexOf(id)!==i);
|
||||
if(noteDupes.length)errors.push(`Duplicate speaker-note id: ${[...new Set(noteDupes)].join(', ')}`);
|
||||
speakerNotes.forEach((note,i)=>{
|
||||
if(!note||typeof note!=='object'){errors.push(`Note ${i+1}: must be an object.`);return;}
|
||||
for(const field of ['id','title','purpose','transition'])if(typeof note[field]!=='string'||!note[field].trim())errors.push(`Note ${i+1}: ${field} is required.`);
|
||||
if(!Number.isFinite(Number(note.minutes))||Number(note.minutes)<=0)errors.push(`Note ${i+1}: minutes must be > 0.`);
|
||||
if(!Array.isArray(note.talk)||!note.talk.length)errors.push(`Note ${i+1}: talk must be a non-empty array.`);
|
||||
else{
|
||||
if(note.talk.some(x=>typeof x!=='string'||!x.trim()))errors.push(`Note ${i+1}: every talk item must be non-empty text.`);
|
||||
if(note.talk.length<3||note.talk.length>5)warnings.push(`Note ${i+1} (${note.id}): talk has ${note.talk.length} items; 3-5 is the default.`);
|
||||
}
|
||||
if(slideIds[i]&¬e.id!==slideIds[i])errors.push(`Position ${i+1}: note id "${note.id}" does not match slide id "${slideIds[i]}".`);
|
||||
});
|
||||
}
|
||||
|
||||
const runtimeChecks=[
|
||||
['right-bottom presenter entry','id="ppt-presenter-btn"'],
|
||||
['presenter shell','id="ppt-presenter"'],
|
||||
['overview control','id="ppt-grid"'],
|
||||
['first-page control','id="ppt-first"'],
|
||||
['last/restart control','id="ppt-last"'],
|
||||
['audience status','id="ppt-sync"'],
|
||||
['audience recovery','id="ppt-reopen"'],
|
||||
['direct window sync','postMessage('],
|
||||
['BroadcastChannel fallback','BroadcastChannel'],
|
||||
['storage fallback','__guizangPptSync'],
|
||||
];
|
||||
for(const [label,needle]of runtimeChecks)if(!html.includes(needle))errors.push(`Presenter runtime missing ${label}.`);
|
||||
|
||||
const planned=speakerNotes.reduce((sum,n)=>sum+(Number(n?.minutes)||0),0);
|
||||
if(Number.isFinite(targetMinutes)&&targetMinutes>0&&planned>targetMinutes*.9)errors.push(`Planned ${planned.toFixed(1)} min exceeds the 90% speaking budget (${(targetMinutes*.9).toFixed(1)} of ${targetMinutes} min).`);
|
||||
if(Number.isFinite(targetMinutes)&&targetMinutes>0&&planned<targetMinutes*.55)warnings.push(`Planned ${planned.toFixed(1)} min is below 55% of the requested ${targetMinutes} min; confirm the deck is not under-scripted.`);
|
||||
|
||||
warnings.forEach(x=>console.warn(`WARN ${x}`));
|
||||
errors.forEach(x=>console.error(`ERROR ${x}`));
|
||||
console.log(`Presenter validation: ${slideIds.length} slides, ${speakerNotes.length} notes, ${planned.toFixed(1)} planned minutes, ${errors.length} errors, ${warnings.length} warnings.`);
|
||||
process.exit(errors.length?1:0);
|
||||
Reference in new issue
Block a user