documentationfor yFiles for HTML 2.6

Undo and Redo

The customization options of the undo mechanism in yFiles for HTML include changing the behavior of the built-in undo process, controlling the recording process of changes, and synchronizing your data and the changes made by undo.

The undo support in yFiles for HTML is managed by the UndoEngine class, which is usually associated with an IGraph instance. To obtain the UndoEngine from an IGraph where 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 thereof over a period of time.

The UndoEngine dispatches the following events for each undo unit that it undoes or redoes:

Related events
Event Occurs when …​
UnitUndone…​ the undo engine has successfully executed IUndoUnit.undo.
UnitRedone…​ the undo engine has successfully executed IUndoUnit.redo.

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 tell the UndoEngine about it, so that the change can be reverted by the user.

The simplest way to go about 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:

Adding a custom Undo/Redo action to the undo engine
/**
 * @param {!INode} node
 * @param {!Fill} fill
 */
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. You can extend the UndoUnitBase class which removes some of the necessary boilerplate. 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, as 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:

Implementing a custom undo unit and adding it to the undo engine
/**
 * Changes the color on the node's ShapeNodeStyle and makes this operation undoable
 * @param {!INode} node
 * @param {!Fill} fill
 */
function changeNodeFill(node, fill) {
  const style = node.style

  // for the following operation, create an undo unit for it and insert it into the UndoEngine
  const unit = new ChangeColorUndoUnit(style)
  graph.undoEngine.addUnit(unit)

  // execute the operation
  style.fill = fill
}

/**
 * An undo unit that remember the paint of the given node style at creation
 * and undos and redos the changing of the paint property.
 */
class ChangeColorUndoUnit extends UndoUnitBase {
  style
  oldFill
  // remember the new value for redo
  newFill = null

  /**
   * @param {!ShapeNodeStyle} style
   */
  constructor(style) {
    super('Change Node Color')
    // remember the changed object
    this.style = style
    // remember the old value
    this.oldFill = style.fill
  }

  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
  }
}/**
 * Changes the color on the node's ShapeNodeStyle and makes this operation undoable
 */
function changeNodeFill(node: INode, fill: Fill): void {
  const style: ShapeNodeStyle = node.style as ShapeNodeStyle

  // for the following operation, create an undo unit for it and insert it into the UndoEngine
  const unit = new ChangeColorUndoUnit(style)
  graph.undoEngine!.addUnit(unit)

  // execute the operation
  style.fill = fill
}

/**
 * An undo unit that remember the paint of the given node style at creation
 * and undos and redos the changing of the paint property.
 */
class ChangeColorUndoUnit extends UndoUnitBase {
  style: ShapeNodeStyle
  oldFill: Fill | null
  // remember the new value for redo
  private newFill: Fill | null = null

  constructor(style: ShapeNodeStyle) {
    super('Change Node Color')
    // remember the changed object
    this.style = style
    // remember the old value
    this.oldFill = style.fill
  }

  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
  }
}

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, for example.

Even though the concrete implementation of these undo units is private, you can still decorate them by overriding their factory methods on DefaultGraph. 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 during a certain time span as defined by autoMergeTime. You can disable this by setting the time span to a zero value. Independently, the UndoEngine can also be configured to try to merge similar undo units into one single unit if possible. This behavior relies on IUndoUnit’s tryMergeUnit and tryReplaceUnit methods. You can enable it by setting mergeUnits to true.

Sometimes we want to merge a chain of changes into one single unit. This happens, e.g., when a node is moved, which would create a lot of undo units for position changes. However, we only want one single undo unit for that, i.e. one change from its initial to its final position. The UndoEngine supports recording a set of changes and merging it into one single unit at the end of the gesture.

To start recording, call beginEdit on IGraph. This method returns an instance of ICompoundEdit which can be used as a hook to end or cancel the recording. To end the recording you need to call the commit method. The recorded undo unit is automatically added to the undo engine.

