Docs
Advanced Topics
Undo/Redo

Undo

We provide an UndoManager that helps you manage the undo/redo stack and can be used during concurrent editing. It also supports cursor position transformation. In the case of concurrent editing, you will want the undo/redo to be local, meaning it should only undo/redo local operations without affecting other collaborators' edits.

Why Local Undo/Redo?

If undo/redo is global, it often does not meet expectations. Consider the following scenario:

  • User A and User B are collaborating: They are likely editing different parts of the document.
  • User A performs an undo: If this undoes User B's operations, User A might see no changes and think the undo failed, as User B might be editing content outside of User A's view.
  • User B's perspective: User B would find their recent edits deleted without knowing how it happened.

Usage

To create an UndoManager, you can specify:

  • Which local operations are not recorded.
  • The merge range for undo operations.
  • The stack depth.
const undoManager = new UndoManager(doc, {
    maxUndoSteps: 100, // default 100
    mergeInterval: 1000, // default 1000ms
    excludeOriginPrefixes: ["sys:"], // default []
    onPush: ...,
    onPop: ...
}));

Limitations

It can only track a single peer. When the peer ID of the document changes, it will clear the undo stack and the redo stack and track the new peer ID.

Restoring Selections

When utilizing undo/redo functionalities, it is important for our application to restore the selection to its position prior to the operation that is being undone or redone. This task is particularly challenging in a collaborative setting where remote operations can alter the cursor's position (for instance, if the cursor needs to revert to the 5th position, but remote operations have added new characters before this position).

Challenges
  • Local undo/redo: Local undo and redo delete and recreate elements. If we use CRDT-based stable positions, they might lock on to the deleted elements, while the user's expectation is for the cursor to return to the newly created elements after redo.
  • Example:
    A fox jumped
    • Select "fox" and delete it.
    • Undo.
    • After undo, the three characters "fox" should be recreated, and the cursor should select these three characters. However, if we use stable positions, it would lock onto the initially deleted characters, and after undo, the absolute positions obtained would be start = 2 and end = 2.

Solution

We support storing cursors for each undo/redo action within the UndoManager. The UndoManager will adjust the stored cursors to reflect changes from remote edits or other undo/redo operations, ensuring they match the current state of the document.

They need be handled by the onPush and onPop callbacks.

On onPush, you can return a list of Cursors that you want to store. On onPop, you can retrieve the stored cursors and use them to restore the selection.

Typically, you may need to store selections in an undo/redo item in the following cases:

  • When a new local change is applied, we need to record a new undo item. Therefore, we must store the selection before the operation to be undone.
    • Purpose: Storing the selection before is crucial because we may lose the selection after applying the operation. If the user selects text and deletes it, after undo, the onPop can retrieve the state of the selected deleted content.
  • First undo operation: Store the current document's selection for the corresponding redo item.
    • Purpose: After redo, it can return to the initial selection state.

Internally, we also automatically handle the storage and reset of cursors in the undo/redo loop state.

const doc = new LoroDoc();
let cursors: Cursor[] = [];
let poppedCursors: Cursor[] = [];
const undo = new UndoManager(doc, {
  mergeInterval: 0,
  onPop: (isUndo, value, counterRange) => {
    poppedCursors = value.cursors;
  },
  onPush: () => {
    return { value: null, cursors: cursors };
  },
});
 
doc.getText("text").insert(0, "hello world");
doc.commit();
cursors = [
  doc.getText("text").getCursor(0)!,
  doc.getText("text").getCursor(5)!,
];
// delete "hello ", the cursors should be transformed
doc.getText("text").delete(0, 6);
doc.commit();
expect(poppedCursors.length).toBe(0);
undo.undo();
expect(poppedCursors.length).toBe(2);
expect(doc.toJSON()).toStrictEqual({
  text: "hello world",
});
// expect the cursors to be transformed back
expect(doc.getCursorPos(poppedCursors[0]).offset).toBe(0);
expect(doc.getCursorPos(poppedCursors[1]).offset).toBe(5);
Implementation

The implementation of undo/redo (opens in a new tab) follows a model similar to ProseMirror/Quill, which are based on OT (Operational Transformation) algorithms (so we also implement basic OT primitives internally).

When implementing undo/redo operations, we need to ensure the following properties:

  • Do not undo remote inserts.
  • Redo after undo should return to the original state.
  • If there is no concurrent editing, undo should return to the previous version's state.

