reader.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. import { SLIDES_SELECTOR } from '../utils/constants.js'
  2. import { queryAll, createStyleSheet } from '../utils/util.js'
  3. /**
  4. * The reader mode lets you read a reveal.js presentation
  5. * as a linear scrollable page.
  6. */
  7. export default class Reader {
  8. constructor( Reveal ) {
  9. this.Reveal = Reveal;
  10. this.active = false;
  11. this.activatedCallbacks = [];
  12. }
  13. /**
  14. * Activates the reader mode. This rearranges the presentation DOM
  15. * by—among other things—wrapping each slide in a page element.
  16. */
  17. async activate() {
  18. if( this.active ) return;
  19. this.active = true;
  20. this.slideHTMLBeforeActivation = this.Reveal.getSlidesElement().innerHTML;
  21. const viewportElement = this.Reveal.getViewportElement();
  22. const slides = queryAll( this.Reveal.getRevealElement(), SLIDES_SELECTOR );
  23. viewportElement.classList.add( 'loading-scroll-mode', 'reveal-reader' );
  24. viewportElement.addEventListener( 'scroll', this.onScroll.bind( this ) );
  25. let presentationBackground;
  26. if( viewportElement ) {
  27. const viewportStyles = window.getComputedStyle( viewportElement );
  28. if( viewportStyles && viewportStyles.background ) {
  29. presentationBackground = viewportStyles.background;
  30. }
  31. }
  32. const pageElements = [];
  33. const pageContainer = slides[0].parentNode;
  34. // Slide and slide background layout
  35. slides.forEach( function( slide ) {
  36. // Vertical stacks are not centred since their section
  37. // children will be
  38. if( slide.classList.contains( 'stack' ) === false ) {
  39. // Wrap the slide in a page element and hide its overflow
  40. // so that no page ever flows onto another
  41. const page = document.createElement( 'div' );
  42. page.className = 'reader-page';
  43. pageElements.push( page );
  44. // Copy the presentation-wide background to each individual
  45. // page when printing
  46. if( presentationBackground ) {
  47. page.style.background = presentationBackground;
  48. }
  49. const stickyContainer = document.createElement( 'div' );
  50. stickyContainer.className = 'reader-page-sticky';
  51. page.appendChild( stickyContainer );
  52. const contentContainer = document.createElement( 'div' );
  53. contentContainer.className = 'reader-page-content';
  54. stickyContainer.appendChild( contentContainer );
  55. contentContainer.appendChild( slide );
  56. slide.classList.remove( 'past', 'future' );
  57. if( slide.slideBackgroundElement ) {
  58. slide.slideBackgroundElement.remove( 'past', 'future' );
  59. contentContainer.insertBefore( slide.slideBackgroundElement, slide );
  60. }
  61. }
  62. }, this );
  63. // Remove leftover stacks
  64. queryAll( this.Reveal.getRevealElement(), '.stack' ).forEach( stack => stack.remove() );
  65. await new Promise( requestAnimationFrame );
  66. pageElements.forEach( page => pageContainer.appendChild( page ) );
  67. // Re-run JS-based content layout after the slide is added to page DOM
  68. this.Reveal.slideContent.layout( this.Reveal.getSlidesElement() );
  69. this.Reveal.layout();
  70. viewportElement.classList.remove( 'loading-scroll-mode' );
  71. this.activatedCallbacks.forEach( callback => callback() );
  72. this.activatedCallbacks = [];
  73. }
  74. /**
  75. * Deactivates the reader mode and restores the standard slide-based
  76. * presentation.
  77. */
  78. deactivate() {
  79. if( !this.active ) return;
  80. this.active = false;
  81. this.Reveal.getViewportElement().classList.remove( 'reveal-reader' );
  82. this.Reveal.getSlidesElement().innerHTML = this.slideHTMLBeforeActivation;
  83. this.Reveal.sync();
  84. // TODO Navigate to the slide that is currently scrolled into view
  85. this.Reveal.slide( 0 );
  86. }
  87. toggle() {
  88. if( this.active === true ) {
  89. this.deactivate();
  90. }
  91. else {
  92. this.activate();
  93. }
  94. }
  95. /**
  96. * Checks if the reader mode is currently active.
  97. */
  98. isActive() {
  99. return this.active;
  100. }
  101. /**
  102. * Updates our reader pages to match the latest configuration and
  103. * presentation size.
  104. */
  105. sync() {
  106. const config = this.Reveal.getConfig();
  107. const slideSize = this.Reveal.getComputedSlideSize( window.innerWidth, window.innerHeight );
  108. const scale = this.Reveal.getScale();
  109. const readerLayout = config.readerLayout;
  110. const viewportElement = this.Reveal.getViewportElement();
  111. const viewportHeight = viewportElement.offsetHeight;
  112. const compactHeight = slideSize.height * scale;
  113. const pageHeight = readerLayout === 'full' ? viewportHeight : compactHeight;
  114. // The height that needs to be scrolled between scroll triggers
  115. const scrollTriggerHeight = viewportHeight / 2;
  116. viewportElement.style.setProperty( '--page-height', pageHeight + 'px' );
  117. viewportElement.style.scrollSnapType = typeof config.readerScrollSnap === 'string' ?
  118. `y ${config.readerScrollSnap}` : '';
  119. const pageElements = Array.from( this.Reveal.getRevealElement().querySelectorAll( '.reader-page' ) );
  120. this.pages = pageElements.map( pageElement => {
  121. const page = {
  122. pageElement: pageElement,
  123. stickyElement: pageElement.querySelector( '.reader-page-sticky' ),
  124. slideElement: pageElement.querySelector( 'section' ),
  125. backgroundElement: pageElement.querySelector( '.slide-background' ),
  126. top: pageElement.offsetTop,
  127. scrollTriggers: []
  128. };
  129. page.slideElement.style.width = slideSize.width + 'px';
  130. page.slideElement.style.height = config.center === true ? '' : slideSize.height + 'px';
  131. // Each fragment 'group' is an array containing one or more
  132. // fragments. Multiple fragments that appear at the same time
  133. // are part of the same group.
  134. page.fragments = this.Reveal.fragments.sort( pageElement.querySelectorAll( '.fragment:not(.disabled)' ) );
  135. page.fragmentGroups = this.Reveal.fragments.sort( pageElement.querySelectorAll( '.fragment' ), true );
  136. // Create scroll triggers that show/hide fragments
  137. if( page.fragmentGroups.length ) {
  138. const segmentSize = 1 / ( page.fragmentGroups.length + 1 );
  139. page.scrollTriggers.push(
  140. // Trigger for the initial state with no fragments visible
  141. { range: [ 0, segmentSize ], fragmentIndex: -1 },
  142. // Triggers for each fragment group
  143. ...page.fragmentGroups.map( ( fragments, i ) => ({
  144. range: [ segmentSize * ( i + 1 ), segmentSize * ( i + 2 ) ],
  145. fragmentIndex: i
  146. }))
  147. );
  148. }
  149. // Add scroll padding based on how many scroll triggers we have
  150. page.scrollPadding = scrollTriggerHeight * page.scrollTriggers.length;
  151. // In the compact layout, only slides with scroll triggers cover the
  152. // full viewport height. This helps avoid empty gaps before or after
  153. // a sticky slide.
  154. if( readerLayout === 'compact' && page.scrollTriggers.length > 0 ) {
  155. page.pageHeight = viewportHeight;
  156. page.pageElement.style.setProperty( '--page-height', viewportHeight + 'px' );
  157. }
  158. else {
  159. page.pageHeight = pageHeight;
  160. page.pageElement.style.removeProperty( '--page-height' );
  161. }
  162. page.pageElement.style.scrollSnapAlign = page.pageHeight < viewportHeight ? 'center' : 'start';
  163. // This variable is used to pad the height of our page in CSS
  164. page.pageElement.style.setProperty( '--page-scroll-padding', page.scrollPadding + 'px' );
  165. // The total height including scrollable space
  166. page.totalHeight = page.pageHeight + page.scrollPadding;
  167. page.bottom = page.top + page.totalHeight;
  168. // If this is a sticky page, stick it to the vertical center
  169. if( page.scrollTriggers.length > 0 ) {
  170. page.stickyElement.style.position = 'sticky';
  171. page.stickyElement.style.top = Math.max( ( viewportHeight - page.pageHeight ) / 2, 0 ) + 'px';
  172. // Make this page freeze at the vertical center of the viewport
  173. page.top -= ( viewportHeight - page.pageHeight ) / 2;
  174. }
  175. else {
  176. page.stickyElement.style.position = 'relative';
  177. }
  178. return page;
  179. } );
  180. }
  181. layout() {
  182. this.sync();
  183. this.onScroll();
  184. }
  185. scrollToSlide( slideElement ) {
  186. if( !this.active ) {
  187. this.activatedCallbacks.push( () => this.scrollToSlide( slideElement ) );
  188. }
  189. else {
  190. slideElement.parentNode.scrollIntoView();
  191. }
  192. }
  193. onScroll() {
  194. const viewportElement = this.Reveal.getViewportElement();
  195. const viewportHeight = viewportElement.offsetHeight;
  196. const scrollTop = viewportElement.scrollTop;
  197. // Find the page closest to the center of the viewport, this
  198. // is the page we want to focus and activate
  199. const activePage = this.pages.reduce( ( closestPage, page ) => {
  200. const distance = Math.abs( ( page.top + page.pageHeight / 2 ) - scrollTop - viewportHeight / 2 );
  201. return distance < closestPage.distance ? { page, distance } : closestPage;
  202. }, { page: this.pages[0], distance: Infinity } ).page;
  203. this.pages.forEach( ( page, pageIndex ) => {
  204. const isWithinPreloadRange = scrollTop + viewportHeight >= page.top - viewportHeight && scrollTop < page.top + page.bottom + viewportHeight;
  205. const isPartiallyVisible = scrollTop + viewportHeight >= page.top && scrollTop < page.top + page.bottom;
  206. // Preload content when it appears within range
  207. if( isWithinPreloadRange ) {
  208. if( !page.preloaded ) {
  209. page.preloaded = true;
  210. this.Reveal.slideContent.load( page.slideElement );
  211. }
  212. }
  213. else if( page.preloaded ) {
  214. page.preloaded = false;
  215. this.Reveal.slideContent.unload( page.slideElement );
  216. }
  217. // Activate the current page — there can only be one active page at
  218. // a time.
  219. if( page === activePage ) {
  220. if( !page.active ) {
  221. page.active = true;
  222. page.pageElement.classList.add( 'present' );
  223. page.slideElement.classList.add( 'present' );
  224. this.Reveal.setCurrentReaderPage( pageIndex, page.pageElement );
  225. this.Reveal.slideContent.startEmbeddedContent( page.slideElement );
  226. if( page.backgroundElement ) {
  227. this.Reveal.slideContent.startEmbeddedContent( page.backgroundElement );
  228. }
  229. }
  230. }
  231. // Deactivate previously active pages
  232. else if( page.active ) {
  233. page.active = false;
  234. page.pageElement.classList.remove( 'present' );
  235. page.slideElement.classList.remove( 'present' );
  236. this.Reveal.slideContent.stopEmbeddedContent( page.slideElement );
  237. if( page.backgroundElement ) {
  238. this.Reveal.slideContent.stopEmbeddedContent( page.backgroundElement );
  239. }
  240. }
  241. // Handle scroll freezing and triggers for slides in view
  242. if( isPartiallyVisible && page.totalHeight > page.pageHeight ) {
  243. let scrollProgress = ( scrollTop - page.top ) / page.scrollPadding;
  244. scrollProgress = Math.max( Math.min( scrollProgress, 1 ), 0 );
  245. page.scrollTriggers.forEach( trigger => {
  246. if( scrollProgress >= trigger.range[0] && scrollProgress < trigger.range[1] ) {
  247. if( !trigger.active ) {
  248. trigger.active = true;
  249. this.Reveal.fragments.update( trigger.fragmentIndex, page.fragments, page.slideElement );
  250. }
  251. }
  252. else {
  253. trigger.active = false;
  254. }
  255. } );
  256. }
  257. } );
  258. }
  259. }