HTML contenteditable: Keep Caret Position When Inner HTML Changes

Viewed 1002

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

4 Answers

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. For *italic* one star. Some more **bold**.');

editor.addEventListener('input', () => {
  //get current cursor position
  let sel = window.getSelection();
  let node = sel.focusNode;
  let offset = sel.focusOffset;
  let pos = getCursorPosition(editor, node, offset, {pos: 0, done: false});
  //console.log('Position: ', pos);

  editor.innerHTML = parse(editor.innerText);

  // restore the position
  sel.removeAllRanges();
  let 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
}

// 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 (var 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 (var i = 0; i < parent.childNodes.length && !stat.done; i++) {
      currentNode = parent.childNodes[i];
      setCursorPosition(currentNode, range, stat);
    }
  }
  return range;
}
.editor {
  height: 100px;
  width: 300px;
  border: 1px solid #888;
  padding: 0.5rem;
}

em,strong{
font-size: 1.3rem;
}
<div class='editor' contenteditable />

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.

The way most rich text editors does it is by keeping their own internal state, updating it on key down events and rendering a custom visual layer. For example like this:

const $editor = document.querySelector('.editor');
const state = {
 cursorPosition: 0,
 contents: 'hello world'.split(''),
 isFocused: false,
};


const $cursor = document.createElement('span');
$cursor.classList.add('cursor');
$cursor.innerText = '᠎'; // Mongolian vowel separator

const renderEditor = () => {
  const $contents = state.contents
    .map(char => {
      const $span = document.createElement('span');
      $span.innerText = char;
      return $span;
    });
  
  $contents.splice(state.cursorPosition, 0, $cursor);
  
  $editor.innerHTML = '';
  $contents.forEach(el => $editor.append(el));
}

document.addEventListener('click', (ev) => {
  if (ev.target === $editor) {
    $editor.classList.add('focus');
    state.isFocused = true;
  } else {
    $editor.classList.remove('focus');
    state.isFocused = false;
  }
});

document.addEventListener('keydown', (ev) => {
  if (!state.isFocused) return;
  
  switch(ev.key) {
    case 'ArrowRight':
      state.cursorPosition = Math.min(
        state.contents.length, 
        state.cursorPosition + 1
      );
      renderEditor();
      return;
    case 'ArrowLeft':
      state.cursorPosition = Math.max(
        0, 
        state.cursorPosition - 1
      );
      renderEditor();
      return;
    case 'Backspace':
      if (state.cursorPosition === 0) return;
      delete state.contents[state.cursorPosition-1];
      state.contents = state.contents.filter(Boolean);
      state.cursorPosition = Math.max(
        0, 
        state.cursorPosition - 1
      );
      renderEditor();
      return;
    default:
      // This is very naive
      if (ev.key.length > 1) return;
      state.contents.splice(state.cursorPosition, 0, ev.key);
      state.cursorPosition += 1;
      renderEditor();
      return;
  }  
});

renderEditor();
.editor {
  position: relative;
  min-height: 100px;
  max-height: max-content;
  width: 100%;
  border: black 1px solid;
}

.editor.focus {
  border-color: blue;
}

.editor.focus .cursor {
  position: absolute;
  border: black solid 1px;
  border-top: 0;
  border-bottom: 0;
  animation-name: blink;
  animation-duration: 1s;
  animation-iteration-count: infinite;
}

@keyframes blink {
  from {opacity: 0;}
  50% {opacity: 1;}
  to {opacity: 0;}
}
<div class="editor"></div>

You need to keep the state of the position and restore it on each input. There is no other way. You can look at how content editable is handled in my project jQuery Terminal (the links point to specific lines in source code and use commit hash, current master when I've written this, so they will always point to those lines).

  • insert method that is used when user type something (or on copy-paste).
  • fix_textarea - the function didn't changed after I've added content editable. The function makes sure that textarea or contenteditable (that are hidden) have the same state as the visible cursor.
  • clip object (that is textarea or content editable - another not refactored name that in beginning was only for clipboard).

For position I use jQuery Caret that is the core of moving the cursor. You can easily modify this code and make it work as you want. jQuery plugin can be easily refactored into a function move_cursor.

This should give you an idea how to implement this on your own in your project.

You can use window.getSelection to get the current position and, after parsing, move the cursor to again this position with sel.modify.

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

sel = window.getSelection()

editor.addEventListener('input', () => {
 
  sel.extend(editor, 0)
  pos = sel.toString().length

  editor.innerHTML = parse(editor.innerText)

  while (pos-->0)
    sel.modify('move', 'forward', "character")
})

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

That said, note the edit history is gone (i.e. no undo), when using editor.innerHTML = ....

As other indicated, it seems better to separate editing and rendering. I call this pseudo-contenteditable. I asked a question related to this Pseudo contenteditable: how does codemirror works?. Still waiting for an answer. But the basic idea might look this https://jsfiddle.net/Lfbt4c7p.

Related