Skip to main content Skip to footer

Easy Undo/Redo for HTML Forms

A friend of mine once told me he had an idea for the "ultimate killer app". It would be a form with two buttons: "Undo" and "Redo". If you find this funny if you are a probably a geek like me...

I remembered this the other day when a customer asked me about adding an undo/redo feature to our FlexGrid control. He said he didn’t want to use the FlexSheet or SpreadJS controls (which do have built-in undo/redo capabilities) because the FlexGrid is smaller and lighter.

I told him we did not want to add this as a built-in feature because we want to keep the FlexGrid small and light, but we could implement this as a sample instead. Then we could get feedback from users, and maybe eventually release it as a new optional module (maybe wijmo.grid.undo).

Things started to get interesting when the customer replied:

That is really awesome you guys can do that! Thank you so much! The only thing I need to know is when the last undo for the grid is handled, so I can proceed with the next undo for a form component. I have to implement that myself for any inputs that are on the same page as a grid. Does that make sense?

It makes perfect sense! But this means the undo/redo feature should not be specific to the grid. Rather, it should be a page/form level feature, capable of undoing actions performed on any controls on the page. A user could fill out an input field, then make some edits on the grid, go to another field, edit the grid some more, and he should be able to undo and redo all these actions.

The request morphed from “add undo/redo capabilities to the FlexGrid” to “add undo/redo capabilities to elements containing input controls, including native HTML elements, Wijmo input controls, and the FlexGrid.”

That’s a much taller order, but it does make the new “UndoStack” much more useful.

Before doing any work, I decided to do a little research and find out what each major browser provides out of the box. Nobody wants to re-invent any wheels.

Built-In Browser Support for Undo/Redo

Undo/redo seems like such a useful feature I would expect modern browsers to provide good support for it. Unfortunately, that is not the case.

IE 11 has limited support for undo/redo. It supports undoing by clearing the value of input elements one by one. It does not redo anything and does not handle select and textarea elements, checkboxes, radio buttons, or range elements.

Edge and Firefox are worse than IE 11 in this area. Edge has no undo/redo at all, and Firefox allow you to undo the last action on each single plain text input element.

Chrome is the best browser in this area. It supports multi-level undo/redo on plain text input and textarea elements. Alas, it does not support select elements, checkboxes, radio buttons, or ranges.

This table summarizes what I found:

Browser Input (text) Input (check, radio, range) Textarea Select
IE Undo - - -
Edge - - - -
Firefox Undo - Undo -
Chrome Undo/Redo - Undo/Redo -

An UndoStack class would be useful if it could:

  1. Fill the gaps in the undo/redo features provided by all browsers,
  2. Improve consistency in undo/redo features among all browsers, and
  3. Add custom components to the list of elements with undoable actions.

A Custom UndoStack Class

The basic structure of most Undo/Redo classes is the same. They have an array of undoable actions (the stack) and a pointer that determines the next action to undo or redo.

The pseudo-code for the UndoStack class looks like this:

export class UndoStack {
        _stack: UndoableAction[] = [];
        _ptr = 0;

        get canUndo(): boolean {
            return this._ptr > 0;
        }
        get canRedo(): boolean {
            return this._ptr < this._stack.length;
        }
        undo() {
            if (this._ptr > 0) {
                this._ptr--;
                let action = this._stack[this._ptr];
                action.undo();
            }
        }
        redo() {
            if (this._ptr < this._stack.length) {
                let action = this._stack[this._ptr];
                this._ptr++;
                action.redo();
            }
        }
        reset() {
            this._ptr = 0;
            this._stack.splice(0, this._stack.length);
        }
    }

The UndoableAction class represents actions with a target control, the state before the action, and the state after the action. It contains enough information to change the application state to what it was before or after the original action took place. The pseudo-code for the UndoableAction class looks like this:

export class UndoableAction {
        protected _target: any;
        protected _oldState: any;
        protected _newState: any;

        undo() {
            this.applyState(this._oldState);
        }
        redo() {
            this.applyState(this._newState);
        }
        close(e: any): boolean {
            return this._oldState != this._newState;
        }
        applyState(state: any) {
            // override in derived classes
        }
    }

That takes care of the infrastructure. The next step is to add target elements to the UndoStack context. To do this, the UndoStack adds event listeners to each target and uses the event handlers to create and add UndoableAction instances to the stack.

For example, this is how you could add support for regular input elements to the UndoStack:

    _addInputElement (input: HTMLInputElement) {
        target.addEventListener('focus', (e) => {
            this._openAction(new InputChangeAction(target));
        }, true);
        target.addEventListener('blur', (e) => {
           this._closePendingAction();
        });
    }

