add transcript view ('r') for text-to-speech read-aloud extensions

Read-aloud browser extensions cannot walk a reveal.js deck: slides
outside the current one are display:none and the visible page text is
indexed only once at page load, so such extensions stop after the title
slide. Pressing 'r' now toggles a transcript view -- a single scrollable
page containing every slide's content and speaker notes (overview TOC
lists excluded, fragments shown, rebuilt on every open) -- which
extensions can read from start to finish. Styled in the ids theme;
closes on click or Escape.

Change-Id: Ia3df9dfa68b9699d318d747831ac8a0e270c8271
diff --git a/NEWS.md b/NEWS.md
index 1c4b61a..f9c044e 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,5 +1,7 @@
 # revealjs.ids (development version)
 
+- New transcript view, toggled with `r`: a single scrollable page containing every slide's content and speaker notes. Text-to-speech ("read aloud") browser extensions cannot walk a reveal.js deck (hidden slides are not part of the visible page text), but they can read the transcript from start to finish -- e.g. to have the whole talk including notes read aloud while practicing.
+
 - Slides titled "Overview" or "Überblick" are now automatically turned into a table of contents (ported from the old Google Docs slide workflow): their body content is replaced at presentation time with a clickable list of all `#` chapter headings. Adding `{data-overview-subheadings=true}` to the heading also includes the `##` sub-headings, laid out in two columns. References and appendix chapters (References/Literatur/Literaturverzeichnis/Appendix/Anhang) are excluded, together with their sub-slides.
 
 - Speaker notes (`::: notes`) and timing comments placed before the first heading are now attached to the title slide instead of ending up on a separate empty phantom slide after it, so the title itself can have a time budget and a welcome note (shown in the speaker view and the mobile notes overlay, and included in `slide_timing()` and the pacing timer).
diff --git a/R/revealjs_presentation.R b/R/revealjs_presentation.R
index dab04c7..e73ede9 100644
--- a/R/revealjs_presentation.R
+++ b/R/revealjs_presentation.R
@@ -40,6 +40,12 @@
 #' useful for practicing a talk on a phone or tablet. It follows along as
 #' you change slides and only appears if the deck contains notes at all.
 #' 
+#' Pressing `r` toggles a transcript view: a single scrollable page
+#' containing every slide's content and speaker notes. Text-to-speech
+#' ("read aloud") browser extensions cannot walk a reveal.js deck, since
+#' hidden slides are not part of the visible page text -- the transcript
+#' gives them something they can read from start to finish.
+#' 
 #' ## Slide timing
 #' 
 #' Speaker time can be planned per slide and per speaker by adding a comment
diff --git a/README.Rmd b/README.Rmd
index 8193316..8c64a12 100644
--- a/README.Rmd
+++ b/README.Rmd
@@ -111,6 +111,8 @@
 
 On mobile browsers (touch devices), a speech-bubble button next to the fullscreen button overlays the current slide with its speaker notes -- useful for practicing a talk on a phone or tablet, where the separate speaker view window of the `'s'` shortcut is awkward. The overlay shows the slide title, its planned time (if annotated with a timing comment), and the notes, and follows along as you change slides. It appears only if the deck contains notes at all.
 
+-   `'r'` toggle the transcript view: a single scrollable page containing every slide's content and speaker notes, so text-to-speech ("read aloud") browser extensions can read the whole presentation -- they cannot walk a reveal.js deck themselves, since hidden slides are not part of the visible page text
+
 -   `'m'` enable menu mode when the 'menu' plugin is activated
 
 Pressing `Esc` exits all of these modes.
diff --git a/README.md b/README.md
index 29b35a9..000c48d 100644
--- a/README.md
+++ b/README.md
@@ -137,6 +137,12 @@
 and the notes, and follows along as you change slides. It appears only
 if the deck contains notes at all.
 
+-   `'r'` toggle the transcript view: a single scrollable page containing
+    every slide's content and speaker notes, so text-to-speech ("read
+    aloud") browser extensions can read the whole presentation -- they
+    cannot walk a reveal.js deck themselves, since hidden slides are not
+    part of the visible page text
+
 -   `'m'` enable menu mode when the ‘menu’ plugin is activated
 
 Pressing `Esc` exits all of these modes.
diff --git a/inst/reveal.js-5.2.1/dist/theme/ids.css b/inst/reveal.js-5.2.1/dist/theme/ids.css
index 16c8f5e..d1b8467 100644
--- a/inst/reveal.js-5.2.1/dist/theme/ids.css
+++ b/inst/reveal.js-5.2.1/dist/theme/ids.css
@@ -1091,4 +1091,51 @@
   color: var(--r-heading-color);
 }
 
+/*********************************************
+ * TRANSCRIPT / READ-ALOUD VIEW ('r')
+ *********************************************/
+#ids-transcript {
+  position: fixed;
+  inset: 0;
+  z-index: 90;
+  background: #fff;
+  color: #444444;
+  font-family: "Fira Sans", "Fira Sans Condensed", sans-serif;
+  font-size: 18px;
+  line-height: 1.5;
+  overflow-y: auto;
+  -webkit-overflow-scrolling: touch;
+  padding: max(32px, env(safe-area-inset-top)) max(40px, env(safe-area-inset-right))
+           max(32px, env(safe-area-inset-bottom)) max(40px, env(safe-area-inset-left));
+}
+#ids-transcript[hidden] {
+  display: none;
+}
+#ids-transcript .ids-transcript-slide {
+  max-width: 900px;
+  margin: 0 auto 2.5em auto;
+}
+#ids-transcript h1, #ids-transcript h2 {
+  color: var(--r-heading-color);
+  margin: 0 0 0.4em 0;
+}
+#ids-transcript h1 { font-size: 1.4em; }
+#ids-transcript h2 { font-size: 1.2em; }
+#ids-transcript .fragment {
+  opacity: 1;
+  visibility: visible;
+}
+#ids-transcript .ids-transcript-notes {
+  border-left: 3px solid #f6a800;
+  padding: 0.2em 0 0.2em 0.8em;
+  margin-top: 0.8em;
+  color: #666;
+}
+#ids-transcript .ids-transcript-notes:empty {
+  display: none;
+}
+#ids-transcript img, #ids-transcript video, #ids-transcript iframe {
+  max-width: 100%;
+}
+
 /*# sourceMappingURL=ids.css.map */
