Engineering

Undo and redo after a paste in a rich text editor

A paste should undo as one step, and undo should bring back the caret. Group typing by pause, bound the history by size, and drop redo on a new edit.

6 min read

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.

Code example
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


RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.