Undo and Redo
The customization options for the undo mechanism in yFiles for HTML include changing the behavior of the built-in undo process, controlling how changes are recorded, and synchronizing your data with the changes made by undo/redo operations.
The undo support in yFiles for HTML is managed by the UndoEngine class, which is typically associated with an IGraph instance. To obtain the UndoEngine from an IGraph when undo is enabled, use the undoEngine property.
The UndoEngine uses IUndoUnits, which represent a change that can be undone and redone. Besides the standard undoable edits, custom undo units can be added as well.
Furthermore, you can instruct the UndoEngine to record a sequence of changes and automatically create a single unit from them over a period of time.
The UndoEngine dispatches the following events for each undo unit that it undoes or redoes:
Event | Occurs when … |
---|---|
Custom Undo Units
All changes to graph items are undoable by default. However, there are use cases where developers might want to add custom operations to be undoable, too. For example, while changing the style instance of an INode is undoable by default, changing a property of that style is not. If you have code that changes a property of a style dynamically, it might be a good idea to notify the UndoEngine about it, so that the change can be reverted by the user.
The simplest way to achieve this — which is often sufficient on its own — is to use addUndoUnit. You can supply names for the Undo and Redo actions that may be shown in the user interface, as well as functions that implement the actual process of undoing and redoing a change.
The following example shows the quick way of adding a custom undo unit for changing a ShapeNodeStyle’s fill property:
function changeNodeColorWithUndo(node, fill) {
const style = node.style instanceof ShapeNodeStyle ? node.style : null
if (style) {
const oldFill = style.fill
graph.addUndoUnit(
'Change Node Color',
'Change Node Color',
() => (style.fill = oldFill),
() => (style.fill = fill)
)
style.fill = fill
}
}
function changeNodeColorWithUndo(node: INode, fill: Fill): void {
const style = node.style instanceof ShapeNodeStyle ? node.style : null
if (style) {
const oldFill = style.fill
graph.addUndoUnit(
'Change Node Color',
'Change Node Color',
() => (style.fill = oldFill),
() => (style.fill = fill)
)
style.fill = fill
}
}
For more complex use cases, you may need to create a custom IUndoUnit and add it to the UndoEngine using the addUnit method when the change occurs. The undo method is supposed to revert the change it represents, and redo to restore the state after the change again, similar to the two functions passed to addUndoUnit above.
It is important that redo is the exact inverse operation of undo. That is, when executing redo after undo, the graph and its related objects are in the exact same state as before the undo. This also requires the exact same references to object instances; otherwise, subsequent undo/redo operations may no longer work correctly.Furthermore, it is important to avoid enqueuing any additional undo units while performing an undo or redo operation. Attempting to do so will result in the silent discarding of these units, potentially leading to hard-to-diagnose bugs. Above all, avoid making any modifications to the graph within the implementation of the undo/redo unit (e.g., the delegates in addUndoUnit or in undo resp. redo)
The following example shows the same Undo/Redo action we used above, just implemented with a custom IUndoUnit:
function changeNodeColor(node, fill) {
const style = node.style
const unit = new ChangeColorUndoUnit(style)
graph.undoEngine?.addUnit(unit)
style.fill = fill
}
class ChangeColorUndoUnit extends BaseClass(IUndoUnit) {
style
oldFill
newFill = null
constructor(style) {
super()
// remember the changed object
this.style = style
// remember the old value
this.oldFill = style.fill
}
get undoName() {
return 'Change Node Color'
}
get redoName() {
return 'Change Node Color'
}
undo() {
// remember the new value for redo
this.newFill = this.style.fill
// set the old value
this.style.fill = this.oldFill
}
redo() {
// set the new value
this.style.fill = this.newFill
}
tryMergeUnit(unit) {
return false
}
tryReplaceUnit(unit) {
return false
}
dispose() {}
}
function changeNodeColor(node: INode, fill: Fill) {
const style: ShapeNodeStyle = node.style as ShapeNodeStyle
const unit: ChangeColorUndoUnit = new ChangeColorUndoUnit(style)
graph.undoEngine?.addUnit(unit)
style.fill = fill
}
class ChangeColorUndoUnit extends BaseClass(IUndoUnit) implements IUndoUnit {
private style: ShapeNodeStyle
private oldFill: Fill | null
private newFill: Fill | null = null
constructor(style: ShapeNodeStyle) {
super()
// remember the changed object
this.style = style
// remember the old value
this.oldFill = style.fill
}
get undoName(): string {
return 'Change Node Color'
}
get redoName(): string {
return 'Change Node Color'
}
undo(): void {
// remember the new value for redo
this.newFill = this.style.fill
// set the old value
this.style.fill = this.oldFill
}
redo(): void {
// set the new value
this.style.fill = this.newFill
}
tryMergeUnit(unit: IUndoUnit): boolean {
return false
}
tryReplaceUnit(unit: IUndoUnit): boolean {
return false
}
dispose(): void {}
}
Built-in Undo Units
As mentioned above, yFiles for HTML automatically records every change to the graph structure in an undo unit. There might be situations, however, when you want to customize the undo operation for graph structure changes, for example, for node removals, as a means to synchronize your own business data.
Even though the concrete implementation of these undo units is private, you can still decorate them by overriding their factory methods on Graph. For example, to decorate the undo unit that undoes the “delete node” action, override the createUndoUnitForNodeRemoval method. There are factory methods like this for every graph structure change.
Merging Changes into One Undo Unit
By default, the UndoEngine merges multiple undo units that occur within a specific time span, as defined
by autoMergeTime. You can disable this feature by setting the time span to zero.
Independently, the UndoEngine can also be configured to merge similar undo units into a single unit,
if possible. This behavior relies on the IUndoUnit's
tryMergeUnit and
tryReplaceUnit methods.
You can enable this by setting mergeUnits to true
.
Sometimes, you might want to merge a chain of changes into a single unit. For example, when a node is moved, a series of undo units for position changes could be created. To avoid this, you can create a single undo unit that represents the change from the node’s initial to its final position. The UndoEngine supports recording a set of changes and merging them into a single unit at the end of the gesture.
To start recording, call beginEdit on IGraph. This method returns an instance of ICompoundEdit, which serves as a hook to end or cancel the recording. To end the recording, call the commit method. The recorded undo unit is then automatically added to the undo engine.
If the action should be canceled or something goes wrong while executing the changes, call cancel to end the recording without adding an undo unit. For example, an input gesture could be added where pressing Escape would cancel the gesture, such as moving a node. In this case, you would not want to add the changes to the UndoEngine.
let edit = null
let editWasCanceled = false
function initializeCustomCompoundEdits(graphComponent) {
// Add listener to the 'Escape' key and remember cancelling
graphComponent.addEventListener('key-up', (evt, sender) => {
if (evt.key == 'Escape') {
editWasCanceled = true
}
})
}
function startCompoundEdit(graph) {
editWasCanceled = false
edit = graph.beginEdit('My Changes', 'My Changes')
}
function endCompoundEdit() {
if (editWasCanceled) {
// 'Escape' was pressed during the changes
edit?.cancel()
} else {
// everything is fine ... commit the changes
edit?.commit()
}
edit = null
}
let edit: ICompoundEdit | null = null
let editWasCanceled: boolean = false
function initializeCustomCompoundEdits(graphComponent: GraphComponent): void {
// Add listener to the 'Escape' key and remember cancelling
graphComponent.addEventListener('key-up', (evt, sender) => {
if (evt.key == 'Escape') {
editWasCanceled = true
}
})
}
function startCompoundEdit(graph: IGraph): void {
editWasCanceled = false
edit = graph.beginEdit('My Changes', 'My Changes')
}
function endCompoundEdit() {
if (editWasCanceled) {
// 'Escape' was pressed during the changes
edit?.cancel()
} else {
// everything is fine ... commit the changes
edit?.commit()
}
edit = null
}
Remembering States for Undo (Memento Design Pattern)
The yFiles for HTML undo mechanism also supports the memento design pattern, which involves taking snapshots of an object’s state so that the object can be restored to that state later. This approach can be used to add undo support for your business data. Mementos are particularly useful when handling data states is easier than handling atomic changes to the items themselves.
The Memento design pattern is used for recording sessions of ICompoundEdit. Your model can be watched over a course of actions, and changes to it can be appropriately undone and redone. Mementos are snapshots of states of arbitrary data. The IMementoSupport interface can be used to convert the data between these states. In this regard, mementos are a more abstract concept than IUndoUnits because they omit the cumbersome process of creating and inserting IUndoUnits at the right time into the UndoEngine.
- getState
- Returns an object that represents the current state of the given object.
- applyState
- Applies the given state to the given object, i.e., restores the saved state.
- stateEquals
- Determines whether the two given states are equal. Used to avoid saving mementos for unchanged states.
To use memento support, implement the IMementoSupport interface and add a lookup to your business data.
The following example shows a class representing a business model:
// The business model
class Employee extends BaseClass(ILookup) {
_position
_age
_name
get name() {
return this._name
}
get position() {
return this._position
}
set position(value) {
this._position = value
}
get age() {
return this._age
}
set age(value) {
this._age = value
}
constructor(name) {
super()
this._name = name
}
// Returning an implementation of IMementoSupport in the lookup method
// facilitates using the memento pattern for undo
lookup(type) {
if (type === IMementoSupport) {
return new EmployeeMementoSupport()
}
return null
}
}
// The business model
class Employee extends BaseClass(ILookup) implements ILookup {
private _position: any
private _age: any
_name: any
get name(): any {
return this._name
}
get position(): any {
return this._position
}
set position(value: any) {
this._position = value
}
get age(): any {
return this._age
}
set age(value: any) {
this._age = value
}
constructor(name: any) {
super()
this._name = name
}
// Returning an implementation of IMementoSupport in the lookup method
// facilitates using the memento pattern for undo
lookup(type: Constructor): any | null {
if (type === IMementoSupport) {
return new EmployeeMementoSupport()
}
return null
}
}
A memento that keeps the Employee’s state is shown below. Note that there is no need
to keep the state of the read-only Name
property:
// Keeps the possible states of class Employee
class EmployeeState {
constructor(position, age) {
this.position = position
this.age = age
}
// Note that Employee's property "Name" cannot be changed,
// so there is no need to keep the state
position
age
}
// Keeps the possible states of class Employee
class EmployeeState {
constructor(position: string, age: number) {
this.position = position
this.age = age
}
// Note that Employee's property "Name" cannot be changed,
// so there is no need to keep the state
position: string
age: number
}
The following example shows how to record all changes to a list of business objects:
// employees is of type Iterable<Employee>
const edit = graph.beginEdit('The Undo Name', 'The Redo Name', employees)
// do some changes to the employee data
doSomeChanges()
edit.commit()
Note: If the business object classes cannot be modified and you cannot implement ILookup to provide an IMementoSupport implementation, you can provide an appropriate IMementoSupport implementation in the beginEdit<T> call:
// employees is of type Iterable<Employee>
const edit = graph.beginEdit(
'The Undo Name',
'The Redo Name',
employees,
(item) => new EmployeeMementoSupport()
)
// do some changes to the employee data
doSomeChanges()
edit.commit()