slidecontent.js 14 KB

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