Procházet zdrojové kódy

Merge pull request #3482 from hakimel/feature/reader-mode

Add reader mode
Hakim El Hattab před 1 rokem
rodič
revize
c80b685a88
45 změnil soubory, kde provedl 1584 přidání a 215 odebrání
  1. 1 1
      css/print/pdf.scss
  2. 220 9
      css/reveal.scss
  3. 3 0
      css/theme/source/beige.scss
  4. 3 0
      css/theme/source/serif.scss
  5. 3 0
      css/theme/source/simple.scss
  6. 3 0
      css/theme/source/sky.scss
  7. 3 0
      css/theme/source/solarized.scss
  8. 3 0
      css/theme/source/white-contrast.scss
  9. 3 0
      css/theme/source/white.scss
  10. 2 0
      css/theme/template/exposer.scss
  11. 5 0
      css/theme/template/settings.scss
  12. 1 1
      dist/reveal.css
  13. 1 1
      dist/reveal.esm.js
  14. 0 0
      dist/reveal.esm.js.map
  15. 1 1
      dist/reveal.js
  16. 0 0
      dist/reveal.js.map
  17. 2 0
      dist/theme/beige.css
  18. 2 0
      dist/theme/black-contrast.css
  19. 2 0
      dist/theme/black.css
  20. 2 0
      dist/theme/blood.css
  21. 2 0
      dist/theme/dracula.css
  22. 2 0
      dist/theme/league.css
  23. 2 0
      dist/theme/moon.css
  24. 2 0
      dist/theme/night.css
  25. 2 0
      dist/theme/serif.css
  26. 2 0
      dist/theme/simple.css
  27. 2 0
      dist/theme/sky.css
  28. 2 0
      dist/theme/solarized.css
  29. 2 0
      dist/theme/white-contrast.css
  30. 2 0
      dist/theme/white.css
  31. 122 0
      examples/reader-mode.html
  32. 31 1
      js/config.js
  33. 42 11
      js/controllers/backgrounds.js
  34. 5 6
      js/controllers/fragments.js
  35. 2 2
      js/controllers/keyboard.js
  36. 1 1
      js/controllers/location.js
  37. 10 4
      js/controllers/notes.js
  38. 1 1
      js/controllers/overview.js
  39. 7 7
      js/controllers/print.js
  40. 596 0
      js/controllers/reader.js
  41. 4 0
      js/controllers/slidecontent.js
  42. 1 1
      js/controllers/slidenumber.js
  43. 203 53
      js/reveal.js
  44. 164 115
      package-lock.json
  45. 115 0
      test/test-reader-mode.html

+ 1 - 1
css/print/pdf.scss

@@ -5,7 +5,7 @@
  * https://revealjs.com/pdf-export/
  */
 
