slidecontent.js 14 KB

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