// 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;
}
});