From bff9bfb101763ed960cb8e5de7152da8d366f2af Mon Sep 17 00:00:00 2001
From: Hakim El Hattab <hakim.elhattab@gmail.com>
Date: Thu, 12 Mar 2020 17:08:20 +0100
Subject: [PATCH] add support for automatically scrolling code highlights into
 view

---
 css/reveal.scss               |   5 ++
 demo.html                     |  15 ++++-
 js/controllers/autoanimate.js |   9 ++-
 js/controllers/fragments.js   |  38 +++++++++--
 js/controllers/overview.js    |  26 +++++---
 js/reveal.js                  |  91 ++++++++++++++++----------
 plugin/highlight/highlight.js | 120 ++++++++++++++++++++++++++++++++++
 7 files changed, 254 insertions(+), 50 deletions(-)

diff --git a/css/reveal.scss b/css/reveal.scss
index f8803b4b..063aa151 100644
--- a/css/reveal.scss
+++ b/css/reveal.scss
@@ -39,6 +39,7 @@ body {
 	opacity: 0;
 	visibility: hidden;
 	transition: all .2s ease;
+	will-change: opacity;
 
 	&.visible {
 		opacity: 1;
@@ -1599,6 +1600,10 @@ $overlayHeaderPadding: 5px;
  * CODE HIGHLGIHTING
  *********************************************/
 
+.reveal .hljs {
+	min-height: 100%;
+}
+
 .reveal .hljs table {
 	margin: initial;
 }
diff --git a/demo.html b/demo.html
index 8eda9eac..06be8caf 100644
--- a/demo.html
+++ b/demo.html
@@ -102,7 +102,7 @@
 
 				<section data-auto-animate>
 					<h2 data-id="code-title">With animations</h2>
-					<pre data-id="code-animation"><code class="hljs" data-trim data-line-numbers="|4|4,8-11">
+					<pre data-id="code-animation"><code class="hljs" data-trim data-line-numbers="|4,8-11|17|22-24">
 						import React, { useState } from 'react';
 
 						function Example() {
@@ -117,6 +117,19 @@
 						    &lt;/div&gt;
 						  );
 						}
+
+						function SecondExample() {
+						  const [count, setCount] = useState(0);
+
+						  return (
+						    &lt;div&gt;
+						      &lt;p&gt;You clicked {count} times&lt;/p&gt;
+						      &lt;button onClick={() =&gt; setCount(count + 1)}&gt;
+						        Click me
+						      &lt;/button&gt;
+						    &lt;/div&gt;
+						  );
+						}
 					</code></pre>
 				</section>
 
diff --git a/js/controllers/autoanimate.js b/js/controllers/autoanimate.js
index 9204524c..4b006d42 100644
--- a/js/controllers/autoanimate.js
+++ b/js/controllers/autoanimate.js
@@ -67,7 +67,14 @@ export default class AutoAnimate {
 				}
 			} );
 
-			this.Reveal.dispatchEvent( 'autoanimate', { fromSlide: fromSlide, toSlide: toSlide, sheet: this.autoAnimateStyleSheet } );
+			this.Reveal.dispatchEvent({
+				type: 'autoanimate',
+				data: {
+					fromSlide,
+					toSlide,
+					sheet: this.autoAnimateStyleSheet
+				}
+			});
 
 		}
 
diff --git a/js/controllers/fragments.js b/js/controllers/fragments.js
index 0ad699bd..01db85f4 100644
--- a/js/controllers/fragments.js
+++ b/js/controllers/fragments.js
@@ -180,7 +180,7 @@ export default class Fragments {
 
 					// Visible fragments
 					if( i <= index ) {
-						if( !el.classList.contains( 'visible' ) ) changedFragments.shown.push( el );
+						let wasVisible = el.classList.contains( 'visible' )
 						el.classList.add( 'visible' );
 						el.classList.remove( 'current-fragment' );
 
@@ -191,12 +191,30 @@ export default class Fragments {
 							el.classList.add( 'current-fragment' );
 							this.Reveal.slideContent.startEmbeddedContent( el );
 						}
+
+						if( !wasVisible ) {
+							changedFragments.shown.push( el )
+							this.Reveal.dispatchEvent({
+								target: el,
+								type: 'visible',
+								bubbles: false
+							});
+						}
 					}
 					// Hidden fragments
 					else {
-						if( el.classList.contains( 'visible' ) ) changedFragments.hidden.push( el );
+						let wasVisible = el.classList.contains( 'visible' )
 						el.classList.remove( 'visible' );
 						el.classList.remove( 'current-fragment' );
+
+						if( wasVisible ) {
+							changedFragments.hidden.push( el );
+							this.Reveal.dispatchEvent({
+								target: el,
+								type: 'hidden',
+								bubbles: false
+							});
+						}
 					}
 
 				} );
