Minimal code editor in JavaScript

How would one write a code editor in JavaScript? A long time ago one would take a regular textarea to handle user input and would create another div that would do syntax highlighting etc. However, this approach quickly becomes unusable when someone opens it on mobile. That’s why CodeMirror has been rewritten to use contenteditable and many other editors followed this approach.

contenteditable

Contenteditable, as the name suggests, allows users to edit the contents of the element using the browser-native techniques - all shortcuts for text selection and navigation work as expected, so does the clipboard and other minor editing features.

Let’s try making a tiny code editor using a single contenteditable div and a bit of JavaScript.

We start with a minimal markup in a single HTML file. The only element we would need is a div:

<div class="editor" contenteditable="true" spellcheck="false">
  <div>function example() {</div>
  <div>  return 42;</div>
  <div>}</div>
</div>

At this point, our editor behaves much like textarea - one can type, select or edit text.

Let’s drop in some rudimentary syntax highlighting. It can be a function that takes a DOM element, iterates over all its children (in our case - lines) and wraps the keywords or other meaningful tokens into additional markup that would help styling and highlighting it with CSS:

const js = el => {
  for (const node of el.children) {
    const s = node.innerText
      .replace(/(\/\/.*)/g, '<em>$1</em>')
      .replace(
        /\b(new|if|else|do|while|switch|for|in|of|continue|break|return|typeof|function|var|const|let|\.length|\.\w+)(?=[^\w])/g,
        '<strong>$1</strong>',
      )
      .replace(/(".*?"|'.*?'|`.*?`)/g, '<strong><em>$1</em></strong>')
      .replace(/\b(\d+)/g, '<em><strong>$1</strong></em>');
    node.innerHTML = s.split('\n').join('<br/>');
  }
};

If we now apply the function to our editor - we would see that the code inside looks not so boring anymore, numbers and keywords look bolder and stand out, while comments and strings look italic.

handling cursor

However, as soon as we start editing the text - the highlighting remains static, so we need to re-apply our function every time the contents of the editor change. Now, if you start listening to the key presses and call the js(el) function on each of them - you will see the cursor behaves weirdly. It jumps to the start position every time. Thus, we need to save cursor position before applying the highlighter and restore it later.

To get caret position one may use the following function:

const caret = el => {
  const range = window.getSelection().getRangeAt(0);
  const prefix = range.cloneRange();
  prefix.selectNodeContents(el);
  prefix.setEnd(range.endContainer, range.endOffset);
  return prefix.toString().length;
};

It basically takes the current selection from the editor (a cursor is a zero-width selection where start and end are at the same position), and returns the position of its end. To restore the caret we would have to do something more tricky, we need to recursively iterate the child nodes of the highlighted text inside the editor and find the one containing the caret, and restore the selection range there:

const setCaret = (pos, parent) => {
  for (const node of parent.childNodes) {
    if (node.nodeType == Node.TEXT_NODE) {
      if (node.length >= pos) {
        const range = document.createRange();
        const sel = window.getSelection();
        range.setStart(node, pos);
        range.collapse(true);
        sel.removeAllRanges();
        sel.addRange(range);
        return -1;
      } else {
        pos = pos - node.length;
      }
    } else {
      pos = setCaret(pos, node);
      if (pos < 0) {
        return pos;
      }
    }
  }
  return pos;
};

Now we should be able to listen to key-up events and highlight contents of the editor without losing the caret position:

el.addEventListener('keyup', e => {
  if (e.keyCode >= 0x30 || e.keyCode == 0x20) {
    const pos = caret();
    highlight(el);
    setCaret(pos);
  }
});

line numbers

A nice bonus here would be line numbers. Of course, one can render a column of small divs, each containing a line number aligned with a row in a contenteditable and manipulate them every time line count changes. But with CSS counters this can be achieved with almost no code:

.editor {
  ...
  counter-reset: line; /* reset the "line" counter */
}
.editor div::before {
  ...
  content: counter(line);   /* insert a div with the value of the counter */
  counter-increment: line;  /* increment the counter */
  position: absolute;
  right: calc(100% + 16px); /* add some space between this and the code line next to it */
}

Here’s how it looks like, with no additional styling:

js-editor

Not bad for a low-code solution?

a fly in the ointment

Why did I have to filter out key presses to apply highlighting only after some printable character was typed? Unfortunately, Chrome seems to calculate cursor position when a newline is added or removed, also when someone moves caret with arrow keys or selects some text with hotkeys - calling setCaret() would rather prevent his intentions.

A possible alternative here would be to use a different type of contenteditable attribute, contenteditable="plaintext-only". This is not standardized yet, however Chrome and WebKit browsers support it. This, however, would make it impossible to use CSS for line numbers, and one would have to re-implement it in JavaScript.

So, on the one hand, it seems to be possible to write a lightweight code editor in under 50 lines of vanilla JavaScript. But for a more featureful and production-ready code editor one should probably use battle-tested implementations, like CodeMirror or VSCode’s Monaco.

If you are willing to experiment further with contenteditable approach and minimal code - the single-HTML gist is available here, or here is the jsfiddle - https://jsfiddle.net/zserge/gkbjv47n/.

I hope you’ve enjoyed this article. You can follow – and contribute to – on Github, Mastodon, Twitter or subscribe via rss.

Aug 24, 2020

See also: RSS is dead and more.