deck_stage.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. /**
  2. * <deck-stage> — HTML幻灯片外壳web component
  3. *
  4. * 提供功能:
  5. * - 固定尺寸canvas(默认1920×1080)+ auto-scale + letterbox
  6. * - 键盘导航(←/→/Space/Home/End/Esc)
  7. * - 左右点击区域导航
  8. * - slide counter (当前/总数)
  9. * - localStorage持久化当前slide
  10. * - Speaker notes postMessage (支持外层渲染)
  11. * - Hash导航 (#slide-5 跳到第5张)
  12. * - Print-to-PDF支持 (Cmd+P / Ctrl+P 一页一slide)
  13. * - 自动给每个slide添加 data-screen-label
  14. *
  15. * 用法:
  16. * <deck-stage>
  17. * <section>Slide 1</section>
  18. * <section>Slide 2</section>
  19. * </deck-stage>
  20. *
  21. * 自定义尺寸:
  22. * <deck-stage width="1080" height="1920">...</deck-stage>
  23. *
  24. * Speaker notes:在<head>加
  25. * <script type="application/json" id="speaker-notes">
  26. * ["slide 1 notes", "slide 2 notes"]
  27. * </script>
  28. */
  29. (function() {
  30. const STORAGE_KEY_PREFIX = 'deck-stage-slide-';
  31. class DeckStage extends HTMLElement {
  32. constructor() {
  33. super();
  34. this.attachShadow({ mode: 'open' });
  35. this._currentSlide = 0;
  36. this._slides = [];
  37. this._storageKey = STORAGE_KEY_PREFIX + (location.pathname || 'default');
  38. }
  39. connectedCallback() {
  40. this._width = parseInt(this.getAttribute('width')) || 1920;
  41. this._height = parseInt(this.getAttribute('height')) || 1080;
  42. // Shadow DOM 先渲染(独立于子节点,不受 parser 时机影响)
  43. this._render();
  44. // 防御:若 script 放在 <head> 里(而非 </deck-stage> 之后),
  45. // parser 此刻可能还没处理完子 <section>,querySelectorAll 会返回空。
  46. // 延迟到下一个事件循环,确保子节点都已 parse 完毕。
  47. const init = () => {
  48. this._collectSlides();
  49. this._setupEventListeners();
  50. this._restoreSlide();
  51. this._updateDisplay();
  52. this._setupPrintStyles();
  53. };
  54. if (this.ownerDocument.readyState === 'loading') {
  55. // 文档还在 parse,等 DOMContentLoaded 一次搞定所有 section
  56. this.ownerDocument.addEventListener('DOMContentLoaded', init, { once: true });
  57. } else {
  58. // 文档已 parse 完(script 在 body 底部或 defer),下一帧收集即可
  59. requestAnimationFrame(init);
  60. }
  61. }
  62. _render() {
  63. this.shadowRoot.innerHTML = `
  64. <style>
  65. :host {
  66. display: block;
  67. position: fixed;
  68. inset: 0;
  69. background: #000;
  70. overflow: hidden;
  71. font-family: -apple-system, 'SF Pro Text', 'PingFang SC', sans-serif;
  72. }
  73. :host([noscale]) .stage {
  74. transform: none !important;
  75. top: 0 !important;
  76. left: 0 !important;
  77. }
  78. .stage {
  79. position: absolute;
  80. top: 50%;
  81. left: 50%;
  82. transform-origin: top left;
  83. will-change: transform;
  84. background: #fff;
  85. }
  86. .slide-wrapper {
  87. width: 100%;
  88. height: 100%;
  89. position: relative;
  90. }
  91. ::slotted(section) {
  92. display: none;
  93. width: 100%;
  94. height: 100%;
  95. position: absolute;
  96. top: 0;
  97. left: 0;
  98. overflow: hidden;
  99. }
  100. ::slotted(section.active) {
  101. display: block;
  102. }
  103. .counter {
  104. position: fixed;
  105. bottom: 20px;
  106. right: 20px;
  107. background: rgba(0, 0, 0, 0.6);
  108. color: #fff;
  109. padding: 6px 14px;
  110. border-radius: 999px;
  111. font-size: 13px;
  112. font-variant-numeric: tabular-nums;
  113. z-index: 100;
  114. user-select: none;
  115. opacity: 0.6;
  116. transition: opacity 0.2s;
  117. }
  118. .counter:hover {
  119. opacity: 1;
  120. }
  121. .nav-zone {
  122. position: fixed;
  123. top: 0;
  124. bottom: 0;
  125. width: 15%;
  126. cursor: pointer;
  127. z-index: 50;
  128. }
  129. .nav-zone.left { left: 0; }
  130. .nav-zone.right { right: 0; }
  131. .nav-hint {
  132. position: absolute;
  133. top: 50%;
  134. transform: translateY(-50%);
  135. width: 44px;
  136. height: 44px;
  137. border-radius: 999px;
  138. background: rgba(255, 255, 255, 0.1);
  139. color: rgba(255, 255, 255, 0.6);
  140. display: flex;
  141. align-items: center;
  142. justify-content: center;
  143. font-size: 24px;
  144. opacity: 0;
  145. transition: opacity 0.2s;
  146. }
  147. .nav-zone.left .nav-hint { left: 20px; }
  148. .nav-zone.right .nav-hint { right: 20px; }
  149. .nav-zone:hover .nav-hint {
  150. opacity: 1;
  151. }
  152. @media print {
  153. :host {
  154. position: static;
  155. background: #fff;
  156. }
  157. .counter, .nav-zone {
  158. display: none !important;
  159. }
  160. .stage {
  161. position: static;
  162. transform: none !important;
  163. page-break-after: always;
  164. }
  165. ::slotted(section) {
  166. display: block !important;
  167. position: relative !important;
  168. page-break-after: always;
  169. width: 100%;
  170. height: 100%;
  171. }
  172. }
  173. </style>
  174. <div class="stage" id="stage" style="width: ${this._width}px; height: ${this._height}px;">
  175. <div class="slide-wrapper">
  176. <slot></slot>
  177. </div>
  178. </div>
  179. <div class="nav-zone left" id="navLeft">
  180. <div class="nav-hint">‹</div>
  181. </div>
  182. <div class="nav-zone right" id="navRight">
  183. <div class="nav-hint">›</div>
  184. </div>
  185. <div class="counter" id="counter">1 / 1</div>
  186. `;
  187. }
  188. _collectSlides() {
  189. this._slides = Array.from(this.querySelectorAll(':scope > section'));
  190. this._slides.forEach((slide, idx) => {
  191. if (!slide.hasAttribute('data-screen-label')) {
  192. const num = String(idx + 1).padStart(2, '0');
  193. slide.setAttribute('data-screen-label', num);
  194. }
  195. if (!slide.hasAttribute('data-om-validate')) {
  196. slide.setAttribute('data-om-validate', '');
  197. }
  198. });
  199. }
  200. _setupEventListeners() {
  201. window.addEventListener('resize', () => this._updateScale());
  202. document.addEventListener('keydown', (e) => {
  203. if (e.target.matches('input, textarea, [contenteditable]')) return;
  204. switch (e.key) {
  205. case 'ArrowRight':
  206. case ' ':
  207. case 'PageDown':
  208. e.preventDefault();
  209. this.next();
  210. break;
  211. case 'ArrowLeft':
  212. case 'PageUp':
  213. e.preventDefault();
  214. this.prev();
  215. break;
  216. case 'Home':
  217. e.preventDefault();
  218. this.goTo(0);
  219. break;
  220. case 'End':
  221. e.preventDefault();
  222. this.goTo(this._slides.length - 1);
  223. break;
  224. }
  225. });
  226. this.shadowRoot.getElementById('navLeft').addEventListener('click', () => this.prev());
  227. this.shadowRoot.getElementById('navRight').addEventListener('click', () => this.next());
  228. window.addEventListener('hashchange', () => this._handleHash());
  229. if (location.hash) {
  230. setTimeout(() => this._handleHash(), 0);
  231. }
  232. const observer = new MutationObserver(() => {
  233. if (this.hasAttribute('noscale')) {
  234. this._updateScale();
  235. }
  236. });
  237. observer.observe(this, { attributes: true, attributeFilter: ['noscale'] });
  238. }
  239. _handleHash() {
  240. const match = location.hash.match(/^#slide-(\d+)$/);
  241. if (match) {
  242. const idx = parseInt(match[1]) - 1;
  243. if (idx >= 0 && idx < this._slides.length) {
  244. this.goTo(idx);
  245. }
  246. }
  247. }
  248. _restoreSlide() {
  249. try {
  250. const stored = localStorage.getItem(this._storageKey);
  251. if (stored !== null) {
  252. const idx = parseInt(stored);
  253. if (idx >= 0 && idx < this._slides.length) {
  254. this._currentSlide = idx;
  255. }
  256. }
  257. } catch (e) {}
  258. }
  259. _saveSlide() {
  260. try {
  261. localStorage.setItem(this._storageKey, String(this._currentSlide));
  262. } catch (e) {}
  263. }
  264. _updateScale() {
  265. if (this.hasAttribute('noscale')) {
  266. const stage = this.shadowRoot.getElementById('stage');
  267. stage.style.transform = 'none';
  268. stage.style.top = '0';
  269. stage.style.left = '0';
  270. return;
  271. }
  272. const stage = this.shadowRoot.getElementById('stage');
  273. if (!stage) return;
  274. const viewportW = window.innerWidth;
  275. const viewportH = window.innerHeight;
  276. const scale = Math.min(viewportW / this._width, viewportH / this._height);
  277. const scaledW = this._width * scale;
  278. const scaledH = this._height * scale;
  279. const offsetX = (viewportW - scaledW) / 2;
  280. const offsetY = (viewportH - scaledH) / 2;
  281. stage.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
  282. stage.style.top = '0';
  283. stage.style.left = '0';
  284. }
  285. _updateDisplay() {
  286. this._slides.forEach((slide, idx) => {
  287. slide.classList.toggle('active', idx === this._currentSlide);
  288. });
  289. const counter = this.shadowRoot.getElementById('counter');
  290. if (counter) {
  291. counter.textContent = `${this._currentSlide + 1} / ${this._slides.length}`;
  292. }
  293. this._updateScale();
  294. try {
  295. window.postMessage({
  296. slideIndexChanged: this._currentSlide,
  297. totalSlides: this._slides.length
  298. }, '*');
  299. } catch (e) {}
  300. try {
  301. if (window.parent && window.parent !== window) {
  302. window.parent.postMessage({
  303. slideIndexChanged: this._currentSlide,
  304. totalSlides: this._slides.length
  305. }, '*');
  306. }
  307. } catch (e) {}
  308. }
  309. _setupPrintStyles() {
  310. const printStyle = document.createElement('style');
  311. printStyle.textContent = `
  312. @media print {
  313. @page {
  314. size: ${this._width}px ${this._height}px;
  315. margin: 0;
  316. }
  317. body {
  318. margin: 0;
  319. padding: 0;
  320. }
  321. deck-stage {
  322. position: static !important;
  323. }
  324. deck-stage > section {
  325. display: block !important;
  326. position: relative !important;
  327. width: ${this._width}px !important;
  328. height: ${this._height}px !important;
  329. page-break-after: always;
  330. overflow: hidden;
  331. }
  332. deck-stage > section:last-child {
  333. page-break-after: auto;
  334. }
  335. }
  336. `;
  337. document.head.appendChild(printStyle);
  338. }
  339. next() {
  340. if (this._currentSlide < this._slides.length - 1) {
  341. this._currentSlide++;
  342. this._saveSlide();
  343. this._updateDisplay();
  344. }
  345. }
  346. prev() {
  347. if (this._currentSlide > 0) {
  348. this._currentSlide--;
  349. this._saveSlide();
  350. this._updateDisplay();
  351. }
  352. }
  353. goTo(idx) {
  354. if (idx >= 0 && idx < this._slides.length) {
  355. this._currentSlide = idx;
  356. this._saveSlide();
  357. this._updateDisplay();
  358. }
  359. }
  360. get currentSlide() {
  361. return this._currentSlide;
  362. }
  363. get totalSlides() {
  364. return this._slides.length;
  365. }
  366. }
  367. customElements.define('deck-stage', DeckStage);
  368. window.DeckStage = DeckStage;
  369. })();