@@ -253,11 +271,23 @@ export default class Fragments {
 				let changedFragments = this.update( index, fragments );
 
 				if( changedFragments.hidden.length ) {
-					this.Reveal.dispatchEvent( 'fragmenthidden', { fragment: changedFragments.hidden[0], fragments: changedFragments.hidden } );
+					this.Reveal.dispatchEvent({
+						type: 'fragmenthidden',
+						data: {
+							fragment: changedFragments.hidden[0],
+							fragments: changedFragments.hidden
+						}
+					});
 				}
 
 				if( changedFragments.shown.length ) {
-					this.Reveal.dispatchEvent( 'fragmentshown', { fragment: changedFragments.shown[0], fragments: changedFragments.shown } );
+					this.Reveal.dispatchEvent({
+						type: 'fragmentshown',
+						data: {
+							fragment: changedFragments.shown[0],
+							fragments: changedFragments.shown
+						}
+					});
 				}
 
 				this.Reveal.updateControls();
diff --git a/js/controllers/overview.js b/js/controllers/overview.js
index 08a24045..ce48a069 100644
--- a/js/controllers/overview.js
+++ b/js/controllers/overview.js
@@ -65,11 +65,14 @@ export default class Overview {
 			const indices = this.Reveal.getIndices();
 
 			// Notify observers of the overview showing
-			this.Reveal.dispatchEvent( 'overviewshown', {
-				'indexh': indices.h,
-				'indexv': indices.v,
-				'currentSlide': this.Reveal.getCurrentSlide()
-			} );
+			this.Reveal.dispatchEvent({
+				type: 'overviewshown',
+				data: {
+					'indexh': indices.h,
+					'indexv': indices.v,
+					'currentSlide': this.Reveal.getCurrentSlide()
+				}
+			});
 
 		}
 
@@ -175,11 +178,14 @@ export default class Overview {
 			this.Reveal.cueAutoSlide();
 
 			// Notify observers of the overview hiding
-			this.Reveal.dispatchEvent( 'overviewhidden', {
-				'indexh': indices.h,
-				'indexv': indices.v,
-				'currentSlide': this.Reveal.getCurrentSlide()
-			} );
+			this.Reveal.dispatchEvent({
+				type: 'overviewhidden',
+				data: {
+					'indexh': indices.h,
+					'indexv': indices.v,
+					'currentSlide': this.Reveal.getCurrentSlide()
+				}
+			});
 
 		}
 	}
diff --git a/js/reveal.js b/js/reveal.js
index 8d237652..2ece9617 100644
--- a/js/reveal.js
+++ b/js/reveal.js
@@ -194,11 +194,14 @@ export default function( revealElement, options ) {
 
 			dom.wrapper.classList.add( 'ready' );
 
-			dispatchEvent( 'ready', {
-				'indexh': indexh,
-				'indexv': indexv,
-				'currentSlide': currentSlide
-			} );
+			dispatchEvent({
+				type: 'ready',
+				data: {
+					indexh,
+					indexv,
+					currentSlide
+				}
+			});
 		}, 1 );
 
 		// Special setup and config is required when printing to PDF
@@ -511,7 +514,7 @@ export default function( revealElement, options ) {
 		} );
 
 		// Notify subscribers that the PDF layout is good to go
-		dispatchEvent( 'pdf-ready' );
+		dispatchEvent({ type: 'pdf-ready' });
 
 	}
 
