Detecting Rendered Line Breaks in a Text Node in JavaScript

https://www.bennadel.com/blog/4310-detecting-rendered-line-breaks-in-a-text-node-in-javascript.htm

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Detecting Rendered Line Breaks In A Text Node In JavaScript</title>
    <link rel="stylesheet" type="text/css" href="./main.css" />
  </head>
  <body>
    <h1>Detecting Rendered Line Breaks In A Text Node In JavaScript</h1>

    <p class="sample" style="width: 400px ;">
      You fell victim to one of the classic blunders-the most famous of which
      is, "Never get involved in a land war in Asia" - but only slightly less
      well-known is this: "Never go against a Sicilian when death is on the
      line"! Ha ha ha ha ha ha ha! Ha ha ha ha ha ha ha!
    </p>

    <p>
      <button class="button">Detect Line Breaks</button>
    </p>

    <script type="text/javascript">
      var source = document.querySelector(".sample").firstChild;
      var button = document.querySelector(".button");

      // When the user clicks the button, process the text node.
      button.addEventListener("click", function handleClick(event) {
        logLines(extractLinesFromTextNode(source));
      });

      // --------------------------------------------------------------------------- //
      // --------------------------------------------------------------------------- //

      /**
       * I extract the visually rendered lines of text from the given textNode as it
       * exists in the document at this very moment. Meaning, it returns the lines of
       * text as seen by the user.
       */
      function extractLinesFromTextNode(textNode) {
        if (textNode.nodeType !== 3) {
          throw new Error("Lines can only be extracted from text nodes.");
        }

        // BECAUSE SAFARI: None of the "modern" browsers seem to care about the actual
        // layout of the underlying markup. However, Safari seems to create range
        // rectangles based on the physical structure of the markup (even when it
        // makes no difference in the rendering of the text). As such, let's rewrite
        // the text content of the node to REMOVE SUPERFLUOS WHITE-SPACE. This will
        // allow Safari's .getClientRects() to work like the other modern browsers.
        textNode.textContent = collapseWhiteSpace(textNode.textContent);

        // A Range represents a fragment of the document which contains nodes and
        // parts of text nodes. One thing that's really cool about a Range is that we
        // can access the bounding boxes that contain the contents of the Range. By
        // incrementally adding characters - from our text node - into the range, and
        // then looking at the Range's client rectangles, we can determine which
        // characters belong in which rendered line.
        var textContent = textNode.textContent;
        var range = document.createRange();
        var lines = [];
        var lineCharacters = [];

        // Iterate over every character in the text node.
        for (var i = 0; i < textContent.length; i++) {
          // Set the range to span from the beginning of the text node up to and
          // including the current character (offset).
          range.setStart(textNode, 0);
          range.setEnd(textNode, i + 1);

          // At this point, the Range's client rectangles will include a rectangle
          // for each visually-rendered line of text. Which means, the last
          // character in our Range (the current character in our for-loop) will be
          // the last character in the last line of text (in our Range). As such, we
          // can use the current rectangle count to determine the line of text.
          var lineIndex = range.getClientRects().length - 1;

          // If this is the first character in this line, create a new buffer for
          // this line.
          if (!lines[lineIndex]) {
            lines.push((lineCharacters = []));
          }

          // Add this character to the currently pending line of text.
          lineCharacters.push(textContent.charAt(i));
        }

        // At this point, we have an array (lines) of arrays (characters). Let's
        // collapse the character buffers down into a single text value.
        lines = lines.map(function operator(characters) {
          return collapseWhiteSpace(characters.join(""));
        });

        // DEBUGGING: Draw boxes around our client rectangles.
        drawRectBoxes(range.getClientRects());

        return lines;
      }

      /**
       * I normalize the white-space in the given value such that the amount of white-
       * space matches the rendered white-space (browsers collapse strings of white-space
       * down to single space character, visually, and this is just updating the text to
       * match that behavior).
       */
      function collapseWhiteSpace(value) {
        return value.trim().replace(/\s+/g, " ");
      }

      /**
       * I draw red boxes on the screen for the given client rects.
       */
      function drawRectBoxes(clientRects) {
        arrayFrom(document.querySelectorAll(".box")).forEach(function iterator(
          node
        ) {
          node.remove();
        });

        arrayFrom(clientRects).forEach(function iterator(rect) {
          var box = document.createElement("div");
          box.classList.add("box");
          box.style.top = rect.y + "px";
          box.style.left = rect.x + "px";
          box.style.width = rect.width + "px";
          box.style.height = rect.height + "px";
          document.body.appendChild(box);
        });
      }

      /**
       * I log the given lines of text using a grouped output.
       */
      function logLines(lines) {
        console.group("Rendered Lines of Text");

        lines.forEach(function iterator(line, i) {
          console.log(i, line);
        });

        console.groupEnd();
      }

      /**
       * I create a true array from the given array-like data. Array.from() if you are on
       * modern browsers.
       */
      function arrayFrom(arrayLike) {
        return Array.prototype.slice.call(arrayLike);
      }
    </script>
  </body>
</html>