bookclub-advr

DSLC Advanced R Book Club
git clone https://git.eamoncaddigan.net/bookclub-advr.git
Log | Files | Refs | README | LICENSE

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 };