line-highlight.js (11549B)
1 window.QuartoLineHighlight = function () { 2 function isPrintView() { 3 return /print-pdf/gi.test(window.location.search) || /view=print/gi.test(window.location.search); 4 } 5 6 const delimiters = { 7 step: "|", 8 line: ",", 9 lineRange: "-", 10 }; 11 12 const regex = new RegExp( 13 "^[\\d" + Object.values(delimiters).join("") + "]+$" 14 ); 15 16 function handleLinesSelector(deck, attr) { 17 // if we are in printview with pdfSeparateFragments: false 18 // then we'll also want to supress 19 if (regex.test(attr)) { 20 if (isPrintView() && deck.getConfig().pdfSeparateFragments !== true) { 21 return false; 22 } else { 23 return true; 24 } 25 } else { 26 return false; 27 } 28 } 29 30 const kCodeLineNumbersAttr = "data-code-line-numbers"; 31 const kFragmentIndex = "data-fragment-index"; 32 33 function initQuartoLineHighlight(deck) { 34 const divSourceCode = deck 35 .getRevealElement() 36 .querySelectorAll("div.sourceCode"); 37 // Process each div created by Pandoc highlighting - numbered line are already included. 38 divSourceCode.forEach((el) => { 39 if (el.hasAttribute(kCodeLineNumbersAttr)) { 40 const codeLineAttr = el.getAttribute(kCodeLineNumbersAttr); 41 el.removeAttribute(kCodeLineNumbersAttr); 42 if (handleLinesSelector(deck, codeLineAttr)) { 43 // Only process if attr is a string to select lines to highlights 44 // e.g "1|3,6|8-11" 45 const codeBlock = el.querySelectorAll("pre code"); 46 codeBlock.forEach((code) => { 47 // move attributes on code block 48 code.setAttribute(kCodeLineNumbersAttr, codeLineAttr); 49 50 const scrollState = { currentBlock: code }; 51 52 // Check if there are steps and duplicate code block accordingly 53 const highlightSteps = splitLineNumbers(codeLineAttr); 54 if (highlightSteps.length > 1) { 55 // If the original code block has a fragment-index, 56 // each clone should follow in an incremental sequence 57 let fragmentIndex = parseInt( 58 code.getAttribute(kFragmentIndex), 59 10 60 ); 61 fragmentIndex = 62 typeof fragmentIndex !== "number" || isNaN(fragmentIndex) 63 ? null 64 : fragmentIndex; 65 66 let stepN = 1; 67 highlightSteps.slice(1).forEach( 68 // Generate fragments for all steps except the original block 69 (step) => { 70 var fragmentBlock = code.cloneNode(true); 71 fragmentBlock.setAttribute( 72 "data-code-line-numbers", 73 joinLineNumbers([step]) 74 ); 75 fragmentBlock.classList.add("fragment"); 76 77 // Pandoc sets id on spans we need to keep unique 78 fragmentBlock 79 .querySelectorAll(":scope > span") 80 .forEach((span) => { 81 if (span.hasAttribute("id")) { 82 span.setAttribute( 83 "id", 84 span.getAttribute("id").concat("-" + stepN) 85 ); 86 } 87 }); 88 stepN = ++stepN; 89 90 // Add duplicated <code> element after existing one 91 code.parentNode.appendChild(fragmentBlock); 92 93 // Each new <code> element is highlighted based on the new attributes value 94 highlightCodeBlock(fragmentBlock); 95 96 if (typeof fragmentIndex === "number") { 97 fragmentBlock.setAttribute(kFragmentIndex, fragmentIndex); 98 fragmentIndex += 1; 99 } else { 100 fragmentBlock.removeAttribute(kFragmentIndex); 101 } 102 103 // Scroll highlights into view as we step through them 104 fragmentBlock.addEventListener( 105 "visible", 106 scrollHighlightedLineIntoView.bind( 107 this, 108 fragmentBlock, 109 scrollState 110 ) 111 ); 112 fragmentBlock.addEventListener( 113 "hidden", 114 scrollHighlightedLineIntoView.bind( 115 this, 116 fragmentBlock.previousSibling, 117 scrollState 118 ) 119 ); 120 } 121 ); 122 code.removeAttribute(kFragmentIndex); 123 code.setAttribute( 124 kCodeLineNumbersAttr, 125 joinLineNumbers([highlightSteps[0]]) 126 ); 127 } 128 129 // Scroll the first highlight into view when the slide becomes visible. 130 const slide = 131 typeof code.closest === "function" 132 ? code.closest("section:not(.stack)") 133 : null; 134 if (slide) { 135 const scrollFirstHighlightIntoView = function () { 136 scrollHighlightedLineIntoView(code, scrollState, true); 137 slide.removeEventListener( 138 "visible", 139 scrollFirstHighlightIntoView 140 ); 141 }; 142 slide.addEventListener("visible", scrollFirstHighlightIntoView); 143 } 144 145 highlightCodeBlock(code); 146 }); 147 } 148 } 149 }); 150 } 151 152 function highlightCodeBlock(codeBlock) { 153 const highlightSteps = splitLineNumbers( 154 codeBlock.getAttribute(kCodeLineNumbersAttr) 155 ); 156 157 if (highlightSteps.length) { 158 // If we have at least one step, we generate fragments 159 highlightSteps[0].forEach((highlight) => { 160 // Add expected class on <pre> for reveal CSS 161 codeBlock.parentNode.classList.add("code-wrapper"); 162 163 // Select lines to highlight 164 spanToHighlight = []; 165 if (typeof highlight.last === "number") { 166 spanToHighlight = [].slice.call( 167 codeBlock.querySelectorAll( 168 ":scope > span:nth-of-type(n+" + 169 highlight.first + 170 "):nth-of-type(-n+" + 171 highlight.last + 172 ")" 173 ) 174 ); 175 } else if (typeof highlight.first === "number") { 176 spanToHighlight = [].slice.call( 177 codeBlock.querySelectorAll( 178 ":scope > span:nth-of-type(" + highlight.first + ")" 179 ) 180 ); 181 } 182 if (spanToHighlight.length) { 183 // Add a class on <code> and <span> to select line to highlight 184 spanToHighlight.forEach((span) => 185 span.classList.add("highlight-line") 186 ); 187 codeBlock.classList.add("has-line-highlights"); 188 } 189 }); 190 } 191 } 192 193 /** 194 * Animates scrolling to the first highlighted line 195 * in the given code block. 196 */ 197 function scrollHighlightedLineIntoView(block, scrollState, skipAnimation) { 198 window.cancelAnimationFrame(scrollState.animationFrameID); 199 200 // Match the scroll position of the currently visible 201 // code block 202 if (scrollState.currentBlock) { 203 block.scrollTop = scrollState.currentBlock.scrollTop; 204 } 205 206 // Remember the current code block so that we can match 207 // its scroll position when showing/hiding fragments 208 scrollState.currentBlock = block; 209 210 const highlightBounds = getHighlightedLineBounds(block); 211 let viewportHeight = block.offsetHeight; 212 213 // Subtract padding from the viewport height 214 const blockStyles = window.getComputedStyle(block); 215 viewportHeight -= 216 parseInt(blockStyles.paddingTop) + parseInt(blockStyles.paddingBottom); 217 218 // Scroll position which centers all highlights 219 const startTop = block.scrollTop; 220 let targetTop = 221 highlightBounds.top + 222 (Math.min(highlightBounds.bottom - highlightBounds.top, viewportHeight) - 223 viewportHeight) / 224 2; 225 226 // Make sure the scroll target is within bounds 227 targetTop = Math.max( 228 Math.min(targetTop, block.scrollHeight - viewportHeight), 229 0 230 ); 231 232 if (skipAnimation === true || startTop === targetTop) { 233 block.scrollTop = targetTop; 234 } else { 235 // Don't attempt to scroll if there is no overflow 236 if (block.scrollHeight <= viewportHeight) return; 237 238 let time = 0; 239 240 const animate = function () { 241 time = Math.min(time + 0.02, 1); 242 243 // Update our eased scroll position 244 block.scrollTop = 245 startTop + (targetTop - startTop) * easeInOutQuart(time); 246 247 // Keep animating unless we've reached the end 248 if (time < 1) { 249 scrollState.animationFrameID = requestAnimationFrame(animate); 250 } 251 }; 252 253 animate(); 254 } 255 } 256 257 function getHighlightedLineBounds(block) { 258 const highlightedLines = block.querySelectorAll(".highlight-line"); 259 if (highlightedLines.length === 0) { 260 return { top: 0, bottom: 0 }; 261 } else { 262 const firstHighlight = highlightedLines[0]; 263 const lastHighlight = highlightedLines[highlightedLines.length - 1]; 264 265 return { 266 top: firstHighlight.offsetTop, 267 bottom: lastHighlight.offsetTop + lastHighlight.offsetHeight, 268 }; 269 } 270 } 271 272 /** 273 * The easing function used when scrolling. 274 */ 275 function easeInOutQuart(t) { 276 // easeInOutQuart 277 return t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t; 278 } 279 280 function splitLineNumbers(lineNumbersAttr) { 281 // remove space 282 lineNumbersAttr = lineNumbersAttr.replace("/s/g", ""); 283 // seperate steps (for fragment) 284 lineNumbersAttr = lineNumbersAttr.split(delimiters.step); 285 286 // for each step, calculate first and last line, if any 287 return lineNumbersAttr.map((highlights) => { 288 // detect lines 289 const lines = highlights.split(delimiters.line); 290 return lines.map((range) => { 291 if (/^[\d-]+$/.test(range)) { 292 range = range.split(delimiters.lineRange); 293 const firstLine = parseInt(range[0], 10); 294 const lastLine = range[1] ? parseInt(range[1], 10) : undefined; 295 return { 296 first: firstLine, 297 last: lastLine, 298 }; 299 } else { 300 return {}; 301 } 302 }); 303 }); 304 } 305 306 function joinLineNumbers(splittedLineNumbers) { 307 return splittedLineNumbers 308 .map(function (highlights) { 309 return highlights 310 .map(function (highlight) { 311 // Line range 312 if (typeof highlight.last === "number") { 313 return highlight.first + delimiters.lineRange + highlight.last; 314 } 315 // Single line 316 else if (typeof highlight.first === "number") { 317 return highlight.first; 318 } 319 // All lines 320 else { 321 return ""; 322 } 323 }) 324 .join(delimiters.line); 325 }) 326 .join(delimiters.step); 327 } 328 329 return { 330 id: "quarto-line-highlight", 331 init: function (deck) { 332 initQuartoLineHighlight(deck); 333 334 // If we're printing to PDF, scroll the code highlights of 335 // all blocks in the deck into view at once 336 deck.on("pdf-ready", function () { 337 [].slice 338 .call( 339 deck 340 .getRevealElement() 341 .querySelectorAll( 342 "pre code[data-code-line-numbers].current-fragment" 343 ) 344 ) 345 .forEach(function (block) { 346 scrollHighlightedLineIntoView(block, {}, true); 347 }); 348 }); 349 }, 350 }; 351 };