To add undo/redo support to your application, you can work with LuciadRIA’s Undoable and UndoManager API. This guide explains the basic concepts of undo/redo in LuciadRIA, and gives you some concrete examples.

Main principles of undo/redo in LuciadRIA

LuciadRIA doesn’t make any assumptions on how undo/redo works in your application. You decide when to create Undoables, what they do, and how they’re managed. The samples show you examples of common use cases, such as undo/redo for selection, creation, editing, and removal of features.

Creating Undoables

Typically, you create an Undoable in response to an event. Common events are:

  • The SelectionChange event on the Map when a user selects a Feature.

  • The ModelChanged event on a Model when a user creates a Feature.

  • The ModelChanged event on a Model, when the user deletes a feature. Note that the ModelChanged event only gives you an identifier. If you need access to the complete Feature instance to implement redo, create the Undoable where you remove it from the model, in a "Delete" context menu action handler for example.

  • The EditShape event of an EditController when a user edits a Feature.

You can implement undo/redo for other events too. For example, you can add undo/redo support for layer visibility changes, by listening to the VisibilityChanged event on Layer.

As an example, let’s add undo/redo support for selection. First, we implement a SelectionUndoable that can undo/redo selections.

Program: Implementation of SelectionUndoable
import {Map} from "@luciad/ria/view/Map.js";
import {Undoable} from "@luciad/ria/view/undo/Undoable.js";
import {FeatureLayer} from "@luciad/ria/view/feature/FeatureLayer.js";
import {TileSet3DLayer} from "@luciad/ria/view/tileset/TileSet3DLayer.js";
import {Layer} from "@luciad/ria/view/Layer.js";
import {Feature} from "@luciad/ria/model/feature/Feature.js";
import {WithIdentifier} from "@luciad/ria/model/WithIdentifier.js";

type Selection = {
  layer: Layer,
  selected: WithIdentifier[]
}[];

/**
 * An undoable for selection
 */
class SelectionUndoable extends Undoable {

  private readonly _map: Map;
  private readonly _selectionBefore: Selection;
  private readonly _selectionAfter: Selection;

  constructor(id: string, label: string, map: Map, selectionBefore: Selection, selectionAfter: Selection) {
    super(id, label);
    this._map = map;
    this._selectionBefore = [...selectionBefore];
    this._selectionAfter = [...selectionAfter];
  }

  redo(): void {
    this.applySelection(this._selectionAfter);
  }

  undo(): void {
    this.applySelection(this._selectionBefore);
  }

  private applySelection(selection: Selection): void {
    const objectsToSelect = selection.map(sel => {
      return {
        layer: sel.layer as FeatureLayer | TileSet3DLayer,
        objects: sel.selected as Feature[]
      };
    });
    this._map.selectObjects(objectsToSelect);
  }
}

Now that we have our SelectionUndoable, we must instantiate it whenever the selection changes on the Map. Once created, we add it to an UndoManager. See Working with the UndoManager for more information.

Program: Create new SelectionUndoable in response to SelectionChange events
import {UndoManager} from "@luciad/ria/view/undo/UndoManager.js";

const SAMPLE_UNDO_MANAGER = new UndoManager();

/**
 * Adds selection undo/redo support to a Map
 * @param map The map to add undo/redo support to
 */
export const addSelectionUndoSupport = (map: Map) => {
  let currentSelection = [...map.selectedObjects];
  let idCounter = 0;
  return map.on("SelectionChanged", () => {
    const id = "" + idCounter++;
    const label = "selection change";
    const undoable = new SelectionUndoable(id, label, map, currentSelection, map.selectedObjects);
    SAMPLE_UNDO_MANAGER.push(undoable);
    currentSelection = [...map.selectedObjects];
  });
}

The UndoManager also holds a label for the Undoable and an id. You can use the label for translation, and the id to differentiate between items on the undoStack.

Working with the UndoManager

The UndoManager manages an undoStack and a redoStack of Undoables. It has undo() and redo() methods that can be called by a button in the UI, or a keyboard shortcut. For example, to wire CTRL+Z to undo() and CTRL+Y to redo(), we can write:

Program: Wiring keyboard events to UndoManager.undo() and UndoManager.redo() (from samples/common/core/util/SampleUndoSupport.ts)
// wire CTRL+Z to undo and CTRL+Y to redo. For Mac users, wire CMD+Z to undo and CMD+SHIFT+Z to redo.
window.addEventListener("keydown", (e) => {
  if (document.activeElement instanceof HTMLInputElement) {
    // input/text field has focus, undo/redo should affect the text and not the map
    return;
  }
  // ctrl+z or cmd+z (mac) to undo
  const isMac = window.navigator.platform.indexOf("Mac") >= 0 || window.navigator.userAgent.indexOf("Mac") >= 0;
  const isUndoKey = isMac ? (e.key === "z" && (e.metaKey && !e.shiftKey)) : (e.key === "z" && e.ctrlKey);
  if (isUndoKey) {
    SAMPLE_UNDO_MANAGER.undo();
    e.preventDefault();
  }
  // ctrl+y or cmd+shift+z (mac) to redo
  const isRedoKey = isMac ? (e.key === "z" && e.metaKey && e.shiftKey) : (e.key === "y" && e.ctrlKey);
  if (isRedoKey) {
    SAMPLE_UNDO_MANAGER.redo();
    e.preventDefault();
  }
});