@@ -1058,16 +1061,18 @@ export default function( revealElement, options ) {
 	 * Dispatches an event of the specified type from the
 	 * reveal DOM element.
 	 */
-	function dispatchEvent( type, args ) {
+	function dispatchEvent({ target=dom.wrapper, type, data, bubbles=true }) {
 
 		let event = document.createEvent( 'HTMLEvents', 1, 2 );
-		event.initEvent( type, true, true );
-		extend( event, args );
-		dom.wrapper.dispatchEvent( event );
+		event.initEvent( type, bubbles, true );
+		extend( event, data );
+		target.dispatchEvent( event );
 
-		// If we're in an iframe, post each reveal.js event to the
-		// parent window. Used by the notes plugin
-		dispatchPostMessage( type );
+		if( target === dom.wrapper ) {
+			// If we're in an iframe, post each reveal.js event to the
+			// parent window. Used by the notes plugin
+			dispatchPostMessage( type );
+		}
 
 	}
 
@@ -1347,11 +1352,14 @@ export default function( revealElement, options ) {
 				}
 
 				if( oldScale !== scale ) {
-					dispatchEvent( 'resize', {
-						'oldScale': oldScale,
-						'scale': scale,
-						'size': size
-					} );
+					dispatchEvent({
+						type: 'resize',
+						data: {
+							oldScale,
+							scale,
+							size
+						}
+					});
 				}
 			}
 
@@ -1577,7 +1585,7 @@ export default function( revealElement, options ) {
 			dom.wrapper.classList.add( 'paused' );
 
 			if( wasPaused === false ) {
-				dispatchEvent( 'paused' );
+				dispatchEvent({ type: 'paused' });
 			}
 		}
 
@@ -1594,7 +1602,7 @@ export default function( revealElement, options ) {
 		cueAutoSlide();
 
 		if( wasPaused ) {
-			dispatchEvent( 'resumed' );
+			dispatchEvent({ type: 'resumed' });
 		}
 
 	}
@@ -1763,7 +1771,7 @@ export default function( revealElement, options ) {
 			document.documentElement.classList.add( state[i] );
 
 			// Dispatch custom event matching the state's name
-			dispatchEvent( state[i] );
+			dispatchEvent({ type: state[i] });
 		}
 
 		// Clean up the remains of the previous state
@@ -1772,13 +1780,16 @@ export default function( revealElement, options ) {
 		}
 
 		if( slideChanged ) {
-			dispatchEvent( 'slidechanged', {
-				'indexh': indexh,
-				'indexv': indexv,
-				'previousSlide': previousSlide,
-				'currentSlide': currentSlide,
-				'origin': o
-			} );
+			dispatchEvent({
+				type: 'slidechanged',
+				data: {
+					indexh,
+					indexv,
+					previousSlide,
+					currentSlide,
+					origin: o
+				}
+			});
 		}
 
 		// Handle embedded content
@@ -2035,14 +2046,26 @@ export default function( revealElement, options ) {
 				}
 			}
 
+			let slide = slides[index];
+			let wasPresent = slide.classList.contains( 'present' );
+
 			// Mark the current slide as present
-			slides[index].classList.add( 'present' );
-			slides[index].removeAttribute( 'hidden' );
-			slides[index].removeAttribute( 'aria-hidden' );
+			slide.classList.add( 'present' );
+			slide.removeAttribute( 'hidden' );
+			slide.removeAttribute( 'aria-hidden' );
+
+			if( !wasPresent ) {
+				// Dispatch an event indicating the slide is now visible
+				dispatchEvent({
+					target: slide,
+					type: 'visible',
+					bubbles: false
+				});
+			}
 
 			// If this slide has a state associated with it, add it
 			// onto the current state of the deck
-			let slideState = slides[index].getAttribute( 'data-state' );
+			let slideState = slide.getAttribute( 'data-state' );
 			if( slideState ) {
 				state = state.concat( slideState.split( ' ' ) );
 			}
@@ -2947,7 +2970,7 @@ export default function( revealElement, options ) {
 
 		if( autoSlide && !autoSlidePaused ) {
 			autoSlidePaused = true;
-			dispatchEvent( 'autoslidepaused' );
+			dispatchEvent({ type: 'autoslidepaused' });
 			clearTimeout( autoSlideTimeout );
 
 			if( autoSlidePlayer ) {
@@ -2961,7 +2984,7 @@ export default function( revealElement, options ) {
 
 		if( autoSlide && autoSlidePaused ) {
 			autoSlidePaused = false;
-			dispatchEvent( 'autoslideresumed' );
+			dispatchEvent({ type: 'autoslideresumed' });
 			cueAutoSlide();
 		}
 
diff --git a/plugin/highlight/highlight.js b/plugin/highlight/highlight.js
index e751fb7a..494f42e4 100644
--- a/plugin/highlight/highlight.js
+++ b/plugin/highlight/highlight.js
@@ -100,6 +100,15 @@
 				if( config.highlightOnLoad ) {
 					RevealHighlight.highlightBlock( block );
 				}
+
+			} );
+
+			// If we're printing to PDF, scroll the code highlights of
+			// all blocks in the deck into view at once
+			Reveal.addEventListener( 'pdf-ready', function() {
+				[].slice.call( document.querySelectorAll( '.reveal pre code[data-line-numbers].current-fragment' ) ).forEach( function( block ) {
+					RevealHighlight.scrollHighlightedLineIntoView( block, {}, true );
+				} );
 			} );
 
 		},
@@ -122,6 +131,8 @@
 			if( block.hasAttribute( 'data-line-numbers' ) ) {
 				hljs.lineNumbersBlock( block, { singleLine: true } );
 
+				var scrollState = { currentBlock: block };
+
 				// If there is at least one highlight step, generate
 				// fragments
 				var highlightSteps = RevealHighlight.deserializeHighlightSteps( block.getAttribute( 'data-line-numbers' ) );
@@ -130,6 +141,7 @@
 					// If the original code block has a fragment-index,
 					// each clone should follow in an incremental sequence
 					var fragmentIndex = parseInt( block.getAttribute( 'data-fragment-index' ), 10 );
+
 					if( typeof fragmentIndex !== 'number' || isNaN( fragmentIndex ) ) {
 						fragmentIndex = null;
 					}
@@ -151,6 +163,10 @@
 							fragmentBlock.removeAttribute( 'data-fragment-index' );
 						}
 
+						// Scroll highlights into view as we step through them
+						fragmentBlock.addEventListener( 'visible', RevealHighlight.scrollHighlightedLineIntoView.bind( RevealHighlight, fragmentBlock, scrollState ) );
+						fragmentBlock.addEventListener( 'hidden', RevealHighlight.scrollHighlightedLineIntoView.bind( RevealHighlight, fragmentBlock.previousSibling, scrollState ) );
+
 					} );
 
 					block.removeAttribute( 'data-fragment-index' )
