scripts/libs/angular-history/history.js
/*global angular*/
/**
* @ngdoc overview
* @name decipher.history
* @description
* A history service for AngularJS. Undo/redo, that sort of thing. Has nothing to do with the "back" button, unless you want it to.
*
*/
(function () {
'use strict';
var DEEPWATCH_EXP = /^\s*(.*?)\s+for\s+(?:([\$\w][\$\w\d]*)|(?:\(\s*([\$\w][\$\w\d]*)\s*,\s*([\$\w][\$\w\d]*)\s*\)))\s+in\s+(.*?)$/,
DEFAULT_TIMEOUT = 1000,
lazyBindFound = false,
isDefined = angular.isDefined,
isUndefined = angular.isUndefined,
isFunction = angular.isFunction,
isArray = angular.isArray,
isString = angular.isString,
isObject = angular.isObject,
forEach = angular.forEach,
copy = angular.copy,
bind = angular.bind;
/**
* Polyfill for Object.keys
*
* @see: https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Object/keys
*/
if (!Object.keys) {
Object.keys = (function () {
var hasOwnProperty = Object.prototype.hasOwnProperty,
hasDontEnumBug = !({toString: null}).propertyIsEnumerable('toString'),
dontEnums = [
'toString',
'toLocaleString',
'valueOf',
'hasOwnProperty',
'isPrototypeOf',
'propertyIsEnumerable',
'constructor'
],
dontEnumsLength = dontEnums.length;
return function (obj) {
if (typeof obj !== 'object' && typeof obj !== 'function' ||
obj === null) {
throw new TypeError('Object.keys called on non-object');
}
var result = [];
for (var prop in obj) {
if (hasOwnProperty.call(obj, prop)) {
result.push(prop);
}
}
if (hasDontEnumBug) {
for (var i = 0; i < dontEnumsLength; i++) {
if (hasOwnProperty.call(obj,
dontEnums[i])) {
result.push(dontEnums[i]);
}
}
}
return result;
};
})();
}
// stub out lazyBind if we don't have it.
try {
angular.module('lazyBind');
lazyBindFound = true;
}
catch (e) {
angular.module('lazyBind', []).factory('$lazyBind', function() {return angular.noop});
}
/**
* @ngdoc service
* @name decipher.history.service:History
* @description
* Provides an API for keeping a history of model values.
*/
angular.module('decipher.history', ['lazyBind']).service('History',
[
'$parse',
'$rootScope',
'$interpolate',
'$lazyBind',
'$timeout',
'$log',
'$injector',
function ($parse, $rootScope, $interpolate, $lazyBind, $timeout, $log,
$injector) {
var service = this,
history = {},
pointers = {},
watches = {},
watchObjs = {},
lazyWatches = {},
descriptions = {},
// TODO: async safe?
batching = false, // whether or not we are currently in a batch
deepWatchId = 0; // incrementing ID of deep {@link decipher.history.object:Watch Watch instance}s
/**
* @ngdoc object
* @name decipher.history.object:Watch
* @overview
* @constructor
* @description
* An object instance that provides several methods for executing handlers after
* certain changes have been made.
*
* Each function return the `Watch` instance, so you can chain the calls.
*
* See the docs for {@link decipher.history.service:History#deepWatch History.deepWatch()} for an example of using these functions.
*
* @todo ability to remove all handlers at once, or all handlers of a certain type
*/
var Watch = function Watch(exp, scope) {
this.exp = exp;
this.scope = scope || $rootScope;
this.$handlers = {
$change : {},
$undo : {},
$rollback : {},
$redo : {},
$revert : {},
};
this.$ignores = {};
};
/**
* @description
* Helper method for the add*Handler functions.
* @param {string} where Type of handler, corresponds to object defined in constructor
* @param {string} name Name of handler to be supplied by user
* @param {Function} fn Handler function to execute
* @param {Object} resolve Mapping of function parameters to values
* @private
* @returns {Watch} This {@link decipher.history.object:Watch Watch instance}
*/
Watch.prototype._addHandler =
function _addHandler(where, name, fn, resolve) {
if (!where || !name || !fn) {
throw new Error('invalid parameters to _addHandler()');
}
this.$handlers[where][name] = {
fn: fn,
resolve: resolve || {}
};
return this;
};
/**
* @description
* Helper method for remove*Handler functions.
* @param {string} where Type of handler, corresponds to object defined in constructor
* @param {string} name Name of handler to be supplied by user
* @private
* @returns {Watch} This {@link decipher.history.object:Watch Watch instance}
*/
Watch.prototype._removeHandler = function (where, name) {
if (!name) {
throw new Error('invalid parameters to _removeHandler()');
}
delete this.$handlers[where][name];
return this;
};
/**
* @ngdoc function
* @name decipher.history.object:Watch#addChangeHandler
* @methodOf decipher.history.object:Watch
* @method
* @param {string} name Unique name of handler
* @param {Function} fn Function to execute upon change
* @param {object} resolve Mapping of function parameters to values
* @description
* Adds a change handler function with name `name` to be executed
* whenever a value matching this watch's expression changes (is archived).
* @returns {Watch} This {@link decipher.history.object:Watch Watch instance}
*/
Watch.prototype.addChangeHandler =
function addChangeHandler(name, fn, resolve) {
if (!name || !fn) {
throw new Error('invalid parameters');
}
return this._addHandler('$change', name, fn, resolve);
};
/**
* @ngdoc function
* @name decipher.history.object:Watch#addUndoHandler
* @methodOf decipher.history.object:Watch
* @method
* @param {string} name Unique name of handler
* @param {Function} fn Function to execute upon change
* @param {object} resolve Mapping of function parameters to values
* @description
* Adds an undo handler function with name `name` to be executed
* whenever a value matching this watch's expression is undone.
* @returns {Watch} This {@link decipher.history.object:Watch Watch instance}
*/
Watch.prototype.addUndoHandler =
function addUndoHandler(name, fn, resolve) {
if (!name || !fn) {
throw new Error('invalid parameters');
}
return this._addHandler('$undo', name, fn, resolve);
};
/**
* @ngdoc function
* @name decipher.history.object:Watch#addRedoHandler
* @methodOf decipher.history.object:Watch
* @method
* @param {string} name Unique name of handler
* @param {Function} fn Function to execute upon change
* @param {object} resolve Mapping of function parameters to values
* @description
* Adds a redo handler function with name `name` to be executed
* whenever a value matching this watch's expression is redone.
* @returns {Watch} This {@link decipher.history.object:Watch Watch instance}
*/
Watch.prototype.addRedoHandler =
function addRedoHandler(name, fn, resolve) {
if (!name || !fn) {
throw new Error('invalid parameters');
}
return this._addHandler('$redo', name, fn, resolve);
};
/**
* @ngdoc function
* @name decipher.history.object:Watch#addRevertHandler
* @methodOf decipher.history.object:Watch
* @method
* @param {string} name Unique name of handler
* @param {Function} fn Function to execute upon change
* @param {object} resolve Mapping of function parameters to values
* @description
* Adds a revert handler function with name `name` to be executed
* whenever a value matching this watch's expression is reverted.
* @returns {Watch} This {@link decipher.history.object:Watch Watch instance}
*/
Watch.prototype.addRevertHandler =
function addRevertHandler(name, fn, resolve) {
if (!name || !fn) {
throw new Error('invalid parameters');
}
return this._addHandler('$revert', name, fn, resolve);
};
/**
* @ngdoc function
* @name decipher.history.object:Watch#addRollbackHandler
* @methodOf decipher.history.object:Watch
* @method
* @param {string} name Unique name of handler
* @param {Function} fn Function to execute upon change
* @param {object} resolve Mapping of function parameters to values
* @description
* Adds a rollback handler function with name `name` to be executed
* whenever the batch tied to this watch is rolled back.
* @returns {Watch} This {@link decipher.history.object:Watch Watch instance}
*/
Watch.prototype.addRollbackHandler =
function addRollbackHandler(name, fn, resolve) {
if (!name || !fn) {
throw new Error('invalid parameters');
}
return this._addHandler('$rollback', name, fn, resolve);
};
/**
* @ngdoc function
* @name decipher.history.object:Watch#removeRevertHandler
* @methodOf decipher.history.object:Watch
* @method
* @param {string} name Name of handler to remove
* @returns {Watch} This {@link decipher.history.object:Watch Watch instance}
* @description
* Removes a revert handler with name `name`.
*/
Watch.prototype.removeRevertHandler = function removeRevertHandler(name) {
if (!name) {
throw new Error('invalid parameters');
}
return this._removeHandler('$revert', name);
};
/**
* @ngdoc function
* @name decipher.history.object:Watch#removeChangeHandler
* @methodOf decipher.history.object:Watch
* @method
* @param {string} name Name of handler to remove
* @returns {Watch} This {@link decipher.history.object:Watch Watch instance}
* @description
* Removes a change handler with name `name`.
*/
Watch.prototype.removeChangeHandler = function removeChangeHandler(name) {
if (!name) {
throw new Error('invalid parameters');
}
return this._removeHandler('$change', name);
};
/**
* @ngdoc function
* @name decipher.history.object:Watch#removeUndoHandler
* @methodOf decipher.history.object:Watch
* @method
* @param {string} name Name of handler to remove
* @returns {Watch} This {@link decipher.history.object:Watch Watch instance}
* @description
* Removes a undo handler with name `name`.
*/
Watch.prototype.removeUndoHandler = function removeUndoHandler(name) {
if (!name) {
throw new Error('invalid parameters');
}
return this._removeHandler('$undo', name);
};
/**
* @ngdoc function
* @name decipher.history.object:Watch#removeRollbackHandler
* @methodOf decipher.history.object:Watch
* @method
* @param {string} name Name of handler to remove
* @returns {Watch} This {@link decipher.history.object:Watch Watch instance}
* @description
* Removes a rollback handler with name `name`.
*/
Watch.prototype.removeRollbackHandler =
function removeRollbackHandler(name) {
return this._removeHandler('$rollback', name);
};
/**
* @ngdoc function
* @name decipher.history.object:Watch#removeRedoHandler
* @methodOf decipher.history.object:Watch
* @method
* @param {string} name Name of handler to remove
* @returns {Watch} This {@link decipher.history.object:Watch Watch instance}
* @description
* Removes a redo handler with name `name`.
*/
Watch.prototype.removeRedoHandler =
function removeRedoHandler(name) {
if (!name) {
throw new Error('invalid parameters');
}
return this._removeHandler('$redo', name);
};
/**
* Fires all handlers for a particular type, optionally w/ a scope.
* @param {string} where Watch type
* @param {string} exp Expression
* @param {Scope} [scope] Optional Scope
* @private
*/
Watch.prototype._fireHandlers =
function _fireHandlers(where, exp, scope) {
var hasScope = isDefined(scope),
localScope = this.scope, that = this;
forEach(this.$handlers[where], function (handler) {
var locals = {
$locals: localScope
};
if (isDefined(scope)) {
locals.$locals = scope;
}
if (isDefined(exp)) {
locals.$expression = exp;
}
forEach(handler.resolve, function (value, key) {
if (hasScope) {
locals[key] = $parse(value)(scope);
} else {
locals[key] = value;
}
});
$injector.invoke(handler.fn, scope || that, locals);
});
};
/**
* Fires the change handlers
* @param {Scope} scope Scope
* @param {string} exp Expression
* @private
*/
Watch.prototype._fireChangeHandlers =
function _fireChangeHandlers(exp, scope) {
this._fireHandlers('$change', exp, scope);
};
/**
* Fires the undo handlers
* @param {Scope} scope Scope
* @param {string} exp Expression
* @private
*/
Watch.prototype._fireUndoHandlers =
function _fireUndoHandlers(exp, scope) {
this._fireHandlers('$undo', exp, scope);
};
/**
* Fires the redo handlers
* @param {Scope} scope Scope
* @param {string} exp Expression
* @private
*/
Watch.prototype._fireRedoHandlers =
function _fireRedoHandlers(exp, scope) {
this._fireHandlers('$redo', exp, scope);
};
/**
* Fires the revert handlers
* @param {Scope} scope Scope
* @param {string} exp Expression
* @private
*/
Watch.prototype._fireRevertHandlers =
function _fireRevertHandlers(exp, scope) {
this._fireHandlers('$revert', exp, scope);
};
/**
* Fires the rollback handlers (note lack of scope and expression)
* @private
*/
Watch.prototype._fireRollbackHandlers =
function _fireRollbackHandlers() {
this._fireHandlers('$rollback');
};
/**
* Decline to broadcast an event for this Watch.
* @param {string} eventName Name of event to avoid. i.e. "History.archived"
* @param {Function=} callback Optional callback
* @param {Object=} resolve Optional mapping of parameters to invoke
* the callback with.
* @returns {Watch} this Watch object
*/
Watch.prototype.ignoreEvent =
function ignoreEvent(eventName, callback, resolve) {
// special case; we cannot ignore History.archived within a Watch obj
// created from a batch. there may be a way around this.
if (this.exp === null && eventName === 'History.archived') {
$log.warn('cannot ignore History.archived event for batch');
return this;
}
resolve = resolve || {};
if (isFunction(callback)) {
this.$ignores[eventName] = {
callback: callback,
resolve: resolve
};
} else if (isDefined(callback)) {
this.$ignores[eventName] = {
callback: function cb() {
return callback;
},
resolve: resolve
};
}
return this;
};
/**
* Broadcasts an event, taking ignored events into account.
* @param {string} eventName Event to broadcast
* @param {*} data Some data to pass
* @private
*/
Watch.prototype._broadcast = function _broadcast(eventName, data) {
var ignore = this.$ignores[eventName];
if (!ignore ||
(isFunction(ignore.callback) &&
!$injector.invoke(ignore.callback, this.scope, angular.extend(ignore.resolve, {$data: data})))) {
$rootScope.$broadcast(eventName, data);
}
};
/**
* Undoes last change against this watch object's target.
*/
Watch.prototype.undo = function undo() {
if (this.exp === null) {
$log.warn("attempt to undo a batch; use rollback() instead");
return;
}
service.undo(this.exp, this.scope);
};
/**
* Redoes last undo against this watch object's target.
*/
Watch.prototype.redo = function redo() {
if (this.exp === null) {
$log.warn("attempt to redo a batch; just execute the batch callback again");
}
service.redo(this.exp, this.scope);
};
/**
* Reverts this target's watch object.
* @param {number=0} pointer Pointer to revert to
*/
Watch.prototype.revert = function revert(pointer) {
if (this.exp === null) {
$log.warn("attempt to revert a batch; use rollback() instead");
}
service.revert(this.exp, this.scope, pointer);
};
/**
* Whether or not you may undo this watch object's target
* @returns {boolean}
*/
Watch.prototype.canUndo = function canUndo() {
return this.exp === null ? false :
service.canUndo(this.exp, this.scope);
};
/**
* Whether or not you may redo this watch object's target
* @returns {boolean}
*/
Watch.prototype.canRedo = function canRedo() {
return this.exp === null ? false :
service.canRedo(this.exp, this.scope);
};
/**
* Evaluates an expression on the scope lazily. That means it will return
* a new value every DEFAULT_TIMEOUT ms at maximum, even if you change it between
* now and then. This allows us to $broadcast at an interval instead of after
* every scope change.
* @param {Object} scope AngularJS Scope
* @param {string} exp AngularJS expression to evaluate
* @param {number} [timeout=DEFAULT_TIMEOUT] How often to change the value
* @returns {Function}
*/
var lazyWatch = function lazyWatch(scope, exp, timeout) {
var bind = $lazyBind(scope);
bind.cacheTime(timeout || DEFAULT_TIMEOUT);
/**
* This is the "expression function" we use to $watch with. You normally
* $watch a string, but you can also watch a function, and this is one of
* those functions. This is where the actual lazy evaluation happens.
*/
return function () {
return bind.call(scope, exp);
};
};
/**
* Initializes object stores for a Scope id
* @param {string} id Sccope id
* @private
*/
this._initStores = function _initStores(id) {
if (isUndefined(watches[id])) {
watches[id] = {};
}
if (isUndefined(lazyWatches[id])) {
lazyWatches[id] = {};
}
if (isUndefined(descriptions[id])) {
descriptions[id] = {};
}
if (isUndefined(history[id])) {
history[id] = {};
}
if (isUndefined(watchObjs[id])) {
watchObjs[id] = {};
}
if (isUndefined(pointers[id])) {
pointers[id] = {};
}
};
/**
* When an expression changes, store the information about it
* and increment a pointer.
* @param {string|Function} exp Expression
* @param {string} id Scope $id
* @param {Scope} locals AngularJS scope
* @param {boolean} pass Whether or not to pass on the first call
* @param {string} description AngularJS string to interpolate
* @return {Function} Watch function
* @private
*/
this._archive = function (exp, id, locals, pass, description) {
var _initStores = this._initStores;
return function (newVal, oldVal) {
var watchObj;
_initStores(id);
if (description) {
descriptions[id][exp] = $interpolate(description)(locals);
}
if (pass) {
pass = false;
return;
}
if (isUndefined(history[id][exp])) {
history[id][exp] = [];
}
if (isUndefined(pointers[id][exp])) {
pointers[id][exp] = 0;
}
history[id][exp].splice(pointers[id][exp] + 1);
history[id][exp].push(copy(newVal));
pointers[id][exp] = history[id][exp].length - 1;
if (pointers[id][exp] > 0 && isDefined(watchObjs[id]) &&
isDefined(watchObj = watchObjs[id][exp])) {
if (!batching) {
watchObj._fireChangeHandlers(exp, locals);
}
watchObj._broadcast('History.archived', {
expression: exp,
newValue: newVal,
oldValue: oldVal,
description: descriptions[id][exp],
locals: locals
});
}
};
};
/**
* @ngdoc function
* @name decipher.history.service:History#watch
* @method
* @methodOf decipher.history.service:History
* @description
* Register some expression(s) for watching.
* @param {string|string[]} exps Array of expressions or one expression as a string
* @param {Scope=} scope Scope; defaults to `$rootScope`
* @param {string=} description Description of this change
* @param {Object=} lazyOptions Options for lazy loading. Only valid
* property is `timeout` at this point
* @returns {Watch|Array} {@link decipher.history.object:Watch Watch instance} or array of them
*
* @example
* <example module="decipher.history">
<file name="script.js">
angular.module('decipher.history')
.run(function(History, $rootScope) {
$rootScope.foo = 'foo';
$rootScope.$on('History.archived', function(evt, data) {
$rootScope.message = data.description;
});
History.watch('foo', $rootScope, 'you changed the foo');
});
</file>
<file name="index.html">
<input type="text" ng-model="foo"/> {{foo}}<br/>
<span ng-show="message">{{message}}</span><br/>
</file>
</example>
*/
this.watch = function watch(exps, scope, description, lazyOptions) {
if (isUndefined(exps)) {
throw new Error('expression required');
}
scope = scope || $rootScope;
description = description || '';
var i,
id = scope.$id,
exp,
objs = [],
watchObj,
model;
if (!isArray(exps)) {
exps = [exps];
}
this._initStores(id);
i = exps.length;
while (i--) {
exp = exps[i];
// assert we have an assignable model
model = $parse(exp);
if (isUndefined(model.assign)) {
throw 'expression "' + exp +
'" is not an assignable expression';
}
// blast any old watches
if (isFunction(watches[id][exp])) {
watches[id][exp]();
}
descriptions[id][exp] = $interpolate(description)(scope);
this._watch(exp, scope, false, lazyOptions);
watchObjs[id][exp] = watchObj = new Watch(exp, scope);
objs.push(watchObj);
}
return objs.length > 1 ? objs : objs[0];
};
/**
* @ngdoc function
* @name decipher.history.service:History#deepWatch
* @method
* @methodOf decipher.history.service:History
* @description
* Allows you to watch an entire array/object full of objects, but only watch
* a certain property of each object.
*
* @example
* <example module="decipher.history">
<file name="script.js">
angular.module('decipher.history')
.run(function(History, $rootScope) {
var exp, locals;
$rootScope.foos = [
{id: 1, name: 'herp'},
{id: 2, name: 'derp'}
];
$rootScope.$on('History.archived', function(evt, data) {
$rootScope.message = data.description;
exp = data.expression;
locals = data.locals;
})
History.deepWatch('foo.name for foo in foos', $rootScope,
'Changed {{foo.id}} to name "{{foo.name}}"')
.addChangeHandler('myChangeHandler', function($expression,
$locals, foo) {
console.log(foo);
console.log("(totally hit the server and update the model)");
$rootScope.undo = function() {
History.undo($expression, $locals);
};
$rootScope.canUndo = function() {
return History.canUndo($expression, $locals);
};
}, {foo: 'foo'});
});
</file>
<file name="index.html">
<input type="text" ng-model="foos[0].name"/> {{foos[0].name}}<br/>
<span ng-show="message">{{message}}</span><br/>
<button ng-disabled="!canUndo()" ng-click="undo()">Undo!</button>
</file>
</example>
* @param {(string|string[])} exp Expression or array of expressions to watch
* @param {Scope=} scope Scope; defaults to `$rootScope`
* @param {string=} description Description of this change
* @param {Object=} lazyOptions Options for lazy loading. Only valid
* property is `timeout` at this point
* @return {Watch} {@link decipher.history.object:Watch Watch instance}
*/
this.deepWatch =
function deepWatch(exp, scope, description, lazyOptions) {
var match,
targetName,
valueFn,
keyName,
value,
valueName,
valuesName,
watchObj,
id = scope.$id,
_clear = bind(this, this._clear),
_initStores = this._initStores,
_archive = bind(this, this._archive),
createDeepWatch = function createDeepWatch(targetName, valueName,
keyName, watchObj) {
return function (values) {
forEach(values, function (v, k) {
var locals = scope.$new(),
id = locals.$id;
locals.$$deepWatchId = scope.$$deepWatch[targetName];
locals.$$deepWatchTargetName = targetName;
locals[valueName] = v;
if (keyName) {
locals[keyName] = k;
}
value = valueFn(scope, locals);
_initStores(id);
descriptions[id][exp] = $interpolate(description)(locals);
if (isFunction(watches[id][targetName])) {
watches[id][targetName]();
}
if (lazyBindFound && isObject(lazyOptions)) {
watches[id][targetName] =
locals.$watch(lazyWatch(locals, targetName,
lazyOptions.timeout || 500),
_archive(targetName, id, locals, false, description),
true);
lazyWatches[id][targetName] = true;
}
else {
watches[id][targetName] = locals.$watch(targetName,
_archive(targetName, id, locals, false, description),
true);
lazyWatches[id][targetName] = false;
}
watchObjs[id][targetName] = watchObj;
locals.$on('$destroy', function () {
_clear(scope);
});
});
};
};
description = description || '';
if (!(match = exp.match(DEEPWATCH_EXP))) {
throw 'expected expression in form of "_select_ for (_key_,)? _value_ in _collection_" but got "' +
exp + '"';
}
targetName = match[1];
valueName = match[4] || match[2];
valueFn = $parse(valueName);
keyName = match[3];
valuesName = match[5];
if (isUndefined(scope.$$deepWatch)) {
scope.$$deepWatch = {};
}
// if we already have a deepWatch on this value, we
// need to kill all the child scopes. because reasons
if (isDefined(scope.$$deepWatch[targetName])) {
_clear(scope, targetName);
}
scope.$$deepWatch[targetName] = ++deepWatchId;
_initStores(id);
watchObjs[id][targetName] = watchObj = new Watch(targetName, scope);
// TODO: assert this doesn't leak memory like crazy. it might if
// we remove things from the values context.
watches[id][targetName] = scope.$watchCollection(valuesName,
createDeepWatch(targetName, valueName, keyName,
watchObj));
return watchObj;
};
/**
* Clears a bunch of information for a scope and optionally an array of expressions.
* Lacking an expression, this will eliminate an entire scopesworth of data.
* It will recognize deep watches and clear them out completely.
* @param {Scope} scope Scope obj
* @param {(string|string[])} exps Expression or array of expressions
* @private
*/
this._clear = function _clear(scope, exps) {
var id = scope.$id,
i,
nextSibling,
exp,
clear = function clear(id, key) {
var zap = function zap(what) {
if (isDefined(what[id][key])) {
delete what[id][key];
if (Object.keys(what[id]).length === 0) {
delete what[id];
}
}
};
if (isDefined(watches[id]) &&
isFunction(watches[id][key])) {
watches[id][key]();
}
if (isDefined(watches[id])) {
zap(watches);
}
if (isDefined(watchObjs[id])) {
zap(watchObjs);
}
if (isDefined(history[id])) {
zap(history);
}
if (isDefined(pointers[id])) {
zap(pointers);
}
if (isDefined(lazyWatches[id])) {
zap(lazyWatches);
}
},
clearAll = function clearAll(id) {
forEach(watches[id], function (watch) {
return isFunction(watch) && watch();
});
delete watches[id];
delete history[id];
delete pointers[id];
delete lazyWatches[id];
delete watchObjs[id];
};
if (isString(exps)) {
exps = [exps];
}
else if (isUndefined(exps) && isDefined(watches[id])) {
exps = Object.keys(watches[id]);
}
if (isDefined(exps)) {
i = exps.length;
while (i--) {
exp = exps[i];
clear(id, exp);
}
} else {
clearAll(id);
}
nextSibling = scope.$$childHead;
while (nextSibling) {
this._clear(nextSibling, exp);
nextSibling = nextSibling.$$nextSibling;
}
};
/**
* @ngdoc function
* @name decipher.history.service:History#forget
* @method
* @methodOf decipher.history.service:History
* @description
* Unregister some watched expression(s).
* @param {(string|string[])} exps Array of expressions or one expression as a string
* @param {Scope=} scope Scope object; defaults to $rootScope
*/
this.forget = function forget(scope, exps) {
scope = scope || $rootScope;
if (isDefined(exps) && isString(exps)) {
exps = [exps];
}
this._clear(scope, exps);
};
/**
* Internal function to change some value in the stack to another.
* Kills the watch and then calls `_watch()` to restore it.
* @param {Scope} scope Scope object
* @param {string} exp AngularJS expression
* @param {array} stack History stack; see `History.history`
* @param {number} pointer Pointer
* @returns {{oldValue: {*}, newValue: {*}}} The old value and the new value
* @private
*/
this._do = function _do(scope, exp, stack, pointer) {
var model,
oldValue,
id = scope.$id;
if (isFunction(watches[id][exp])) {
watches[id][exp]();
delete watches[id][exp];
}
model = $parse(exp);
oldValue = model(scope);
// todo: assert there's no bug here with unassignable expressions
model.assign(scope, copy(stack[pointer]));
this._watch(exp, scope, true);
return {
oldValue: oldValue,
newValue: model(scope)
};
};
/**
* @ngdoc function
* @name decipher.history.service:History#undo
* @method
* @methodOf decipher.history.service:History
* @description
* Undos an expression to last known value.
* @param {string} exp Expression to undo
* @param {Scope=} scope Scope; defaults to `$rootScope`
*/
this.undo = function undo(exp, scope) {
scope = scope || $rootScope;
if (isUndefined(exp)) {
throw new Error('expression required');
}
var id = scope.$id,
scopeHistory = history[id],
stack,
values,
pointer,
watchObj;
if (isUndefined(scopeHistory)) {
throw 'could not find history for scope ' + id;
}
stack = scopeHistory[exp];
if (isUndefined(stack)) {
throw 'could not find history in scope "' + id +
' against expression "' + exp + '"';
}
pointer = --pointers[id][exp];
if (pointer < 0) {
$log.warn('attempt to undo past history');
pointers[id][exp]++;
return;
}
values = this._do(scope, exp, stack, pointer);
if (isDefined(watchObjs[id]) &&
isDefined(watchObjs[id][exp])) {
watchObj = watchObjs[id][exp];
watchObj._fireUndoHandlers(exp, scope);
watchObj._broadcast('History.undone', {
expression: exp,
newValue: values.newValue,
oldValue: values.oldValue,
description: descriptions[id][exp],
scope: scope
});
}
};
/**
* Actually issues the appropriate scope.$watch
* @param {string} exp Expression
* @param {Scope=} scope Scope; defaults to $rootScope
* @param {boolean=} pass Whether or not to skip the first watch execution. Defaults to false
* @param {Object} lazyOptions Options to send the lazy module
* @private
*/
this._watch = function _watch(exp, scope, pass, lazyOptions) {
var id;
scope = scope || $rootScope;
pass = pass || false;
id = scope.$id;
// do we have an array or object?
if (lazyBindFound && (isObject(lazyOptions) ||
(lazyWatches[id] && !!lazyWatches[id][exp]))) {
watches[id][exp] =
scope.$watch(lazyWatch(scope, exp, lazyOptions.timeout),
bind(this, this._archive(exp, id, scope, pass)), true);
lazyWatches[id][exp] = true;
}
else {
watches[id][exp] =
scope.$watch(exp, bind(this, this._archive(exp, id, scope, pass)),
true);
lazyWatches[id][exp] = false;
}
};
/**
* @ngdoc function
* @name decipher.history.service:History#redo
* @method
* @methodOf decipher.history.service:History
* @description
* Redoes (?) the last undo.
* @param {string} exp Expression to redo
* @param {Scope=} scope Scope; defaults to `$rootScope`
*/
this.redo = function redo(exp, scope) {
scope = scope || $rootScope;
var id = scope.$id,
stack = history[id][exp],
values,
pointer,
watchObj;
if (isUndefined(stack)) {
throw 'could not find history in scope "' + id +
' against expression "' + exp + '"';
}
pointer = ++pointers[id][exp];
if (pointer === stack.length) {
$log.warn('attempt to redo past history');
pointers[id][exp]--;
return;
}
values = this._do(scope, exp, stack, pointer);
if (isDefined(watchObjs[id]) &&
isDefined(watchObjs[id][exp])) {
watchObj = watchObjs[id][exp];
watchObj._fireRedoHandlers(exp, scope);
watchObj._broadcast('History.redone', {
expression: exp,
oldValue: copy(values.newValue),
newValue: copy(values.oldValue),
description: descriptions[id][exp],
scope: scope
});
}
};
/**
* @ngdoc function
* @name decipher.history.service:History#canUndo
* @method
* @methodOf decipher.history.service:History
* @description
* Whether or not we have accumulated any history for a particular expression.
* @param {string} exp Expression
* @param {Scope=} scope Scope; defaults to $rootScope
* @return {boolean} Whether or not you can issue an `undo()`
* @example
* <example module="decipher.history">
<file name="script.js">
angular.module('decipher.history').run(function(History, $rootScope) {
$rootScope.foo = 'bar';
History.watch('foo');
$rootScope.canUndo = History.canUndo;
});
</file>
<file name="index.html">
<input type="text" ng-model="foo"/> Can undo? {{canUndo('foo')}}
</file>
</example>
*/
this.canUndo = function canUndo(exp, scope) {
var id;
scope = scope || $rootScope;
id = scope.$id;
return isDefined(pointers[id]) &&
isDefined(pointers[id][exp]) &&
pointers[id][exp] > 0;
};
/**
* @ngdoc function
* @name decipher.history.service:History#canRedo
* @method
* @methodOf decipher.history.service:History
* @description
* Whether or not we can redo an expression's value.
* @param {string} exp Expression
* @param {Scope=} scope Scope; defaults to $rootScope
* @return {Boolean} Whether or not you can issue a `redo()`
* @example
* <example module="decipher.history">
<file name="script.js">
angular.module('decipher.history').run(function(History, $rootScope) {
$rootScope.foo = 'bar';
History.watch('foo');
$rootScope.canRedo = History.canRedo;
$rootScope.canUndo = History.canUndo;
$rootScope.undo = History.undo;
});
</file>
<file name="index.html">
<input type="text" ng-model="foo"/> <br/>
<button ng-show="canUndo('foo')" ng-click="undo('foo')">Undo</button><br/>
Can redo? {{canRedo('foo')}}
</file>
</example>
*/
this.canRedo = function canRedo(exp, scope) {
var id;
scope = scope || $rootScope;
id = scope.$id;
return isDefined(pointers[id]) &&
isDefined(pointers[id][exp]) &&
pointers[id][exp] < history[id][exp].length - 1;
};
/**
* @ngdoc function
* @method
* @methodOf decipher.history.service:History
* @name decipher.history.service:History#revert
* @description
* Reverts to earliest known value of some expression, or at a particular
* pointer if you please.
* @param {string} exp Expression
* @param {Scope=} scope Scope; defaults to $rootScope
* @param {number=} pointer Optional; defaults to 0
*/
this.revert = function (exp, scope, pointer) {
scope = scope || $rootScope;
pointer = pointer || 0;
var id = scope.$id,
stack = history[id][exp],
values,
watchObj;
if (isUndefined(stack)) {
$log.warn('nothing to revert');
return;
}
values = this._do(scope, exp, stack, pointer);
// wait; what is this?
history[id][exp].splice();
pointers[id][exp] = pointer;
if (isDefined(watchObjs[id]) &&
isDefined(watchObjs[id][exp])) {
watchObj = watchObjs[id][exp];
watchObj._fireRevertHandlers(exp, scope);
watchObj._broadcast('History.reverted', {
expression: exp,
oldValue: copy(values.newValue),
newValue: copy(values.oldValue),
description: descriptions[id][exp],
scope: scope,
pointer: pointer
});
}
};
/**
* @ngdoc function
* @name decipher.history.service:History#batch
* @method
* @methodOf decipher.history.service:History
* @description
* Executes a function within a batch context which can then be rolled back.
* @param {function} fn Function to execute
* @param {Scope=} scope Scope object; defaults to `$rootScope`
* @param {string=} description Description of this change
* @returns {Watch} {@link decipher.history.object:Watch Watch instance}
* @example
<example module="decipher.history">
<file name="script.js">
angular.module('decipher.history').run(function(History, $rootScope) {
var t;
$rootScope.herp = 'derp';
$rootScope.bar = 'baz';
$rootScope.frick = 'frack';
$rootScope.$on('History.batchEnded', function(evt, data) {
t = data.transaction;
});
History.watch('herp');
History.watch('bar');
History.watch('frick');
$rootScope.batch = function() {
History.batch(function() {
$rootScope.herp = 'derp2';
$rootScope.bar = 'baz2';
$rootScope.frick = 'frack2';
})
.addRollbackHandler('myRollbackHandler', function() {
$rootScope.message = 'rolled a bunch of stuff back';
});
$rootScope.message = "batch complete";
};
$rootScope.rollback = function() {
if (isDefined(t)) {
History.rollback(t);
}
};
});
</file>
<file name="index.html">
<ul>
<li>herp: {{herp}}</li>
<li>bar: {{bar}}</li>
<li>frick: {{frick}}</li>
</ul>
<button ng-click="batch()">Batch</button>
<button ng-click="rollback()">Rollback</button><br/>
{{message}}
</file>
</example>
*/
this.batch = function batch(fn, scope, description) {
var _clear = bind(this, this._clear),
_initStores = this._initStores,
listener,
watchObj,
child;
scope = scope || $rootScope;
if (!isFunction(fn)) {
throw new Error('transaction requires a function');
}
child = scope.$new();
child.$on('$destroy', function () {
_clear(child);
});
listener = scope.$on('History.archived', function (evt, data) {
var deepChild,
exp = data.expression,
id;
if (data.locals.$id !== child.$id) {
deepChild = child.$new();
deepChild.$on('$destroy', function () {
_clear(deepChild);
});
deepChild.$$locals = data.locals;
id = deepChild.$id;
_initStores(id);
history[id][exp] =
copy(history[data.locals.$id][exp]);
pointers[id][exp] = pointers[data.locals.$id][exp] - 1;
}
});
watchObjs[child.$id] = watchObj = new Watch(null, child);
watchObj._broadcast('History.batchBegan', {
transaction: child,
description: description
});
// we need to put this into a timeout and apply manually
// since it's not clear when the watchers will get fired,
// and we must ensure that any existing watchers on the archived
// event can be skipped before the batchEnd occurs.
batching = true;
$timeout(function () {
fn(child);
scope.$apply();
})
.then(function () {
listener();
batching = false;
watchObj._broadcast('History.batchEnded', {
transaction: child,
description: description
});
});
return watchObj;
};
/**
* @ngdoc function
* @name decipher.history.service:History#rollback
* @method
* @methodOf decipher.history.service:History
* @description
* Rolls a transaction back that was executed via {@link decipher.history.service:History#batch batch()}.
*
* For an example, see {@link decipher.history.service:History#batch batch()}.
* @param {Scope} t Scope object in which the transaction was executed.
*/
this.rollback = function rollback(t) {
var _do = bind(this, this._do),
parent = t.$parent,
packets = {},
nextSibling,
watchObj,
nextSiblingLocals;
if (!t || !isObject(t)) {
throw new Error('must pass a scope to rollback');
}
function _rollback(scope, comparisonScope) {
var id = scope.$id,
comparisonScopeId = comparisonScope.$id,
stack = history[id],
pointer,
descs,
exp,
values,
exps,
rolledback,
i;
if (stack) {
exps = Object.keys(stack);
i = exps.length;
} else {
// might not actually have history, it's ok
return;
}
while (i--) {
exp = exps[i];
values = [];
descs = [];
pointer = pointers[comparisonScopeId][exp];
rolledback = false;
while (pointer > pointers[id][exp]) {
pointer--;
values.push(_do(comparisonScope,
exp, history[comparisonScopeId][exp], pointer));
pointers[comparisonScopeId][exp] = pointer;
descs.push(descriptions[comparisonScopeId][exp]);
// throw this off the history stack so
// we don't end up with it in the stack while we
// do normal undo() calls later against the same
// expression and scope
history[comparisonScopeId][exp].pop();
rolledback = true;
}
if (rolledback) {
packets[exp] = {
values: values,
scope: scope,
comparisonScope: comparisonScope,
descriptions: descs
};
}
}
}
watchObj = watchObjs[t.$id];
if (isDefined(parent) &&
isDefined(history[parent.$id])) {
_rollback(t, parent);
}
nextSibling = t.$$childHead;
while (nextSibling) {
nextSiblingLocals = nextSibling.$$locals;
if (nextSiblingLocals) {
_rollback(nextSibling, nextSiblingLocals);
}
nextSibling = nextSibling.$$nextSibling;
}
watchObj._fireRollbackHandlers();
watchObj._broadcast('History.rolledback', packets);
};
/**
* @ngdoc property
* @name decipher.history.service:History#history
* @propertyOf decipher.history.service:History
* @description
* The complete history stack, keyed by Scope `$id` and then expression.
* @type {{}}
*/
this.history = history;
/**
* @ngdoc property
* @name decipher.history.service:History#descriptions
* @propertyOf decipher.history.service:History
* @description
* The complete map of change descriptions, keyed by Scope `$id` and then expression.
* @type {{}}
*/
this.descriptions = descriptions;
/**
* @ngdoc property
* @name decipher.history.service:History#pointers
* @propertyOf decipher.history.service:History
* @description
* The complete pointer map, keyed by Scope `$id` and then expression.
* @type {{}}
*/
this.pointers = pointers;
/**
* @ngdoc property
* @name decipher.history.service:History#watches
* @propertyOf decipher.history.service:History
* @description
* The complete index of all AngularJS `$watch`es, keyed by Scope `$id` and then expression.
* @type {{}}
*/
this.watches = watches;
/**
* @ngdoc property
* @name decipher.history.service:History#lazyWatches
* @propertyOf decipher.history.service:History
* @description
* The complete index of all AngularJS `$watch`es designated to be "lazy", keyed by Scope `$id` and then expression.
* @type {{}}
*/
this.lazyWatches = lazyWatches;
/**
* @ngdoc property
* @name decipher.history.service:History#watchObjs
* @propertyOf decipher.history.service:History
* @description
* The complete index of all {@link decipher.history.object:Watch Watch} objects registered, keyed by Scope `$id` and then (optionally) expression.
* @type {{}}
*/
this.watchObjs = watchObjs;
/**
* @ngdoc property
* @name decipher.history.service:History#Watch
* @propertyOf decipher.history.service:History
* @description
* Here's the Watch prototype for you to play with.
* @type {Watch}
*/
this.Watch = Watch;
}]);
})();