blob: 15fe5bd8c48e7696d99a2f7d36715ed174f4be34 [file] [log] [blame]
Christophe Dervieuxe1893ae2021-10-07 17:09:02 +02001import { HORIZONTAL_SLIDES_SELECTOR, VERTICAL_SLIDES_SELECTOR } from '../utils/constants.js'
2import { extend, queryAll, closest } from '../utils/util.js'
3import { isMobile } from '../utils/device.js'
4
5import fitty from 'fitty';
6
7/**
8 * Handles loading, unloading and playback of slide
9 * content such as images, videos and iframes.
10 */
11export default class SlideContent {
12
13 constructor( Reveal ) {
14
15 this.Reveal = Reveal;
16
17 this.startEmbeddedIframe = this.startEmbeddedIframe.bind( this );
18
19 }
20
21 /**
22 * Should the given element be preloaded?
23 * Decides based on local element attributes and global config.
24 *
25 * @param {HTMLElement} element
26 */
27 shouldPreload( element ) {
28
29 // Prefer an explicit global preload setting
30 let preload = this.Reveal.getConfig().preloadIframes;
31
32 // If no global setting is available, fall back on the element's
33 // own preload setting
34 if( typeof preload !== 'boolean' ) {
35 preload = element.hasAttribute( 'data-preload' );
36 }
37
38 return preload;
39 }
40
41 /**
42 * Called when the given slide is within the configured view
43 * distance. Shows the slide element and loads any content
44 * that is set to load lazily (data-src).
45 *
46 * @param {HTMLElement} slide Slide to show
47 */
48 load( slide, options = {} ) {
49
50 // Show the slide element
51 slide.style.display = this.Reveal.getConfig().display;
52
53 // Media elements with data-src attributes
54 queryAll( slide, 'img[data-src], video[data-src], audio[data-src], iframe[data-src]' ).forEach( element => {
55 if( element.tagName !== 'IFRAME' || this.shouldPreload( element ) ) {
56 element.setAttribute( 'src', element.getAttribute( 'data-src' ) );
57 element.setAttribute( 'data-lazy-loaded', '' );
58 element.removeAttribute( 'data-src' );
59 }
60 } );
61
62 // Media elements with <source> children
63 queryAll( slide, 'video, audio' ).forEach( media => {
64 let sources = 0;
65
66 queryAll( media, 'source[data-src]' ).forEach( source => {
67 source.setAttribute( 'src', source.getAttribute( 'data-src' ) );
68 source.removeAttribute( 'data-src' );
69 source.setAttribute( 'data-lazy-loaded', '' );
70 sources += 1;
71 } );
72
73 // Enable inline video playback in mobile Safari
74 if( isMobile && media.tagName === 'VIDEO' ) {
75 media.setAttribute( 'playsinline', '' );
76 }
77
78 // If we rewrote sources for this video/audio element, we need
79 // to manually tell it to load from its new origin
80 if( sources > 0 ) {
81 media.load();
82 }
83 } );
84
85
86 // Show the corresponding background element
87 let background = slide.slideBackgroundElement;
88 if( background ) {
89 background.style.display = 'block';
90
91 let backgroundContent = slide.slideBackgroundContentElement;
92 let backgroundIframe = slide.getAttribute( 'data-background-iframe' );
93
94 // If the background contains media, load it
95 if( background.hasAttribute( 'data-loaded' ) === false ) {
96 background.setAttribute( 'data-loaded', 'true' );
97
98 let backgroundImage = slide.getAttribute( 'data-background-image' ),
99 backgroundVideo = slide.getAttribute( 'data-background-video' ),
100 backgroundVideoLoop = slide.hasAttribute( 'data-background-video-loop' ),
101 backgroundVideoMuted = slide.hasAttribute( 'data-background-video-muted' );
102
103 // Images
104 if( backgroundImage ) {
105 backgroundContent.style.backgroundImage = backgroundImage.split( ',' ).map( background => {
106 return `url(${encodeURI(background.trim())})`;
107 }).join( ',' );
108 }
109 // Videos
110 else if ( backgroundVideo && !this.Reveal.isSpeakerNotes() ) {
111 let video = document.createElement( 'video' );
112
113 if( backgroundVideoLoop ) {
114 video.setAttribute( 'loop', '' );
115 }
116
117 if( backgroundVideoMuted ) {
118 video.muted = true;
119 }
120
121 // Enable inline playback in mobile Safari
122 //
123 // Mute is required for video to play when using
124 // swipe gestures to navigate since they don't
125 // count as direct user actions :'(
126 if( isMobile ) {
127 video.muted = true;
128 video.setAttribute( 'playsinline', '' );
129 }
130
131 // Support comma separated lists of video sources
132 backgroundVideo.split( ',' ).forEach( source => {
133 video.innerHTML += '<source src="'+ source +'">';
134 } );
135
136 backgroundContent.appendChild( video );
137 }
138 // Iframes
139 else if( backgroundIframe && options.excludeIframes !== true ) {
140 let iframe = document.createElement( 'iframe' );
141 iframe.setAttribute( 'allowfullscreen', '' );
142 iframe.setAttribute( 'mozallowfullscreen', '' );
143 iframe.setAttribute( 'webkitallowfullscreen', '' );
144 iframe.setAttribute( 'allow', 'autoplay' );
145
146 iframe.setAttribute( 'data-src', backgroundIframe );
147
148 iframe.style.width = '100%';
149 iframe.style.height = '100%';
150 iframe.style.maxHeight = '100%';
151 iframe.style.maxWidth = '100%';
152
153 backgroundContent.appendChild( iframe );
154 }
155 }
156
157 // Start loading preloadable iframes
158 let backgroundIframeElement = backgroundContent.querySelector( 'iframe[data-src]' );
159 if( backgroundIframeElement ) {
160
161 // Check if this iframe is eligible to be preloaded
162 if( this.shouldPreload( background ) && !/autoplay=(1|true|yes)/gi.test( backgroundIframe ) ) {
163 if( backgroundIframeElement.getAttribute( 'src' ) !== backgroundIframe ) {
164 backgroundIframeElement.setAttribute( 'src', backgroundIframe );
165 }
166 }
167
168 }
169
170 }
171
172 this.layout( slide );
173
174 }
175
176 /**
177 * Applies JS-dependent layout helpers for the given slide,
178 * if there are any.
179 */
180 layout( slide ) {
181
182 // Autosize text with the r-fit-text class based on the
183 // size of its container. This needs to happen after the
184 // slide is visible in order to measure the text.
185 Array.from( slide.querySelectorAll( '.r-fit-text' ) ).forEach( element => {
186 fitty( element, {
187 minSize: 24,
188 maxSize: this.Reveal.getConfig().height * 0.8,
189 observeMutations: false,
190 observeWindow: false
191 } );
192 } );
193
194 }
195
196 /**
197 * Unloads and hides the given slide. This is called when the
198 * slide is moved outside of the configured view distance.
199 *
200 * @param {HTMLElement} slide
201 */
202 unload( slide ) {
203
204 // Hide the slide element
205 slide.style.display = 'none';
206
207 // Hide the corresponding background element
208 let background = this.Reveal.getSlideBackground( slide );
209 if( background ) {
210 background.style.display = 'none';
211
212 // Unload any background iframes
213 queryAll( background, 'iframe[src]' ).forEach( element => {
214 element.removeAttribute( 'src' );
215 } );
216 }
217
218 // Reset lazy-loaded media elements with src attributes
219 queryAll( slide, 'video[data-lazy-loaded][src], audio[data-lazy-loaded][src], iframe[data-lazy-loaded][src]' ).forEach( element => {
220 element.setAttribute( 'data-src', element.getAttribute( 'src' ) );
221 element.removeAttribute( 'src' );
222 } );
223
224 // Reset lazy-loaded media elements with <source> children
225 queryAll( slide, 'video[data-lazy-loaded] source[src], audio source[src]' ).forEach( source => {
226 source.setAttribute( 'data-src', source.getAttribute( 'src' ) );
227 source.removeAttribute( 'src' );
228 } );
229
230 }
231
232 /**
233 * Enforces origin-specific format rules for embedded media.
234 */
235 formatEmbeddedContent() {
236
237 let _appendParamToIframeSource = ( sourceAttribute, sourceURL, param ) => {
238 queryAll( this.Reveal.getSlidesElement(), 'iframe['+ sourceAttribute +'*="'+ sourceURL +'"]' ).forEach( el => {
239 let src = el.getAttribute( sourceAttribute );
240 if( src && src.indexOf( param ) === -1 ) {
241 el.setAttribute( sourceAttribute, src + ( !/\?/.test( src ) ? '?' : '&' ) + param );
242 }
243 });
244 };
245
246 // YouTube frames must include "?enablejsapi=1"
247 _appendParamToIframeSource( 'src', 'youtube.com/embed/', 'enablejsapi=1' );
248 _appendParamToIframeSource( 'data-src', 'youtube.com/embed/', 'enablejsapi=1' );
249
250 // Vimeo frames must include "?api=1"
251 _appendParamToIframeSource( 'src', 'player.vimeo.com/', 'api=1' );
252 _appendParamToIframeSource( 'data-src', 'player.vimeo.com/', 'api=1' );
253
254 }
255
256 /**
257 * Start playback of any embedded content inside of
258 * the given element.
259 *
260 * @param {HTMLElement} element
261 */
262 startEmbeddedContent( element ) {
263
264 if( element && !this.Reveal.isSpeakerNotes() ) {
265
266 // Restart GIFs
267 queryAll( element, 'img[src$=".gif"]' ).forEach( el => {
268 // Setting the same unchanged source like this was confirmed
269 // to work in Chrome, FF & Safari
270 el.setAttribute( 'src', el.getAttribute( 'src' ) );
271 } );
272
273 // HTML5 media elements
274 queryAll( element, 'video, audio' ).forEach( el => {
275 if( closest( el, '.fragment' ) && !closest( el, '.fragment.visible' ) ) {
276 return;
277 }
278
279 // Prefer an explicit global autoplay setting
280 let autoplay = this.Reveal.getConfig().autoPlayMedia;
281
282 // If no global setting is available, fall back on the element's
283 // own autoplay setting
284 if( typeof autoplay !== 'boolean' ) {
285 autoplay = el.hasAttribute( 'data-autoplay' ) || !!closest( el, '.slide-background' );
286 }
287
288 if( autoplay && typeof el.play === 'function' ) {
289
290 // If the media is ready, start playback
291 if( el.readyState > 1 ) {
292 this.startEmbeddedMedia( { target: el } );
293 }
294 // Mobile devices never fire a loaded event so instead
295 // of waiting, we initiate playback
296 else if( isMobile ) {
297 let promise = el.play();
298
299 // If autoplay does not work, ensure that the controls are visible so
300 // that the viewer can start the media on their own
301 if( promise && typeof promise.catch === 'function' && el.controls === false ) {
302 promise.catch( () => {
303 el.controls = true;
304
305 // Once the video does start playing, hide the controls again
306 el.addEventListener( 'play', () => {
307 el.controls = false;
308 } );
309 } );
310 }
311 }
312 // If the media isn't loaded, wait before playing
313 else {
314 el.removeEventListener( 'loadeddata', this.startEmbeddedMedia ); // remove first to avoid dupes
315 el.addEventListener( 'loadeddata', this.startEmbeddedMedia );
316 }
317
318 }
319 } );
320
321 // Normal iframes
322 queryAll( element, 'iframe[src]' ).forEach( el => {
323 if( closest( el, '.fragment' ) && !closest( el, '.fragment.visible' ) ) {
324 return;
325 }
326
327 this.startEmbeddedIframe( { target: el } );
328 } );
329
330 // Lazy loading iframes
331 queryAll( element, 'iframe[data-src]' ).forEach( el => {
332 if( closest( el, '.fragment' ) && !closest( el, '.fragment.visible' ) ) {
333 return;
334 }
335
336 if( el.getAttribute( 'src' ) !== el.getAttribute( 'data-src' ) ) {
337 el.removeEventListener( 'load', this.startEmbeddedIframe ); // remove first to avoid dupes
338 el.addEventListener( 'load', this.startEmbeddedIframe );
339 el.setAttribute( 'src', el.getAttribute( 'data-src' ) );
340 }
341 } );
342
343 }
344
345 }
346
347 /**
348 * Starts playing an embedded video/audio element after
349 * it has finished loading.
350 *
351 * @param {object} event
352 */
353 startEmbeddedMedia( event ) {
354
355 let isAttachedToDOM = !!closest( event.target, 'html' ),
356 isVisible = !!closest( event.target, '.present' );
357
358 if( isAttachedToDOM && isVisible ) {
359 event.target.currentTime = 0;
360 event.target.play();
361 }
362
363 event.target.removeEventListener( 'loadeddata', this.startEmbeddedMedia );
364
365 }
366
367 /**
368 * "Starts" the content of an embedded iframe using the
369 * postMessage API.
370 *
371 * @param {object} event
372 */
373 startEmbeddedIframe( event ) {
374
375 let iframe = event.target;
376
377 if( iframe && iframe.contentWindow ) {
378
379 let isAttachedToDOM = !!closest( event.target, 'html' ),
380 isVisible = !!closest( event.target, '.present' );
381
382 if( isAttachedToDOM && isVisible ) {
383
384 // Prefer an explicit global autoplay setting
385 let autoplay = this.Reveal.getConfig().autoPlayMedia;
386
387 // If no global setting is available, fall back on the element's
388 // own autoplay setting
389 if( typeof autoplay !== 'boolean' ) {
390 autoplay = iframe.hasAttribute( 'data-autoplay' ) || !!closest( iframe, '.slide-background' );
391 }
392
393 // YouTube postMessage API
394 if( /youtube\.com\/embed\//.test( iframe.getAttribute( 'src' ) ) && autoplay ) {
395 iframe.contentWindow.postMessage( '{"event":"command","func":"playVideo","args":""}', '*' );
396 }
397 // Vimeo postMessage API
398 else if( /player\.vimeo\.com\//.test( iframe.getAttribute( 'src' ) ) && autoplay ) {
399 iframe.contentWindow.postMessage( '{"method":"play"}', '*' );
400 }
401 // Generic postMessage API
402 else {
403 iframe.contentWindow.postMessage( 'slide:start', '*' );
404 }
405
406 }
407
408 }
409
410 }
411
412 /**
413 * Stop playback of any embedded content inside of
414 * the targeted slide.
415 *
416 * @param {HTMLElement} element
417 */
418 stopEmbeddedContent( element, options = {} ) {
419
420 options = extend( {
421 // Defaults
422 unloadIframes: true
423 }, options );
424
425 if( element && element.parentNode ) {
426 // HTML5 media elements
427 queryAll( element, 'video, audio' ).forEach( el => {
428 if( !el.hasAttribute( 'data-ignore' ) && typeof el.pause === 'function' ) {
429 el.setAttribute('data-paused-by-reveal', '');
430 el.pause();
431 }
432 } );
433
434 // Generic postMessage API for non-lazy loaded iframes
435 queryAll( element, 'iframe' ).forEach( el => {
436 if( el.contentWindow ) el.contentWindow.postMessage( 'slide:stop', '*' );
437 el.removeEventListener( 'load', this.startEmbeddedIframe );
438 });
439
440 // YouTube postMessage API
441 queryAll( element, 'iframe[src*="youtube.com/embed/"]' ).forEach( el => {
442 if( !el.hasAttribute( 'data-ignore' ) && el.contentWindow && typeof el.contentWindow.postMessage === 'function' ) {
443 el.contentWindow.postMessage( '{"event":"command","func":"pauseVideo","args":""}', '*' );
444 }
445 });
446
447 // Vimeo postMessage API
448 queryAll( element, 'iframe[src*="player.vimeo.com/"]' ).forEach( el => {
449 if( !el.hasAttribute( 'data-ignore' ) && el.contentWindow && typeof el.contentWindow.postMessage === 'function' ) {
450 el.contentWindow.postMessage( '{"method":"pause"}', '*' );
451 }
452 });
453
454 if( options.unloadIframes === true ) {
455 // Unload lazy-loaded iframes
456 queryAll( element, 'iframe[data-src]' ).forEach( el => {
457 // Only removing the src doesn't actually unload the frame
458 // in all browsers (Firefox) so we set it to blank first
459 el.setAttribute( 'src', 'about:blank' );
460 el.removeAttribute( 'src' );
461 } );
462 }
463 }
464
465 }
466
467}