-html.print-pdf {
+html.reveal-print {
 	* {
 		-webkit-print-color-adjust: exact;
 	}

+ 220 - 9
css/reveal.scss

@@ -19,6 +19,7 @@ html.reveal-full-page {
 	height: 100%;
 	height: 100vh;
 	height: calc( var(--vh, 1vh) * 100 );
+	height: 100svh;
 	overflow: hidden;
 }
 
@@ -31,6 +32,8 @@ html.reveal-full-page {
 
 	background-color: #fff;
 	color: #000;
+
+	--r-controls-spacing: 12px;
 }
 
 // Force the presentation to cover the full viewport when we
@@ -271,13 +274,11 @@ $controlsArrowAngleActive: 36deg;
 }
 
 .reveal .controls {
-	$spacing: 12px;
-
 	display: none;
 	position: absolute;
 	top: auto;
-	bottom: $spacing;
-	right: $spacing;
+	bottom: var(--r-controls-spacing);
+	right: var(--r-controls-spacing);
 	left: auto;
 	z-index: 11;
 	color: #000;
@@ -509,7 +510,9 @@ $controlsArrowAngleActive: 36deg;
 // Edge aligned controls layout
 @media screen and (min-width: 500px) {
 
-	$spacing: 0.8em;
+	.reveal-viewport {
+		--r-controls-spacing: 0.8em;
+	}
 
 	.reveal .controls[data-controls-layout="edges"] {
 		& {
@@ -529,24 +532,24 @@ $controlsArrowAngleActive: 36deg;
 
 		.navigate-left {
 			top: 50%;
-			left: $spacing;
+			left: var(--r-controls-spacing);
 			margin-top: -$controlArrowSize*0.5;
 		}
 
 		.navigate-right {
 			top: 50%;
-			right: $spacing;
+			right: var(--r-controls-spacing);
 			margin-top: -$controlArrowSize*0.5;
 		}
 
 		.navigate-up {
-			top: $spacing;
+			top: var(--r-controls-spacing);
 			left: 50%;
 			margin-left: -$controlArrowSize*0.5;
 		}
 
 		.navigate-down {
-			bottom: $spacing - $controlArrowSpacing + 0.3em;
+			bottom: calc(var(--r-controls-spacing) - #{$controlArrowSpacing} + 0.3em);
 			left: 50%;
 			margin-left: -$controlArrowSize*0.5;
 		}
@@ -1864,6 +1867,214 @@ $notesWidthPercent: 25%;
 }
 
 
+/*********************************************
+ * READER MODE
+ *********************************************/
+.reveal-viewport.loading-scroll-mode {
+	visibility: hidden;
+}
+
+.reveal-viewport.reveal-reader {
+	& {
+		margin: 0 auto !important;
+		overflow: auto;
+		overflow-x: hidden;
+		overflow-y: auto;
+		z-index: 1;
+
+		--r-reader-progress-width: 8px;
+		--r-reader-progress-trigger-size: 6px;
+	}
+
+	@media screen and (max-width: 500px) {
+		--r-reader-progress-width: 3px;
+		--r-reader-progress-trigger-size: 3px;
+	}
+
+	.reveal .controls,
+	.reveal .progress,
+	.reveal .playback,
+	.reveal .backgrounds,
+	.reveal .slide-number {
+		display: none !important;
+	}
+
+	.reveal {
+		overflow: visible;
+		touch-action: manipulation;
+	}
+	.reveal .slides {
+		position: static;
+		pointer-events: initial;
+
+		left: auto;
+		top: auto;
+		width: 100% !important;
+		margin: 0 !important;
+		padding: 0 !important;
+
+		overflow: visible;
+		display: block;
+
+		perspective: none;
+		perspective-origin: 50% 50%;
+	}
+
+	.reveal .slides .reader-page {
+		position: relative;
+		width: 100%;
+		height: calc(var(--page-height) + var(--page-scroll-padding));
+		z-index: 1;
+		overflow: visible;
+	}
+
+	.reveal .slides .reader-page-sticky {
+		position: sticky;
+		height: var(--page-height);
+		top: 0px;
+	}
+
+	.reveal .slides .reader-page-content {
+		position: absolute;
+		top: 0;
+		left: 0;
+		width: 100%;
+		height: 100%;
+	}
+
+	.reveal .slides .reader-page section {
+		visibility: visible !important;
+		display: block !important;
+		position: absolute !important;
+		top: 50% !important;
+		left: 50% !important;
+		opacity: 1 !important;
+		transform: scale(var(--slide-scale)) translate(-50%, -50%) !important;
+		transform-style: flat !important;
+		transform-origin: 0 0 !important;
+	}
+
+	.reveal .slide-background {
+		display: block !important;
+		position: absolute;
+		top: 0;
+		left: 0;
+		width: 100%;
+		height: 100%;
+		z-index: auto !important;
+		visibility: visible;
+		opacity: 1;
+		touch-action: manipulation;
+	}
+}
+
+.reveal-viewport.reveal-reader[data-reader-scroll-bar="true"]::-webkit-scrollbar,
+.reveal-viewport.reveal-reader[data-reader-scroll-bar="auto"]::-webkit-scrollbar {
+  display: none;
+}
+
+.reveal.has-dark-background,
+.reveal-viewport.has-dark-background {
+	--r-overlay-element-bg-color: 240, 240, 240;
+	--r-overlay-element-fg-color: 0, 0, 0;
+}
+.reveal.has-light-background,
+.reveal-viewport.has-light-background {
+	--r-overlay-element-bg-color: 0, 0, 0;
+	--r-overlay-element-fg-color: 240, 240, 240;
+}
+
+.reveal-viewport.reveal-reader .reader-progress {
+	position: sticky;
+	top: 50%;
+	z-index: 20;
+	opacity: 0;
+	transition: all 0.3s ease;
+
+	&.visible,
+	&:hover {
+		opacity: 1;
+	}
+
+	.reader-progress-inner {
+		position: absolute;
+		width: var(--r-reader-progress-width);
+		height: calc(var(--viewport-height) - var(--r-controls-spacing) * 2);
+		right: var(--r-controls-spacing);
+		top: 0;
+		transform: translateY(-50%);
+		border-radius: var(--r-reader-progress-width);
+		z-index: 10;
+	}
+
+	// Hit area
+	.reader-progress-inner:after {
+		content: '';
+		position: absolute;
+		width: 200%;
+		height: 100%;
+		top: 0;
+		left: -50%;
+		background: rgba( 0, 0, 0, 0 );
+		z-index: -1;
+	}
+
+	.reader-progress-playhead {
+		position: absolute;
+		width: var(--r-reader-progress-width);
+		height: var(--r-reader-progress-width);
+		top: 0;
+		left: 0;
+		border-radius: var(--r-reader-progress-width);
+		background-color: rgba(var(--r-overlay-element-bg-color), 1);
+		z-index: 11;
+		transition: background-color 0.2s ease, height 0.4s ease;
+	}
+
+	.reader-progress-slide {
+		position: absolute;
+		width: 100%;
+		background-color: rgba(var(--r-overlay-element-bg-color), 0.2);
+		box-shadow: 0 0 0px 1px rgba(var(--r-overlay-element-fg-color), 0.1);
+		border-radius: var(--r-reader-progress-width);
+		transition: background-color 0.2s ease;
+	}
+
+	.reader-progress-slide.active {
+		background-color: rgba(var(--r-overlay-element-bg-color), 1);
+	}
+
+	.reader-progress-trigger {
+		position: absolute;
+		width: 100%;
+		transition: background-color 0.2s ease;
+	}
+
+	.reader-progress-slide.active.has-triggers {
+		background-color: rgba(var(--r-overlay-element-bg-color), 0.4);
+		z-index: 10;
+	}
+
+	.reader-progress-slide.active .reader-progress-trigger:after {
+		content: '';
+		position: absolute;
+		width: var(--r-reader-progress-trigger-size);
+		height: var(--r-reader-progress-trigger-size);
+		border-radius: 20px;
+		bottom: 0;
+		left: 50%;
+		transform: translate(-50%, 0);
+		background-color: rgba(var(--r-overlay-element-bg-color), 0.8);
+		transition: transform 0.2s ease;
+	}
+
+	.reader-progress-slide.active .reader-progress-trigger.active:after {
+		transform: translate(calc( var(--r-reader-progress-width) * -2), 0);
+		background-color: rgba(var(--r-overlay-element-bg-color), 1);
+	}
+}
+
+
 /*********************************************
  * PRINT STYLES
  *********************************************/

+ 3 - 0
css/theme/source/beige.scss

@@ -27,6 +27,9 @@ $linkColorHover: lighten( $linkColor, 20% );
 $selectionBackgroundColor: rgba(79, 64, 28, 0.99);
 $heading1TextShadow: 0 1px 0 #ccc, 0 2px 0 #c9c9c9, 0 3px 0 #bbb, 0 4px 0 #b9b9b9, 0 5px 0 #aaa, 0 6px 1px rgba(0,0,0,.1), 0 0 5px rgba(0,0,0,.1), 0 1px 3px rgba(0,0,0,.3), 0 3px 5px rgba(0,0,0,.2), 0 5px 10px rgba(0,0,0,.25), 0 20px 20px rgba(0,0,0,.15);
 
+$overlayElementBgColor: 0, 0, 0;
+$overlayElementFgColor: 240, 240, 240;
+
 // Background generator
 @mixin bodyBackground() {
 	@include radial-gradient( rgba(247,242,211,1), rgba(255,255,255,1) );

+ 3 - 0
css/theme/source/serif.scss

@@ -25,6 +25,9 @@ $linkColor: #51483D;
 $linkColorHover: lighten( $linkColor, 20% );
 $selectionBackgroundColor: #26351C;
 
+$overlayElementBgColor: 0, 0, 0;
+$overlayElementFgColor: 240, 240, 240;
+
 .reveal a {
   line-height: 1.3em;
 }

+ 3 - 0
css/theme/source/simple.scss

@@ -31,6 +31,9 @@ $linkColor: #00008B;
 $linkColorHover: lighten( $linkColor, 20% );
 $selectionBackgroundColor: rgba(0, 0, 0, 0.99);
 
+$overlayElementBgColor: 0, 0, 0;
+$overlayElementFgColor: 240, 240, 240;
+
 // Change text colors against dark slide backgrounds
 @include dark-bg-text-color(#fff);
 

+ 3 - 0
css/theme/source/sky.scss

@@ -29,6 +29,9 @@ $linkColor: #3b759e;
 $linkColorHover: lighten( $linkColor, 20% );
 $selectionBackgroundColor: #134674;
 
+$overlayElementBgColor: 0, 0, 0;
+$overlayElementFgColor: 240, 240, 240;
+
 // Fix links so they are not cut off
 .reveal a {
 	line-height: 1.3em;

+ 3 - 0
css/theme/source/solarized.scss

@@ -51,6 +51,9 @@ $linkColor: $blue;
 $linkColorHover: lighten( $linkColor, 20% );
 $selectionBackgroundColor: $magenta;
 
+$overlayElementBgColor: 0, 0, 0;
+$overlayElementFgColor: 240, 240, 240;
+
 // Background generator
 // @mixin bodyBackground() {
 // 	@include radial-gradient( rgba($base3,1), rgba(lighten($base3, 20%),1) );

+ 3 - 0
css/theme/source/white-contrast.scss

@@ -40,6 +40,9 @@ $heading2Size: 1.6em;
 $heading3Size: 1.3em;
 $heading4Size: 1.0em;
 
+$overlayElementBgColor: 0, 0, 0;
+$overlayElementFgColor: 240, 240, 240;
+
 // Change text colors against dark slide backgrounds
 @include dark-bg-text-color(#fff);
 

+ 3 - 0
css/theme/source/white.scss

@@ -37,6 +37,9 @@ $heading2Size: 1.6em;
 $heading3Size: 1.3em;
 $heading4Size: 1.0em;
 
+$overlayElementBgColor: 0, 0, 0;
+$overlayElementFgColor: 240, 240, 240;
+
 // Change text colors against dark slide backgrounds
 @include dark-bg-text-color(#fff);
 

+ 2 - 0
css/theme/template/exposer.scss

@@ -25,4 +25,6 @@
   --r-link-color-hover: #{$linkColorHover};
   --r-selection-background-color: #{$selectionBackgroundColor};
   --r-selection-color: #{$selectionColor};
+  --r-overlay-element-bg-color: #{$overlayElementBgColor};
+  --r-overlay-element-fg-color: #{$overlayElementFgColor};
 }

+ 5 - 0
css/theme/template/settings.scss

@@ -38,6 +38,11 @@ $linkColorHover: lighten( $linkColor, 20% );
 $selectionBackgroundColor: #FF5E99;
 $selectionColor: #fff;
 
+// Colors used for UI elements that are overlaid on top of
+// the presentation
+$overlayElementBgColor: 240, 240, 240;
+$overlayElementFgColor: 0, 0, 0;
+
 // Generates the presentation background, can be overridden
 // to return a background image or gradient
 @mixin bodyBackground() {

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 1
dist/reveal.css


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 1
dist/reveal.esm.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
dist/reveal.esm.js.map


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 1
dist/reveal.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
dist/reveal.js.map


+ 2 - 0
dist/theme/beige.css

@@ -37,6 +37,8 @@ section.has-dark-background, section.has-dark-background h1, section.has-dark-ba
   --r-link-color-hover: #c0a86e;
   --r-selection-background-color: rgba(79, 64, 28, 0.99);
   --r-selection-color: #fff;
+  --r-overlay-element-bg-color: 0, 0, 0;
+  --r-overlay-element-fg-color: 240, 240, 240;
 }
 
 .reveal-viewport {

+ 2 - 0
dist/theme/black-contrast.css

@@ -39,6 +39,8 @@ section.has-light-background, section.has-light-background h1, section.has-light
   --r-link-color-hover: #8dcffc;
   --r-selection-background-color: #bee4fd;
   --r-selection-color: #fff;
+  --r-overlay-element-bg-color: 240, 240, 240;
+  --r-overlay-element-fg-color: 0, 0, 0;
 }
 
 .reveal-viewport {

+ 2 - 0
dist/theme/black.css

@@ -36,6 +36,8 @@ section.has-light-background, section.has-light-background h1, section.has-light
   --r-link-color-hover: #8dcffc;
   --r-selection-background-color: rgba(66, 175, 250, 0.75);
   --r-selection-color: #fff;
+  --r-overlay-element-bg-color: 240, 240, 240;
+  --r-overlay-element-fg-color: 0, 0, 0;
 }
 
 .reveal-viewport {

+ 2 - 0
dist/theme/blood.css

@@ -42,6 +42,8 @@ section.has-light-background, section.has-light-background h1, section.has-light
   --r-link-color-hover: #dd5566;
   --r-selection-background-color: #a23;
   --r-selection-color: #fff;
+  --r-overlay-element-bg-color: 240, 240, 240;
+  --r-overlay-element-fg-color: 0, 0, 0;
 }
 
 .reveal-viewport {

+ 2 - 0
dist/theme/dracula.css

@@ -43,6 +43,8 @@ section.has-light-background, section.has-light-background h1, section.has-light
   --r-link-color-hover: #8BE9FD;
   --r-selection-background-color: #44475A;
   --r-selection-color: #fff;
+  --r-overlay-element-bg-color: 240, 240, 240;
+  --r-overlay-element-fg-color: 0, 0, 0;
 }
 
 .reveal-viewport {

+ 2 - 0
dist/theme/league.css

@@ -39,6 +39,8 @@ section.has-light-background, section.has-light-background h1, section.has-light
   --r-link-color-hover: #71e9f4;
   --r-selection-background-color: #FF5E99;
   --r-selection-color: #fff;
+  --r-overlay-element-bg-color: 240, 240, 240;
+  --r-overlay-element-fg-color: 0, 0, 0;
 }
 
 .reveal-viewport {

+ 2 - 0
dist/theme/moon.css

@@ -44,6 +44,8 @@ section.has-light-background, section.has-light-background h1, section.has-light
   --r-link-color-hover: #78b9e6;
   --r-selection-background-color: #d33682;
   --r-selection-color: #fff;
+  --r-overlay-element-bg-color: 240, 240, 240;
+  --r-overlay-element-fg-color: 0, 0, 0;
 }
 
 .reveal-viewport {

+ 2 - 0
dist/theme/night.css

@@ -37,6 +37,8 @@ section.has-light-background, section.has-light-background h1, section.has-light
   --r-link-color-hover: #f3d7ac;
   --r-selection-background-color: #e7ad52;
   --r-selection-color: #fff;
+  --r-overlay-element-bg-color: 240, 240, 240;
+  --r-overlay-element-fg-color: 0, 0, 0;
 }
 
 .reveal-viewport {

+ 2 - 0
dist/theme/serif.css

@@ -40,6 +40,8 @@ section.has-dark-background, section.has-dark-background h1, section.has-dark-ba
   --r-link-color-hover: #8b7c69;
   --r-selection-background-color: #26351C;
   --r-selection-color: #fff;
+  --r-overlay-element-bg-color: 0, 0, 0;
+  --r-overlay-element-fg-color: 240, 240, 240;
 }
 
 .reveal-viewport {

+ 2 - 0
dist/theme/simple.css

@@ -39,6 +39,8 @@ section.has-dark-background, section.has-dark-background h1, section.has-dark-ba
   --r-link-color-hover: #0000f1;
   --r-selection-background-color: rgba(0, 0, 0, 0.99);
   --r-selection-color: #fff;
+  --r-overlay-element-bg-color: 0, 0, 0;
+  --r-overlay-element-fg-color: 240, 240, 240;
 }
 
 .reveal-viewport {

+ 2 - 0
dist/theme/sky.css

@@ -41,6 +41,8 @@ section.has-dark-background, section.has-dark-background h1, section.has-dark-ba
   --r-link-color-hover: #74a7cb;
   --r-selection-background-color: #134674;
   --r-selection-color: #fff;
+  --r-overlay-element-bg-color: 0, 0, 0;
+  --r-overlay-element-fg-color: 240, 240, 240;
 }
 
 .reveal-viewport {

+ 2 - 0
dist/theme/solarized.css

@@ -40,6 +40,8 @@ html * {
   --r-link-color-hover: #78b9e6;
   --r-selection-background-color: #d33682;
   --r-selection-color: #fff;
+  --r-overlay-element-bg-color: 0, 0, 0;
+  --r-overlay-element-fg-color: 240, 240, 240;
 }
 
 .reveal-viewport {

+ 2 - 0
dist/theme/white-contrast.css

@@ -39,6 +39,8 @@ section.has-dark-background, section.has-dark-background h1, section.has-dark-ba
   --r-link-color-hover: #6ca0e8;
   --r-selection-background-color: #98bdef;
   --r-selection-color: #fff;
+  --r-overlay-element-bg-color: 0, 0, 0;
+  --r-overlay-element-fg-color: 240, 240, 240;
 }
 
 .reveal-viewport {

+ 2 - 0
dist/theme/white.css

@@ -36,6 +36,8 @@ section.has-dark-background, section.has-dark-background h1, section.has-dark-ba
   --r-link-color-hover: #6ca0e8;
   --r-selection-background-color: #98bdef;
   --r-selection-color: #fff;
+  --r-overlay-element-bg-color: 0, 0, 0;
+  --r-overlay-element-fg-color: 240, 240, 240;
 }
 
 .reveal-viewport {

+ 122 - 0
examples/reader-mode.html

@@ -0,0 +1,122 @@
+<!doctype html>
+<html lang="en">
+
+	<head>
+		<meta charset="utf-8">
+
+		<title>reveal.js - Reader Mode</title>
+
+		<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
+
+		<link rel="stylesheet" href="../dist/reset.css">
+		<link rel="stylesheet" href="../dist/reveal.css">
+		<link rel="stylesheet" href="../dist/theme/black.css" id="theme">
+    <link rel="stylesheet" href="../plugin/highlight/monokai.css">
+	</head>
+
+	<body>
+
+		<div class="reveal">
+
+			<div class="slides">
+
+				<section><h1>Reader Mode</h1></section>
+				<section data-auto-animate data-auto-animate-easing="cubic-bezier(0.770, 0.000, 0.175, 1.000)">
+					<h2>Auto-Animate</h2>
+					<p>Automatically animate matching elements across slides with <a href="https://revealjs.com/auto-animate/">Auto-Animate</a>.</p>
+					<div class="r-hstack justify-center">
+						<div data-id="box1" style="background: #999; width: 50px; height: 50px; margin: 10px; border-radius: 5px;"></div>
+						<div data-id="box2" style="background: #999; width: 50px; height: 50px; margin: 10px; border-radius: 5px;"></div>
+						<div data-id="box3" style="background: #999; width: 50px; height: 50px; margin: 10px; border-radius: 5px;"></div>
+					</div>
+				</section>
+				<section data-auto-animate data-auto-animate-easing="cubic-bezier(0.770, 0.000, 0.175, 1.000)">
+					<div class="r-hstack justify-center">
+						<div data-id="box1" data-auto-animate-delay="0" style="background: cyan; width: 150px; height: 100px; margin: 10px;"></div>
+						<div data-id="box2" data-auto-animate-delay="0.1" style="background: magenta; width: 150px; height: 100px; margin: 10px;"></div>
+						<div data-id="box3" data-auto-animate-delay="0.2" style="background: yellow; width: 150px; height: 100px; margin: 10px;"></div>
+					</div>
+					<h2 style="margin-top: 20px;">Auto-Animate</h2>
+				</section>
+				<section data-auto-animate data-auto-animate-easing="cubic-bezier(0.770, 0.000, 0.175, 1.000)">
+					<div class="r-stack">
+						<div data-id="box1" style="background: cyan; width: 300px; height: 300px; border-radius: 200px;"></div>
+						<div data-id="box2" style="background: magenta; width: 200px; height: 200px; border-radius: 200px;"></div>
+						<div data-id="box3" style="background: yellow; width: 100px; height: 100px; border-radius: 200px;"></div>
+					</div>
+					<h2 style="margin-top: 20px;">Auto-Animate</h2>
+				</section>
+				<section data-background="indigo">
+					<h2>Scroll triggers</h2>
+					<ul>
+						<li class="fragment">Fragment one</li>
+						<li class="fragment">Fragment two</li>
+						<li class="fragment">Fragment three</li>
+					</ul>
+				</section>
+				<section data-background-gradient="linear-gradient(to bottom, #283b95, #17b2c3)" id="gradient-bg">
+					<h2>Gradient Backgrounds</h2>
+				</section>
+				<section data-auto-animate>
+					<h2 data-id="code-title">Scroll triggered code highlights</h2>
+					<pre data-id="code-animation"><code class="hljs javascript" data-trim data-line-numbers="|4,8-11|17|22-24"><script type="text/template">
+						import React, { useState } from 'react';
+
+						function Example() {
+						  const [count, setCount] = useState(0);
+
+						  return (
+						    <div>
+						      <p>You clicked {count} times</p>
+						      <button onClick={() => setCount(count + 1)}>
+						        Click me
+						      </button>
+						    </div>
+						  );
+						}
+
+						function SecondExample() {
+						  const [count, setCount] = useState(0);
+
+						  return (
+						    <div>
+						      <p>You clicked {count} times</p>
+						      <button onClick={() => setCount(count + 1)}>
+						        Click me
+						      </button>
+						    </div>
+						  );
+						}
+					</script></code></pre>
+				</section>
+				<section class="stack">
+          <section data-background="https://static.slid.es/reveal/image-placeholder.png" id="image-bg">
+            <h2>Image Backgrounds</h2>
+            <pre><code class="hljs html">&lt;section data-background="image.png"&gt;</code></pre>
+          </section>
+          <section data-background-video="https://s3.amazonaws.com/static.slid.es/site/homepage/v1/homepage-video-editor.mp4,https://s3.amazonaws.com/static.slid.es/site/homepage/v1/homepage-video-editor.webm">
+            <h2>Video background</h2>
+          </section>
+        </section>
+				<section data-background-color="#fff"><h2>White background</h2></section>
+				<section><h2>The end</h2></section>
+
+			</div>
+
+		</div>
+
+		<script src="../dist/reveal.js"></script>
+		<script src="../plugin/notes/notes.js"></script>
+		<script src="../plugin/markdown/markdown.js"></script>
+		<script src="../plugin/highlight/highlight.js"></script>
+		<script>
+      Reveal.initialize({
+        view: 'reader',
+        hash: true,
+
+				plugins: [ RevealMarkdown, RevealHighlight, RevealNotes ]
+			});
+    </script>
+
+	</body>
+</html>

+ 31 - 1
js/config.js

@@ -256,6 +256,36 @@ export default {
 	parallaxBackgroundHorizontal: null,
 	parallaxBackgroundVertical: null,
 
+	// Can be used to initialize reveal.js in one of the following views:
+	// - print:   Render the presentation so that it can be printed to PDF
+	// - reader:  Show the presentation as a tall scrollable page with scroll
+	//            triggered animations
+	view: null,
+
+	// Adjusts the height of each slide in reader mode
+	// - full:       Each slide is as tall as the viewport
+	// - compact:    Slides are as small as possible, allowing multiple slides
+	//               to be visible in parallel on tall devices
+	readerLayout: 'full',
+
+	// Control how scroll snapping works in reader mode.
+	// - false:   	No snapping, scrolling is continuous
+	// - proximity:  Snap when close to a slide
+	// - mandatory:  Always snap to the closest slide
+	//
+	// Only applies to presentations in reader mode.
+	readerScrollSnap: 'mandatory',
+
+	// Enables and configure the reader mode scroll bar.
+	// - 'auto':    Show the scrollbar while scrolling, hide while idle
+	// - true:      Always show the scrollbar
+	// - false:     Never show the scrollbar
+	readerScrollbar: 'auto',
+
+	// Responsively activate the reader mode when we reach the specified
+	// width (in pixels)
+	readerActivationWidth: null,
+
 	// The maximum number of pages a single slide can expand onto when printing
 	// to PDF, unlimited by default
 	pdfMaxPagesPerSlide: Number.POSITIVE_INFINITY,
@@ -287,7 +317,7 @@ export default {
 	// Time before the cursor is hidden (in ms)
 	hideCursorTime: 5000,
 
-	// Should we automatmically sort and set indices for fragments
+	// Should we automatically sort and set indices for fragments
 	// at each sync? (See Reveal.sync)
 	sortFragmentsOnSync: true,
 

+ 42 - 11
js/controllers/backgrounds.js

@@ -190,10 +190,30 @@ export default class Backgrounds {
 		if( data.backgroundPosition ) contentElement.style.backgroundPosition = data.backgroundPosition;
 		if( data.backgroundOpacity ) contentElement.style.opacity = data.backgroundOpacity;
 
+		const contrastClass = this.getContrastClass( slide );
+
+		if( typeof contrastClass === 'string' ) {
+			slide.classList.add( contrastClass );
+		}
+
+	}
+
+	/**
+	 * Returns a class name that can be applied to a slide to indicate
+	 * if it has a light or dark background.
+	 *
+	 * @param {*} slide
+	 *
+	 * @returns {string|null}
+	 */
+	getContrastClass( slide ) {
+
+		const element = slide.slideBackgroundElement;
+
 		// If this slide has a background color, we add a class that
 		// signals if it is light or dark. If the slide has no background
 		// color, no class will be added
-		let contrastColor = data.backgroundColor;
+		let contrastColor = slide.getAttribute( 'data-background-color' );
 
 		// If no bg color was found, or it cannot be converted by colorToRgb, check the computed background
 		if( !contrastColor || !colorToRgb( contrastColor ) ) {
@@ -211,14 +231,32 @@ export default class Backgrounds {
 			// an element with no background
 			if( rgb && rgb.a !== 0 ) {
 				if( colorBrightness( contrastColor ) < 128 ) {
-					slide.classList.add( 'has-dark-background' );
+					return 'has-dark-background';
 				}
 				else {
-					slide.classList.add( 'has-light-background' );
+					return 'has-light-background';
 				}
 			}
 		}
 
+		return null;
+
+	}
+
+	/**
+	 * Bubble the 'has-light-background'/'has-dark-background' classes.
+	 */
+	bubbleSlideContrastClassToElement( slide, target ) {
+
+		[ 'has-light-background', 'has-dark-background' ].forEach( classToBubble => {
+			if( slide.classList.contains( classToBubble ) ) {
+				target.classList.add( classToBubble );
+			}
+			else {
+				target.classList.remove( classToBubble );
+			}
+		}, this );
+
 	}
 
 	/**
@@ -322,14 +360,7 @@ export default class Backgrounds {
 		// If there's a background brightness flag for this slide,
 		// bubble it to the .reveal container
 		if( currentSlide ) {
-			[ 'has-light-background', 'has-dark-background' ].forEach( classToBubble => {
-				if( currentSlide.classList.contains( classToBubble ) ) {
-					this.Reveal.getRevealElement().classList.add( classToBubble );
-				}
-				else {
-					this.Reveal.getRevealElement().classList.remove( classToBubble );
-				}
-			}, this );
+			this.bubbleSlideContrastClassToElement( currentSlide, this.Reveal.getRevealElement() );
 		}
 
 		// Allow the first background to apply without transition

+ 5 - 6
js/controllers/fragments.js

@@ -174,24 +174,23 @@ export default class Fragments {
 	 *
 	 * @return {{shown: array, hidden: array}}
 	 */
-	update( index, fragments ) {
+	update( index, fragments, slide = this.Reveal.getCurrentSlide() ) {
 
 		let changedFragments = {
 			shown: [],
 			hidden: []
 		};
 
-		let currentSlide = this.Reveal.getCurrentSlide();
-		if( currentSlide && this.Reveal.getConfig().fragments ) {
+		if( slide && this.Reveal.getConfig().fragments ) {
 
-			fragments = fragments || this.sort( currentSlide.querySelectorAll( '.fragment' ) );
+			fragments = fragments || this.sort( slide.querySelectorAll( '.fragment' ) );
 
 			if( fragments.length ) {
 
 				let maxIndex = 0;
 
 				if( typeof index !== 'number' ) {
-					let currentFragment = this.sort( currentSlide.querySelectorAll( '.fragment.visible' ) ).pop();
+					let currentFragment = this.sort( slide.querySelectorAll( '.fragment.visible' ) ).pop();
 					if( currentFragment ) {
 						index = parseInt( currentFragment.getAttribute( 'data-fragment-index' ) || 0, 10 );
 					}
@@ -252,7 +251,7 @@ export default class Fragments {
 				// the current fragment index.
 				index = typeof index === 'number' ? index : -1;
 				index = Math.max( Math.min( index, maxIndex ), -1 );
-				currentSlide.setAttribute( 'data-fragment', index );
+				slide.setAttribute( 'data-fragment', index );
 
 			}
 

+ 2 - 2
js/controllers/keyboard.js

@@ -360,13 +360,13 @@ export default class Keyboard {
 			}
 			// A
 			else if( keyCode === 65 ) {
-				if ( config.autoSlideStoppable ) {
+				if( config.autoSlideStoppable ) {
 					this.Reveal.toggleAutoSlide( autoSlideWasPaused );
 				}
 			}
 			// G
 			else if( keyCode === 71 ) {
-				if ( config.jumpToSlide ) {
+				if( config.jumpToSlide ) {
 					this.Reveal.toggleJumpToSlide();
 				}
 			}

+ 1 - 1
js/controllers/location.js

@@ -64,7 +64,7 @@ export default class Location {
 			try {
 				slide = document
 					.getElementById( decodeURIComponent( name ) )
-					.closest('.slides>section, .slides>section>section');
+					.closest('.slides section');
 			}
 			catch ( error ) { }
 

+ 10 - 4
js/controllers/notes.js

@@ -38,10 +38,12 @@ export default class Notes {
 	 */
 	update() {
 
-		if( this.Reveal.getConfig().showNotes && this.element && this.Reveal.getCurrentSlide() && !this.Reveal.print.isPrintingPDF() ) {
-
+		if( this.Reveal.getConfig().showNotes &&
+			this.element && this.Reveal.getCurrentSlide() &&
+			!this.Reveal.isReaderMode() &&
+			!this.Reveal.isPrinting()
+		) {
 			this.element.innerHTML = this.getSlideNotes() || '<span class="notes-placeholder">No notes on this slide.</span>';
-
 		}
 
 	}
@@ -54,7 +56,11 @@ export default class Notes {
 	 */
 	updateVisibility() {
 
-		if( this.Reveal.getConfig().showNotes && this.hasNotes() && !this.Reveal.print.isPrintingPDF() ) {
+		if( this.Reveal.getConfig().showNotes &&
+			this.hasNotes() &&
+			!this.Reveal.isReaderMode() &&
+			!this.Reveal.isPrinting()
+		) {
 			this.Reveal.getRevealElement().classList.add( 'show-notes' );
 		}
 		else {

+ 1 - 1
js/controllers/overview.js

@@ -24,7 +24,7 @@ export default class Overview {
 	activate() {
 
 		// Only proceed if enabled in config
-		if( this.Reveal.getConfig().overview && !this.isActive() ) {
+		if( this.Reveal.getConfig().overview && !this.Reveal.isReaderMode() && !this.isActive() ) {
 
 			this.active = true;
 

+ 7 - 7
js/controllers/print.js

@@ -16,7 +16,7 @@ export default class Print {
 	 * Configures the presentation for printing to a static
 	 * PDF.
 	 */
-	async setupPDF() {
+	async activate() {
 
 		const config = this.Reveal.getConfig();
 		const slides = queryAll( this.Reveal.getRevealElement(), SLIDES_SELECTOR )
@@ -42,11 +42,11 @@ export default class Print {
 		// Limit the size of certain elements to the dimensions of the slide
 		createStyleSheet( '.reveal section>img, .reveal section>video, .reveal section>iframe{max-width: '+ slideWidth +'px; max-height:'+ slideHeight +'px}' );
 
-		document.documentElement.classList.add( 'print-pdf' );
+		document.documentElement.classList.add( 'reveal-print', 'print-pdf' );
 		document.body.style.width = pageWidth + 'px';
 		document.body.style.height = pageHeight + 'px';
 
-		const viewportElement = document.querySelector( '.reveal-viewport' );
+		const viewportElement = this.Reveal.getViewportElement();
 		let presentationBackground;
 		if( viewportElement ) {
 			const viewportStyles = window.getComputedStyle( viewportElement );
@@ -226,12 +226,12 @@ export default class Print {
 	}
 
 	/**
-	 * Checks if this instance is being used to print a PDF.
+	 * Checks if the print mode is/should be activated.
 	 */
-	isPrintingPDF() {
+	isActive() {
 
-		return ( /print-pdf/gi ).test( window.location.search );
+		return this.Reveal.getConfig().view === 'print';
 
 	}
 
-}
+}

+ 596 - 0
js/controllers/reader.js

@@ -0,0 +1,596 @@
+import { HORIZONTAL_SLIDES_SELECTOR } from '../utils/constants.js'
+import { queryAll } from '../utils/util.js'
+
+const HIDE_SCROLLBAR_TIMEOUT = 500;
+const PROGRESS_SPACING = 4;
+const MIN_PROGRESS_SEGMENT_HEIGHT = 6;
+const MIN_PLAYHEAD_HEIGHT = 18;
+
+/**
+ * The reader mode lets you read a reveal.js presentation
+ * as a linear scrollable page.
+ */
+export default class Reader {
+
+	constructor( Reveal ) {
+
+		this.Reveal = Reveal;
+
+		this.active = false;
+		this.activatedCallbacks = [];
+
+		this.onScroll = this.onScroll.bind( this );
+
+	}
+
+	/**
+	 * Activates the reader mode. This rearranges the presentation DOM
+	 * by—among other things—wrapping each slide in a page element.
+	 */
+	activate() {
+
+		if( this.active ) return;
+
+		const state = this.Reveal.getState();
+
+		this.active = true;
+
+		this.slideHTMLBeforeActivation = this.Reveal.getSlidesElement().innerHTML;
+
+		const horizontalSlides = queryAll( this.Reveal.getRevealElement(), HORIZONTAL_SLIDES_SELECTOR );
+
+		this.viewportElement.classList.add( 'loading-scroll-mode', 'reveal-reader' );
+		this.viewportElement.addEventListener( 'scroll', this.onScroll, { passive: true } );
+
+		let presentationBackground;
+
+		const viewportStyles = window.getComputedStyle( this.viewportElement );
+		if( viewportStyles && viewportStyles.background ) {
+			presentationBackground = viewportStyles.background;
+		}
+
+		const pageElements = [];
+		const pageContainer = horizontalSlides[0].parentNode;
+
+		function createPage( slide, h, v ) {
+
+			// Wrap the slide in a page element and hide its overflow
+			// so that no page ever flows onto another
+			const page = document.createElement( 'div' );
+			page.className = 'reader-page';
+			pageElements.push( page );
+
+			// Copy the presentation-wide background to each page
+			if( presentationBackground ) {
+				page.style.background = presentationBackground;
+			}
+
+			const stickyContainer = document.createElement( 'div' );
+			stickyContainer.className = 'reader-page-sticky';
+			page.appendChild( stickyContainer );
+
+			const contentContainer = document.createElement( 'div' );
+			contentContainer.className = 'reader-page-content';
+			stickyContainer.appendChild( contentContainer );
+
+			contentContainer.appendChild( slide );
+
+			slide.classList.remove( 'past', 'future' );
+
+			if( typeof h === 'number' ) slide.setAttribute( 'data-index-h', h );
+			if( typeof v === 'number' ) slide.setAttribute( 'data-index-v', v );
+
+			if( slide.slideBackgroundElement ) {
+				slide.slideBackgroundElement.remove( 'past', 'future' );
+				contentContainer.insertBefore( slide.slideBackgroundElement, slide );
+			}
+
+		}
+
+		// Slide and slide background layout
+		horizontalSlides.forEach( ( horizontalSlide, h ) => {
+
+			if( this.Reveal.isVerticalStack( horizontalSlide ) ) {
+				horizontalSlide.querySelectorAll( 'section' ).forEach( ( verticalSlide, v ) => {
+					createPage( verticalSlide, h, v );
+				});
+			}
+			else {
+				createPage( horizontalSlide, h, 0 );
+			}
+
+		}, this );
+
+		this.createProgressBar();
+
+		// Remove leftover stacks
+		queryAll( this.Reveal.getRevealElement(), '.stack' ).forEach( stack => stack.remove() );
+
+		pageElements.forEach( page => pageContainer.appendChild( page ) );
+
+		// Re-run JS-based content layout after the slide is added to page DOM
+		this.Reveal.slideContent.layout( this.Reveal.getSlidesElement() );
+
+		this.Reveal.layout();
+		this.Reveal.setState( state );
+
+		this.viewportElement.classList.remove( 'loading-scroll-mode' );
+
+		this.activatedCallbacks.forEach( callback => callback() );
+		this.activatedCallbacks = [];
+
+	}
+
+	/**
+	 * Deactivates the reader mode and restores the standard slide-based
+	 * presentation.
+	 */
+	deactivate() {
+
+		if( !this.active ) return;
+
+		const state = this.Reveal.getState();
+
+		this.active = false;
+
+		this.viewportElement.removeEventListener( 'scroll', this.onScroll );
+		this.viewportElement.classList.remove( 'reveal-reader' );
+
+		this.removeProgressBar();
+
+		this.Reveal.getSlidesElement().innerHTML = this.slideHTMLBeforeActivation;
+		this.Reveal.sync();
+		this.Reveal.setState( state );
+
+	}
+
+	toggle( override ) {
+
+		if( typeof override === 'boolean' ) {
+			override ? this.activate() : this.deactivate();
+		}
+		else {
+			this.isActive() ? this.deactivate() : this.activate();
+		}
+
+	}
+
+	/**
+	 * Checks if the reader mode is currently active.
+	 */
+	isActive() {
+
+		return this.active;
+
+	}
+
+	/**
+	 * Retrieve a slide by its original h/v index (i.e. the indices the
+	 * slide had before being linearized).
+	 *
+	 * @param {number} h
+	 * @param {number} v
+	 * @returns {HTMLElement}
+	 */
+	getSlideByIndices( h, v ) {
+
+		const page = this.pages.find( page => page.indexh === h && page.indexv === v );
+
+		return page ? page.slideElement : null;
+
+	}
+
+	/**
+	 * Renders the progress bar component.
+	 */
+	createProgressBar() {
+
+		this.progressBar = document.createElement( 'div' );
+		this.progressBar.className = 'reader-progress';
+
+		this.progressBarInner = document.createElement( 'div' );
+		this.progressBarInner.className = 'reader-progress-inner';
+		this.progressBar.appendChild( this.progressBarInner );
+
+		this.progressBarPlayhead = document.createElement( 'div' );
+		this.progressBarPlayhead.className = 'reader-progress-playhead';
+		this.progressBarInner.appendChild( this.progressBarPlayhead );
+
+		this.viewportElement.insertBefore( this.progressBar, this.viewportElement.firstChild );
+
+		const handleDocumentMouseMove	= ( event ) => {
+
+			let progress = ( event.clientY - this.progressBarInner.getBoundingClientRect().top ) / this.progressBarHeight;
+
+			progress = Math.max( Math.min( progress, 1 ), 0 );
+
+			this.viewportElement.scrollTop = progress * ( this.viewportElement.scrollHeight - this.viewportElement.offsetHeight );
+
+		};
+
+		const handleDocumentMouseUp = ( event ) => {
+
+			this.draggingProgressBar = false;
+			this.showProgressBar();
+
+			document.removeEventListener( 'mousemove', handleDocumentMouseMove );
+			document.removeEventListener( 'mouseup', handleDocumentMouseUp );
+
+		};
+
+		const handleMouseDown = ( event ) => {
+
+			event.preventDefault();
+
+			this.draggingProgressBar = true;
+
+			document.addEventListener( 'mousemove', handleDocumentMouseMove );
+			document.addEventListener( 'mouseup', handleDocumentMouseUp );
+
+			handleDocumentMouseMove( event );
+
+		};
+
+		this.progressBarInner.addEventListener( 'mousedown', handleMouseDown );
+
+	}
+
+	removeProgressBar() {
+
+		if( this.progressBar ) {
+			this.progressBar.remove();
+			this.progressBar = null;
+		}
+
+	}
+
+	layout() {
+
+		if( this.isActive() ) {
+			this.syncPages();
+			this.onScroll();
+		}
+
+	}
+
+	/**
+	 * Updates our reader pages to match the latest configuration and
+	 * presentation size.
+	 */
+	syncPages() {
+
+		const config = this.Reveal.getConfig();
+
+		const slideSize = this.Reveal.getComputedSlideSize( window.innerWidth, window.innerHeight );
+		const scale = this.Reveal.getScale();
+		const readerLayout = config.readerLayout;
+
+		const viewportHeight = this.viewportElement.offsetHeight;
+		const compactHeight = slideSize.height * scale;
+		const pageHeight = readerLayout === 'full' ? viewportHeight : compactHeight;
+
+		// The height that needs to be scrolled between scroll triggers
+		const scrollTriggerHeight = viewportHeight / 2;
+
+		this.viewportElement.style.setProperty( '--page-height', pageHeight + 'px' );
+		this.viewportElement.style.scrollSnapType = typeof config.readerScrollSnap === 'string' ?
+												`y ${config.readerScrollSnap}` : '';
+
+		const pageElements = Array.from( this.Reveal.getRevealElement().querySelectorAll( '.reader-page' ) );
+
+		this.pages = pageElements.map( pageElement => {
+			const page = {
+				pageElement: pageElement,
+				stickyElement: pageElement.querySelector( '.reader-page-sticky' ),
+				slideElement: pageElement.querySelector( 'section' ),
+				backgroundElement: pageElement.querySelector( '.slide-background' ),
+				top: pageElement.offsetTop,
+				scrollTriggers: [],
+				scrollTriggerHeight
+			};
+
+			page.indexh = parseInt( page.slideElement.getAttribute( 'data-index-h' ), 10 );
+			page.indexv = parseInt( page.slideElement.getAttribute( 'data-index-v' ), 10 );
+
+			page.slideElement.style.width = slideSize.width + 'px';
+			page.slideElement.style.height = config.center === true ? '' : slideSize.height + 'px';
+
+			// Each fragment 'group' is an array containing one or more
+			// fragments. Multiple fragments that appear at the same time
+			// are part of the same group.
+			page.fragments = this.Reveal.fragments.sort( pageElement.querySelectorAll( '.fragment:not(.disabled)' ) );
+			page.fragmentGroups = this.Reveal.fragments.sort( pageElement.querySelectorAll( '.fragment' ), true );
+
+			// Create scroll triggers that show/hide fragments
+			if( page.fragmentGroups.length ) {
+				const segmentSize = 1 / page.fragmentGroups.length;
+				let segmentY = segmentSize / 4;
+				page.scrollTriggers.push(
+					// Trigger for the initial state with no fragments visible
+					{ range: [ 0, segmentY ], fragmentIndex: -1 },
+
+					// Triggers for each fragment group
+					...page.fragmentGroups.map( ( fragments, i ) => {
+						segmentY += segmentSize;
+							return {
+								range: [ segmentY - segmentSize, segmentY ],
+								fragmentIndex: i
+							};
+						}
+					)
+				);
+			}
+
+			// Add scroll padding based on how many scroll triggers we have
+			page.scrollPadding = scrollTriggerHeight * Math.max( page.scrollTriggers.length - 1, 0 );
+
+			// In the compact layout, only slides with scroll triggers cover the
+			// full viewport height. This helps avoid empty gaps before or after
+			// a sticky slide.
+			if( readerLayout === 'compact' && page.scrollTriggers.length > 0 ) {
+				page.pageHeight = viewportHeight;
+				page.pageElement.style.setProperty( '--page-height', viewportHeight + 'px' );
+			}
+			else {
+				page.pageHeight = pageHeight;
+				page.pageElement.style.removeProperty( '--page-height' );
+			}
+
+			page.pageElement.style.scrollSnapAlign = page.pageHeight < viewportHeight ? 'center' : 'start';
+
+			// This variable is used to pad the height of our page in CSS
+			page.pageElement.style.setProperty( '--page-scroll-padding', page.scrollPadding + 'px' );
+
+			// The total height including scrollable space
+			page.totalHeight = page.pageHeight + page.scrollPadding;
+
+			page.bottom = page.top + page.totalHeight;
+
+			// If this is a sticky page, stick it to the vertical center
+			if( page.scrollTriggers.length > 0 ) {
+				page.stickyElement.style.position = 'sticky';
+				page.stickyElement.style.top = Math.max( ( viewportHeight - page.pageHeight ) / 2, 0 ) + 'px';
+
+				// Make this page freeze at the vertical center of the viewport
+				page.top -= ( viewportHeight - page.pageHeight ) / 2;
+			}
+			else {
+				page.stickyElement.style.position = 'relative';
+			}
+
+			return page;
+		} );
+
+		this.viewportElement.setAttribute( 'data-reader-scroll-bar', config.readerScrollbar )
+
+		if( config.readerScrollbar ) {
+			// Create the progress bar if it doesn't already exist
+			if( !this.progressBar ) this.createProgressBar();
+
+			this.syncProgressBar();
+		}
+		else {
+			this.removeProgressBar();
+		}
+
+	}
+
+	/**
+	 * Rerenders progress bar segments so that they match the current
+	 * reveal.js config and size.
+	 */
+	syncProgressBar() {
+
+		this.progressBarInner.querySelectorAll( '.reader-progress-slide' ).forEach( slide => slide.remove() );
+
+		const viewportHeight = this.viewportElement.offsetHeight;
+		const scrollHeight = this.viewportElement.scrollHeight;
+
+		this.progressBarHeight = this.progressBarInner.offsetHeight;
+		this.playheadHeight = Math.max( this.pages[0].pageHeight / scrollHeight * this.progressBarHeight, MIN_PLAYHEAD_HEIGHT );
+		this.progressBarScrollableHeight = this.progressBarHeight - this.playheadHeight;
+
+		this.progressBarPlayhead.style.height = this.playheadHeight - PROGRESS_SPACING + 'px';
+
+		const progressSegmentHeight = viewportHeight / scrollHeight * this.progressBarHeight;
+
+		// Don't show individual segments if they're too small
+		if( progressSegmentHeight > MIN_PROGRESS_SEGMENT_HEIGHT ) {
+
+			this.pages.forEach( page => {
+
+				page.progressBarSlide = document.createElement( 'div' );
+				page.progressBarSlide.className = 'reader-progress-slide';
+				page.progressBarSlide.classList.toggle( 'has-triggers', page.scrollTriggers.length > 0 );
+				page.progressBarSlide.style.top = page.top / scrollHeight * this.progressBarHeight + 'px';
+				page.progressBarSlide.style.height = page.totalHeight / scrollHeight * this.progressBarHeight - PROGRESS_SPACING + 'px';
+				this.progressBarInner.appendChild( page.progressBarSlide );
+
+				const scrollTriggerTopOffset = page.scrollTriggerHeight / scrollHeight * this.progressBarHeight;
+				const scrollPaddingProgressHeight = page.scrollPadding / scrollHeight * this.progressBarHeight;
+
+				// Create visual representations for each scroll trigger
+				page.scrollTriggerElements = page.scrollTriggers.map( ( trigger, i ) => {
+
+					const triggerElement = document.createElement( 'div' );
+					triggerElement.className = 'reader-progress-trigger';
+					triggerElement.style.top = scrollTriggerTopOffset + trigger.range[0] * scrollPaddingProgressHeight + 'px';
+					triggerElement.style.height = ( trigger.range[1] - trigger.range[0] ) * scrollPaddingProgressHeight - PROGRESS_SPACING + 'px';
+					page.progressBarSlide.appendChild( triggerElement );
+
+					if( i === 0 ) triggerElement.style.display = 'none';
+
+					return triggerElement;
+
+				} );
+
+			} );
+
+		}
+		else {
+
+			this.pages.forEach( page => page.progressBarSlide = null );
+
+		}
+
+	}
+
+	/**
+	 * Moves the progress bar playhead to the specified position.
+	 *
+	 * @param {number} progress 0-1
+	 */
+	setProgressBarValue( progress ) {
+
+		if( this.progressBar ) {
+
+			this.progressBarPlayhead.style.transform = `translateY(${progress * this.progressBarScrollableHeight}px)`;
+
+			this.pages
+				.filter( page => page.progressBarSlide )
+				.forEach( ( page ) => {
+					page.progressBarSlide.classList.toggle( 'active', !!page.active );
+
+					page.scrollTriggers.forEach( ( trigger, i ) => {
+						page.scrollTriggerElements[i].classList.toggle( 'active', page.active && trigger.active );
+					} );
+				} );
+
+			this.showProgressBar();
+
+		}
+
+	}
+
+	/**
+	 * Show the progress bar and, if configured, automatically hide
+	 * it after a delay.
+	 */
+	showProgressBar() {
+
+		this.progressBar.classList.add( 'visible' );
+
+		clearTimeout( this.hideProgressBarTimeout );
+
+		if( this.Reveal.getConfig().readerScrollbar === 'auto' && !this.draggingProgressBar ) {
+
+			this.hideProgressBarTimeout = setTimeout( () => {
+				this.progressBar.classList.remove( 'visible' );
+			}, HIDE_SCROLLBAR_TIMEOUT );
+
+		}
+
+	}
+
+	/**
+	 * Scrolls the given slide element into view.
+	 *
+	 * @param {HTMLElement} slideElement
+	 */
+	scrollToSlide( slideElement ) {
+
+		if( !this.active ) {
+			this.activatedCallbacks.push( () => this.scrollToSlide( slideElement ) );
+		}
+		else {
+			slideElement.parentNode.scrollIntoView();
+		}
+
+	}
+
+	onScroll() {
+
+		const viewportHeight = this.viewportElement.offsetHeight;
+		const scrollTop = this.viewportElement.scrollTop;
+
+		// Find the page closest to the center of the viewport, this
+		// is the page we want to focus and activate
+		const activePage = this.pages.reduce( ( closestPage, page ) => {
+
+			// For tall pages with multiple scroll triggers we need to
+			// check the distnace from both the top of the page and the
+			// bottom
+			const distance = Math.min(
+				Math.abs( ( page.top + page.pageHeight / 2 ) - scrollTop - viewportHeight / 2 ),
+				Math.abs( ( page.top + ( page.totalHeight - page.pageHeight / 2 ) ) - scrollTop - viewportHeight / 2 )
+			);
+
+			return distance < closestPage.distance ? { page, distance } : closestPage;
+
+		}, { page: this.pages[0], distance: Infinity } ).page;
+
+		this.pages.forEach( ( page, pageIndex ) => {
+			const isWithinPreloadRange = scrollTop + viewportHeight >= page.top - viewportHeight && scrollTop < page.top + page.bottom + viewportHeight;
+			const isPartiallyVisible = scrollTop + viewportHeight >= page.top && scrollTop < page.top + page.bottom;
+
+			// Preload content when it appears within range
+			if( isWithinPreloadRange ) {
+				if( !page.preloaded ) {
+					page.preloaded = true;
+					this.Reveal.slideContent.load( page.slideElement );
+				}
+			}
+			else if( page.preloaded ) {
+				page.preloaded = false;
+				this.Reveal.slideContent.unload( page.slideElement );
+			}
+
+			// Activate the current page
+			if( page === activePage ) {
+				// Ignore if the page is already active
+				if( !page.active ) {
+					page.active = true;
+					page.pageElement.classList.add( 'present' );
+					page.slideElement.classList.add( 'present' );
+
+					this.Reveal.setCurrentReaderPage( page.pageElement, page.indexh, page.indexv );
+					this.Reveal.slideContent.startEmbeddedContent( page.slideElement );
+					this.Reveal.backgrounds.bubbleSlideContrastClassToElement( page.slideElement, this.viewportElement );
+
+					if( page.backgroundElement ) {
+						this.Reveal.slideContent.startEmbeddedContent( page.backgroundElement );
+					}
+				}
+			}
+			// Deactivate previously active pages
+			else if( page.active ) {
+				page.active = false;
+				page.pageElement.classList.remove( 'present' );
+				page.slideElement.classList.remove( 'present' );
+				this.Reveal.slideContent.stopEmbeddedContent( page.slideElement );
+
+				if( page.backgroundElement ) {
+					this.Reveal.slideContent.stopEmbeddedContent( page.backgroundElement );
+				}
+			}
+
+			// Handle scroll triggers for slides in view
+			if( isPartiallyVisible && page.totalHeight > page.pageHeight ) {
+				let scrollProgress = ( scrollTop - page.top ) / page.scrollPadding;
+				scrollProgress = Math.max( Math.min( scrollProgress, 1 ), 0 );
+
+				page.scrollTriggers.forEach( trigger => {
+					if( scrollProgress >= trigger.range[0] && scrollProgress < trigger.range[1] ) {
+						if( !trigger.active ) {
+							trigger.active = true;
+							this.Reveal.fragments.update( trigger.fragmentIndex, page.fragments, page.slideElement );
+						}
+					}
+					else {
+						trigger.active = false;
+					}
+				} );
+			}
+		} );
+
+		this.setProgressBarValue( scrollTop / ( this.viewportElement.scrollHeight - viewportHeight ) );
+
+	}
+
+	get viewportElement() {
+
+		return this.Reveal.getViewportElement();
+
+	}
+
+}

+ 4 - 0
js/controllers/slidecontent.js

@@ -25,6 +25,10 @@ export default class SlideContent {
 	 */
 	shouldPreload( element ) {
 
+		if( this.Reveal.isReaderMode() ) {
+			return true;
+		}
+
 		// Prefer an explicit global preload setting
 		let preload = this.Reveal.getConfig().preloadIframes;
 

+ 1 - 1
js/controllers/slidenumber.js

@@ -23,7 +23,7 @@ export default class SlideNumber {
 	configure( config, oldConfig ) {
 
 		let slideNumberDisplay = 'none';
-		if( config.slideNumber && !this.Reveal.isPrintingPDF() ) {
+		if( config.slideNumber && !this.Reveal.isPrinting() ) {
 			if( config.showSlideNumber === 'all' ) {
 				slideNumberDisplay = 'block';
 			}

+ 203 - 53
js/reveal.js

@@ -11,6 +11,7 @@ import Controls from './controllers/controls.js'
 import Progress from './controllers/progress.js'
 import Pointer from './controllers/pointer.js'
 import Plugins from './controllers/plugins.js'
+import Reader from './controllers/reader.js'
 import Print from './controllers/print.js'
 import Touch from './controllers/touch.js'
 import Focus from './controllers/focus.js'
@@ -113,6 +114,7 @@ export default function( revealElement, options ) {
 		progress = new Progress( Reveal ),
 		pointer = new Pointer( Reveal ),
 		plugins = new Plugins( Reveal ),
+		reader = new Reader( Reveal ),
 		print = new Print( Reveal ),
 		focus = new Focus( Reveal ),
 		touch = new Touch( Reveal ),
@@ -140,6 +142,11 @@ export default function( revealElement, options ) {
 		// 5. Query params
 		config = { ...defaultConfig, ...config, ...options, ...initOptions, ...Util.getQueryHash() };
 
+		// Legacy support for the ?print-pdf query
+		if( /print-pdf/gi.test( window.location.search ) ) {
+			config.view = 'print';
+		}
+
 		setViewport();
 
 		// Force a layout when the whole page, incl fonts, has loaded
@@ -201,12 +208,15 @@ export default function( revealElement, options ) {
 		// Updates the presentation to match the current configuration values
 		configure();
 
-		// Read the initial hash
-		location.readURL();
-
 		// Create slide backgrounds
 		backgrounds.update( true );
 
+		// Activate the print/reader mode if configured
+		activateInitialView();
+
+		// Read the initial hash
+		location.readURL();
+
 		// Notify listeners that the presentation is ready but use a 1ms
 		// timeout to ensure it's not fired synchronously after #initialize()
 		setTimeout( () => {
@@ -225,19 +235,41 @@ export default function( revealElement, options ) {
 			});
 		}, 1 );
 
-		// Special setup and config is required when printing to PDF
-		if( print.isPrintingPDF() ) {
-			removeEventListeners();
+	}
+
+	/**
+	 * Activates the correct reveal.js view based on our config.
+	 * This is only invoked once during initialization.
+	 */
+	function activateInitialView() {
 
-			// The document needs to have loaded for the PDF layout
-			// measurements to be accurate
-			if( document.readyState === 'complete' ) {
-				print.setupPDF();
+		const activatePrintView = config.view === 'print';
+		const activateReaderView = config.view === 'reader';
+
+		if( activatePrintView || activateReaderView ) {
+
+			if( activatePrintView ) {
+				removeEventListeners();
 			}
 			else {
-				window.addEventListener( 'load', () => {
-					print.setupPDF();
-				} );
+				touch.unbind();
+			}
+
+			// Avoid content flickering during layout
+			dom.viewport.classList.add( 'loading-scroll-mode' );
+
+			if( activatePrintView ) {
+				// The document needs to have loaded for the PDF layout
+				// measurements to be accurate
+				if( document.readyState === 'complete' ) {
+					print.activate();
+				}
+				else {
+					window.addEventListener( 'load', () => print.activate() );
+				}
+			}
+			else {
+				reader.activate();
 			}
 		}
 
@@ -689,6 +721,26 @@ export default function( revealElement, options ) {
 
 	}
 
+	/**
+	 * Dispatches a slidechanged event.
+	 *
+	 * @param {string} origin Used to identify multiplex clients
+	 */
+	function dispatchSlideChanged( origin ) {
+
+		dispatchEvent({
+			type: 'slidechanged',
+			data: {
+				indexh,
+				indexv,
+				previousSlide,
+				currentSlide,
+				origin
+			}
+		});
+
+	}
+
 	/**
 	 * Dispatched a postMessage of the given type from our window.
 	 */
@@ -872,7 +924,10 @@ export default function( revealElement, options ) {
 	 */
 	function layout() {
 
-		if( dom.wrapper && !print.isPrintingPDF() ) {
+		if( dom.wrapper && !print.isActive() ) {
+
+			const viewportWidth = dom.viewport.offsetWidth;
+			const viewportHeight = dom.viewport.offsetHeight;
 
 			if( !config.disableLayout ) {
 
@@ -886,7 +941,9 @@ export default function( revealElement, options ) {
 					document.documentElement.style.setProperty( '--vh', ( window.innerHeight * 0.01 ) + 'px' );
 				}
 
-				const size = getComputedSlideSize();
+				const size = reader.isActive() ?
+							 getComputedSlideSize( viewportWidth, viewportHeight ) :
+							 getComputedSlideSize();
 
 				const oldScale = scale;
 
@@ -903,8 +960,9 @@ export default function( revealElement, options ) {
 				scale = Math.max( scale, config.minScale );
 				scale = Math.min( scale, config.maxScale );
 
-				// Don't apply any scaling styles if scale is 1
-				if( scale === 1 ) {
+				// Don't apply any scaling styles if scale is 1 or we're
+				// in reader mode
+				if( scale === 1 || reader.isActive() ) {
 					dom.slides.style.zoom = '';
 					dom.slides.style.left = '';
 					dom.slides.style.top = '';
@@ -932,7 +990,7 @@ export default function( revealElement, options ) {
 						continue;
 					}
 
-					if( config.center || slide.classList.contains( 'center' ) ) {
+					if( ( config.center || slide.classList.contains( 'center' ) ) ) {
 						// Vertical stacks are not centred since their section
 						// children will be
 						if( slide.classList.contains( 'stack' ) ) {
@@ -958,9 +1016,25 @@ export default function( revealElement, options ) {
 						}
 					});
 				}
+
+				// Responsively turn on the reader mode if there is an activation
+				// width configured. Ignore if we're configured to always be in
+				// reader mode.
+				if( typeof config.readerActivationWidth === 'number' && config.view !== 'reader' ) {
+					if( size.presentationWidth < config.readerActivationWidth ) {
+						if( !reader.isActive() ) reader.activate();
+					}
+					else {
+						if( reader.isActive() ) reader.deactivate();
+					}
+				}
 			}
 
 			dom.viewport.style.setProperty( '--slide-scale', scale );
+			dom.viewport.style.setProperty( '--viewport-width', viewportWidth + 'px' );
+			dom.viewport.style.setProperty( '--viewport-height', viewportHeight + 'px' );
+
+			reader.layout();
 
 			progress.update();
 			backgrounds.updateParallax();
@@ -981,7 +1055,6 @@ export default function( revealElement, options ) {
 	 * @param {string|number} height
 	 */
 	function layoutSlideContents( width, height ) {
-
 		// Handle sizing of elements with the 'r-stretch' class
 		Util.queryAll( dom.slides, 'section > .stretch, section > .r-stretch' ).forEach( element => {
 
@@ -1017,6 +1090,7 @@ export default function( revealElement, options ) {
 	 * @param {number} [presentationHeight=dom.wrapper.offsetHeight]
 	 */
 	function getComputedSlideSize( presentationWidth, presentationHeight ) {
+
 		let width = config.width;
 		let height = config.height;
 
@@ -1103,6 +1177,19 @@ export default function( revealElement, options ) {
 
 	}
 
+	/**
+	 * Checks if the current or specified slide is a stack containing
+	 * vertical slides.
+	 *
+	 * @param {HTMLElement} [slide=currentSlide]
+	 * @return {Boolean}
+	 */
+	function isVerticalStack( slide = currentSlide ) {
+
+		return slide.classList.contains( '.stack' ) || slide.querySelector( 'section' ) !== null;
+
+	}
+
 	/**
 	 * Returns true if we're on the last slide in the current
 	 * vertical stack.
@@ -1288,6 +1375,14 @@ export default function( revealElement, options ) {
 		// Query all horizontal slides in the deck
 		const horizontalSlides = dom.wrapper.querySelectorAll( HORIZONTAL_SLIDES_SELECTOR );
 
+		// If we're in reader mode we scroll the target slide into view
+		// instead of running our standard slide transition
+		if( reader.isActive() ) {
+			const scrollToSlide = reader.getSlideByIndices( h, v );
+			if( scrollToSlide ) reader.scrollToSlide( scrollToSlide );
+			return;
+		}
+
 		// Abort if there are no slides
 		if( horizontalSlides.length === 0 ) return;
 
@@ -1334,6 +1429,9 @@ export default function( revealElement, options ) {
 
 		// Detect if we're moving between two auto-animated slides
 		if( slideChanged && previousSlide && currentSlide && !overview.isActive() ) {
+			transition = 'running';
+
+			autoAnimateTransition = shoulAutoAnimateBetween( previousSlide, currentSlide, indexhBefore, indexvBefore );
 
 			// If this is an auto-animated transition, we disable the
 			// regular slide transition
@@ -1341,16 +1439,9 @@ export default function( revealElement, options ) {
 			// Note 20-03-2020:
 			// This needs to happen before we update slide visibility,
 			// otherwise transitions will still run in Safari.
-			if( previousSlide.hasAttribute( 'data-auto-animate' ) && currentSlide.hasAttribute( 'data-auto-animate' )
-					&& previousSlide.getAttribute( 'data-auto-animate-id' ) === currentSlide.getAttribute( 'data-auto-animate-id' )
-					&& !( ( indexh > indexhBefore || indexv > indexvBefore ) ? currentSlide : previousSlide ).hasAttribute( 'data-auto-animate-restart' ) ) {
-
-				autoAnimateTransition = true;
-				dom.slides.classList.add( 'disable-slide-transitions' );
+			if( autoAnimateTransition ) {
+				dom.slides.classList.add( 'disable-slide-transitions' )
 			}
-
-			transition = 'running';
-
 		}
 
 		// Update the visibility of slides now that the indices have changed
@@ -1409,16 +1500,7 @@ export default function( revealElement, options ) {
 		}
 
 		if( slideChanged ) {
-			dispatchEvent({
-				type: 'slidechanged',
-				data: {
-					indexh,
-					indexv,
-					previousSlide,
-					currentSlide,
-					origin
-				}
-			});
+			dispatchSlideChanged( origin );
 		}
 
 		// Handle embedded content
@@ -1463,6 +1545,57 @@ export default function( revealElement, options ) {
 
 	}
 
+	/**
+	 * Checks whether or not an auto-animation should take place between
+	 * the two given slides.
+	 *
+	 * @param {HTMLElement} fromSlide
+	 * @param {HTMLElement} toSlide
+	 * @param {number} indexhBefore
+	 * @param {number} indexvBefore
+	 *
+	 * @returns {boolean}
+	 */
+	function shoulAutoAnimateBetween( fromSlide, toSlide, indexhBefore, indexvBefore ) {
+
+		return 	fromSlide.hasAttribute( 'data-auto-animate' ) && toSlide.hasAttribute( 'data-auto-animate' ) &&
+				fromSlide.getAttribute( 'data-auto-animate-id' ) === toSlide.getAttribute( 'data-auto-animate-id' ) &&
+				!( ( indexh > indexhBefore || indexv > indexvBefore ) ? toSlide : fromSlide ).hasAttribute( 'data-auto-animate-restart' );
+
+	}
+
+	/**
+	 * Called anytime current page in reader mode changes. The current
+	 * page is the page that occupies the most space in the viewport.
+	 *
+	 * @param {number} pageIndex
+	 * @param {HTMLElement} pageElement
+	 */
+	function setCurrentReaderPage( pageElement, h, v ) {
+
+		let indexhBefore = indexh || 0;
+
+		indexh = h;
+		indexv = v;
+
+		previousSlide = currentSlide;
+		currentSlide = pageElement.querySelector( 'section' );
+
+		if( currentSlide && previousSlide ) {
+			if( config.autoAnimate && shoulAutoAnimateBetween( previousSlide, currentSlide, indexhBefore, indexv ) ) {
+				// Run the auto-animation between our slides
+				// autoAnimate.run( previousSlide, currentSlide );
+			}
+		}
+
+		requestAnimationFrame( () => {
+			announceStatus( getStatusText( currentSlide ) );
+		});
+
+		dispatchSlideChanged();
+
+	}
+
 	/**
 	 * Syncs the presentation with the current DOM. Useful
 	 * when new slides or control elements are added or when
@@ -1608,7 +1741,7 @@ export default function( revealElement, options ) {
 		let slides = Util.queryAll( dom.wrapper, selector ),
 			slidesLength = slides.length;
 
-		let printMode = print.isPrintingPDF();
+		let printMode = reader.isActive() || print.isActive();
 		let loopedForwards = false;
 		let loopedBackwards = false;
 
@@ -1768,7 +1901,7 @@ export default function( revealElement, options ) {
 			}
 
 			// All slides need to be visible when exporting to PDF
-			if( print.isPrintingPDF() ) {
+			if( print.isActive() ) {
 				viewDistance = Number.MAX_VALUE;
 			}
 
@@ -1999,21 +2132,30 @@ export default function( revealElement, options ) {
 
 		// If a slide is specified, return the indices of that slide
 		if( slide ) {
-			let isVertical = isVerticalSlide( slide );
-			let slideh = isVertical ? slide.parentNode : slide;
+			if( reader.isActive() ) {
+				h = parseInt( slide.getAttribute( 'data-index-h' ), 10 );
+
+				if( slide.getAttribute( 'data-index-v' ) ) {
+					v = parseInt( slide.getAttribute( 'data-index-v' ), 10 );
+				}
+			}
+			else {
+				let isVertical = isVerticalSlide( slide );
+				let slideh = isVertical ? slide.parentNode : slide;
 
-			// Select all horizontal slides
-			let horizontalSlides = getHorizontalSlides();
+				// Select all horizontal slides
+				let horizontalSlides = getHorizontalSlides();
 
-			// Now that we know which the horizontal slide is, get its index
-			h = Math.max( horizontalSlides.indexOf( slideh ), 0 );
+				// Now that we know which the horizontal slide is, get its index
+				h = Math.max( horizontalSlides.indexOf( slideh ), 0 );
 
-			// Assume we're not vertical
-			v = undefined;
+				// Assume we're not vertical
+				v = undefined;
 
-			// If this is a vertical slide, grab the vertical index
-			if( isVertical ) {
-				v = Math.max( Util.queryAll( slide.parentNode, 'section' ).indexOf( slide ), 0 );
+				// If this is a vertical slide, grab the vertical index
+				if( isVertical ) {
+					v = Math.max( Util.queryAll( slide.parentNode, 'section' ).indexOf( slide ), 0 );
+				}
 			}
 		}
 
@@ -2686,6 +2828,9 @@ export default function( revealElement, options ) {
 		// Toggles the overview mode on/off
 		toggleOverview: overview.toggle.bind( overview ),
 
+		// Toggles the reader mode on/off
+		toggleReaderMode: reader.toggle.bind( reader ),
+
 		// Toggles the "black screen" mode on/off
 		togglePause,
 
@@ -2700,6 +2845,7 @@ export default function( revealElement, options ) {
 		isLastSlide,
 		isLastVerticalSlide,
 		isVerticalSlide,
+		isVerticalStack,
 
 		// State checks
 		isPaused,
@@ -2707,7 +2853,9 @@ export default function( revealElement, options ) {
 		isSpeakerNotes: notes.isSpeakerNotesWindow.bind( notes ),
 		isOverview: overview.isActive.bind( overview ),
 		isFocused: focus.isFocused.bind( focus ),
-		isPrintingPDF: print.isPrintingPDF.bind( print ),
+
+		isReaderMode: reader.isActive.bind( reader ),
+		isPrinting: print.isActive.bind( print ),
 
 		// Checks if reveal.js has been loaded and is ready for use
 		isReady: () => ready,
@@ -2791,6 +2939,7 @@ export default function( revealElement, options ) {
 		registerKeyboardShortcut: keyboard.registerKeyboardShortcut.bind( keyboard ),
 
 		getComputedSlideSize,
+		setCurrentReaderPage,
 
 		// Returns the current scale of the presentation content
 		getScale: () => scale,
@@ -2827,13 +2976,14 @@ export default function( revealElement, options ) {
 		getStatusText,
 
 		// Controllers
-		print,
 		focus,
+		reader,
 		progress,
 		controls,
 		location,
 		overview,
 		fragments,
+		backgrounds,
 		slideContent,
 		slideNumber,
 

+ 164 - 115
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "reveal.js",
-  "version": "4.5.0",
+  "version": "4.6.0",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "reveal.js",
-      "version": "4.5.0",
+      "version": "4.6.0",
       "license": "MIT",
       "devDependencies": {
         "@babel/core": "^7.14.3",
@@ -2197,26 +2197,35 @@
       }
     },
     "node_modules/browserslist": {
-      "version": "4.19.3",
-      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.19.3.tgz",
-      "integrity": "sha512-XK3X4xtKJ+Txj8G5c30B4gsm71s69lqXlkYui4s6EkKxuv49qjYlY6oVd+IFJ73d4YymtM3+djvvt/R/iJwwDg==",
+      "version": "4.21.10",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.10.tgz",
+      "integrity": "sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==",
       "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
       "dependencies": {
-        "caniuse-lite": "^1.0.30001312",
-        "electron-to-chromium": "^1.4.71",
-        "escalade": "^3.1.1",
-        "node-releases": "^2.0.2",
-        "picocolors": "^1.0.0"
+        "caniuse-lite": "^1.0.30001517",
+        "electron-to-chromium": "^1.4.477",
+        "node-releases": "^2.0.13",
+        "update-browserslist-db": "^1.0.11"
       },
       "bin": {
         "browserslist": "cli.js"
       },
       "engines": {
         "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/browserslist"
       }
     },
     "node_modules/buffer": {
@@ -2337,9 +2346,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001374",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001374.tgz",
-      "integrity": "sha512-mWvzatRx3w+j5wx/mpFN5v5twlPrabG8NqX2c6e45LCpymdoGqNvRkRutFUqpRTXKFQFNQJasvK0YT7suW6/Hw==",
+      "version": "1.0.30001538",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001538.tgz",
+      "integrity": "sha512-HWJnhnID+0YMtGlzcp3T9drmBJUVDchPJ08tpUGFLs9CYlwWPH2uLgpHn8fND5pCgXVtnGS3H4QR9XLMHVNkHw==",
       "dev": true,
       "funding": [
         {
@@ -2349,6 +2358,10 @@
         {
           "type": "tidelift",
           "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
         }
       ]
     },
@@ -2854,28 +2867,18 @@
       }
     },
     "node_modules/core-js-compat": {
-      "version": "3.12.1",
-      "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.12.1.tgz",
-      "integrity": "sha512-i6h5qODpw6EsHAoIdQhKoZdWn+dGBF3dSS8m5tif36RlWvW3A6+yu2S16QHUo3CrkzrnEskMAt9f8FxmY9fhWQ==",
+      "version": "3.32.2",
+      "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.32.2.tgz",
+      "integrity": "sha512-+GjlguTDINOijtVRUxrQOv3kfu9rl+qPNdX2LTbJ/ZyVTuxK+ksVSAGX1nHstu4hrv1En/uPTtWgq2gI5wt4AQ==",
       "dev": true,
       "dependencies": {
-        "browserslist": "^4.16.6",
-        "semver": "7.0.0"
+        "browserslist": "^4.21.10"
       },
       "funding": {
         "type": "opencollective",
         "url": "https://opencollective.com/core-js"
       }
     },
-    "node_modules/core-js-compat/node_modules/semver": {
-      "version": "7.0.0",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz",
-      "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==",
-      "dev": true,
-      "bin": {
-        "semver": "bin/semver.js"
-      }
-    },
     "node_modules/core-util-is": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
@@ -3155,9 +3158,9 @@
       "dev": true
     },
     "node_modules/electron-to-chromium": {
-      "version": "1.4.73",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.73.tgz",
-      "integrity": "sha512-RlCffXkE/LliqfA5m29+dVDPB2r72y2D2egMMfIy3Le8ODrxjuZNVo4NIC2yPL01N4xb4nZQLwzi6Z5tGIGLnA==",
+      "version": "1.4.526",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.526.tgz",
+      "integrity": "sha512-tjjTMjmZAx1g6COrintLTa2/jcafYKxKoiEkdQOrVdbLaHh2wCt2nsAF8ZHweezkrP+dl/VG9T5nabcYoo0U5Q==",
       "dev": true
     },
     "node_modules/emoji-regex": {
@@ -3476,9 +3479,9 @@
       }
     },
     "node_modules/eslint/node_modules/semver": {
-      "version": "7.3.5",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
-      "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
+      "version": "7.5.4",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
+      "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
       "dev": true,
       "peer": true,
       "dependencies": {
@@ -4754,9 +4757,9 @@
       }
     },
     "node_modules/gulp-eslint/node_modules/cross-spawn/node_modules/semver": {
-      "version": "5.7.1",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
-      "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+      "version": "5.7.2",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
+      "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
       "dev": true,
       "bin": {
         "semver": "bin/semver"
@@ -6451,10 +6454,16 @@
       "dev": true
     },
     "node_modules/nanoid": {
-      "version": "3.3.1",
-      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz",
-      "integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==",
+      "version": "3.3.6",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
+      "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
       "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
       "bin": {
         "nanoid": "bin/nanoid.cjs"
       },
@@ -6591,9 +6600,9 @@
       }
     },
     "node_modules/node-releases": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.2.tgz",
-      "integrity": "sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==",
+      "version": "2.0.13",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz",
+      "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==",
       "dev": true
     },
     "node_modules/node-watch": {
@@ -6618,9 +6627,9 @@
       }
     },
     "node_modules/normalize-package-data/node_modules/semver": {
-      "version": "5.7.1",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
-      "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+      "version": "5.7.2",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
+      "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
       "dev": true,
       "bin": {
         "semver": "bin/semver"
@@ -7251,21 +7260,31 @@
       }
     },
     "node_modules/postcss": {
-      "version": "8.4.7",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.7.tgz",
-      "integrity": "sha512-L9Ye3r6hkkCeOETQX6iOaWZgjp3LL6Lpqm6EtgbKrgqGGteRMNb9vzBfRL96YOSu8o7x3MfIH9Mo5cPJFGrW6A==",
+      "version": "8.4.31",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+      "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
       "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
       "dependencies": {
-        "nanoid": "^3.3.1",
+        "nanoid": "^3.3.6",
         "picocolors": "^1.0.0",
         "source-map-js": "^1.0.2"
       },
       "engines": {
         "node": "^10 || ^12 || >=14"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/postcss/"
       }
     },
     "node_modules/postcss-value-parser": {
@@ -7966,9 +7985,9 @@
       }
     },
     "node_modules/semver": {
-      "version": "6.3.0",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
-      "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
       "dev": true,
       "bin": {
         "semver": "bin/semver.js"
@@ -9406,6 +9425,36 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/update-browserslist-db": {
+      "version": "1.0.12",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.12.tgz",
+      "integrity": "sha512-tE1smlR58jxbFMtrMpFNRmsrOXlpNXss965T1CrpwuZUzUAg/TBQc94SpyhDLSzrqrJS9xTRBthnZAGcE1oaxg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "dependencies": {
+        "escalade": "^3.1.1",
+        "picocolors": "^1.0.0"
+      },
+      "bin": {
+        "update-browserslist-db": "cli.js"
+      },
+      "peerDependencies": {
+        "browserslist": ">= 4.21.0"
+      }
+    },
     "node_modules/uri-js": {
       "version": "4.4.1",
       "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -9629,9 +9678,9 @@
       "dev": true
     },
     "node_modules/word-wrap": {
-      "version": "1.2.3",
-      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
-      "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+      "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
       "dev": true,
       "engines": {
         "node": ">=0.10.0"
@@ -11529,16 +11578,15 @@
       }
     },
     "browserslist": {
-      "version": "4.19.3",
-      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.19.3.tgz",
-      "integrity": "sha512-XK3X4xtKJ+Txj8G5c30B4gsm71s69lqXlkYui4s6EkKxuv49qjYlY6oVd+IFJ73d4YymtM3+djvvt/R/iJwwDg==",
+      "version": "4.21.10",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.10.tgz",
+      "integrity": "sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==",
       "dev": true,
       "requires": {
-        "caniuse-lite": "^1.0.30001312",
-        "electron-to-chromium": "^1.4.71",
-        "escalade": "^3.1.1",
-        "node-releases": "^2.0.2",
-        "picocolors": "^1.0.0"
+        "caniuse-lite": "^1.0.30001517",
+        "electron-to-chromium": "^1.4.477",
+        "node-releases": "^2.0.13",
+        "update-browserslist-db": "^1.0.11"
       }
     },
     "buffer": {
@@ -11621,9 +11669,9 @@
       "dev": true
     },
     "caniuse-lite": {
-      "version": "1.0.30001374",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001374.tgz",
-      "integrity": "sha512-mWvzatRx3w+j5wx/mpFN5v5twlPrabG8NqX2c6e45LCpymdoGqNvRkRutFUqpRTXKFQFNQJasvK0YT7suW6/Hw==",
+      "version": "1.0.30001538",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001538.tgz",
+      "integrity": "sha512-HWJnhnID+0YMtGlzcp3T9drmBJUVDchPJ08tpUGFLs9CYlwWPH2uLgpHn8fND5pCgXVtnGS3H4QR9XLMHVNkHw==",
       "dev": true
     },
     "chalk": {
@@ -12036,21 +12084,12 @@
       "dev": true
     },
     "core-js-compat": {
-      "version": "3.12.1",
-      "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.12.1.tgz",
-      "integrity": "sha512-i6h5qODpw6EsHAoIdQhKoZdWn+dGBF3dSS8m5tif36RlWvW3A6+yu2S16QHUo3CrkzrnEskMAt9f8FxmY9fhWQ==",
+      "version": "3.32.2",
+      "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.32.2.tgz",
+      "integrity": "sha512-+GjlguTDINOijtVRUxrQOv3kfu9rl+qPNdX2LTbJ/ZyVTuxK+ksVSAGX1nHstu4hrv1En/uPTtWgq2gI5wt4AQ==",
       "dev": true,
       "requires": {
-        "browserslist": "^4.16.6",
-        "semver": "7.0.0"
-      },
-      "dependencies": {
-        "semver": {
-          "version": "7.0.0",
-          "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz",
-          "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==",
-          "dev": true
-        }
+        "browserslist": "^4.21.10"
       }
     },
     "core-util-is": {
@@ -12277,9 +12316,9 @@
       "dev": true
     },
     "electron-to-chromium": {
-      "version": "1.4.73",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.73.tgz",
-      "integrity": "sha512-RlCffXkE/LliqfA5m29+dVDPB2r72y2D2egMMfIy3Le8ODrxjuZNVo4NIC2yPL01N4xb4nZQLwzi6Z5tGIGLnA==",
+      "version": "1.4.526",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.526.tgz",
+      "integrity": "sha512-tjjTMjmZAx1g6COrintLTa2/jcafYKxKoiEkdQOrVdbLaHh2wCt2nsAF8ZHweezkrP+dl/VG9T5nabcYoo0U5Q==",
       "dev": true
     },
     "emoji-regex": {
@@ -12505,9 +12544,9 @@
           "peer": true
         },
         "semver": {
-          "version": "7.3.5",
-          "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
-          "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
+          "version": "7.5.4",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
+          "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
           "dev": true,
           "peer": true,
           "requires": {
@@ -13577,9 +13616,9 @@
           },
           "dependencies": {
             "semver": {
-              "version": "5.7.1",
-              "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
-              "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+              "version": "5.7.2",
+              "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
+              "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
               "dev": true
             }
           }
@@ -14919,9 +14958,9 @@
       "dev": true
     },
     "nanoid": {
-      "version": "3.3.1",
-      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz",
-      "integrity": "sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw==",
+      "version": "3.3.6",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
+      "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
       "dev": true
     },
     "nanomatch": {
@@ -15023,9 +15062,9 @@
       }
     },
     "node-releases": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.2.tgz",
-      "integrity": "sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==",
+      "version": "2.0.13",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz",
+      "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==",
       "dev": true
     },
     "node-watch": {
@@ -15047,9 +15086,9 @@
       },
       "dependencies": {
         "semver": {
-          "version": "5.7.1",
-          "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
-          "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+          "version": "5.7.2",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
+          "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
           "dev": true
         }
       }
@@ -15520,12 +15559,12 @@
       "dev": true
     },
     "postcss": {
-      "version": "8.4.7",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.7.tgz",
-      "integrity": "sha512-L9Ye3r6hkkCeOETQX6iOaWZgjp3LL6Lpqm6EtgbKrgqGGteRMNb9vzBfRL96YOSu8o7x3MfIH9Mo5cPJFGrW6A==",
+      "version": "8.4.31",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+      "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
       "dev": true,
       "requires": {
-        "nanoid": "^3.3.1",
+        "nanoid": "^3.3.6",
         "picocolors": "^1.0.0",
         "source-map-js": "^1.0.2"
       }
@@ -16081,9 +16120,9 @@
       }
     },
     "semver": {
-      "version": "6.3.0",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
-      "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
       "dev": true
     },
     "semver-greatest-satisfied-range": {
@@ -17299,6 +17338,16 @@
         }
       }
     },
+    "update-browserslist-db": {
+      "version": "1.0.12",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.12.tgz",
+      "integrity": "sha512-tE1smlR58jxbFMtrMpFNRmsrOXlpNXss965T1CrpwuZUzUAg/TBQc94SpyhDLSzrqrJS9xTRBthnZAGcE1oaxg==",
+      "dev": true,
+      "requires": {
+        "escalade": "^3.1.1",
+        "picocolors": "^1.0.0"
+      }
+    },
     "uri-js": {
       "version": "4.4.1",
       "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -17487,9 +17536,9 @@
       "dev": true
     },
     "word-wrap": {
-      "version": "1.2.3",
-      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
-      "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+      "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
       "dev": true
     },
     "wrap-ansi": {

+ 115 - 0
test/test-reader-mode.html

@@ -0,0 +1,115 @@
+<!doctype html>
+<html lang="en">
+
+	<head>
+		<meta charset="utf-8">
+
+		<title>reveal.js - Test Reader Mode</title>
+
+		<link rel="stylesheet" href="../dist/reveal.css">
+		<link rel="stylesheet" href="../node_modules/qunit/qunit/qunit.css">
+		<script src="../node_modules/qunit/qunit/qunit.js"></script>
+	</head>
+
+	<body style="overflow: auto;">
+
+		<div id="qunit"></div>
+		<div id="qunit-fixture"></div>
+
+		<div class="reveal" style="opacity: 0; pointer-events: none;">
+
+			<div class="slides">
+
+				<section>
+					<h1>slide 1</h1>
+				</section>
+
+				<section>
+					<h1>slide 2</h1>
+				</section>
+
+				<section>
+					<h1>slide 3</h1>
+					<p class="fragment">fragment 1</p>
+					<p class="fragment">fragment 2</p>
+					<p class="fragment">fragment 3</p>
+				</section>
+
+				<section>
+					<h1>slide 4</h1>
+				</section>
+
+			</div>
+
+		</div>
+
+		<script src="../dist/reveal.js"></script>
+		<script>
+
+			QUnit.config.testTimeout = 30000;
+			QUnit.config.reorder = false;
+
+			function getScrollHeight() {
+				return Reveal.getViewportElement().scrollHeight;
+			}
+
+			function getViewportHeight() {
+				return Reveal.getViewportElement().offsetHeight;
+			}
+
+			Reveal.initialize({ view: 'reader' }).then( async () => {
+
+				QUnit.module( 'Reader Mode' );
+
+				QUnit.test( 'Activates', assert => {
+					assert.ok( getScrollHeight() > getViewportHeight(), 'Is overflowing' );
+				});
+
+				QUnit.test( 'Can be toggled via API', assert => {
+					Reveal.toggleReaderMode( false );
+					assert.ok( getScrollHeight() <= getViewportHeight(), 'Is not overflowing' );
+					Reveal.toggleReaderMode( true );
+					assert.ok( getScrollHeight() > getViewportHeight(), 'Is overflowing' );
+				});
+
+				QUnit.test( 'Changes present slide when scrolling', assert => {
+					assert.timeout( 200 );
+					assert.expect( 2 );
+
+					const slides = document.querySelectorAll( '.reveal .slides section' );
+
+					assert.ok( slides[0].classList.contains( 'present' ), 'First slide is present' );
+					Reveal.getViewportElement().scrollTop = getViewportHeight() * 1;
+
+					return new Promise( resolve => {
+						setTimeout(() => {
+							assert.ok( slides[1].classList.contains( 'present' ), 'Second slide is present' );
+							resolve();
+						}, 100);
+					} );
+				});
+
+				QUnit.test( 'Fires slideschanged event when scrolling', assert => {
+					assert.timeout( 200 );
+					assert.expect( 2 );
+
+					const slides = document.querySelectorAll( '.reveal .slides section' );
+
+					return new Promise( resolve => {
+						let callback = ( event ) => {
+								Reveal.off( 'slidechanged', callback );
+								assert.ok( true, 'slidechanged event fired' );
+								assert.ok( event.currentSlide.classList.contains( 'present' ), 'slidechanged provides reference to currentSlide' );
+								resolve();
+							}
+
+							Reveal.on( 'slidechanged', callback );
+							Reveal.getViewportElement().scrollTop = getViewportHeight() * 2;
+					});
+				});
+
+			} );
+		</script>
+
+	</body>
+</html>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů