#StackBounty: #javascript #cursor #richtextbox Fix cursor position when replacing innerHTML of <div contenteditable="true"…

Bounty: 50

What’s the best way to keep the cursor in the right place when typing inside of a <div id="richTextBox" contenteditable="true"></div> whose innerHTML changes on each keystroke? The act of replacing the innerHTML messes up the cursor position.

The reason I change the innerHTML is because I am adding <span> tags. It’s part of a code highlighting program. The span tags allow me to place the correct color highlights.

I am using the below code from a StackOverflow answer as a band aid for the moment, but it has a significant bug. If you hit enter, the cursor stays at the old spot, or goes to a random spot. That’s because the algorithm counts how many characters from the beginning the cursor is. But it doesn’t count HTML tags or line breaks as characters. And the richTextBox inserts <br> to make enters.

Ideas for fixing:

  • Fix the below code? See Fiddle
  • Replace with simpler code? I tried a bunch of simpler stuff involving window.getSelection() and document.createRange(), but I could not get that to work.
  • Replace with a richTextBox library or module that doesn’t have this bug?

Screenshot

screenshot of richTextBox rendered in JSFiddle

// Credit to Liam (Stack Overflow)
// https://stackoverflow.com/a/41034697/3480193
class Cursor {
  static getCurrentCursorPosition(parentElement) {
    var selection = window.getSelection(),
      charCount = -1,
      node;

    if (selection.focusNode) {
      if (Cursor._isChildOf(selection.focusNode, parentElement)) {
        node = selection.focusNode; 
        charCount = selection.focusOffset;

        while (node) {
          if (node === parentElement) {
            break;
          }

          if (node.previousSibling) {
            node = node.previousSibling;
            charCount += node.textContent.length;
          } else {
            node = node.parentNode;
            if (node === null) {
              break;
            }
          }
        }
      }
    }

    return charCount;
  }

  static setCurrentCursorPosition(chars, element) {
    if (chars >= 0) {
      var selection = window.getSelection();

      let range = Cursor._createRange(element, { count: chars });

      if (range) {
        range.collapse(false);
        selection.removeAllRanges();
        selection.addRange(range);
      }
    }
  }

  static _createRange(node, chars, range) {
    if (!range) {
      range = document.createRange()
      range.selectNode(node);
      range.setStart(node, 0);
    }

    if (chars.count === 0) {
      range.setEnd(node, chars.count);
    } else if (node && chars.count >0) {
      if (node.nodeType === Node.TEXT_NODE) {
        if (node.textContent.length < chars.count) {
          chars.count -= node.textContent.length;
        } else {
          range.setEnd(node, chars.count);
          chars.count = 0;
        }
      } else {
        for (var lp = 0; lp < node.childNodes.length; lp++) {
          range = Cursor._createRange(node.childNodes[lp], chars, range);

          if (chars.count === 0) {
          break;
          }
        }
      }
    } 

    return range;
  }

  static _isChildOf(node, parentElement) {
    while (node !== null) {
      if (node === parentElement) {
        return true;
      }
      node = node.parentNode;
    }

    return false;
  }
}

window.addEventListener('DOMContentLoaded', (e) => {
  let richText = document.getElementById('rich-text');

  richText.addEventListener('input', function(e) {
    let offset = Cursor.getCurrentCursorPosition(richText);
    // Pretend we do stuff with innerHTML here. The innerHTML will end up getting replaced with slightly changed code.
    let s = richText.innerHTML;
    richText.innerHTML = "";
    richText.innerHTML = s;
    Cursor.setCurrentCursorPosition(offset, richText);
    richText.focus(); // blinks the cursor
  });
});
body {
  margin: 1em;
}

#rich-text {
  width: 100%;
  height: 450px;
  border: 1px solid black;
  cursor: text;
  overflow: scroll;
  resize: both;
  /* in Chrome, must have display: inline-block for contenteditable=true to prevent it from adding <div> <p> and <span> when you type. */
  display: inline-block;
}
<p>
Click somewhere in the middle of line 1. Hit enter. Start typing. Cursor is in the wrong place.
</p>

<p>
Reset. Click somewhere in the middle of line 1. Hit enter. Hit enter again. Cursor goes to some random place.
</p>

<div id="rich-text" contenteditable="true">Testing 123<br />Testing 456</div>

    

Browser

Google Chrome v83, Windows 7


Get this bounty!!!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.