diff --git a/inst/rmarkdown/templates/revealjs_presentation/resources/default.html b/inst/rmarkdown/templates/revealjs_presentation/resources/default.html
index 062ffa7..003336b 100644
--- a/inst/rmarkdown/templates/revealjs_presentation/resources/default.html
+++ b/inst/rmarkdown/templates/revealjs_presentation/resources/default.html
@@ -1045,5 +1045,74 @@
     }
   })();
 </script>
+<script>
+  // Practice/read-aloud view: pressing 'r' toggles a single scrollable page
+  // containing every slide's content and speaker notes. Text-to-speech
+  // browser extensions cannot walk a reveal.js deck (hidden slides are
+  // display:none and indexed only once at page load), but they can read
+  // this flat transcript from start to finish. Styling in the ids theme.
+  (function () {
+    var overlay = null;
+
+    function build() {
+      overlay = document.createElement('div');
+      overlay.id = 'ids-transcript';
+      overlay.hidden = true;
+      overlay.setAttribute('aria-label', 'Transcript of all slides and notes');
+      Reveal.getSlides().forEach(function (slide) {
+        var section = document.createElement('section');
+        section.className = 'ids-transcript-slide';
+        var heading = slide.querySelector('h1,h2');
+        if (heading) section.appendChild(heading.cloneNode(true));
+        Array.prototype.forEach.call(slide.childNodes, function (node) {
+          if (node.nodeType !== Node.ELEMENT_NODE) return;
+          if (node.tagName === 'ASIDE' || node.tagName === 'H1' ||
+              node.tagName === 'H2' || node.tagName === 'SECTION' ||
+              (node.classList && node.classList.contains('ids-overview-toc'))) return;
+          section.appendChild(node.cloneNode(true));
+        });
+        var notes = Array.prototype.map.call(
+          slide.querySelectorAll('aside.notes'),
+          function (n) { return n.innerHTML; }
+        ).join('');
+        if (notes) {
+          var block = document.createElement('div');
+          block.className = 'ids-transcript-notes';
+          block.innerHTML = notes;
+          section.appendChild(block);
+        }
+        overlay.appendChild(section);
+      });
+      overlay.addEventListener('click', function () { overlay.hidden = true; });
+      document.body.appendChild(overlay);
+    }
+
+    function toggle() {
+      if (typeof Reveal === 'undefined' || !Reveal.isReady()) return;
+      if (!overlay) build();
+      overlay.hidden = !overlay.hidden;
+      if (!overlay.hidden) {
+        overlay.scrollTop = 0;
+        // rebuild on every open so the transcript reflects the current deck
+        overlay.remove();
+        overlay = null;
+        build();
+        overlay.hidden = false;
+      }
+    }
+
+    document.addEventListener('keydown', function (e) {
+      if (e.key !== 'r' && e.key !== 'R') return;
+      if (e.ctrlKey || e.altKey || e.metaKey) return;
+      var tag = e.target && e.target.tagName;
+      if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target && e.target.isContentEditable)) return;
+      e.preventDefault();
+      toggle();
+    });
+    document.addEventListener('keydown', function (e) {
+      if (e.key === 'Escape' && overlay && !overlay.hidden) overlay.hidden = true;
+    });
+  })();
+</script>
   </body>
 </html>
diff --git a/man/revealjs_presentation.Rd b/man/revealjs_presentation.Rd
index a903d31..f0b82b4 100644
--- a/man/revealjs_presentation.Rd
+++ b/man/revealjs_presentation.Rd
@@ -183,6 +183,12 @@
 title and, if present, the planned time from a timing comment), which is
 useful for practicing a talk on a phone or tablet. It follows along as
 you change slides and only appears if the deck contains notes at all.
+
+Pressing \code{r} toggles a transcript view: a single scrollable page
+containing every slide's content and speaker notes. Text-to-speech
+("read aloud") browser extensions cannot walk a reveal.js deck, since
+hidden slides are not part of the visible page text -- the transcript
+gives them something they can read from start to finish.
 }
 
 }
diff --git a/tests/testthat/test-slide_timing.R b/tests/testthat/test-slide_timing.R
index 219a60d..8159149 100644
--- a/tests/testthat/test-slide_timing.R
+++ b/tests/testthat/test-slide_timing.R
@@ -235,6 +235,24 @@
   expect_true(any(grepl("Überblick", html, fixed = TRUE)))
 })
 
+test_that("transcript view script is included", {
+  skip_if_not_pandoc()
+  rmd <- local_temp_rmd_file(
+    "---",
+    "title: Transcript test",
+    "output: revealjs.ids::revealjs_presentation",
+    "---",
+    "",
+    "## Slide",
+    "",
+    "::: notes",
+    "Note text",
+    ":::"
+  )
+  html <- .render_and_read(rmd, xml = FALSE)
+  expect_true(any(grepl("ids-transcript", html)))
+})
+
 test_that("a user-supplied totalTime is respected", {
   skip_if_not_pandoc()
   rmd <- local_temp_rmd_file(