6

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

ADAMJR
  • 1,880
  • 1
  • 14
  • 34

4 Answers4

15

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.
the Hutt
  • 16,980
  • 2
  • 14
  • 44
  • 2
    This works more smoothly than Olian04's answer (that one doesn't always seem to accept input). Tested on Mac, Chrome 96 and Firefox 95. – Katharine Osborne Jan 26 '22 at 13:37
  • Tab or new line doesn't work. – The concise Oct 17 '22 at 21:26
  • @Theconcise `\n` to `
    ` conversion is required for new line. Same goes for other special characters. I've updated the demo code; tried to handle tab and newline. To test tab you'll have to paste the tab character.
    – the Hutt Nov 14 '22 at 07:35
5

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>
Olian04
  • 6,480
  • 2
  • 27
  • 54
  • Previously, I did try having a div being a visual layer over a text box. The user typed in a hidden text box while the value was parsed on a div. It worked mostly well, apart from the caret which was hidden with the text box. – ADAMJR Nov 13 '21 at 20:25
  • This is a great answer. Do you happen to have a link to a Codesandbox/CodePen of this implementation? Would be good to see – Drenai Nov 13 '21 at 22:55
  • 1
    @Drenai I don't quite follow. What would a separate tool achieve that you cant achieve right here on SO? The code is right there in the answer, and you can run it by clicking on "Run code snippet" – Olian04 Nov 14 '21 at 09:09
2

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.

jcubic
  • 61,973
  • 54
  • 229
  • 402
1

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.