slidecontent.js 15 KB

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