deck_stage.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  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. this._render();
  43. this._collectSlides();
  44. this._setupEventListeners();
  45. this._restoreSlide();
  46. this._updateDisplay();
  47. this._setupPrintStyles();
  48. }
  49. _render() {
  50. this.shadowRoot.innerHTML = `
  51. <style>
  52. :host {
  53. display: block;
  54. position: fixed;
  55. inset: 0;
  56. background: #000;
  57. overflow: hidden;
  58. font-family: -apple-system, 'SF Pro Text', 'PingFang SC', sans-serif;
  59. }
  60. :host([noscale]) .stage {
  61. transform: none !important;
  62. top: 0 !important;
  63. left: 0 !important;
  64. }
  65. .stage {
  66. position: absolute;
  67. top: 50%;
  68. left: 50%;
  69. transform-origin: top left;
  70. will-change: transform;
  71. background: #fff;
  72. }
  73. .slide-wrapper {
  74. width: 100%;
  75. height: 100%;
  76. position: relative;
  77. }
  78. ::slotted(section) {
  79. display: none;
  80. width: 100%;
  81. height: 100%;
  82. position: absolute;
  83. top: 0;
  84. left: 0;
  85. overflow: hidden;
  86. }
  87. ::slotted(section.active) {
  88. display: block;
  89. }
  90. .counter {
  91. position: fixed;
  92. bottom: 20px;
  93. right: 20px;
  94. background: rgba(0, 0, 0, 0.6);
  95. color: #fff;
  96. padding: 6px 14px;
  97. border-radius: 999px;
  98. font-size: 13px;
  99. font-variant-numeric: tabular-nums;
  100. z-index: 100;
  101. user-select: none;
  102. opacity: 0.6;
  103. transition: opacity 0.2s;
  104. }
  105. .counter:hover {
  106. opacity: 1;
  107. }
  108. .nav-zone {
  109. position: fixed;
  110. top: 0;
  111. bottom: 0;
  112. width: 15%;
  113. cursor: pointer;
  114. z-index: 50;
  115. }
  116. .nav-zone.left { left: 0; }
  117. .nav-zone.right { right: 0; }
  118. .nav-hint {
  119. position: absolute;
  120. top: 50%;
  121. transform: translateY(-50%);
  122. width: 44px;
  123. height: 44px;
  124. border-radius: 999px;
  125. background: rgba(255, 255, 255, 0.1);
  126. color: rgba(255, 255, 255, 0.6);
  127. display: flex;
  128. align-items: center;
  129. justify-content: center;
  130. font-size: 24px;
  131. opacity: 0;
  132. transition: opacity 0.2s;
  133. }
  134. .nav-zone.left .nav-hint { left: 20px; }
  135. .nav-zone.right .nav-hint { right: 20px; }
  136. .nav-zone:hover .nav-hint {
  137. opacity: 1;
  138. }
  139. @media print {
  140. :host {
  141. position: static;
  142. background: #fff;
  143. }
  144. .counter, .nav-zone {
  145. display: none !important;
  146. }
  147. .stage {
  148. position: static;
  149. transform: none !important;
  150. page-break-after: always;
  151. }
  152. ::slotted(section) {
  153. display: block !important;
  154. position: relative !important;
  155. page-break-after: always;
  156. width: 100%;
  157. height: 100%;
  158. }
  159. }
  160. </style>
  161. <div class="stage" id="stage" style="width: ${this._width}px; height: ${this._height}px;">
  162. <div class="slide-wrapper">
  163. <slot></slot>
  164. </div>
  165. </div>
  166. <div class="nav-zone left" id="navLeft">
  167. <div class="nav-hint">‹</div>
  168. </div>
  169. <div class="nav-zone right" id="navRight">
  170. <div class="nav-hint">›</div>
  171. </div>
  172. <div class="counter" id="counter">1 / 1</div>
  173. `;
  174. }
  175. _collectSlides() {
  176. this._slides = Array.from(this.querySelectorAll(':scope > section'));
  177. this._slides.forEach((slide, idx) => {
  178. if (!slide.hasAttribute('data-screen-label')) {
  179. const num = String(idx + 1).padStart(2, '0');
  180. slide.setAttribute('data-screen-label', num);
  181. }
  182. if (!slide.hasAttribute('data-om-validate')) {
  183. slide.setAttribute('data-om-validate', '');
  184. }
  185. });
  186. }
  187. _setupEventListeners() {
  188. window.addEventListener('resize', () => this._updateScale());
  189. document.addEventListener('keydown', (e) => {
  190. if (e.target.matches('input, textarea, [contenteditable]')) return;
  191. switch (e.key) {
  192. case 'ArrowRight':
  193. case ' ':
  194. case 'PageDown':
  195. e.preventDefault();
  196. this.next();
  197. break;
  198. case 'ArrowLeft':
  199. case 'PageUp':
  200. e.preventDefault();
  201. this.prev();
  202. break;
  203. case 'Home':
  204. e.preventDefault();
  205. this.goTo(0);
  206. break;
  207. case 'End':
  208. e.preventDefault();
  209. this.goTo(this._slides.length - 1);
  210. break;
  211. }
  212. });
  213. this.shadowRoot.getElementById('navLeft').addEventListener('click', () => this.prev());
  214. this.shadowRoot.getElementById('navRight').addEventListener('click', () => this.next());
  215. window.addEventListener('hashchange', () => this._handleHash());
  216. if (location.hash) {
  217. setTimeout(() => this._handleHash(), 0);
  218. }
  219. const observer = new MutationObserver(() => {
  220. if (this.hasAttribute('noscale')) {
  221. this._updateScale();
  222. }
  223. });
  224. observer.observe(this, { attributes: true, attributeFilter: ['noscale'] });
  225. }
  226. _handleHash() {
  227. const match = location.hash.match(/^#slide-(\d+)$/);
  228. if (match) {
  229. const idx = parseInt(match[1]) - 1;
  230. if (idx >= 0 && idx < this._slides.length) {
  231. this.goTo(idx);
  232. }
  233. }
  234. }
  235. _restoreSlide() {
  236. try {
  237. const stored = localStorage.getItem(this._storageKey);
  238. if (stored !== null) {
  239. const idx = parseInt(stored);
  240. if (idx >= 0 && idx < this._slides.length) {
  241. this._currentSlide = idx;
  242. }
  243. }
  244. } catch (e) {}
  245. }
  246. _saveSlide() {
  247. try {
  248. localStorage.setItem(this._storageKey, String(this._currentSlide));
  249. } catch (e) {}
  250. }
  251. _updateScale() {
  252. if (this.hasAttribute('noscale')) {
  253. const stage = this.shadowRoot.getElementById('stage');
  254. stage.style.transform = 'none';
  255. stage.style.top = '0';
  256. stage.style.left = '0';
  257. return;
  258. }
  259. const stage = this.shadowRoot.getElementById('stage');
  260. if (!stage) return;
  261. const viewportW = window.innerWidth;
  262. const viewportH = window.innerHeight;
  263. const scale = Math.min(viewportW / this._width, viewportH / this._height);
  264. const scaledW = this._width * scale;
  265. const scaledH = this._height * scale;
  266. const offsetX = (viewportW - scaledW) / 2;
  267. const offsetY = (viewportH - scaledH) / 2;
  268. stage.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
  269. stage.style.top = '0';
  270. stage.style.left = '0';
  271. }
  272. _updateDisplay() {
  273. this._slides.forEach((slide, idx) => {
  274. slide.classList.toggle('active', idx === this._currentSlide);
  275. });
  276. const counter = this.shadowRoot.getElementById('counter');
  277. if (counter) {
  278. counter.textContent = `${this._currentSlide + 1} / ${this._slides.length}`;
  279. }
  280. this._updateScale();
  281. try {
  282. window.postMessage({
  283. slideIndexChanged: this._currentSlide,
  284. totalSlides: this._slides.length
  285. }, '*');
  286. } catch (e) {}
  287. try {
  288. if (window.parent && window.parent !== window) {
  289. window.parent.postMessage({
  290. slideIndexChanged: this._currentSlide,
  291. totalSlides: this._slides.length
  292. }, '*');
  293. }
  294. } catch (e) {}
  295. }
  296. _setupPrintStyles() {
  297. const printStyle = document.createElement('style');
  298. printStyle.textContent = `
  299. @media print {
  300. @page {
  301. size: ${this._width}px ${this._height}px;
  302. margin: 0;
  303. }
  304. body {
  305. margin: 0;
  306. padding: 0;
  307. }
  308. deck-stage {
  309. position: static !important;
  310. }
  311. deck-stage > section {
  312. display: block !important;
  313. position: relative !important;
  314. width: ${this._width}px !important;
  315. height: ${this._height}px !important;
  316. page-break-after: always;
  317. overflow: hidden;
  318. }
  319. deck-stage > section:last-child {
  320. page-break-after: auto;
  321. }
  322. }
  323. `;
  324. document.head.appendChild(printStyle);
  325. }
  326. next() {
  327. if (this._currentSlide < this._slides.length - 1) {
  328. this._currentSlide++;
  329. this._saveSlide();
  330. this._updateDisplay();
  331. }
  332. }
  333. prev() {
  334. if (this._currentSlide > 0) {
  335. this._currentSlide--;
  336. this._saveSlide();
  337. this._updateDisplay();
  338. }
  339. }
  340. goTo(idx) {
  341. if (idx >= 0 && idx < this._slides.length) {
  342. this._currentSlide = idx;
  343. this._saveSlide();
  344. this._updateDisplay();
  345. }
  346. }
  347. get currentSlide() {
  348. return this._currentSlide;
  349. }
  350. get totalSlides() {
  351. return this._slides.length;
  352. }
  353. }
  354. customElements.define('deck-stage', DeckStage);
  355. window.DeckStage = DeckStage;
  356. })();