501 lines
15 KiB
JavaScript
501 lines
15 KiB
JavaScript
import { extend, queryAll, closest, getMimeTypeFromFile, encodeRFC3986URI } from '../utils/util.js'
|
|
import { isMobile } from '../utils/device.js'
|
|
|
|
import fitty from 'fitty';
|
|
|
|
/**
|
|
* Handles loading, unloading and playback of slide
|
|
* content such as images, videos and iframes.
|
|
*/
|
|
export default class SlideContent {
|
|
|
|
constructor( Reveal ) {
|
|
|
|
this.Reveal = Reveal;
|
|
|
|
this.startEmbeddedIframe = this.startEmbeddedIframe.bind( this );
|
|
|
|
}
|
|
|
|
/**
|
|
* Should the given element be preloaded?
|
|
* Decides based on local element attributes and global config.
|
|
*
|
|
* @param {HTMLElement} element
|
|
*/
|
|
shouldPreload( element ) {
|
|
|
|
if( this.Reveal.isScrollView() ) {
|
|
return true;
|
|
}
|
|
|
|
// Prefer an explicit global preload setting
|
|
let preload = this.Reveal.getConfig().preloadIframes;
|
|
|
|
// If no global setting is available, fall back on the element's
|
|
// own preload setting
|
|
if( typeof preload !== 'boolean' ) {
|
|
preload = element.hasAttribute( 'data-preload' );
|
|
}
|
|
|
|
return preload;
|
|
}
|
|
|
|
/**
|
|
* Called when the given slide is within the configured view
|
|
* distance. Shows the slide element and loads any content
|
|
* that is set to load lazily (data-src).
|
|
*
|
|
* @param {HTMLElement} slide Slide to show
|
|
*/
|
|
load( slide, options = {} ) {
|
|
|
|
// Show the slide element
|
|
slide.style.display = this.Reveal.getConfig().display;
|
|
|
|
// Media elements with data-src attributes
|
|
queryAll( slide, 'img[data-src], video[data-src], audio[data-src], iframe[data-src]' ).forEach( element => {
|
|
if( element.tagName !== 'IFRAME' || this.shouldPreload( element ) ) {
|
|
element.setAttribute( 'src', element.getAttribute( 'data-src' ) );
|
|
element.setAttribute( 'data-lazy-loaded', '' );
|
|
element.removeAttribute( 'data-src' );
|
|
}
|
|
} );
|
|
|
|
// Media elements with <source> children
|
|
queryAll( slide, 'video, audio' ).forEach( media => {
|
|
let sources = 0;
|
|
|
|
queryAll( media, 'source[data-src]' ).forEach( source => {
|
|
source.setAttribute( 'src', source.getAttribute( 'data-src' ) );
|
|
source.removeAttribute( 'data-src' );
|
|
source.setAttribute( 'data-lazy-loaded', '' );
|
|
sources += 1;
|
|
} );
|
|
|
|
// Enable inline video playback in mobile Safari
|
|
if( isMobile && media.tagName === 'VIDEO' ) {
|
|
media.setAttribute( 'playsinline', '' );
|
|
}
|
|
|
|
// If we rewrote sources for this video/audio element, we need
|
|
// to manually tell it to load from its new origin
|
|
if( sources > 0 ) {
|
|
media.load();
|
|
}
|
|
} );
|
|
|
|
|
|
// Show the corresponding background element
|
|
let background = slide.slideBackgroundElement;
|
|
if( background ) {
|
|
background.style.display = 'block';
|
|
|
|
let backgroundContent = slide.slideBackgroundContentElement;
|
|
let backgroundIframe = slide.getAttribute( 'data-background-iframe' );
|
|
|
|
// If the background contains media, load it
|
|
if( background.hasAttribute( 'data-loaded' ) === false ) {
|
|
background.setAttribute( 'data-loaded', 'true' );
|
|
|
|
let backgroundImage = slide.getAttribute( 'data-background-image' ),
|
|
backgroundVideo = slide.getAttribute( 'data-background-video' ),
|
|
backgroundVideoLoop = slide.hasAttribute( 'data-background-video-loop' ),
|
|
backgroundVideoMuted = slide.hasAttribute( 'data-background-video-muted' );
|
|
|
|
// Images
|
|
if( backgroundImage ) {
|
|
// base64
|
|
if( /^data:/.test( backgroundImage.trim() ) ) {
|
|
backgroundContent.style.backgroundImage = `url(${backgroundImage.trim()})`;
|
|
}
|
|
// URL(s)
|
|
else {
|
|
backgroundContent.style.backgroundImage = backgroundImage.split( ',' ).map( background => {
|
|
// Decode URL(s) that are already encoded first
|
|
let decoded = decodeURI(background.trim());
|
|
return `url(${encodeRFC3986URI(decoded)})`;
|
|
}).join( ',' );
|
|
}
|
|
}
|
|
// Videos
|
|
else if ( backgroundVideo ) {
|
|
let video = document.createElement( 'video' );
|
|
|
|
if( backgroundVideoLoop ) {
|
|
video.setAttribute( 'loop', '' );
|
|
}
|
|
|
|
if( backgroundVideoMuted || this.Reveal.isSpeakerNotes() ) {
|
|
video.muted = true;
|
|
}
|
|
|
|
// Enable inline playback in mobile Safari
|
|
//
|
|
// Mute is required for video to play when using
|
|
// swipe gestures to navigate since they don't
|
|
// count as direct user actions :'(
|
|
if( isMobile ) {
|
|
video.muted = true;
|
|
video.setAttribute( 'playsinline', '' );
|
|
}
|
|
|
|
// Support comma separated lists of video sources
|
|
backgroundVideo.split( ',' ).forEach( source => {
|
|
const sourceElement = document.createElement( 'source' );
|
|
sourceElement.setAttribute( 'src', source );
|
|
|
|
let type = getMimeTypeFromFile( source );
|
|
if( type ) {
|
|
sourceElement.setAttribute( 'type', type );
|
|
}
|
|
|
|
video.appendChild( sourceElement );
|
|
} );
|
|
|
|
backgroundContent.appendChild( video );
|
|
}
|
|
// Iframes
|
|
else if( backgroundIframe && options.excludeIframes !== true ) {
|
|
let iframe = document.createElement( 'iframe' );
|
|
iframe.setAttribute( 'allowfullscreen', '' );
|
|
iframe.setAttribute( 'mozallowfullscreen', '' );
|
|
iframe.setAttribute( 'webkitallowfullscreen', '' );
|
|
iframe.setAttribute( 'allow', 'autoplay' );
|
|
|
|
iframe.setAttribute( 'data-src', backgroundIframe );
|
|
|
|
iframe.style.width = '100%';
|
|
iframe.style.height = '100%';
|
|
iframe.style.maxHeight = '100%';
|
|
iframe.style.maxWidth = '100%';
|
|
|
|
backgroundContent.appendChild( iframe );
|
|
}
|
|
}
|
|
|
|
// Start loading preloadable iframes
|
|
let backgroundIframeElement = backgroundContent.querySelector( 'iframe[data-src]' );
|
|
if( backgroundIframeElement ) {
|
|
|
|
// Check if this iframe is eligible to be preloaded
|
|
if( this.shouldPreload( background ) && !/autoplay=(1|true|yes)/gi.test( backgroundIframe ) ) {
|
|
if( backgroundIframeElement.getAttribute( 'src' ) !== backgroundIframe ) {
|
|
backgroundIframeElement.setAttribute( 'src', backgroundIframe );
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
this.layout( slide );
|
|
|
|
}
|
|
|
|
/**
|
|
* Applies JS-dependent layout helpers for the scope.
|
|
*/
|
|
layout( scopeElement ) {
|
|
|
|
// Autosize text with the r-fit-text class based on the
|
|
// size of its container. This needs to happen after the
|
|
// slide is visible in order to measure the text.
|
|
Array.from( scopeElement.querySelectorAll( '.r-fit-text' ) ).forEach( element => {
|
|
fitty( element, {
|
|
minSize: 24,
|
|
maxSize: this.Reveal.getConfig().height * 0.8,
|
|
observeMutations: false,
|
|
observeWindow: false
|
|
} );
|
|
} );
|
|
|
|
}
|
|
|
|
/**
|
|
* Unloads and hides the given slide. This is called when the
|
|
* slide is moved outside of the configured view distance.
|
|
*
|
|
* @param {HTMLElement} slide
|
|
*/
|
|
unload( slide ) {
|
|
|
|
// Hide the slide element
|
|
slide.style.display = 'none';
|
|
|
|
// Hide the corresponding background element
|
|
let background = this.Reveal.getSlideBackground( slide );
|
|
if( background ) {
|
|
background.style.display = 'none';
|
|
|
|
// Unload any background iframes
|
|
queryAll( background, 'iframe[src]' ).forEach( element => {
|
|
element.removeAttribute( 'src' );
|
|
} );
|
|
}
|
|
|
|
// Reset lazy-loaded media elements with src attributes
|
|
queryAll( slide, 'video[data-lazy-loaded][src], audio[data-lazy-loaded][src], iframe[data-lazy-loaded][src]' ).forEach( element => {
|
|
element.setAttribute( 'data-src', element.getAttribute( 'src' ) );
|
|
element.removeAttribute( 'src' );
|
|
} );
|
|
|
|
// Reset lazy-loaded media elements with <source> children
|
|
queryAll( slide, 'video[data-lazy-loaded] source[src], audio source[src]' ).forEach( source => {
|
|
source.setAttribute( 'data-src', source.getAttribute( 'src' ) );
|
|
source.removeAttribute( 'src' );
|
|
} );
|
|
|
|
}
|
|
|
|
/**
|
|
* Enforces origin-specific format rules for embedded media.
|
|
*/
|
|
formatEmbeddedContent() {
|
|
|
|
let _appendParamToIframeSource = ( sourceAttribute, sourceURL, param ) => {
|
|
queryAll( this.Reveal.getSlidesElement(), 'iframe['+ sourceAttribute +'*="'+ sourceURL +'"]' ).forEach( el => {
|
|
let src = el.getAttribute( sourceAttribute );
|
|
if( src && src.indexOf( param ) === -1 ) {
|
|
el.setAttribute( sourceAttribute, src + ( !/\?/.test( src ) ? '?' : '&' ) + param );
|
|
}
|
|
});
|
|
};
|
|
|
|
// YouTube frames must include "?enablejsapi=1"
|
|
_appendParamToIframeSource( 'src', 'youtube.com/embed/', 'enablejsapi=1' );
|
|
_appendParamToIframeSource( 'data-src', 'youtube.com/embed/', 'enablejsapi=1' );
|
|
|
|
// Vimeo frames must include "?api=1"
|
|
_appendParamToIframeSource( 'src', 'player.vimeo.com/', 'api=1' );
|
|
_appendParamToIframeSource( 'data-src', 'player.vimeo.com/', 'api=1' );
|
|
|
|
}
|
|
|
|
/**
|
|
* Start playback of any embedded content inside of
|
|
* the given element.
|
|
*
|
|
* @param {HTMLElement} element
|
|
*/
|
|
startEmbeddedContent( element ) {
|
|
|
|
if( element ) {
|
|
|
|
const isSpeakerNotesWindow = this.Reveal.isSpeakerNotes();
|
|
|
|
// Restart GIFs
|
|
queryAll( element, 'img[src$=".gif"]' ).forEach( el => {
|
|
// Setting the same unchanged source like this was confirmed
|
|
// to work in Chrome, FF & Safari
|
|
el.setAttribute( 'src', el.getAttribute( 'src' ) );
|
|
} );
|
|
|
|
// HTML5 media elements
|
|
queryAll( element, 'video, audio' ).forEach( el => {
|
|
if( closest( el, '.fragment' ) && !closest( el, '.fragment.visible' ) ) {
|
|
return;
|
|
}
|
|
|
|
// Prefer an explicit global autoplay setting
|
|
let autoplay = this.Reveal.getConfig().autoPlayMedia;
|
|
|
|
// If no global setting is available, fall back on the element's
|
|
// own autoplay setting
|
|
if( typeof autoplay !== 'boolean' ) {
|
|
autoplay = el.hasAttribute( 'data-autoplay' ) || !!closest( el, '.slide-background' );
|
|
}
|
|
|
|
if( autoplay && typeof el.play === 'function' ) {
|
|
|
|
// In teh speaker view we only auto-play muted media
|
|
if( isSpeakerNotesWindow && !el.muted ) return;
|
|
|
|
// If the media is ready, start playback
|
|
if( el.readyState > 1 ) {
|
|
this.startEmbeddedMedia( { target: el } );
|
|
}
|
|
// Mobile devices never fire a loaded event so instead
|
|
// of waiting, we initiate playback
|
|
else if( isMobile ) {
|
|
let promise = el.play();
|
|
|
|
// If autoplay does not work, ensure that the controls are visible so
|
|
// that the viewer can start the media on their own
|
|
if( promise && typeof promise.catch === 'function' && el.controls === false ) {
|
|
promise.catch( () => {
|
|
el.controls = true;
|
|
|
|
// Once the video does start playing, hide the controls again
|
|
el.addEventListener( 'play', () => {
|
|
el.controls = false;
|
|
} );
|
|
} );
|
|
}
|
|
}
|
|
// If the media isn't loaded, wait before playing
|
|
else {
|
|
el.removeEventListener( 'loadeddata', this.startEmbeddedMedia ); // remove first to avoid dupes
|
|
el.addEventListener( 'loadeddata', this.startEmbeddedMedia );
|
|
}
|
|
|
|
}
|
|
} );
|
|
|
|
// Don't play iframe content in the speaker view since we can't
|
|
// guarantee that it's muted
|
|
if( !isSpeakerNotesWindow ) {
|
|
|
|
// Normal iframes
|
|
queryAll( element, 'iframe[src]' ).forEach( el => {
|
|
if( closest( el, '.fragment' ) && !closest( el, '.fragment.visible' ) ) {
|
|
return;
|
|
}
|
|
|
|
this.startEmbeddedIframe( { target: el } );
|
|
} );
|
|
|
|
// Lazy loading iframes
|
|
queryAll( element, 'iframe[data-src]' ).forEach( el => {
|
|
if( closest( el, '.fragment' ) && !closest( el, '.fragment.visible' ) ) {
|
|
return;
|
|
}
|
|
|
|
if( el.getAttribute( 'src' ) !== el.getAttribute( 'data-src' ) ) {
|
|
el.removeEventListener( 'load', this.startEmbeddedIframe ); // remove first to avoid dupes
|
|
el.addEventListener( 'load', this.startEmbeddedIframe );
|
|
el.setAttribute( 'src', el.getAttribute( 'data-src' ) );
|
|
}
|
|
} );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* Starts playing an embedded video/audio element after
|
|
* it has finished loading.
|
|
*
|
|
* @param {object} event
|
|
*/
|
|
startEmbeddedMedia( event ) {
|
|
|
|
let isAttachedToDOM = !!closest( event.target, 'html' ),
|
|
isVisible = !!closest( event.target, '.present' );
|
|
|
|
if( isAttachedToDOM && isVisible ) {
|
|
// Don't restart if media is already playing
|
|
if( event.target.paused || event.target.ended ) {
|
|
event.target.currentTime = 0;
|
|
event.target.play();
|
|
}
|
|
}
|
|
|
|
event.target.removeEventListener( 'loadeddata', this.startEmbeddedMedia );
|
|
|
|
}
|
|
|
|
/**
|
|
* "Starts" the content of an embedded iframe using the
|
|
* postMessage API.
|
|
*
|
|
* @param {object} event
|
|
*/
|
|
startEmbeddedIframe( event ) {
|
|
|
|
let iframe = event.target;
|
|
|
|
if( iframe && iframe.contentWindow ) {
|
|
|
|
let isAttachedToDOM = !!closest( event.target, 'html' ),
|
|
isVisible = !!closest( event.target, '.present' );
|
|
|
|
if( isAttachedToDOM && isVisible ) {
|
|
|
|
// Prefer an explicit global autoplay setting
|
|
let autoplay = this.Reveal.getConfig().autoPlayMedia;
|
|
|
|
// If no global setting is available, fall back on the element's
|
|
// own autoplay setting
|
|
if( typeof autoplay !== 'boolean' ) {
|
|
autoplay = iframe.hasAttribute( 'data-autoplay' ) || !!closest( iframe, '.slide-background' );
|
|
}
|
|
|
|
// YouTube postMessage API
|
|
if( /youtube\.com\/embed\//.test( iframe.getAttribute( 'src' ) ) && autoplay ) {
|
|
iframe.contentWindow.postMessage( '{"event":"command","func":"playVideo","args":""}', '*' );
|
|
}
|
|
// Vimeo postMessage API
|
|
else if( /player\.vimeo\.com\//.test( iframe.getAttribute( 'src' ) ) && autoplay ) {
|
|
iframe.contentWindow.postMessage( '{"method":"play"}', '*' );
|
|
}
|
|
// Generic postMessage API
|
|
else {
|
|
iframe.contentWindow.postMessage( 'slide:start', '*' );
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* Stop playback of any embedded content inside of
|
|
* the targeted slide.
|
|
*
|
|
* @param {HTMLElement} element
|
|
*/
|
|
stopEmbeddedContent( element, options = {} ) {
|
|
|
|
options = extend( {
|
|
// Defaults
|
|
unloadIframes: true
|
|
}, options );
|
|
|
|
if( element && element.parentNode ) {
|
|
// HTML5 media elements
|
|
queryAll( element, 'video, audio' ).forEach( el => {
|
|
if( !el.hasAttribute( 'data-ignore' ) && typeof el.pause === 'function' ) {
|
|
el.setAttribute('data-paused-by-reveal', '');
|
|
el.pause();
|
|
}
|
|
} );
|
|
|
|
// Generic postMessage API for non-lazy loaded iframes
|
|
queryAll( element, 'iframe' ).forEach( el => {
|
|
if( el.contentWindow ) el.contentWindow.postMessage( 'slide:stop', '*' );
|
|
el.removeEventListener( 'load', this.startEmbeddedIframe );
|
|
});
|
|
|
|
// YouTube postMessage API
|
|
queryAll( element, 'iframe[src*="youtube.com/embed/"]' ).forEach( el => {
|
|
if( !el.hasAttribute( 'data-ignore' ) && el.contentWindow && typeof el.contentWindow.postMessage === 'function' ) {
|
|
el.contentWindow.postMessage( '{"event":"command","func":"pauseVideo","args":""}', '*' );
|
|
}
|
|
});
|
|
|
|
// Vimeo postMessage API
|
|
queryAll( element, 'iframe[src*="player.vimeo.com/"]' ).forEach( el => {
|
|
if( !el.hasAttribute( 'data-ignore' ) && el.contentWindow && typeof el.contentWindow.postMessage === 'function' ) {
|
|
el.contentWindow.postMessage( '{"method":"pause"}', '*' );
|
|
}
|
|
});
|
|
|
|
if( options.unloadIframes === true ) {
|
|
// Unload lazy-loaded iframes
|
|
queryAll( element, 'iframe[data-src]' ).forEach( el => {
|
|
// Only removing the src doesn't actually unload the frame
|
|
// in all browsers (Firefox) so we set it to blank first
|
|
el.setAttribute( 'src', 'about:blank' );
|
|
el.removeAttribute( 'src' );
|
|
} );
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
}
|