Therefore, we have also added some relevant checks in our internal fuzzing tests to ensure correctness.

Demonstration

ProseMirror with Loro binding

Understanding the Undo/Redo Stack

The UndoManager maintains two stacks:

  1. Undo Stack: Contains operations that can be undone
  2. Redo Stack: Contains operations that were undone and can be redone

How the Callbacks Work

The onPush and onPop callbacks are triggered when these stacks change:

  • onPush(isUndo, range, event): Called when a new item is pushed to either stack

    • isUndo: boolean: true for the undo stack, false for the redo stack
    • range: (number, number): The operations' counter range that associated with the undo/redo action
    • Returns: An object that can include value (any data you want to store) and cursors (cursor positions)
  • onPop(isUndo, value): Called when an item is popped from either stack

    • isUndo: true for the undo stack, false for the redo stack
    • value: The value you returned from onPush when this item was created

Understanding Action Merging

The mergeInterval option in the UndoManager controls how closely spaced operations are grouped:

const undoManager = new UndoManager(doc, {
  mergeInterval: 1000, // 1000ms = 1 second (default)
});

How mergeInterval works:

  • Operations occurring within the specified time interval (in milliseconds) will be merged into a single undo action
  • Even though these operations are merged, onPush events will still be triggered for each individual operation
  • When undoing, all merged operations will be undone as a single unit
  • A lower value results in more granular undo steps; a higher value creates fewer, more comprehensive undo steps
  • Set to 0 to disable merging entirely (every operation becomes a separate undo step)

Stack Operations Flow

  1. When a local transaction is committed, a new undo item is pushed to the undo stack (triggers onPush with isUndo=true)
  2. When .undo() is called:
    • An item is popped from the undo stack (triggers onPop with isUndo=true)
    • A corresponding item is pushed to the redo stack (triggers onPush with isUndo=false)
  3. When .redo() is called:
    • An item is popped from the redo stack (triggers onPop with isUndo=false)
    • A corresponding item is pushed to the undo stack (triggers onPush with isUndo=true)

Example: Text Editing with Undo/Redo

Consider a simple text editor that uses Loro for collaboration. Let's walk through what happens during typical editing operations:

// Create document and undo manager
const doc = new LoroDoc();
const textField = doc.getText("textField");
 
// Set up undo manager with callbacks to track changes
const undoManager = new UndoManager(doc, {
  // Store cursor positions and any other state you need
  onPush: (isUndo, range) => {
    if (isUndo) {
      console.log("Action recorded for undo");
    } else {
      console.log("Action recorded for redo");
    }
    // Return whatever data you want associated with this action
    return {
      value: { affectedRange: range },
      cursors: [/* your cursor positions */]
    };
  },
  
  onPop: (isUndo, storedData) => {
    // Access the data you stored during onPush
    const { value, cursors } = storedData;
    
    if (isUndo) {
      console.log("Retrieving data for undo");
    } else {
      console.log("Retrieving data for redo");
    }
    
    // Use the stored cursors to restore selection
    // applyStoredCursors(cursors);
  },
 
  mergeInterval: 0,
});
 
// User types "Hello"
textField.insert(0, "Hello");
doc.commit();
// → onPush triggered (isUndo=true) - Adds to undo stack
 
// User types " World"
textField.insert(5, " World");
doc.commit();
// → onPush triggered (isUndo=true) - Adds to undo stack
 
// User clicks Undo button
undoManager.undo();
// → onPop triggered (isUndo=true) - Removes " World" from document
// → onPush triggered (isUndo=false) - Adds to redo stack
 
// Document now contains only "Hello"
 
// User clicks Redo button
undoManager.redo();
// → onPop triggered (isUndo=false) - Retrieves " World" operation
// → onPush triggered (isUndo=true) - Adds back to undo stack
 
// Document now contains "Hello World" again

When the user clicks Undo, two things happen:

  1. The last action is popped from the undo stack (removing " World")
  2. That action is pushed to the redo stack so it can be redone later

This approach ensures that local changes can be undone without affecting other users' edits, making it ideal for collaborative editing.

Cursor Efficiency

The built-in cursor solution is optimized for performance and handles collaborative scenarios efficiently, including situations where peers may change the document concurrently during undo/redo operations. For complex editors like rich text editors, the cursor implementation provides the best balance of performance and correctness.