Source: core/undo-manager.js

// Consider proposal at https://rniwa.com/editing/undomanager.html
/**
 @module montage/core/undo-manager
 */

var Montage = require("./core").Montage,
    Target = require("./target").Target,
    Promise = require("./promise").Promise,
    Map = require("collections/map"),
    List = require("collections/list");

var UNDO_OPERATION = 0,
    REDO_OPERATION = 1;

/**
 * Applications that allow end-user operations can use an UndoManager to record
 * information on how to undo those operations.
 *
 * ## Undoable Operations
 *
 * To make an operation undoable an application simply adds the inverse of that
 * operation to an UndoManager instance using the `add` method:
 *
 * `undoManager.register(label, operationPromise)`
 *
 * This means that every undo-able user operation has to have an inverse
 * operation available. For example a calculator might provide a `subtract`
 * method as the inverse of the `add` method.
 *
 * An simple example would look something like this:
 *
 * ```javascript
 * add: {
 *        value: function (number) {
 *            this.undoManager.register("Add", Promise.resolve([this.subtract, this, number]));
 *            var result = this.total += number;
 *            return result;
 *        }
 *    },
 *
 * subtract: {
 *        value: function (number) {
 *            this.undoManager.register("Subtract", Promise.resolve([this.add, this, number]));
 *            var result = this.total -= number;
 *            return result;
 *        }
 *    }
 * ```
 *
 * Of immediate interest is the actual promise added to the undoManager.
 * `Promise.resolve(["Add", this.subtract, this, number])`
 *
 * The promise provides the final label (optionally), a reference to the function to call,
 * the context for the function to be executed in, and any number of arguments
 * to be passed along when calling the function.
 *
 * In simple cases such as this the promise for the inverse operation
 * can be resolved immediately; this is not necessarily always possible in cases
 * where the operation itself is asynchronous.
 *
 * ## Basic Undoing and Redoing
 *
 * After performing `calculator.add(42)` the undoManager will have an entry
 * on how to undo that addition operation. Each operation added to the
 * undoManager is added on top of a stack. Calling the undoManager's `undo`
 * method will perform the operation on the top of that stack if
 * original operationPromise has been resolved.
 *
 * While performing an undo operation any additions to the undoManager will
 * instead be placed on the redo stack. Conversely, any additions made while
 * performing a redo operation will be placed on the undo stack.
 *
 * When not actively undoing or redoing, the redo stack is cleared whenever a
 * new operation is added; the only way operations end up on the redo stack is
 * through undoing an operation.
 *
 * ## Asynchronous Considerations
 *
 * It is possible for a user invoked operation to take some time to complete or
 * details of how to undo the operation may not be known until the operation
 * has completed.
 *
 * In these cases it is important to remember that the undo stack captures user
 * intent, which is considered synchronous. This is why the undoManager accepts
 * promises for the operations but places them on the stack synchronously.
 *
 * Consider the following example:
 *
 * ```javascript
 * addRandomNumber: {
 *        var deferredUndo,
 *            self = this;
 *
 *        this.undoManager.register("Add Random", deferredUndo.promise);
 *
 *        return this.randomNumberGeneratorService.next().then(function (rand) {
 *            deferredUndo.resolve(["Add " + rand, self.subtract, self, rand];
 *            var result = self.total = self.total + number;
 *            return result
 *        });
 *    }
 * ```
 *
 * Here we see that the undo operation for addRandomNumber is added to the
 * UndoManager before we even know how to undo the operation, indeed it's added
 * before the operation has even happened.
 *
 * It is worth noting that the undoManager does not block anything. Users are
 * still free to call `add`, `subtract`, `addRandomNumber` or any
 * other APIs exposed by the calculator, whether the `addRandomNumber` has
 * resolved or not. It's the responsibility of an API provider to handle this
 * scenario as necessary.
 *
 * At this point two things can happen:
 * 1) A user could invoke `undo` after the operation promise's resolution.
 * 2) A user could invoke `undo` prior to the operation promise's resolution.
 *
 * In the first scenario, things move along much like they did in the first case
 * we described above.
 *
 * In the second scenario, the undoManager puts the unresolved promise into a
 * queue of operations to be performed when possible. Subsequent undo and redo
 * requests are added to this queue.
 *
 * Whenever a promise is resolved the undoManager runs through this queue in
 * order, oldest to newest, and attempts to perform the operation specified,
 * stopping when it encounters an unfulfilled operation promise.
 *
 * This guarantees that promised operations are added in the order as they were
 * performed by the user and are executed, not in the order they are fulfilled,
 * but in the order they are undone or redone.
 *
 * class UndoManager
 * extends Target
 */
