Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Fix cursor position when replacing innerHTML of <div contenteditable="true">

How can I 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

like image 287
RedDragonWebDesign Avatar asked Jul 02 '20 21:07

RedDragonWebDesign


1 Answers

The issue seems to be that adding a new line adds a <br>, but as you are still in the parent element, previous DOM children are not taken into account, and the selection.focusOffset only gives the value of 4.

It may help to add a newline to the end of the innerHtml, as it is being stripped when you remove and re-add it. + "\n" to the end of line 100 on the Fiddle would do.


Your main problem though is that getCurrentCursorPosition you copied from that other StackOverflow question doesn't actually work.

I'd suggest you go through some of the other answers to this question: Get contentEditable caret index position, and console.log what they output and see which one works best for your edge-cases.

If you don't want to write it yourself, then Caret.js (part of the At.js editor library) would be useful.


like image 197
Luke Storry Avatar answered Oct 06 '22 12:10

Luke Storry