Stephen Haney

Get contenteditable plaintext with correct linebreaks

What do you do if you need plaintext from a contenteditable HTML element with correct linebreaks?

It should be simple – just use element.innerText.

But TIL there are bugs with contenteditable and innerText linebreaks. For me, Chrome reports too many linebreaks. And I’m finding reports of FireFox doing the same.

// What my content editable looks like, a single line between
a

b

// What Chrome innerText reports, two lines between
a


b

I created a reproduction case codepen here, type type “a -> return -> return -> b” in the left and you’ll see the wrong result pop up on the right hand side (unless it’s been fixed by now!)

Where did this come from?

The problem, it seems, originates from browsers making up their own HTML for contenteditable. Some use divs, some use p tags, others prefer br. Chrome seems to mix and match divs and br elements even now.

No wonder innerText returns the wrong result – it’s hard to parse unpredictable HTML!

It sounds like browsers have tried to align on a standard for contenteditable HTML… my guess is that those efforts broke the innerText linebreak parsing without anyone noticing.

There’s a great history on MDN if you’d like more info.

A solution emerges

Ok, so what do we do for now if we need a plaintext value but also need perfect linebreaks?

Here is a snippet I’m using – it’s working but I haven’t tried to account for use cases beyond my own. So… apply it with your own critical thinking.

let newValue = '';
let isOnFreshLine = true;

// Recursive function to navigate childNodes and build linebreaks with text
function parseChildNodesForValueAndLines(childNodes: NodeListOf<ChildNode>) {
  for (let i = 0; i < childNodes.length; i++) {
    const childNode = childNodes[i];

    if (childNode.nodeName === 'BR') {
      // BRs are always line breaks which means the next loop is on a fresh line
      newValue += '\n';
      isOnFreshLine = true;
      continue;
    }

    // We may or may not need to create a new line
    if (childNode.nodeName === 'DIV' && isOnFreshLine === false) {
      // Divs create new lines for themselves if they aren't already on one
      newValue += '\n';
    }

    // Whether we created a new line or not, we'll use it for this content so the next loop will not be on a fresh line:
    isOnFreshLine = false;

    // Add the text content if this is a text node:
    if (childNode.nodeType === 3 && childNode.textContent) {
      newValue += childNode.textContent;
    }

    // If this node has children, get into them as well:
    parseChildNodesForValueAndLines(childNode.childNodes);
  }
}

// Parse the child nodes for HTML and newlines:
parseChildNodesForValueAndLines(e.currentTarget.childNodes);

// Do whatever you want with newValue now

You’ll also need `white-space: ‘pre-wrap’` set on your elements to preserve your linebreaks.

In testing that, I’ve another found issue in Safari where a single unbreakable word that’s too wide for its element will render with an extra incorrect linebreak – but not when contenteditable is set! But that’s an issue for another day.

I hope this experience helps someone else who stumbles upon this problem.