search

Home  >  Q&A  >  body text

Preserve caret position in HTML contenteditable when inner HTML changes

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

Issue: When typing letters, the caret position is reset to the beginning of the div.


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

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

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


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

Markdown editors like QuillJS seem to be able 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 to prevent the caret position from being reset when typing?

renew: I've 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


P粉668804228P粉668804228382 days ago796

reply all(2)I'll reply

  • P粉393030917

    P粉3930309172023-11-09 18:34:46

    What most rich text editors do is keep their own internal state, update it on key events and render a custom visual layer. For example:

    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;}
    }

    reply
    0
  • P粉060112396

    P粉0601123962023-11-09 14:42:18

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

    Restoring the cursor position is the tricky part when there are nested elements. Additionally, you will create new and elements every time and the old ones will be 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, "****") // bold
          .replace(/(?*") // italic
          // handle special characters
          .replace(/\n/gm, "
    ") .replace(/\t/gm, " ") ); } // 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;
    }

    reply
    0
  • Cancelreply