feat: expand presenter mode toolkit

This commit is contained in:
郭浩 committed 2026-08-06 22:50:45 +08:00
1 parent c192c855d0
commit 8cad17760d
6 files changed
+800 -139

No files matched your search

+10 -8
View File
@@ -28,7 +28,7 @@ description: 生成横向翻页网页 PPT(单 HTML 文件),含 WebGL 背
- 模板:`assets/template-swiss.html` · 主题色:`references/themes-swiss.md` · 布局:`references/layouts-swiss.md` - 模板:`assets/template-swiss.html` · 主题色:`references/themes-swiss.md` · 布局:`references/layouts-swiss.md`
- 美学锚点:像 Massimo Vignelli + Helvetica Forever - 美学锚点:像 Massimo Vignelli + Helvetica Forever
**两种风格共享**:横向翻页(键盘 ← →、滚轮、触屏、ESC 总览)、右下角 `P` 演讲者模式、当前/下一页预览、讲稿备注、计时、观众屏状态与断线恢复、Lucide 图标、Motion One 入场动效(本地 + CDN 双保险)。 **两种风格共享**:横向翻页(键盘 ← →、滚轮、触屏、ESC 总览)、右下角 `P` 演讲者模式、当前/下一页 16:9 预览、讲稿备注、逐页计时、排练记录、可选自动翻页、激光笔/圈选、观众屏黑白屏/冻结、同步状态与断线恢复、演前检查、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 --> <!-- 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 -->
@@ -115,10 +115,10 @@ git -C "<SKILL_ROOT>" pull --ff-only
如果用于正式演讲,页面计划不能只有“这一页放什么”,还要同时规划“台上说什么”。先读 `references/presenter-mode.md`,给每页确定稳定的 `data-slide-id`,并补齐: 如果用于正式演讲,页面计划不能只有“这一页放什么”,还要同时规划“台上说什么”。先读 `references/presenter-mode.md`,给每页确定稳定的 `data-slide-id`,并补齐:
| 页码 | 页面 ID | 页面目的 | 观众可见信息 | 演讲者补充 | 建议时长 | 转场 | cue | | 页码 | 页面 ID | 章节 | 页面目的 | 观众可见信息 | 演讲者补充 | 建议时长 | 转场 | 可选现场信息 |
|---|---|---|---|---|---:|---|---| |---|---|---|---|---|---|---:|---|---|
默认生成 3-5 条提词卡式讲述要点,不写逐字稿;只有用户明确要求逐字稿时才展开。总建议时长最多占用户时长的 90%,给停顿、互动和现场意外留缓冲。 默认生成 3-5 条提词卡式讲述要点,不写逐字稿;只有用户明确要求逐字稿时才展开。总建议时长最多占用户时长的 90%,给停顿、互动和现场意外留缓冲。用户没有提供的现场信息不猜测:时长缺失时显示横杠,其他可选模块整段隐藏。
大纲建议保存为 `项目记录.md` 或 `大纲-v1.md`,便于后续迭代。 大纲建议保存为 `项目记录.md` 或 `大纲-v1.md`,便于后续迭代。
@@ -237,9 +237,11 @@ cp "<SKILL_ROOT>/assets/template-swiss.html" "项目/XXX/ppt/index.html"
- `purpose` 说明这一页在整场叙事中的任务。 - `purpose` 说明这一页在整场叙事中的任务。
- `talk` 补充背景、例子、判断依据和语气,不逐字复述 slide。 - `talk` 补充背景、例子、判断依据和语气,不逐字复述 slide。
- `transition` 解释为什么下一页紧接着出现。 - `transition` 解释为什么下一页紧接着出现。
- `cue` 只写停顿、提问、Demo、视频、敏感信息提醒等舞台动作。 - `section` 只在大纲已给出章节或连续页面明显属于同一章节时填写。
- `minutes` 是讲述计划;`autoAdvanceSeconds` 是播放行为,两者必须分开,且后者只在用户明确要求时填写。
- `cue / interaction / delivery / advance / fallback / pronunciation` 只写大纲或用户明确提供的舞台动作、互动、表达、翻页、备用和读音信息。
没有来源支持的事实不能写进备注;缺失信息要标记“待补充”或询问用户。 没有来源支持的事实不能写进备注;影响内容正确性的缺失信息要标记“待补充”或询问用户,不影响内容的可选演讲信息直接省略。
#### 3.0 · 预检:类名必须在模板的 `<style>` 里有定义(**最重要**) #### 3.0 · 预检:类名必须在模板的 `<style>` 里有定义(**最重要**)
@@ -448,7 +450,7 @@ 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 node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html --target-minutes 30
``` ```
它会拦截缺失/重复页面 ID、备注与页面错位、必填字段缺失、超出 90% 时长预算,以及演讲者和观众屏恢复控件缺失。 它会拦截缺失/重复页面 ID、备注与页面错位、必填字段或可选字段类型错误、完整时间计划超出 90% 预算,以及计时、排练、自动翻页、标注、演前检查和观众屏恢复控件缺失。
#### 4.0.1 · 先量后改:超出 / 空白 / 标题间距 #### 4.0.1 · 先量后改:超出 / 空白 / 标题间距
@@ -532,7 +534,7 @@ open "项目/XXX/ppt/index.html"
不需要本地服务器。图片走相对路径 `images/xxx.png`。 不需要本地服务器。图片走相对路径 `images/xxx.png`。
预览时不能只看普通页面。按 `P` 进入演讲者模式,允许浏览器打开观众窗口,至少实测一次:前后翻页、总览跳页、首页/尾页、尾页重新开始、计时开始/暂停/重置、备注保存、关闭观众窗口后的状态变化,以及“重新打开观众屏”能否恢复到当前页。 预览时不能只看普通页面。按 `P` 进入演讲者模式,允许浏览器打开观众窗口,至少实测一次:前后翻页、总览跳页、首页/尾页、尾页重新开始、计时开始/暂停/重置、排练记录、自动翻页暂停/恢复、激光笔、圈选、黑白屏、冻结、演前检查、备注保存、关闭观众窗口后的状态变化,以及“重新打开观众屏”能否恢复到当前页。
### Step 6 · 迭代 ### Step 6 · 迭代
+326 -51
View File
@@ -573,23 +573,28 @@
.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{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::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="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-sync[data-state="connecting"]::before,.ppt-sync[data-state="frozen"]::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{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-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(300px,.75fr);gap:18px;min-height:0;padding:18px 24px} .ppt-presenter-main{display:grid;grid-template-columns:minmax(0,1.75fr) minmax(300px,.75fr);gap:18px;min-height:0;padding:18px 24px}
.ppt-preview-stack{display:grid;grid-template-rows:minmax(0,1fr) clamp(132px,22vh,210px);gap:16px;min-width:0;min-height:0} .ppt-preview-stack{display:grid;grid-template-rows:minmax(0,1fr) clamp(132px,22vh,210px);gap:16px;min-width:0;min-height:0}
.ppt-stage-card,.ppt-notes-card{background:#171a20;border:1px solid #2b2f37;border-radius:12px;overflow:hidden} .ppt-stage-card,.ppt-notes-card{background:#171a20;border:1px solid #2b2f37;border-radius:12px;overflow:hidden}
.ppt-stage-card{display:grid;grid-template-rows:auto minmax(0,1fr);min-width:0;min-height:0} .ppt-stage-card{display:grid;grid-template-rows:auto minmax(0,1fr);min-width:0;min-height:0}
.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-stage-label{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:11px 14px;color:#aeb4be;font-size:13px;border-bottom:1px solid #292d34}
.ppt-frame-viewport{display:grid;place-items:center;min-width:0;min-height:0;overflow:hidden;background:#08090b} .ppt-annotation-tools{display:flex;align-items:center;gap:5px;margin-left:auto}.ppt-annotation-tools .ppt-ui-btn{padding:6px 9px;font-size:12px}.ppt-annotation-tools .ppt-ui-btn.is-active{color:#111318;background:#ff5b63;border-color:#ff5b63}.ppt-annotation-tools .ppt-clear-btn{min-width:0}
.ppt-frame-viewport{position:relative;display:grid;place-items:center;min-width:0;min-height:0;overflow:hidden;background:#08090b}
.ppt-frame{display:block;width:0;height:0;max-width:100%;max-height:100%;aspect-ratio:16/9;border:0;background:#08090b}.ppt-next-frame{opacity:.86} .ppt-frame{display:block;width:0;height:0;max-width:100%;max-height:100%;aspect-ratio:16/9;border:0;background:#08090b}.ppt-next-frame{opacity:.86}
.ppt-annotation-layer{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:4;width:0;height:0;pointer-events:none;touch-action:none}.ppt-annotation-layer.is-active{pointer-events:auto;cursor:crosshair}
#ppt-audience-ink{position:fixed;inset:0;z-index:70;width:100vw;height:100vh;pointer-events:none}#ppt-audience-cover{position:fixed;inset:0;z-index:80;display:none;pointer-events:none}
.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-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-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-saved{color:#72df99}.ppt-presenter-bottom{display:grid;grid-template-columns:minmax(280px,1fr) auto minmax(260px,1fr);align-items:center;gap:20px;padding:14px 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} .ppt-timer{display:flex;align-items:center;gap:9px;min-width:0}.ppt-time-copy{min-width:142px}.ppt-clock{font:600 24px/1 var(--mono,monospace);font-variant-numeric:tabular-nums}.ppt-time-detail{margin-top:5px;color:#9fa6b1;font-size:12px;white-space:nowrap}.ppt-time-detail.is-over{color:#ff8a90}.ppt-rehearsal-on{color:#ff8a90}.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}.ppt-auto-state{display:inline-flex;align-items:center;gap:6px;margin-top:7px}.ppt-auto-state.is-on{color:#72df99}.ppt-auto-state.is-paused{color:#f0c766}
#ppt-presenter[data-layout="slides"] .ppt-presenter-main{grid-template-columns:minmax(0,2.15fr) minmax(260px,.58fr)}#ppt-presenter[data-layout="notes"] .ppt-presenter-main{grid-template-columns:minmax(0,1.15fr) minmax(380px,1fr)}
.ppt-dialog{position:fixed;inset:0;z-index:130;display:grid;place-items:center;padding:24px;background:rgba(4,6,9,.72);backdrop-filter:blur(10px)}.ppt-dialog[hidden]{display:none}.ppt-dialog-panel{width:min(720px,calc(100vw - 48px));max-height:min(760px,calc(100vh - 48px));display:grid;grid-template-rows:auto minmax(0,1fr);overflow:hidden;border:1px solid #343943;border-radius:14px;background:#171a20;box-shadow:0 24px 80px rgba(0,0,0,.45)}.ppt-dialog-head{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 18px;border-bottom:1px solid #2b2f37}.ppt-dialog-title{font-size:20px;font-weight:700}.ppt-dialog-body{overflow:auto;padding:18px;color:#d8dce2;font-size:14px;line-height:1.6}.ppt-dialog-section+.ppt-dialog-section{margin-top:22px}.ppt-dialog-section h3{margin:0 0 10px;color:#fff;font-size:15px}.ppt-dialog-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.ppt-dialog-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0;border-bottom:1px solid #282c33}.ppt-dialog-row:last-child{border-bottom:0}.ppt-field{display:grid;gap:6px}.ppt-field label{color:#aeb4be;font-size:12px}.ppt-field input{width:100%;border:1px solid #3a3e48;border-radius:8px;background:#101216;color:#fff;padding:9px 10px;font:600 14px Inter,sans-serif}.ppt-check{display:flex;align-items:flex-start;gap:10px}.ppt-check-dot{width:9px;height:9px;margin-top:7px;border-radius:50%;background:#ff6b72}.ppt-check.is-ok .ppt-check-dot{background:#72df99}.ppt-check.is-warn .ppt-check-dot{background:#f0c766}.ppt-summary{width:100%;border-collapse:collapse;font-size:13px}.ppt-summary th,.ppt-summary td{padding:9px 8px;border-bottom:1px solid #2b2f37;text-align:left}.ppt-summary th{color:#9fa6b1;font-weight:600}.ppt-summary td:last-child,.ppt-summary th:last-child{text-align:right}.ppt-muted{color:#8f96a1}.ppt-shortcuts{display:grid;grid-template-columns:auto 1fr;gap:8px 14px}.ppt-shortcuts kbd{min-width:44px;padding:4px 7px;border:1px solid #3a3e48;border-radius:6px;background:#101216;color:#fff;text-align:center;font:600 12px var(--mono,monospace)}
#overview{z-index:110!important} #overview{z-index:110!important}
@media(max-width:1200px){.ppt-presenter-main{grid-template-columns:minmax(0,1.55fr) minmax(280px,.72fr)}} @media(max-width:1200px){.ppt-presenter-main{grid-template-columns:minmax(0,1.55fr) minmax(280px,.72fr)}}
@media(max-width:900px){.ppt-presenter-top{padding:12px 16px}.ppt-presenter-main{grid-template-columns:minmax(0,1.45fr) minmax(250px,.72fr);gap:12px;padding:12px 16px}.ppt-preview-stack{gap:12px;grid-template-rows:minmax(0,1fr) clamp(104px,20vh,150px)}.ppt-presenter-bottom{gap:8px;padding:12px 16px}.ppt-controls{gap:5px}.ppt-controls .ppt-ui-btn{min-width:58px;padding:8px 10px}.ppt-page-state{display:none}} @media(max-width:900px){.ppt-presenter-top{padding:12px 16px;flex-wrap:wrap}.ppt-presenter-top .ppt-presenter-meta{gap:6px}.ppt-presenter-top .ppt-ui-btn{padding:8px 9px}.ppt-presenter-main,#ppt-presenter[data-layout="slides"] .ppt-presenter-main,#ppt-presenter[data-layout="notes"] .ppt-presenter-main{grid-template-columns:minmax(0,1.45fr) minmax(250px,.72fr);gap:12px;padding:12px 16px}.ppt-preview-stack{gap:12px;grid-template-rows:minmax(0,1fr) clamp(104px,20vh,150px)}.ppt-presenter-bottom{grid-template-columns:1fr auto;gap:8px;padding:10px 16px}.ppt-time-copy{min-width:112px}.ppt-time-detail{white-space:normal}.ppt-timer .ppt-ui-btn:nth-of-type(2){display:none}.ppt-controls{gap:5px}.ppt-controls .ppt-ui-btn{min-width:58px;padding:8px 10px}.ppt-page-state{display:none}.ppt-dialog-grid{grid-template-columns:1fr}}
@media(max-height:700px){.ppt-presenter-top{padding-top:11px;padding-bottom:11px}.ppt-presenter-main{padding-top:12px;padding-bottom:12px}.ppt-preview-stack{grid-template-rows:minmax(0,1fr) clamp(96px,18vh,126px);gap:12px}.ppt-stage-label{padding:8px 11px}.ppt-notes-head{padding:12px 14px}.ppt-presenter-bottom{padding-top:10px;padding-bottom:10px}} @media(max-height:700px){.ppt-presenter-top{padding-top:11px;padding-bottom:11px}.ppt-presenter-main{padding-top:12px;padding-bottom:12px}.ppt-preview-stack{grid-template-rows:minmax(0,1fr) clamp(96px,18vh,126px);gap:12px}.ppt-stage-label{padding:8px 11px}.ppt-notes-head{padding:12px 14px}.ppt-presenter-bottom{padding-top:10px;padding-bottom:10px}}
/* ESC 索引页: 动画元素强制可见 (覆盖 motion-ready 的 opacity:0) */ /* ESC 索引页: 动画元素强制可见 (覆盖 motion-ready 的 opacity:0) */
@@ -1334,13 +1339,13 @@
<script> <script>
const SPEAKER_NOTES = [ const SPEAKER_NOTES = [
{ {
id: 'cover', title: '[必填] Deck 标题', minutes: 0.6, id: 'cover', title: '[必填] Deck 标题', section: '开场', minutes: 0.6,
purpose: '建立主题与现场预期', purpose: '建立主题与现场预期',
talk: ['用一句反差或问题开场', '说明为什么是现在讲这件事', '只预告主线,不在封面展开细节'], talk: ['用一句反差或问题开场', '说明为什么是现在讲这件事', '只预告主线,不在封面展开细节'],
transition: '从开场问题进入核心内容', cue: '停顿,确认观众屏已同步' transition: '从开场问题进入核心内容', cue: '停顿,确认观众屏已同步'
}, },
{ {
id: 'closing', title: '[必填] Closing', minutes: 0.8, id: 'closing', title: '[必填] Closing', section: '收束', minutes: 0.8,
purpose: '收束全场并留下可记忆的行动点', purpose: '收束全场并留下可记忆的行动点',
talk: ['回扣开场时提出的问题', '把三条 takeaway 说成一个完整判断', '给观众一个会后可以立即采取的动作'], talk: ['回扣开场时提出的问题', '把三条 takeaway 说成一个完整判断', '给观众一个会后可以立即采取的动作'],
transition: '结束演讲,进入提问或致谢', cue: '停顿 2 秒后致谢' transition: '结束演讲,进入提问或致谢', cue: '停顿 2 秒后致谢'
@@ -1574,51 +1579,169 @@ window.__pptPresenter=(()=>{
const originalTitle=document.title; const originalTitle=document.title;
const notes=()=>Array.isArray(window.__SPEAKER_NOTES__)?window.__SPEAKER_NOTES__:[]; const notes=()=>Array.isArray(window.__SPEAKER_NOTES__)?window.__SPEAKER_NOTES__:[];
const slideId=i=>slides[i]?.dataset.slideId||`slide-${String(i+1).padStart(2,'0')}`; const slideId=i=>slides[i]?.dataset.slideId||`slide-${String(i+1).padStart(2,'0')}`;
const storageKey=name=>`guizang-ppt-${name}:${location.pathname}`;
let mode='deck',session=query.get('session')||Math.random().toString(36).slice(2),audienceWindow=null; 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 channel=null,transportReady=false,popupBlocked=false,audienceOpenedAt=0,lastAckAt=0,lastAckIndex=-1,lastAckSeq=-1,seq=0,lastPingAt=0;
let renderedNoteId='',saveTimer=0,noteSize=Number(localStorage.getItem('guizang-ppt-note-size')||17),presenterFrameObserver=null; let renderedNoteId='',saveTimer=0,noteSize=Number(localStorage.getItem('guizang-ppt-note-size')||17),presenterFrameObserver=null;
let elapsed=0,timerRunning=false,timerStartedAt=0,lowPowerBeforePresenter=window.__lowPowerMode; let elapsed=0,timerRunning=false,timerStartedAt=0,timedSlideIndex=idx,slideTimerStartedAt=0,slideElapsed=Array(total).fill(0);
let rehearsalActive=false,lowPowerBeforePresenter=window.__lowPowerMode;
let layoutMode=localStorage.getItem(storageKey('layout'))||'balanced';
let autoEnabled=localStorage.getItem(storageKey('auto-enabled'))==='1';
let autoGlobalSeconds=Math.max(0,Number(localStorage.getItem(storageKey('auto-seconds'))||0));
let autoDeadline=0,autoRemainingMs=0;
let screenMode='normal',audienceFrozen=false;
let annotationTool='none',annotationPointerDown=false,annotationStart=null,draftCircle=null,circles=[],audienceCircles=[];
let laserPoint=null,audienceLaser=null,laserHideAt=0,audienceLaserHideAt=0,lastLaserSent=0;
const seenNonces=[],seenNonceSet=new Set();
const syncKey=()=>`guizang-ppt-sync:${location.pathname}:${session}`; const syncKey=()=>`guizang-ppt-sync:${location.pathname}:${session}`;
const channelName=()=>`guizang-ppt:${location.pathname}:${session}`; const channelName=()=>`guizang-ppt:${location.pathname}:${session}`;
function noteAt(i){ function noteAt(i){
const id=slideId(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:'待补充转场'}; return notes().find(n=>n.id===id)||{id,title:slides[i]?.dataset.title||`第 ${i+1} 页`,purpose:'待补充本页目的',talk:['待补充讲述要点'],transition:'待补充转场'};
} }
function slideLabel(i){return `${i+1} / ${total} · ${noteAt(i).title||slideId(i)}`;} function slideLabel(i){return `${i+1} / ${total} · ${noteAt(i).title||slideId(i)}`;}
function escapeHTML(value){return String(value??'').replace(/[&<>"']/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));}
function valueLines(value){
if(Array.isArray(value))return value.filter(Boolean).map(x=>`• ${x}`).join('\n');
return value?String(value):'';
}
function generatedNote(n){ function generatedNote(n){
const talk=(n.talk||[]).map(x=>`• ${x}`).join('\n'); const parts=[];
return `【本页目的】\n${n.purpose||''}\n\n【讲述要点】\n${talk}\n\n【转场】\n${n.transition||''}${n.cue?`\n\n【舞台提示】\n${n.cue}`:''}`; if(n.purpose)parts.push(`【本页目的】\n${n.purpose}`);
if(Array.isArray(n.talk)&&n.talk.length)parts.push(`【讲述要点】\n${valueLines(n.talk)}`);
if(n.transition)parts.push(`【转场】\n${n.transition}`);
const optional=[['舞台提示',n.cue],['互动',n.interaction],['表达提示',n.delivery],['翻页时机',n.advance],['备用方案',n.fallback],['读音提醒',n.pronunciation]];
optional.forEach(([label,value])=>{const text=valueLines(value);if(text)parts.push(`【${label}】\n${text}`);});
return parts.join('\n\n');
} }
function noteStorageKey(id){return `guizang-ppt-note:${location.pathname}:${id}`;} function noteStorageKey(id){return `guizang-ppt-note:${location.pathname}:${id}`;}
function loadNoteValue(id,i,n){
const saved=localStorage.getItem(noteStorageKey(id));
if(saved!==null)return saved;
const prefix=window.__PPT_LEGACY_NOTE_KEY__;
if(prefix){
const legacy=localStorage.getItem(`${prefix}:${i}`);
if(legacy!==null){localStorage.setItem(noteStorageKey(id),legacy);return legacy;}
}
return generatedNote(n);
}
function frameUrl(targetMode,i){ function frameUrl(targetMode,i){
const url=new URL(location.href);url.hash=''; 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)); url.searchParams.set('mode',targetMode);url.searchParams.set('session',session);url.searchParams.set('slide',String(i+1));
return url.href; return url.href;
} }
function plannedMsAt(i){
const minutes=Number(noteAt(i).minutes);
return Number.isFinite(minutes)&&minutes>0?minutes*60000:null;
}
function totalPlannedMs(){
const values=Array.from({length:total},(_,i)=>plannedMsAt(i));
return values.length&&values.every(Number.isFinite)?values.reduce((sum,x)=>sum+x,0):null;
}
function sectionInfo(i){
const name=String(noteAt(i).section||'').trim();
if(!name)return null;
let first=i,last=i;
while(first>0&&String(noteAt(first-1).section||'').trim()===name)first--;
while(last<total-1&&String(noteAt(last+1).section||'').trim()===name)last++;
const targets=[];for(let x=first;x<=last;x++)targets.push(plannedMsAt(x));
const planned=targets.every(Number.isFinite)?targets.reduce((sum,x)=>sum+x,0):null;
const used=slideElapsed.slice(first,last+1).reduce((sum,x)=>sum+x,0)+(timerRunning&&timedSlideIndex>=first&&timedSlideIndex<=last?Date.now()-slideTimerStartedAt:0);
return{name,position:i-first+1,count:last-first+1,planned,used};
}
function durationText(ms){
const value=Math.max(0,Math.round(Number(ms)||0)),seconds=Math.floor(value/1000);
const hours=Math.floor(seconds/3600),minutes=Math.floor((seconds%3600)/60),secs=seconds%60;
return hours?`${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}:${String(secs).padStart(2,'0')}`:`${String(minutes).padStart(2,'0')}:${String(secs).padStart(2,'0')}`;
}
function signedDuration(ms){return `${ms>=0?'+':'−'}${durationText(Math.abs(ms))}`;}
function clockTime(timestamp){return new Date(timestamp).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});}
function fitFrame(frame){ function fitFrame(frame){
const viewport=frame?.closest('.ppt-frame-viewport'); const viewport=frame?.closest('.ppt-frame-viewport');
if(!viewport)return; if(!viewport)return;
const availableWidth=viewport.clientWidth,availableHeight=viewport.clientHeight; const availableWidth=viewport.clientWidth,availableHeight=viewport.clientHeight;
if(!availableWidth||!availableHeight)return; if(!availableWidth||!availableHeight)return;
const width=Math.min(availableWidth,availableHeight*(16/9)); const width=Math.min(availableWidth,availableHeight*(16/9)),height=width*(9/16);
frame.style.width=`${width}px`; frame.style.width=`${width}px`;frame.style.height=`${height}px`;
frame.style.height=`${width*(9/16)}px`; if(frame.id==='ppt-current-frame')sizeInk(document.getElementById('ppt-presenter-ink'),width,height);
} }
function fitPresenterFrames(){ function fitPresenterFrames(){
fitFrame(document.getElementById('ppt-current-frame')); fitFrame(document.getElementById('ppt-current-frame'));
fitFrame(document.getElementById('ppt-next-frame')); fitFrame(document.getElementById('ppt-next-frame'));
} }
function sizeInk(canvas,width,height){
if(!canvas||!width||!height)return;
canvas.style.width=`${width}px`;canvas.style.height=`${height}px`;
const dpr=Math.min(devicePixelRatio||1,2),pixelWidth=Math.round(width*dpr),pixelHeight=Math.round(height*dpr);
if(canvas.width!==pixelWidth||canvas.height!==pixelHeight){canvas.width=pixelWidth;canvas.height=pixelHeight;}
}
function drawEllipse(ctx,circle,width,height,alpha=1){
const x1=circle.x1*width,y1=circle.y1*height,x2=circle.x2*width,y2=circle.y2*height;
ctx.save();ctx.globalAlpha=alpha;ctx.strokeStyle='#ff4f59';ctx.lineWidth=Math.max(3,width*.004);ctx.lineCap='round';
ctx.beginPath();ctx.ellipse((x1+x2)/2,(y1+y2)/2,Math.abs(x2-x1)/2,Math.abs(y2-y1)/2,0,0,Math.PI*2);ctx.stroke();ctx.restore();
}
function drawInk(canvas,list,laser,draft){
if(!canvas)return;const width=canvas.clientWidth,height=canvas.clientHeight;if(!width||!height)return;
sizeInk(canvas,width,height);const dpr=canvas.width/width,ctx=canvas.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);
(list||[]).forEach(circle=>drawEllipse(ctx,circle,width,height));
if(draft)drawEllipse(ctx,draft,width,height,.72);
if(laser){
const x=laser.x*width,y=laser.y*height,r=Math.max(6,width*.007);
const glow=ctx.createRadialGradient(x,y,0,x,y,r*2.8);glow.addColorStop(0,'rgba(255,255,255,.98)');glow.addColorStop(.2,'rgba(255,64,72,.98)');glow.addColorStop(1,'rgba(255,64,72,0)');
ctx.fillStyle=glow;ctx.beginPath();ctx.arc(x,y,r*2.8,0,Math.PI*2);ctx.fill();
}
}
function renderInks(){
const now=Date.now();if(now>laserHideAt)laserPoint=null;if(now>audienceLaserHideAt)audienceLaser=null;
drawInk(document.getElementById('ppt-presenter-ink'),circles,laserPoint,draftCircle);
drawInk(document.getElementById('ppt-audience-ink'),audienceCircles,audienceLaser,null);
}
function setAnnotationTool(tool){
annotationTool=annotationTool===tool?'none':tool;annotationPointerDown=false;draftCircle=null;
const canvas=document.getElementById('ppt-presenter-ink');canvas?.classList.toggle('is-active',annotationTool!=='none');
document.querySelectorAll('[data-ppt-tool]').forEach(button=>button.classList.toggle('is-active',button.dataset.pptTool===annotationTool));
renderInks();
}
function annotationPoint(event){
const canvas=event.currentTarget,rect=canvas.getBoundingClientRect();
return{x:Math.max(0,Math.min(1,(event.clientX-rect.left)/rect.width)),y:Math.max(0,Math.min(1,(event.clientY-rect.top)/rect.height))};
}
function onAnnotationPointerDown(event){
if(annotationTool!=='circle')return;event.preventDefault();annotationPointerDown=true;annotationStart=annotationPoint(event);draftCircle={x1:annotationStart.x,y1:annotationStart.y,x2:annotationStart.x,y2:annotationStart.y};event.currentTarget.setPointerCapture?.(event.pointerId);renderInks();
}
function onAnnotationPointerMove(event){
if(annotationTool==='laser'){
const point=annotationPoint(event);laserPoint=point;laserHideAt=Date.now()+650;
if(Date.now()-lastLaserSent>32){lastLaserSent=Date.now();emit({type:'laser',index:idx,point},{persist:false});}renderInks();return;
}
if(annotationTool==='circle'&&annotationPointerDown&&draftCircle){const point=annotationPoint(event);draftCircle.x2=point.x;draftCircle.y2=point.y;renderInks();}
}
function onAnnotationPointerUp(event){
if(annotationTool!=='circle'||!annotationPointerDown||!draftCircle)return;event.preventDefault();annotationPointerDown=false;
const circle={...draftCircle};draftCircle=null;
if(Math.abs(circle.x2-circle.x1)>.012&&Math.abs(circle.y2-circle.y1)>.012){circles.push(circle);emit({type:'circle',index:idx,circle});}
renderInks();
}
function onAnnotationPointerLeave(){
if(annotationTool==='laser'){laserPoint=null;emit({type:'laser',index:idx,point:null},{persist:false});renderInks();}
}
function clearAnnotations(broadcast=true){circles=[];draftCircle=null;laserPoint=null;if(broadcast)emit({type:'clear',index:idx});renderInks();}
function mountShell(){ function mountShell(){
if(document.getElementById('ppt-presenter'))return; if(document.getElementById('ppt-presenter'))return;
document.body.insertAdjacentHTML('beforeend',`<div id="ppt-presenter" aria-label="演讲者视图"> document.body.insertAdjacentHTML('beforeend',`<div id="ppt-presenter" data-layout="${layoutMode}" 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-top"><div><div class="ppt-presenter-brand">演讲者视图</div><div class="ppt-presenter-meta"><span id="ppt-plan"></span><span id="ppt-section" hidden></span></div></div><div class="ppt-presenter-meta"><span id="ppt-sync" class="ppt-sync" data-state="disconnected" title="表示观众页面的软件同步状态,不代表 HDMI 等物理线缆状态">观众屏 · 未连接</span><button id="ppt-preflight" class="ppt-ui-btn" type="button">检查</button><button id="ppt-settings" class="ppt-ui-btn" type="button">设置</button><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><div class="ppt-frame-viewport"><iframe id="ppt-current-frame" class="ppt-frame" title="当前页预览"></iframe></div></div><div class="ppt-stage-card" id="ppt-next-card"><div class="ppt-stage-label"><span>下一页</span><span id="ppt-next-label"></span></div><div class="ppt-frame-viewport"><iframe id="ppt-next-frame" class="ppt-frame ppt-next-frame" title="下一页预览"></iframe></div></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 class="ppt-annotation-tools"><button class="ppt-ui-btn" data-ppt-tool="laser" type="button" title="激光笔(L)">激光</button><button class="ppt-ui-btn" data-ppt-tool="circle" type="button" title="圈选(C)">圈选</button><button id="ppt-ink-clear" class="ppt-ui-btn ppt-clear-btn" type="button" title="清除标记(X)">清除</button></span><span id="ppt-current-label"></span></div><div class="ppt-frame-viewport"><iframe id="ppt-current-frame" class="ppt-frame" title="当前页预览"></iframe><canvas id="ppt-presenter-ink" class="ppt-annotation-layer" aria-label="演讲标注层"></canvas></div></div><div class="ppt-stage-card" id="ppt-next-card"><div class="ppt-stage-label"><span>下一页</span><span id="ppt-next-label"></span></div><div class="ppt-frame-viewport"><iframe id="ppt-next-frame" class="ppt-frame ppt-next-frame" title="下一页预览"></iframe></div></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-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 class="ppt-presenter-bottom"><div class="ppt-timer"><div class="ppt-time-copy"><div id="ppt-clock" class="ppt-clock">00:00</div><div id="ppt-time-detail" class="ppt-time-detail">本页 00:00 / —</div></div><button id="ppt-timer-toggle" class="ppt-ui-btn" type="button">开始</button><button id="ppt-timer-reset" class="ppt-ui-btn" type="button">重置</button><button id="ppt-rehearsal" 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><br><button id="ppt-auto-toggle" class="ppt-ui-btn ppt-auto-state" type="button">自动翻页 · 关闭</button></div></div>
<div id="ppt-dialog" class="ppt-dialog" hidden><div class="ppt-dialog-panel" role="dialog" aria-modal="true"><div class="ppt-dialog-head"><div id="ppt-dialog-title" class="ppt-dialog-title"></div><button id="ppt-dialog-close" class="ppt-ui-btn" type="button">关闭</button></div><div id="ppt-dialog-body" class="ppt-dialog-body"></div></div></div>
</div>`); </div>`);
document.getElementById('ppt-reopen').onclick=reopenAudience; document.getElementById('ppt-reopen').onclick=reopenAudience;
document.getElementById('ppt-exit').onclick=exitPresenter; document.getElementById('ppt-exit').onclick=exitPresenter;
document.getElementById('ppt-preflight').onclick=openPreflight;
document.getElementById('ppt-settings').onclick=openSettings;
document.getElementById('ppt-first').onclick=()=>go(0,{force:true}); document.getElementById('ppt-first').onclick=()=>go(0,{force:true});
document.getElementById('ppt-prev').onclick=()=>go(idx-1,{force:true}); document.getElementById('ppt-prev').onclick=()=>go(idx-1,{force:true});
document.getElementById('ppt-grid').onclick=toggleOverview; document.getElementById('ppt-grid').onclick=toggleOverview;
@@ -1626,13 +1749,20 @@ window.__pptPresenter=(()=>{
document.getElementById('ppt-last').onclick=()=>go(idx===total-1?0:total-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-toggle').onclick=toggleTimer;
document.getElementById('ppt-timer-reset').onclick=resetTimer; document.getElementById('ppt-timer-reset').onclick=resetTimer;
document.getElementById('ppt-rehearsal').onclick=toggleRehearsal;
document.getElementById('ppt-auto-toggle').onclick=toggleAutoAdvance;
document.getElementById('ppt-note-smaller').onclick=()=>setNoteSize(noteSize-1); document.getElementById('ppt-note-smaller').onclick=()=>setNoteSize(noteSize-1);
document.getElementById('ppt-note-larger').onclick=()=>setNoteSize(noteSize+1); document.getElementById('ppt-note-larger').onclick=()=>setNoteSize(noteSize+1);
document.getElementById('ppt-note-editor').addEventListener('input',saveNote); document.getElementById('ppt-note-editor').addEventListener('input',saveNote);
document.querySelectorAll('[data-ppt-tool]').forEach(button=>button.onclick=()=>setAnnotationTool(button.dataset.pptTool));
document.getElementById('ppt-ink-clear').onclick=()=>clearAnnotations(true);
const ink=document.getElementById('ppt-presenter-ink');
ink.addEventListener('pointerdown',onAnnotationPointerDown);ink.addEventListener('pointermove',onAnnotationPointerMove);ink.addEventListener('pointerup',onAnnotationPointerUp);ink.addEventListener('pointercancel',onAnnotationPointerUp);ink.addEventListener('pointerleave',onAnnotationPointerLeave);
document.getElementById('ppt-dialog-close').onclick=closeDialog;
document.getElementById('ppt-dialog').onclick=event=>{if(event.target.id==='ppt-dialog')closeDialog();};
if('ResizeObserver'in window){presenterFrameObserver=new ResizeObserver(()=>requestAnimationFrame(fitPresenterFrames));presenterFrameObserver.observe(document.querySelector('.ppt-preview-stack'));} if('ResizeObserver'in window){presenterFrameObserver=new ResizeObserver(()=>requestAnimationFrame(fitPresenterFrames));presenterFrameObserver.observe(document.querySelector('.ppt-preview-stack'));}
else addEventListener('resize',fitPresenterFrames); else addEventListener('resize',fitPresenterFrames);
setNoteSize(noteSize,false); setNoteSize(noteSize,false);applyLayout(layoutMode,false);requestAnimationFrame(fitPresenterFrames);
requestAnimationFrame(fitPresenterFrames);
} }
function updateShell(){ function updateShell(){
if(mode!=='presenter')return;mountShell(); if(mode!=='presenter')return;mountShell();
@@ -1643,28 +1773,143 @@ window.__pptPresenter=(()=>{
document.getElementById('ppt-current-label').textContent=`${idx+1} / ${total}`; document.getElementById('ppt-current-label').textContent=`${idx+1} / ${total}`;
document.getElementById('ppt-next-label').textContent=idx===total-1?'已到尾页':`${next+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-notes-title').textContent=n.title||`第 ${idx+1} 页`;
document.getElementById('ppt-note-meta').textContent=`建议 ${Number(n.minutes||0).toFixed(1)} 分钟 · ${id}`; const target=plannedMsAt(idx);document.getElementById('ppt-note-meta').textContent=`建议 ${target?(target/60000).toFixed(1):'—'} 分钟 · ${id}`;
document.getElementById('ppt-page-title').textContent=n.title||`第 ${idx+1} 页`; 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-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-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?'重新开始':'尾页'; 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='已保存';} if(renderedNoteId!==id){renderedNoteId=id;editor.value=loadNoteValue(id,idx,n);editor.scrollTop=0;document.getElementById('ppt-saved').textContent='已保存';}
requestAnimationFrame(fitPresenterFrames); requestAnimationFrame(fitPresenterFrames);renderTimer();updateSyncStatus();
updateSyncStatus();
} }
function saveNote(){ function saveNote(){
const saved=document.getElementById('ppt-saved');saved.textContent='保存中…';saved.classList.remove('ppt-saved');clearTimeout(saveTimer); 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); 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){ 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));}
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 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 currentSlideElapsed(i=idx){return slideElapsed[i]+(timerRunning&&timedSlideIndex===i?Date.now()-slideTimerStartedAt:0);}
function toggleTimer(){if(timerRunning){elapsed=currentElapsed();timerRunning=false;}else{timerStartedAt=Date.now();timerRunning=true;}document.getElementById('ppt-timer-toggle').textContent=timerRunning?'暂停':'继续';renderTimer();} function commitRunningTime(now=Date.now()){
function resetTimer(){elapsed=0;timerRunning=false;timerStartedAt=0;document.getElementById('ppt-timer-toggle').textContent='开始计时';renderTimer();} if(!timerRunning)return;elapsed+=now-timerStartedAt;slideElapsed[timedSlideIndex]+=now-slideTimerStartedAt;timerStartedAt=now;slideTimerStartedAt=now;
}
function rollSlideTimer(nextIndex){if(timerRunning)commitRunningTime();timedSlideIndex=nextIndex;slideTimerStartedAt=Date.now();}
function renderTimer(){
const totalUsed=currentElapsed(),pageUsed=currentSlideElapsed(),target=plannedMsAt(idx),clock=document.getElementById('ppt-clock');
if(clock)clock.textContent=durationText(totalUsed);
const detail=document.getElementById('ppt-time-detail');
if(detail){
detail.classList.toggle('is-over',Boolean(target&&pageUsed>target));
detail.textContent=target?(pageUsed<=target?`本页 ${durationText(pageUsed)} / ${durationText(target)} · 剩余 ${durationText(target-pageUsed)}`:`本页 ${durationText(pageUsed)} / ${durationText(target)} · 超时 ${durationText(pageUsed-target)}`):`本页 ${durationText(pageUsed)} / —`;
}
const totalPlan=totalPlannedMs(),plan=document.getElementById('ppt-plan');
if(plan)plan.textContent=totalPlan?`计划 ${Number(totalPlan/60000).toFixed(1)} 分钟 · 预计 ${clockTime(Date.now()+Math.max(0,totalPlan-totalUsed))}`:'计划 —';
const section=sectionInfo(idx),sectionEl=document.getElementById('ppt-section');
if(sectionEl){sectionEl.hidden=!section;sectionEl.textContent=section?`${section.name} · ${section.position}/${section.count}${section.planned?` · 剩余 ${durationText(Math.max(0,section.planned-section.used))}`:''}`:'';}
const timerButton=document.getElementById('ppt-timer-toggle');if(timerButton)timerButton.textContent=timerRunning?'暂停':(totalUsed?'继续':'开始');
const rehearsalButton=document.getElementById('ppt-rehearsal');if(rehearsalButton){rehearsalButton.textContent=rehearsalActive?'结束排练':'排练';rehearsalButton.classList.toggle('ppt-rehearsal-on',rehearsalActive);}
renderAutoState();
}
function toggleTimer(){const now=Date.now();if(timerRunning){commitRunningTime(now);timerRunning=false;}else{timerStartedAt=now;slideTimerStartedAt=now;timedSlideIndex=idx;timerRunning=true;}renderTimer();}
function resetTimer(){
elapsed=0;slideElapsed=Array(total).fill(0);timedSlideIndex=idx;
if(timerRunning){timerStartedAt=Date.now();slideTimerStartedAt=timerStartedAt;}else{timerStartedAt=0;slideTimerStartedAt=0;}
renderTimer();
}
function rehearsalHistory(){try{return JSON.parse(localStorage.getItem(storageKey('rehearsals'))||'[]');}catch(e){return[];}}
function toggleRehearsal(){
if(rehearsalActive){finishRehearsal(true);return;}
rehearsalActive=true;if(timerRunning){commitRunningTime();timerRunning=false;}resetTimer();toggleTimer();renderTimer();
}
function finishRehearsal(showSummary){
if(!rehearsalActive)return;if(timerRunning){commitRunningTime();timerRunning=false;}
const result={endedAt:Date.now(),totalMs:elapsed,slides:slideElapsed.map((ms,i)=>({index:i,id:slideId(i),title:noteAt(i).title||slideId(i),ms,targetMs:plannedMsAt(i)}))};
const history=rehearsalHistory();history.unshift(result);try{localStorage.setItem(storageKey('rehearsals'),JSON.stringify(history.slice(0,5)));}catch(e){}
rehearsalActive=false;renderTimer();if(showSummary)openRehearsalSummary(result,history.slice(0,5));
}
function openRehearsalSummary(result,history){
const rows=result.slides.filter(item=>item.ms>0).map(item=>`<tr><td>${item.index+1}</td><td>${escapeHTML(item.title)}</td><td>${durationText(item.ms)}</td><td>${item.targetMs?signedDuration(item.ms-item.targetMs):'—'}</td></tr>`).join('');
const recent=history.map(item=>`<div class="ppt-dialog-row"><span>${new Date(item.endedAt).toLocaleString()}</span><strong>${durationText(item.totalMs)}</strong></div>`).join('');
openDialog('排练记录',`<div class="ppt-dialog-section"><div class="ppt-dialog-row"><span>本次总时长</span><strong>${durationText(result.totalMs)}</strong></div><table class="ppt-summary"><thead><tr><th>#</th><th>页面</th><th>实际</th><th>与计划相比</th></tr></thead><tbody>${rows||'<tr><td colspan="4">没有记录到页面时间</td></tr>'}</tbody></table></div><div class="ppt-dialog-section"><h3>最近排练</h3>${recent}</div>`);
}
function autoDurationMs(i=idx){const explicit=Number(noteAt(i).autoAdvanceSeconds);const seconds=Number.isFinite(explicit)&&explicit>0?explicit:autoGlobalSeconds;return seconds>0?seconds*1000:null;}
function armAutoAdvance(){autoDeadline=0;autoRemainingMs=autoDurationMs()||0;}
function autoBlockedReason(){
if(overviewOn)return'总览中';if(annotationTool!=='none')return'标注中';if(screenMode!=='normal')return'观众屏已暂停';if(document.hidden)return'窗口不可见';
const dialog=document.getElementById('ppt-dialog');if(dialog&&!dialog.hidden)return'设置中';
if(audienceOpenedAt&&syncState()!=='synced')return'等待观众屏同步';return'';
}
function updateAutoAdvance(){
if(!autoEnabled)return;
const duration=autoDurationMs();if(!duration||idx===total-1)return;
const blocked=autoBlockedReason(),now=Date.now();
if(blocked){if(autoDeadline){autoRemainingMs=Math.max(0,autoDeadline-now);autoDeadline=0;}return;}
if(!autoDeadline)autoDeadline=now+(autoRemainingMs||duration);
autoRemainingMs=Math.max(0,autoDeadline-now);
if(now>=autoDeadline){autoDeadline=0;autoRemainingMs=0;go(idx+1,{force:true});}
}
function renderAutoState(){
const button=document.getElementById('ppt-auto-toggle');if(!button)return;
const duration=autoDurationMs(),blocked=autoEnabled?autoBlockedReason():'';
button.classList.toggle('is-on',autoEnabled&&!blocked);button.classList.toggle('is-paused',autoEnabled&&Boolean(blocked));
if(!autoEnabled)button.textContent='自动翻页 · 关闭';
else if(!duration)button.textContent='自动翻页 · 等待时长';
else if(idx===total-1)button.textContent='自动翻页 · 已结束';
else if(blocked)button.textContent=`自动翻页 · ${blocked}`;
else button.textContent=`自动翻页 · ${durationText(autoRemainingMs||duration)}`;
}
function toggleAutoAdvance(){
if(!autoEnabled&&!autoDurationMs()){openSettings();return;}
autoEnabled=!autoEnabled;localStorage.setItem(storageKey('auto-enabled'),autoEnabled?'1':'0');armAutoAdvance();renderAutoState();
}
function openDialog(title,html){
const dialog=document.getElementById('ppt-dialog');if(!dialog)return;
document.getElementById('ppt-dialog-title').textContent=title;document.getElementById('ppt-dialog-body').innerHTML=html;dialog.hidden=false;renderAutoState();
}
function closeDialog(){const dialog=document.getElementById('ppt-dialog');if(dialog)dialog.hidden=true;renderAutoState();}
function applyLayout(value,persist=true){
layoutMode=['balanced','slides','notes'].includes(value)?value:'balanced';
document.getElementById('ppt-presenter')?.setAttribute('data-layout',layoutMode);
if(persist)localStorage.setItem(storageKey('layout'),layoutMode);requestAnimationFrame(fitPresenterFrames);
}
function applyAudienceScreen(value){
const cover=document.getElementById('ppt-audience-cover');if(!cover)return;
if(value==='black'||value==='white'){cover.style.display='block';cover.style.background=value;}else cover.style.display='none';
}
function setScreenMode(value){
const next=['normal','black','white','frozen'].includes(value)?value:'normal',wasFrozen=audienceFrozen;
screenMode=next;audienceFrozen=next==='frozen';emit({type:'screen',mode:next});
if(wasFrozen&&!audienceFrozen)sendGoto(true);updateSyncStatus();armAutoAdvance();renderAutoState();
}
function openSettings(){
openDialog('演示设置',`<div class="ppt-dialog-section"><h3>自动翻页</h3><div class="ppt-dialog-grid"><label class="ppt-dialog-row"><span>启用</span><input id="ppt-auto-enabled-setting" type="checkbox" ${autoEnabled?'checked':''}></label><div class="ppt-field"><label for="ppt-auto-seconds-setting">全局间隔(秒)</label><input id="ppt-auto-seconds-setting" type="number" min="1" step="1" value="${autoGlobalSeconds||''}" placeholder="未设置"></div><button id="ppt-settings-save" class="ppt-ui-btn" type="button">保存自动翻页</button></div><p class="ppt-muted">页面里的 autoAdvanceSeconds 优先于全局间隔;建议讲述时间 minutes 不会自动触发翻页。</p></div><div class="ppt-dialog-section"><h3>演讲者布局</h3><div class="ppt-dialog-grid"><button class="ppt-ui-btn" data-layout-choice="balanced" type="button">均衡</button><button class="ppt-ui-btn" data-layout-choice="slides" type="button">页面优先</button><button class="ppt-ui-btn" data-layout-choice="notes" type="button">备注优先</button></div></div><div class="ppt-dialog-section"><h3>观众屏</h3><div class="ppt-dialog-grid"><button class="ppt-ui-btn" data-screen-choice="normal" type="button">正常显示</button><button class="ppt-ui-btn" data-screen-choice="black" type="button">黑屏 B</button><button class="ppt-ui-btn" data-screen-choice="white" type="button">白屏 W</button><button class="ppt-ui-btn" data-screen-choice="frozen" type="button">冻结 F</button></div></div><div class="ppt-dialog-section"><button id="ppt-open-shortcuts" class="ppt-ui-btn" type="button">查看快捷键</button></div>`);
document.querySelectorAll('[data-layout-choice]').forEach(button=>{button.classList.toggle('ppt-primary',button.dataset.layoutChoice===layoutMode);button.onclick=()=>{applyLayout(button.dataset.layoutChoice);openSettings();};});
document.querySelectorAll('[data-screen-choice]').forEach(button=>{button.classList.toggle('ppt-primary',button.dataset.screenChoice===screenMode);button.onclick=()=>{setScreenMode(button.dataset.screenChoice);openSettings();};});
document.getElementById('ppt-settings-save').onclick=()=>{autoEnabled=document.getElementById('ppt-auto-enabled-setting').checked;autoGlobalSeconds=Math.max(0,Number(document.getElementById('ppt-auto-seconds-setting').value||0));localStorage.setItem(storageKey('auto-enabled'),autoEnabled?'1':'0');localStorage.setItem(storageKey('auto-seconds'),String(autoGlobalSeconds));armAutoAdvance();closeDialog();};
document.getElementById('ppt-open-shortcuts').onclick=openShortcuts;
}
function openShortcuts(){
const items=[['← / →','上一页 / 下一页'],['Home / End','首页 / 尾页'],['Esc','总览;标注工具激活时先退出工具'],['L','激光笔'],['C','圈选'],['X','清除标记'],['B / W','观众屏黑屏 / 白屏'],['F','冻结 / 恢复观众屏'],['A','开启 / 关闭自动翻页'],['R','开始 / 结束排练'],['?','快捷键说明']];
openDialog('快捷键',`<div class="ppt-shortcuts">${items.map(([key,label])=>`<kbd>${key}</kbd><span>${label}</span>`).join('')}</div>`);
}
function preflightChecks(){
const images=[...document.images],videos=[...document.querySelectorAll('video')],frame=document.getElementById('ppt-current-frame'),rect=frame?.getBoundingClientRect();
return[
{label:'观众屏连接',ok:['synced','frozen'].includes(syncState()),detail:`当前状态:${document.getElementById('ppt-sync')?.textContent||'未连接'}`},
{label:'弹窗权限',ok:!popupBlocked,detail:popupBlocked?'浏览器阻止了观众窗口':'未检测到弹窗拦截'},
{label:'全屏状态',ok:Boolean(document.fullscreenElement),warn:true,detail:document.fullscreenElement?'已进入全屏':'尚未全屏,可在演讲前进入'},
{label:'字体加载',ok:!document.fonts||document.fonts.status==='loaded',detail:document.fonts?`状态:${document.fonts.status}`:'浏览器不提供字体状态'},
{label:'图片资源',ok:images.every(image=>image.complete&&image.naturalWidth>0),detail:`${images.filter(image=>image.complete&&image.naturalWidth>0).length} / ${images.length} 已加载`},
{label:'视频资源',ok:videos.every(video=>video.readyState>=2),detail:videos.length?`${videos.filter(video=>video.readyState>=2).length} / ${videos.length} 可播放`:'没有视频'},
{label:'当前页 16:9',ok:Boolean(rect&&Math.abs(rect.width/rect.height-16/9)<.003),detail:rect?`${Math.round(rect.width)} × ${Math.round(rect.height)}`:'尚未渲染'}
];
}
function openPreflight(){
const checks=preflightChecks(),html=checks.map(item=>`<div class="ppt-dialog-row"><div class="ppt-check ${item.ok?'is-ok':(item.warn?'is-warn':'')}"><span class="ppt-check-dot"></span><span><strong>${item.label}</strong><br><span class="ppt-muted">${item.detail}</span></span></div><strong>${item.ok?'正常':(item.warn?'建议':'异常')}</strong></div>`).join('');
openDialog('演前检查',`<div class="ppt-dialog-section">${html}</div><div class="ppt-dialog-section"><button id="ppt-preflight-refresh" class="ppt-ui-btn" type="button">重新检查</button> <button id="ppt-enter-fullscreen" class="ppt-ui-btn" type="button">进入全屏</button></div><p class="ppt-muted">这里只能检查浏览器和观众页面的软件状态,不能确认 HDMI、转接器或投影仪线缆是否物理连接。</p>`);
document.getElementById('ppt-preflight-refresh').onclick=openPreflight;
document.getElementById('ppt-enter-fullscreen').onclick=async()=>{try{await document.documentElement.requestFullscreen();}catch(e){}openPreflight();};
}
function initTransport(){ function initTransport(){
if(transportReady)return;transportReady=true; if(transportReady)return;transportReady=true;
@@ -1672,62 +1917,89 @@ window.__pptPresenter=(()=>{
addEventListener('message',e=>receive(e.data)); addEventListener('message',e=>receive(e.data));
addEventListener('storage',e=>{if(e.key===syncKey()&&e.newValue){try{receive(JSON.parse(e.newValue));}catch(err){}}}); addEventListener('storage',e=>{if(e.key===syncKey()&&e.newValue){try{receive(JSON.parse(e.newValue));}catch(err){}}});
} }
function emit(payload){ function emit(payload,{persist=true}={}){
const message={__guizangPptSync:1,session,role:mode,...payload,at:Date.now(),nonce:Math.random().toString(36).slice(2)}; 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{channel?.postMessage(message);}catch(e){}
try{localStorage.setItem(syncKey(),JSON.stringify(message));}catch(e){} if(persist)try{localStorage.setItem(syncKey(),JSON.stringify(message));}catch(e){}
try{if(mode==='presenter'&&audienceWindow&&!audienceWindow.closed)audienceWindow.postMessage(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){} try{if(mode==='audience'&&window.opener&&!window.opener.closed)window.opener.postMessage(message,'*');}catch(e){}
} }
function receive(message){ function receive(message){
if(!message||message.__guizangPptSync!==1||message.session!==session||message.role===mode)return; if(!message||message.__guizangPptSync!==1||message.session!==session||message.role===mode)return;
if(message.nonce){if(seenNonceSet.has(message.nonce))return;seenNonceSet.add(message.nonce);seenNonces.push(message.nonce);if(seenNonces.length>256)seenNonceSet.delete(seenNonces.shift());}
if(mode==='presenter'){ if(mode==='presenter'){
if(message.type==='ready'){popupBlocked=false;sendGoto(true);} if(message.type==='ready'){popupBlocked=false;sendGoto(true);}
if(message.type==='ack'){lastAckAt=Date.now();lastAckIndex=Number(message.index);lastAckSeq=Number(message.seq);updateSyncStatus();} if(message.type==='ack'){lastAckAt=Date.now();lastAckIndex=Number(message.index);lastAckSeq=Number(message.seq);updateSyncStatus();}
return; return;
} }
if(mode==='audience'&&(message.type==='goto'||message.type==='ping')){ if(mode!=='audience')return;
if(message.type==='goto')go(Number(message.index),{force:true}); if(message.type==='screen'){applyAudienceScreen(message.mode);return;}
if(message.type==='laser'&&Number(message.index)===idx){audienceLaser=message.point||null;audienceLaserHideAt=Date.now()+650;renderInks();return;}
if(message.type==='circle'&&Number(message.index)===idx&&message.circle){audienceCircles.push(message.circle);renderInks();return;}
if(message.type==='clear'&&Number(message.index)===idx){audienceCircles=[];renderInks();return;}
if(message.type==='goto'||message.type==='ping'){
if(message.type==='goto'){audienceCircles=Array.isArray(message.circles)?message.circles:[];applyAudienceScreen(message.screenMode);go(Number(message.index),{force:true});}
emit({type:'ack',index:idx,seq:Number(message.seq)}); 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 sendGoto(force=false){
if(mode!=='presenter'||audienceFrozen)return;if(force||lastAckIndex!==idx)seq++;
emit({type:'goto',index:idx,seq,screenMode,circles});updateSyncStatus();
}
function syncState(){ function syncState(){
if(popupBlocked)return'blocked'; if(popupBlocked)return'blocked';if(audienceWindow&&audienceWindow.closed)return'disconnected';if(audienceFrozen)return'frozen';
if(audienceWindow&&audienceWindow.closed)return'disconnected';
if(!lastAckAt)return audienceOpenedAt&&Date.now()-audienceOpenedAt<2600?'connecting':(audienceOpenedAt?'unsynced':'disconnected'); if(!lastAckAt)return audienceOpenedAt&&Date.now()-audienceOpenedAt<2600?'connecting':(audienceOpenedAt?'unsynced':'disconnected');
if(Date.now()-lastAckAt>2600||lastAckIndex!==idx||lastAckSeq<seq)return'unsynced'; if(Date.now()-lastAckAt>2600||lastAckIndex!==idx||lastAckSeq<seq)return'unsynced';return'synced';
return'synced';
} }
function updateSyncStatus(){ function updateSyncStatus(){
const el=document.getElementById('ppt-sync');if(!el)return;const state=syncState(); const el=document.getElementById('ppt-sync');if(!el)return;const state=syncState();
const labels={blocked:'弹窗被拦截',disconnected:'未连接',connecting:'连接中',unsynced:'未同步',synced:`已同步 · 第 ${idx+1} 页`}; const labels={blocked:'弹窗被拦截',disconnected:'未连接',connecting:'连接中',unsynced:'未同步',frozen:'已冻结',synced:`已同步 · 第 ${idx+1} 页`};
el.dataset.state=state;el.textContent=`观众屏 · ${labels[state]}`; el.dataset.state=state;el.textContent=`观众屏 · ${labels[state]}`;
} }
function reopenAudience(){ function reopenAudience(){
initTransport();popupBlocked=false;audienceOpenedAt=Date.now();lastAckAt=0;lastAckIndex=-1;lastAckSeq=-1; initTransport();popupBlocked=false;audienceOpenedAt=Date.now();lastAckAt=0;lastAckIndex=-1;lastAckSeq=-1;
if(audienceFrozen){audienceFrozen=false;screenMode='normal';}
audienceWindow=window.open(frameUrl('audience',idx),`guizang-ppt-audience-${session}`,'popup=yes,width=1440,height=900'); audienceWindow=window.open(frameUrl('audience',idx),`guizang-ppt-audience-${session}`,'popup=yes,width=1440,height=900');
if(!audienceWindow)popupBlocked=true;updateSyncStatus(); if(!audienceWindow)popupBlocked=true;updateSyncStatus();
} }
function enterPresenter(openAudience=true){ function enterPresenter(openAudience=true){
if(mode==='presenter')return;lowPowerBeforePresenter=window.__lowPowerMode;mode='presenter';initTransport();mountShell(); 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}`; 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(); window.__setLowPowerMode?.(true,{persist:false});updateShell();armAutoAdvance();if(openAudience)reopenAudience();
} }
function exitPresenter(){ function exitPresenter(){
emit({type:'bye'});mode='deck';document.body.classList.remove('ppt-presenter');document.title=originalTitle;window.__setLowPowerMode?.(lowPowerBeforePresenter,{persist:false}); if(rehearsalActive)finishRehearsal(false);else if(timerRunning){commitRunningTime();timerRunning=false;}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}`;
document.body.insertAdjacentHTML('beforeend','<canvas id="ppt-audience-ink" aria-hidden="true"></canvas><div id="ppt-audience-cover" aria-hidden="true"></div>');
const resize=()=>{sizeInk(document.getElementById('ppt-audience-ink'),innerWidth,innerHeight);renderInks();};addEventListener('resize',resize);resize();
initTransport();emit({type:'ready',index:idx});
} }
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 initPreview(){mode='preview';document.body.classList.add('ppt-preview');window.__setLowPowerMode?.(true,{persist:false});}
function onSlideChange(){if(mode==='presenter'){updateShell();sendGoto();}} function onSlideChange(nextIndex){
if(mode!=='presenter')return;rollSlideTimer(nextIndex);circles=[];draftCircle=null;laserPoint=null;annotationPointerDown=false;armAutoAdvance();updateShell();sendGoto();
}
function handleKey(e){ function handleKey(e){
const editable=e.target?.matches?.('input,textarea,[contenteditable="true"]'); const editable=e.target?.matches?.('input,textarea,[contenteditable="true"]');
if(editable){if(e.key==='Escape')e.target.blur();return true;} if(editable){if(e.key==='Escape')e.target.blur();return true;}
if(mode==='audience'||mode==='preview'){e.preventDefault();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==='deck'&&e.key?.toLowerCase()==='p'&&!e.metaKey&&!e.ctrlKey&&!e.altKey){e.preventDefault();enterPresenter(true);return true;}
if(mode!=='presenter')return false; if(mode!=='presenter')return false;
if(e.key==='Escape'){e.preventDefault();toggleOverview();return true;} const key=e.key?.toLowerCase();
if(e.key==='?'||key==='/'){e.preventDefault();openShortcuts();return true;}
const dialog=document.getElementById('ppt-dialog');
if(!dialog?.hidden){if(e.key==='Escape'){e.preventDefault();closeDialog();}return true;}
if(e.key==='Escape'){e.preventDefault();if(annotationTool!=='none')setAnnotationTool('none');else toggleOverview();return true;}
if(overviewOn)return true; if(overviewOn)return true;
if(key==='l'){e.preventDefault();setAnnotationTool('laser');return true;}
if(key==='c'){e.preventDefault();setAnnotationTool('circle');return true;}
if(key==='x'){e.preventDefault();clearAnnotations(true);return true;}
if(key==='b'){e.preventDefault();setScreenMode(screenMode==='black'?'normal':'black');return true;}
if(key==='w'){e.preventDefault();setScreenMode(screenMode==='white'?'normal':'white');return true;}
if(key==='f'){e.preventDefault();setScreenMode(audienceFrozen?'normal':'frozen');return true;}
if(key==='a'){e.preventDefault();toggleAutoAdvance();return true;}
if(key==='r'){e.preventDefault();toggleRehearsal();return true;}
if(['ArrowRight','PageDown',' ','ArrowDown'].includes(e.key)){e.preventDefault();go(idx+1,{force:true});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(['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==='Home'){e.preventDefault();go(0,{force:true});return true;}
@@ -1740,8 +2012,11 @@ window.__pptPresenter=(()=>{
document.getElementById('ppt-presenter-btn')?.addEventListener('click',()=>enterPresenter(true)); document.getElementById('ppt-presenter-btn')?.addEventListener('click',()=>enterPresenter(true));
if(requestedMode==='audience')initAudience();else if(requestedMode==='preview')initPreview();else if(requestedMode==='presenter')enterPresenter(false); 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); setInterval(()=>{
return{initMode,onSlideChange,handleKey,slideLabel,enterPresenter,reopenAudience}; renderTimer();renderInks();updateAutoAdvance();
if(mode==='presenter'&&Date.now()-lastPingAt>900){lastPingAt=Date.now();if(audienceWindow?.closed)updateSyncStatus();emit({type:'ping',index:idx,seq});updateSyncStatus();}
},250);
return{initMode,onSlideChange,handleKey,slideLabel,enterPresenter,reopenAudience,debugState:()=>({mode,idx,session,syncState:syncState(),timerRunning,rehearsalActive,autoEnabled,autoGlobalSeconds,autoRemainingMs,screenMode,annotationTool,circles:[...circles],audienceCircles:[...audienceCircles],layoutMode})};
})(); })();
addEventListener('keydown',e=>{ addEventListener('keydown',e=>{
+328 -51
View File
@@ -217,23 +217,28 @@
.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{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::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="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-sync[data-state="connecting"]::before,.ppt-sync[data-state="frozen"]::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{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-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(300px,.75fr);gap:18px;min-height:0;padding:18px 24px} .ppt-presenter-main{display:grid;grid-template-columns:minmax(0,1.75fr) minmax(300px,.75fr);gap:18px;min-height:0;padding:18px 24px}
.ppt-preview-stack{display:grid;grid-template-rows:minmax(0,1fr) clamp(132px,22vh,210px);gap:16px;min-width:0;min-height:0} .ppt-preview-stack{display:grid;grid-template-rows:minmax(0,1fr) clamp(132px,22vh,210px);gap:16px;min-width:0;min-height:0}
.ppt-stage-card,.ppt-notes-card{background:#171a20;border:1px solid #2b2f37;border-radius:12px;overflow:hidden} .ppt-stage-card,.ppt-notes-card{background:#171a20;border:1px solid #2b2f37;border-radius:12px;overflow:hidden}
.ppt-stage-card{display:grid;grid-template-rows:auto minmax(0,1fr);min-width:0;min-height:0} .ppt-stage-card{display:grid;grid-template-rows:auto minmax(0,1fr);min-width:0;min-height:0}
.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-stage-label{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:11px 14px;color:#aeb4be;font-size:13px;border-bottom:1px solid #292d34}
.ppt-frame-viewport{display:grid;place-items:center;min-width:0;min-height:0;overflow:hidden;background:#08090b} .ppt-annotation-tools{display:flex;align-items:center;gap:5px;margin-left:auto}.ppt-annotation-tools .ppt-ui-btn{padding:6px 9px;font-size:12px}.ppt-annotation-tools .ppt-ui-btn.is-active{color:#111318;background:#ff5b63;border-color:#ff5b63}.ppt-annotation-tools .ppt-clear-btn{min-width:0}
.ppt-frame-viewport{position:relative;display:grid;place-items:center;min-width:0;min-height:0;overflow:hidden;background:#08090b}
.ppt-frame{display:block;width:0;height:0;max-width:100%;max-height:100%;aspect-ratio:16/9;border:0;background:#08090b}.ppt-next-frame{opacity:.86} .ppt-frame{display:block;width:0;height:0;max-width:100%;max-height:100%;aspect-ratio:16/9;border:0;background:#08090b}.ppt-next-frame{opacity:.86}
.ppt-annotation-layer{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:4;width:0;height:0;pointer-events:none;touch-action:none}.ppt-annotation-layer.is-active{pointer-events:auto;cursor:crosshair}
#ppt-audience-ink{position:fixed;inset:0;z-index:70;width:100vw;height:100vh;pointer-events:none}#ppt-audience-cover{position:fixed;inset:0;z-index:80;display:none;pointer-events:none}
.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-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-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-saved{color:#72df99}.ppt-presenter-bottom{display:grid;grid-template-columns:minmax(280px,1fr) auto minmax(260px,1fr);align-items:center;gap:20px;padding:14px 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} .ppt-timer{display:flex;align-items:center;gap:9px;min-width:0}.ppt-time-copy{min-width:142px}.ppt-clock{font:600 24px/1 var(--mono,monospace);font-variant-numeric:tabular-nums}.ppt-time-detail{margin-top:5px;color:#9fa6b1;font-size:12px;white-space:nowrap}.ppt-time-detail.is-over{color:#ff8a90}.ppt-rehearsal-on{color:#ff8a90}.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}.ppt-auto-state{display:inline-flex;align-items:center;gap:6px;margin-top:7px}.ppt-auto-state.is-on{color:#72df99}.ppt-auto-state.is-paused{color:#f0c766}
#ppt-presenter[data-layout="slides"] .ppt-presenter-main{grid-template-columns:minmax(0,2.15fr) minmax(260px,.58fr)}#ppt-presenter[data-layout="notes"] .ppt-presenter-main{grid-template-columns:minmax(0,1.15fr) minmax(380px,1fr)}
.ppt-dialog{position:fixed;inset:0;z-index:130;display:grid;place-items:center;padding:24px;background:rgba(4,6,9,.72);backdrop-filter:blur(10px)}.ppt-dialog[hidden]{display:none}.ppt-dialog-panel{width:min(720px,calc(100vw - 48px));max-height:min(760px,calc(100vh - 48px));display:grid;grid-template-rows:auto minmax(0,1fr);overflow:hidden;border:1px solid #343943;border-radius:14px;background:#171a20;box-shadow:0 24px 80px rgba(0,0,0,.45)}.ppt-dialog-head{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 18px;border-bottom:1px solid #2b2f37}.ppt-dialog-title{font-size:20px;font-weight:700}.ppt-dialog-body{overflow:auto;padding:18px;color:#d8dce2;font-size:14px;line-height:1.6}.ppt-dialog-section+.ppt-dialog-section{margin-top:22px}.ppt-dialog-section h3{margin:0 0 10px;color:#fff;font-size:15px}.ppt-dialog-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.ppt-dialog-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0;border-bottom:1px solid #282c33}.ppt-dialog-row:last-child{border-bottom:0}.ppt-field{display:grid;gap:6px}.ppt-field label{color:#aeb4be;font-size:12px}.ppt-field input{width:100%;border:1px solid #3a3e48;border-radius:8px;background:#101216;color:#fff;padding:9px 10px;font:600 14px Inter,sans-serif}.ppt-check{display:flex;align-items:flex-start;gap:10px}.ppt-check-dot{width:9px;height:9px;margin-top:7px;border-radius:50%;background:#ff6b72}.ppt-check.is-ok .ppt-check-dot{background:#72df99}.ppt-check.is-warn .ppt-check-dot{background:#f0c766}.ppt-summary{width:100%;border-collapse:collapse;font-size:13px}.ppt-summary th,.ppt-summary td{padding:9px 8px;border-bottom:1px solid #2b2f37;text-align:left}.ppt-summary th{color:#9fa6b1;font-weight:600}.ppt-summary td:last-child,.ppt-summary th:last-child{text-align:right}.ppt-muted{color:#8f96a1}.ppt-shortcuts{display:grid;grid-template-columns:auto 1fr;gap:8px 14px}.ppt-shortcuts kbd{min-width:44px;padding:4px 7px;border:1px solid #3a3e48;border-radius:6px;background:#101216;color:#fff;text-align:center;font:600 12px var(--mono,monospace)}
#overview{z-index:110!important} #overview{z-index:110!important}
@media(max-width:1200px){.ppt-presenter-main{grid-template-columns:minmax(0,1.55fr) minmax(280px,.72fr)}} @media(max-width:1200px){.ppt-presenter-main{grid-template-columns:minmax(0,1.55fr) minmax(280px,.72fr)}}
@media(max-width:900px){.ppt-presenter-top{padding:12px 16px}.ppt-presenter-main{grid-template-columns:minmax(0,1.45fr) minmax(250px,.72fr);gap:12px;padding:12px 16px}.ppt-preview-stack{gap:12px;grid-template-rows:minmax(0,1fr) clamp(104px,20vh,150px)}.ppt-presenter-bottom{gap:8px;padding:12px 16px}.ppt-controls{gap:5px}.ppt-controls .ppt-ui-btn{min-width:58px;padding:8px 10px}.ppt-page-state{display:none}} @media(max-width:900px){.ppt-presenter-top{padding:12px 16px;flex-wrap:wrap}.ppt-presenter-top .ppt-presenter-meta{gap:6px}.ppt-presenter-top .ppt-ui-btn{padding:8px 9px}.ppt-presenter-main,#ppt-presenter[data-layout="slides"] .ppt-presenter-main,#ppt-presenter[data-layout="notes"] .ppt-presenter-main{grid-template-columns:minmax(0,1.45fr) minmax(250px,.72fr);gap:12px;padding:12px 16px}.ppt-preview-stack{gap:12px;grid-template-rows:minmax(0,1fr) clamp(104px,20vh,150px)}.ppt-presenter-bottom{grid-template-columns:1fr auto;gap:8px;padding:10px 16px}.ppt-time-copy{min-width:112px}.ppt-time-detail{white-space:normal}.ppt-timer .ppt-ui-btn:nth-of-type(2){display:none}.ppt-controls{gap:5px}.ppt-controls .ppt-ui-btn{min-width:58px;padding:8px 10px}.ppt-page-state{display:none}.ppt-dialog-grid{grid-template-columns:1fr}}
@media(max-height:700px){.ppt-presenter-top{padding-top:11px;padding-bottom:11px}.ppt-presenter-main{padding-top:12px;padding-bottom:12px}.ppt-preview-stack{grid-template-rows:minmax(0,1fr) clamp(96px,18vh,126px);gap:12px}.ppt-stage-label{padding:8px 11px}.ppt-notes-head{padding:12px 14px}.ppt-presenter-bottom{padding-top:10px;padding-bottom:10px}} @media(max-height:700px){.ppt-presenter-top{padding-top:11px;padding-bottom:11px}.ppt-presenter-main{padding-top:12px;padding-bottom:12px}.ppt-preview-stack{grid-template-rows:minmax(0,1fr) clamp(96px,18vh,126px);gap:12px}.ppt-stage-label{padding:8px 11px}.ppt-notes-head{padding:12px 14px}.ppt-presenter-bottom{padding-top:10px;padding-bottom:10px}}
/* ============================================================ /* ============================================================
@@ -534,10 +539,12 @@
<script> <script>
const SPEAKER_NOTES = [ const SPEAKER_NOTES = [
/* { /* {
id: 'stable-slide-id', title: '页面标题', minutes: 1, id: 'stable-slide-id', title: '页面标题', section: '主体', minutes: 1,
purpose: '这一页要让观众理解什么', purpose: '这一页要让观众理解什么',
talk: ['只在台上说的补充信息', '例子、证据或语气提示', '不复述页面可见文字'], talk: ['只在台上说的补充信息', '例子、证据或语气提示', '不复述页面可见文字'],
transition: '为什么下一页紧接着出现', cue: '可选:停顿 / Demo / 视频' transition: '为什么下一页紧接着出现', cue: '可选:停顿 / Demo / 视频',
interaction: '可选:现场提问', delivery: '可选:语气 / 重音',
advance: '可选:翻页时机', fallback: '可选:Demo 失败时的备用说法'
} */ } */
]; ];
window.__SPEAKER_NOTES__ = SPEAKER_NOTES; window.__SPEAKER_NOTES__ = SPEAKER_NOTES;
@@ -749,51 +756,169 @@ window.__pptPresenter=(()=>{
const originalTitle=document.title; const originalTitle=document.title;
const notes=()=>Array.isArray(window.__SPEAKER_NOTES__)?window.__SPEAKER_NOTES__:[]; const notes=()=>Array.isArray(window.__SPEAKER_NOTES__)?window.__SPEAKER_NOTES__:[];
const slideId=i=>slides[i]?.dataset.slideId||`slide-${String(i+1).padStart(2,'0')}`; const slideId=i=>slides[i]?.dataset.slideId||`slide-${String(i+1).padStart(2,'0')}`;
const storageKey=name=>`guizang-ppt-${name}:${location.pathname}`;
let mode='deck',session=query.get('session')||Math.random().toString(36).slice(2),audienceWindow=null; 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 channel=null,transportReady=false,popupBlocked=false,audienceOpenedAt=0,lastAckAt=0,lastAckIndex=-1,lastAckSeq=-1,seq=0,lastPingAt=0;
let renderedNoteId='',saveTimer=0,noteSize=Number(localStorage.getItem('guizang-ppt-note-size')||17),presenterFrameObserver=null; let renderedNoteId='',saveTimer=0,noteSize=Number(localStorage.getItem('guizang-ppt-note-size')||17),presenterFrameObserver=null;
let elapsed=0,timerRunning=false,timerStartedAt=0,lowPowerBeforePresenter=window.__lowPowerMode; let elapsed=0,timerRunning=false,timerStartedAt=0,timedSlideIndex=idx,slideTimerStartedAt=0,slideElapsed=Array(total).fill(0);
let rehearsalActive=false,lowPowerBeforePresenter=window.__lowPowerMode;
let layoutMode=localStorage.getItem(storageKey('layout'))||'balanced';
let autoEnabled=localStorage.getItem(storageKey('auto-enabled'))==='1';
let autoGlobalSeconds=Math.max(0,Number(localStorage.getItem(storageKey('auto-seconds'))||0));
let autoDeadline=0,autoRemainingMs=0;
let screenMode='normal',audienceFrozen=false;
let annotationTool='none',annotationPointerDown=false,annotationStart=null,draftCircle=null,circles=[],audienceCircles=[];
let laserPoint=null,audienceLaser=null,laserHideAt=0,audienceLaserHideAt=0,lastLaserSent=0;
const seenNonces=[],seenNonceSet=new Set();
const syncKey=()=>`guizang-ppt-sync:${location.pathname}:${session}`; const syncKey=()=>`guizang-ppt-sync:${location.pathname}:${session}`;
const channelName=()=>`guizang-ppt:${location.pathname}:${session}`; const channelName=()=>`guizang-ppt:${location.pathname}:${session}`;
function noteAt(i){ function noteAt(i){
const id=slideId(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:'待补充转场'}; return notes().find(n=>n.id===id)||{id,title:slides[i]?.dataset.title||`第 ${i+1} 页`,purpose:'待补充本页目的',talk:['待补充讲述要点'],transition:'待补充转场'};
} }
function slideLabel(i){return `${i+1} / ${total} · ${noteAt(i).title||slideId(i)}`;} function slideLabel(i){return `${i+1} / ${total} · ${noteAt(i).title||slideId(i)}`;}
function escapeHTML(value){return String(value??'').replace(/[&<>"']/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));}
function valueLines(value){
if(Array.isArray(value))return value.filter(Boolean).map(x=>`• ${x}`).join('\n');
return value?String(value):'';
}
function generatedNote(n){ function generatedNote(n){
const talk=(n.talk||[]).map(x=>`• ${x}`).join('\n'); const parts=[];
return `【本页目的】\n${n.purpose||''}\n\n【讲述要点】\n${talk}\n\n【转场】\n${n.transition||''}${n.cue?`\n\n【舞台提示】\n${n.cue}`:''}`; if(n.purpose)parts.push(`【本页目的】\n${n.purpose}`);
if(Array.isArray(n.talk)&&n.talk.length)parts.push(`【讲述要点】\n${valueLines(n.talk)}`);
if(n.transition)parts.push(`【转场】\n${n.transition}`);
const optional=[['舞台提示',n.cue],['互动',n.interaction],['表达提示',n.delivery],['翻页时机',n.advance],['备用方案',n.fallback],['读音提醒',n.pronunciation]];
optional.forEach(([label,value])=>{const text=valueLines(value);if(text)parts.push(`【${label}】\n${text}`);});
return parts.join('\n\n');
} }
function noteStorageKey(id){return `guizang-ppt-note:${location.pathname}:${id}`;} function noteStorageKey(id){return `guizang-ppt-note:${location.pathname}:${id}`;}
function loadNoteValue(id,i,n){
const saved=localStorage.getItem(noteStorageKey(id));
if(saved!==null)return saved;
const prefix=window.__PPT_LEGACY_NOTE_KEY__;
if(prefix){
const legacy=localStorage.getItem(`${prefix}:${i}`);
if(legacy!==null){localStorage.setItem(noteStorageKey(id),legacy);return legacy;}
}
return generatedNote(n);
}
function frameUrl(targetMode,i){ function frameUrl(targetMode,i){
const url=new URL(location.href);url.hash=''; 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)); url.searchParams.set('mode',targetMode);url.searchParams.set('session',session);url.searchParams.set('slide',String(i+1));
return url.href; return url.href;
} }
function plannedMsAt(i){
const minutes=Number(noteAt(i).minutes);
return Number.isFinite(minutes)&&minutes>0?minutes*60000:null;
}
function totalPlannedMs(){
const values=Array.from({length:total},(_,i)=>plannedMsAt(i));
return values.length&&values.every(Number.isFinite)?values.reduce((sum,x)=>sum+x,0):null;
}
function sectionInfo(i){
const name=String(noteAt(i).section||'').trim();
if(!name)return null;
let first=i,last=i;
while(first>0&&String(noteAt(first-1).section||'').trim()===name)first--;
while(last<total-1&&String(noteAt(last+1).section||'').trim()===name)last++;
const targets=[];for(let x=first;x<=last;x++)targets.push(plannedMsAt(x));
const planned=targets.every(Number.isFinite)?targets.reduce((sum,x)=>sum+x,0):null;
const used=slideElapsed.slice(first,last+1).reduce((sum,x)=>sum+x,0)+(timerRunning&&timedSlideIndex>=first&&timedSlideIndex<=last?Date.now()-slideTimerStartedAt:0);
return{name,position:i-first+1,count:last-first+1,planned,used};
}
function durationText(ms){
const value=Math.max(0,Math.round(Number(ms)||0)),seconds=Math.floor(value/1000);
const hours=Math.floor(seconds/3600),minutes=Math.floor((seconds%3600)/60),secs=seconds%60;
return hours?`${String(hours).padStart(2,'0')}:${String(minutes).padStart(2,'0')}:${String(secs).padStart(2,'0')}`:`${String(minutes).padStart(2,'0')}:${String(secs).padStart(2,'0')}`;
}
function signedDuration(ms){return `${ms>=0?'+':'−'}${durationText(Math.abs(ms))}`;}
function clockTime(timestamp){return new Date(timestamp).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});}
function fitFrame(frame){ function fitFrame(frame){
const viewport=frame?.closest('.ppt-frame-viewport'); const viewport=frame?.closest('.ppt-frame-viewport');
if(!viewport)return; if(!viewport)return;
const availableWidth=viewport.clientWidth,availableHeight=viewport.clientHeight; const availableWidth=viewport.clientWidth,availableHeight=viewport.clientHeight;
if(!availableWidth||!availableHeight)return; if(!availableWidth||!availableHeight)return;
const width=Math.min(availableWidth,availableHeight*(16/9)); const width=Math.min(availableWidth,availableHeight*(16/9)),height=width*(9/16);
frame.style.width=`${width}px`; frame.style.width=`${width}px`;frame.style.height=`${height}px`;
frame.style.height=`${width*(9/16)}px`; if(frame.id==='ppt-current-frame')sizeInk(document.getElementById('ppt-presenter-ink'),width,height);
} }
function fitPresenterFrames(){ function fitPresenterFrames(){
fitFrame(document.getElementById('ppt-current-frame')); fitFrame(document.getElementById('ppt-current-frame'));
fitFrame(document.getElementById('ppt-next-frame')); fitFrame(document.getElementById('ppt-next-frame'));
} }
function sizeInk(canvas,width,height){
if(!canvas||!width||!height)return;
canvas.style.width=`${width}px`;canvas.style.height=`${height}px`;
const dpr=Math.min(devicePixelRatio||1,2),pixelWidth=Math.round(width*dpr),pixelHeight=Math.round(height*dpr);
if(canvas.width!==pixelWidth||canvas.height!==pixelHeight){canvas.width=pixelWidth;canvas.height=pixelHeight;}
}
function drawEllipse(ctx,circle,width,height,alpha=1){
const x1=circle.x1*width,y1=circle.y1*height,x2=circle.x2*width,y2=circle.y2*height;
ctx.save();ctx.globalAlpha=alpha;ctx.strokeStyle='#ff4f59';ctx.lineWidth=Math.max(3,width*.004);ctx.lineCap='round';
ctx.beginPath();ctx.ellipse((x1+x2)/2,(y1+y2)/2,Math.abs(x2-x1)/2,Math.abs(y2-y1)/2,0,0,Math.PI*2);ctx.stroke();ctx.restore();
}
function drawInk(canvas,list,laser,draft){
if(!canvas)return;const width=canvas.clientWidth,height=canvas.clientHeight;if(!width||!height)return;
sizeInk(canvas,width,height);const dpr=canvas.width/width,ctx=canvas.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);
(list||[]).forEach(circle=>drawEllipse(ctx,circle,width,height));
if(draft)drawEllipse(ctx,draft,width,height,.72);
if(laser){
const x=laser.x*width,y=laser.y*height,r=Math.max(6,width*.007);
const glow=ctx.createRadialGradient(x,y,0,x,y,r*2.8);glow.addColorStop(0,'rgba(255,255,255,.98)');glow.addColorStop(.2,'rgba(255,64,72,.98)');glow.addColorStop(1,'rgba(255,64,72,0)');
ctx.fillStyle=glow;ctx.beginPath();ctx.arc(x,y,r*2.8,0,Math.PI*2);ctx.fill();
}
}
function renderInks(){
const now=Date.now();if(now>laserHideAt)laserPoint=null;if(now>audienceLaserHideAt)audienceLaser=null;
drawInk(document.getElementById('ppt-presenter-ink'),circles,laserPoint,draftCircle);
drawInk(document.getElementById('ppt-audience-ink'),audienceCircles,audienceLaser,null);
}
function setAnnotationTool(tool){
annotationTool=annotationTool===tool?'none':tool;annotationPointerDown=false;draftCircle=null;
const canvas=document.getElementById('ppt-presenter-ink');canvas?.classList.toggle('is-active',annotationTool!=='none');
document.querySelectorAll('[data-ppt-tool]').forEach(button=>button.classList.toggle('is-active',button.dataset.pptTool===annotationTool));
renderInks();
}
function annotationPoint(event){
const canvas=event.currentTarget,rect=canvas.getBoundingClientRect();
return{x:Math.max(0,Math.min(1,(event.clientX-rect.left)/rect.width)),y:Math.max(0,Math.min(1,(event.clientY-rect.top)/rect.height))};
}
function onAnnotationPointerDown(event){
if(annotationTool!=='circle')return;event.preventDefault();annotationPointerDown=true;annotationStart=annotationPoint(event);draftCircle={x1:annotationStart.x,y1:annotationStart.y,x2:annotationStart.x,y2:annotationStart.y};event.currentTarget.setPointerCapture?.(event.pointerId);renderInks();
}
function onAnnotationPointerMove(event){
if(annotationTool==='laser'){
const point=annotationPoint(event);laserPoint=point;laserHideAt=Date.now()+650;
if(Date.now()-lastLaserSent>32){lastLaserSent=Date.now();emit({type:'laser',index:idx,point},{persist:false});}renderInks();return;
}
if(annotationTool==='circle'&&annotationPointerDown&&draftCircle){const point=annotationPoint(event);draftCircle.x2=point.x;draftCircle.y2=point.y;renderInks();}
}
function onAnnotationPointerUp(event){
if(annotationTool!=='circle'||!annotationPointerDown||!draftCircle)return;event.preventDefault();annotationPointerDown=false;
const circle={...draftCircle};draftCircle=null;
if(Math.abs(circle.x2-circle.x1)>.012&&Math.abs(circle.y2-circle.y1)>.012){circles.push(circle);emit({type:'circle',index:idx,circle});}
renderInks();
}
function onAnnotationPointerLeave(){
if(annotationTool==='laser'){laserPoint=null;emit({type:'laser',index:idx,point:null},{persist:false});renderInks();}
}
function clearAnnotations(broadcast=true){circles=[];draftCircle=null;laserPoint=null;if(broadcast)emit({type:'clear',index:idx});renderInks();}
function mountShell(){ function mountShell(){
if(document.getElementById('ppt-presenter'))return; if(document.getElementById('ppt-presenter'))return;
document.body.insertAdjacentHTML('beforeend',`<div id="ppt-presenter" aria-label="演讲者视图"> document.body.insertAdjacentHTML('beforeend',`<div id="ppt-presenter" data-layout="${layoutMode}" 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-top"><div><div class="ppt-presenter-brand">演讲者视图</div><div class="ppt-presenter-meta"><span id="ppt-plan"></span><span id="ppt-section" hidden></span></div></div><div class="ppt-presenter-meta"><span id="ppt-sync" class="ppt-sync" data-state="disconnected" title="表示观众页面的软件同步状态,不代表 HDMI 等物理线缆状态">观众屏 · 未连接</span><button id="ppt-preflight" class="ppt-ui-btn" type="button">检查</button><button id="ppt-settings" class="ppt-ui-btn" type="button">设置</button><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><div class="ppt-frame-viewport"><iframe id="ppt-current-frame" class="ppt-frame" title="当前页预览"></iframe></div></div><div class="ppt-stage-card" id="ppt-next-card"><div class="ppt-stage-label"><span>下一页</span><span id="ppt-next-label"></span></div><div class="ppt-frame-viewport"><iframe id="ppt-next-frame" class="ppt-frame ppt-next-frame" title="下一页预览"></iframe></div></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 class="ppt-annotation-tools"><button class="ppt-ui-btn" data-ppt-tool="laser" type="button" title="激光笔(L)">激光</button><button class="ppt-ui-btn" data-ppt-tool="circle" type="button" title="圈选(C)">圈选</button><button id="ppt-ink-clear" class="ppt-ui-btn ppt-clear-btn" type="button" title="清除标记(X)">清除</button></span><span id="ppt-current-label"></span></div><div class="ppt-frame-viewport"><iframe id="ppt-current-frame" class="ppt-frame" title="当前页预览"></iframe><canvas id="ppt-presenter-ink" class="ppt-annotation-layer" aria-label="演讲标注层"></canvas></div></div><div class="ppt-stage-card" id="ppt-next-card"><div class="ppt-stage-label"><span>下一页</span><span id="ppt-next-label"></span></div><div class="ppt-frame-viewport"><iframe id="ppt-next-frame" class="ppt-frame ppt-next-frame" title="下一页预览"></iframe></div></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-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 class="ppt-presenter-bottom"><div class="ppt-timer"><div class="ppt-time-copy"><div id="ppt-clock" class="ppt-clock">00:00</div><div id="ppt-time-detail" class="ppt-time-detail">本页 00:00 / —</div></div><button id="ppt-timer-toggle" class="ppt-ui-btn" type="button">开始</button><button id="ppt-timer-reset" class="ppt-ui-btn" type="button">重置</button><button id="ppt-rehearsal" 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><br><button id="ppt-auto-toggle" class="ppt-ui-btn ppt-auto-state" type="button">自动翻页 · 关闭</button></div></div>
<div id="ppt-dialog" class="ppt-dialog" hidden><div class="ppt-dialog-panel" role="dialog" aria-modal="true"><div class="ppt-dialog-head"><div id="ppt-dialog-title" class="ppt-dialog-title"></div><button id="ppt-dialog-close" class="ppt-ui-btn" type="button">关闭</button></div><div id="ppt-dialog-body" class="ppt-dialog-body"></div></div></div>
</div>`); </div>`);
document.getElementById('ppt-reopen').onclick=reopenAudience; document.getElementById('ppt-reopen').onclick=reopenAudience;
document.getElementById('ppt-exit').onclick=exitPresenter; document.getElementById('ppt-exit').onclick=exitPresenter;
document.getElementById('ppt-preflight').onclick=openPreflight;
document.getElementById('ppt-settings').onclick=openSettings;
document.getElementById('ppt-first').onclick=()=>go(0,{force:true}); document.getElementById('ppt-first').onclick=()=>go(0,{force:true});
document.getElementById('ppt-prev').onclick=()=>go(idx-1,{force:true}); document.getElementById('ppt-prev').onclick=()=>go(idx-1,{force:true});
document.getElementById('ppt-grid').onclick=toggleOverview; document.getElementById('ppt-grid').onclick=toggleOverview;
@@ -801,13 +926,20 @@ window.__pptPresenter=(()=>{
document.getElementById('ppt-last').onclick=()=>go(idx===total-1?0:total-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-toggle').onclick=toggleTimer;
document.getElementById('ppt-timer-reset').onclick=resetTimer; document.getElementById('ppt-timer-reset').onclick=resetTimer;
document.getElementById('ppt-rehearsal').onclick=toggleRehearsal;
document.getElementById('ppt-auto-toggle').onclick=toggleAutoAdvance;
document.getElementById('ppt-note-smaller').onclick=()=>setNoteSize(noteSize-1); document.getElementById('ppt-note-smaller').onclick=()=>setNoteSize(noteSize-1);
document.getElementById('ppt-note-larger').onclick=()=>setNoteSize(noteSize+1); document.getElementById('ppt-note-larger').onclick=()=>setNoteSize(noteSize+1);
document.getElementById('ppt-note-editor').addEventListener('input',saveNote); document.getElementById('ppt-note-editor').addEventListener('input',saveNote);
document.querySelectorAll('[data-ppt-tool]').forEach(button=>button.onclick=()=>setAnnotationTool(button.dataset.pptTool));
document.getElementById('ppt-ink-clear').onclick=()=>clearAnnotations(true);
const ink=document.getElementById('ppt-presenter-ink');
ink.addEventListener('pointerdown',onAnnotationPointerDown);ink.addEventListener('pointermove',onAnnotationPointerMove);ink.addEventListener('pointerup',onAnnotationPointerUp);ink.addEventListener('pointercancel',onAnnotationPointerUp);ink.addEventListener('pointerleave',onAnnotationPointerLeave);
document.getElementById('ppt-dialog-close').onclick=closeDialog;
document.getElementById('ppt-dialog').onclick=event=>{if(event.target.id==='ppt-dialog')closeDialog();};
if('ResizeObserver'in window){presenterFrameObserver=new ResizeObserver(()=>requestAnimationFrame(fitPresenterFrames));presenterFrameObserver.observe(document.querySelector('.ppt-preview-stack'));} if('ResizeObserver'in window){presenterFrameObserver=new ResizeObserver(()=>requestAnimationFrame(fitPresenterFrames));presenterFrameObserver.observe(document.querySelector('.ppt-preview-stack'));}
else addEventListener('resize',fitPresenterFrames); else addEventListener('resize',fitPresenterFrames);
setNoteSize(noteSize,false); setNoteSize(noteSize,false);applyLayout(layoutMode,false);requestAnimationFrame(fitPresenterFrames);
requestAnimationFrame(fitPresenterFrames);
} }
function updateShell(){ function updateShell(){
if(mode!=='presenter')return;mountShell(); if(mode!=='presenter')return;mountShell();
@@ -818,28 +950,143 @@ window.__pptPresenter=(()=>{
document.getElementById('ppt-current-label').textContent=`${idx+1} / ${total}`; document.getElementById('ppt-current-label').textContent=`${idx+1} / ${total}`;
document.getElementById('ppt-next-label').textContent=idx===total-1?'已到尾页':`${next+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-notes-title').textContent=n.title||`第 ${idx+1} 页`;
document.getElementById('ppt-note-meta').textContent=`建议 ${Number(n.minutes||0).toFixed(1)} 分钟 · ${id}`; const target=plannedMsAt(idx);document.getElementById('ppt-note-meta').textContent=`建议 ${target?(target/60000).toFixed(1):'—'} 分钟 · ${id}`;
document.getElementById('ppt-page-title').textContent=n.title||`第 ${idx+1} 页`; 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-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-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?'重新开始':'尾页'; 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='已保存';} if(renderedNoteId!==id){renderedNoteId=id;editor.value=loadNoteValue(id,idx,n);editor.scrollTop=0;document.getElementById('ppt-saved').textContent='已保存';}
requestAnimationFrame(fitPresenterFrames); requestAnimationFrame(fitPresenterFrames);renderTimer();updateSyncStatus();
updateSyncStatus();
} }
function saveNote(){ function saveNote(){
const saved=document.getElementById('ppt-saved');saved.textContent='保存中…';saved.classList.remove('ppt-saved');clearTimeout(saveTimer); 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); 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){ 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));}
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 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 currentSlideElapsed(i=idx){return slideElapsed[i]+(timerRunning&&timedSlideIndex===i?Date.now()-slideTimerStartedAt:0);}
function toggleTimer(){if(timerRunning){elapsed=currentElapsed();timerRunning=false;}else{timerStartedAt=Date.now();timerRunning=true;}document.getElementById('ppt-timer-toggle').textContent=timerRunning?'暂停':'继续';renderTimer();} function commitRunningTime(now=Date.now()){
function resetTimer(){elapsed=0;timerRunning=false;timerStartedAt=0;document.getElementById('ppt-timer-toggle').textContent='开始计时';renderTimer();} if(!timerRunning)return;elapsed+=now-timerStartedAt;slideElapsed[timedSlideIndex]+=now-slideTimerStartedAt;timerStartedAt=now;slideTimerStartedAt=now;
}
function rollSlideTimer(nextIndex){if(timerRunning)commitRunningTime();timedSlideIndex=nextIndex;slideTimerStartedAt=Date.now();}
function renderTimer(){
const totalUsed=currentElapsed(),pageUsed=currentSlideElapsed(),target=plannedMsAt(idx),clock=document.getElementById('ppt-clock');
if(clock)clock.textContent=durationText(totalUsed);
const detail=document.getElementById('ppt-time-detail');
if(detail){
detail.classList.toggle('is-over',Boolean(target&&pageUsed>target));
detail.textContent=target?(pageUsed<=target?`本页 ${durationText(pageUsed)} / ${durationText(target)} · 剩余 ${durationText(target-pageUsed)}`:`本页 ${durationText(pageUsed)} / ${durationText(target)} · 超时 ${durationText(pageUsed-target)}`):`本页 ${durationText(pageUsed)} / —`;
}
const totalPlan=totalPlannedMs(),plan=document.getElementById('ppt-plan');
if(plan)plan.textContent=totalPlan?`计划 ${Number(totalPlan/60000).toFixed(1)} 分钟 · 预计 ${clockTime(Date.now()+Math.max(0,totalPlan-totalUsed))}`:'计划 —';
const section=sectionInfo(idx),sectionEl=document.getElementById('ppt-section');
if(sectionEl){sectionEl.hidden=!section;sectionEl.textContent=section?`${section.name} · ${section.position}/${section.count}${section.planned?` · 剩余 ${durationText(Math.max(0,section.planned-section.used))}`:''}`:'';}
const timerButton=document.getElementById('ppt-timer-toggle');if(timerButton)timerButton.textContent=timerRunning?'暂停':(totalUsed?'继续':'开始');
const rehearsalButton=document.getElementById('ppt-rehearsal');if(rehearsalButton){rehearsalButton.textContent=rehearsalActive?'结束排练':'排练';rehearsalButton.classList.toggle('ppt-rehearsal-on',rehearsalActive);}
renderAutoState();
}
function toggleTimer(){const now=Date.now();if(timerRunning){commitRunningTime(now);timerRunning=false;}else{timerStartedAt=now;slideTimerStartedAt=now;timedSlideIndex=idx;timerRunning=true;}renderTimer();}
function resetTimer(){
elapsed=0;slideElapsed=Array(total).fill(0);timedSlideIndex=idx;
if(timerRunning){timerStartedAt=Date.now();slideTimerStartedAt=timerStartedAt;}else{timerStartedAt=0;slideTimerStartedAt=0;}
renderTimer();
}
function rehearsalHistory(){try{return JSON.parse(localStorage.getItem(storageKey('rehearsals'))||'[]');}catch(e){return[];}}
function toggleRehearsal(){
if(rehearsalActive){finishRehearsal(true);return;}
rehearsalActive=true;if(timerRunning){commitRunningTime();timerRunning=false;}resetTimer();toggleTimer();renderTimer();
}
function finishRehearsal(showSummary){
if(!rehearsalActive)return;if(timerRunning){commitRunningTime();timerRunning=false;}
const result={endedAt:Date.now(),totalMs:elapsed,slides:slideElapsed.map((ms,i)=>({index:i,id:slideId(i),title:noteAt(i).title||slideId(i),ms,targetMs:plannedMsAt(i)}))};
const history=rehearsalHistory();history.unshift(result);try{localStorage.setItem(storageKey('rehearsals'),JSON.stringify(history.slice(0,5)));}catch(e){}
rehearsalActive=false;renderTimer();if(showSummary)openRehearsalSummary(result,history.slice(0,5));
}
function openRehearsalSummary(result,history){
const rows=result.slides.filter(item=>item.ms>0).map(item=>`<tr><td>${item.index+1}</td><td>${escapeHTML(item.title)}</td><td>${durationText(item.ms)}</td><td>${item.targetMs?signedDuration(item.ms-item.targetMs):'—'}</td></tr>`).join('');
const recent=history.map(item=>`<div class="ppt-dialog-row"><span>${new Date(item.endedAt).toLocaleString()}</span><strong>${durationText(item.totalMs)}</strong></div>`).join('');
openDialog('排练记录',`<div class="ppt-dialog-section"><div class="ppt-dialog-row"><span>本次总时长</span><strong>${durationText(result.totalMs)}</strong></div><table class="ppt-summary"><thead><tr><th>#</th><th>页面</th><th>实际</th><th>与计划相比</th></tr></thead><tbody>${rows||'<tr><td colspan="4">没有记录到页面时间</td></tr>'}</tbody></table></div><div class="ppt-dialog-section"><h3>最近排练</h3>${recent}</div>`);
}
function autoDurationMs(i=idx){const explicit=Number(noteAt(i).autoAdvanceSeconds);const seconds=Number.isFinite(explicit)&&explicit>0?explicit:autoGlobalSeconds;return seconds>0?seconds*1000:null;}
function armAutoAdvance(){autoDeadline=0;autoRemainingMs=autoDurationMs()||0;}
function autoBlockedReason(){
if(overviewOn)return'总览中';if(annotationTool!=='none')return'标注中';if(screenMode!=='normal')return'观众屏已暂停';if(document.hidden)return'窗口不可见';
const dialog=document.getElementById('ppt-dialog');if(dialog&&!dialog.hidden)return'设置中';
if(audienceOpenedAt&&syncState()!=='synced')return'等待观众屏同步';return'';
}
function updateAutoAdvance(){
if(!autoEnabled)return;
const duration=autoDurationMs();if(!duration||idx===total-1)return;
const blocked=autoBlockedReason(),now=Date.now();
if(blocked){if(autoDeadline){autoRemainingMs=Math.max(0,autoDeadline-now);autoDeadline=0;}return;}
if(!autoDeadline)autoDeadline=now+(autoRemainingMs||duration);
autoRemainingMs=Math.max(0,autoDeadline-now);
if(now>=autoDeadline){autoDeadline=0;autoRemainingMs=0;go(idx+1,{force:true});}
}
function renderAutoState(){
const button=document.getElementById('ppt-auto-toggle');if(!button)return;
const duration=autoDurationMs(),blocked=autoEnabled?autoBlockedReason():'';
button.classList.toggle('is-on',autoEnabled&&!blocked);button.classList.toggle('is-paused',autoEnabled&&Boolean(blocked));
if(!autoEnabled)button.textContent='自动翻页 · 关闭';
else if(!duration)button.textContent='自动翻页 · 等待时长';
else if(idx===total-1)button.textContent='自动翻页 · 已结束';
else if(blocked)button.textContent=`自动翻页 · ${blocked}`;
else button.textContent=`自动翻页 · ${durationText(autoRemainingMs||duration)}`;
}
function toggleAutoAdvance(){
if(!autoEnabled&&!autoDurationMs()){openSettings();return;}
autoEnabled=!autoEnabled;localStorage.setItem(storageKey('auto-enabled'),autoEnabled?'1':'0');armAutoAdvance();renderAutoState();
}
function openDialog(title,html){
const dialog=document.getElementById('ppt-dialog');if(!dialog)return;
document.getElementById('ppt-dialog-title').textContent=title;document.getElementById('ppt-dialog-body').innerHTML=html;dialog.hidden=false;renderAutoState();
}
function closeDialog(){const dialog=document.getElementById('ppt-dialog');if(dialog)dialog.hidden=true;renderAutoState();}
function applyLayout(value,persist=true){
layoutMode=['balanced','slides','notes'].includes(value)?value:'balanced';
document.getElementById('ppt-presenter')?.setAttribute('data-layout',layoutMode);
if(persist)localStorage.setItem(storageKey('layout'),layoutMode);requestAnimationFrame(fitPresenterFrames);
}
function applyAudienceScreen(value){
const cover=document.getElementById('ppt-audience-cover');if(!cover)return;
if(value==='black'||value==='white'){cover.style.display='block';cover.style.background=value;}else cover.style.display='none';
}
function setScreenMode(value){
const next=['normal','black','white','frozen'].includes(value)?value:'normal',wasFrozen=audienceFrozen;
screenMode=next;audienceFrozen=next==='frozen';emit({type:'screen',mode:next});
if(wasFrozen&&!audienceFrozen)sendGoto(true);updateSyncStatus();armAutoAdvance();renderAutoState();
}
function openSettings(){
openDialog('演示设置',`<div class="ppt-dialog-section"><h3>自动翻页</h3><div class="ppt-dialog-grid"><label class="ppt-dialog-row"><span>启用</span><input id="ppt-auto-enabled-setting" type="checkbox" ${autoEnabled?'checked':''}></label><div class="ppt-field"><label for="ppt-auto-seconds-setting">全局间隔(秒)</label><input id="ppt-auto-seconds-setting" type="number" min="1" step="1" value="${autoGlobalSeconds||''}" placeholder="未设置"></div><button id="ppt-settings-save" class="ppt-ui-btn" type="button">保存自动翻页</button></div><p class="ppt-muted">页面里的 autoAdvanceSeconds 优先于全局间隔;建议讲述时间 minutes 不会自动触发翻页。</p></div><div class="ppt-dialog-section"><h3>演讲者布局</h3><div class="ppt-dialog-grid"><button class="ppt-ui-btn" data-layout-choice="balanced" type="button">均衡</button><button class="ppt-ui-btn" data-layout-choice="slides" type="button">页面优先</button><button class="ppt-ui-btn" data-layout-choice="notes" type="button">备注优先</button></div></div><div class="ppt-dialog-section"><h3>观众屏</h3><div class="ppt-dialog-grid"><button class="ppt-ui-btn" data-screen-choice="normal" type="button">正常显示</button><button class="ppt-ui-btn" data-screen-choice="black" type="button">黑屏 B</button><button class="ppt-ui-btn" data-screen-choice="white" type="button">白屏 W</button><button class="ppt-ui-btn" data-screen-choice="frozen" type="button">冻结 F</button></div></div><div class="ppt-dialog-section"><button id="ppt-open-shortcuts" class="ppt-ui-btn" type="button">查看快捷键</button></div>`);
document.querySelectorAll('[data-layout-choice]').forEach(button=>{button.classList.toggle('ppt-primary',button.dataset.layoutChoice===layoutMode);button.onclick=()=>{applyLayout(button.dataset.layoutChoice);openSettings();};});
document.querySelectorAll('[data-screen-choice]').forEach(button=>{button.classList.toggle('ppt-primary',button.dataset.screenChoice===screenMode);button.onclick=()=>{setScreenMode(button.dataset.screenChoice);openSettings();};});
document.getElementById('ppt-settings-save').onclick=()=>{autoEnabled=document.getElementById('ppt-auto-enabled-setting').checked;autoGlobalSeconds=Math.max(0,Number(document.getElementById('ppt-auto-seconds-setting').value||0));localStorage.setItem(storageKey('auto-enabled'),autoEnabled?'1':'0');localStorage.setItem(storageKey('auto-seconds'),String(autoGlobalSeconds));armAutoAdvance();closeDialog();};
document.getElementById('ppt-open-shortcuts').onclick=openShortcuts;
}
function openShortcuts(){
const items=[['← / →','上一页 / 下一页'],['Home / End','首页 / 尾页'],['Esc','总览;标注工具激活时先退出工具'],['L','激光笔'],['C','圈选'],['X','清除标记'],['B / W','观众屏黑屏 / 白屏'],['F','冻结 / 恢复观众屏'],['A','开启 / 关闭自动翻页'],['R','开始 / 结束排练'],['?','快捷键说明']];
openDialog('快捷键',`<div class="ppt-shortcuts">${items.map(([key,label])=>`<kbd>${key}</kbd><span>${label}</span>`).join('')}</div>`);
}
function preflightChecks(){
const images=[...document.images],videos=[...document.querySelectorAll('video')],frame=document.getElementById('ppt-current-frame'),rect=frame?.getBoundingClientRect();
return[
{label:'观众屏连接',ok:['synced','frozen'].includes(syncState()),detail:`当前状态:${document.getElementById('ppt-sync')?.textContent||'未连接'}`},
{label:'弹窗权限',ok:!popupBlocked,detail:popupBlocked?'浏览器阻止了观众窗口':'未检测到弹窗拦截'},
{label:'全屏状态',ok:Boolean(document.fullscreenElement),warn:true,detail:document.fullscreenElement?'已进入全屏':'尚未全屏,可在演讲前进入'},
{label:'字体加载',ok:!document.fonts||document.fonts.status==='loaded',detail:document.fonts?`状态:${document.fonts.status}`:'浏览器不提供字体状态'},
{label:'图片资源',ok:images.every(image=>image.complete&&image.naturalWidth>0),detail:`${images.filter(image=>image.complete&&image.naturalWidth>0).length} / ${images.length} 已加载`},
{label:'视频资源',ok:videos.every(video=>video.readyState>=2),detail:videos.length?`${videos.filter(video=>video.readyState>=2).length} / ${videos.length} 可播放`:'没有视频'},
{label:'当前页 16:9',ok:Boolean(rect&&Math.abs(rect.width/rect.height-16/9)<.003),detail:rect?`${Math.round(rect.width)} × ${Math.round(rect.height)}`:'尚未渲染'}
];
}
function openPreflight(){
const checks=preflightChecks(),html=checks.map(item=>`<div class="ppt-dialog-row"><div class="ppt-check ${item.ok?'is-ok':(item.warn?'is-warn':'')}"><span class="ppt-check-dot"></span><span><strong>${item.label}</strong><br><span class="ppt-muted">${item.detail}</span></span></div><strong>${item.ok?'正常':(item.warn?'建议':'异常')}</strong></div>`).join('');
openDialog('演前检查',`<div class="ppt-dialog-section">${html}</div><div class="ppt-dialog-section"><button id="ppt-preflight-refresh" class="ppt-ui-btn" type="button">重新检查</button> <button id="ppt-enter-fullscreen" class="ppt-ui-btn" type="button">进入全屏</button></div><p class="ppt-muted">这里只能检查浏览器和观众页面的软件状态,不能确认 HDMI、转接器或投影仪线缆是否物理连接。</p>`);
document.getElementById('ppt-preflight-refresh').onclick=openPreflight;
document.getElementById('ppt-enter-fullscreen').onclick=async()=>{try{await document.documentElement.requestFullscreen();}catch(e){}openPreflight();};
}
function initTransport(){ function initTransport(){
if(transportReady)return;transportReady=true; if(transportReady)return;transportReady=true;
@@ -847,62 +1094,89 @@ window.__pptPresenter=(()=>{
addEventListener('message',e=>receive(e.data)); addEventListener('message',e=>receive(e.data));
addEventListener('storage',e=>{if(e.key===syncKey()&&e.newValue){try{receive(JSON.parse(e.newValue));}catch(err){}}}); addEventListener('storage',e=>{if(e.key===syncKey()&&e.newValue){try{receive(JSON.parse(e.newValue));}catch(err){}}});
} }
function emit(payload){ function emit(payload,{persist=true}={}){
const message={__guizangPptSync:1,session,role:mode,...payload,at:Date.now(),nonce:Math.random().toString(36).slice(2)}; 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{channel?.postMessage(message);}catch(e){}
try{localStorage.setItem(syncKey(),JSON.stringify(message));}catch(e){} if(persist)try{localStorage.setItem(syncKey(),JSON.stringify(message));}catch(e){}
try{if(mode==='presenter'&&audienceWindow&&!audienceWindow.closed)audienceWindow.postMessage(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){} try{if(mode==='audience'&&window.opener&&!window.opener.closed)window.opener.postMessage(message,'*');}catch(e){}
} }
function receive(message){ function receive(message){
if(!message||message.__guizangPptSync!==1||message.session!==session||message.role===mode)return; if(!message||message.__guizangPptSync!==1||message.session!==session||message.role===mode)return;
if(message.nonce){if(seenNonceSet.has(message.nonce))return;seenNonceSet.add(message.nonce);seenNonces.push(message.nonce);if(seenNonces.length>256)seenNonceSet.delete(seenNonces.shift());}
if(mode==='presenter'){ if(mode==='presenter'){
if(message.type==='ready'){popupBlocked=false;sendGoto(true);} if(message.type==='ready'){popupBlocked=false;sendGoto(true);}
if(message.type==='ack'){lastAckAt=Date.now();lastAckIndex=Number(message.index);lastAckSeq=Number(message.seq);updateSyncStatus();} if(message.type==='ack'){lastAckAt=Date.now();lastAckIndex=Number(message.index);lastAckSeq=Number(message.seq);updateSyncStatus();}
return; return;
} }
if(mode==='audience'&&(message.type==='goto'||message.type==='ping')){ if(mode!=='audience')return;
if(message.type==='goto')go(Number(message.index),{force:true}); if(message.type==='screen'){applyAudienceScreen(message.mode);return;}
if(message.type==='laser'&&Number(message.index)===idx){audienceLaser=message.point||null;audienceLaserHideAt=Date.now()+650;renderInks();return;}
if(message.type==='circle'&&Number(message.index)===idx&&message.circle){audienceCircles.push(message.circle);renderInks();return;}
if(message.type==='clear'&&Number(message.index)===idx){audienceCircles=[];renderInks();return;}
if(message.type==='goto'||message.type==='ping'){
if(message.type==='goto'){audienceCircles=Array.isArray(message.circles)?message.circles:[];applyAudienceScreen(message.screenMode);go(Number(message.index),{force:true});}
emit({type:'ack',index:idx,seq:Number(message.seq)}); 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 sendGoto(force=false){
if(mode!=='presenter'||audienceFrozen)return;if(force||lastAckIndex!==idx)seq++;
emit({type:'goto',index:idx,seq,screenMode,circles});updateSyncStatus();
}
function syncState(){ function syncState(){
if(popupBlocked)return'blocked'; if(popupBlocked)return'blocked';if(audienceWindow&&audienceWindow.closed)return'disconnected';if(audienceFrozen)return'frozen';
if(audienceWindow&&audienceWindow.closed)return'disconnected';
if(!lastAckAt)return audienceOpenedAt&&Date.now()-audienceOpenedAt<2600?'connecting':(audienceOpenedAt?'unsynced':'disconnected'); if(!lastAckAt)return audienceOpenedAt&&Date.now()-audienceOpenedAt<2600?'connecting':(audienceOpenedAt?'unsynced':'disconnected');
if(Date.now()-lastAckAt>2600||lastAckIndex!==idx||lastAckSeq<seq)return'unsynced'; if(Date.now()-lastAckAt>2600||lastAckIndex!==idx||lastAckSeq<seq)return'unsynced';return'synced';
return'synced';
} }
function updateSyncStatus(){ function updateSyncStatus(){
const el=document.getElementById('ppt-sync');if(!el)return;const state=syncState(); const el=document.getElementById('ppt-sync');if(!el)return;const state=syncState();
const labels={blocked:'弹窗被拦截',disconnected:'未连接',connecting:'连接中',unsynced:'未同步',synced:`已同步 · 第 ${idx+1} 页`}; const labels={blocked:'弹窗被拦截',disconnected:'未连接',connecting:'连接中',unsynced:'未同步',frozen:'已冻结',synced:`已同步 · 第 ${idx+1} 页`};
el.dataset.state=state;el.textContent=`观众屏 · ${labels[state]}`; el.dataset.state=state;el.textContent=`观众屏 · ${labels[state]}`;
} }
function reopenAudience(){ function reopenAudience(){
initTransport();popupBlocked=false;audienceOpenedAt=Date.now();lastAckAt=0;lastAckIndex=-1;lastAckSeq=-1; initTransport();popupBlocked=false;audienceOpenedAt=Date.now();lastAckAt=0;lastAckIndex=-1;lastAckSeq=-1;
if(audienceFrozen){audienceFrozen=false;screenMode='normal';}
audienceWindow=window.open(frameUrl('audience',idx),`guizang-ppt-audience-${session}`,'popup=yes,width=1440,height=900'); audienceWindow=window.open(frameUrl('audience',idx),`guizang-ppt-audience-${session}`,'popup=yes,width=1440,height=900');
if(!audienceWindow)popupBlocked=true;updateSyncStatus(); if(!audienceWindow)popupBlocked=true;updateSyncStatus();
} }
function enterPresenter(openAudience=true){ function enterPresenter(openAudience=true){
if(mode==='presenter')return;lowPowerBeforePresenter=window.__lowPowerMode;mode='presenter';initTransport();mountShell(); 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}`; 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(); window.__setLowPowerMode?.(true,{persist:false});updateShell();armAutoAdvance();if(openAudience)reopenAudience();
} }
function exitPresenter(){ function exitPresenter(){
emit({type:'bye'});mode='deck';document.body.classList.remove('ppt-presenter');document.title=originalTitle;window.__setLowPowerMode?.(lowPowerBeforePresenter,{persist:false}); if(rehearsalActive)finishRehearsal(false);else if(timerRunning){commitRunningTime();timerRunning=false;}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}`;
document.body.insertAdjacentHTML('beforeend','<canvas id="ppt-audience-ink" aria-hidden="true"></canvas><div id="ppt-audience-cover" aria-hidden="true"></div>');
const resize=()=>{sizeInk(document.getElementById('ppt-audience-ink'),innerWidth,innerHeight);renderInks();};addEventListener('resize',resize);resize();
initTransport();emit({type:'ready',index:idx});
} }
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 initPreview(){mode='preview';document.body.classList.add('ppt-preview');window.__setLowPowerMode?.(true,{persist:false});}
function onSlideChange(){if(mode==='presenter'){updateShell();sendGoto();}} function onSlideChange(nextIndex){
if(mode!=='presenter')return;rollSlideTimer(nextIndex);circles=[];draftCircle=null;laserPoint=null;annotationPointerDown=false;armAutoAdvance();updateShell();sendGoto();
}
function handleKey(e){ function handleKey(e){
const editable=e.target?.matches?.('input,textarea,[contenteditable="true"]'); const editable=e.target?.matches?.('input,textarea,[contenteditable="true"]');
if(editable){if(e.key==='Escape')e.target.blur();return true;} if(editable){if(e.key==='Escape')e.target.blur();return true;}
if(mode==='audience'||mode==='preview'){e.preventDefault();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==='deck'&&e.key?.toLowerCase()==='p'&&!e.metaKey&&!e.ctrlKey&&!e.altKey){e.preventDefault();enterPresenter(true);return true;}
if(mode!=='presenter')return false; if(mode!=='presenter')return false;
if(e.key==='Escape'){e.preventDefault();toggleOverview();return true;} const key=e.key?.toLowerCase();
if(e.key==='?'||key==='/'){e.preventDefault();openShortcuts();return true;}
const dialog=document.getElementById('ppt-dialog');
if(!dialog?.hidden){if(e.key==='Escape'){e.preventDefault();closeDialog();}return true;}
if(e.key==='Escape'){e.preventDefault();if(annotationTool!=='none')setAnnotationTool('none');else toggleOverview();return true;}
if(overviewOn)return true; if(overviewOn)return true;
if(key==='l'){e.preventDefault();setAnnotationTool('laser');return true;}
if(key==='c'){e.preventDefault();setAnnotationTool('circle');return true;}
if(key==='x'){e.preventDefault();clearAnnotations(true);return true;}
if(key==='b'){e.preventDefault();setScreenMode(screenMode==='black'?'normal':'black');return true;}
if(key==='w'){e.preventDefault();setScreenMode(screenMode==='white'?'normal':'white');return true;}
if(key==='f'){e.preventDefault();setScreenMode(audienceFrozen?'normal':'frozen');return true;}
if(key==='a'){e.preventDefault();toggleAutoAdvance();return true;}
if(key==='r'){e.preventDefault();toggleRehearsal();return true;}
if(['ArrowRight','PageDown',' ','ArrowDown'].includes(e.key)){e.preventDefault();go(idx+1,{force:true});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(['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==='Home'){e.preventDefault();go(0,{force:true});return true;}
@@ -915,8 +1189,11 @@ window.__pptPresenter=(()=>{
document.getElementById('ppt-presenter-btn')?.addEventListener('click',()=>enterPresenter(true)); document.getElementById('ppt-presenter-btn')?.addEventListener('click',()=>enterPresenter(true));
if(requestedMode==='audience')initAudience();else if(requestedMode==='preview')initPreview();else if(requestedMode==='presenter')enterPresenter(false); 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); setInterval(()=>{
return{initMode,onSlideChange,handleKey,slideLabel,enterPresenter,reopenAudience}; renderTimer();renderInks();updateAutoAdvance();
if(mode==='presenter'&&Date.now()-lastPingAt>900){lastPingAt=Date.now();if(audienceWindow?.closed)updateSyncStatus();emit({type:'ping',index:idx,seq});updateSyncStatus();}
},250);
return{initMode,onSlideChange,handleKey,slideLabel,enterPresenter,reopenAudience,debugState:()=>({mode,idx,session,syncState:syncState(),timerRunning,rehearsalActive,autoEnabled,autoGlobalSeconds,autoRemainingMs,screenMode,annotationTool,circles:[...circles],audienceCircles:[...audienceCircles],layoutMode})};
})(); })();
addEventListener('keydown',e=>{ addEventListener('keydown',e=>{
+8 -2
View File
@@ -8,7 +8,7 @@
## 🔴 P0 · 一定不能犯的错 ## 🔴 P0 · 一定不能犯的错
### 0-P. 演讲者模式必须可控、可恢复、备注不串页 ### 0-P. 演讲者模式必须可控、可排练、可恢复、备注不串页
**现象**:演讲者端已经翻页,观众屏仍停在上一页;观众窗口关闭后没有提示;页面重排后,用户改过的讲稿跑到了另一页;或演讲模式入口变成页面右上角的突兀按钮。 **现象**:演讲者端已经翻页,观众屏仍停在上一页;观众窗口关闭后没有提示;页面重排后,用户改过的讲稿跑到了另一页;或演讲模式入口变成页面右上角的突兀按钮。
@@ -18,6 +18,12 @@
- 两个预览 iframe 必须严格保持 `16:9`,容器不足时等比缩小并留边,不得裁切或拉伸;小屏优先压缩下一页预览高度。 - 两个预览 iframe 必须严格保持 `16:9`,容器不足时等比缩小并留边,不得裁切或拉伸;小屏优先压缩下一页预览高度。
- 演讲者控制顺序固定为 `首页 / 上一页 / 总览 / 下一页 / 尾页`;尾页时最后一个按钮变为“重新开始”。 - 演讲者控制顺序固定为 `首页 / 上一页 / 总览 / 下一页 / 尾页`;尾页时最后一个按钮变为“重新开始”。
- 观众屏必须显示软件链路状态:`连接中 / 已同步 / 未同步 / 未连接 / 弹窗被拦截`,并始终提供“重新打开观众屏”。 - 观众屏必须显示软件链路状态:`连接中 / 已同步 / 未同步 / 未连接 / 弹窗被拦截`,并始终提供“重新打开观众屏”。
- 演讲者必须能看到总时长、本页时长、计划/剩余/超时;缺失 `minutes` 时显示横杠,不猜测。
- 排练模式记录每页实际时长,本地保留最近 5 次;只做数据汇总,不做 AI 教练评分。
- 自动翻页默认关闭,`minutes` 与 `autoAdvanceSeconds` 必须分开;总览、圈选、设置、页面隐藏、观众屏暂停或失同步时倒计必须暂停。
- 必须提供激光笔、圈选、清除、黑屏、白屏和冻结/恢复;标注坐标要归一化同步到观众屏。
- 演前检查必须覆盖观众屏、弹窗、全屏、字体、图片/视频和 16:9;同时明确提醒它不能检测物理线缆。
- 备注里的 `section / cue / interaction / delivery / advance / fallback / pronunciation / autoAdvanceSeconds` 都是可选字段;用户大纲没有就整段隐藏。
- 每页必须有稳定、唯一的 `data-slide-id`;`SPEAKER_NOTES` 数量、顺序和 ID 必须逐页一致。 - 每页必须有稳定、唯一的 `data-slide-id`;`SPEAKER_NOTES` 数量、顺序和 ID 必须逐页一致。
- 备注本地编辑按页面 ID 保存;切页重置滚动位置并显示保存状态。 - 备注本地编辑按页面 ID 保存;切页重置滚动位置并显示保存状态。
- 浏览器只能确认观众页面是否同步,不能确认 HDMI/投影线物理连接;现场仍要目视检查外接屏。 - 浏览器只能确认观众页面是否同步,不能确认 HDMI/投影线物理连接;现场仍要目视检查外接屏。
@@ -28,7 +34,7 @@
node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html
``` ```
浏览器里关闭观众窗口,确认状态变化;再点击“重新打开观众屏”,确认它恢复到演讲者当前页。缩小浏览器窗口再检查一次:当前页与下一页仍上下排列,两个 iframe 的 `width / height` 仍约等于 `16 / 9`,且没有超出预览容器。 浏览器里关闭观众窗口,确认状态变化;再点击“重新打开观众屏”,确认它恢复到演讲者当前页。再逐项实测计时、排练、自动翻页暂停/恢复、激光笔、圈选、清除、黑屏、白屏、冻结和演前检查。缩小浏览器窗口再检查一次:当前页与下一页仍上下排列,两个 iframe 的 `width / height` 仍约等于 `16 / 9`,且没有超出预览容器。
### 0-S. Swiss locked mode:正文页必须来自原始 22P ### 0-S. Swiss locked mode:正文页必须来自原始 22P
+103 -22
View File
@@ -1,22 +1,34 @@
# 演讲者模式与演讲备注契约 # 演讲者模式与演讲备注契约
制作任何需要现场讲述的 deck 时加载本文件。目标不是把页面文案复制到备注区,而是让页面、讲述和转场形成一条可以排练的叙事链。 制作任何需要现场讲述的 deck 时加载本文件。目标不是把页面文案复制到备注区,而是让页面、讲述、转场、时间和现场控制形成一条可以排练和可以恢复的叙事链。
## 1. 页面计划必须同时规划“看什么”和“说什么” ## 1. 页面计划同时规划“看什么”和“说什么”
在写 HTML 前,先做一张表: 在写 HTML 前,先做一张表。只填用户大纲、素材或已确认的现场信息能够支持的列:
| 页码 | `data-slide-id` | 页面目的 | 观众屏可见信息 | 演讲者补充 | 分钟 | 转场 | cue | | 页码 | `data-slide-id` | 章节 | 页面目的 | 观众屏可见信息 | 演讲者补充 | 建议时长 | 转场 | 可选现场信息 |
|---|---|---|---|---|---:|---|---| |---|---|---|---|---|---|---:|---|---|
- **观众屏可见信息**:观众此刻必须读到的结论、证据、结构或图像。 - **观众屏可见信息**:观众此刻必须读到的结论、证据、结构或图像。
- **演讲者补充**:背景、例子、判断依据、语气和细节,不要逐字复述页面。 - **演讲者补充**:背景、例子、判断依据、语气和细节,不要逐字复述页面。
- **转场**:解释下一页为什么紧接着出现,不要只写“下一页”。 - **转场**:解释下一页为什么紧接着出现,不要只写“下一页”。
- **cue**:可选的舞台动作,如停顿、提问、播放视频、切 Demo、提醒敏感信息。 - **可选现场信息**:可包含停顿、提问、Demo、视频、敏感信息、翻页时机、备用方案或读音。
默认生成“提词卡”,不是逐字稿。只有用户明确要求逐字稿时,才写完整口播。 默认生成“提词卡”,不是逐字稿。只有用户明确要求逐字稿时,才写完整口播。
## 2. 稳定的页面 ID ## 2. 模型如何把大纲信息带进演讲模式
对用户的大纲、项目记录和补充说明做结构化提取,不要自行增加现场事实。
- 已给出章节名或可靠推断的连续章节:写入 `section`。
- 已给出总时长或逐页节奏:可给出 `minutes`,总建议时长不超过现场时长的 90%。
- 大纲明确写了停顿、提问、Demo、视频、语气、翻页点或备用路径:分别写入对应的可选字段。
- 用户明确要求某页自动停留多少秒:才写 `autoAdvanceSeconds`。
- 没有提供的信息不猜测、不写“待补充”到演讲界面,直接省略字段。界面对缺失时长显示横杠,其他可选模块整段隐藏。
`minutes` 是讲述计划,`autoAdvanceSeconds` 是播放行为,二者必须分开。不得因为一页建议讲 1 分钟,就默认 60 秒后自动翻页。
## 3. 稳定的页面 ID
每一页都必须有唯一、语义化且稳定的 ID: 每一页都必须有唯一、语义化且稳定的 ID:
@@ -28,7 +40,7 @@
- 页面重排时保留原 ID;只有页面语义彻底改变时才更换。 - 页面重排时保留原 ID;只有页面语义彻底改变时才更换。
- 不要用页码作为 ID。演讲者在浏览器里修改的备注按 ID 保存,稳定 ID 可避免重排后串页。 - 不要用页码作为 ID。演讲者在浏览器里修改的备注按 ID 保存,稳定 ID 可避免重排后串页。
## 3. `SPEAKER_NOTES` 数据结构 ## 4. `SPEAKER_NOTES` 数据结构
在 deck 中保留一条与每页 ID 对应的记录,顺序必须与 slide 完全一致: 在 deck 中保留一条与每页 ID 对应的记录,顺序必须与 slide 完全一致:
@@ -37,6 +49,7 @@ const SPEAKER_NOTES = [
{ {
id: 'codepilot-capabilities', id: 'codepilot-capabilities',
title: 'CodePilot 能做什么', title: 'CodePilot 能做什么',
section: 'CodePilot',
minutes: 0.9, minutes: 0.9,
purpose: '先让观众建立完整产品认知,再进入实现过程', purpose: '先让观众建立完整产品认知,再进入实现过程',
talk: [ talk: [
@@ -45,39 +58,81 @@ const SPEAKER_NOTES = [
'强调模型、工具和上下文可以在同一工作流里协作' '强调模型、工具和上下文可以在同一工作流里协作'
], ],
transition: '观众知道产品长什么样后,再回答为什么要这样实现', transition: '观众知道产品长什么样后,再回答为什么要这样实现',
cue: '按截图顺序指向四个能力区' cue: '按截图顺序指向四个能力区',
advance: '讲完“同一工作流”后翻页'
} }
]; ];
window.__SPEAKER_NOTES__ = SPEAKER_NOTES; window.__SPEAKER_NOTES__ = SPEAKER_NOTES;
``` ```
硬规则: 必填字段:
- `id`、`title`、`minutes`、`purpose`、`talk`、`transition` 必填,`cue` 可选。 - `id`、`title`、`purpose`、`talk`、`transition`。
- `talk` 默认 3–5 条,每条只表达一个意思;封面、章节页和纯过渡页可以更短。 - `talk` 默认 3–5 条,每条只表达一个意思;封面、章节页和纯过渡页可以更短。
- 不编造原素材没有的数字、事实或经历;信息缺失时标记“待补充”或向用户询问。
- 总建议时长不超过用户时长的 90%,给停顿、现场互动和意外留出约 10% 缓冲。
- 封面只负责建立问题与预期;Demo 页写清操作顺序和失败兜底;尾页回扣开场并给出行动点。
## 4. 演讲者界面行为 可选字段:
| 字段 | 用途 | 界面缺失时 |
|---|---|---|
| `section` | 章节名和章节进度 | 隐藏章节行 |
| `minutes` | 本页建议讲述分钟 | 显示 `—` |
| `cue` | 停顿、Demo、视频、敏感信息提醒 | 隐藏 |
| `interaction` | 现场提问、举手、投票或 Q&A | 隐藏 |
| `delivery` | 语气、重音、语速或停顿 | 隐藏 |
| `advance` | 人工翻页的句子或动作时机 | 隐藏 |
| `fallback` | Demo / 视频 / 网络失败时的备用说法 | 隐藏 |
| `pronunciation` | 人名、缩写和外语读音 | 隐藏 |
| `autoAdvanceSeconds` | 本页自动停留秒数,优先于全局间隔 | 使用全局间隔或不自动翻页 |
`cue`、`interaction`、`delivery`、`advance`、`fallback`、`pronunciation` 可以是字符串,也可以是字符串数组。
## 5. 演讲者界面行为
- 普通 deck 的右下角控制区显示 `P 演讲模式`,不单独悬浮一个抢眼按钮。 - 普通 deck 的右下角控制区显示 `P 演讲模式`,不单独悬浮一个抢眼按钮。
- 点击后当前窗口进入演讲者视图,并打开独立观众屏。 - 点击后当前窗口进入演讲者视图,并打开独立观众屏。
- 演讲者主体保持两栏:左侧预览、右侧备注;左侧“当前页在上、下一页在下”,不要再把两个预览左右并排成第三栏。 - 主体保持两栏:左侧预览、右侧备注;左侧当前页在上、下一页在下。
- 当前页与下一页 iframe 必须始终保持严格 `16:9`。空间不足时在预览容器内留边并整体等比缩小,不得裁切、压扁或让页面内部文字重新排版;小屏优先缩小下一页预览,把空间留给当前页。 - 当前页与下一页 iframe 始终严格保持 `16:9`。空间不足时留边并整体等比缩小,不得裁切、压扁或让页面内部文字重新排版。
- 小屏优先缩小下一页预览,把空间留给当前页。
- 控制顺序固定为:`首页 / 上一页 / 总览 / 下一页 / 尾页`;到尾页后“尾页”变为“重新开始”。 - 控制顺序固定为:`首页 / 上一页 / 总览 / 下一页 / 尾页`;到尾页后“尾页”变为“重新开始”。
- 总览卡片显示页码、标题和当前页高亮,点击缩略图直接跳页;`ESC` 同样切换总览。 - 总览卡片显示页码、标题和当前页高亮,点击缩略图直接跳页;`ESC` 切换总览。
- 计时器由用户显式开始,可暂停、继续、重置;不要进入模式就自动计时。 - 演讲者可从“均衡 / 页面优先 / 备注优先”三种布局选择。
- 备注编辑按 `data-slide-id` 存入 `localStorage`,显示保存状态;切页后备注滚动回顶部,并允许调整字号。 - 备注编辑按 `data-slide-id` 存入 `localStorage`,显示保存状态;切页后备注滚动回顶部,并允许调整字号。
- `Home` / `End`、方向键、PageUp/PageDown 与可见按钮保持一致;光标在备注编辑器中时不得触发翻页。 - `Home` / `End`、方向键、PageUp/PageDown 与可见按钮保持一致;光标在备注编辑器中时不得触发翻页。
## 5. 观众屏同步与恢复 ## 6. 时间控制与排练
- 计时器由用户显式开始,可暂停、继续和重置;不要进入模式就自动计时。
- 同时显示总实际时长、本页实际时长、本页计划、剩余或超时。
- 只有每页都有 `minutes` 时,才显示总计划和预计结束时间;部分页缺失时显示横杠。
- 有 `section` 时显示章节位置;该章节每页都有计划时长时,再显示章节剩余时间。
- 排练模式记录每页实际时长和总时长,本地保留最近 5 次。
- 排练结果只做数据汇总,不生成“AI 教练”式评判。
## 7. 自动翻页
- 默认关闭,由用户显式开启。
- 可设置全局间隔;页面存在 `autoAdvanceSeconds` 时优先使用页面值。
- 进入总览、正在圈选、打开设置、浏览器页面不可见、观众屏黑屏/白屏/冻结或观众屏失去同步时,自动倒计暂停。
- 离开暂停状态后从原剩余时间继续,不重头计时。
- 到尾页后停止,不自动回到首页。
## 8. 激光笔、圈选与观众屏控制
- `L` 切换激光笔,红点短暂淡出,不保存。
- `C` 切换圈选,圈选保留在当前页;翻页时清除。
- `X` 清除当前页标注。
- 所有坐标归一化后传给观众屏,不受演讲者屏和观众屏尺寸差异影响。
- `B` 切换观众屏黑屏,`W` 切换白屏,`F` 冻结/恢复观众屏。
- 冻结时演讲者可以继续翻页,观众屏保留原页;恢复时立即追平演讲者当前页。
## 9. 观众屏同步与恢复
模板同时使用窗口 `postMessage`、`BroadcastChannel` 和 `storage` 事件同步。观众页在加载和每次翻页后回传确认,演讲者端显示: 模板同时使用窗口 `postMessage`、`BroadcastChannel` 和 `storage` 事件同步。观众页在加载和每次翻页后回传确认,演讲者端显示:
- **连接中**:观众窗口已打开,尚未收到确认。 - **连接中**:观众窗口已打开,尚未收到确认。
- **已同步**:观众页确认的页码与演讲者页相同,心跳有效。 - **已同步**:观众页确认的页码与演讲者页相同,心跳有效。
- **未同步**:确认超时或两端页码不同。 - **未同步**:确认超时或两端页码不同。
- **已冻结**:观众屏故意保留原页,不是同步故障。
- **未连接**:观众窗口未打开或已关闭。 - **未连接**:观众窗口未打开或已关闭。
- **弹窗被拦截**:浏览器阻止打开观众窗口。 - **弹窗被拦截**:浏览器阻止打开观众窗口。
@@ -85,11 +140,37 @@ window.__SPEAKER_NOTES__ = SPEAKER_NOTES;
浏览器只能确认观众**页面/窗口**的软件同步状态,不能检测 HDMI、转接器或投影仪线缆是否物理断开。现场仍应目视确认外接屏。 浏览器只能确认观众**页面/窗口**的软件同步状态,不能检测 HDMI、转接器或投影仪线缆是否物理断开。现场仍应目视确认外接屏。
## 6. 生成后验证 ## 10. 演前检查和快捷键
“检查”面板检查观众屏同步、弹窗权限、全屏、字体、图片、视频和当前页 16:9。检查结果不得宣称物理外接屏已连接。
| 键 | 行为 |
|---|---|
| `L` | 激光笔 |
| `C` | 圈选 |
| `X` | 清除标记 |
| `B` / `W` | 观众屏黑屏 / 白屏 |
| `F` | 冻结 / 恢复观众屏 |
| `A` | 开启 / 关闭自动翻页 |
| `R` | 开始 / 结束排练 |
| `?` | 打开快捷键说明 |
## 11. 能力边界
这个演讲模式只依赖当前 HTML 和浏览器本地能力,不需要账号或在线服务。不要默认加入:
- 实时字幕、语音转文字或外部语音模型。
- AI 排练教练或对演讲者的评分。
- 扫码提问、在线投票或云端互动后台。
- 手机遥控或需要服务端中继的跨设备控制。
## 12. 生成后验证
```bash ```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
node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html --target-minutes 30 node <SKILL_ROOT>/scripts/validate-presenter-mode.mjs path/to/index.html --target-minutes 30
``` ```
浏览器实测至少包含:进入演讲模式、弹窗允许/拦截、前后翻页、总览跳页、首页/尾页、尾页重新开始、观众窗口关闭后的状态变化、重新打开后恢复同步、备注保存与重排后不串页。至少在一组常用尺寸和一组小屏尺寸下检查当前页/下一页为上下排列,两个 iframe 宽高比均为 `16:9`,且没有超出各自容器。 浏览器实测至少包含:进入演讲模式、弹窗允许/拦截、前后翻页、总览跳页、首页/尾页、尾页重新开始、观众窗口关闭后的状态变化、重新打开后恢复同步、备注保存、计时、排练记录、自动翻页暂停/恢复、激光笔、圈选、清除、黑屏、白屏、冻结与演前检查。
至少在一组常用尺寸和一组小屏尺寸下检查:当前页/下一页上下排列,两个 iframe 宽高比均为 `16:9`,且没有超出各自容器。
+25 -5
View File
@@ -65,7 +65,15 @@ else{
speakerNotes.forEach((note,i)=>{ speakerNotes.forEach((note,i)=>{
if(!note||typeof note!=='object'){errors.push(`Note ${i+1}: must be an object.`);return;} 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.`); 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(note.minutes!==undefined&&note.minutes!==null&&note.minutes!==''&&(!Number.isFinite(Number(note.minutes))||Number(note.minutes)<=0))errors.push(`Note ${i+1}: minutes must be > 0 when provided.`);
if(note.autoAdvanceSeconds!==undefined&&note.autoAdvanceSeconds!==null&&note.autoAdvanceSeconds!==''&&(!Number.isFinite(Number(note.autoAdvanceSeconds))||Number(note.autoAdvanceSeconds)<=0))errors.push(`Note ${i+1}: autoAdvanceSeconds must be > 0 when provided.`);
if(note.section!==undefined&&(typeof note.section!=='string'||!note.section.trim()))errors.push(`Note ${i+1}: section must be non-empty text when provided.`);
for(const field of ['cue','interaction','delivery','advance','fallback','pronunciation']){
const value=note[field];
if(value===undefined||value===null||value==='')continue;
const valid=typeof value==='string'?Boolean(value.trim()):Array.isArray(value)&&value.length>0&&value.every(x=>typeof x==='string'&&x.trim());
if(!valid)errors.push(`Note ${i+1}: ${field} must be non-empty text or an array of non-empty text.`);
}
if(!Array.isArray(note.talk)||!note.talk.length)errors.push(`Note ${i+1}: talk must be a non-empty array.`); if(!Array.isArray(note.talk)||!note.talk.length)errors.push(`Note ${i+1}: talk must be a non-empty array.`);
else{ 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.some(x=>typeof x!=='string'||!x.trim()))errors.push(`Note ${i+1}: every talk item must be non-empty text.`);
@@ -90,15 +98,27 @@ const runtimeChecks=[
['direct window sync','postMessage('], ['direct window sync','postMessage('],
['BroadcastChannel fallback','BroadcastChannel'], ['BroadcastChannel fallback','BroadcastChannel'],
['storage fallback','__guizangPptSync'], ['storage fallback','__guizangPptSync'],
['per-slide timer','id="ppt-time-detail"'],
['rehearsal mode','id="ppt-rehearsal"'],
['auto advance','id="ppt-auto-toggle"'],
['laser and circle tools','id="ppt-presenter-ink"'],
['audience annotation layer','id="ppt-audience-ink"'],
['black, white, and freeze controls','setScreenMode'],
['preflight checks','id="ppt-preflight"'],
['layout presets','data-layout-choice'],
['shortcut help','openShortcuts'],
]; ];
for(const [label,needle]of runtimeChecks)if(!html.includes(needle))errors.push(`Presenter runtime missing ${label}.`); for(const [label,needle]of runtimeChecks)if(!html.includes(needle))errors.push(`Presenter runtime missing ${label}.`);
if(/\.ppt-preview-stack\{[^}]*grid-template-columns/.test(html))errors.push('Presenter previews must stack vertically; remove grid-template-columns from .ppt-preview-stack.'); if(/\.ppt-preview-stack\{[^}]*grid-template-columns/.test(html))errors.push('Presenter previews must stack vertically; remove grid-template-columns from .ppt-preview-stack.');
const planned=speakerNotes.reduce((sum,n)=>sum+(Number(n?.minutes)||0),0); const timedNotes=speakerNotes.filter(n=>Number.isFinite(Number(n?.minutes))&&Number(n.minutes)>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).`); const planned=timedNotes.reduce((sum,n)=>sum+Number(n.minutes),0);
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.`); const completePlan=speakerNotes.length>0&&timedNotes.length===speakerNotes.length;
if(timedNotes.length&&!completePlan)warnings.push(`Timing is partial: ${timedNotes.length} of ${speakerNotes.length} notes provide minutes; total-budget checks are skipped.`);
if(completePlan&&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(completePlan&&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}`)); warnings.forEach(x=>console.warn(`WARN ${x}`));
errors.forEach(x=>console.error(`ERROR ${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.`); console.log(`Presenter validation: ${slideIds.length} slides, ${speakerNotes.length} notes, ${completePlan?planned.toFixed(1):'partial / —'} planned minutes, ${errors.length} errors, ${warnings.length} warnings.`);
process.exit(errors.length?1:0); process.exit(errors.length?1:0);