Commit 1d831397 authored by Addy Osmani's avatar Addy Osmani

Merge pull request #218 from AlbertoMonteiro/master

Knockout - routing with crossroad js
parents a4b14fa1 43b78f41
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible">
<title>Knockout.js • TodoMVC</title>
<link rel="stylesheet" href="../../assets/base.css">
<link href="../../assets/base.css" rel="stylesheet">
<!--[if IE]>
<script src="../../assets/ie.js"></script>
<![endif]-->
</head>
<body>
</head>
<body>
<section id="todoapp">
<header id="header">
<h1>todos</h1>
<input id="new-todo" type="text" data-bind="value: current, valueUpdate: 'afterkeydown', enterKey: add"
placeholder="What needs to be done?" autofocus>
<input id="new-todo" autofocus data-bind="value: current, valueUpdate: 'afterkeydown', enterKey: add" placeholder="What needs to be done?" type="text">
</header>
<section id="main" data-bind="visible: todos().length">
<input id="toggle-all" type="checkbox" data-bind="checked: allCompleted">
<input id="toggle-all" data-bind="checked: allCompleted" type="checkbox">
<label for="toggle-all">Mark all as complete</label>
<ul id="todo-list" data-bind="foreach: todos">
<ul id="todo-list" data-bind="foreach: filteredTodos">
<li data-bind="css: { completed: completed, editing: editing }">
<div class="view" data-bind="event: { dblclick: $root.editItem }">
<input class="toggle" type="checkbox" data-bind="checked: completed">
<input class="toggle" data-bind="checked: completed" type="checkbox">
<label data-bind="text: title"></label>
<button class="destroy" data-bind="click: $root.remove"></button>
</div>
<input class="edit" data-bind="value: title, valueUpdate: 'afterkeydown', enterKey: $root.stopEditing, selectAndFocus: editing, event: { blur: $root.stopEditing }" >
<input class="edit" data-bind="value: title, valueUpdate: 'afterkeydown', enterKey: $root.stopEditing, selectAndFocus: editing, event: { blur: $root.stopEditing }">
</li>
</ul>
</section>
......@@ -35,6 +34,17 @@
<strong data-bind="text: remainingCount">1</strong>
<span data-bind="text: getLabel( remainingCount )"></span> left
</span>
<ul id="filters">
<li>
<a data-bind="css: { selected: showMode() == 'all' }" href="#/all">All</a>
</li>
<li>
<a data-bind="css: { selected: showMode() == 'active' }" href="#/active">Active</a>
</li>
<li>
<a data-bind="css: { selected: showMode() == 'completed' }" href="#/completed">Completed</a>
</li>
</ul>
<button id="clear-completed" data-bind="visible: completedCount, click: removeCompleted">Clear completed (<span data-bind="text: completedCount"></span>)</button>
</footer>
</section>
......@@ -46,6 +56,13 @@
</footer>
<script src="../../assets/base.js"></script>
<script src="js/lib/knockout-2.0.0.js"></script>
<!--Crossroad.js Begin -->
<script src="js/lib/signals.js"></script>
<script src="js/lib/crossroads.js"></script>
<script src="js/lib/route.js"></script>
<script src="js/lib/pattern_lexer.js"></script>
<script src="js/lib/intro.js"></script>
<!--Crossroad.js End -->
<script src="js/app.js"></script>
</body>
</body>
</html>
\ No newline at end of file
......@@ -3,21 +3,21 @@
var ENTER_KEY = 13;
// trim polyfill
if ( !String.prototype.trim ) {
if(!String.prototype.trim) {
String.prototype.trim = function() {
return this.replace( /^\s+|\s+$/g, '' );
return this.replace(/^\s+|\s+$/g, '');
};
}
// a custom binding to handle the enter key (could go in a separate library)
ko.bindingHandlers.enterKey = {
init: function( element, valueAccessor, allBindingsAccessor, data ) {
init: function(element, valueAccessor, allBindingsAccessor, data) {
var wrappedHandler, newValueAccessor;
// wrap the handler with a check for the enter key
wrappedHandler = function( data, event ) {
if ( event.keyCode === ENTER_KEY ) {
valueAccessor().call( this, data, event );
wrappedHandler = function(data, event) {
if(event.keyCode === ENTER_KEY) {
valueAccessor().call(this, data, event);
}
};
......@@ -29,84 +29,97 @@
};
// call the real event binding's init function
ko.bindingHandlers.event.init( element, newValueAccessor, allBindingsAccessor, data );
ko.bindingHandlers.event.init(element, newValueAccessor, allBindingsAccessor, data);
}
};
// wrapper to hasfocus that also selects text and applies focus async
ko.bindingHandlers.selectAndFocus = {
init: function( element, valueAccessor, allBindingsAccessor ) {
ko.bindingHandlers.hasfocus.init( element, valueAccessor, allBindingsAccessor );
ko.utils.registerEventHandler( element, 'focus', function() {
init: function(element, valueAccessor, allBindingsAccessor) {
ko.bindingHandlers.hasfocus.init(element, valueAccessor, allBindingsAccessor);
ko.utils.registerEventHandler(element, 'focus', function() {
element.select();
} );
});
},
update: function( element, valueAccessor ) {
ko.utils.unwrapObservable( valueAccessor() ); // for dependency
update: function(element, valueAccessor) {
ko.utils.unwrapObservable(valueAccessor()); // for dependency
// ensure that element is visible before trying to focus
setTimeout(function() {
ko.bindingHandlers.hasfocus.update( element, valueAccessor );
}, 0 );
ko.bindingHandlers.hasfocus.update(element, valueAccessor);
}, 0);
}
};
// represent a single todo item
var Todo = function( title, completed ) {
this.title = ko.observable( title );
this.completed = ko.observable( completed );
this.editing = ko.observable( false );
var Todo = function(title, completed) {
this.title = ko.observable(title);
this.completed = ko.observable(completed);
this.editing = ko.observable(false);
};
// our main view model
var ViewModel = function( todos ) {
var ViewModel = function(todos) {
var self = this;
// map array of passed in todos to an observableArray of Todo objects
self.todos = ko.observableArray( ko.utils.arrayMap( todos, function( todo ) {
return new Todo( todo.title, todo.completed );
self.todos = ko.observableArray(ko.utils.arrayMap(todos, function(todo) {
return new Todo(todo.title, todo.completed);
}));
// store the new todo value being entered
self.current = ko.observable();
self.showMode = ko.observable('all');
self.filteredTodos = ko.computed(function() {
switch(self.showMode()) {
case 'active':
return self.todos().filter(function(todo) { return !todo.completed(); });
case 'completed':
return self.todos().filter(function(todo) { return todo.completed(); });
default:
return self.todos();
}
});
// add a new todo, when enter key is pressed
self.add = function() {
var current = self.current().trim();
if ( current ) {
self.todos.push( new Todo( current ) );
self.current( '' );
if(current) {
self.todos.push(new Todo(current));
self.current('');
}
};
// remove a single todo
self.remove = function( todo ) {
self.todos.remove( todo );
self.remove = function(todo) {
self.todos.remove(todo);
};
// remove all completed todos
self.removeCompleted = function() {
self.todos.remove(function( todo ) {
self.todos.remove(function(todo) {
return todo.completed();
});
};
// edit an item
self.editItem = function( item ) {
item.editing( true );
self.editItem = function(item) {
item.editing(true);
};
// stop editing an item. Remove the item, if it is now empty
self.stopEditing = function( item ) {
item.editing( false );
if ( !item.title().trim() ) {
self.remove( item );
self.stopEditing = function(item) {
item.editing(false);
if(!item.title().trim()) {
self.remove(item);
}
};
// count of all completed todos
self.completedCount = ko.computed(function() {
return ko.utils.arrayFilter( self.todos(), function(todo) {
return ko.utils.arrayFilter(self.todos(), function(todo) {
return todo.completed();
} ).length;
}).length;
});
// count of todos that are not complete
......@@ -121,23 +134,23 @@
return !self.remainingCount();
},
// set all todos to the written value (true/false)
write: function( newValue ) {
ko.utils.arrayForEach(self.todos(), function( todo ) {
write: function(newValue) {
ko.utils.arrayForEach(self.todos(), function(todo) {
// set even if value is the same, as subscribers are not notified in that case
todo.completed( newValue );
todo.completed(newValue);
});
}
});
// helper function to keep expressions out of markup
self.getLabel = function( count ) {
return ko.utils.unwrapObservable( count ) === 1 ? 'item' : 'items';
self.getLabel = function(count) {
return ko.utils.unwrapObservable(count) === 1 ? 'item' : 'items';
};
// internal computed observable that fires whenever anything changes in our todos
ko.computed(function() {
// store a clean copy to local storage, which also creates a dependency on the observableArray and all observables in each item
localStorage.setItem( 'todos-knockout', ko.toJSON( self.todos ) );
localStorage.setItem('todos-knockout', ko.toJSON(self.todos));
}).extend({
throttle: 500
......@@ -145,8 +158,19 @@
};
// check local storage for todos
var todos = ko.utils.parseJson( localStorage.getItem( 'todos-knockout' ) );
var todos = ko.utils.parseJson(localStorage.getItem('todos-knockout'));
// bind a new instance of our view model to the page
ko.applyBindings( new ViewModel( todos || [] ) );
var viewModel = new ViewModel(todos || [])
ko.applyBindings(viewModel);
//setup crossroads
crossroads.addRoute('all', function() { viewModel.showMode('all'); });
crossroads.addRoute('active', function() { viewModel.showMode('active'); });
crossroads.addRoute('completed', function() { viewModel.showMode('completed'); });
window.onhashchange = function() {
crossroads.parse(location.hash.replace("#", ""));
};
crossroads.parse(location.hash.replace("#", ""));
})();
\ No newline at end of file
// Crossroads --------
//====================
/**
* @constructor
*/
function Crossroads() {
this._routes = [];
this._prevRoutes = [];
this.bypassed = new signals.Signal();
this.routed = new signals.Signal();
}
Crossroads.prototype = {
greedy : false,
greedyEnabled : true,
normalizeFn : null,
create : function () {
return new Crossroads();
},
shouldTypecast : false,
addRoute : function (pattern, callback, priority) {
var route = new Route(pattern, callback, priority, this);
this._sortedInsert(route);
return route;
},
removeRoute : function (route) {
var i = arrayIndexOf(this._routes, route);
if (i !== -1) {
this._routes.splice(i, 1);
}
route._destroy();
},
removeAllRoutes : function () {
var n = this.getNumRoutes();
while (n--) {
this._routes[n]._destroy();
}
this._routes.length = 0;
},
parse : function (request, defaultArgs) {
request = request || '';
defaultArgs = defaultArgs || [];
var routes = this._getMatchedRoutes(request),
i = 0,
n = routes.length,
cur;
if (n) {
this._notifyPrevRoutes(routes, request);
this._prevRoutes = routes;
//shold be incremental loop, execute routes in order
while (i < n) {
cur = routes[i];
cur.route.matched.dispatch.apply(cur.route.matched, defaultArgs.concat(cur.params));
cur.isFirst = !i;
this.routed.dispatch.apply(this.routed, defaultArgs.concat([request, cur]));
i += 1;
}
} else {
this.bypassed.dispatch.apply(this.bypassed, defaultArgs.concat([request]));
}
},
_notifyPrevRoutes : function(matchedRoutes, request) {
var i = 0, prev;
while (prev = this._prevRoutes[i++]) {
//check if switched exist since route may be disposed
if(prev.route.switched && this._didSwitch(prev.route, matchedRoutes)) {
prev.route.switched.dispatch(request);
}
}
},
_didSwitch : function (route, matchedRoutes){
var matched,
i = 0;
while (matched = matchedRoutes[i++]) {
// only dispatch switched if it is going to a different route
if (matched.route === route) {
return false;
}
}
return true;
},
getNumRoutes : function () {
return this._routes.length;
},
_sortedInsert : function (route) {
//simplified insertion sort
var routes = this._routes,
n = routes.length;
do { --n; } while (routes[n] && route._priority <= routes[n]._priority);
routes.splice(n+1, 0, route);
},
_getMatchedRoutes : function (request) {
var res = [],
routes = this._routes,
n = routes.length,
route;
//should be decrement loop since higher priorities are added at the end of array
while (route = routes[--n]) {
if ((!res.length || this.greedy || route.greedy) && route.match(request)) {
res.push({
route : route,
params : route._getParamsArray(request)
});
}
if (!this.greedyEnabled && res.length) {
break;
}
}
return res;
},
toString : function () {
return '[crossroads numRoutes:'+ this.getNumRoutes() +']';
}
};
//"static" instance
crossroads = new Crossroads();
crossroads.VERSION = '::VERSION_NUMBER::';
crossroads.NORM_AS_ARRAY = function (req, vals) {
return [vals.vals_];
};
crossroads.NORM_AS_OBJECT = function (req, vals) {
return [vals];
};
var crossroads,
UNDEF;
// Helpers -----------
//====================
function arrayIndexOf(arr, val) {
if (arr.indexOf) {
return arr.indexOf(val);
} else {
//Array.indexOf doesn't work on IE 6-7
var n = arr.length;
while (n--) {
if (arr[n] === val) {
return n;
}
}
return -1;
}
}
function isKind(val, kind) {
return '[object '+ kind +']' === Object.prototype.toString.call(val);
}
function isRegExp(val) {
return isKind(val, 'RegExp');
}
function isArray(val) {
return isKind(val, 'Array');
}
function isFunction(val) {
return typeof val === 'function';
}
//borrowed from AMD-utils
function typecastValue(val) {
var r;
if (val === null || val === 'null') {
r = null;
} else if (val === 'true') {
r = true;
} else if (val === 'false') {
r = false;
} else if (val === UNDEF || val === 'undefined') {
r = UNDEF;
} else if (val === '' || isNaN(val)) {
//isNaN('') returns false
r = val;
} else {
//parseFloat(null || '') returns NaN
r = parseFloat(val);
}
return r;
}
function typecastArrayValues(values) {
var n = values.length,
result = [];
while (n--) {
result[n] = typecastValue(values[n]);
}
return result;
}
//borrowed from AMD-Utils
function decodeQueryString(str) {
var queryArr = (str || '').replace('?', '').split('&'),
n = queryArr.length,
obj = {},
item, val;
while (n--) {
item = queryArr[n].split('=');
val = typecastValue(item[1]);
obj[item[0]] = (typeof val === 'string')? decodeURIComponent(val) : val;
}
return obj;
}
// Pattern Lexer ------
//=====================
crossroads.patternLexer = (function () {
var
//match chars that should be escaped on string regexp
ESCAPE_CHARS_REGEXP = /[\\.+*?\^$\[\](){}\/'#]/g,
//trailing slashes (begin/end of string)
LOOSE_SLASHES_REGEXP = /^\/|\/$/g,
LEGACY_SLASHES_REGEXP = /\/$/g,
//params - everything between `{ }` or `: :`
PARAMS_REGEXP = /(?:\{|:)([^}:]+)(?:\}|:)/g,
//used to save params during compile (avoid escaping things that
//shouldn't be escaped).
TOKENS = {
'OS' : {
//optional slashes
//slash between `::` or `}:` or `\w:` or `:{?` or `}{?` or `\w{?`
rgx : /([:}]|\w(?=\/))\/?(:|(?:\{\?))/g,
save : '$1{{id}}$2',
res : '\\/?'
},
'RS' : {
//required slashes
//used to insert slash between `:{` and `}{`
rgx : /([:}])\/?(\{)/g,
save : '$1{{id}}$2',
res : '\\/'
},
'RQ' : {
//required query string - everything in between `{? }`
rgx : /\{\?([^}]+)\}/g,
//everything from `?` till `#` or end of string
res : '\\?([^#]+)'
},
'OQ' : {
//optional query string - everything in between `:? :`
rgx : /:\?([^:]+):/g,
//everything from `?` till `#` or end of string
res : '(?:\\?([^#]*))?'
},
'OR' : {
//optional rest - everything in between `: *:`
rgx : /:([^:]+)\*:/g,
res : '(.*)?' // optional group to avoid passing empty string as captured
},
'RR' : {
//rest param - everything in between `{ *}`
rgx : /\{([^}]+)\*\}/g,
res : '(.+)'
},
// required/optional params should come after rest segments
'RP' : {
//required params - everything between `{ }`
rgx : /\{([^}]+)\}/g,
res : '([^\\/?]+)'
},
'OP' : {
//optional params - everything between `: :`
rgx : /:([^:]+):/g,
res : '([^\\/?]+)?\/?'
}
},
LOOSE_SLASH = 1,
STRICT_SLASH = 2,
LEGACY_SLASH = 3,
_slashMode = LOOSE_SLASH;
function precompileTokens(){
var key, cur;
for (key in TOKENS) {
if (TOKENS.hasOwnProperty(key)) {
cur = TOKENS[key];
cur.id = '__CR_'+ key +'__';
cur.save = ('save' in cur)? cur.save.replace('{{id}}', cur.id) : cur.id;
cur.rRestore = new RegExp(cur.id, 'g');
}
}
}
precompileTokens();
function captureVals(regex, pattern) {
var vals = [], match;
while (match = regex.exec(pattern)) {
vals.push(match[1]);
}
return vals;
}
function getParamIds(pattern) {
return captureVals(PARAMS_REGEXP, pattern);
}
function getOptionalParamsIds(pattern) {
return captureVals(TOKENS.OP.rgx, pattern);
}
function compilePattern(pattern) {
pattern = pattern || '';
if(pattern){
if (_slashMode === LOOSE_SLASH) {
pattern = pattern.replace(LOOSE_SLASHES_REGEXP, '');
}
else if (_slashMode === LEGACY_SLASH) {
pattern = pattern.replace(LEGACY_SLASHES_REGEXP, '');
}
//save tokens
pattern = replaceTokens(pattern, 'rgx', 'save');
//regexp escape
pattern = pattern.replace(ESCAPE_CHARS_REGEXP, '\\$&');
//restore tokens
pattern = replaceTokens(pattern, 'rRestore', 'res');
if (_slashMode === LOOSE_SLASH) {
pattern = '\\/?'+ pattern;
}
}
if (_slashMode !== STRICT_SLASH) {
//single slash is treated as empty and end slash is optional
pattern += '\\/?';
}
return new RegExp('^'+ pattern + '$');
}
function replaceTokens(pattern, regexpName, replaceName) {
var cur, key;
for (key in TOKENS) {
if (TOKENS.hasOwnProperty(key)) {
cur = TOKENS[key];
pattern = pattern.replace(cur[regexpName], cur[replaceName]);
}
}
return pattern;
}
function getParamValues(request, regexp, shouldTypecast) {
var vals = regexp.exec(request);
if (vals) {
vals.shift();
if (shouldTypecast) {
vals = typecastArrayValues(vals);
}
}
return vals;
}
function interpolate(pattern, replacements) {
if (typeof pattern !== 'string') {
throw new Error('Route pattern should be a string.');
}
var replaceFn = function(match, prop){
var val;
if (prop in replacements) {
val = replacements[prop];
if (match.indexOf('*') === -1 && val.indexOf('/') !== -1) {
throw new Error('Invalid value "'+ val +'" for segment "'+ match +'".');
}
}
else if (match.indexOf('{') !== -1) {
throw new Error('The segment '+ match +' is required.');
}
else {
val = '';
}
return val;
};
if (! TOKENS.OS.trail) {
TOKENS.OS.trail = new RegExp('(?:'+ TOKENS.OS.id +')+$');
}
return pattern
.replace(TOKENS.OS.rgx, TOKENS.OS.save)
.replace(PARAMS_REGEXP, replaceFn)
.replace(TOKENS.OS.trail, '') // remove trailing
.replace(TOKENS.OS.rRestore, '/'); // add slash between segments
}
//API
return {
strict : function(){
_slashMode = STRICT_SLASH;
},
loose : function(){
_slashMode = LOOSE_SLASH;
},
legacy : function(){
_slashMode = LEGACY_SLASH;
},
getParamIds : getParamIds,
getOptionalParamsIds : getOptionalParamsIds,
getParamValues : getParamValues,
compilePattern : compilePattern,
interpolate : interpolate
};
}());
// Route --------------
//=====================
/**
* @constructor
*/
function Route(pattern, callback, priority, router) {
var isRegexPattern = isRegExp(pattern),
patternLexer = crossroads.patternLexer;
this._router = router;
this._pattern = pattern;
this._paramsIds = isRegexPattern? null : patternLexer.getParamIds(this._pattern);
this._optionalParamsIds = isRegexPattern? null : patternLexer.getOptionalParamsIds(this._pattern);
this._matchRegexp = isRegexPattern? pattern : patternLexer.compilePattern(pattern);
this.matched = new signals.Signal();
this.switched = new signals.Signal();
if (callback) {
this.matched.add(callback);
}
this._priority = priority || 0;
}
Route.prototype = {
greedy : false,
rules : void(0),
match : function (request) {
request = request || '';
return this._matchRegexp.test(request) && this._validateParams(request); //validate params even if regexp because of `request_` rule.
},
_validateParams : function (request) {
var rules = this.rules,
values = this._getParamsObject(request),
key;
for (key in rules) {
// normalize_ isn't a validation rule... (#39)
if(key !== 'normalize_' && rules.hasOwnProperty(key) && ! this._isValidParam(request, key, values)){
return false;
}
}
return true;
},
_isValidParam : function (request, prop, values) {
var validationRule = this.rules[prop],
val = values[prop],
isValid = false,
isQuery = (prop.indexOf('?') === 0);
if (val == null && this._optionalParamsIds && arrayIndexOf(this._optionalParamsIds, prop) !== -1) {
isValid = true;
}
else if (isRegExp(validationRule)) {
if (isQuery) {
val = values[prop +'_']; //use raw string
}
isValid = validationRule.test(val);
}
else if (isArray(validationRule)) {
if (isQuery) {
val = values[prop +'_']; //use raw string
}
isValid = arrayIndexOf(validationRule, val) !== -1;
}
else if (isFunction(validationRule)) {
isValid = validationRule(val, request, values);
}
return isValid; //fail silently if validationRule is from an unsupported type
},
_getParamsObject : function (request) {
var shouldTypecast = this._router.shouldTypecast,
values = crossroads.patternLexer.getParamValues(request, this._matchRegexp, shouldTypecast),
o = {},
n = values.length,
param, val;
while (n--) {
val = values[n];
if (this._paramsIds) {
param = this._paramsIds[n];
if (param.indexOf('?') === 0 && val) {
//make a copy of the original string so array and
//RegExp validation can be applied properly
o[param +'_'] = val;
//update vals_ array as well since it will be used
//during dispatch
val = decodeQueryString(val);
values[n] = val;
}
o[param] = val;
}
//alias to paths and for RegExp pattern
o[n] = val;
}
o.request_ = shouldTypecast? typecastValue(request) : request;
o.vals_ = values;
return o;
},
_getParamsArray : function (request) {
var norm = this.rules? this.rules.normalize_ : null,
params;
norm = norm || this._router.normalizeFn; // default normalize
if (norm && isFunction(norm)) {
params = norm(request, this._getParamsObject(request));
} else {
params = this._getParamsObject(request).vals_;
}
return params;
},
interpolate : function(replacements) {
var str = crossroads.patternLexer.interpolate(this._pattern, replacements);
if (! this._validateParams(str) ) {
throw new Error('Generated string doesn\'t validate against `Route.rules`.');
}
return str;
},
dispose : function () {
this._router.removeRoute(this);
},
_destroy : function () {
this.matched.dispose();
this.switched.dispose();
this.matched = this.switched = this._pattern = this._matchRegexp = null;
},
toString : function () {
return '[Route pattern:"'+ this._pattern +'", numListeners:'+ this.matched.getNumListeners() +']';
}
};
/*jslint onevar:true, undef:true, newcap:true, regexp:true, bitwise:true, maxerr:50, indent:4, white:false, nomen:false, plusplus:false */
/*global window:false, global:false*/
/*!!
* JS Signals <http://millermedeiros.github.com/js-signals/>
* Released under the MIT license <http://www.opensource.org/licenses/mit-license.php>
* @author Miller Medeiros <http://millermedeiros.com/>
* @version 0.6.3
* @build 187 (07/11/2011 10:14 AM)
*/
(function(global){
/**
* @namespace Signals Namespace - Custom event/messaging system based on AS3 Signals
* @name signals
*/
var signals = /** @lends signals */{
/**
* Signals Version Number
* @type String
* @const
*/
VERSION : '0.6.3'
};
// SignalBinding -------------------------------------------------
//================================================================
/**
* Object that represents a binding between a Signal and a listener function.
* <br />- <strong>This is an internal constructor and shouldn't be called by regular users.</strong>
* <br />- inspired by Joa Ebert AS3 SignalBinding and Robert Penner's Slot classes.
* @author Miller Medeiros
* @constructor
* @internal
* @name signals.SignalBinding
* @param {signals.Signal} signal Reference to Signal object that listener is currently bound to.
* @param {Function} listener Handler function bound to the signal.
* @param {boolean} isOnce If binding should be executed just once.
* @param {Object} [listenerContext] Context on which listener will be executed (object that should represent the `this` variable inside listener function).
* @param {Number} [priority] The priority level of the event listener. (default = 0).
*/
function SignalBinding(signal, listener, isOnce, listenerContext, priority) {
/**
* Handler function bound to the signal.
* @type Function
* @private
*/
this._listener = listener;
/**
* If binding should be executed just once.
* @type boolean
* @private
*/
this._isOnce = isOnce;
/**
* Context on which listener will be executed (object that should represent the `this` variable inside listener function).
* @memberOf signals.SignalBinding.prototype
* @name context
* @type Object|undefined|null
*/
this.context = listenerContext;
/**
* Reference to Signal object that listener is currently bound to.
* @type signals.Signal
* @private
*/
this._signal = signal;
/**
* Listener priority
* @type Number
* @private
*/
this._priority = priority || 0;
}
SignalBinding.prototype = /** @lends signals.SignalBinding.prototype */ {
/**
* If binding is active and should be executed.
* @type boolean
*/
active : true,
/**
* Default parameters passed to listener during `Signal.dispatch` and `SignalBinding.execute`. (curried parameters)
* @type Array|null
*/
params : null,
/**
* Call listener passing arbitrary parameters.
* <p>If binding was added using `Signal.addOnce()` it will be automatically removed from signal dispatch queue, this method is used internally for the signal dispatch.</p>
* @param {Array} [paramsArr] Array of parameters that should be passed to the listener
* @return {*} Value returned by the listener.
*/
execute : function (paramsArr) {
var handlerReturn, params;
if (this.active && !!this._listener) {
params = this.params? this.params.concat(paramsArr) : paramsArr;
handlerReturn = this._listener.apply(this.context, params);
if (this._isOnce) {
this.detach();
}
}
return handlerReturn;
},
/**
* Detach binding from signal.
* - alias to: mySignal.remove(myBinding.getListener());
* @return {Function|null} Handler function bound to the signal or `null` if binding was previously detached.
*/
detach : function () {
return this.isBound()? this._signal.remove(this._listener) : null;
},
/**
* @return {Boolean} `true` if binding is still bound to the signal and have a listener.
*/
isBound : function () {
return (!!this._signal && !!this._listener);
},
/**
* @return {Function} Handler function bound to the signal.
*/
getListener : function () {
return this._listener;
},
/**
* Delete instance properties
* @private
*/
_destroy : function () {
delete this._signal;
delete this._listener;
delete this.context;
},
/**
* @return {boolean} If SignalBinding will only be executed once.
*/
isOnce : function () {
return this._isOnce;
},
/**
* @return {string} String representation of the object.
*/
toString : function () {
return '[SignalBinding isOnce: ' + this._isOnce +', isBound: '+ this.isBound() +', active: ' + this.active + ']';
}
};
/*global signals:true, SignalBinding:false*/
// Signal --------------------------------------------------------
//================================================================
function validateListener(listener, fnName) {
if (typeof listener !== 'function') {
throw new Error( 'listener is a required param of {fn}() and should be a Function.'.replace('{fn}', fnName) );
}
}
/**
* Custom event broadcaster
* <br />- inspired by Robert Penner's AS3 Signals.
* @author Miller Medeiros
* @constructor
*/
signals.Signal = function () {
/**
* @type Array.<SignalBinding>
* @private
*/
this._bindings = [];
};
signals.Signal.prototype = {
/**
* @type boolean
* @private
*/
_shouldPropagate : true,
/**
* If Signal is active and should broadcast events.
* <p><strong>IMPORTANT:</strong> Setting this property during a dispatch will only affect the next dispatch, if you want to stop the propagation of a signal use `halt()` instead.</p>
* @type boolean
*/
active : true,
/**
* @param {Function} listener
* @param {boolean} isOnce
* @param {Object} [scope]
* @param {Number} [priority]
* @return {SignalBinding}
* @private
*/
_registerListener : function (listener, isOnce, scope, priority) {
var prevIndex = this._indexOfListener(listener),
binding;
if (prevIndex !== -1) { //avoid creating a new Binding for same listener if already added to list
binding = this._bindings[prevIndex];
if (binding.isOnce() !== isOnce) {
throw new Error('You cannot add'+ (isOnce? '' : 'Once') +'() then add'+ (!isOnce? '' : 'Once') +'() the same listener without removing the relationship first.');
}
} else {
binding = new SignalBinding(this, listener, isOnce, scope, priority);
this._addBinding(binding);
}
return binding;
},
/**
* @param {SignalBinding} binding
* @private
*/
_addBinding : function (binding) {
//simplified insertion sort
var n = this._bindings.length;
do { --n; } while (this._bindings[n] && binding._priority <= this._bindings[n]._priority);
this._bindings.splice(n + 1, 0, binding);
},
/**
* @param {Function} listener
* @return {number}
* @private
*/
_indexOfListener : function (listener) {
var n = this._bindings.length;
while (n--) {
if (this._bindings[n]._listener === listener) {
return n;
}
}
return -1;
},
/**
* Add a listener to the signal.
* @param {Function} listener Signal handler function.
* @param {Object} [scope] Context on which listener will be executed (object that should represent the `this` variable inside listener function).
* @param {Number} [priority] The priority level of the event listener. Listeners with higher priority will be executed before listeners with lower priority. Listeners with same priority level will be executed at the same order as they were added. (default = 0)
* @return {SignalBinding} An Object representing the binding between the Signal and listener.
*/
add : function (listener, scope, priority) {
validateListener(listener, 'add');
return this._registerListener(listener, false, scope, priority);
},
/**
* Add listener to the signal that should be removed after first execution (will be executed only once).
* @param {Function} listener Signal handler function.
* @param {Object} [scope] Context on which listener will be executed (object that should represent the `this` variable inside listener function).
* @param {Number} [priority] The priority level of the event listener. Listeners with higher priority will be executed before listeners with lower priority. Listeners with same priority level will be executed at the same order as they were added. (default = 0)
* @return {SignalBinding} An Object representing the binding between the Signal and listener.
*/
addOnce : function (listener, scope, priority) {
validateListener(listener, 'addOnce');
return this._registerListener(listener, true, scope, priority);
},
/**
* Remove a single listener from the dispatch queue.
* @param {Function} listener Handler function that should be removed.
* @return {Function} Listener handler function.
*/
remove : function (listener) {
validateListener(listener, 'remove');
var i = this._indexOfListener(listener);
if (i !== -1) {
this._bindings[i]._destroy(); //no reason to a SignalBinding exist if it isn't attached to a signal
this._bindings.splice(i, 1);
}
return listener;
},
/**
* Remove all listeners from the Signal.
*/
removeAll : function () {
var n = this._bindings.length;
while (n--) {
this._bindings[n]._destroy();
}
this._bindings.length = 0;
},
/**
* @return {number} Number of listeners attached to the Signal.
*/
getNumListeners : function () {
return this._bindings.length;
},
/**
* Stop propagation of the event, blocking the dispatch to next listeners on the queue.
* <p><strong>IMPORTANT:</strong> should be called only during signal dispatch, calling it before/after dispatch won't affect signal broadcast.</p>
* @see signals.Signal.prototype.disable
*/
halt : function () {
this._shouldPropagate = false;
},
/**
* Dispatch/Broadcast Signal to all listeners added to the queue.
* @param {...*} [params] Parameters that should be passed to each handler.
*/
dispatch : function (params) {
if (! this.active) {
return;
}
var paramsArr = Array.prototype.slice.call(arguments),
bindings = this._bindings.slice(), //clone array in case add/remove items during dispatch
n = this._bindings.length;
this._shouldPropagate = true; //in case `halt` was called before dispatch or during the previous dispatch.
//execute all callbacks until end of the list or until a callback returns `false` or stops propagation
//reverse loop since listeners with higher priority will be added at the end of the list
do { n--; } while (bindings[n] && this._shouldPropagate && bindings[n].execute(paramsArr) !== false);
},
/**
* Remove all bindings from signal and destroy any reference to external objects (destroy Signal object).
* <p><strong>IMPORTANT:</strong> calling any method on the signal instance after calling dispose will throw errors.</p>
*/
dispose : function () {
this.removeAll();
delete this._bindings;
},
/**
* @return {string} String representation of the object.
*/
toString : function () {
return '[Signal active: '+ this.active +' numListeners: '+ this.getNumListeners() +']';
}
};
global.signals = signals;
}(window || this));
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment