Find rendered line breaks with javascript

You can make use of the Range API and its handy getBoundingClientRect() method to determine which character marks the seizure in a TextNode.

Note that this obviously needs to be recalculated every time the window is resized / something changes the layout.

function getLineBreaks(node) {
  // we only deal with TextNodes
  if(!node || !node.parentNode || node.nodeType !== 3)
    return [];
  // our Range object form which we'll get the characters positions
  const range = document.createRange();
  // here we'll store all our lines
  const lines = [];
  // begin at the first char
  range.setStart(node, 0);
  // initial position
  let prevBottom = range.getBoundingClientRect().bottom;
  let str = node.textContent;
  let current = 1; // we already got index 0
  let lastFound = 0;
  let bottom = 0;
  // iterate over all characters
  while(current <= str.length) {
    // move our cursor
    range.setStart(node, current);
    if(current < str.length -1)
     range.setEnd(node, current+1);
    bottom = range.getBoundingClientRect().bottom;
    if(bottom > prevBottom) { // line break
      lines.push(
        str.substr(lastFound , current - lastFound) // text content
      );
      prevBottom = bottom;
      lastFound = current;
    }
    current++;
  }
  // push the last line
  lines.push(str.substr(lastFound));

  return lines;
}

console.log(getLineBreaks(document.querySelector('.test').childNodes[0]));
div.test {
  width: 50px;
  margin-bottom: 100px;
  word-break: break-all;
}

body>.as-console-wrapper{max-height:100px}
<div class="test">This is some quite long content that will wrap in multiple lines</div>

And if you need the relative y position of each lines:

function getLineBreaks(node) {
  // we only deal with TextNodes
  if(!node || !node.parentNode || node.nodeType !== 3)
    return [];
  // our Range object form which we'll get the characters positions
  const range = document.createRange();
  // here we'll store all our lines
  const lines = [];
  // begin at the first character
  range.setStart(node, 0);
  // get the position of the parent node so we can have relative positions later
  let contTop = node.parentNode.getBoundingClientRect().top;
  // initial position
  let prevBottom = range.getBoundingClientRect().bottom;
  let str = node.textContent;
  let current = 1; // we already got index 0
  let lastFound = 0;
  let bottom = 0;
  // iterate over all characters
  while(current <= str.length) {
    // move our cursor
    range.setStart(node, current);
    if(current < str.length - 1)
      range.setEnd(node, current+1); // wrap it (for Chrome...)
    bottom = range.getBoundingClientRect().bottom;
    if(bottom > prevBottom) { // line break
      lines.push({
        y: prevBottom - (contTop || 0), // relative bottom
        text: str.substr(lastFound , current - lastFound) // text content
      });
      prevBottom = bottom;
      lastFound = current;
    }
    current++;
  }
  // push the last line
  lines.push({
    y: bottom - (contTop || 0),
    text: str.substr(lastFound)
  });

  return lines;
}

console.log(getLineBreaks(document.querySelector('.test').childNodes[0]));
div.test {
  width: 50px;
  margin-bottom: 100px;
}

body>.as-console-wrapper{max-height:100px}
<div class="test">This is some quite long content that will wrap in multiple lines</div>

For the ones who need it to work over elements instead of a single text-node, here is a rewrite, which may very well fail (e.g with RTL direction) but which should be fine for most cases.

function getLineBreaks(elem) {
  // our Range object form which we'll get the characters positions
  const range = document.createRange();
  // here we'll store all our lines
  const lines = [];
  const nodes = grabTextNodes(elem);
  let left = 0;
  // get the position of the parent node so we can have relative positions later
  let contTop = nodes[0].parentNode.getBoundingClientRect().top;
  // initial position
  let prevLeft = null;
  let lineText = "";
  let startRange = null;
  for (const node of nodes) {
    let nodeText = node.textContent;
    const textLength = nodeText.length;
    let rangeIndex = 0;
    let textIndex = 0;
    while (rangeIndex <= textLength) {
      range.setStart(node, rangeIndex);
      if (rangeIndex < textLength - 1) {
        range.setEnd(node, rangeIndex + 1); // wrap the range (for Chrome...)
      }
      left = range.getBoundingClientRect().right;
      if (prevLeft === null) { // first pass
        prevLeft = left;
        startRange = range.cloneRange();
      } else if (left < prevLeft) { // line break
        // store the current line content
        lineText += nodeText.slice(0, textIndex);
        startRange.setEnd(range.endContainer, range.endOffset);
        const {
          bottom
        } = startRange.getBoundingClientRect();
        lines.push({
          y: bottom - contTop,
          text: lineText
        });
        // start a new line
        prevLeft = left;
        lineText = "";
        nodeText = nodeText.slice(textIndex);
        textIndex = 0;
        startRange = range.cloneRange();
      }
      rangeIndex++;
      textIndex++;
      prevLeft = left;
    }
    // add the remaining text from this node into the current line content
    lineText += nodeText;
  }
  // push the last line
  startRange.setEnd(range.endContainer, range.endOffset);
  const { bottom } = startRange.getBoundingClientRect();
  lines.push({
    y: bottom - contTop,
    text: lineText
  });
  return lines;
}

console.log(getLineBreaks(document.querySelector('.test')));

function grabTextNodes(elem) {
  const walker = document.createTreeWalker(elem, NodeFilter.SHOW_TEXT, null);
  const nodes = [];
  while (walker.nextNode()) {
    nodes.push(walker.currentNode);
  }
  return nodes;
}
div.test {
  width: 150px;
  margin-bottom: 100px;
}

.red {
  color: red;
}
<div class="test"><span class="red">This</span> is some quite long content that will wrap in <span class="red">mutiple</span> lines..</div>

Leave a Comment