If the action should be canceled or something went 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, e.g. moving a node. In this case we would not want to add the changes to the UndoEngine.

Automatically recording a set of changes
// start recording
const edit = graph.beginEdit('The Undo Name', 'The Redo Name')
try {
  // do some changes to the employee data
  doSomeChanges()

  // after the changes, stop the recording
  // the changes were successful, so commit the undo to the engine
  edit.commit()
} catch (e) {
  // something went wrong, ignore this edit
  edit.cancel()
}

Remembering States for Undo (Memento Design Pattern)

The yFiles for HTML undo mechanism also supports the memento design pattern, i.e. taking snapshots of the state of an object to be able to restore that state later. This concept can be used to add undo support for your business data. Mementos are particularly efficient where 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 of IUndoUnits where the cumbersome process of creating and inserting IUndoUnits at the right time into the UndoEngine is omitted.

getState
Returns an object which represents the current state of the given object.
applyState
Applies the given state to the given object, i.e. restores the saved state.
stateEquals
Whether the two given states are equal. Used to avoid saving mementos for unchanged states.

To use memento support implement the IMementoSupport interface …​

/**
 * The memento support for class Employee.
 */
class EmployeeMementoSupport extends BaseClass(IMementoSupport) {
  /**
   * @param {*} subject
   * @returns {?object}
   */
  getState(subject) {
    // Provides an EmployeeState instance
    // which represents the current state of the Employee
    if (subject instanceof Employee) {
      return {
        position: subject.position,
        age: subject.age
      }
    }
    return null
  }

  /**
   * @param {*} subject
   * @param {*} state
   */
  applyState(subject, state) {
    // Applies the given state to the Employee instance
    if (subject instanceof Employee) {
      const employee = subject
      employee.position = state.position
      employee.age = state.age
    }
  }

  /**
   * @param {*} state1
   * @param {*} state2
   * @returns {boolean}
   */
  stateEquals(state1, state2) {
    // Whether the two given states are equal
    return state1.age === state2.age && state1.position === state2.position
  }
}/**
 * The memento support for class Employee.
 */
class EmployeeMementoSupport extends BaseClass(IMementoSupport) implements IMementoSupport {
  getState(subject: any): { position: any; age: any } | null {
    // Provides an EmployeeState instance
    // which represents the current state of the Employee
    if (subject instanceof Employee) {
      return {
        position: subject.position,
        age: subject.age
      }
    }
    return null
  }

  applyState(subject: any, state: any): void {
    // Applies the given state to the Employee instance
    if (subject instanceof Employee) {
      const employee = subject
      employee.position = state.position
      employee.age = state.age
    }
  }

  stateEquals(state1: any, state2: any): boolean {
    // Whether the two given states are equal
    return state1.age === state2.age && state1.position === state2.position
  }
}

... and add a lookup to your business data:

// The business model
class Employee extends BaseClass(ILookup) {
  _position
  _age
  _name

  /**
   * @type {*}
   */
  get name() {
    return this._name
  }

  /**
   * @type {*}
   */
  get position() {
    return this._position
  }

  /**
   * @type {*}
   */
  set position(value) {
    this._position = value
  }

  /**
   * @type {*}
   */
  get age() {
    return this._age
  }

  /**
   * @type {*}
   */
  set age(value) {
    this._age = value
  }

  /**
   * @param {*} name
   */
  constructor(name) {
    super()
    this._name = name
  }

  // Returning an implementation of IMementoSupport in the lookup method
  // facilitates using the memento pattern for undo
  /**
   * @template {*} T
   * @param {!Class.<T>} type
   * @returns {?T}
   */
  lookup(type) {
    if (type === IMementoSupport.$class) {
      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
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint
  lookup<T extends any>(type: Class<T>): T | null {
    if (type === IMementoSupport.$class) {
      return new EmployeeMementoSupport() as T
    }
    return null
  }
}

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()