UndoManager.undo() and UndoManager.redo() take an Undoable from the stack and call Undoable.undo() or Undoable.redo(). For our SelectionUndoable, this causes the selection to change.

Preventing event loops

One last issue that we must handle is the prevention of event loops. In our selection example, SelectionUndoable.undo() and redo() change the Map selection, which creates another, unwanted SelectionUndoable. To prevent such an event loop, we must stop listening to selection events while we’re applying the Undoable. To stop listening, we make use of a simple Lock object. We lock the lock in the undo() and redo() method, and prevent the "SelectionChange" listener from creating new SelectionUndoable instances.

interface Lock {
  locked: boolean;
}

class SelectionUndoable /*...*/ {

   private readonly _lock: Lock;

   constructor(..., lock: Lock) {
     // ...
     this._lock = lock;
   }

   applySelection(selection: Selection) {
     // avoid creating a new SelectionUndoable in the map.selectObjects call below
     this._lock.locked = true;
     this._map.selectObjects(selection);
     this._lock.locked = false;
   }
}

const selectionLock = {locked: false};
map.on("SelectionChanged", () => {
  if (!selectionLock.locked) {
    // create new SelectionUndoable(..., selectionLock)
  }
}
Program: The complete source code for this article, with locks.
import {Feature} from "@luciad/ria/model/feature/Feature.js";
import {WithIdentifier} from "@luciad/ria/model/WithIdentifier.js";
import {FeatureLayer} from "@luciad/ria/view/feature/FeatureLayer.js";
import {Layer} from "@luciad/ria/view/Layer.js";
import {Map} from "@luciad/ria/view/Map.js";
import {TileSet3DLayer} from "@luciad/ria/view/tileset/TileSet3DLayer.js";
import {Undoable} from "@luciad/ria/view/undo/Undoable.js";
import {UndoManager} from "@luciad/ria/view/undo/UndoManager.js";

type Selection = {
  layer: Layer,
  selected: WithIdentifier[]
}[];

interface Lock {
  locked: boolean;
}

/**
 * An undoable for selection
 */
class SelectionUndoable extends Undoable {

  private readonly _map: Map;
  private readonly _selectionBefore: Selection;
  private readonly _selectionAfter: Selection;
  private readonly _lock: Lock;

  constructor(id: string, label: string, map: Map, selectionBefore: Selection, selectionAfter: Selection, lock: Lock) {
    super(id, label);
    this._map = map;
    this._selectionBefore = [...selectionBefore];
    this._selectionAfter = [...selectionAfter];
    this._lock = lock;
  }

  redo(): void {
    this.applySelection(this._selectionAfter);
  }

  undo(): void {
    this.applySelection(this._selectionBefore);
  }

  private applySelection(selection: Selection): void {
    const objectsToSelect = selection.map(sel => {
      return {
        layer: sel.layer as FeatureLayer | TileSet3DLayer,
        objects: sel.selected as Feature[]
      };
    });
    // avoid creating a new SelectionUndoable in the map.selectObjects call below
    this._lock.locked = true;
    this._map.selectObjects(objectsToSelect);
    this._lock.locked = false;
  }
}

const SAMPLE_UNDO_MANAGER = new UndoManager();

/**
 * Adds selection undo/redo support to a Map
 * @param map The map to add undo/redo support to
 */
export const addSelectionUndoSupport = (map: Map) => {
  let currentSelection = [...map.selectedObjects];
  let idCounter = 0;
  const selectionLock = {locked: false};
  return map.on("SelectionChanged", () => {
    const id = "" + idCounter++;
    const label = "selection change"
    const undoable = new SelectionUndoable(id, label, map, currentSelection, map.selectedObjects, selectionLock);
    SAMPLE_UNDO_MANAGER.push(undoable);
    currentSelection = [...map.selectedObjects];
  });
}

More examples

For more examples, check out:

  • The module samples/common/core/util/SampleUndoSupport implements commonly used undo/redo operations in LuciadRIA samples, such as undo/redo for selection, creation, deletion, and editing of features.

  • The 'Create and Edit` sample shows you how to wire undo/redo buttons in the UI to an UndoManager.

  • The 'Geolocate' sample shows you how to use the undo/redo API with a custom controller implementation.