var UndoManager = exports.UndoManager = Target.specialize( /** @lends UndoManager# */ {

    /**
     * Dispatched when a new change is registered (i.e. not while undoing or
     * redoing).
     * @event operationRegistered
     * @memberof UndoManager
    */

    /**
     * Dispatched when an undo has been completed.
     * @event undo
     * @memberof UndoManager
     */

    /**
     * Dispatched when a redo has been completed.
     * @event redo
     * @memberof UndoManager
     */

    _operationQueue: {
        value: null
    },

    _promiseOperationMap: {
        value: null
    },

    constructor: {
        value: function UndoManager() {
            this._operationQueue = [];
            this._promiseOperationMap = new Map();
            this._undoStack = new List();
            this._redoStack = new List();
            this._batchStack = new List();

            this.defineBinding("undoLabel", {"<-": "undoEntry.label || _promiseOperationMap.get(_undoStack.head.prev.value).label"});
            this.defineBinding("undoCount", {"<-": "length", source: this._undoStack});
            this.defineBinding("canUndo", {"<-": "!!length", source: this._undoStack});
            this.defineBinding("isUndoing", {"<-": "!!undoEntry"});

            this.defineBinding("redoLabel", {"<-": "redoEntry.label || _promiseOperationMap.get(_redoStack.head.prev.value).label"});
            this.defineBinding("redoCount", {"<-": "length", source: this._redoStack});
            this.defineBinding("canRedo", {"<-": "!!length", source: this._redoStack});
            this.defineBinding("isRedoing", {"<-": "!!redoEntry"});

            this.defineBinding("currentBatch", {"<-": "_batchStack.head.prev.value"});
        }
    },

    _maxUndoCount: {
        enumerable: false,
        value: null
    },

    /**
     * Maximum number of operations allowed in each undo and redo stack.
     * Setting this lower than the current count of undo/redo operations will
     * remove the oldest undos/redos as necessary to meet the new limit.
     */
    maxUndoCount: {
        get: function () {
            return this._maxUndoCount;
        },
        set: function (value) {
            if (value === this._maxUndoCount) {
                return;
            }

            this._maxUndoCount = value;

            if (this._maxUndoCount) {
                this._trimStacks();
            }
        }

    },

    _undoStack: {
        value: null
    },

    /**
     * The current number of stored undoable operations
     */
    undoCount: {
        value: 0
    },

    _redoStack: {
        value: null
    },

    /**
     * The current number of stored redoable operations
     */
    redoCount: {
        value: 0
    },

    _trimStacks: {
        enumerable: false,
        value: function () {

            var undoRemoveCount = this._undoStack.length - this._maxUndoCount,
                redoRemoveCount = this._redoStack.length - this._maxUndoCount;

            if (undoRemoveCount > 0) {
                this._undoStack.splice(0, undoRemoveCount);
            }

            if (redoRemoveCount > 0) {
                this._redoStack.splice(0, redoRemoveCount);
            }
        }
    },

    /**
     * Whether or not to accept registration of undo/redo operations.
     *
     * This is typically used to disable registration of operations
     * temporarily while undoable actions should be performed without
     * being undoable.
     */
    registrationEnabled: {
        value: true
    },

    /**
     * The stack of batch undos
     */
    _batchStack: {
        value: null
    },

    /**
     * The batch to which newly registered undo and redo operations will be added
     */
    currentBatch: {
        value: null
    },

    /**
     * Opens a batch operation; subsequent calls to `register` will add those
     * operations to this batch.
     */
    openBatch: {
        value: function (label) {
            var deferredBatch = {};
            deferredBatch.label = label;
            deferredBatch.promisedOperations = [];
            this._batchStack.push(deferredBatch);
        }
    },

    /**
     * Closes the current batch operation; subsequent calls to `register` will
     * add operations to the parent batch or the top level if the closed batch
     * was the top-most batch.
     */
    closeBatch: {
        value: function () {
            var self = this;
            if (!this.currentBatch) {
                throw new Error("No batch operation to close");
            }

            var label = this.currentBatch.label,
                promisedOperations = this.currentBatch.promisedOperations,
                operations = [],
                entry,
                batchOperation = function () {
                    entry = Object.create(null);

                    // Open a batch to collect redo operations
                    this.openBatch(label);

                    var done = operations.reduceRight(function (previous, operationInfo) {
                        return previous.then(function () {
                            self._resolveUndoEntry(entry, operationInfo);
                            return entry.undoFunction.apply(entry.context, entry.args);
                        });
                    }, Promise.resolve());

                    return done.finally(function () {
                        self.closeBatch();
                    });
                };


            var batchPromise = promisedOperations.reduce(function (previous, promisedOperation) {
                return previous.then(function (resolvedOperation) {
                    if (resolvedOperation) {
                        operations.push(resolvedOperation);
                    }
                    return promisedOperation;
                });
            }, Promise.resolve());

            this._batchStack.pop();

            this.register(label, batchPromise.then(function (finalOperation) {
                operations.push(finalOperation);
                // We resolve the batch undo with a function we've created to undo all the child operations
                // it is resolved with the same shape as the usual undo operation promises
                return [batchOperation, self];
            }));
        }
    },

    /**
     * Adds a new operation to the either the undo or redo stack as appropriate.
     *
     * The operationPromise should be resolved with an array containing:
     *     - A label string for the operation (optional)
     *     - The function to execute when performing this operation
     *     - The object to use as the context when performing the function
     *     - Any number of arguments to apply when performing the function
     *
     * ### Examples
     *
     * Registering an undo operation with no arguments
     * ```javascript
     * undoManager.register("Square", Promise.resolve([calculator.sqrt, calculator]));
     *  ```
     *
     *    Registering an undo operation with arguments
     * ```javascript
     * undoManager.register("Add", Promise.resolve([calculator.subtract, calculator, number]));
     * ```
     *
     * Registering an undo operation with a label and arguments
     *
     * ```javascript
     *     undoManager.register("Add", Promise.resolve(["Add 5", calculator.subtract, calculator, 5]));
     * ```
     *
     * @param {string} label A label to associate with this undo entry.
     * @param {promise} operationPromise A promise for an undoable operation
     * @returns a promise for the resolution of the operationPromise
     * @function
     */
    register: {
        value: function (label, operationPromise) {

            var promisedUndoableOperation,
                self = this;

            if (typeof operationPromise.then !== "function") {
                throw new Error("UndoManager expected a promise");
            }

            if (0 === this._maxUndoCount || !this.registrationEnabled) {
                return Promise.resolve(null);
            }

            if (this.currentBatch) {
                this.currentBatch.promisedOperations.push(operationPromise);
                promisedUndoableOperation = operationPromise;
            } else {

                var undoEntry = {label: label};
                this._promiseOperationMap.set(operationPromise, undoEntry);

                if (this.isUndoing) {

                    // Preserve the current undo label as the redo label by default
                    undoEntry.label = this.undoLabel;

                    if (this._redoStack.length === this._maxUndoCount) {
                        this._redoStack.shift();
                    }

                    this._redoStack.push(operationPromise);
                } else {

                    if (this._undoStack.length === this._maxUndoCount) {
                        this._undoStack.shift();
                    }

                    this._undoStack.push(operationPromise);

                    if (!this.isRedoing && this._redoStack.length > 0) {
                        this.clearRedo();
                    }
                }

                // Only call if this is a new change, not one being added
                // during an undo or redo operation
                if (!this.isUndoing && !this.isRedoing) {
                    this.dispatchEventNamed("operationRegistered", true, false);
                }

                promisedUndoableOperation = operationPromise.then(function (operationInfo) {
                    self._resolveUndoEntry(undoEntry, operationInfo);
                    return undoEntry;
                }).then(function () {
                    return self._flushOperationQueue();
                });
            }

            return promisedUndoableOperation;
        }
    },

    _resolveUndoEntry: {
        value: function (entry, operationInfo) {
            var label,
                undoFunction,
                context,
                firstArgIndex;

            if (typeof operationInfo[0] === "string") {
                label = operationInfo[0];
                undoFunction = operationInfo[1];
                context = operationInfo[2];
                firstArgIndex = 3;
            } else {
                undoFunction = operationInfo[0];
                context = operationInfo[1];
                firstArgIndex = 2;
            }

            if (label) {
                entry.label = label;
            }

            if (typeof undoFunction !== "function") {
                throw new Error("Need undo function for '" + entry.label + "' operation, not: " + undoFunction);
            }

            entry.undoFunction = undoFunction;
            entry.context = context;
            entry.args = operationInfo.slice(firstArgIndex);
        }
    },

    _flushOperationQueue: {
        value: function () {
            var opQueue = this._operationQueue,
                opCount = opQueue.length,
                completedPromises = [],
                completedCount,
                opMap = this._promiseOperationMap,
                self = this;

            if (0 === opCount) {
                return;
            }

            // If we hit an operation without an undoFunction then we can't
            // process any more. Equivalent to a `break` in a for-loop
            var inoperableOperation = false;
            var performed = opQueue.reduce(function (previous, promise) {
                var entry = self._promiseOperationMap.get(promise);

                if (!inoperableOperation && typeof entry.undoFunction === "function") {
                    completedPromises.push(promise);
                    return previous.then(function () {
                        return self._performOperation(entry);
                    }).then(function () {
                        opMap.delete(promise);
                    });
                } else {
                    inoperableOperation = true;
                    return previous;
                }
            }, Promise.resolve());

            completedCount = completedPromises.length;
            if (completedCount > 0) {
                // remove the (soon to be) performed operations
                opQueue.splice(0, completedCount);
            }

            return performed;
        }
    },

    _performOperation: {
        value: function (entry) {
            var self = this;

            if (entry.operationType === UNDO_OPERATION) {
                this.undoEntry = entry;
            } else {
                this.redoEntry = entry;
            }

            var opResult;
            try {
                opResult = entry.undoFunction.apply(entry.context, entry.args);
            } catch (e) {
                entry.deferredOperationReject(e);
                throw e;
            }

            if (opResult && typeof opResult.then === "function") {
                return opResult.finally(function () {
                    self.undoEntry = null;
                    self.redoEntry = null;
                }).then(function (success) {
                    entry.deferredOperationResolve(success);
                }, function (failure) {
                    entry.deferredOperationReject(failure);
                });
            } else {
                this.undoEntry = null;
                this.redoEntry = null;
                entry.deferredOperationResolve(opResult);
            }
            return opResult;
        }
    },

    /**
     * Removes all items from the undo stack.
     * @function
     */
    clearUndo: {
        value: function () {
            this._undoStack.splice(0, this._undoStack.length);
        }
    },

    /**
     * Removes all items from the redo stack.
     * @function
     */
    clearRedo: {
        value: function () {
            this._redoStack.splice(0, this._redoStack.length);
        }
    },

    /**
     * Returns `true` if the UndoManager is in the middle of an undo operation, otherwise returns `false`.
     */
    isUndoing: {
        // TODO restore as computed property with dependency on undoEntry
        value: false
    },

    /**
     * Returns `true` if the UndoManager is in the middle of an redo operation, otherwise returns `false`.
     */
    isRedoing: {
        // TODO restore as computed property with dependency on reoEntry
        value: false
    },

    undoEntry: {
        enumerable: false,
        value: null
    },

    redoEntry: {
        enumerable: false,
        value: null
    },

    /**
     * Schedules the next undo operation for invocation as soon as possible
     * @function
     * @returns {Promise} A promise resolving to true when this undo request has been performed
     */
    undo: {
        value: function () {

            if (0 === this.undoCount) {
                return Promise.resolve(null);
            }

            var self = this;
            return this._scheduleOperation(this._undoStack.pop(), UNDO_OPERATION)
            .then(function (value) {
                self.dispatchEventNamed("undo", true, false, value);
                return value;
            });
        }
    },

    /**
     * Schedules the next redo operation for invocation as soon as possible
     * @function
     * @returns {Promise} A promise resolving to true when this redo request has been performed
     */
    redo: {
        value: function () {

            if (0 === this.redoCount) {
                return Promise.resolve(null);
            }

            var self = this;
            return this._scheduleOperation(this._redoStack.pop(), REDO_OPERATION)
            .then(function (value) {
                self.dispatchEventNamed("redo", true, false);
                return value;
            });
        }
    },

    _scheduleOperation: {
        value: function (operationPromise, operationType) {

            var entry = this._promiseOperationMap.get(operationPromise),
                deferredOperationPromise = new Promise(function(resolve, reject) {
                    entry.deferredOperationResolve = resolve;
                    entry.deferredOperationReject = reject;
                });

            entry.operationType = operationType;

            this._operationQueue.push(operationPromise);

            return this._flushOperationQueue().thenReturn(deferredOperationPromise);
        }
    },

    /**
     * Returns true if the undo stack contains any items, otherwise returns
     * false.
     */
    canUndo: {
        // TODO restore this as a readOnly getter with a dependency on the
        // undoStack.length
        value: null
    },

    /**
     * Returns true if the redo stack contains any items, otherwise returns
     * false.
     */
    canRedo: {
        // TODO restore this as a readOnly getter with a dependency on the
        // redoStack.length
        value: null
    },

    /**
     * Contains the label describing the operation on top of the undo stack.
     * End-users are strongly advised to prefix this with a localized "Undo"
     * when presenting the label within an interface.
     */
    undoLabel: {
        // TODO restore this as a readOnly getter with a dependency on the
        // undoStack.head.prev
        value: null
    },

    /**
     * Contains the label describing the operation on top of the redo stack.
     * End-users are strongly advised to prefix this with a localized "Redo"
     * when presenting the label within an interface.
     */
    redoLabel: {
        // TODO restore this as a readOnly getter with a dependency on the
        // redoStack.head.prev
        value: null
    }

});

var _defaultUndoManager = null;
Montage.defineProperty(exports, "defaultUndoManager", {
    get: function () {
        if (!_defaultUndoManager) {
            _defaultUndoManager = new UndoManager();
        }
        return _defaultUndoManager;
    }
});