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 theMap
when a user selects aFeature
. -
The
ModelChanged
event on aModel
when a user creates aFeature
. -
The
ModelChanged
event on aModel
, when the user deletes a feature. Note that theModelChanged
event only gives you an identifier. If you need access to the completeFeature
instance to implement redo, create theUndoable
where you remove it from the model, in a "Delete" context menu action handler for example. -
The
EditShape
event of anEditController
when a user edits aFeature
. -
The
Restarted
event of anEditController
orCreateController
when used to cancel an edit or create interaction, cf. Cancelling create and edit interactions.
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.
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.
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:
UndoManager.undo()
and UndoManager.redo()
(from toolbox/ria/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)
}
}
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
toolbox/ria/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.