Search code examples
javascripthtmlformsdomtext

HTML contenteditable: Keep Caret Position When Inner HTML Changes


I have a div that acts as a WYSIWYG editor. This acts as a text box but renders markdown syntax within it, to show live changes.

Problem: When a letter is typed, the caret position is reset to the start of the div.

const editor = document.querySelector('div');
editor.innerHTML = parse('**dlob**  *cilati*');

editor.addEventListener('input', () => {
  editor.innerHTML = parse(editor.innerText);
});

function parse(text) {
  return text
    .replace(/\*\*(.*)\*\*/gm, '**<strong>$1</strong>**')     // bold
    .replace(/\*(.*)\*/gm, '*<em>$1</em>*');                  // italic
}
div {
  height: 100vh;
  width: 100vw;
}
<div contenteditable />

Codepen: https://codepen.io/ADAMJR/pen/MWvPebK

Markdown editors like QuillJS seem to edit child elements without editing the parent element. This avoids the problem but I'm now sure how to recreate that logic with this setup.

Question: How would I get the caret position to not reset when typing?

Update: I have managed to send the caret position to the end of the div, on each input. However, this still essentially resets the position. https://codepen.io/ADAMJR/pen/KKvGNbY


Solution

  • You need to get position of the cursor first then process and set the content. Then restore the cursor position.

    Restoring cursor position is a tricky part when there are nested elements. Also you are creating new <strong> and <em> elements every time, old ones are being discarded.

    const editor = document.querySelector(".editor");
    editor.innerHTML = parse(
      "For **bold** two stars.\nFor *italic* one star. Some more **bold**."
    );
    
    editor.addEventListener("input", () => {
      //get current cursor position
      const sel = window.getSelection();
      const node = sel.focusNode;
      const offset = sel.focusOffset;
      const pos = getCursorPosition(editor, node, offset, { pos: 0, done: false });
      if (offset === 0) pos.pos += 0.5;
    
      editor.innerHTML = parse(editor.innerText);
    
      // restore the position
      sel.removeAllRanges();
      const range = setCursorPosition(editor, document.createRange(), {
        pos: pos.pos,
        done: false,
      });
      range.collapse(true);
      sel.addRange(range);
    });
    
    function parse(text) {
      //use (.*?) lazy quantifiers to match content inside
      return (
        text
          .replace(/\*{2}(.*?)\*{2}/gm, "**<strong>$1</strong>**") // bold
          .replace(/(?<!\*)\*(?!\*)(.*?)(?<!\*)\*(?!\*)/gm, "*<em>$1</em>*") // italic
          // handle special characters
          .replace(/\n/gm, "<br>")
          .replace(/\t/gm, "&#9;")
      );
    }
    
    // get the cursor position from .editor start
    function getCursorPosition(parent, node, offset, stat) {
      if (stat.done) return stat;
    
      let currentNode = null;
      if (parent.childNodes.length == 0) {
        stat.pos += parent.textContent.length;
      } else {
        for (let i = 0; i < parent.childNodes.length && !stat.done; i++) {
          currentNode = parent.childNodes[i];
          if (currentNode === node) {
            stat.pos += offset;
            stat.done = true;
            return stat;
          } else getCursorPosition(currentNode, node, offset, stat);
        }
      }
      return stat;
    }
    
    //find the child node and relative position and set it on range
    function setCursorPosition(parent, range, stat) {
      if (stat.done) return range;
    
      if (parent.childNodes.length == 0) {
        if (parent.textContent.length >= stat.pos) {
          range.setStart(parent, stat.pos);
          stat.done = true;
        } else {
          stat.pos = stat.pos - parent.textContent.length;
        }
      } else {
        for (let i = 0; i < parent.childNodes.length && !stat.done; i++) {
          currentNode = parent.childNodes[i];
          setCursorPosition(currentNode, range, stat);
        }
      }
      return range;
    }
    .editor {
      height: 100px;
      width: 400px;
      border: 1px solid #888;
      padding: 0.5rem;
      white-space: pre;
    }
    
    em, strong{
      font-size: 1.3rem;
    }
    <div class="editor" contenteditable ></div>

    The API window.getSelection returns Node and position relative to it. Every time you are creating brand new elements so we can't restore position using old node objects. So to keep it simple and have more control, we are getting position relative to the .editor using getCursorPosition function. And, after we set innerHTML content we restore the cursor position using setCursorPosition.
    Both functions work with nested elements.

    Also, improved the regular expressions: used (.*?) lazy quantifiers and lookahead and behind for better matching. You can find better expressions.

    Note:

    • I've tested the code on Chrome 97 on Windows 10.
    • Used recursive solution in getCursorPosition and setCursorPosition for the demo and to keep it simple.
    • Special characters like newline require conversion to their equivalent HTML form, e.g. <br>. Tab characters require white-space: pre set on the editable element. I've tried to handled \n, \t in the demo.