@@ -33,6 +33,8 @@ window.QuartoLineHighlight = function () {
3333 // move attributes on code block
3434 code . setAttribute ( kCodeLineNumbersAttr , codeLineAttr ) ;
3535
36+ const scrollState = { currentBlock : code } ;
37+
3638 // Check if there are steps and duplicate code block accordingly
3739 const highlightSteps = splitLineNumbers ( codeLineAttr ) ;
3840 if ( highlightSteps . length > 1 ) {
@@ -84,7 +86,23 @@ window.QuartoLineHighlight = function () {
8486 fragmentBlock . removeAttribute ( kFragmentIndex ) ;
8587 }
8688
87- // TODO add scrolling animation
89+ // Scroll highlights into view as we step through them
90+ fragmentBlock . addEventListener (
91+ "visible" ,
92+ scrollHighlightedLineIntoView . bind (
93+ this ,
94+ fragmentBlock ,
95+ scrollState
96+ )
97+ ) ;
98+ fragmentBlock . addEventListener (
99+ "hidden" ,
100+ scrollHighlightedLineIntoView . bind (
101+ this ,
102+ fragmentBlock . previousSibling ,
103+ scrollState
104+ )
105+ ) ;
88106 }
89107 ) ;
90108 code . removeAttribute ( kFragmentIndex ) ;
@@ -93,7 +111,22 @@ window.QuartoLineHighlight = function () {
93111 joinLineNumbers ( [ highlightSteps [ 0 ] ] )
94112 ) ;
95113 }
96- // TODO add scrolling animation: scroll the first highlight into view when the slide
114+
115+ // Scroll the first highlight into view when the slide becomes visible.
116+ const slide =
117+ typeof code . closest === "function"
118+ ? code . closest ( "section:not(.stack)" )
119+ : null ;
120+ if ( slide ) {
121+ const scrollFirstHighlightIntoView = function ( ) {
122+ scrollHighlightedLineIntoView ( code , scrollState , true ) ;
123+ slide . removeEventListener (
124+ "visible" ,
125+ scrollFirstHighlightIntoView
126+ ) ;
127+ } ;
128+ slide . addEventListener ( "visible" , scrollFirstHighlightIntoView ) ;
129+ }
97130
98131 highlightCodeBlock ( code ) ;
99132 } ) ;
@@ -143,6 +176,93 @@ window.QuartoLineHighlight = function () {
143176 }
144177 }
145178
179+ /**
180+ * Animates scrolling to the first highlighted line
181+ * in the given code block.
182+ */
183+ function scrollHighlightedLineIntoView ( block , scrollState , skipAnimation ) {
184+ window . cancelAnimationFrame ( scrollState . animationFrameID ) ;
185+
186+ // Match the scroll position of the currently visible
187+ // code block
188+ if ( scrollState . currentBlock ) {
189+ block . scrollTop = scrollState . currentBlock . scrollTop ;
190+ }
191+
192+ // Remember the current code block so that we can match
193+ // its scroll position when showing/hiding fragments
194+ scrollState . currentBlock = block ;
195+
196+ const highlightBounds = getHighlightedLineBounds ( block ) ;
197+ let viewportHeight = block . offsetHeight ;
198+
199+ // Subtract padding from the viewport height
200+ const blockStyles = window . getComputedStyle ( block ) ;
201+ viewportHeight -=
202+ parseInt ( blockStyles . paddingTop ) + parseInt ( blockStyles . paddingBottom ) ;
203+
204+ // Scroll position which centers all highlights
205+ const startTop = block . scrollTop ;
206+ let targetTop =
207+ highlightBounds . top +
208+ ( Math . min ( highlightBounds . bottom - highlightBounds . top , viewportHeight ) -
209+ viewportHeight ) /
210+ 2 ;
211+
212+ // Make sure the scroll target is within bounds
213+ targetTop = Math . max (
214+ Math . min ( targetTop , block . scrollHeight - viewportHeight ) ,
215+ 0
216+ ) ;
217+
218+ if ( skipAnimation === true || startTop === targetTop ) {
219+ block . scrollTop = targetTop ;
220+ } else {
221+ // Don't attempt to scroll if there is no overflow
222+ if ( block . scrollHeight <= viewportHeight ) return ;
223+
224+ let time = 0 ;
225+
226+ const animate = function ( ) {
227+ time = Math . min ( time + 0.02 , 1 ) ;
228+
229+ // Update our eased scroll position
230+ block . scrollTop =
231+ startTop + ( targetTop - startTop ) * easeInOutQuart ( time ) ;
232+
233+ // Keep animating unless we've reached the end
234+ if ( time < 1 ) {
235+ scrollState . animationFrameID = requestAnimationFrame ( animate ) ;
236+ }
237+ } ;
238+
239+ animate ( ) ;
240+ }
241+ }
242+
243+ function getHighlightedLineBounds ( block ) {
244+ const highlightedLines = block . querySelectorAll ( ".highlight-line" ) ;
245+ if ( highlightedLines . length === 0 ) {
246+ return { top : 0 , bottom : 0 } ;
247+ } else {
248+ const firstHighlight = highlightedLines [ 0 ] ;
249+ const lastHighlight = highlightedLines [ highlightedLines . length - 1 ] ;
250+
251+ return {
252+ top : firstHighlight . offsetTop ,
253+ bottom : lastHighlight . offsetTop + lastHighlight . offsetHeight ,
254+ } ;
255+ }
256+ }
257+
258+ /**
259+ * The easing function used when scrolling.
260+ */
261+ function easeInOutQuart ( t ) {
262+ // easeInOutQuart
263+ return t < 0.5 ? 8 * t * t * t * t : 1 - 8 * -- t * t * t * t ;
264+ }
265+
146266 function splitLineNumbers ( lineNumbersAttr ) {
147267 // remove space
148268 lineNumbersAttr = lineNumbersAttr . replace ( "/s/g" , "" ) ;
@@ -196,6 +316,22 @@ window.QuartoLineHighlight = function () {
196316 id : "quarto-line-highlight" ,
197317 init : function ( deck ) {
198318 initQuartoLineHighlight ( deck ) ;
319+
320+ // If we're printing to PDF, scroll the code highlights of
321+ // all blocks in the deck into view at once
322+ deck . on ( "pdf-ready" , function ( ) {
323+ [ ] . slice
324+ . call (
325+ deck
326+ . getRevealElement ( )
327+ . querySelectorAll (
328+ "pre code[data-code-line-numbers].current-fragment"
329+ )
330+ )
331+ . forEach ( function ( block ) {
332+ scrollHighlightedLineIntoView ( block , { } , true ) ;
333+ } ) ;
334+ } ) ;
199335 } ,
200336 } ;
201337} ;
0 commit comments