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

1

7

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://mcmap.net/q/99894/-how-to-set-the-caret-cursor-position-in-a-contenteditable-element-div
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

Saito answered 2/7, 2020 at 21:40 Comment(2)
Have you considered a parser like jquery's $(selector).text() instead of innerHTML? It strips tags. Using that might save the headache of trying to count how many tags you inserted or removed... it would just give the text content as opposed to the length of the HTML.Comedienne
@Comedienne Good idea. I'll give that a try.Saito
F
4

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.


Fiber answered 9/7, 2020 at 11:0 Comment(5)
Thank you! The fix seems to work. However, the new isChildOf function is throwing Uncaught ReferenceError: isChildOf is not defined. Any idea what's going on?Saito
I wonder how the code even works with that error being thrown. I tried commenting out the part of the code that in theory shouldn't be getting executed because of the Syntax Error, but commenting it out breaks your fix. I am quite stumped as to what is going on.Saito
Ah, yes, the fiddle wasn't quite set up right for me - I didn't get that error, but I see that you can cause an error there, which then breaks and cancels all of the cursor-moving code, meaning it acts like it normally would, probably not what you want!Fiber
@LukeStorry None of the answers actually work, so the solution here is still much appreciated. Tbh, I don't see how Caret.js solves the problem - from the demo at the link you provided they have a cursor at the absolute screen coordinates, not character count. Maybe I'm just missing something - let's say you obtained those value, how do you put a cursor at this position?Silber
My bad, I do see the examples now but both projects seem abandoned already and there aren't any alternatives. If you know any, I'll greatly appreciate it.Silber

© 2022 - 2024 — McMap. All rights reserved.