@@ -158,12 +174,116 @@
 
 				}
 
+				// Scroll the first highlight into view when the slide
+				// becomes visible. Note supported in IE11 since it lacks
+				// support for Element.closest.
+				var slide = typeof block.closest === 'function' ? block.closest( 'section:not(.stack)' ) : null;
+				if( slide ) {
+					var scrollFirstHighlightIntoView = function() {
+						RevealHighlight.scrollHighlightedLineIntoView( block, scrollState, true );
+						slide.removeEventListener( 'visible', scrollFirstHighlightIntoView );
+					}
+					slide.addEventListener( 'visible', scrollFirstHighlightIntoView );
+				}
+
 				RevealHighlight.highlightLines( block );
 
 			}
 
 		},
 
+		/**
+		 * Animates scrolling to the first highlighted line
+		 * in the given code block.
+		 */
+		scrollHighlightedLineIntoView: function( block, scrollState, skipAnimation ) {
+
+			cancelAnimationFrame( scrollState.animationFrameID );
+
+			// Match the scroll position of the currently visible
+			// code block
+			if( scrollState.currentBlock ) {
+				block.scrollTop = scrollState.currentBlock.scrollTop;
+			}
+
+			// Remember the current code block so that we can match
+			// its scroll position when showing/hiding fragments
+			scrollState.currentBlock = block;
+
+			var highlightBounds = this.getHighlightedLineBounds( block )
+			var viewportHeight = block.offsetHeight;
+
+			// Subtract padding from the viewport height
+			var blockStyles = getComputedStyle( block );
+			viewportHeight -= parseInt( blockStyles.paddingTop ) + parseInt( blockStyles.paddingBottom );
+
+			// Scroll position which centers all highlights
+			var startTop = block.scrollTop;
+			var targetTop = highlightBounds.top + ( Math.min( highlightBounds.bottom - highlightBounds.top, viewportHeight ) - viewportHeight ) / 2;
+
+			// Account for offsets in position applied to the
+			// <table> that holds our lines of code
+			var lineTable = block.querySelector( '.hljs-ln' );
+			if( lineTable ) targetTop += lineTable.offsetTop - parseInt( blockStyles.paddingTop );
+
+			// Make sure the scroll target is within bounds
+			targetTop = Math.max( Math.min( targetTop, block.scrollHeight - viewportHeight ), 0 );
+
+			if( skipAnimation === true || startTop === targetTop ) {
+				block.scrollTop = targetTop;
+			}
+			else {
+
+				// Don't attempt to scroll if there is no overflow
+				if( block.scrollHeight <= viewportHeight ) return;
+
+				var time = 0;
+				var animate = function() {
+					time = Math.min( time + 0.02, 1 );
+
+					// Update our eased scroll position
+					block.scrollTop = startTop + ( targetTop - startTop ) * RevealHighlight.easeInOutQuart( time );
+
+					// Keep animating unless we've reached the end
+					if( time < 1 ) {
+						scrollState.animationFrameID = requestAnimationFrame( animate );
+					}
+				};
+
+				animate();
+
+			}
+
+		},
+
+		/**
+		 * The easing function used when scrolling.
+		 */
+		easeInOutQuart: function( t ) {
+
+			// easeInOutQuart
+			return t<.5 ? 8*t*t*t*t : 1-8*(--t)*t*t*t;
+
+		},
+
+		getHighlightedLineBounds: function( block ) {
+
+			var highlightedLines = block.querySelectorAll( '.highlight-line' );
+			if( highlightedLines.length === 0 ) {
+				return { top: 0, bottom: 0 };
+			}
+			else {
+				var firstHighlight = highlightedLines[0];
+				var lastHighlight = highlightedLines[ highlightedLines.length -1 ];
+
+				return {
+					top: firstHighlight.offsetTop,
+					bottom: lastHighlight.offsetTop + lastHighlight.offsetHeight
+				}
+			}
+
+		},
+
 		/**
 		 * Visually emphasize specific lines within a code block.
 		 * This only works on blocks with line numbering turned on.
-- 
GitLab