Guide · Frontend
Rich Text Editor Design: Document Model, Selections, Paste and Undo
26 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- Rich text editor design means keeping your own document model, a plain data copy of the text and its formatting. The browser's editable HTML is only a view of it.
- contenteditable, the HTML attribute that lets users type inside any element, gives you a caret and typing for free. It does not give you clean HTML, predictable formatting or an undo that knows your code.
- Every change goes through a transaction, one recorded edit to the model. The beforeinput event, fired just before the browser edits, tells you what the user meant, such as insertParagraph or formatBold.
- Pasted HTML is untrusted. Run it through a sanitiser, a tool that strips scripts and unknown tags, then through your schema, the list of node types your editor allows. Undo applies the inverse of stored steps.
- Beginners can stop after the document model section. Engineers get tested quirks in Chromium 153, the open-source engine inside Chrome, and undo rules read from ProseMirror and Lexical, two open-source editor toolkits.
What goes wrong when you build a rich text editor on contenteditable alone?
The editor works in a demo and then quietly corrupts what you save. Rich text editor design starts here: the browser decides what HTML your users produce, and different actions produce different shapes for the same look.
Picture a common setup. A team adds a notes box to its web app, so people can write meeting notes with bold, lists and links. The setup is made up, but every behaviour below comes from our runs in Chromium 153, the engine inside Chrome.
The first version is one div element with the contenteditable attribute and a Save button that stores its innerHTML, the element's HTML as a string. Within a week, four bug reports arrive.
- Pressing Enter made the new line a div element, not a paragraph, so the notes look different in the email digest that styles paragraphs.
- A user put the caret just after a bold word, typed, and the new text came out bold even though the selection said it was outside.
- A teammate added a button that inserts today's date with a DOM call. After that, Ctrl+Z (undo) removed the user's typing but left the date behind.
- Someone pasted from a word processor, and the notes now carry the Calibri font, a dark blue colour and 20-point text in style attributes.
None of these is a browser bug in the usual sense. They are what contenteditable promises: the browser edits its own DOM, the tree of elements on the page, its own way. The rest of this guide shows how real editors take that control back.
Which words do you need before designing a rich text editor?
You need seven words. Each one is tied to the notes box.
- contenteditable: an HTML attribute that makes an element editable. The HTML standard defines three keywords: true, false and plaintext-only, where only raw text can be edited.
- DOM: the browser's live tree of elements and text on the page. In the naive notes box, the DOM is the only copy of the note.
- Selection: the highlighted range, or the blinking caret when nothing is highlighted. The browser keeps one per document and describes it with DOM nodes and offsets.
- Document model: your own data structure for the note, such as a list of paragraphs holding text with marks. It lives in JavaScript memory, not in the DOM.
- Mark: formatting attached to a run of text, such as bold, italic or a link. A model stores marks as labels on text, not as nested tags.
- Schema: the rules for your model, listing which node types and marks exist and where they may appear. Anything outside the schema cannot enter the document.
- Transaction: one recorded change to the model, made of steps such as insert this text here or add bold from position 1 to 6. Undo works by inverting them.
What is rich text editor design, and which parts does it need?
Rich text editor design is the plan for who owns the document: your model does, and the DOM only displays it. The browser still draws the caret and handles keys, but every lasting change goes through your code first.
That gives five parts. An input layer reads what the user meant. Commands turn that intent into a transaction. The state holds the new document and selection. A view updates the DOM to match. A history keeps transactions for undo.
One keystroke through a model-first editor
Drag to exploreNotice that the DOM is touched only near the end, by the view. The browser's own edit is cancelled at the beforeinput step, so the model stays the single source of truth.
Diagram connections:
- User presses Ctrl+B to beforeinput event (inputType)
- beforeinput event to Toggle-bold command (preventDefault, then run)
- Toggle-bold command to New editor state (apply transaction)
- New editor state to History (record steps)
- New editor state to View (render)
- View to Browser selection (sync caret)
- User presses Ctrl+B: Browser turns the key into an intent
- beforeinput event: inputType formatBold, before any DOM change
- Toggle-bold command: Builds a transaction with one addMark step
- New editor state: New document value plus mapped selection
- History: Stores the step so undo can invert it
- View: Updates the DOM for the changed nodes
- Browser selection: Model selection written back to the DOM
An everyday picture helps. In a cricket match, the scorer writes every ball in the scorebook, and the big scoreboard only shows what the book says. The scorebook is your model and the scoreboard is the DOM.
If a fan climbs up and changes a number on the board, the scorer does not copy it into the book. The scorer either records it properly or puts the board back. That is what an editor does when the DOM changes behind its back.
Where the picture breaks: sometimes the browser must change the board first. While someone types Hindi or Japanese through an input method, the browser edits the DOM and you can only read the change afterwards. The input section covers that.
Why does contenteditable alone mislead you?
Because the browser's editing behaviour is not fully specified, so what you see on screen tells you little about the HTML underneath. The HTML standard defines the attribute and its states, but not what Enter or bold must produce.
The command API that older editors used, document.execCommand, lives in a separate W3C draft. As of 27 October 2026, that draft says it is incomplete and not expected to advance beyond draft status. It tells authors to use JavaScript editing libraries, and MDN marks execCommand deprecated.
We drove a plain contenteditable div with real key presses in Chrome for Testing 153.0.8010.12, a Chromium 153 build, on Linux. We used Playwright, a tool that drives a real browser from a script. The table shows what the DOM got for each bug report from the notes box.
| What the user did | What the DOM got | Why it hurts an editor |
|---|---|---|
| Typed Hello, pressed Enter, typed World | Hello as bare text, then World inside a new div element | Saved HTML depends on a browser default, not on your design |
| Selected Hello and pressed Ctrl+B | Hello wrapped in a b element, not a strong element | Many HTML shapes can mean bold, so you must normalise them |
| Put the caret at the start of plain cd after bold ab, typed X | X went inside the bold element, giving abX in bold | The selection you read is not where text lands |
| Script appended text, user typed, then pressed Ctrl+Z | Undo removed the typing and left the script's text | Native undo only knows edits the browser made itself |
| Pasted HTML from a word processor | class and style attributes kept, including Calibri and 20pt | Foreign styling leaks into every saved note |
The caret that reads one way and types another
Pause and predict: the paragraph holds ab in bold, then cd in plain text. We set the selection to offset 0 of the plain cd text node and typed X. Where does X go?
It went into the bold text, giving abX in bold, then cd. Reading the selection back still said offset 0 of the plain cd node. When we flipped it, with plain ab before bold cd, X stayed plain.
What just happened? Several DOM positions draw the caret in the same place. End of ab and start of cd were 24.89 and 24.91 pixels from the left. Chromium chose where to insert by its own rule, which is not in any spec we found.
Native undo forgets your code's changes
We typed abc, appended text from a script with a plain DOM call, then typed d. One Ctrl+Z removed abcd and left the script's text. A second Ctrl+Z did nothing.
MDN explains the split: unlike direct DOM changes, changes made through execCommand keep the browser's undo buffer. So the moment your code edits the DOM directly, the browser's history and your document disagree.
How does a document model separate from the DOM work?
The model is a plain tree of data that your code owns, and the view renders it into the DOM. When the DOM changes, the editor turns that change into a model change, or throws it away and redraws.
ProseMirror, an open-source editor toolkit, is a clear example. Its guide says the document is a custom data structure holding only the elements you allow. Inline content is a flat sequence of text, with marks attached as labels.
That flat shape fixes the bold problem. We parsed four HTML shapes with the basic schema in prosemirror-model 1.25.12: two b elements side by side, one strong element, a b nested in a b, and a div.
- The first three all became one text node, abcd, with one strong mark. The guide states that adjacent text with the same marks is always combined.
- The div became a paragraph. The schema has no div node, so the parser ignored the div and wrapped its text in the default block, a paragraph.
- Saving the model back to HTML gave one clean shape every time: a paragraph with a strong element for the bold part.
Positions are plain numbers
ProseMirror gives every place in the document an integer position. Position 0 is before the first node. Entering or leaving a node such as a paragraph counts as one, and each character counts as one.
| Position | Where it sits | Marks a new character would get |
|---|---|---|
| 0 | Before the paragraph, at document level | None, this is not inside text |
| 1 | Start of the paragraph, before a | strong |
| 3 | After ab, before c | strong, because typing at the end of a bold run extends it |
| 4 | After c | None |
| 5 | After cd, end of the paragraph | None |
| 6 | After the paragraph, end of the document | None, this is not inside text |
Compare that with the DOM, where one caret spot has several names. It can be the end of the ab text, the start of the cd text, or offset 1 inside the editor div. The model has one number, so commands can do arithmetic on it.
Lexical, another open-source editor framework, takes the same stance. Its docs say the editor state, not the DOM, is the source of truth. It attaches to a contenteditable element and keeps its own model of the document.
Beginners can stop here: you know why the notes box broke and what a model-first editor does instead. The rest covers selections, beforeinput, paste cleaning and undo, with code we ran.
How do selections and positions work in a rich text editor?
The browser describes the selection with DOM points, and the editor translates them into model positions both ways. Every edit then has to move the selection, so an editor maps positions through each change.
The W3C Selection API, still a draft as of 27 October 2026, says each page has one selection with at most one range. The anchor is where the user started dragging and the focus is where they stopped. Direction is forward, backward or none.
We selected Hello world from offset 8 back to offset 2. The selection reported anchor 8, focus 2 and direction backward, while its range still ran from 2 to 8. Store both ends, not just the range, or you lose which end the user is moving.
The draft also says some browsers aggressively normalise selections, and it calls that a bad idea. The caret test above is one place where reading the selection did not match what typing did.
Mapping positions through a change
When text is inserted, every position after it shifts. A transaction carries a mapping, a function that converts old positions to new ones. Here is the notes box's bold ab and plain cd, with X typed at position 3.
import { schema } from 'prosemirror-schema-basic';
import { EditorState } from 'prosemirror-state';
const bold = schema.marks.strong.create();
const doc = schema.node('doc', null, [
schema.node('paragraph', null, [schema.text('ab', [bold]), schema.text('cd')])
]);
const state = EditorState.create({ doc });
const tr = state.tr.insertText('X', 3);
console.log(JSON.stringify(tr.steps[0].toJSON()));
console.log(state.doc.textContent, tr.doc.textContent);
console.log(tr.mapping.map(5));Run it with prosemirror-model 1.25.12, prosemirror-state 1.4.4 and prosemirror-transform 1.12.4 under Node 22. It prints three lines.
- One replace step from 3 to 3 that inserts X with the strong mark. Bold is inclusive in this schema, so typing at the end of a bold run extends it.
- abcd abXcd: the old state is untouched and the new state has X. Documents are values, so a change makes a new one.
- 6: position 5, the end of the paragraph, moved one place right.
A position exactly at the insert point is ambiguous. The map function takes an assoc argument, -1 or 1, for which side it sticks to. In our run, position 3 stayed at 3 with -1 and moved to 4 with 1.
How do beforeinput and inputType tell you what the user meant?
The beforeinput event fires before the browser changes an editable element, and its inputType names the intent. If the event is cancelable, you can call preventDefault and run your own command instead.
Input Events Level 2, the W3C draft that defines it, was still a draft as of 27 October 2026. It lists inputTypes such as insertText, insertParagraph and historyUndo. It says every beforeinput event is cancelable except those sent during an IME composition, the process of building characters through an input method.
We pressed each key in a contenteditable div in Chromium 153 on Linux. Every event below had cancelable set to true. Arrow keys fired no beforeinput. Ctrl+Y fired none either, while Ctrl+Shift+Z sent historyRedo.
| User action | inputType | Notes from our run |
|---|---|---|
| Type a letter | insertText | event.data holds the character |
| Enter | insertParagraph | Your command decides the new block type |
| Shift+Enter | insertLineBreak | A line break inside the same block |
| Backspace or Delete | deleteContentBackward or deleteContentForward | One target range given |
| Ctrl+Backspace | deleteWordBackward | Word rules come from the platform |
| Ctrl+B, Ctrl+I, Ctrl+U | formatBold, formatItalic, formatUnderline | No DOM change if cancelled |
| Ctrl+Z or Ctrl+Shift+Z | historyUndo or historyRedo | Cancel these and run your own history |
| Paste | insertFromPaste | dataTransfer carried text/plain and text/html |
This listener logs each intent and takes over bold. We ran it on a contenteditable div holding Hello, with its id set to editor.
const editor = document.getElementById('editor');
editor.addEventListener('beforeinput', function (event) {
console.log(event.inputType, event.cancelable, event.getTargetRanges().length);
if (event.inputType === 'formatBold' && event.cancelable) {
event.preventDefault();
// run your own toggle-bold command on the model here
}
});Selecting Hello and pressing Ctrl+B logged formatBold true 1, and the HTML stayed plain Hello. Typing an exclamation mark and pressing Enter logged insertText and insertParagraph, and those changes went through as normal.
Why you still need a fallback
Some edits cannot be cancelled. We drove an input method through the Chrome DevTools Protocol, the browser's remote-control interface, typing n, then na, then committing a Devanagari syllable. Every beforeinput was insertCompositionText with cancelable false, and preventDefault did not stop the text.
MDN adds autocomplete, spell-check corrections and password autofill to that list, and says the details vary by browser and OS. So a solid editor cancels what it can and reads back the rest from the DOM.
Two paths for one input event
Drag to exploreNotice that both paths end in the same place: a transaction on the model. The No path is the price of supporting input methods and autocorrect.
Diagram connections:
- beforeinput arrives to Can it be cancelled? (check)
- Can it be cancelled? to Cancel, run a command (Yes)
- Can it be cancelled? to Let the browser edit (No: composition)
- Let the browser edit to Read the DOM change (DOM already changed)
- Read the DOM change to Transaction on model (parse changed range)
- Cancel, run a command to Transaction on model (build steps)
- Transaction on model to View re-renders (apply and render)
- beforeinput arrives: inputType and target ranges
- Can it be cancelled?: Read event.cancelable
- Cancel, run a command: preventDefault, then your own logic
- Let the browser edit: IME text, autocorrect, autofill
- Read the DOM change: input event or MutationObserver
- Transaction on model: Steps, mapped selection, history entry
- View re-renders: DOM matches the model again
How ProseMirror and Lexical choose
The two toolkits lean in opposite directions, which shows this is a real trade-off. The points below come from their docs and from source code at the versions we installed.
- ProseMirror's guide says typing is usually left to the browser. The editor notices the DOM change, re-parses that part and turns it into a transaction. Its view uses a MutationObserver, a browser API that reports DOM changes.
- In prosemirror-view 1.42.8, the main beforeinput handler is only a fix for one Android Backspace bug. A source comment says beforeinput support is so spotty that the author is still waiting to see where it goes.
- Lexical 0.52.0 does the opposite in its beforeinput handler. It cancels most events and runs its own commands, sending historyUndo to its undo command. Some plain typed text still goes to the browser first.
- Lexical also batches updates. In our run, without the discrete option, getEditorState() returned the old state until the next tick. Changing a node inside read() threw a read-only error.
How do you sanitise pasted HTML in a rich text editor?
Cancel the browser's paste, read the HTML from the clipboard, clean it with an allow-list sanitiser, parse it with your schema, and insert the result as one transaction. Fall back to plain text if nothing useful is left.
Clipboard HTML is untrusted input. It can come from any web page or office app. We put a fake fragment on the clipboard in the shape a word processor writes, plus three harmless test payloads that set a flag variable on the page.
- The paste event's text/html still contained the script element, an image with an onerror attribute and a javascript: link. Nothing had been cleaned yet.
- Chromium's own paste removed the script and stripped the onerror attribute and the javascript: href. It kept the class attribute and the style attributes: Calibri, a dark blue colour and 20-point text.
- A naive handler that put the clipboard HTML into a div with innerHTML set the flag. The script element did not run, but the image's onerror handler did.
MDN's innerHTML page warns about exactly this: script elements inserted that way do not run, but an image with a broken source and an error handler does. The fix is never to parse clipboard HTML into the live page unsanitised.
The paste pipeline
Drag to exploreNotice the two filters. The sanitiser removes anything that can run code. The schema removes anything your editor does not model, such as fonts and colours.
Diagram connections:
- paste event to DOMPurify allow-list (raw HTML string)
- DOMPurify allow-list to Schema parser (clean DOM fragment)
- Schema parser to Anything left? (slice)
- Anything left? to One transaction (Yes)
- Anything left? to Use text/plain instead (No: empty slice)
- Use text/plain instead to One transaction (text slice)
- paste event: Read text/html, then preventDefault
- DOMPurify allow-list: Drops scripts, handlers, unsafe URLs
- Schema parser: Keeps only your nodes and marks
- Anything left?: Check the parsed slice size
- Use text/plain instead: Plain text as paragraphs
- One transaction: Replace the selection; one undo step
DOMPurify is an open-source sanitiser that works on the DOM and also runs in Node with jsdom, a browser-like DOM for Node. This function cleans the clipboard HTML with an allow-list and parses it with the ProseMirror basic schema. Pass the page's window in a browser.
import createDOMPurify from 'dompurify';
import { DOMParser as ModelParser } from 'prosemirror-model';
import { schema } from 'prosemirror-schema-basic';
export function pastedSlice(html, win) {
const purify = createDOMPurify(win);
const fragment = purify.sanitize(html, {
ALLOWED_TAGS: ['p', 'br', 'b', 'strong', 'i', 'em', 'a', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href'],
RETURN_DOM_FRAGMENT: true
});
const holder = win.document.createElement('div');
holder.appendChild(fragment);
return ModelParser.fromSchema(schema).parseSlice(holder);
}We ran it in Node with dompurify 3.4.17 and jsdom 30.1.3 on a similar fake fragment that also has a safe report link. The result was two paragraphs: Quarterly numbers look good, with numbers in bold, then link and report, where only report kept a link.
The script, the onerror image, the javascript: link, the fonts and the colours were all gone. jsdom does not run scripts by default, so the proof is what was removed, not an unset flag. In a ProseMirror editor, the view's transformPastedHTML prop is a hook where this cleaning can run.
Why the schema is not your sanitiser
It is tempting to skip the sanitiser because the schema already drops unknown tags. We tested that: the basic schema kept a link whose href was javascript:void(0). Its link mark only checks that href is a string.
So treat them as two filters with two jobs. The sanitiser handles security, and the schema handles shape. If your link mark renders hrefs, also check their protocol when you parse and when you save.
The DOMPurify README adds two warnings. Changing the HTML after sanitising can undo its work. Its removed list is for debugging, not for security decisions. The frontend security guide on this site covers XSS, cross-site scripting, in more depth.
How do undo and redo work with transactions?
The history stores each transaction's steps, and undo applies their inverse. Small transactions that arrive close together are grouped into one undo step, so one Ctrl+Z removes a burst of typing, not one letter.
prosemirror-history 1.5.1 starts a new group when more than 500 milliseconds pass between changes, or when a change is not next to the previous one. It keeps 100 groups by default. Both numbers come from its source.
This script types Hello one letter every 100 milliseconds, waits a second, then types a space and wo. It fakes the clock with setTime so the result does not depend on how fast the machine is.
import { schema } from 'prosemirror-schema-basic';
import { EditorState, TextSelection } from 'prosemirror-state';
import { history, undo, undoDepth } from 'prosemirror-history';
let state = EditorState.create({ schema, plugins: [history()] });
state = state.apply(state.tr.setSelection(TextSelection.create(state.doc, 1)));
let clock = 1000;
function type(text, gapMs) {
clock += gapMs;
const tr = state.tr.insertText(text);
tr.setTime(clock);
state = state.apply(tr);
}
for (const ch of 'Hello') type(ch, 100);
type(' ', 1000);
for (const ch of 'wo') type(ch, 100);
console.log(undoDepth(state), state.doc.textContent);
undo(state, function (tr) { state = state.apply(tr); });
console.log(state.doc.textContent);It printed 2 Hello wo, then Hello. Two undo groups: one for Hello and one for the space and wo after the pause. One undo removed only the second group.
- Two inserts 50 milliseconds apart, at positions 2 and 10, still made two groups, because they were not adjacent.
- Calling closeHistory on a transaction forced a new group even inside 500 milliseconds. Use it before a paste or a toolbar action.
- Toggling bold on a range was one transaction with one addMark step, so one undo removed it.
Undo that skips changes you did not make
ProseMirror's guide calls its history selective undo, which is more than rolling back to an old state. A transaction marked addToHistory false is never undone, but the history maps its own steps around it.
We typed a local edit, then applied a change marked addToHistory false at the start of the paragraph, the way a remote collaborator's edit arrives. Undo removed the local edit and kept the remote text.
Lexical 0.52.0 does the same job differently. Its history merges changes of the same kind made within a delay, 300 milliseconds in its history extension. By default, pastes and cuts get their own undo entry, and in-progress composition text is not recorded.
| Question | prosemirror-history 1.5.1 | @lexical/history 0.52.0 |
|---|---|---|
| What starts a new undo group? | More than 500 ms gap, or a non-adjacent change | A different kind of change, or a gap over the delay |
| Default delay | 500 ms (newGroupDelay) | 300 ms in its history extension |
| How many groups are kept? | 100 (depth) | No cap unless you set maxDepth |
| Force a new undo entry | closeHistory(tr) | history-push update tag |
| Special handling for one change | addToHistory set to false: never undone | history-merge tag: joins the previous undo entry |
Collaborative editing builds on this. The SevyDevy guide on OT vs CRDT explains how concurrent edits from many people are merged. Selective undo is what lets each person undo only their own changes on top of that.
How do you test that a rich text editor behaves?
Drive a real browser with real key presses and compare the model, not the HTML string. Every table in this guide came from that kind of test, using Playwright with Chromium.
Load the editor in a real browser
Use Playwright or a similar tool. In our run, keyboard events dispatched from script inserted nothing, so press real keys.
Log every beforeinput
Record inputType, cancelable and getTargetRanges().length for each key you support. Run it on every browser and OS you ship to.
Assert on the model
After each action, compare the model's JSON with what you expect. Two DOM shapes can be equally fine; two models should not.
Test the caret at mark edges
Put the caret at the start and end of bold and link text, type, and check which marks the new text got.
Paste hostile fragments
Put fake HTML with a script, an error handler and a javascript: link on the clipboard, paste, and assert that a flag variable stays unset.
Check undo boundaries
Type, pause, paste and undo. Each Ctrl+Z should remove exactly one group, and the caret should land where the change was.
Input methods need their own test. In Chromium, the DevTools Protocol commands Input.imeSetComposition and Input.insertText simulate composition. Assert that the final model holds the committed text exactly once.
How do you explain rich text editor design in a system design interview?
Start with the problem: contenteditable gives you typing but not a trustworthy document. Then walk the data flow from key press to saved model, and finish with paste, undo and collaboration.
State the need
Users write formatted notes; we store them, render them elsewhere and may add real-time collaboration later.
Pick the source of truth
A document model with a schema, saved as JSON. HTML is an output format, produced by a serialiser.
Describe the input layer
Listen to beforeinput, map inputType to commands and cancel the default. Read back IME and autocorrect changes the browser makes itself.
Explain transactions
Every change is a transaction of steps with a position mapping. The selection is mapped through it, and the view updates the DOM to match.
Cover paste
Sanitise with an allow-list, parse with the schema, check link protocols, insert as one transaction, fall back to plain text.
Cover undo
History groups transactions by time and adjacency. Remote changes skip the history but are mapped, so undo stays selective.
Name the trade-offs
Building your own model is a large project. A toolkit such as ProseMirror or Lexical gives you this design, and you add the schema and commands.
Questions people ask
- Is contenteditable bad for building a rich text editor?
- No, but it is not enough on its own. contenteditable gives you the caret, typing, input methods and spell-check, which are very hard to rebuild. The problem is letting the DOM it edits be your saved document. Keep a model, cancel the edits you can handle with beforeinput, and read back the ones you cannot.
- Should a rich text editor store HTML or JSON?
- Store the model, usually as JSON, and produce HTML from it when you need to display or email it. JSON from a schema has one shape for each meaning, so bold is always the same mark. Stored HTML carries whatever the browser or a paste produced, and every reader must clean it again.
- Does a schema make pasted HTML safe?
- No. A schema decides which nodes and marks may exist, not whether their attributes are safe. In our test, the ProseMirror basic schema kept a link whose href was javascript:void(0). Run an allow-list sanitiser such as DOMPurify first, then the schema, and check link protocols on save too.
- Why can I not cancel beforeinput while someone types through an IME?
- Because an IME, an input method used for phonetic Hindi or Japanese, builds characters in steps, and the Input Events spec makes beforeinput events inside a composition non-cancelable. In Chromium 153 they arrived as insertCompositionText with cancelable false. The browser edits the DOM first, so your editor must read the change back and turn it into a transaction.
- Can I still use document.execCommand for bold and lists?
- You can, but plan to move away from it. As of 27 October 2026, the W3C draft that defines it says it is incomplete and not expected to advance. MDN marks it deprecated and non-standard. Its one real advantage is that its changes stay in the browser's undo stack, which direct DOM changes do not.
- How is an editor's undo different from OT or CRDT?
- Undo reverses one user's own transactions inside one editor. OT and CRDT are ways to merge edits from many users at once. They meet in collaborative editors, where remote changes skip the local history but are mapped through it, so each person's undo removes only their own work.
- Do I need to handle beforeinput myself if I use ProseMirror or Lexical?
- Usually not. Both toolkits handle input events for you, and you write schemas, commands and plugins on top. They differ underneath: ProseMirror mostly lets the browser type and re-parses the DOM, while Lexical cancels most beforeinput events and runs its own commands. Knowing which helps when you debug input bugs.
Sources and further reading
- WHATWG HTML Living Standard (developer edition): 6.8 Editing, the contenteditable attribute
- W3C execCommand draft (unofficial, incomplete): status warning and default paragraph separator
- W3C Input Events Level 2 editor's draft: inputType table, cancelability, getTargetRanges
- W3C Selection API editor's draft: anchor, focus, direction and normalisation note
- MDN: Element beforeinput event
- MDN: contenteditable global attribute
- MDN: Document execCommand (deprecated; undo buffer note)
- MDN: Element paste event
- MDN: Element innerHTML, security considerations
- ProseMirror guide: documents, positions, transactions, view and history
- prosemirror-history source, history.ts: depth, newGroupDelay and addToHistory
- prosemirror-view source, input.ts: the beforeinput handler comment
- Lexical docs: introduction (editor state as source of truth)
- Lexical docs: editor state, updates and discrete commits
- Lexical v0.52.0 source, LexicalEvents.ts: $handleBeforeInput
- DOMPurify README: configuration, RETURN_DOM_FRAGMENT and warnings