/ templates / deck_stage.js
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  })();