A writer pastes a block of text, presses undo, and the paste disappears along with the three words they typed just before it. Undo looks like one feature, but in a rich text editor it is a history of changes to a tree, and a paste is the hardest change to record correctly.
Group changes the way a writer thinks
Recording every keystroke as its own undo step makes undo useless. Recording the whole document after every pause stores a copy of the document for each step. Most editors land between the two: a run of typed characters in the same paragraph becomes one step, and the step closes when the writer pauses or starts a different kind of action.
let openGroup: { kind: string; at: number } | null = null;
const GROUP_GAP_MS = 1200;
function recordStep(kind: string, now: number): "join" | "start" {
const canJoin =
openGroup !== null &&
openGroup.kind === kind &&
now - openGroup.at < GROUP_GAP_MS;
openGroup = { kind, at: now };
return canJoin ? "join" : "start";
}A paste is always its own kind of step, so it never joins the typing before it. One undo after a paste removes only the paste, and the words typed just before it stay where they were.
A paste is a single step, even when it is large
A paste usually changes many nodes at once: new paragraphs, inline marks, a table, maybe an image. Recording each node as it is inserted gives a chain of tiny undo steps the writer must walk back one at a time. Record the paste as one transaction instead: keep the range before the paste, apply the whole change, and store the before and after states as one entry. Undo then restores the range in one operation. The same rule applies to a cut, a drag and drop, and a replace-all. Anything the writer thinks of as one action should be one step in the history.
Restore the selection, not just the content
Undo that restores the text but leaves the caret where the change happened feels broken. Store the selection before each step as well as the content, and restore both. When the stored selection points into content that no longer exists, fall back to the nearest valid position rather than leaving the editor with no caret at all.
A new edit discards the redo stack
When a writer undoes three steps and then types a new word, the undone steps can no longer be redone in a meaningful way, because the new edit may have reshaped the paragraphs they refer to. Drop the redo stack when a new change is recorded. The one case to handle separately is a change from another source, such as a collaborator’s edit. That change should be rebased against the history, not clear the local writer’s redo stack.
Bound the history by size, not by count
A limit of 100 steps sounds reasonable until one of those steps is a paste of a 40-page document with images. Limit by the approximate size of the stored states, drop the oldest steps first, and keep the limit in a single constant so it can be tuned without a rewrite.
A short test list
- Type a sentence, paste a paragraph, press undo once. The paste should go and the sentence should stay.
- Paste a table, press undo, and confirm the table is removed in one step, not row by row.
- Undo three steps, type a new word, and confirm redo no longer offers the old steps.
- Undo a paste at the end of a long paragraph and confirm the caret returns near where the paste began.
- Paste a very large document, undo, and check that memory use in the browser returns to normal.
RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.