deck_stage.js
1 /** 2 * <deck-stage> — HTML slide shell web component 3 * 4 * Features: 5 * - Fixed-size canvas (default 1920×1080) + auto-scale + letterbox 6 * - Keyboard navigation (←/→/Space/Home/End/Esc) 7 * - Left/right click zone navigation 8 * - Slide counter (current/total) 9 * - localStorage persistence for current slide 10 * - Speaker notes postMessage (supports external rendering) 11 * - Hash navigation (#slide-5 jumps to slide 5) 12 * - Print-to-PDF support (Cmd+P / Ctrl+P, one slide per page) 13 * - Auto-assigns data-screen-label to each slide 14 * 15 * Usage: 16 * <deck-stage> 17 * <section>Slide 1</section> 18 * <section>Slide 2</section> 19 * </deck-stage> 20 * 21 * Custom dimensions: 22 * <deck-stage width="1080" height="1920">...</deck-stage> 23 * 24 * Speaker notes: add in <head> 25 * <script type="application/json" id="speaker-notes"> 26 * ["slide 1 notes", "slide 2 notes"] 27 * </script> 28 */ 29 30 (function() { 31 const STORAGE_KEY_PREFIX = 'deck-stage-slide-'; 32 33 class DeckStage extends HTMLElement { 34 constructor() { 35 super(); 36 this.attachShadow({ mode: 'open' }); 37 this._currentSlide = 0; 38 this._slides = []; 39 this._storageKey = STORAGE_KEY_PREFIX + (location.pathname || 'default'); 40 } 41 42 connectedCallback() { 43 this._width = parseInt(this.getAttribute('width')) || 1920; 44 this._height = parseInt(this.getAttribute('height')) || 1080; 45 46 // Shadow DOM renders first (independent of children, unaffected by parser timing) 47 this._render(); 48 49 // Defense: if script is in <head> (instead of after </deck-stage>), 50 // parser may not have finished processing child <section>s, querySelectorAll returns empty. 51 // Delay to next event loop to ensure all child nodes are parsed. 52 const init = () => { 53 this._collectSlides(); 54 this._setupEventListeners(); 55 this._restoreSlide(); 56 this._updateDisplay(); 57 this._setupPrintStyles(); 58 }; 59 60 if (this.ownerDocument.readyState === 'loading') { 61 // Document still parsing, wait for DOMContentLoaded to handle all sections 62 this.ownerDocument.addEventListener('DOMContentLoaded', init, { once: true }); 63 } else { 64 // Document parsed (script at body bottom or defer), collect in next frame 65 requestAnimationFrame(init); 66 } 67 } 68 69 _render() { 70 this.shadowRoot.innerHTML = ` 71 <style> 72 :host { 73 display: block; 74 position: fixed; 75 inset: 0; 76 background: #000; 77 overflow: hidden; 78 font-family: -apple-system, 'SF Pro Text', 'PingFang SC', sans-serif; 79 } 80 81 :host([noscale]) .stage { 82 transform: none !important; 83 top: 0 !important; 84 left: 0 !important; 85 } 86 87 .stage { 88 position: absolute; 89 top: 50%; 90 left: 50%; 91 transform-origin: top left; 92 will-change: transform; 93 background: #fff; 94 } 95 96 .slide-wrapper { 97 width: 100%; 98 height: 100%; 99 position: relative; 100 } 101 102 ::slotted(section) { 103 display: none; 104 width: 100%; 105 height: 100%; 106 position: absolute; 107 top: 0; 108 left: 0; 109 overflow: hidden; 110 } 111 112 ::slotted(section.active) { 113 display: block; 114 } 115 116 .counter { 117 position: fixed; 118 bottom: 20px; 119 right: 20px; 120 background: rgba(0, 0, 0, 0.6); 121 color: #fff; 122 padding: 6px 14px; 123 border-radius: 999px; 124 font-size: 13px; 125 font-variant-numeric: tabular-nums; 126 z-index: 100; 127 user-select: none; 128 opacity: 0.6; 129 transition: opacity 0.2s; 130 } 131 132 .counter:hover { 133 opacity: 1; 134 } 135 136 .nav-zone { 137 position: fixed; 138 top: 0; 139 bottom: 0; 140 width: 15%; 141 cursor: pointer; 142 z-index: 50; 143 } 144 145 .nav-zone.left { left: 0; } 146 .nav-zone.right { right: 0; } 147 148 .nav-hint { 149 position: absolute; 150 top: 50%; 151 transform: translateY(-50%); 152 width: 44px; 153 height: 44px; 154 border-radius: 999px; 155 background: rgba(255, 255, 255, 0.1); 156 color: rgba(255, 255, 255, 0.6); 157 display: flex; 158 align-items: center; 159 justify-content: center; 160 font-size: 24px; 161 opacity: 0; 162 transition: opacity 0.2s; 163 } 164 165 .nav-zone.left .nav-hint { left: 20px; } 166 .nav-zone.right .nav-hint { right: 20px; } 167 168 .nav-zone:hover .nav-hint { 169 opacity: 1; 170 } 171 172 @media print { 173 :host { 174 position: static; 175 background: #fff; 176 } 177 .counter, .nav-zone { 178 display: none !important; 179 } 180 .stage { 181 position: static; 182 transform: none !important; 183 page-break-after: always; 184 } 185 ::slotted(section) { 186 display: block !important; 187 position: relative !important; 188 page-break-after: always; 189 width: 100%; 190 height: 100%; 191 } 192 } 193 </style> 194 195 <div class="stage" id="stage" style="width: ${this._width}px; height: ${this._height}px;"> 196 <div class="slide-wrapper"> 197 <slot></slot> 198 </div> 199 </div> 200 201 <div class="nav-zone left" id="navLeft"> 202 <div class="nav-hint">‹</div> 203 </div> 204 <div class="nav-zone right" id="navRight"> 205 <div class="nav-hint">›</div> 206 </div> 207 208 <div class="counter" id="counter">1 / 1</div> 209 `; 210 } 211 212 _collectSlides() { 213 this._slides = Array.from(this.querySelectorAll(':scope > section')); 214 215 this._slides.forEach((slide, idx) => { 216 if (!slide.hasAttribute('data-screen-label')) { 217 const num = String(idx + 1).padStart(2, '0'); 218 slide.setAttribute('data-screen-label', num); 219 } 220 if (!slide.hasAttribute('data-om-validate')) { 221 slide.setAttribute('data-om-validate', ''); 222 } 223 }); 224 } 225 226 _setupEventListeners() { 227 window.addEventListener('resize', () => this._updateScale()); 228 229 document.addEventListener('keydown', (e) => { 230 if (e.target.matches('input, textarea, [contenteditable]')) return; 231 232 switch (e.key) { 233 case 'ArrowRight': 234 case ' ': 235 case 'PageDown': 236 e.preventDefault(); 237 this.next(); 238 break; 239 case 'ArrowLeft': 240 case 'PageUp': 241 e.preventDefault(); 242 this.prev(); 243 break; 244 case 'Home': 245 e.preventDefault(); 246 this.goTo(0); 247 break; 248 case 'End': 249 e.preventDefault(); 250 this.goTo(this._slides.length - 1); 251 break; 252 } 253 }); 254 255 this.shadowRoot.getElementById('navLeft').addEventListener('click', () => this.prev()); 256 this.shadowRoot.getElementById('navRight').addEventListener('click', () => this.next()); 257 258 window.addEventListener('hashchange', () => this._handleHash()); 259 if (location.hash) { 260 setTimeout(() => this._handleHash(), 0); 261 } 262 263 const observer = new MutationObserver(() => { 264 if (this.hasAttribute('noscale')) { 265 this._updateScale(); 266 } 267 }); 268 observer.observe(this, { attributes: true, attributeFilter: ['noscale'] }); 269 } 270 271 _handleHash() { 272 const match = location.hash.match(/^#slide-(\d+)$/); 273 if (match) { 274 const idx = parseInt(match[1]) - 1; 275 if (idx >= 0 && idx < this._slides.length) { 276 this.goTo(idx); 277 } 278 } 279 } 280 281 _restoreSlide() { 282 try { 283 const stored = localStorage.getItem(this._storageKey); 284 if (stored !== null) { 285 const idx = parseInt(stored); 286 if (idx >= 0 && idx < this._slides.length) { 287 this._currentSlide = idx; 288 } 289 } 290 } catch (e) {} 291 } 292 293 _saveSlide() { 294 try { 295 localStorage.setItem(this._storageKey, String(this._currentSlide)); 296 } catch (e) {} 297 } 298 299 _updateScale() { 300 if (this.hasAttribute('noscale')) { 301 const stage = this.shadowRoot.getElementById('stage'); 302 stage.style.transform = 'none'; 303 stage.style.top = '0'; 304 stage.style.left = '0'; 305 return; 306 } 307 308 const stage = this.shadowRoot.getElementById('stage'); 309 if (!stage) return; 310 311 const viewportW = window.innerWidth; 312 const viewportH = window.innerHeight; 313 const scale = Math.min(viewportW / this._width, viewportH / this._height); 314 const scaledW = this._width * scale; 315 const scaledH = this._height * scale; 316 const offsetX = (viewportW - scaledW) / 2; 317 const offsetY = (viewportH - scaledH) / 2; 318 319 stage.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`; 320 stage.style.top = '0'; 321 stage.style.left = '0'; 322 } 323 324 _updateDisplay() { 325 this._slides.forEach((slide, idx) => { 326 slide.classList.toggle('active', idx === this._currentSlide); 327 }); 328 329 const counter = this.shadowRoot.getElementById('counter'); 330 if (counter) { 331 counter.textContent = `${this._currentSlide + 1} / ${this._slides.length}`; 332 } 333 334 this._updateScale(); 335 336 try { 337 window.postMessage({ 338 slideIndexChanged: this._currentSlide, 339 totalSlides: this._slides.length 340 }, '*'); 341 } catch (e) {} 342 343 try { 344 if (window.parent && window.parent !== window) { 345 window.parent.postMessage({ 346 slideIndexChanged: this._currentSlide, 347 totalSlides: this._slides.length 348 }, '*'); 349 } 350 } catch (e) {} 351 } 352 353 _setupPrintStyles() { 354 const printStyle = document.createElement('style'); 355 printStyle.textContent = ` 356 @media print { 357 @page { 358 size: ${this._width}px ${this._height}px; 359 margin: 0; 360 } 361 body { 362 margin: 0; 363 padding: 0; 364 } 365 deck-stage { 366 position: static !important; 367 } 368 deck-stage > section { 369 display: block !important; 370 position: relative !important; 371 width: ${this._width}px !important; 372 height: ${this._height}px !important; 373 page-break-after: always; 374 overflow: hidden; 375 } 376 deck-stage > section:last-child { 377 page-break-after: auto; 378 } 379 } 380 `; 381 document.head.appendChild(printStyle); 382 } 383 384 next() { 385 if (this._currentSlide < this._slides.length - 1) { 386 this._currentSlide++; 387 this._saveSlide(); 388 this._updateDisplay(); 389 } 390 } 391 392 prev() { 393 if (this._currentSlide > 0) { 394 this._currentSlide--; 395 this._saveSlide(); 396 this._updateDisplay(); 397 } 398 } 399 400 goTo(idx) { 401 if (idx >= 0 && idx < this._slides.length) { 402 this._currentSlide = idx; 403 this._saveSlide(); 404 this._updateDisplay(); 405 } 406 } 407 408 get currentSlide() { 409 return this._currentSlide; 410 } 411 412 get totalSlides() { 413 return this._slides.length; 414 } 415 } 416 417 customElements.define('deck-stage', DeckStage); 418 419 window.DeckStage = DeckStage; 420 })();