When the target element gets the focus, the code opens a new InputChangeAction. This saves any pending actions and sets the current pending action to the new action, which contains the target’s current state. When the element loses focus, the code closes this pending action, saving the element’s new state and adding the action to the undo stack.

Here’s the implementation of the InputChangeAction:

class InputChangeAction extends UndoableAction {
        constructor(e) {
            super(e.target);
            this._oldState = this._target.value;
        }
        close(): boolean {
            this._newState = this._target.value;
            return this._newState != this._oldState;
        }
        applyState(state: any) {
            this._target = state;
            this._target.select();
        }
    }

This is a pretty naïve implementation, but is already as good as the best native browser undo/redo (it handles unlimited undo/redo actions on regular input elements.

Extending the UndoStack class to support more components is a matter of writing the undoable action classes and the event handlers to create them.

Adding Support for More Elements

Adding support for textarea and select elements is easy.

Like regular input elements, textarea and select elements have a value property that gets or sets the element’s current value. This allows us to use the same InputChangeAction class we used for the input elements:

_addTextAreaElement(target: HTMLTextAreaElement) {
        target.addEventListener('focus', (e) => {
            this._openAction(new InputChangeAction(target));
        });
        target.addEventListener('blur', (e) => {
            this._closePendingAction();
        });
    }
    _addSelectElement(target: HTMLSelectElement) {
        target.addEventListener('focus', (e) => {
            this._openAction(new InputChangeAction(target));
        });
        target.addEventListener('blur', (e) => {
            this._closePendingAction();
        });
    }

But input elements can be used to enter Boolean values as well as text. To support checkboxes, radio buttons, and range elements, we extended the original implementation of the _addInputElement method a little. Please refer to the source code to see the full version.

Adding Support for Custom Components

At this point, we have full undo/redo support for all HTML standard editors.

The next step is to add support for custom editor components. Let’s start with the Wijmo input controls.

Wijmo Input Controls

Wijmo input controls are more complex than single HTML elements. In addition to the main input element, many of the input controls have buttons, dropdowns, etc.

But in terms of undo/redo, most Wijmo controls can use a single property to represent their state (usually “value” or “text”). So, the main logic remains the same. Open an action when the control gets the focus, or receives a mousedown event, and close the action when it loses focus:

    _addWijmoControl(ctl: Control) {

        // create action on focus, push on blur
        let host = ctl.hostElement;
        ctl.addEventListener(host, 'focus', (e) => {
            this._openAction(new WijmoControlChangeAction(ctl));
        }, true);
        ctl.addEventListener(host, 'mousedown', (e) => {
            this._openAction(new WijmoControlChangeAction(ctl));
        }, true);
        ctl.addEventListener(host, 'blur', (e) => {
            if (!ctl.containsFocus()) {
                this._closePendingAction();
            }
        }, true);
        // handle blur on dropDown element as well
        let dd = ctl['dropDown'];
        if (dd != null) {
            ctl.addEventListener(dd, 'blur', (e) => {
                if (!ctl.containsFocus()) {
                    this._closePendingAction();
                }
            }, true);
        }
    }

Note how the “blur” event handlers call the control’s containsFocus method to determine whether the blur moved the focus to another element within the control or to an element outside (in which case the control really lost the focus).

The WijmoControlChangeAction is implemented as follows:

class WijmoControlChangeAction extends UndoableAction {
        _propName: string;
        constructor(ctl: Control) {
            super(ctl);
            this._propName = WijmoControlChangeAction._getControlValueProperty(ctl);
            let value = this._target[this._propName];
            this._oldState = isArray(value) ? value.slice(0) :
                isDate(value) ? DateTime.clone(value) : value;
        }
        close(): boolean {
            let value = this._target[this._propName];
            this._newState = isArray(value) ? value.slice(0) :
                isDate(value) ? DateTime.clone(value) : value;
            return !UndoStack._sameState(this._oldState, this._newState);
        }
        applyState(state: any) {
            this._target[this._propName] = state;
        }
        static _getControlValueProperty(ctl: Control) {
            let props = 'checkedItems,selectedItems,value,text'.split(',');
            for (let i = 0; i < props.length; i++) {
                if (props[i] in ctl) {
                    return props[i];
                }
            }
            return null;
        }
    }

The action relies on a “getControlValue” property method that chooses the property that represents the control’s current value. The method looks for “special” properties line “checkedItems” and “selectedItems” (present in the MultiSelect and MultiAutoComplete controls), and falls back on the more standard “value” (present in the InputDate, InputNumber, and other controls), and “text” (present in the ComboBox control).

The WijmoControlChangeAction class is relatively simple considering the number of input controls it supports: ComboBox, AutoComplete, InputMask, InputNumber, InputDate, InputTime, InputDateTime, InputColor, MultiSelect, and MultiAutoComplete.

FlexGrid Control

Unlike most Wijmo input controls, the FlexGrid state is not represented by a single property. Cells have values, columns have positions and widths, rows can be added or removed, etc.

Fortunately, the FlexGrid provides events that occur before and after all major actions. We can use the “before” evens to store the relevant part of the grid state before the action took place, and the “after” events to close the action before pushing it onto the stack.

Our UndoRedo stack class supports the following grid actions:

  1. GridEditAction: Represents cell editing actions, including regular edits, pasting from the clipboard, and deleting with the Delete key.
  2. GridSortAction: Represents column sorting actions (clicking column headers when the grid’s allowSorting property is set to true).
  3. GridResizeAction: Represents column resizing and auto-sizing actions (clicking and double-clicking the edge of column headers when the grid’s allowResizing property is set to true).
  4. GridDragAction: Represents column reordering actions (dragging column headers when the grid’s allowDragging property is set to true).
  5. GridAddRowAction: Represents row adding using the new row template (when the grid’s allowAddNew property is set to true)
  6. GridDeleteRowAction: Represents row deletion actions by pressing the Delete key (when the grid’s allowDelete property is set to true).

Each of these actions is represented by a class. Instances of these classes are created and added to the undo stack in response to corresponding grid events. For example, here is the code that handles the FlexGrid edit actions:

// add a FlexGrid control to the UndoStack context
    _addFlexGrid(grid: wijmo.grid.FlexGrid) {
        // ignore read-only grids
        if (grid.isReadOnly) return;
        // edit/clear actions
        grid.cellEditEnding.addHandler((s, e: wijmo.grid.CellRangeEventArgs) => {
            if (!e.cancel && !this._atNewRowAtTop(s, e) &&
                s.rows[e.row].dataItem != s.editableCollectionView.currentAddItem) {
                this._openAction(new GridEditAction(s, e));
            }
        });
        grid.cellEditEnded.addHandler((s, e: wijmo.grid.CellRangeEventArgs) => {
            this._closePendingAction();
        });

The code adds handlers to the grid’s cellEditEnding and cellEditEnded events to open and close GridEditAction actions.

The GridEditAction class is slightly more complex than the input edit class. The constructor saves the current cell value and the current time. When closing the action, the class saves the new cell value and closes as usual. In addition to this standard handling, it also implements a shouldAddAsChildAction method that causes edit actions that happen in a quick succession to be treated as a single action. This allows actions that affect ranges (like pasting or deleting cells) to be treated as a single action.

Please refer to the application source code for details on the grid edit actions.

Final Touches

Excluding Elements from the Undo Context

The UndoStack class is almost ready. One small and useful addition is the ability to exclude certain elements from the UndoStack context. For example, you could use a checkbox to show additional help or change the style of the form, and you might want to exclude that non-data-related element from the undo stack.

This is easily done by making the addUndoTarget method skip elements that contain a certain class:

addUndoTarget(target: any) {

        // selectors
        if (isString(target)) {
            this.addUndoTarget(getElement(target));
        }
        // skip elements with 'wj-no-undo' attribute
        else if (target instanceof HTMLElement &&
                 target.classList.contains('wj-no-undo')) {
        }
        // input elements
        else if (target instanceof HTMLInputElement) {
            this._addInputElement(target);
        }
        // etc

Now, you can exclude elements from the UndoStack context by giving them a “wj-no-undo” class.

Limiting the Stack Size

Another refinement you may want to add is the ability to limit the size of the undo stack. We added a maxActions property to the UndoStack class. Before pushing a new action into the stack, the class checks the size limit and discards actions from the bottom of the stack as needed.

Conclusion

The UndoStack class presented here can be used to add undo/redo functionality to HTML applications and forms. It supports standard HTML input elements as well as Wijmo controls.

The UndoStack class was designed to be extensible, so you can add support for more custom components if you want.

The fiddle below shows the UndoStack class in action:

http://jsfiddle.net/Wijmo5/y75uddtc/

If you have any suggestions or feedback on the UndoStack class or on the Wijmo controls, we’d love to hear from you: wijmoexperts@componentone.com.

comments powered by Disqus