reader.js 11 KB

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