pptx-browser 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,492 @@
1
+ /**
2
+ * slideshow.js — Full-screen slide show player.
3
+ *
4
+ * Creates a browser-native presentation mode with:
5
+ * - Fullscreen API integration (F11 / Escape to exit)
6
+ * - Keyboard navigation (←→ arrow keys, Space, PageUp/Down, Home, End)
7
+ * - Touch / swipe support
8
+ * - Slide counter HUD
9
+ * - Presenter notes panel (optional)
10
+ * - Animation engine integration (PptxPlayer)
11
+ * - Transition effects between slides
12
+ * - Thumbnail strip for quick navigation
13
+ *
14
+ * Usage:
15
+ * import { SlideShow } from 'pptx-canvas-renderer';
16
+ *
17
+ * const show = new SlideShow(renderer, container);
18
+ * await show.start(0); // start from slide 0, request fullscreen
19
+ * show.stop(); // exit and clean up
20
+ *
21
+ * // Or without fullscreen:
22
+ * const show = new SlideShow(renderer, container, { fullscreen: false });
23
+ * await show.start();
24
+ */
25
+
26
+ export class SlideShow {
27
+ /**
28
+ * @param {object} renderer — loaded PptxRenderer instance
29
+ * @param {HTMLElement} container — DOM element to attach to
30
+ * @param {object} [opts]
31
+ * @param {boolean} [opts.fullscreen=true] — request fullscreen on start
32
+ * @param {boolean} [opts.showNotes=false] — show presenter notes panel
33
+ * @param {boolean} [opts.showThumbs=false] — show thumbnail strip
34
+ * @param {boolean} [opts.showHud=true] — show slide counter HUD
35
+ * @param {boolean} [opts.loop=false] — loop back to start at end
36
+ * @param {boolean} [opts.autoAdvance=0] — ms between slides (0=manual)
37
+ * @param {function} [opts.onSlideChange] — (index) => void
38
+ */
39
+ constructor(renderer, container, opts = {}) {
40
+ this.renderer = renderer;
41
+ this.container = container;
42
+ this.opts = {
43
+ fullscreen: true,
44
+ showNotes: false,
45
+ showThumbs: false,
46
+ showHud: true,
47
+ loop: false,
48
+ autoAdvance: 0,
49
+ ...opts,
50
+ };
51
+
52
+ this._index = 0;
53
+ this._playing = false;
54
+ this._player = null;
55
+ this._autoTimer = null;
56
+ this._el = null; // root overlay element
57
+ this._canvas = null;
58
+ this._notesEl = null;
59
+ this._hudEl = null;
60
+ this._thumbsEl = null;
61
+ this._thumbnails = []; // low-res canvas elements
62
+
63
+ // Touch tracking
64
+ this._touchStartX = 0;
65
+ this._touchStartY = 0;
66
+
67
+ // Bound handlers (for removeEventListener)
68
+ this._onKey = this._onKey.bind(this);
69
+ this._onResize = this._onResize.bind(this);
70
+ this._onFsChange = this._onFsChange.bind(this);
71
+ this._onTouchStart = this._onTouchStart.bind(this);
72
+ this._onTouchEnd = this._onTouchEnd.bind(this);
73
+ }
74
+
75
+ // ── Lifecycle ───────────────────────────────────────────────────────────────
76
+
77
+ /** Start the slide show, optionally requesting fullscreen. */
78
+ async start(slideIndex = 0) {
79
+ this._playing = true;
80
+ this._index = Math.max(0, Math.min(slideIndex, this.renderer.slideCount - 1));
81
+
82
+ this._buildDOM();
83
+ this._attachEvents();
84
+
85
+ if (this.opts.fullscreen) {
86
+ await this._requestFullscreen();
87
+ }
88
+
89
+ // Pre-generate thumbnails in background
90
+ if (this.opts.showThumbs) {
91
+ this._generateThumbnails(); // don't await — background task
92
+ }
93
+
94
+ await this._renderCurrent();
95
+ this._updateHud();
96
+
97
+ if (this.opts.autoAdvance > 0) {
98
+ this._startAutoAdvance();
99
+ }
100
+ }
101
+
102
+ /** Stop and clean up. */
103
+ stop() {
104
+ this._playing = false;
105
+ this._stopAutoAdvance();
106
+ this._detachEvents();
107
+ if (document.fullscreenElement === this._el) {
108
+ document.exitFullscreen().catch(() => {});
109
+ }
110
+ if (this._el && this._el.parentNode) {
111
+ this._el.parentNode.removeChild(this._el);
112
+ }
113
+ this._el = null;
114
+ this._canvas = null;
115
+ this._player = null;
116
+ this.opts.onSlideChange?.(null);
117
+ }
118
+
119
+ /** Go to a specific slide. */
120
+ async goto(index) {
121
+ if (!this._playing) return;
122
+ const newIndex = Math.max(0, Math.min(index, this.renderer.slideCount - 1));
123
+ if (newIndex === this._index) return;
124
+
125
+ const prevIndex = this._index;
126
+ this._index = newIndex;
127
+ this._resetAutoAdvance();
128
+ await this._renderCurrent(prevIndex);
129
+ this._updateHud();
130
+ this.opts.onSlideChange?.(this._index);
131
+ }
132
+
133
+ /** Advance to next slide (or next animation click). */
134
+ async next() {
135
+ if (!this._playing) return;
136
+ if (this._player) {
137
+ // If animations remain, advance click
138
+ const steps = this.renderer.getAnimations?.(this._index) || [];
139
+ const maxClick = steps.length ? Math.max(...steps.map(s => s.clickNum), 0) : 0;
140
+ // Simple heuristic — if player exists and not at end, click
141
+ await this._player.nextClick?.();
142
+ }
143
+ if (this._index < this.renderer.slideCount - 1) {
144
+ await this.goto(this._index + 1);
145
+ } else if (this.opts.loop) {
146
+ await this.goto(0);
147
+ }
148
+ }
149
+
150
+ /** Go to previous slide. */
151
+ async prev() {
152
+ if (!this._playing) return;
153
+ if (this._index > 0) {
154
+ await this.goto(this._index - 1);
155
+ } else if (this.opts.loop) {
156
+ await this.goto(this.renderer.slideCount - 1);
157
+ }
158
+ }
159
+
160
+ get currentIndex() { return this._index; }
161
+ get isPlaying() { return this._playing; }
162
+
163
+ // ── DOM construction ────────────────────────────────────────────────────────
164
+
165
+ _buildDOM() {
166
+ // Root overlay
167
+ const el = document.createElement('div');
168
+ el.style.cssText = [
169
+ 'position:fixed', 'inset:0', 'z-index:999999',
170
+ 'background:#000', 'display:flex', 'flex-direction:column',
171
+ 'align-items:center', 'justify-content:center',
172
+ 'user-select:none', 'touch-action:none',
173
+ ].join(';');
174
+ el.setAttribute('tabindex', '0');
175
+ this._el = el;
176
+
177
+ // Main canvas area
178
+ const canvasWrap = document.createElement('div');
179
+ canvasWrap.style.cssText = 'position:relative; flex:1; display:flex; align-items:center; justify-content:center; width:100%; overflow:hidden;';
180
+
181
+ const canvas = document.createElement('canvas');
182
+ canvas.style.cssText = 'display:block; box-shadow:0 4px 32px rgba(0,0,0,0.6);';
183
+ this._canvas = canvas;
184
+ canvasWrap.appendChild(canvas);
185
+ el.appendChild(canvasWrap);
186
+
187
+ // HUD (slide counter + controls)
188
+ if (this.opts.showHud) {
189
+ const hud = document.createElement('div');
190
+ hud.style.cssText = [
191
+ 'position:absolute', 'bottom:24px', 'left:50%', 'transform:translateX(-50%)',
192
+ 'display:flex', 'align-items:center', 'gap:12px',
193
+ 'background:rgba(0,0,0,0.55)', 'backdrop-filter:blur(8px)',
194
+ 'border-radius:24px', 'padding:8px 20px',
195
+ 'color:white', 'font:500 14px/1 system-ui,sans-serif',
196
+ 'pointer-events:none',
197
+ ].join(';');
198
+ this._hudEl = hud;
199
+ el.appendChild(hud);
200
+ }
201
+
202
+ // Notes panel
203
+ if (this.opts.showNotes) {
204
+ const notes = document.createElement('div');
205
+ notes.style.cssText = [
206
+ 'width:100%', 'max-height:22vh', 'overflow-y:auto',
207
+ 'background:rgba(0,0,0,0.7)', 'backdrop-filter:blur(8px)',
208
+ 'color:#e0e0e0', 'font:14px/1.5 system-ui,sans-serif',
209
+ 'padding:12px 24px', 'white-space:pre-wrap', 'flex-shrink:0',
210
+ ].join(';');
211
+ this._notesEl = notes;
212
+ el.appendChild(notes);
213
+ }
214
+
215
+ // Thumbnail strip
216
+ if (this.opts.showThumbs) {
217
+ const thumbs = document.createElement('div');
218
+ thumbs.style.cssText = [
219
+ 'display:flex', 'gap:6px', 'padding:8px 16px',
220
+ 'overflow-x:auto', 'background:rgba(0,0,0,0.7)',
221
+ 'width:100%', 'flex-shrink:0',
222
+ ].join(';');
223
+ this._thumbsEl = thumbs;
224
+ el.appendChild(thumbs);
225
+ // Placeholder thumbnails
226
+ for (let i = 0; i < this.renderer.slideCount; i++) {
227
+ const thumb = document.createElement('canvas');
228
+ thumb.width = 120;
229
+ thumb.height = Math.round(120 / (this.renderer.slideSize.cx / this.renderer.slideSize.cy));
230
+ thumb.style.cssText = 'flex-shrink:0; cursor:pointer; border:2px solid transparent; border-radius:3px; opacity:0.6; transition:opacity 0.2s,border-color 0.2s;';
231
+ thumb.title = `Slide ${i + 1}`;
232
+ const idx = i;
233
+ thumb.addEventListener('click', () => this.goto(idx));
234
+ thumbs.appendChild(thumb);
235
+ this._thumbnails.push(thumb);
236
+ }
237
+ }
238
+
239
+ // Click to advance (on canvas wrap)
240
+ canvasWrap.addEventListener('click', () => this.next());
241
+
242
+ // Nav arrow buttons (hidden unless hover)
243
+ this._buildNavArrows(canvasWrap);
244
+
245
+ // Close button
246
+ const closeBtn = document.createElement('button');
247
+ closeBtn.textContent = '✕';
248
+ closeBtn.style.cssText = [
249
+ 'position:absolute', 'top:16px', 'right:20px',
250
+ 'background:rgba(255,255,255,0.15)', 'border:none',
251
+ 'color:white', 'font-size:18px', 'width:36px', 'height:36px',
252
+ 'border-radius:50%', 'cursor:pointer', 'opacity:0.7',
253
+ 'transition:opacity 0.2s',
254
+ ].join(';');
255
+ closeBtn.addEventListener('mouseover', () => closeBtn.style.opacity = '1');
256
+ closeBtn.addEventListener('mouseout', () => closeBtn.style.opacity = '0.7');
257
+ closeBtn.addEventListener('click', (e) => { e.stopPropagation(); this.stop(); });
258
+ el.appendChild(closeBtn);
259
+
260
+ this.container.appendChild(el);
261
+ el.focus();
262
+ this._resizeCanvas();
263
+ }
264
+
265
+ _buildNavArrows(parent) {
266
+ const makeArrow = (dir) => {
267
+ const btn = document.createElement('button');
268
+ btn.textContent = dir === 'prev' ? '❮' : '❯';
269
+ btn.style.cssText = [
270
+ 'position:absolute',
271
+ dir === 'prev' ? 'left:16px' : 'right:16px',
272
+ 'top:50%', 'transform:translateY(-50%)',
273
+ 'background:rgba(255,255,255,0.18)', 'border:none',
274
+ 'color:white', 'font-size:22px', 'width:48px', 'height:64px',
275
+ 'border-radius:8px', 'cursor:pointer',
276
+ 'opacity:0', 'transition:opacity 0.2s',
277
+ ].join(';');
278
+ btn.addEventListener('mouseenter', () => btn.style.opacity = '1');
279
+ btn.addEventListener('mouseleave', () => btn.style.opacity = '0');
280
+ btn.addEventListener('click', (e) => {
281
+ e.stopPropagation();
282
+ dir === 'prev' ? this.prev() : this.next();
283
+ });
284
+ parent.addEventListener('mouseenter', () => btn.style.opacity = '0.5');
285
+ parent.addEventListener('mouseleave', () => btn.style.opacity = '0');
286
+ parent.appendChild(btn);
287
+ };
288
+ makeArrow('prev');
289
+ makeArrow('next');
290
+ }
291
+
292
+ // ── Canvas sizing ───────────────────────────────────────────────────────────
293
+
294
+ _resizeCanvas() {
295
+ if (!this._canvas || !this._el) return;
296
+ const { cx, cy } = this.renderer.slideSize;
297
+ const aspect = cx / cy;
298
+
299
+ const container = this._canvas.parentElement;
300
+ const availW = container.clientWidth || window.innerWidth;
301
+ const availH = container.clientHeight || window.innerHeight * 0.75;
302
+
303
+ let w = availW, h = w / aspect;
304
+ if (h > availH) { h = availH; w = h * aspect; }
305
+
306
+ this._canvas.width = Math.round(w * window.devicePixelRatio);
307
+ this._canvas.height = Math.round(h * window.devicePixelRatio);
308
+ this._canvas.style.width = Math.round(w) + 'px';
309
+ this._canvas.style.height = Math.round(h) + 'px';
310
+ }
311
+
312
+ // ── Rendering ───────────────────────────────────────────────────────────────
313
+
314
+ async _renderCurrent(prevIndex = null) {
315
+ if (!this._canvas) return;
316
+ this._resizeCanvas();
317
+
318
+ // Transition if we have a prev canvas snapshot
319
+ if (prevIndex !== null && this.renderer.getTransition) {
320
+ const transition = this.renderer.getTransition(this._index);
321
+ if (transition && transition.type !== 'cut' && typeof OffscreenCanvas !== 'undefined') {
322
+ const { renderTransitionFrame } = await import('./animation.js');
323
+ const from = new OffscreenCanvas(this._canvas.width, this._canvas.height);
324
+ from.getContext('2d').drawImage(this._canvas, 0, 0);
325
+
326
+ await this.renderer.renderSlide(this._index, this._canvas, this._canvas.width / window.devicePixelRatio);
327
+
328
+ const to = new OffscreenCanvas(this._canvas.width, this._canvas.height);
329
+ to.getContext('2d').drawImage(this._canvas, 0, 0);
330
+
331
+ const ctx = this._canvas.getContext('2d');
332
+ const dur = transition.duration || 700;
333
+ const start = performance.now();
334
+ await new Promise(resolve => {
335
+ const frame = (now) => {
336
+ const p = Math.min(1, (now - start) / dur);
337
+ renderTransitionFrame(ctx, from, to, transition, p);
338
+ if (p < 1) requestAnimationFrame(frame); else resolve();
339
+ };
340
+ requestAnimationFrame(frame);
341
+ });
342
+ this._updateThumbnail(this._index);
343
+ this._updateNotes();
344
+ return;
345
+ }
346
+ }
347
+
348
+ await this.renderer.renderSlide(this._index, this._canvas, this._canvas.width / window.devicePixelRatio);
349
+ this._updateThumbnail(this._index);
350
+ this._updateNotes();
351
+ }
352
+
353
+ _updateThumbnail(index) {
354
+ const thumb = this._thumbnails[index];
355
+ if (!thumb || !this._canvas) return;
356
+ thumb.getContext('2d').drawImage(this._canvas, 0, 0, thumb.width, thumb.height);
357
+ // Update active state
358
+ this._thumbnails.forEach((t, i) => {
359
+ t.style.borderColor = i === this._index ? '#4af' : 'transparent';
360
+ t.style.opacity = i === this._index ? '1' : '0.6';
361
+ });
362
+ }
363
+
364
+ _updateHud() {
365
+ if (!this._hudEl) return;
366
+ const n = this.renderer.slideCount;
367
+ this._hudEl.innerHTML = `
368
+ <span style="opacity:0.7">Slide</span>
369
+ <strong>${this._index + 1}</strong>
370
+ <span style="opacity:0.5">of ${n}</span>
371
+ `;
372
+ }
373
+
374
+ async _updateNotes() {
375
+ if (!this._notesEl) return;
376
+ try {
377
+ const notes = await this.renderer.getSlideNotes(this._index);
378
+ this._notesEl.textContent = notes || '(no notes)';
379
+ } catch (_) {
380
+ this._notesEl.textContent = '';
381
+ }
382
+ }
383
+
384
+ async _generateThumbnails() {
385
+ for (let i = 0; i < this.renderer.slideCount; i++) {
386
+ if (!this._playing) break;
387
+ const thumb = this._thumbnails[i];
388
+ if (!thumb) continue;
389
+ try {
390
+ await this.renderer.renderSlide(i, thumb, thumb.width);
391
+ } catch (_) {}
392
+ // Small delay to avoid blocking the main render
393
+ await new Promise(r => setTimeout(r, 50));
394
+ }
395
+ }
396
+
397
+ // ── Keyboard & touch ────────────────────────────────────────────────────────
398
+
399
+ _attachEvents() {
400
+ document.addEventListener('keydown', this._onKey);
401
+ window.addEventListener('resize', this._onResize);
402
+ document.addEventListener('fullscreenchange', this._onFsChange);
403
+ if (this._el) {
404
+ this._el.addEventListener('touchstart', this._onTouchStart, { passive: true });
405
+ this._el.addEventListener('touchend', this._onTouchEnd, { passive: true });
406
+ }
407
+ }
408
+
409
+ _detachEvents() {
410
+ document.removeEventListener('keydown', this._onKey);
411
+ window.removeEventListener('resize', this._onResize);
412
+ document.removeEventListener('fullscreenchange', this._onFsChange);
413
+ }
414
+
415
+ _onKey(e) {
416
+ if (!this._playing) return;
417
+ switch (e.key) {
418
+ case 'ArrowRight': case 'ArrowDown': case ' ': case 'PageDown':
419
+ e.preventDefault(); this.next(); break;
420
+ case 'ArrowLeft': case 'ArrowUp': case 'PageUp': case 'Backspace':
421
+ e.preventDefault(); this.prev(); break;
422
+ case 'Home': e.preventDefault(); this.goto(0); break;
423
+ case 'End': e.preventDefault(); this.goto(this.renderer.slideCount - 1); break;
424
+ case 'Escape': case 'q': case 'Q':
425
+ e.preventDefault(); this.stop(); break;
426
+ case 'f': case 'F':
427
+ e.preventDefault(); this._toggleFullscreen(); break;
428
+ }
429
+ }
430
+
431
+ _onResize() {
432
+ this._resizeCanvas();
433
+ this._renderCurrent(); // re-render at new size
434
+ }
435
+
436
+ _onFsChange() {
437
+ if (!document.fullscreenElement) {
438
+ // User pressed Esc in fullscreen — stop show
439
+ this.stop();
440
+ }
441
+ }
442
+
443
+ _onTouchStart(e) {
444
+ this._touchStartX = e.touches[0].clientX;
445
+ this._touchStartY = e.touches[0].clientY;
446
+ }
447
+
448
+ _onTouchEnd(e) {
449
+ const dx = e.changedTouches[0].clientX - this._touchStartX;
450
+ const dy = e.changedTouches[0].clientY - this._touchStartY;
451
+ if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) {
452
+ if (dx < 0) this.next(); else this.prev();
453
+ } else if (Math.abs(dx) < 10 && Math.abs(dy) < 10) {
454
+ this.next(); // tap to advance
455
+ }
456
+ }
457
+
458
+ // ── Fullscreen ──────────────────────────────────────────────────────────────
459
+
460
+ async _requestFullscreen() {
461
+ try {
462
+ const el = this._el;
463
+ if (el.requestFullscreen) await el.requestFullscreen();
464
+ else if (el.webkitRequestFullscreen) await el.webkitRequestFullscreen();
465
+ } catch (_) { /* ignore if not allowed */ }
466
+ }
467
+
468
+ async _toggleFullscreen() {
469
+ if (document.fullscreenElement) {
470
+ await document.exitFullscreen().catch(() => {});
471
+ } else {
472
+ await this._requestFullscreen();
473
+ }
474
+ }
475
+
476
+ // ── Auto-advance ─────────────────────────────────────────────────────────────
477
+
478
+ _startAutoAdvance() {
479
+ this._stopAutoAdvance();
480
+ if (this.opts.autoAdvance > 0) {
481
+ this._autoTimer = setInterval(() => this.next(), this.opts.autoAdvance);
482
+ }
483
+ }
484
+
485
+ _stopAutoAdvance() {
486
+ if (this._autoTimer) { clearInterval(this._autoTimer); this._autoTimer = null; }
487
+ }
488
+
489
+ _resetAutoAdvance() {
490
+ if (this.opts.autoAdvance > 0) this._startAutoAdvance();
491
+ }
492
+ }