/**
* The Undo/Redo Component
*
* @module aui-undo-redo
*/
/**
* Fired after a redo has finished running.
*
* @event afterRedo
*/
/**
* Fired after an undo has finished running.
*
* @event afterUndo
*/
/**
* Fired right before a redo is run.
*
* @event beforeRedo
* @preventable _defBeforeActionFn
*/
/**
* Fired right before an undo is run.
*
* @event beforeUndo
* @preventable _defBeforeActionFn
*/
A.UndoRedo = A.Base.create('undo-redo', A.Base, [], {
ACTION_TYPE_REDO: 'redo',
ACTION_TYPE_UNDO: 'undo',
AFTER_REDO: 'afterRedo',
AFTER_UNDO: 'afterUndo',
BEFORE_REDO: 'beforeRedo',
BEFORE_UNDO: 'beforeUndo',
EVENT_PREFIX_AFTER: 'after',
EVENT_PREFIX_BEFORE: 'before',
/**
* This index points to the last state that was executed. Calling undo()
* will undo the state this index points to.
*
* @property _currentStateIndex
* @type {Number}
* @protected
*/
_currentStateIndex: -1,
/**
* List of pending actions.
*
* @property _pendingActions
* @type {Array}
* @protected
*/
_pendingActions: null,
/**
* List of states containing `undo` and `redo` action methods added by the
* user through the `add` method.
*
* @property _states
* @type {Array}
* @protected
*/
_states: null,
/**
* Constructor for the Undo/Redo component.
*
* @method initializer
* @protected
*/
initializer: function() {
this.clearHistory();
A.getDoc().on(
'key',
A.bind('_onRedoKey', this),
'down:89+ctrl'
);
A.getDoc().on(
'key',
A.bind('_onUndoKey', this),
'down:90+ctrl'
);
this.publish({
afterRedo: {},
afterUndo: {},
beforeRedo: {
defaultFn: this._defBeforeActionFn,
preventedFn: this._prevBeforeActionFn
},
beforeUndo: {
defaultFn: this._defBeforeActionFn,
preventedFn: this._prevBeforeActionFn
}
});
this.after('maxUndoDepthChange', this._removeStatesBeyondMaxDepth);
},
/**
* Adds a state to the stack and makes it the current state. Note that all
* states that could be redone will be removed from the stack after this.
* Valid states are objects that have at least 2 functions: undo and redo.
* These functions can return promises, in which case any subsequent calls
* will be queued, waiting for all pending promises to end.
*
* @method add
* @param {Object | Function} state Object that contains `undo` and `redo`
* action methods (and, optionally, a 'merge' method).
*/
add: function(state) {
if (!state.undo || !state.redo) {
throw new Error('Invalid state. States used in UndoRedo need to ' +
'have both the \'undo\' and the \'redo\' functions defined');
}
if (this._currentStateIndex < this._states.length - 1) {
// First remove all states after the current one, since
// those can't be redone anymore now that a new state was added.
this._states = this._states.slice(0, this._currentStateIndex + 1);
}
if (this._tryMerge(state)) {
// We shouldn't add the state again if it was already merged.
return;
}
this._states.push(state);
this._currentStateIndex++;
this._removeStatesBeyondMaxDepth();
},
/**
* Checks if it's possible to redo an action.
*
* @method canRedo
* @return {Boolean}
*/
canRedo: function() {
return this._currentStateIndex < this._states.length - 1 && !this._shouldIgnoreNewActions();
},
/**
* Checks if it's possible to undo an action.
*
* @method canUndo
* @return {Boolean}
*/
canUndo: function() {
return this._currentStateIndex >= 0 && !this._shouldIgnoreNewActions();
},
/**
* Resets the stack, clearing all states and pending actions.
*
* @method clearHistory
*/
clearHistory: function() {
this._states = [];
this._pendingActions = [];
this._currentStateIndex = -1;
},
/**
* Checks if either an undo or a redo action is currently in progress.
*
* @method isActionInProgress
* @return {Boolean}
*/
isActionInProgress: function() {
return this._pendingActions.length > 0;
},
/**
* Redoes the next state.
*
* @method redo
* @return {Boolean} Returns false if there was no state to be redone and
* true, otherwise.
*/
redo: function() {
if (!this.canRedo()) {
return false;
}
this._currentStateIndex++;
this._runAction({
state: this._states[this._currentStateIndex],
type: this.ACTION_TYPE_REDO,
undoIndex: this._currentStateIndex - 1
});
return true;
},
/**
* Returns the state that will be redone when calling redo().
*
* @method redoPeek
* @return {Boolean}
*/
redoPeek: function() {
return this._states[this._currentStateIndex + 1];
},
/**
* Undoes the last state.
*
* @method undo
* @return {Boolean} Returns false if there was no state to be undone and
* true, otherwise.
*/
undo: function() {
if (!this.canUndo()) {
return false;
}
this._runAction({
state: this._states[this._currentStateIndex],
type: this.ACTION_TYPE_UNDO,
undoIndex: this._currentStateIndex
});
this._currentStateIndex--;
return true;
},
/**
* Returns the state that will be undone when calling undo().
*
* @method undoPeek
* @return {Boolean}
*/
undoPeek: function() {
return this._states[this._currentStateIndex];
},
/**
* Executes right after an action finishes running.
*
* @method _afterAction
* @param {Object} action Object containing the `state` and action `type`
* (undo or redo) to be executed.
* @protected
*/
_afterAction: function(action) {
this.fire(this._makeEventName(this.EVENT_PREFIX_AFTER, action.type));
this._pendingActions.shift();
this._removeStatesBeyondMaxDepth();
this._runNextPendingAction();
},
/**
* Calls redo method on ctrl + y keydown event
*
* @method _onRedoKey
* @return {EventFacade} event
*/
_onRedoKey: function() {
this.redo();
},
/**
* Calls undo method on ctrl + z keydown event
*
* @method _onUndoKey
* @return {EventFacade} event
*/
_onUndoKey: function() {
this.undo();
},
/**
* This is the default function for the beforeUndo and beforeRedo events.
*
* @method _defBeforeActionFn
* @param {EventFacade} event
* @protected
*/
_defBeforeActionFn: function() {
var action = this._pendingActions[0],
result = action.state[action.type]();
if (A.Promise.isPromise(result)) {
result.then(A.bind(this._afterAction, this, action));
}
else {
this._afterAction(action);
}
},
/**
* Constructs the event's name based on its prefix and the action type
* related to it.
*
* @method _makeEventName
* @return {String} Returns the camel case version of `prefix` plus
* `actionType`.
* @protected
*/
_makeEventName: function(prefix, actionType) {
return prefix + actionType.substring(0, 1).toUpperCase() + actionType.substring(1);
},
/**
* This function runs when a beforeUndo or beforeRedo event is prevented.
*
* @method _prevBeforeActionFn
* @param {EventFacade} event
* @protected
*/
_prevBeforeActionFn: function() {
var action = this._pendingActions[0];
this._currentStateIndex = action.undoIndex;
this._pendingActions = [];
this._removeStatesBeyondMaxDepth();
},
/**
* Removes states in the stack if it's over the max depth limit.
*
* @method _removeStatesBeyondMaxDepth
* @protected
*/
_removeStatesBeyondMaxDepth: function() {
var extraCount = this._currentStateIndex + 1 - this.get('maxUndoDepth');
// We should ignore this call if there are pending actions, as we may need
// to roll back to a state due to the user preventing one of them.
if (extraCount > 0 && !this._pendingActions.length) {
this._states = this._states.slice(extraCount);
this._currentStateIndex -= extraCount;
}
},
/**
* Executes the given action (which can be either undo or redo).
*
* @method _runAction
* @param {Object} action Object containing the `state` and action `type`
* (undo or redo) to be executed.
* @protected
*/
_runAction: function(action) {
this._pendingActions.push(action);
if (this._pendingActions.length === 1) {
this._runNextPendingAction();
}
},
/**
* Executes the next pending action, if one exists.
*
* @method _runNextPendingAction
* @protected
*/
_runNextPendingAction: function() {
var action = this._pendingActions[0];
if (!action) {
return;
}
this.fire(this._makeEventName(this.EVENT_PREFIX_BEFORE, action.type));
},
/**
* Checks if new actions (calls to undo/redo) should be ignored. Actions
* should only be ignored if the `queueable` attribute is false and there
* is currently an action in progress.
*
* @method _shouldIgnoreNewActions
* @return {Boolean}
* @protected
*/
_shouldIgnoreNewActions: function() {
return !this.get('queueable') && this.isActionInProgress();
},
/**
* Tries to merge the given state to one at the current position in the
* stack.
*
* @method _tryMerge
* @return {Boolean} Returns true if the merge happened and false otherwise.
* @protected
*/
_tryMerge: function(state) {
if (this._currentStateIndex >= 0 && A.Lang.isFunction(state.merge)) {
return state.merge(this.undoPeek());
}
return false;
}
}, {
ATTRS: {
/**
* Limits the states stack size. Useful for memory optimization.
*
* @attribute maxUndoDepth
* @default 100
* @type {Number}
*/
maxUndoDepth: {
validator: function(depth) {
return depth >= 1;
},
value: 100
},
/**
* Defines how this module will behave when the user calls undo
* or redo while an action is still in progress. If false, these
* calls will be ignored. If true, they will be queued, running
* in order as soon as the pending action finishes.
*
* @attribute queueable
* @default false
* @type {Boolean}
*/
queueable: {
validator: A.Lang.isBoolean,
value: false
}
}
});