template.html 101 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>[必填] 替换为 PPT 标题 · Deck Title</title>
  7. <link rel="preconnect" href="https://fonts.googleapis.com">
  8. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  9. <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&family=Source+Serif+4:ital,opsz,wght@0,8..60,300;0,8..60,400;0,8..60,500;0,8..60,600;1,8..60,400&family=IBM+Plex+Mono:wght@300;400;500;600&family=Noto+Serif+SC:wght@300;400;500;600;700;900&family=Noto+Sans+SC:wght@300;400;500;700;900&display=swap" rel="stylesheet">
  10. <style>
  11. :root{
  12. /* ============ 主题色(默认:🖋 墨水经典) ============
  13. 切换主题:从 references/themes.md 复制对应的 :root 块
  14. 整体替换这几行(--ink / --ink-rgb / --paper / --paper-rgb)
  15. 其他地方散落的 rgba() 都走 var(--ink-rgb) / var(--paper-rgb),无需逐处改 */
  16. --ink:#0a0a0b;
  17. --ink-rgb:10,10,11;
  18. --paper:#f1efea;
  19. --paper-rgb:241,239,234;
  20. --paper-tint:#e8e5de;
  21. --ink-tint:#18181a;
  22. /* ============ 字体(跨主题固定) ============ */
  23. --mono:"IBM Plex Mono",ui-monospace,monospace;
  24. --serif-en:"Playfair Display","Source Serif 4",Georgia,serif;
  25. --serif-body-en:"Source Serif 4",Georgia,serif;
  26. --serif-zh:"Noto Serif SC",source-han-serif-sc,serif;
  27. --sans-zh:"Noto Sans SC",source-han-sans-sc,sans-serif;
  28. }
  29. *{box-sizing:border-box;margin:0;padding:0}
  30. html,body{width:100%;height:100%;overflow:hidden;background:var(--ink);color:var(--paper);font-family:var(--sans-zh);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
  31. /* ============ WebGL 双背景 ============ */
  32. canvas.bg{position:fixed;inset:0;width:100vw;height:100vh;z-index:0;display:block;transition:opacity 1.2s ease}
  33. canvas#bg-light{opacity:0}
  34. canvas#bg-dark{opacity:1}
  35. body.light-bg canvas#bg-light{opacity:1}
  36. body.light-bg canvas#bg-dark{opacity:0}
  37. body.low-power canvas.bg{display:none!important}
  38. /* ============ Deck 容器 + 翻页 ============ */
  39. /* width: NSLIDES * 100vw,会在 JS 里动态矫正 */
  40. #deck{position:fixed;inset:0;width:10000vw;height:100vh;display:flex;flex-wrap:nowrap;transition:transform .9s cubic-bezier(.77,0,.175,1);z-index:10;will-change:transform}
  41. .slide{width:100vw;height:100vh;flex:0 0 100vw;position:relative;padding:6vh 6vw 10vh 6vw;display:flex;flex-direction:column;overflow:hidden}
  42. .slide.light{color:var(--ink);background:var(--paper)}
  43. .slide.dark{color:var(--paper);background:var(--ink)}
  44. /* 默认页:遮罩较厚,保证文字可读 */
  45. .slide::before{content:"";position:absolute;inset:0;z-index:-1;pointer-events:none;transition:background .7s ease}
  46. .slide.light::before{background:rgba(var(--paper-rgb),.78);backdrop-filter:blur(3px)}
  47. .slide.dark::before{background:rgba(var(--ink-rgb),.78);backdrop-filter:blur(3px)}
  48. /* Hero 页:遮罩大幅降低,让 WebGL 背景明显透出 */
  49. .slide.hero.light::before{background:rgba(var(--paper-rgb),.16);backdrop-filter:none}
  50. .slide.hero.dark::before{background:rgba(var(--ink-rgb),.12);backdrop-filter:none}
  51. /* Hero 页顶底微弱渐隐,保证 chrome/foot 区域可读 */
  52. .slide.hero::after{content:"";position:absolute;inset:0;z-index:-1;pointer-events:none}
  53. .slide.hero.light::after{background:linear-gradient(180deg,rgba(var(--paper-rgb),.28) 0%,rgba(var(--paper-rgb),0) 14%,rgba(var(--paper-rgb),0) 86%,rgba(var(--paper-rgb),.28) 100%)}
  54. .slide.hero.dark::after{background:linear-gradient(180deg,rgba(var(--ink-rgb),.32) 0%,rgba(var(--ink-rgb),0) 14%,rgba(var(--ink-rgb),0) 86%,rgba(var(--ink-rgb),.32) 100%)}
  55. /* ============ Magazine chrome:顶部 meta + 底部 foot ============
  56. (v1 布局 + v2 字号在此合并为唯一定义,不要在别处二次定义) */
  57. .chrome{display:flex;justify-content:space-between;align-items:flex-start;font-family:var(--mono);font-size:max(11px,.78vw);letter-spacing:.2em;text-transform:uppercase;opacity:.62}
  58. .chrome .left,.chrome .right{display:flex;gap:2.4em;align-items:center}
  59. .chrome .sep{width:40px;height:1px;background:currentColor;opacity:.4}
  60. .foot{margin-top:auto;display:flex;justify-content:space-between;align-items:flex-end;font-family:var(--mono);font-size:max(11px,.78vw);letter-spacing:.18em;text-transform:uppercase;opacity:.5}
  61. .foot .title{font-family:var(--serif-zh);font-weight:400;letter-spacing:.05em;text-transform:none;opacity:.75;font-size:13px}
  62. .tag{display:inline-block;font-family:var(--mono);font-size:11px;letter-spacing:.24em;text-transform:uppercase;padding:6px 14px;border:1px solid currentColor;opacity:.85}
  63. .rule{width:100%;height:1px;background:currentColor;opacity:.25;margin:3vh 0}
  64. .rule.v{width:1px;height:100%;margin:0}
  65. /* ============ 字体规则 ============
  66. · 衬线(Noto Serif SC / Playfair):大标题、重点金句、数字
  67. · 非衬线(Noto Sans SC):正文描述、body、补充说明
  68. · 等宽(IBM Plex Mono):kicker、meta 小标签、foot 右侧
  69. */
  70. .kicker{font-family:var(--mono);font-size:12px;letter-spacing:.3em;text-transform:uppercase;opacity:.6;margin-bottom:2.6vh}
  71. .display{font-family:var(--serif-en);font-weight:700;font-size:11vw;line-height:.92;letter-spacing:-.025em}
  72. .display-zh{font-family:var(--serif-zh);font-weight:700;font-size:7.8vw;line-height:1.04;letter-spacing:-.005em}
  73. .h1-zh{font-family:var(--serif-zh);font-weight:700;font-size:4.6vw;line-height:1.12;letter-spacing:-.005em}
  74. .h2-zh{font-family:var(--serif-zh);font-weight:600;font-size:3.2vw;line-height:1.2;letter-spacing:0}
  75. .h3-zh{font-family:var(--serif-zh);font-weight:500;font-size:1.9vw;line-height:1.35}
  76. .body-zh{font-family:var(--sans-zh);font-weight:400;font-size:max(15px,1.22vw);line-height:1.75;opacity:.82;letter-spacing:.01em}
  77. .body-serif{font-family:var(--serif-zh);font-weight:400;font-size:max(15px,1.3vw);line-height:1.65;opacity:.88}
  78. .lead{font-family:var(--serif-zh);font-weight:400;font-size:1.75vw;line-height:1.5;opacity:.86}
  79. .meta{font-family:var(--mono);font-size:max(11px,.88vw);letter-spacing:.16em;text-transform:uppercase;opacity:.6}
  80. .big-num{font-family:var(--serif-en);font-weight:800;font-size:10vw;line-height:.85;letter-spacing:-.03em;font-feature-settings:"tnum"}
  81. .mid-num{font-family:var(--serif-en);font-weight:700;font-size:5.5vw;line-height:.88;letter-spacing:-.02em;font-feature-settings:"tnum"}
  82. .ghost{font-family:var(--serif-en);font-weight:900;font-size:34vw;line-height:.8;opacity:.06;letter-spacing:-.04em;position:absolute;font-feature-settings:"tnum"}
  83. em{font-style:italic;font-family:var(--serif-en)}
  84. .en{font-family:var(--serif-en);font-style:italic;font-weight:500}
  85. /* ============ 布局工具 ============ */
  86. .col{display:flex;flex-direction:column;gap:2.4vh}
  87. .row{display:flex;align-items:center;gap:3vw}
  88. .grid-6{display:grid;grid-template-columns:repeat(3,1fr);grid-template-rows:repeat(2,1fr);gap:4vw 6vw;flex:1;align-content:center;padding:2vh 0}
  89. .grid-9{display:grid;grid-template-columns:repeat(3,1fr);grid-template-rows:repeat(3,1fr);gap:3vh 4vw;flex:1;align-content:center}
  90. .grid-4{display:grid;grid-template-columns:repeat(2,1fr);grid-template-rows:repeat(2,1fr);gap:4vh 6vw;flex:1;align-content:center}
  91. .grid-3{display:grid;grid-template-columns:repeat(3,1fr);gap:4vw;flex:1;align-content:center}
  92. .split{display:grid;grid-template-columns:1fr 1fr;gap:4vw;flex:1;align-items:center}
  93. .split-55{display:grid;grid-template-columns:55fr 45fr;gap:5vw;flex:1;align-items:stretch}
  94. .fill{flex:1}
  95. .center{align-items:center;justify-content:center;text-align:center}
  96. .bottom-left{position:absolute;left:6vw;bottom:9vh;max-width:50vw}
  97. .bottom-right{position:absolute;right:6vw;bottom:9vh;max-width:50vw;text-align:right}
  98. .top-right{position:absolute;right:6vw;top:6vh;text-align:right}
  99. /* ============ Stat(数字矩阵) ============ */
  100. .stat{display:flex;flex-direction:column;gap:1vh;align-items:flex-start}
  101. .stat .n{font-family:var(--serif-en);font-weight:800;font-size:8vw;line-height:.88;letter-spacing:-.03em;font-feature-settings:"tnum"}
  102. .stat .l{font-family:var(--sans-zh);font-size:max(13px,1.05vw);opacity:.7;margin-top:1vh;font-weight:400;line-height:1.5}
  103. .stat .m{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;opacity:.5;margin-bottom:.2vh}
  104. /* ============ Callout(引用框) ============ */
  105. .callout{padding:3vh 2.4vw;border-left:3px solid currentColor;position:relative;font-family:var(--serif-zh);font-size:max(15px,1.2vw);line-height:1.55;opacity:.92}
  106. .slide.light .callout{background:rgba(var(--ink-rgb),.05)}
  107. .slide.dark .callout{background:rgba(var(--paper-rgb),.06)}
  108. .callout .cite{display:block;margin-top:1.6vh;font-family:var(--mono);font-size:11px;letter-spacing:.2em;text-transform:uppercase;opacity:.6}
  109. .callout .q-big{font-family:var(--serif-zh);font-weight:600;font-size:max(17px,1.6vw);line-height:1.42}
  110. /* ============ Platform(平台卡) ============ */
  111. .plat{display:flex;flex-direction:column;justify-content:flex-end;padding:2vh 0;border-top:1px solid currentColor;border-color:rgba(127,127,127,.35)}
  112. .plat .name{font-family:var(--serif-zh);font-weight:700;font-size:1.8vw;margin-bottom:.6vh}
  113. .plat .nb{font-family:var(--serif-en);font-weight:700;font-size:3.2vw;letter-spacing:-.02em;line-height:1;font-feature-settings:"tnum"}
  114. .plat .sub{font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;opacity:.55;margin-top:.6vh}
  115. .plat .fill{font-family:var(--sans-zh);font-weight:300;font-size:2.4vw;opacity:.28;letter-spacing:-.01em;line-height:1}
  116. /* ============ Rowline(表格行) ============ */
  117. .rowline{display:grid;grid-template-columns:1fr 2fr 1fr;gap:2vw;padding:2.2vh 0;border-top:1px solid currentColor;align-items:center;border-color:rgba(127,127,127,.25)}
  118. .rowline:last-child{border-bottom:1px solid currentColor;border-color:rgba(127,127,127,.25)}
  119. .rowline .k{font-family:var(--serif-zh);font-weight:700;font-size:1.7vw}
  120. .rowline .v{font-family:var(--sans-zh);font-weight:400;font-size:max(14px,1.2vw);opacity:.85;line-height:1.55}
  121. .rowline .m{font-family:var(--mono);font-size:11px;letter-spacing:.2em;text-transform:uppercase;opacity:.6;justify-self:end}
  122. /* ============ Pillar(支柱卡片) ============ */
  123. .pillar{display:flex;flex-direction:column;gap:1.8vh}
  124. .pillar .ic{font-family:var(--serif-en);font-style:italic;font-size:2.6vw;opacity:.45;font-weight:400}
  125. .pillar .ic svg{width:2.8vw;height:2.8vw;stroke-width:1.2;opacity:.7}
  126. .pillar .t{font-family:var(--serif-zh);font-weight:700;font-size:2.4vw;line-height:1.1}
  127. .pillar .d{font-family:var(--sans-zh);font-weight:400;font-size:max(14px,1.1vw);opacity:.76;line-height:1.6}
  128. /* ============ Signature / Highlight ============ */
  129. .sign{font-family:var(--serif-en);font-style:italic;font-weight:500;font-size:2vw;opacity:.7}
  130. .hi{position:relative;display:inline}
  131. .slide.dark .hi::after{content:"";position:absolute;left:-.1em;right:-.1em;bottom:-.05em;height:.28em;background:rgba(var(--paper-rgb),.15);z-index:-1}
  132. .slide.light .hi::after{content:"";position:absolute;left:-.1em;right:-.1em;bottom:-.05em;height:.28em;background:rgba(var(--ink-rgb),.08);z-index:-1}
  133. /* ============ Icons(Lucide via CDN) ============ */
  134. .ico{width:1em;height:1em;display:inline-block;vertical-align:-.12em;stroke:currentColor;fill:none;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}
  135. .ico-lg,.ico-md,.ico-sm{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round}
  136. .ico-lg{width:2.6vw;height:2.6vw;stroke-width:1.2;display:inline-block}
  137. .ico-md{width:1.8vw;height:1.8vw;stroke-width:1.3;display:inline-block;vertical-align:-.4em}
  138. .ico-sm{width:1.1vw;height:1.1vw;stroke-width:1.4;display:inline-block;vertical-align:-.15em;opacity:.7}
  139. /* ============ 图片占位(虚线框,提示设计师位置) ============ */
  140. .img-slot{border:1.5px dashed rgba(127,127,127,.4);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:1vh;padding:2vh 2vw;font-family:var(--mono);font-size:10px;letter-spacing:.28em;text-transform:uppercase;opacity:.55;position:relative;aspect-ratio:16/9;width:100%;max-height:56vh;margin-inline:auto;box-sizing:border-box}
  141. .img-slot::before{content:"";position:absolute;inset:8px;border:1px solid currentColor;opacity:.2}
  142. .img-slot .plus{font-size:2vw;font-weight:300;opacity:.5;letter-spacing:0}
  143. .img-slot .label{position:relative;z-index:2;text-align:center}
  144. .img-slot.r-4x3{aspect-ratio:4/3}
  145. .img-slot.r-3x2{aspect-ratio:3/2}
  146. .img-slot.r-1x1{aspect-ratio:1/1}
  147. /* ============ 图片实填框(关键:固定高度 + 只裁底部) ============
  148. 重要约束:高度用内联 height:Nvh 精确控制,不要用 aspect-ratio(会撑破布局)
  149. object-position:top center 保证严禁裁剪顶部和左右,只裁剪底部
  150. */
  151. .frame-img{overflow:hidden;position:relative;background:rgba(0,0,0,.04);box-sizing:border-box;width:100%;border-radius:4px}
  152. .slide.dark .frame-img{background:rgba(255,255,255,.04);border-color:rgba(255,255,255,.12)}
  153. .frame-img > img{width:100%;height:100%;object-fit:cover;object-position:top center;display:block}
  154. .frame-img.fit-contain > img{object-fit:contain;object-position:center center}
  155. .frame-img.r-16x9{aspect-ratio:16/9;max-height:64vh}
  156. .frame-img.r-16x10{aspect-ratio:16/10;max-height:56vh}
  157. .frame-img.r-4x3{aspect-ratio:4/3;max-height:56vh}
  158. .frame-img.r-3x2{aspect-ratio:3/2;max-height:46vh}
  159. .frame-img.r-3x4{aspect-ratio:3/4;max-height:60vh}
  160. .frame-img.r-1x1{aspect-ratio:1/1;max-height:50vh}
  161. .frame-img.h-16{height:16vh}
  162. .frame-img.h-18{height:18vh}
  163. .frame-img.h-22{height:22vh}
  164. .frame-img.h-26{height:26vh}
  165. .frame-img.h-28{height:28vh}
  166. .frame-cap{display:flex;justify-content:space-between;align-items:baseline;gap:1vw;margin-top:.8vh;font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;opacity:.72}
  167. .frame-cap .pf{font-family:var(--serif-zh);font-weight:600;font-size:max(13px,1vw);letter-spacing:.04em;text-transform:none;opacity:.94}
  168. .frame-cap .nb{font-family:var(--serif-en);font-style:italic;font-size:max(15px,1.2vw);letter-spacing:.02em;text-transform:none;opacity:.88}
  169. .frame-cap .idx{font-family:var(--mono);opacity:.5}
  170. figure.tile{display:flex;flex-direction:column;margin:0;min-width:0}
  171. figure.tile > .frame-img{flex:0 0 auto}
  172. /* ============ 导航 ============ */
  173. #nav{position:fixed;left:50%;bottom:2.6vh;transform:translateX(-50%);z-index:30;display:flex;gap:10px;padding:8px 14px;border-radius:999px;background:rgba(0,0,0,.18);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px)}
  174. #nav .dot{width:8px;height:8px;border-radius:50%;background:rgba(255,255,255,.3);cursor:pointer;transition:all .3s ease;border:0;padding:0}
  175. #nav .dot:hover{background:rgba(255,255,255,.5);transform:scale(1.15)}
  176. #nav .dot.active{background:rgba(255,255,255,.95);width:22px;border-radius:999px}
  177. body.light-bg #nav{background:rgba(255,255,255,.25)}
  178. body.light-bg #nav .dot{background:rgba(var(--ink-rgb),.25)}
  179. body.light-bg #nav .dot.active{background:rgba(var(--ink-rgb),.9)}
  180. #hint{position:fixed;bottom:3vh;right:3vw;z-index:30;display:flex;align-items:center;gap:.65em;font-family:var(--mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;opacity:.4;mix-blend-mode:difference;color:#aaa}
  181. #hint button{all:unset;display:inline-flex;align-items:center;gap:.45em;cursor:pointer;color:inherit;white-space:nowrap}
  182. #hint button:hover,#hint button:focus-visible{opacity:1;text-decoration:underline;text-underline-offset:.35em}
  183. #hint .ppt-key{padding:.16em .38em;border:1px solid currentColor;border-radius:3px;letter-spacing:.08em}
  184. #hint .ppt-sep{opacity:.55}
  185. body.low-power #hint{opacity:.72;color:var(--paper);mix-blend-mode:normal}
  186. body.light-bg.low-power #hint{color:var(--ink)}
  187. /* ============ 演讲者模式 ============ */
  188. body.ppt-audience #hint,body.ppt-audience #nav,
  189. body.ppt-preview #hint,body.ppt-preview #nav{display:none!important}
  190. body.ppt-audience{cursor:none}
  191. body.ppt-preview{pointer-events:none}body.ppt-preview #deck{transition:none!important}
  192. body.ppt-presenter>#deck,body.ppt-presenter>#nav,body.ppt-presenter>#hint,
  193. body.ppt-presenter>.bg{visibility:hidden}
  194. #ppt-presenter{position:fixed;inset:0;z-index:90;display:none;min-width:0;min-height:0;overflow:hidden;background:#101216;color:#f5f6f7;font-family:Inter,"Noto Sans SC",sans-serif}
  195. body.ppt-presenter #ppt-presenter{display:grid;grid-template-rows:auto 1fr auto}
  196. .ppt-presenter-top{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:18px 24px;border-bottom:1px solid #2b2e35;background:#15181d}
  197. .ppt-presenter-brand{font-weight:650;letter-spacing:.02em}.ppt-presenter-meta{display:flex;align-items:center;gap:12px;color:#aeb4be;font-size:14px}
  198. .ppt-sync{display:inline-flex;align-items:center;gap:8px;padding:7px 11px;border:1px solid #363a43;border-radius:999px;color:#d2d6dc;background:#1b1e24}
  199. .ppt-sync::before{content:"";width:8px;height:8px;border-radius:50%;background:#818894}
  200. .ppt-sync[data-state="synced"]::before{background:#72df99;box-shadow:0 0 0 4px rgba(114,223,153,.12)}
  201. .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}
  202. .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}
  203. .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}
  204. .ppt-presenter-main{display:grid;grid-template-columns:minmax(0,1.75fr) minmax(300px,.75fr);gap:18px;min-height:0;padding:18px 24px}
  205. .ppt-preview-stack{position:relative;display:grid;grid-template-rows:minmax(0,1fr) clamp(132px,22vh,210px);gap:16px;min-width:0;min-height:0}
  206. .ppt-stage-card,.ppt-notes-card,.ppt-presenter-overview{background:#171a20;border:1px solid #2b2f37;border-radius:12px;overflow:hidden}
  207. .ppt-stage-card{display:grid;grid-template-rows:auto minmax(0,1fr);min-width:0;min-height:0}
  208. .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}
  209. .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-grid-btn.is-active{color:#111318;background:#edf1f5;border-color:#edf1f5}.ppt-annotation-tools .ppt-clear-btn{min-width:0}.ppt-grid-btn{display:inline-flex;align-items:center;gap:6px}.ppt-grid-icon{display:grid;grid-template-columns:repeat(2,4px);gap:2px}.ppt-grid-icon i{width:4px;height:4px;background:currentColor}
  210. .ppt-frame-viewport{position:relative;display:grid;place-items:center;min-width:0;min-height:0;overflow:hidden;background:#08090b}
  211. .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-next-card.is-end .ppt-frame{visibility:hidden}#ppt-next-card.is-end .ppt-frame-viewport::after{content:"演示结束";position:absolute;color:#727a86;font:650 14px/1 Inter,"Noto Sans SC",sans-serif;letter-spacing:.08em}
  212. .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}
  213. #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-audience-cover.is-ended{display:grid!important;place-items:center;background:#101216!important;color:#f5f6f7;font:650 clamp(24px,3vw,46px)/1.2 Inter,"Noto Sans SC",sans-serif;letter-spacing:.08em}
  214. .ppt-preview-stack.is-overview>.ppt-stage-card{display:none}.ppt-presenter-overview{grid-row:1/-1;display:grid;grid-template-rows:auto minmax(0,1fr);min-width:0;min-height:0}.ppt-presenter-overview[hidden]{display:none}.ppt-overview-head{display:flex;align-items:center;justify-content:space-between;padding:13px 15px;border-bottom:1px solid #292d34}.ppt-overview-head strong{font-size:15px}.ppt-overview-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;align-content:start;overflow:auto;padding:14px}.ppt-overview-page{appearance:none;min-height:112px;display:grid;grid-template-rows:auto 1fr auto;gap:12px;padding:13px;border:1px solid #343943;border-radius:10px;background:#111318;color:#eef1f4;text-align:left;cursor:pointer}.ppt-overview-page:hover{background:#1d2128;border-color:#636b78}.ppt-overview-page.is-current{border-color:#84a8ff;box-shadow:inset 0 0 0 1px #84a8ff}.ppt-overview-page-no{color:#8f97a3;font:600 12px/1 var(--mono,monospace)}.ppt-overview-page-title{font-size:14px;font-weight:650;line-height:1.35}.ppt-overview-page-meta{display:flex;justify-content:space-between;gap:8px;color:#7f8793;font-size:11px}
  215. .ppt-notes-card{display:grid;grid-template-rows:auto minmax(0,1fr);min-height:0}.ppt-notes-head{padding:16px 18px;border-bottom:1px solid #292d34}.ppt-notes-label{margin-bottom:6px;color:#848c98;font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}.ppt-notes-title{font-size:21px;font-weight:680;line-height:1.32}.ppt-purpose-block{margin-top:14px;padding:11px 12px;border-left:3px solid #84a8ff;background:#111318}.ppt-purpose-copy{color:#c9ced6;font-size:14px;line-height:1.55}.ppt-notes-draft-head{display:flex;align-items:flex-end;justify-content:space-between;gap:10px;margin-top:15px;padding-top:13px;border-top:1px solid #2d3139}.ppt-notes-draft-head .ppt-notes-label{margin:0 0 4px}.ppt-note-meta{color:#8f97a3;font-size:12px}
  216. .ppt-notes-tools{display:flex;align-items:center;gap:6px}.ppt-notes-tools .ppt-ui-btn{padding:7px 9px}.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-note-editor:focus{box-shadow:inset 0 0 0 1px #3d4654}
  217. .ppt-saved{color:#72df99;white-space:nowrap}.ppt-presenter-bottom{display:grid;grid-template-columns:minmax(330px,1fr) auto minmax(110px,1fr);align-items:center;gap:20px;padding:12px 24px;border-top:1px solid #2b2e35;background:#15181d}
  218. .ppt-time-dashboard{display:flex;align-items:stretch;min-width:0}.ppt-time-stat{min-width:90px;display:grid;align-content:center;gap:4px;padding:0 16px}.ppt-time-stat:first-child{padding-left:0}.ppt-time-stat+.ppt-time-stat{border-left:1px solid #343943}.ppt-time-label{color:#89919d;font-size:11px;font-weight:700;letter-spacing:.1em}.ppt-time-value{color:#f7f8fa;font:650 23px/1 var(--mono,monospace);font-variant-numeric:tabular-nums}.ppt-time-hint{color:#747c88;font-size:11px;white-space:nowrap}.ppt-time-stat.is-over .ppt-time-label,.ppt-time-stat.is-over .ppt-time-value{color:#ff8a90}.ppt-time-stat.is-remaining .ppt-time-value{color:#72df99}
  219. .ppt-control-stack{display:grid;gap:7px;justify-items:center}.ppt-controls,.ppt-session-actions{display:flex;align-items:center;justify-content:center;gap:7px}.ppt-controls .ppt-ui-btn,.ppt-session-actions .ppt-ui-btn{min-width:68px}.ppt-session-actions{padding-top:7px;border-top:1px solid #343943}.ppt-rehearsal-on{color:#ff8a90}.ppt-page-state{text-align:right}.ppt-page-state strong{display:block;color:#fff;font-size:17px;margin-bottom:5px}.ppt-page-state span{color:#8f97a3;font:600 13px/1 var(--mono,monospace)}.ppt-auto-state{display:inline-flex;align-items:center;gap:6px;border-radius:999px}.ppt-auto-state.is-on{color:#72df99;border-color:rgba(114,223,153,.45);background:rgba(114,223,153,.08)}.ppt-auto-state.is-paused{color:#f0c766;border-color:rgba(240,199,102,.42);background:rgba(240,199,102,.08)}
  220. #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)}
  221. .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)}
  222. .ppt-settings-card{padding:15px;border:1px solid #30353e;border-radius:12px;background:#12151a}.ppt-settings-card+.ppt-settings-card{margin-top:12px}.ppt-settings-card h3{margin:0 0 3px}.ppt-settings-copy{margin:0 0 14px;color:#858d99;font-size:12px}.ppt-switch-row{display:flex;align-items:center;justify-content:space-between;gap:18px;cursor:pointer}.ppt-switch-row input{position:absolute;opacity:0;pointer-events:none}.ppt-switch{width:48px;height:28px;flex:0 0 auto;padding:3px;border:1px solid #434955;border-radius:999px;background:#252a31;transition:.2s}.ppt-switch::after{content:"";display:block;width:20px;height:20px;border-radius:50%;background:#a8afb9;transition:.2s}.ppt-switch-row input:checked+.ppt-switch{border-color:#72df99;background:rgba(114,223,153,.16)}.ppt-switch-row input:checked+.ppt-switch::after{transform:translateX(20px);background:#72df99}.ppt-stepper{display:grid;grid-template-columns:38px minmax(72px,1fr) auto 38px;align-items:center;margin-top:14px;border:1px solid #363c46;border-radius:10px;background:#0d0f13;overflow:hidden}.ppt-stepper button{height:42px;border:0;background:#20242a;color:#fff;font-size:19px;cursor:pointer}.ppt-stepper button:hover{background:#2c323b}.ppt-stepper input{width:100%;height:42px;border:0!important;border-radius:0!important;background:transparent!important;text-align:center;font:650 16px var(--mono,monospace)!important;-moz-appearance:textfield}.ppt-stepper input::-webkit-inner-spin-button,.ppt-stepper input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.ppt-stepper-unit{color:#858d99;font-size:12px}.ppt-segmented{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}.ppt-segmented.screen{grid-template-columns:repeat(4,1fr)}.ppt-segmented .ppt-ui-btn{min-width:0}.ppt-settings-save{width:100%;margin-top:14px;background:#edf1f5;color:#111318;border-color:#edf1f5}.ppt-settings-save:hover{background:#fff}.ppt-settings-save+.ppt-settings-copy{margin:10px 0 0}
  223. #overview{z-index:110!important}
  224. @media(max-width:1200px){.ppt-presenter-main{grid-template-columns:minmax(0,1.55fr) minmax(280px,.72fr)}}
  225. @media(max-width:1100px){.ppt-presenter-bottom{grid-template-columns:minmax(292px,1fr) auto minmax(86px,.6fr);gap:12px}.ppt-time-stat{min-width:82px;padding:0 11px}.ppt-time-value{font-size:20px}.ppt-controls .ppt-ui-btn,.ppt-session-actions .ppt-ui-btn{min-width:60px;padding:8px 9px}}
  226. @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-saved{display:none}.ppt-presenter-bottom{grid-template-columns:minmax(270px,1fr) auto 74px;gap:8px;padding:9px 16px}.ppt-time-stat{min-width:0;padding:0 9px}.ppt-time-value{font-size:18px}.ppt-time-hint{max-width:78px;overflow:hidden;text-overflow:ellipsis}.ppt-controls,.ppt-session-actions{gap:5px}.ppt-controls .ppt-ui-btn,.ppt-session-actions .ppt-ui-btn{min-width:52px;padding:7px 8px;font-size:12px}.ppt-page-state strong{font-size:14px}.ppt-dialog-grid{grid-template-columns:1fr}.ppt-segmented.screen{grid-template-columns:repeat(2,1fr)}}
  227. @media(max-height:700px){.ppt-presenter-top{padding-top:10px;padding-bottom:10px}.ppt-presenter-main{padding-top:10px;padding-bottom:10px}.ppt-preview-stack{grid-template-rows:minmax(0,1fr) clamp(88px,16vh,118px);gap:10px}.ppt-stage-label{padding:7px 10px}.ppt-notes-head{padding:12px 14px}.ppt-purpose-block{margin-top:10px;padding:8px 10px}.ppt-notes-draft-head{margin-top:10px;padding-top:9px}.ppt-presenter-bottom{padding-top:8px;padding-bottom:8px}.ppt-control-stack{gap:5px}.ppt-session-actions{padding-top:5px}}
  228. /* ============ /演讲者模式 ============ */
  229. /* ============================================================
  230. ============ LAYOUTS API · 面向 agent 的类(v2)============
  231. 所有 layouts.md 中的骨架都基于下面这套命名。
  232. 如果你在 layouts.md 里看到某个类,它必须在下面有定义。
  233. ============================================================ */
  234. /* ---------- .frame:每页主内容容器 ---------- */
  235. .frame{flex:1;display:flex;flex-direction:column;min-height:0;overflow:hidden}
  236. /* 当 .frame 同时加了 grid 类时,grid 的 display:grid 覆盖 flex */
  237. .frame.grid-2-7-5,
  238. .frame.grid-2-6-6,
  239. .frame.grid-2-8-4,
  240. .frame.grid-3-3,
  241. .frame.grid-6{display:grid}
  242. /* ---------- 标题层级(API 名称,衬线为主) ---------- */
  243. .h-hero{
  244. font-family:var(--serif-zh);
  245. font-weight:900;
  246. font-size:10vw;
  247. line-height:.96;
  248. letter-spacing:-.02em;
  249. }
  250. .h-xl{
  251. font-family:var(--serif-zh);
  252. font-weight:700;
  253. font-size:6.2vw;
  254. line-height:1.08;
  255. letter-spacing:-.01em;
  256. }
  257. .h-sub{
  258. font-family:var(--serif-zh);
  259. font-weight:500;
  260. font-size:3.1vw;
  261. line-height:1.25;
  262. letter-spacing:0;
  263. opacity:.7;
  264. }
  265. .h-md{
  266. font-family:var(--serif-zh);
  267. font-weight:600;
  268. font-size:2.3vw;
  269. line-height:1.3;
  270. }
  271. /* 英文标题专用(Playfair 衬线) */
  272. .h-hero-en,.h-xl-en{font-family:var(--serif-en);letter-spacing:-.025em}
  273. /* ---------- lead 引语:定义见上方“字体规则”区块(已合并,勿重复定义) ---------- */
  274. /* ---------- meta-row 底部元数据 ---------- */
  275. .meta-row{
  276. display:flex;
  277. gap:1.2em;
  278. align-items:baseline;
  279. flex-wrap:wrap;
  280. font-family:var(--mono);
  281. font-size:max(12px,.92vw);
  282. letter-spacing:.16em;
  283. text-transform:uppercase;
  284. opacity:.6;
  285. }
  286. /* ---------- stat-card(数据大字报用) ---------- */
  287. .stat-card{
  288. display:flex;
  289. flex-direction:column;
  290. gap:.8vh;
  291. align-items:flex-start;
  292. padding-top:1.6vh;
  293. border-top:1px solid currentColor;
  294. border-color:rgba(127,127,127,.3);
  295. }
  296. .stat-card .stat-label{
  297. font-family:var(--mono);
  298. font-size:max(10px,.78vw);
  299. letter-spacing:.24em;
  300. text-transform:uppercase;
  301. opacity:.55;
  302. }
  303. .stat-card .stat-nb{
  304. font-family:var(--serif-en);
  305. font-weight:800;
  306. font-size:5.8vw;
  307. line-height:.9;
  308. letter-spacing:-.03em;
  309. font-feature-settings:"tnum";
  310. margin-top:.4vh;
  311. }
  312. .stat-card .stat-nb .stat-unit{
  313. font-family:var(--serif-zh);
  314. font-weight:500;
  315. font-size:.38em;
  316. letter-spacing:0;
  317. opacity:.72;
  318. margin-left:.14em;
  319. }
  320. .stat-card .stat-note{
  321. font-family:var(--sans-zh);
  322. font-weight:400;
  323. font-size:max(13px,1.05vw);
  324. line-height:1.5;
  325. opacity:.72;
  326. margin-top:.6vh;
  327. }
  328. /* 当 stat-card 用于 grid-4(2x2),数字适度放大;若页面有标题+引文在上方,可内联 style 覆盖为更小值 */
  329. .grid-4 .stat-card .stat-nb{font-size:5vw}
  330. /* 当只有 3 个,字也可以稍大 */
  331. .grid-3 .stat-card .stat-nb{font-size:6.8vw}
  332. /* ---------- pipeline(流水线) ---------- */
  333. .pipeline-section{
  334. margin-top:4.4vh;
  335. padding-top:2.8vh;
  336. border-top:1px dashed rgba(127,127,127,.32);
  337. }
  338. .pipeline-section:first-of-type{
  339. border-top:0;
  340. padding-top:0;
  341. margin-top:3vh;
  342. }
  343. .pipeline-label{
  344. font-family:var(--mono);
  345. font-size:max(11px,.85vw);
  346. letter-spacing:.24em;
  347. text-transform:uppercase;
  348. opacity:.62;
  349. margin-bottom:2.2vh;
  350. }
  351. .pipeline{
  352. display:grid;
  353. grid-template-columns:repeat(5,1fr);
  354. gap:1.2vw;
  355. }
  356. .pipeline[data-cols="3"]{grid-template-columns:repeat(3,1fr)}
  357. .pipeline[data-cols="4"]{grid-template-columns:repeat(4,1fr)}
  358. .pipeline[data-cols="6"]{grid-template-columns:repeat(6,1fr)}
  359. .step{
  360. display:flex;
  361. flex-direction:column;
  362. gap:.8vh;
  363. padding-top:1.4vh;
  364. border-top:1px solid currentColor;
  365. border-color:rgba(127,127,127,.35);
  366. }
  367. .step-nb{
  368. font-family:var(--serif-en);
  369. font-style:italic;
  370. font-weight:500;
  371. font-size:1.15vw;
  372. opacity:.45;
  373. }
  374. .step-title{
  375. font-family:var(--sans-zh);
  376. font-weight:700;
  377. font-size:1.55vw;
  378. letter-spacing:.01em;
  379. line-height:1.2;
  380. }
  381. .step-desc{
  382. font-family:var(--sans-zh);
  383. font-weight:400;
  384. font-size:max(12px,.95vw);
  385. line-height:1.45;
  386. opacity:.72;
  387. }
  388. /* ---------- 网格(layouts.md 所用) ---------- */
  389. /* 这些类独立挂到任何容器上都能生效,不依赖 .frame 复合选择器 */
  390. .grid-2-7-5{display:grid;grid-template-columns:7fr 5fr;gap:3vw 4vh;align-items:start}
  391. .grid-2-6-6{display:grid;grid-template-columns:1fr 1fr;gap:3vw 4vh;align-items:start}
  392. .grid-2-8-4{display:grid;grid-template-columns:8fr 4fr;gap:3vw 4vh;align-items:start}
  393. .grid-3-3{
  394. display:grid;
  395. grid-template-columns:repeat(3,1fr);
  396. grid-auto-rows:minmax(0,1fr);
  397. gap:2.4vh 2vw;
  398. }
  399. /* grid-6 已在旧样式里定义为 3x2,这里仅补 align */
  400. /* ---------- 图片 frame-img(layouts.md 主命名) ---------- */
  401. /* 在旧样式里已定义,这里补 img-cap 命名别名与增强 */
  402. figure.frame-img{margin:0;display:flex;flex-direction:column;min-width:0}
  403. /* 有图注时:图片弹性收缩,图注收进固定高度/比例框内部,
  404. 否则 img 的 height:100% 会把 figcaption 挤出 overflow:hidden 裁掉 */
  405. figure.frame-img:has(> .img-cap) > img{flex:1 1 auto;min-height:0;height:auto}
  406. .img-cap{
  407. display:block;
  408. flex:0 0 auto;
  409. padding:.7vh .9em .9vh;
  410. font-family:var(--mono);
  411. font-size:max(10px,.8vw);
  412. letter-spacing:.22em;
  413. text-transform:uppercase;
  414. opacity:.6;
  415. }
  416. /* callout src 命名别名 */
  417. .callout-src{
  418. display:block;
  419. margin-top:1.6vh;
  420. font-family:var(--mono);
  421. font-size:11px;
  422. letter-spacing:.2em;
  423. text-transform:uppercase;
  424. opacity:.6;
  425. }
  426. /* ---------- chrome & foot:定义见上方“Magazine chrome”区块(已合并,勿重复定义) ---------- */
  427. /* ============ 动效系统(Motion One 驱动) ============
  428. 所有 [data-anim] 元素默认隐藏,进入页面时由 JS 逐个揭示。
  429. - cascade(默认):按 DOM 顺序 stagger 120ms 淡入 + y:16→0
  430. - hero(hero 页自动):慢一点的 stagger 180ms
  431. - quote(含 [data-anim="line"]):逐行 600ms 淡入
  432. - directional(含 [data-anim="left|right"]):左→分隔线→右
  433. - pipeline(data-animate="pipeline"):Space/→ 手动推进
  434. Motion One 没加载成功时(CDN 断 + 本地丢),所有 [data-anim] 会兜底显示。
  435. */
  436. [data-anim]{opacity:1}
  437. body.motion-ready [data-anim]{opacity:0}
  438. body.motion-ready [data-anim="left"]{transform:translateX(-24px)}
  439. body.motion-ready [data-anim="right"]{transform:translateX(24px)}
  440. body.motion-ready [data-anim="line"]{opacity:0;transform:translateY(10px)}
  441. body.motion-ready [data-animate="pipeline"] [data-anim]{opacity:.15}
  442. body.low-power #deck{transition:none!important}
  443. body.low-power *,
  444. body.low-power *::before,
  445. body.low-power *::after{animation:none!important;transition:none!important}
  446. body.low-power.motion-ready [data-anim],
  447. body.low-power [data-anim]{opacity:1!important;transform:none!important}
  448. /* ESC 索引缩略图:克隆的 slide 不经过动效播放,强制显示所有待动画元素 */
  449. #overview [data-anim]{opacity:1!important;transform:none!important}
  450. /* ---------- 响应式降级 ---------- */
  451. @media (max-width:900px){
  452. .display{font-size:16vw}
  453. .display-zh{font-size:12vw}
  454. .h1-zh{font-size:7vw}
  455. .h-hero{font-size:14vw}
  456. .h-xl{font-size:9vw}
  457. .pipeline{grid-template-columns:repeat(2,1fr)}
  458. .grid-2-7-5,.grid-2-6-6,.grid-2-8-4{grid-template-columns:1fr}
  459. }
  460. </style>
  461. </head>
  462. <body>
  463. <script>
  464. (function(){
  465. const KEY = 'guizang-ppt-low-power';
  466. const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
  467. const stored = localStorage.getItem(KEY);
  468. window.__lowPowerMode = stored === '1' || (stored === null && reduced);
  469. function updateHint(){
  470. const label = document.querySelector('.ppt-low-power-label');
  471. if(label) label.textContent = window.__lowPowerMode ? '动态' : '静态';
  472. }
  473. window.__setLowPowerMode = function(on, opts={}){
  474. window.__lowPowerMode = !!on;
  475. document.body.classList.toggle('low-power', window.__lowPowerMode);
  476. if(opts.persist !== false) localStorage.setItem(KEY, window.__lowPowerMode ? '1' : '0');
  477. if(window.__lowPowerMode && document.getAnimations){
  478. document.getAnimations().forEach(a=>a.cancel());
  479. }
  480. updateHint();
  481. dispatchEvent(new CustomEvent('ppt-low-power-change', {detail:{on:window.__lowPowerMode}}));
  482. if(window.__playSlide) window.__playSlide(window.__currentSlideIndex || 0);
  483. };
  484. document.body.classList.toggle('low-power', window.__lowPowerMode);
  485. addEventListener('DOMContentLoaded', updateHint, {once:true});
  486. })();
  487. </script>
  488. <canvas id="bg-dark" class="bg"></canvas>
  489. <canvas id="bg-light" class="bg"></canvas>
  490. <div id="hint" role="toolbar" aria-label="快捷控制">
  491. <span>← → 翻页</span><span class="ppt-sep">·</span>
  492. <button id="ppt-low-power-btn" type="button"><span class="ppt-key">B</span><span class="ppt-low-power-label">静态</span></button><span class="ppt-sep">·</span>
  493. <button id="ppt-overview-btn" type="button"><span class="ppt-key">ESC</span>总览</button><span class="ppt-sep">·</span>
  494. <button id="ppt-presenter-btn" type="button"><span class="ppt-key">P</span>演讲模式</button>
  495. </div>
  496. <div id="deck">
  497. <!-- ============================================================
  498. SLIDES 插入区 · 在此处填充所有 <section class="slide ..."> 页面
  499. 每页模板参考 references/page-patterns.md
  500. 页面组件参考 references/components.md
  501. ============================================================ -->
  502. <!-- SLIDES_HERE -->
  503. </div>
  504. <div id="nav"></div>
  505. <!-- 演讲备注:每页一条,id 必须与 section[data-slide-id] 一致。见 references/presenter-mode.md -->
  506. <script>
  507. const SPEAKER_NOTES = [
  508. /* {
  509. id: 'stable-slide-id', title: '页面标题', section: '主体', minutes: 1,
  510. purpose: '这一页要让观众理解什么',
  511. talk: ['只在台上说的补充信息', '例子、证据或语气提示', '不复述页面可见文字'],
  512. transition: '为什么下一页紧接着出现', cue: '可选:停顿 / Demo / 视频',
  513. interaction: '可选:现场提问', delivery: '可选:语气 / 重音',
  514. advance: '可选:翻页时机', fallback: '可选:Demo 失败时的备用说法'
  515. } */
  516. ];
  517. window.__SPEAKER_NOTES__ = SPEAKER_NOTES;
  518. </script>
  519. <script>
  520. /* =============== WebGL 双背景 ===============
  521. 深色页:Holographic Dispersion(全息色散 · 钛金暗流)—— 彩虹微扰、鼠标径向涟漪
  522. 浅色页:Spiral Vortex(旋转涡流 · 银色珍珠)—— domain-warp 流动、无中心
  523. 修改风格请参考 references/webgl-backgrounds.md
  524. */
  525. const VS = `attribute vec2 position;void main(){gl_Position=vec4(position,0.0,1.0);}`;
  526. const FS_DARK = `precision highp float;
  527. uniform vec2 u_resolution;uniform float u_time;uniform vec2 u_mouse;
  528. vec3 palette(float t,vec3 a,vec3 b,vec3 c,vec3 d){return a+b*cos(6.28318*(c*t+d));}
  529. void main(){
  530. vec2 uv=gl_FragCoord.xy/u_resolution.xy;
  531. vec2 p=uv*2.0-1.0;p.x*=u_resolution.x/u_resolution.y;
  532. vec2 m=u_mouse*2.0-1.0;m.x*=u_resolution.x/u_resolution.y;
  533. float md=length(p-m);
  534. float mr=sin(md*15.0-u_time*4.0)*exp(-md*3.0);p+=mr*0.08;
  535. vec2 p0=p;
  536. for(float i=1.0;i<4.0;i++){
  537. p.x+=0.1/i*sin(i*3.0*p.y+u_time*0.4)+0.05;
  538. p.y+=0.1/i*cos(i*2.0*p.x+u_time*0.3)-0.05;
  539. }
  540. float r=length(p);float ang=atan(p.y,p.x);
  541. vec3 a=vec3(0.12,0.12,0.13);
  542. vec3 b=vec3(0.03,0.04,0.05);
  543. vec3 c=vec3(1.0,1.0,1.0);
  544. vec3 d=vec3(0.1,0.2,0.4);
  545. vec3 col=palette(r*1.5+p0.x*0.5+u_time*0.1,a,b,c,d);
  546. float disp=sin(r*25.0-u_time*1.5+ang*2.0)*0.5+0.5;
  547. col+=vec3(disp*0.015,disp*0.01,disp*0.02);
  548. float hi=pow(sin(p.x*4.0+p.y*3.0+u_time)*0.5+0.5,8.0);
  549. col+=hi*0.08;
  550. vec3 base=vec3(0.05,0.05,0.06);
  551. col=mix(base,col,0.85);
  552. gl_FragColor=vec4(col,1.0);
  553. }`;
  554. const FS_LIGHT = `precision highp float;
  555. uniform vec2 u_resolution;uniform float u_time;uniform vec2 u_mouse;
  556. float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
  557. float noise(vec2 p){
  558. vec2 i=floor(p),f=fract(p);
  559. float a=hash(i),b=hash(i+vec2(1,0));
  560. float c=hash(i+vec2(0,1)),d=hash(i+vec2(1,1));
  561. vec2 u=f*f*(3.0-2.0*f);
  562. return mix(a,b,u.x)+(c-a)*u.y*(1.0-u.x)+(d-b)*u.x*u.y;
  563. }
  564. float fbm(vec2 p){
  565. float v=0.0,a=0.5;
  566. mat2 m=mat2(0.80,0.60,-0.60,0.80);
  567. for(int i=0;i<5;i++){v+=a*noise(p);p=m*p*2.02;a*=0.5;}
  568. return v;
  569. }
  570. void main(){
  571. vec2 uv=gl_FragCoord.xy/u_resolution.xy;
  572. vec2 p=uv;p.x*=u_resolution.x/u_resolution.y;
  573. vec2 m=u_mouse;m.x*=u_resolution.x/u_resolution.y;
  574. vec2 md=p-m;float dl=length(md);
  575. p+=normalize(md+vec2(0.0001))*exp(-dl*5.0)*0.03;
  576. vec2 q=vec2(fbm(p*1.8+u_time*0.07),fbm(p*1.8+vec2(5.2,1.3)+u_time*0.06));
  577. vec2 r=vec2(fbm(p*2.0+q*1.3+vec2(1.7,9.2)+u_time*0.05),
  578. fbm(p*2.0+q*1.3+vec2(8.3,2.8)+u_time*0.04));
  579. float f=fbm(p*2.2+r*1.5);
  580. vec3 silverDark=vec3(0.86,0.85,0.84);
  581. vec3 paper=vec3(0.955,0.945,0.925);
  582. vec3 col=mix(silverDark,paper,f);
  583. float ph=r.x*2.2+u_time*0.35;
  584. col+=vec3(0.78,0.62,0.92)*sin(ph)*0.055;
  585. col+=vec3(0.55,0.72,0.95)*sin(ph*0.8+2.0)*0.05;
  586. float hl=smoothstep(0.48,0.92,f);
  587. col+=hl*0.06;
  588. gl_FragColor=vec4(col,1.0);
  589. }`;
  590. const mouse={x:0.5,y:0.5};
  591. addEventListener('mousemove',e=>{mouse.x=e.clientX/innerWidth;mouse.y=e.clientY/innerHeight});
  592. function bootGL(canvasId, fsSrc){
  593. const canvas=document.getElementById(canvasId);
  594. const gl=canvas.getContext('webgl',{alpha:false,antialias:true});
  595. if(!gl) return ()=>false;
  596. const mk=(t,s)=>{const sh=gl.createShader(t);gl.shaderSource(sh,s);gl.compileShader(sh);return sh};
  597. const prog=gl.createProgram();
  598. gl.attachShader(prog,mk(gl.VERTEX_SHADER,VS));
  599. gl.attachShader(prog,mk(gl.FRAGMENT_SHADER,fsSrc));
  600. gl.linkProgram(prog);gl.useProgram(prog);
  601. const buf=gl.createBuffer();
  602. gl.bindBuffer(gl.ARRAY_BUFFER,buf);
  603. gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW);
  604. const pos=gl.getAttribLocation(prog,'position');
  605. gl.enableVertexAttribArray(pos);gl.vertexAttribPointer(pos,2,gl.FLOAT,false,0,0);
  606. const lRes=gl.getUniformLocation(prog,'u_resolution');
  607. const lT=gl.getUniformLocation(prog,'u_time');
  608. const lM=gl.getUniformLocation(prog,'u_mouse');
  609. const resize=()=>{
  610. const d=Math.min(window.devicePixelRatio||1,2);
  611. canvas.width=innerWidth*d;canvas.height=innerHeight*d;
  612. gl.viewport(0,0,canvas.width,canvas.height);
  613. };
  614. addEventListener('resize',resize);resize();
  615. return (tSec)=>{
  616. gl.uniform2f(lRes,canvas.width,canvas.height);
  617. gl.uniform1f(lT,tSec);
  618. gl.uniform2f(lM,mouse.x,1-mouse.y);
  619. gl.drawArrays(gl.TRIANGLES,0,6);
  620. return true;
  621. };
  622. }
  623. let drawDark=null, drawLight=null, glRAF=0, glT0=Date.now();
  624. function startGL(){
  625. if(window.__lowPowerMode || glRAF) return;
  626. if(!drawDark) drawDark=bootGL('bg-dark',FS_DARK);
  627. if(!drawLight) drawLight=bootGL('bg-light',FS_LIGHT);
  628. glT0=Date.now();
  629. function loop(){
  630. if(window.__lowPowerMode){glRAF=0;return;}
  631. const t=(Date.now()-glT0)/1000;
  632. drawDark(t);drawLight(t);
  633. glRAF=requestAnimationFrame(loop);
  634. }
  635. glRAF=requestAnimationFrame(loop);
  636. }
  637. function stopGL(){
  638. if(glRAF) cancelAnimationFrame(glRAF);
  639. glRAF=0;
  640. }
  641. startGL();
  642. addEventListener('ppt-low-power-change', e=>{e.detail.on ? stopGL() : startGL();});
  643. // =============== 导航(翻页 / 圆点 / 键盘 / 滚轮 / 触屏) ===============
  644. const deck=document.getElementById('deck');
  645. const slides=deck.querySelectorAll('.slide');
  646. const nav=document.getElementById('nav');
  647. let idx=0,total=slides.length,lock=false;
  648. // 关键:矫正 deck 宽度为 total * 100vw,否则翻页会错位
  649. deck.style.width=(total*100)+'vw';
  650. slides.forEach((s,i)=>{
  651. const b=document.createElement('button');
  652. b.className='dot';b.dataset.i=i;b.setAttribute('aria-label','Page '+(i+1));
  653. b.onclick=()=>go(i);
  654. nav.appendChild(b);
  655. });
  656. function go(n,opts={}){
  657. if(lock&&!opts.force)return;
  658. idx=Math.max(0,Math.min(total-1,n));
  659. window.__currentSlideIndex = idx;
  660. deck.style.transform=`translateX(${-idx*100}vw)`;
  661. nav.querySelectorAll('.dot').forEach((d,i)=>d.classList.toggle('active',i===idx));
  662. /* 主题切换:优先读 data-theme,其次从 class(light/dark)推断 */
  663. const el=slides[idx];
  664. const th=el.dataset.theme || (el.classList.contains('light')?'light':(el.classList.contains('dark')?'dark':'dark'));
  665. document.body.classList.toggle('light-bg',th==='light');
  666. /* 动效:翻页过渡中段触发当前页的入场动画(由 motion-boot 注册) */
  667. if(window.__playSlide) setTimeout(()=>window.__playSlide(idx), 450);
  668. window.__pptPresenter?.onSlideChange(idx);
  669. if(!opts.force){lock=true;setTimeout(()=>lock=false,700);}
  670. }
  671. /* =============== ESC 索引视图 =============== */
  672. let overviewOn=false;
  673. const ov=document.createElement('div');
  674. ov.id='overview';
  675. ov.style.cssText='position:fixed;inset:0;z-index:100;background:rgba(var(--ink-rgb),.92);backdrop-filter:blur(12px);display:none;overflow-y:auto;padding:4vh 4vw';
  676. document.body.appendChild(ov);
  677. function buildOverview(){
  678. ov.innerHTML='';
  679. const grid=document.createElement('div');
  680. grid.style.cssText='display:grid;grid-template-columns:repeat(4,1fr);gap:2vh 1.6vw;max-width:90vw;margin:0 auto';
  681. slides.forEach((s,i)=>{
  682. const card=document.createElement('div');
  683. card.style.cssText='cursor:pointer;border-radius:6px;overflow:hidden;border:2px solid '+(i===idx?'rgba(var(--paper-rgb),.8)':'rgba(var(--paper-rgb),.15)')+';transition:border-color .2s';
  684. card.onmouseenter=()=>card.style.borderColor='rgba(var(--paper-rgb),.6)';
  685. card.onmouseleave=()=>card.style.borderColor=i===idx?'rgba(var(--paper-rgb),.8)':'rgba(var(--paper-rgb),.15)';
  686. const wrap=document.createElement('div');
  687. wrap.style.cssText='width:100%;aspect-ratio:16/9;overflow:hidden;position:relative;pointer-events:none;background:'+(s.classList.contains('light')?'var(--paper)':'var(--ink)');
  688. const clone=s.cloneNode(true);
  689. clone.style.cssText='width:100vw;height:100vh;transform:scale('+(1/4.5)+');transform-origin:top left;position:absolute;top:0;left:0;pointer-events:none';
  690. wrap.appendChild(clone);
  691. const label=document.createElement('div');
  692. label.style.cssText='padding:6px 10px;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--paper);opacity:.7';
  693. label.textContent=window.__pptPresenter?.slideLabel(i) || ((i+1)+' / '+total);
  694. card.appendChild(wrap);
  695. card.appendChild(label);
  696. card.onclick=()=>{toggleOverview();go(i)};
  697. grid.appendChild(card);
  698. });
  699. ov.appendChild(grid);
  700. }
  701. function toggleOverview(){
  702. overviewOn=!overviewOn;
  703. if(overviewOn){buildOverview();ov.style.display='block';}
  704. else{ov.style.display='none';}
  705. }
  706. /* =============== 演讲者模式 / 观众屏同步 =============== */
  707. window.__pptPresenter=(()=>{
  708. const query=new URLSearchParams(location.search);
  709. const requestedMode=query.get('mode')||'deck';
  710. const originalTitle=document.title;
  711. const notes=()=>Array.isArray(window.__SPEAKER_NOTES__)?window.__SPEAKER_NOTES__:[];
  712. const slideId=i=>slides[i]?.dataset.slideId||`slide-${String(i+1).padStart(2,'0')}`;
  713. const storageKey=name=>`guizang-ppt-${name}:${location.pathname}`;
  714. let mode='deck',session=query.get('session')||Math.random().toString(36).slice(2),audienceWindow=null;
  715. let channel=null,transportReady=false,popupBlocked=false,audienceOpenedAt=0,lastAckAt=0,lastAckIndex=-1,lastAckSeq=-1,seq=0,lastPingAt=0;
  716. let renderedNoteId='',saveTimer=0,noteSize=Number(localStorage.getItem('guizang-ppt-note-size')||17),presenterFrameObserver=null;
  717. let elapsed=0,timerRunning=false,timerStartedAt=0,timedSlideIndex=idx,slideTimerStartedAt=0,slideElapsed=Array(total).fill(0);
  718. let rehearsalActive=false,presenterOverviewOn=false,lowPowerBeforePresenter=window.__lowPowerMode;
  719. let layoutMode=localStorage.getItem(storageKey('layout'))||'balanced';
  720. let autoEnabled=localStorage.getItem(storageKey('auto-enabled'))==='1';
  721. let autoGlobalSeconds=Math.max(0,Number(localStorage.getItem(storageKey('auto-seconds'))||0));
  722. let autoDeadline=0,autoRemainingMs=0;
  723. let screenMode='normal',audienceFrozen=false;
  724. let annotationTool='none',annotationPointerDown=false,annotationStart=null,draftCircle=null,circles=[],audienceCircles=[];
  725. let laserPoint=null,audienceLaser=null,laserHideAt=0,audienceLaserHideAt=0,lastLaserSent=0;
  726. const seenNonces=[],seenNonceSet=new Set();
  727. const syncKey=()=>`guizang-ppt-sync:${location.pathname}:${session}`;
  728. const channelName=()=>`guizang-ppt:${location.pathname}:${session}`;
  729. function noteAt(i){
  730. const id=slideId(i);
  731. return notes().find(n=>n.id===id)||{id,title:slides[i]?.dataset.title||`第 ${i+1} 页`,purpose:'—',talk:[],transition:''};
  732. }
  733. function slideLabel(i){return `${i+1} / ${total} · ${noteAt(i).title||slideId(i)}`;}
  734. function escapeHTML(value){return String(value??'').replace(/[&<>"']/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));}
  735. function valueLines(value){
  736. if(Array.isArray(value))return value.filter(Boolean).map(x=>`• ${x}`).join('\n');
  737. return value?String(value):'';
  738. }
  739. function generatedNote(n){
  740. const parts=[];
  741. if(Array.isArray(n.talk)&&n.talk.length)parts.push(`【讲述要点】\n${valueLines(n.talk)}`);
  742. if(n.transition)parts.push(`【转场】\n${n.transition}`);
  743. const optional=[['舞台提示',n.cue],['互动',n.interaction],['表达提示',n.delivery],['翻页时机',n.advance],['备用方案',n.fallback],['读音提醒',n.pronunciation]];
  744. optional.forEach(([label,value])=>{const text=valueLines(value);if(text)parts.push(`【${label}】\n${text}`);});
  745. return parts.join('\n\n')||'—';
  746. }
  747. function noteStorageKey(id){return `guizang-ppt-note:${location.pathname}:${id}`;}
  748. function loadNoteValue(id,i,n){
  749. const saved=localStorage.getItem(noteStorageKey(id));
  750. if(saved!==null)return saved;
  751. const prefix=window.__PPT_LEGACY_NOTE_KEY__;
  752. if(prefix){
  753. const legacy=localStorage.getItem(`${prefix}:${i}`);
  754. if(legacy!==null){localStorage.setItem(noteStorageKey(id),legacy);return legacy;}
  755. }
  756. return generatedNote(n);
  757. }
  758. function frameUrl(targetMode,i){
  759. const url=new URL(location.href);url.hash='';
  760. url.searchParams.set('mode',targetMode);url.searchParams.set('session',session);url.searchParams.set('slide',String(i+1));
  761. return url.href;
  762. }
  763. function postPreviewGoto(frame,i){
  764. try{frame.contentWindow?.postMessage({__guizangPptSync:1,session,role:'presenter',type:'preview-goto',index:i},'*');}catch(e){}
  765. }
  766. function updatePreviewFrame(frame,i){
  767. frame.dataset.slide=String(i);
  768. if(!frame.dataset.previewBound){frame.dataset.previewBound='1';frame.addEventListener('load',()=>postPreviewGoto(frame,Number(frame.dataset.slide)));}
  769. if(!frame.getAttribute('src'))frame.src=frameUrl('preview',i);else postPreviewGoto(frame,i);
  770. }
  771. function plannedMsAt(i){
  772. const minutes=Number(noteAt(i).minutes);
  773. return Number.isFinite(minutes)&&minutes>0?minutes*60000:null;
  774. }
  775. function totalPlannedMs(){
  776. const values=Array.from({length:total},(_,i)=>plannedMsAt(i));
  777. return values.length&&values.every(Number.isFinite)?values.reduce((sum,x)=>sum+x,0):null;
  778. }
  779. function sectionInfo(i){
  780. const name=String(noteAt(i).section||'').trim();
  781. if(!name)return null;
  782. let first=i,last=i;
  783. while(first>0&&String(noteAt(first-1).section||'').trim()===name)first--;
  784. while(last<total-1&&String(noteAt(last+1).section||'').trim()===name)last++;
  785. const targets=[];for(let x=first;x<=last;x++)targets.push(plannedMsAt(x));
  786. const planned=targets.every(Number.isFinite)?targets.reduce((sum,x)=>sum+x,0):null;
  787. const used=slideElapsed.slice(first,last+1).reduce((sum,x)=>sum+x,0)+(timerRunning&&timedSlideIndex>=first&&timedSlideIndex<=last?Date.now()-slideTimerStartedAt:0);
  788. return{name,position:i-first+1,count:last-first+1,planned,used};
  789. }
  790. function durationText(ms){
  791. const value=Math.max(0,Math.round(Number(ms)||0)),seconds=Math.floor(value/1000);
  792. const hours=Math.floor(seconds/3600),minutes=Math.floor((seconds%3600)/60),secs=seconds%60;
  793. 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')}`;
  794. }
  795. function signedDuration(ms){return `${ms>=0?'+':'−'}${durationText(Math.abs(ms))}`;}
  796. function clockTime(timestamp){return new Date(timestamp).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});}
  797. function fitFrame(frame){
  798. const viewport=frame?.closest('.ppt-frame-viewport');
  799. if(!viewport)return;
  800. const availableWidth=viewport.clientWidth,availableHeight=viewport.clientHeight;
  801. if(!availableWidth||!availableHeight)return;
  802. const width=Math.min(availableWidth,availableHeight*(16/9)),height=width*(9/16);
  803. frame.style.width=`${width}px`;frame.style.height=`${height}px`;
  804. if(frame.id==='ppt-current-frame')sizeInk(document.getElementById('ppt-presenter-ink'),width,height);
  805. }
  806. function fitPresenterFrames(){
  807. fitFrame(document.getElementById('ppt-current-frame'));
  808. fitFrame(document.getElementById('ppt-next-frame'));
  809. }
  810. function sizeInk(canvas,width,height){
  811. if(!canvas||!width||!height)return;
  812. canvas.style.width=`${width}px`;canvas.style.height=`${height}px`;
  813. const dpr=Math.min(devicePixelRatio||1,2),pixelWidth=Math.round(width*dpr),pixelHeight=Math.round(height*dpr);
  814. if(canvas.width!==pixelWidth||canvas.height!==pixelHeight){canvas.width=pixelWidth;canvas.height=pixelHeight;}
  815. }
  816. function drawEllipse(ctx,circle,width,height,alpha=1){
  817. const x1=circle.x1*width,y1=circle.y1*height,x2=circle.x2*width,y2=circle.y2*height;
  818. ctx.save();ctx.globalAlpha=alpha;ctx.strokeStyle='#ff4f59';ctx.lineWidth=Math.max(3,width*.004);ctx.lineCap='round';
  819. 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();
  820. }
  821. function drawInk(canvas,list,laser,draft){
  822. if(!canvas)return;const width=canvas.clientWidth,height=canvas.clientHeight;if(!width||!height)return;
  823. 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);
  824. (list||[]).forEach(circle=>drawEllipse(ctx,circle,width,height));
  825. if(draft)drawEllipse(ctx,draft,width,height,.72);
  826. if(laser){
  827. const x=laser.x*width,y=laser.y*height,r=Math.max(6,width*.007);
  828. 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)');
  829. ctx.fillStyle=glow;ctx.beginPath();ctx.arc(x,y,r*2.8,0,Math.PI*2);ctx.fill();
  830. }
  831. }
  832. function renderInks(){
  833. const now=Date.now();if(now>laserHideAt)laserPoint=null;if(now>audienceLaserHideAt)audienceLaser=null;
  834. drawInk(document.getElementById('ppt-presenter-ink'),circles,laserPoint,draftCircle);
  835. drawInk(document.getElementById('ppt-audience-ink'),audienceCircles,audienceLaser,null);
  836. }
  837. function setAnnotationTool(tool){
  838. annotationTool=annotationTool===tool?'none':tool;annotationPointerDown=false;draftCircle=null;
  839. const canvas=document.getElementById('ppt-presenter-ink');canvas?.classList.toggle('is-active',annotationTool!=='none');
  840. document.querySelectorAll('[data-ppt-tool]').forEach(button=>button.classList.toggle('is-active',button.dataset.pptTool===annotationTool));
  841. renderInks();
  842. }
  843. function annotationPoint(event){
  844. const canvas=event.currentTarget,rect=canvas.getBoundingClientRect();
  845. 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))};
  846. }
  847. function onAnnotationPointerDown(event){
  848. 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();
  849. }
  850. function onAnnotationPointerMove(event){
  851. if(annotationTool==='laser'){
  852. const point=annotationPoint(event);laserPoint=point;laserHideAt=Date.now()+650;
  853. if(Date.now()-lastLaserSent>32){lastLaserSent=Date.now();emit({type:'laser',index:idx,point},{persist:false});}renderInks();return;
  854. }
  855. if(annotationTool==='circle'&&annotationPointerDown&&draftCircle){const point=annotationPoint(event);draftCircle.x2=point.x;draftCircle.y2=point.y;renderInks();}
  856. }
  857. function onAnnotationPointerUp(event){
  858. if(annotationTool!=='circle'||!annotationPointerDown||!draftCircle)return;event.preventDefault();annotationPointerDown=false;
  859. const circle={...draftCircle};draftCircle=null;
  860. if(Math.abs(circle.x2-circle.x1)>.012&&Math.abs(circle.y2-circle.y1)>.012){circles.push(circle);emit({type:'circle',index:idx,circle});}
  861. renderInks();
  862. }
  863. function onAnnotationPointerLeave(){
  864. if(annotationTool==='laser'){laserPoint=null;emit({type:'laser',index:idx,point:null},{persist:false});renderInks();}
  865. }
  866. function clearAnnotations(broadcast=true){circles=[];draftCircle=null;laserPoint=null;if(broadcast)emit({type:'clear',index:idx});renderInks();}
  867. function buildPresenterOverview(){
  868. const grid=document.getElementById('ppt-presenter-overview-grid');if(!grid)return;grid.innerHTML='';
  869. slides.forEach((slide,i)=>{
  870. const n=noteAt(i),button=document.createElement('button');button.type='button';button.className=`ppt-overview-page${i===idx?' is-current':''}`;
  871. button.innerHTML=`<span class="ppt-overview-page-no">${String(i+1).padStart(2,'0')} / ${String(total).padStart(2,'0')}</span><span class="ppt-overview-page-title">${escapeHTML(n.title||slideId(i))}</span><span class="ppt-overview-page-meta"><span>${escapeHTML(n.section||'未分组')}</span><span>${Math.round(((i+1)/total)*100)}%</span></span>`;
  872. button.onclick=()=>{togglePresenterOverview(false);go(i,{force:true});};grid.appendChild(button);
  873. });
  874. requestAnimationFrame(()=>grid.querySelector('.is-current')?.scrollIntoView({block:'nearest',inline:'nearest'}));
  875. }
  876. function togglePresenterOverview(force){
  877. presenterOverviewOn=typeof force==='boolean'?force:!presenterOverviewOn;
  878. if(presenterOverviewOn&&annotationTool!=='none')setAnnotationTool('none');
  879. const stack=document.querySelector('.ppt-preview-stack'),overview=document.getElementById('ppt-presenter-overview');
  880. stack?.classList.toggle('is-overview',presenterOverviewOn);if(overview)overview.hidden=!presenterOverviewOn;
  881. const button=document.getElementById('ppt-grid');if(button){button.classList.toggle('is-active',presenterOverviewOn);button.setAttribute('aria-pressed',String(presenterOverviewOn));}
  882. if(presenterOverviewOn)buildPresenterOverview();else requestAnimationFrame(fitPresenterFrames);renderAutoState();
  883. }
  884. function mountShell(){
  885. if(document.getElementById('ppt-presenter'))return;
  886. document.body.insertAdjacentHTML('beforeend',`<div id="ppt-presenter" data-layout="${layoutMode}" aria-label="演讲者视图">
  887. <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-auto-toggle" class="ppt-ui-btn ppt-auto-state" type="button">自动翻页 · 关闭</button><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>
  888. <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 id="ppt-grid" class="ppt-ui-btn ppt-grid-btn" type="button" aria-pressed="false" title="宫格选页(Esc)"><span class="ppt-grid-icon" aria-hidden="true"><i></i><i></i><i></i><i></i></span>宫格</button><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 id="ppt-presenter-overview" class="ppt-presenter-overview" hidden><div class="ppt-overview-head"><strong>选择页面</strong><button id="ppt-overview-close" class="ppt-ui-btn" type="button">返回预览</button></div><div id="ppt-presenter-overview-grid" class="ppt-overview-grid"></div></div></div>
  889. <div class="ppt-notes-card"><div class="ppt-notes-head"><div class="ppt-notes-label">标题</div><div id="ppt-notes-title" class="ppt-notes-title"></div><div class="ppt-purpose-block"><div class="ppt-notes-label">本页目的</div><div id="ppt-notes-purpose" class="ppt-purpose-copy"></div></div><div class="ppt-notes-draft-head"><div><div class="ppt-notes-label">草稿(备注)</div><div id="ppt-note-meta" class="ppt-note-meta"></div></div><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>
  890. <div class="ppt-presenter-bottom"><div class="ppt-time-dashboard"><div class="ppt-time-stat"><span class="ppt-time-label">已进行</span><strong id="ppt-clock" class="ppt-time-value">00:00</strong><span class="ppt-time-hint">总时长</span></div><div class="ppt-time-stat"><span class="ppt-time-label">本页</span><strong id="ppt-slide-clock" class="ppt-time-value">00:00</strong><span id="ppt-slide-plan" class="ppt-time-hint">计划 —</span></div><div id="ppt-slide-status-box" class="ppt-time-stat"><span id="ppt-slide-status-label" class="ppt-time-label">计划</span><strong id="ppt-slide-status" class="ppt-time-value">—</strong><span id="ppt-slide-status-hint" class="ppt-time-hint">未设置</span></div></div><div class="ppt-control-stack"><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-next" class="ppt-ui-btn" type="button">下一页</button><button id="ppt-last" class="ppt-ui-btn" type="button">尾页</button></div><div class="ppt-session-actions"><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><div class="ppt-page-state"><strong id="ppt-page-count"></strong><span id="ppt-page-percent"></span></div></div>
  891. <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>
  892. </div>`);
  893. document.getElementById('ppt-reopen').onclick=reopenAudience;
  894. document.getElementById('ppt-exit').onclick=exitPresenter;
  895. document.getElementById('ppt-preflight').onclick=openPreflight;
  896. document.getElementById('ppt-settings').onclick=openSettings;
  897. document.getElementById('ppt-first').onclick=()=>go(0,{force:true});
  898. document.getElementById('ppt-prev').onclick=()=>go(idx-1,{force:true});
  899. document.getElementById('ppt-grid').onclick=()=>togglePresenterOverview();
  900. document.getElementById('ppt-overview-close').onclick=()=>togglePresenterOverview(false);
  901. document.getElementById('ppt-next').onclick=()=>go(idx+1,{force:true});
  902. document.getElementById('ppt-last').onclick=()=>go(idx===total-1?0:total-1,{force:true});
  903. document.getElementById('ppt-timer-toggle').onclick=toggleTimer;
  904. document.getElementById('ppt-timer-reset').onclick=resetTimer;
  905. document.getElementById('ppt-rehearsal').onclick=toggleRehearsal;
  906. document.getElementById('ppt-auto-toggle').onclick=toggleAutoAdvance;
  907. document.getElementById('ppt-note-smaller').onclick=()=>setNoteSize(noteSize-1);
  908. document.getElementById('ppt-note-larger').onclick=()=>setNoteSize(noteSize+1);
  909. document.getElementById('ppt-note-editor').addEventListener('input',saveNote);
  910. document.querySelectorAll('[data-ppt-tool]').forEach(button=>button.onclick=()=>setAnnotationTool(button.dataset.pptTool));
  911. document.getElementById('ppt-ink-clear').onclick=()=>clearAnnotations(true);
  912. const ink=document.getElementById('ppt-presenter-ink');
  913. ink.addEventListener('pointerdown',onAnnotationPointerDown);ink.addEventListener('pointermove',onAnnotationPointerMove);ink.addEventListener('pointerup',onAnnotationPointerUp);ink.addEventListener('pointercancel',onAnnotationPointerUp);ink.addEventListener('pointerleave',onAnnotationPointerLeave);
  914. document.getElementById('ppt-dialog-close').onclick=closeDialog;
  915. document.getElementById('ppt-dialog').onclick=event=>{if(event.target.id==='ppt-dialog')closeDialog();};
  916. if('ResizeObserver'in window){presenterFrameObserver=new ResizeObserver(()=>requestAnimationFrame(fitPresenterFrames));presenterFrameObserver.observe(document.querySelector('.ppt-preview-stack'));}
  917. else addEventListener('resize',fitPresenterFrames);
  918. setNoteSize(noteSize,false);applyLayout(layoutMode,false);requestAnimationFrame(fitPresenterFrames);
  919. }
  920. function updateShell(){
  921. if(mode!=='presenter')return;mountShell();
  922. const n=noteAt(idx),next=Math.min(idx+1,total-1),id=slideId(idx),editor=document.getElementById('ppt-note-editor');
  923. const currentFrame=document.getElementById('ppt-current-frame'),nextFrame=document.getElementById('ppt-next-frame');
  924. if(currentFrame.dataset.slide!==String(idx))updatePreviewFrame(currentFrame,idx);
  925. if(nextFrame.dataset.slide!==String(next))updatePreviewFrame(nextFrame,next);
  926. document.getElementById('ppt-next-card').classList.toggle('is-end',idx===total-1);
  927. document.getElementById('ppt-current-label').textContent=`${idx+1} / ${total}`;
  928. document.getElementById('ppt-next-label').textContent=idx===total-1?'已到尾页':`${next+1} / ${total}`;
  929. document.getElementById('ppt-notes-title').textContent=n.title||`第 ${idx+1} 页`;
  930. document.getElementById('ppt-notes-purpose').textContent=n.purpose||'—';
  931. const target=plannedMsAt(idx);document.getElementById('ppt-note-meta').textContent=`建议 ${target?(target/60000).toFixed(1):'—'} 分钟 · ${id}`;
  932. document.getElementById('ppt-page-count').textContent=`第 ${idx+1} / ${total} 页`;
  933. document.getElementById('ppt-page-percent').textContent=`${Math.round(((idx+1)/total)*100)}%`;
  934. document.getElementById('ppt-first').disabled=idx===0;document.getElementById('ppt-prev').disabled=idx===0;document.getElementById('ppt-next').disabled=idx===total-1;
  935. document.getElementById('ppt-last').textContent=idx===total-1?'重新开始':'尾页';
  936. if(renderedNoteId!==id){renderedNoteId=id;editor.value=loadNoteValue(id,idx,n);editor.scrollTop=0;document.getElementById('ppt-saved').textContent='已保存';}
  937. if(presenterOverviewOn)buildPresenterOverview();requestAnimationFrame(fitPresenterFrames);renderTimer();updateSyncStatus();
  938. }
  939. function saveNote(){
  940. const saved=document.getElementById('ppt-saved');saved.textContent='保存中…';saved.classList.remove('ppt-saved');clearTimeout(saveTimer);
  941. saveTimer=setTimeout(()=>{localStorage.setItem(noteStorageKey(renderedNoteId),document.getElementById('ppt-note-editor').value);saved.textContent='已保存';saved.classList.add('ppt-saved');},300);
  942. }
  943. 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));}
  944. function currentElapsed(){return elapsed+(timerRunning?Date.now()-timerStartedAt:0);}
  945. function currentSlideElapsed(i=idx){return slideElapsed[i]+(timerRunning&&timedSlideIndex===i?Date.now()-slideTimerStartedAt:0);}
  946. function commitRunningTime(now=Date.now()){
  947. if(!timerRunning)return;elapsed+=now-timerStartedAt;slideElapsed[timedSlideIndex]+=now-slideTimerStartedAt;timerStartedAt=now;slideTimerStartedAt=now;
  948. }
  949. function rollSlideTimer(nextIndex){if(timerRunning)commitRunningTime();timedSlideIndex=nextIndex;slideTimerStartedAt=Date.now();}
  950. function renderTimer(){
  951. const totalUsed=currentElapsed(),pageUsed=currentSlideElapsed(),target=plannedMsAt(idx),clock=document.getElementById('ppt-clock');
  952. if(clock)clock.textContent=durationText(totalUsed);
  953. const slideClock=document.getElementById('ppt-slide-clock'),slidePlan=document.getElementById('ppt-slide-plan'),statusBox=document.getElementById('ppt-slide-status-box'),statusLabel=document.getElementById('ppt-slide-status-label'),statusValue=document.getElementById('ppt-slide-status'),statusHint=document.getElementById('ppt-slide-status-hint');
  954. if(slideClock)slideClock.textContent=durationText(pageUsed);if(slidePlan)slidePlan.textContent=target?`计划 ${durationText(target)}`:'计划 —';
  955. if(statusBox&&statusLabel&&statusValue&&statusHint){
  956. const over=Boolean(target&&pageUsed>target),remaining=Boolean(target&&!over);statusBox.classList.toggle('is-over',over);statusBox.classList.toggle('is-remaining',remaining);
  957. statusLabel.textContent=over?'超时':(remaining?'剩余':'计划');statusValue.textContent=target?durationText(Math.abs(target-pageUsed)):'—';statusHint.textContent=over?'已超出本页计划':(remaining?'距本页计划':'未设置');
  958. }
  959. const totalPlan=totalPlannedMs(),plan=document.getElementById('ppt-plan');
  960. if(plan)plan.textContent=totalPlan?`计划 ${Number(totalPlan/60000).toFixed(1)} 分钟 · 预计 ${clockTime(Date.now()+Math.max(0,totalPlan-totalUsed))}`:'计划 —';
  961. const section=sectionInfo(idx),sectionEl=document.getElementById('ppt-section');
  962. if(sectionEl){sectionEl.hidden=!section;sectionEl.textContent=section?`${section.name} · ${section.position}/${section.count}${section.planned?` · 剩余 ${durationText(Math.max(0,section.planned-section.used))}`:''}`:'';}
  963. const timerButton=document.getElementById('ppt-timer-toggle');if(timerButton)timerButton.textContent=timerRunning?'暂停':(totalUsed?'继续计时':'开始计时');
  964. const rehearsalButton=document.getElementById('ppt-rehearsal');if(rehearsalButton){rehearsalButton.textContent=rehearsalActive?'结束排练':'排练';rehearsalButton.classList.toggle('ppt-rehearsal-on',rehearsalActive);}
  965. renderAutoState();
  966. }
  967. function toggleTimer(){const now=Date.now();if(timerRunning){commitRunningTime(now);timerRunning=false;}else{timerStartedAt=now;slideTimerStartedAt=now;timedSlideIndex=idx;timerRunning=true;}renderTimer();}
  968. function resetTimer(){
  969. elapsed=0;slideElapsed=Array(total).fill(0);timedSlideIndex=idx;
  970. if(timerRunning){timerStartedAt=Date.now();slideTimerStartedAt=timerStartedAt;}else{timerStartedAt=0;slideTimerStartedAt=0;}
  971. renderTimer();
  972. }
  973. function rehearsalHistory(){try{return JSON.parse(localStorage.getItem(storageKey('rehearsals'))||'[]');}catch(e){return[];}}
  974. function toggleRehearsal(){
  975. if(rehearsalActive){finishRehearsal(true);return;}
  976. rehearsalActive=true;if(timerRunning){commitRunningTime();timerRunning=false;}resetTimer();toggleTimer();renderTimer();
  977. }
  978. function finishRehearsal(showSummary){
  979. if(!rehearsalActive)return;if(timerRunning){commitRunningTime();timerRunning=false;}
  980. 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)}))};
  981. const history=rehearsalHistory();history.unshift(result);try{localStorage.setItem(storageKey('rehearsals'),JSON.stringify(history.slice(0,5)));}catch(e){}
  982. rehearsalActive=false;renderTimer();if(showSummary)openRehearsalSummary(result,history.slice(0,5));
  983. }
  984. function openRehearsalSummary(result,history){
  985. 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('');
  986. const recent=history.map(item=>`<div class="ppt-dialog-row"><span>${new Date(item.endedAt).toLocaleString()}</span><strong>${durationText(item.totalMs)}</strong></div>`).join('');
  987. 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>`);
  988. }
  989. 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;}
  990. function armAutoAdvance(){autoDeadline=0;autoRemainingMs=autoDurationMs()||0;}
  991. function autoBlockedReason(){
  992. if(presenterOverviewOn||overviewOn)return'宫格选页中';if(annotationTool!=='none')return'标注中';if(screenMode!=='normal')return'观众屏已暂停';if(document.hidden)return'窗口不可见';
  993. const dialog=document.getElementById('ppt-dialog');if(dialog&&!dialog.hidden)return'设置中';
  994. if(audienceOpenedAt&&syncState()!=='synced')return'等待观众屏同步';return'';
  995. }
  996. function updateAutoAdvance(){
  997. if(!autoEnabled)return;
  998. const duration=autoDurationMs();if(!duration||idx===total-1)return;
  999. const blocked=autoBlockedReason(),now=Date.now();
  1000. if(blocked){if(autoDeadline){autoRemainingMs=Math.max(0,autoDeadline-now);autoDeadline=0;}return;}
  1001. if(!autoDeadline)autoDeadline=now+(autoRemainingMs||duration);
  1002. autoRemainingMs=Math.max(0,autoDeadline-now);
  1003. if(now>=autoDeadline){autoDeadline=0;autoRemainingMs=0;go(idx+1,{force:true});}
  1004. }
  1005. function renderAutoState(){
  1006. const button=document.getElementById('ppt-auto-toggle');if(!button)return;
  1007. const duration=autoDurationMs(),blocked=autoEnabled?autoBlockedReason():'';
  1008. button.classList.toggle('is-on',autoEnabled&&!blocked);button.classList.toggle('is-paused',autoEnabled&&Boolean(blocked));
  1009. if(!autoEnabled)button.textContent='自动翻页 · 关闭';
  1010. else if(!duration)button.textContent='自动翻页 · 等待时长';
  1011. else if(idx===total-1)button.textContent='自动翻页 · 已结束';
  1012. else if(blocked)button.textContent=`自动翻页 · ${blocked}`;
  1013. else button.textContent=`自动翻页 · ${durationText(autoRemainingMs||duration)}`;
  1014. }
  1015. function toggleAutoAdvance(){
  1016. if(!autoEnabled&&!autoDurationMs()){openSettings();return;}
  1017. autoEnabled=!autoEnabled;localStorage.setItem(storageKey('auto-enabled'),autoEnabled?'1':'0');armAutoAdvance();renderAutoState();
  1018. }
  1019. function openDialog(title,html){
  1020. const dialog=document.getElementById('ppt-dialog');if(!dialog)return;
  1021. document.getElementById('ppt-dialog-title').textContent=title;document.getElementById('ppt-dialog-body').innerHTML=html;dialog.hidden=false;renderAutoState();
  1022. }
  1023. function closeDialog(){const dialog=document.getElementById('ppt-dialog');if(dialog)dialog.hidden=true;renderAutoState();}
  1024. function applyLayout(value,persist=true){
  1025. layoutMode=['balanced','slides','notes'].includes(value)?value:'balanced';
  1026. document.getElementById('ppt-presenter')?.setAttribute('data-layout',layoutMode);
  1027. if(persist)localStorage.setItem(storageKey('layout'),layoutMode);requestAnimationFrame(fitPresenterFrames);
  1028. }
  1029. function applyAudienceScreen(value){
  1030. const cover=document.getElementById('ppt-audience-cover');if(!cover)return;
  1031. cover.classList.remove('is-ended');cover.textContent='';
  1032. if(value==='black'||value==='white'){cover.style.display='block';cover.style.background=value;}else cover.style.display='none';
  1033. }
  1034. function showAudienceEnded(){
  1035. const cover=document.getElementById('ppt-audience-cover');if(cover){cover.style.display='';cover.style.background='';cover.textContent='演示已结束';cover.classList.add('is-ended');}
  1036. setTimeout(()=>{try{window.close();}catch(e){}},120);
  1037. }
  1038. function setScreenMode(value){
  1039. const next=['normal','black','white','frozen'].includes(value)?value:'normal',wasFrozen=audienceFrozen;
  1040. screenMode=next;audienceFrozen=next==='frozen';emit({type:'screen',mode:next});
  1041. if(wasFrozen&&!audienceFrozen)sendGoto(true);updateSyncStatus();armAutoAdvance();renderAutoState();
  1042. }
  1043. function openSettings(){
  1044. openDialog('演示设置',`<div class="ppt-settings-card"><label class="ppt-switch-row" for="ppt-auto-enabled-setting"><span><strong>自动翻页</strong><br><span class="ppt-muted">需要时手动开启,默认关闭</span></span><input id="ppt-auto-enabled-setting" type="checkbox" ${autoEnabled?'checked':''}><span class="ppt-switch" aria-hidden="true"></span></label><div class="ppt-field"><label for="ppt-auto-seconds-setting">全局间隔</label><div class="ppt-stepper"><button id="ppt-auto-minus" type="button" aria-label="减少一秒">−</button><input id="ppt-auto-seconds-setting" type="number" min="1" step="1" value="${autoGlobalSeconds||''}" placeholder="—"><span class="ppt-stepper-unit">秒</span><button id="ppt-auto-plus" type="button" aria-label="增加一秒">+</button></div></div><button id="ppt-settings-save" class="ppt-ui-btn ppt-settings-save" type="button">保存自动翻页</button><p class="ppt-settings-copy">单页 autoAdvanceSeconds 优先于全局间隔;minutes 只是讲述计划。</p></div><div class="ppt-settings-card"><h3>演讲者布局</h3><p class="ppt-settings-copy">根据屏幕宽度调整页面与备注的比例</p><div class="ppt-segmented"><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-settings-card"><h3>观众屏</h3><p class="ppt-settings-copy">临时遮挡或冻结观众屏</p><div class="ppt-segmented screen"><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>`);
  1045. document.querySelectorAll('[data-layout-choice]').forEach(button=>{button.classList.toggle('ppt-primary',button.dataset.layoutChoice===layoutMode);button.onclick=()=>{applyLayout(button.dataset.layoutChoice);openSettings();};});
  1046. document.querySelectorAll('[data-screen-choice]').forEach(button=>{button.classList.toggle('ppt-primary',button.dataset.screenChoice===screenMode);button.onclick=()=>{setScreenMode(button.dataset.screenChoice);openSettings();};});
  1047. const secondsInput=document.getElementById('ppt-auto-seconds-setting'),stepSeconds=delta=>{const current=Math.max(0,Number(secondsInput.value||0));secondsInput.value=String(Math.max(1,current+delta));};
  1048. document.getElementById('ppt-auto-minus').onclick=()=>stepSeconds(-1);document.getElementById('ppt-auto-plus').onclick=()=>stepSeconds(1);
  1049. document.getElementById('ppt-settings-save').onclick=()=>{autoEnabled=document.getElementById('ppt-auto-enabled-setting').checked;autoGlobalSeconds=Math.max(0,Number(secondsInput.value||0));localStorage.setItem(storageKey('auto-enabled'),autoEnabled?'1':'0');localStorage.setItem(storageKey('auto-seconds'),String(autoGlobalSeconds));armAutoAdvance();closeDialog();};
  1050. document.getElementById('ppt-open-shortcuts').onclick=openShortcuts;
  1051. }
  1052. function openShortcuts(){
  1053. const items=[['← / →','上一页 / 下一页'],['Home / End','首页 / 尾页'],['Esc','宫格选页;标注工具激活时先退出工具'],['L','激光笔'],['C','圈选'],['X','清除标记'],['B / W','观众屏黑屏 / 白屏'],['F','冻结 / 恢复观众屏'],['A','开启 / 关闭自动翻页'],['R','开始 / 结束排练'],['?','快捷键说明']];
  1054. openDialog('快捷键',`<div class="ppt-shortcuts">${items.map(([key,label])=>`<kbd>${key}</kbd><span>${label}</span>`).join('')}</div>`);
  1055. }
  1056. function preflightChecks(){
  1057. const images=[...document.images],videos=[...document.querySelectorAll('video')],frame=document.getElementById('ppt-current-frame'),rect=frame?.getBoundingClientRect();
  1058. return[
  1059. {label:'观众屏连接',ok:['synced','frozen'].includes(syncState()),detail:`当前状态:${document.getElementById('ppt-sync')?.textContent||'未连接'}`},
  1060. {label:'弹窗权限',ok:!popupBlocked,detail:popupBlocked?'浏览器阻止了观众窗口':'未检测到弹窗拦截'},
  1061. {label:'全屏状态',ok:Boolean(document.fullscreenElement),warn:true,detail:document.fullscreenElement?'已进入全屏':'尚未全屏,可在演讲前进入'},
  1062. {label:'字体加载',ok:!document.fonts||document.fonts.status==='loaded',detail:document.fonts?`状态:${document.fonts.status}`:'浏览器不提供字体状态'},
  1063. {label:'图片资源',ok:images.every(image=>image.complete&&image.naturalWidth>0),detail:`${images.filter(image=>image.complete&&image.naturalWidth>0).length} / ${images.length} 已加载`},
  1064. {label:'视频资源',ok:videos.every(video=>video.readyState>=2),detail:videos.length?`${videos.filter(video=>video.readyState>=2).length} / ${videos.length} 可播放`:'没有视频'},
  1065. {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)}`:'尚未渲染'}
  1066. ];
  1067. }
  1068. function openPreflight(){
  1069. 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('');
  1070. 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>`);
  1071. document.getElementById('ppt-preflight-refresh').onclick=openPreflight;
  1072. document.getElementById('ppt-enter-fullscreen').onclick=async()=>{try{await document.documentElement.requestFullscreen();}catch(e){}openPreflight();};
  1073. }
  1074. function initTransport(){
  1075. if(transportReady)return;transportReady=true;
  1076. if('BroadcastChannel'in window){try{channel=new BroadcastChannel(channelName());channel.onmessage=e=>receive(e.data);}catch(e){}}
  1077. addEventListener('message',e=>receive(e.data));
  1078. addEventListener('storage',e=>{if(e.key===syncKey()&&e.newValue){try{receive(JSON.parse(e.newValue));}catch(err){}}});
  1079. }
  1080. function emit(payload,{persist=true}={}){
  1081. const message={__guizangPptSync:1,session,role:mode,...payload,at:Date.now(),nonce:Math.random().toString(36).slice(2)};
  1082. try{channel?.postMessage(message);}catch(e){}
  1083. if(persist)try{localStorage.setItem(syncKey(),JSON.stringify(message));}catch(e){}
  1084. try{if(mode==='presenter'&&audienceWindow&&!audienceWindow.closed)audienceWindow.postMessage(message,'*');}catch(e){}
  1085. try{if(mode==='audience'&&window.opener&&!window.opener.closed)window.opener.postMessage(message,'*');}catch(e){}
  1086. }
  1087. function receive(message){
  1088. if(!message||message.__guizangPptSync!==1||message.session!==session||message.role===mode)return;
  1089. 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());}
  1090. if(mode==='presenter'){
  1091. if(message.type==='ready'){popupBlocked=false;sendGoto(true);}
  1092. if(message.type==='ack'){lastAckAt=Date.now();lastAckIndex=Number(message.index);lastAckSeq=Number(message.seq);updateSyncStatus();}
  1093. return;
  1094. }
  1095. if(mode!=='audience')return;
  1096. if(message.type==='bye'){showAudienceEnded();return;}
  1097. if(message.type==='screen'){applyAudienceScreen(message.mode);return;}
  1098. if(message.type==='laser'&&Number(message.index)===idx){audienceLaser=message.point||null;audienceLaserHideAt=Date.now()+650;renderInks();return;}
  1099. if(message.type==='circle'&&Number(message.index)===idx&&message.circle){audienceCircles.push(message.circle);renderInks();return;}
  1100. if(message.type==='clear'&&Number(message.index)===idx){audienceCircles=[];renderInks();return;}
  1101. if(message.type==='goto'||message.type==='ping'){
  1102. if(message.type==='goto'){audienceCircles=Array.isArray(message.circles)?message.circles:[];applyAudienceScreen(message.screenMode);go(Number(message.index),{force:true});}
  1103. emit({type:'ack',index:idx,seq:Number(message.seq)});
  1104. }
  1105. }
  1106. function receivePreview(message){
  1107. if(!message||message.__guizangPptSync!==1||message.session!==session||message.role!=='presenter'||message.type!=='preview-goto')return;
  1108. go(Number(message.index),{force:true});
  1109. }
  1110. function sendGoto(force=false){
  1111. if(mode!=='presenter'||audienceFrozen)return;if(force||lastAckIndex!==idx)seq++;
  1112. emit({type:'goto',index:idx,seq,screenMode,circles});updateSyncStatus();
  1113. }
  1114. function syncState(){
  1115. if(popupBlocked)return'blocked';if(audienceWindow&&audienceWindow.closed)return'disconnected';if(audienceFrozen)return'frozen';
  1116. if(!lastAckAt)return audienceOpenedAt&&Date.now()-audienceOpenedAt<2600?'connecting':'disconnected';
  1117. if(Date.now()-lastAckAt>2600)return'disconnected';if(lastAckIndex!==idx||lastAckSeq<seq)return'unsynced';return'synced';
  1118. }
  1119. function updateSyncStatus(){
  1120. const el=document.getElementById('ppt-sync');if(!el)return;const state=syncState();
  1121. const labels={blocked:'弹窗被拦截',disconnected:'未连接',connecting:'连接中',unsynced:'未同步',frozen:'已冻结',synced:`已同步 · 第 ${idx+1} 页`};
  1122. el.dataset.state=state;el.textContent=`观众屏 · ${labels[state]}`;
  1123. }
  1124. function reopenAudience(){
  1125. initTransport();popupBlocked=false;audienceOpenedAt=Date.now();lastAckAt=0;lastAckIndex=-1;lastAckSeq=-1;
  1126. if(audienceFrozen){audienceFrozen=false;screenMode='normal';}
  1127. audienceWindow=window.open(frameUrl('audience',idx),`guizang-ppt-audience-${session}`,'popup=yes,width=1440,height=900');
  1128. if(!audienceWindow)popupBlocked=true;updateSyncStatus();
  1129. }
  1130. function enterPresenter(openAudience=true){
  1131. if(mode==='presenter')return;if(overviewOn)toggleOverview();lowPowerBeforePresenter=window.__lowPowerMode;mode='presenter';initTransport();mountShell();
  1132. document.body.classList.remove('ppt-audience','ppt-preview');document.body.classList.add('ppt-presenter');document.title=`演讲者视图 · ${originalTitle}`;
  1133. window.__setLowPowerMode?.(true,{persist:false});updateShell();armAutoAdvance();if(openAudience)reopenAudience();
  1134. }
  1135. function exitPresenter(){
  1136. if(rehearsalActive)finishRehearsal(false);else if(timerRunning){commitRunningTime();timerRunning=false;}togglePresenterOverview(false);emit({type:'bye'});audienceWindow=null;audienceOpenedAt=0;lastAckAt=0;lastAckIndex=-1;lastAckSeq=-1;mode='deck';document.body.classList.remove('ppt-presenter');document.title=originalTitle;window.__setLowPowerMode?.(lowPowerBeforePresenter,{persist:false});
  1137. }
  1138. function initAudience(){
  1139. mode='audience';document.body.classList.add('ppt-audience');document.title=`观众屏 · ${originalTitle}`;
  1140. document.body.insertAdjacentHTML('beforeend','<canvas id="ppt-audience-ink" aria-hidden="true"></canvas><div id="ppt-audience-cover" aria-hidden="true"></div>');
  1141. const resize=()=>{sizeInk(document.getElementById('ppt-audience-ink'),innerWidth,innerHeight);renderInks();};addEventListener('resize',resize);resize();
  1142. initTransport();emit({type:'ready',index:idx});
  1143. }
  1144. function initPreview(){mode='preview';document.body.classList.add('ppt-preview');window.__setLowPowerMode?.(true,{persist:false});addEventListener('message',e=>{if(e.source===parent)receivePreview(e.data);});}
  1145. function onSlideChange(nextIndex){
  1146. if(mode!=='presenter')return;rollSlideTimer(nextIndex);circles=[];draftCircle=null;laserPoint=null;annotationPointerDown=false;armAutoAdvance();updateShell();sendGoto();
  1147. }
  1148. function handleKey(e){
  1149. const editable=e.target?.matches?.('input,textarea,[contenteditable="true"]');
  1150. if(editable){if(e.key==='Escape')e.target.blur();return true;}
  1151. if(mode==='audience'||mode==='preview'){e.preventDefault();return true;}
  1152. if(mode==='deck'&&e.key?.toLowerCase()==='p'&&!e.metaKey&&!e.ctrlKey&&!e.altKey){e.preventDefault();enterPresenter(true);return true;}
  1153. if(mode!=='presenter')return false;
  1154. const key=e.key?.toLowerCase();
  1155. const dialog=document.getElementById('ppt-dialog');
  1156. if(!dialog?.hidden){if(e.key==='Escape'){e.preventDefault();closeDialog();}return true;}
  1157. if(e.key==='?'||key==='/'){e.preventDefault();openShortcuts();return true;}
  1158. if(e.key==='Escape'){e.preventDefault();if(annotationTool!=='none')setAnnotationTool('none');else togglePresenterOverview();return true;}
  1159. if(presenterOverviewOn)return true;
  1160. if(key==='l'){e.preventDefault();setAnnotationTool('laser');return true;}
  1161. if(key==='c'){e.preventDefault();setAnnotationTool('circle');return true;}
  1162. if(key==='x'){e.preventDefault();clearAnnotations(true);return true;}
  1163. if(key==='b'){e.preventDefault();setScreenMode(screenMode==='black'?'normal':'black');return true;}
  1164. if(key==='w'){e.preventDefault();setScreenMode(screenMode==='white'?'normal':'white');return true;}
  1165. if(key==='f'){e.preventDefault();setScreenMode(audienceFrozen?'normal':'frozen');return true;}
  1166. if(key==='a'){e.preventDefault();toggleAutoAdvance();return true;}
  1167. if(key==='r'){e.preventDefault();toggleRehearsal();return true;}
  1168. if(['ArrowRight','PageDown',' ','ArrowDown'].includes(e.key)){e.preventDefault();go(idx+1,{force:true});return true;}
  1169. if(['ArrowLeft','PageUp','ArrowUp'].includes(e.key)){e.preventDefault();go(idx-1,{force:true});return true;}
  1170. if(e.key==='Home'){e.preventDefault();go(0,{force:true});return true;}
  1171. if(e.key==='End'){e.preventDefault();go(total-1,{force:true});return true;}
  1172. return false;
  1173. }
  1174. function initMode(){
  1175. document.getElementById('ppt-low-power-btn')?.addEventListener('click',()=>window.__setLowPowerMode(!window.__lowPowerMode));
  1176. document.getElementById('ppt-overview-btn')?.addEventListener('click',toggleOverview);
  1177. document.getElementById('ppt-presenter-btn')?.addEventListener('click',()=>enterPresenter(true));
  1178. if(requestedMode==='audience')initAudience();else if(requestedMode==='preview')initPreview();else if(requestedMode==='presenter')enterPresenter(false);
  1179. }
  1180. setInterval(()=>{
  1181. renderTimer();renderInks();updateAutoAdvance();
  1182. if(mode==='presenter'&&Date.now()-lastPingAt>900){lastPingAt=Date.now();if(audienceWindow?.closed)updateSyncStatus();emit({type:'ping',index:idx,seq});updateSyncStatus();}
  1183. },250);
  1184. return{initMode,onSlideChange,handleKey,slideLabel,enterPresenter,reopenAudience,toggleOverview:togglePresenterOverview,debugState:()=>({mode,idx,session,syncState:syncState(),timerRunning,rehearsalActive,presenterOverviewOn,autoEnabled,autoGlobalSeconds,autoRemainingMs,screenMode,annotationTool,circles:[...circles],audienceCircles:[...audienceCircles],layoutMode})};
  1185. })();
  1186. /* =============== /演讲者模式 / 观众屏同步 =============== */
  1187. addEventListener('keydown',e=>{
  1188. if(window.__pptPresenter?.handleKey(e))return;
  1189. if(e.key==='Escape'){e.preventDefault();toggleOverview();return;}
  1190. if(e.key && e.key.toLowerCase()==='b' && !e.metaKey && !e.ctrlKey && !e.altKey){
  1191. e.preventDefault();
  1192. window.__setLowPowerMode(!window.__lowPowerMode);
  1193. return;
  1194. }
  1195. if(overviewOn)return;
  1196. if(e.key==='ArrowRight'||e.key==='PageDown'||e.key===' '||e.key==='ArrowDown'){
  1197. if(window.__pipeAdvance && window.__pipeAdvance()) return;
  1198. go(idx+1);
  1199. return;
  1200. }
  1201. if(e.key==='ArrowLeft'||e.key==='PageUp'||e.key==='ArrowUp')go(idx-1);
  1202. if(e.key==='Home')go(0);
  1203. if(e.key==='End')go(total-1);
  1204. });
  1205. let wheelTO=null,wheelAcc=0;
  1206. addEventListener('wheel',e=>{
  1207. wheelAcc+=e.deltaY+e.deltaX;
  1208. if(Math.abs(wheelAcc)>50){
  1209. if(wheelAcc>0 && window.__pipeAdvance && window.__pipeAdvance()){
  1210. wheelAcc=0;
  1211. }else{
  1212. go(idx+(wheelAcc>0?1:-1));wheelAcc=0;
  1213. }
  1214. }
  1215. clearTimeout(wheelTO);wheelTO=setTimeout(()=>wheelAcc=0,150);
  1216. },{passive:true});
  1217. let tx=0,ty=0;
  1218. addEventListener('touchstart',e=>{tx=e.touches[0].clientX;ty=e.touches[0].clientY},{passive:true});
  1219. addEventListener('touchend',e=>{
  1220. const dx=(e.changedTouches[0].clientX-tx);
  1221. const dy=(e.changedTouches[0].clientY-ty);
  1222. if(Math.abs(dx)>50&&Math.abs(dx)>Math.abs(dy)){
  1223. if(dx<0 && window.__pipeAdvance && window.__pipeAdvance()) return;
  1224. go(idx+(dx<0?1:-1));
  1225. }
  1226. },{passive:true});
  1227. /* ?slide=N 直达(1-based),与 Swiss 模板行为一致 */
  1228. const initialSlideParam = new URLSearchParams(location.search).get('slide');
  1229. const initialSlide = initialSlideParam ? Number(initialSlideParam) - 1 : 0;
  1230. go(Number.isFinite(initialSlide) ? initialSlide : 0);
  1231. window.__pptPresenter?.initMode();
  1232. </script>
  1233. <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
  1234. <script>lucide.createIcons();</script>
  1235. <!-- ============ Motion One 动效引擎(本地优先,CDN 兜底) ============
  1236. 加载策略:先 ./assets/motion.min.js(本地,离线可用),失败 fallback 到 jsDelivr CDN
  1237. 加载完成后挂 window.__playSlide / window.__pipeAdvance,
  1238. 导航脚本会在翻页中段调用 __playSlide(idx),键盘/滚轮/触屏会在翻页前调用 __pipeAdvance()
  1239. 双双失败时会兜底把所有 [data-anim] 设为可见,不让动效破坏阅读
  1240. -->
  1241. <script type="module">
  1242. let motion;
  1243. try {
  1244. motion = await import('./assets/motion.min.js');
  1245. } catch(e1) {
  1246. try {
  1247. motion = await import('https://cdn.jsdelivr.net/npm/motion@11.11.17/+esm');
  1248. } catch(e2) {
  1249. console.warn('[motion] local + CDN both failed, disabling animations', e1, e2);
  1250. document.querySelectorAll('[data-anim]').forEach(el=>{el.style.opacity='1';el.style.transform='none'});
  1251. document.querySelectorAll('[data-animate="pipeline"] [data-anim]').forEach(el=>el.style.opacity='1');
  1252. }
  1253. }
  1254. if(motion){
  1255. const { animate, stagger } = motion;
  1256. document.body.classList.add('motion-ready');
  1257. const EASE = [.22, 1, .36, 1];
  1258. const slides = [...document.querySelectorAll('.slide')];
  1259. let pipeStep = -1;
  1260. let lastIdx = -1;
  1261. function resetAnims(slide){
  1262. slide.querySelectorAll('[data-anim]').forEach(el=>{
  1263. el.style.opacity='';
  1264. el.style.transform='';
  1265. });
  1266. }
  1267. function revealStatic(slide){
  1268. resetAnims(slide);
  1269. document.getAnimations?.().forEach(a=>a.cancel());
  1270. slide.querySelectorAll('[data-anim]').forEach(el=>{
  1271. el.style.opacity='1';
  1272. el.style.transform='none';
  1273. });
  1274. }
  1275. function playSlide(i){
  1276. const slide = slides[i];
  1277. if(!slide) return;
  1278. lastIdx = i;
  1279. const recipe = slide.dataset.animate || (slide.classList.contains('hero') ? 'hero' : 'cascade');
  1280. if(window.__lowPowerMode){
  1281. revealStatic(slide);
  1282. return;
  1283. }
  1284. if(recipe === 'pipeline'){
  1285. pipeStep = -1;
  1286. slide.querySelectorAll('[data-anim]').forEach(el=>{
  1287. el.style.opacity='0.15';
  1288. el.style.transform='none';
  1289. });
  1290. return;
  1291. }
  1292. resetAnims(slide);
  1293. const all = [...slide.querySelectorAll('[data-anim]')];
  1294. if(!all.length) return;
  1295. if(recipe === 'directional'){
  1296. const lefts = all.filter(el=>el.dataset.anim==='left');
  1297. const divs = all.filter(el=>el.dataset.anim==='divider');
  1298. const rights = all.filter(el=>el.dataset.anim==='right');
  1299. const others = all.filter(el=>!['left','right','divider'].includes(el.dataset.anim));
  1300. if(others.length) animate(others, {opacity:[0,1], y:[12,0]}, {duration:.6, delay:stagger(.1, {start:.15}), easing:EASE});
  1301. if(lefts.length) animate(lefts, {opacity:[0,1], x:[-24,0]}, {duration:.8, delay:.35, easing:EASE});
  1302. if(divs.length) animate(divs, {opacity:[0,.25]}, {duration:.5, delay:.9});
  1303. if(rights.length) animate(rights, {opacity:[0,1], x:[24,0]}, {duration:.8, delay:1.0, easing:EASE});
  1304. return;
  1305. }
  1306. if(recipe === 'quote'){
  1307. const lines = all.filter(el=>el.dataset.anim==='line');
  1308. const others = all.filter(el=>el.dataset.anim!=='line');
  1309. if(others.length) animate(others, {opacity:[0,1], y:[8,0]}, {duration:.6, delay:stagger(.12, {start:.2}), easing:EASE});
  1310. if(lines.length) animate(lines, {opacity:[.35,1], y:[10,0]}, {duration:.8, delay:stagger(.55, {start:.5}), easing:EASE});
  1311. return;
  1312. }
  1313. if(recipe === 'hero'){
  1314. animate(all, {opacity:[0,1], y:[14,0]}, {duration:.9, delay:stagger(.16, {start:.2}), easing:EASE});
  1315. return;
  1316. }
  1317. // default: cascade
  1318. animate(all, {opacity:[0,1], y:[16,0]}, {duration:.75, delay:stagger(.1, {start:.15}), easing:EASE});
  1319. }
  1320. function pipeAdvance(){
  1321. if(window.__lowPowerMode) return false;
  1322. const slide = slides[lastIdx];
  1323. if(!slide || slide.dataset.animate !== 'pipeline') return false;
  1324. const steps = [...slide.querySelectorAll('[data-anim="step"]')];
  1325. const arrows = [...slide.querySelectorAll('[data-anim="arrow"]')];
  1326. if(pipeStep >= steps.length - 1) return false;
  1327. pipeStep++;
  1328. animate(steps[pipeStep], {opacity:[0.15,1], y:[8,0]}, {duration:.5, easing:EASE});
  1329. if(pipeStep > 0 && arrows[pipeStep-1]){
  1330. animate(arrows[pipeStep-1], {opacity:[0.15,.7]}, {duration:.3, delay:.15});
  1331. }
  1332. return true;
  1333. }
  1334. window.__playSlide = playSlide;
  1335. window.__pipeAdvance = pipeAdvance;
  1336. // 首屏:go() 已跑过(同步),没来得及触发动效 → 这里补一下(尊重 ?slide=N)
  1337. playSlide(window.__currentSlideIndex || 0);
  1338. }
  1339. </script>
  1340. </body>
  1341. </html>