DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Your Rich Text Editor's Source of Truth Must Not Be the DOM. Here Is What Replaces It

A rich text editor is not a styled contenteditable. The moment document.execCommand or a stray <span style> becomes the truth, three things break at once: undo stops being reversible, collaboration stops being expressible, and paste starts smuggling other people's CSS into your product.

Every editor you have actually used — Google Docs, Notion, Slack, your IDE — keeps a document model and treats the screen as a projection of it.

Here the whole document is two fields:

{
  text:  "hello world",
  marks: [ { from: 0, to: 5, type: "bold" } ]   // half-open intervals
}
Enter fullscreen mode Exit fullscreen mode

One plain string, plus a flat list of {from, to, type} for bold, italic, code, strike and link. Every keystroke is intercepted at beforeinput, applied to that model, re-rendered, and the caret is put back — so the browser never gets to invent markup.

Live, type in it: https://dev48.infy.uk/design/day62-rich-text-editor.html

Toggle actually means toggle

The behaviour everyone assumes is obvious and almost nobody gets right first try:

If every character in the range already carries the mark, remove it. Otherwise add it.

Select a half-bold range and press Ctrl+B: it goes fully bold. Press it again: it goes clean. Two different branches — and if you only implement "add", the second press does nothing.

One position-mapping function, and a boundary rule you can say out loud

When text is inserted or deleted, every mark endpoint has to move. The temptation is to rebuild the marks. Do not. Map them, with a single function and a rule you can state in one sentence:

Insertions are left-exclusive, right-inclusive.

That one sentence produces all the behaviour you already expect:

  • typing at the right edge of bold extends it
  • typing at the left edge does not
  • a delete across a mark clips it
  • a delete that swallows a mark erases it

Write the rule down before the code. Every editor bug I have chased in this area turned out to be an unstated boundary rule.

Overlapping intervals cannot be nested tags

Marks overlap freely. Tags cannot. So you cannot wrap each interval in its own element — bold from 0 to 8 with italic from 4 to 12 has no valid nesting.

The fix is a boundary sweep: collect every endpoint, sort, and emit non-overlapping style runs that tile the text exactly. Those runs are nested into elements in a fixed order, so the same document always renders to the same markup.

Bridging the Selection API, both ways

Model offsets are integers. The DOM has text nodes and <br> elements. Converting between them means walking the tree and counting — in both directions, because you need to read the caret before an edit and restore it after.

This is exactly where naive editors lose the caret, and it is worth writing carefully once rather than patching forever.

The part that catches real bugs

The pure core is fuzz-tested in Node against a completely different representation: one Set of mark names per character. Slow, stupid, obviously correct.

Random edits are applied to both, and after every operation the interval model is flattened to per-character sets and compared. Any disagreement is a real bug — and because the second model shares no code with the first, it cannot agree for the wrong reason.

That is the difference between a test that checks your code against itself and a test that can actually fail.

Plus undo with coalescing (a typed word undoes as one step; a mark change or a caret jump breaks the run), paste sanitisation through an allowlist that reports how many nodes and attributes it rejected, and markdown input rules that rewrite **x**, _x_, ~~x~~ and backticks as you type.

No editor framework, no schema library, no execCommand.

Repo: https://github.com/dev48v/design-from-zero

Top comments (0)