/* Copyright (c) 2011-2013 @WalmartLabs Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ ;; (function() { /*global cloneInheritVars, createInheritVars, resetInheritVars, createRegistryWrapper, getValue, inheritVars, createErrorMessage */ //support zepto.forEach on jQuery if (!$.fn.forEach) { $.fn.forEach = function(iterator, context) { $.fn.each.call(this, function(index) { iterator.call(context || this, this, index); }); }; } var viewNameAttributeName = 'data-view-name', viewCidAttributeName = 'data-view-cid', viewHelperAttributeName = 'data-view-helper'; //view instances var viewsIndexedByCid = {}; if (!Handlebars.templates) { Handlebars.templates = {}; } var Thorax = this.Thorax = { templatePathPrefix: '', //view classes Views: {}, //certain error prone pieces of code (on Android only it seems) //are wrapped in a try catch block, then trigger this handler in //the catch, with the name of the function or event that was //trying to be executed. Override this with a custom handler //to debug / log / etc onException: function(name, err) { throw err; }, //deprecated, here to ensure existing projects aren't mucked with templates: Handlebars.templates }; Thorax.View = Backbone.View.extend({ constructor: function() { // store first argument for configureView() this._constructorArg = arguments[0]; var response = Backbone.View.apply(this, arguments); delete this._constructorArg; _.each(inheritVars, function(obj) { if (obj.ctor) { obj.ctor.call(this, response); } }, this); return response; }, // View configuration, _configure was removed // in Backbone 1.1, define _configure as a noop // for Backwards compatibility with 1.0 and earlier _configure: function() {}, _ensureElement: function () { configureView.call(this); return Backbone.View.prototype._ensureElement.call(this); }, toString: function() { return '[object View.' + this.name + ']'; }, setElement : function() { var response = Backbone.View.prototype.setElement.apply(this, arguments); this.name && this.$el.attr(viewNameAttributeName, this.name); this.$el.attr(viewCidAttributeName, this.cid); return response; }, _addChild: function(view) { if (this.children[view.cid]) { return view; } view.retain(); this.children[view.cid] = view; // _helperOptions is used to detect if is HelperView // we do not want to remove child in this case as // we are adding the HelperView to the declaring view // (whatever view used the view helper in it's template) // but it's parent will not equal the declaring view // in the case of a nested helper, which will cause an error. // In either case it's not necessary to ever call // _removeChild on a HelperView as _addChild should only // be called when a HelperView is created. if (view.parent && view.parent !== this && !view._helperOptions) { view.parent._removeChild(view); } view.parent = this; this.trigger('child', view); return view; }, _removeChild: function(view) { delete this.children[view.cid]; view.parent = null; view.release(); return view; }, _destroy: function(options) { _.each(this._boundDataObjectsByCid, this.unbindDataObject, this); this.trigger('destroyed'); delete viewsIndexedByCid[this.cid]; _.each(this.children, function(child) { this._removeChild(child); }, this); if (this.el) { this.undelegateEvents(); this.remove(); // Will call stopListening() this.off(); // Kills off remaining events } // Absolute worst case scenario, kill off some known fields to minimize the impact // of being retained. this.el = this.$el = undefined; this.parent = undefined; this.model = this.collection = this._collection = undefined; this._helperOptions = undefined; }, render: function(output) { // NOP for destroyed views if (!this.el) { return; } if (this._rendering) { // Nested rendering of the same view instances can lead to some very nasty issues with // the root render process overwriting any updated data that may have been output in the child // execution. If in a situation where you need to rerender in response to an event that is // triggered sync in the rendering lifecycle it's recommended to defer the subsequent render // or refactor so that all preconditions are known prior to exec. throw new Error(createErrorMessage('nested-render')); } this._previousHelpers = _.filter(this.children, function(child) { return child._helperOptions; }); var children = {}; _.each(this.children, function(child, key) { if (!child._helperOptions) { children[key] = child; } }); this.children = children; this.trigger('before:rendered'); this._rendering = true; try { if (_.isUndefined(output) || (!_.isElement(output) && !Thorax.Util.is$(output) && !(output && output.el) && !_.isString(output) && !_.isFunction(output))) { // try one more time to assign the template, if we don't // yet have one we must raise assignTemplate.call(this, 'template', { required: true }); output = this.renderTemplate(this.template); } else if (_.isFunction(output)) { output = this.renderTemplate(output); } // Destroy any helpers that may be lingering _.each(this._previousHelpers, function(child) { this._removeChild(child); }, this); this._previousHelpers = undefined; //accept a view, string, Handlebars.SafeString or DOM element this.html((output && output.el) || (output && output.string) || output); ++this._renderCount; this.trigger('rendered'); } finally { this._rendering = false; } return output; }, context: function() { return _.extend({}, (this.model && this.model.attributes) || {}); }, _getContext: function() { return _.extend({}, this, getValue(this, 'context') || {}); }, // Private variables in handlebars / options.data in template helpers _getData: function(data) { return { view: this, cid: _.uniqueId('t'), yield: function() { // fn is seeded by template helper passing context to data return data.fn && data.fn(data); } }; }, renderTemplate: function(file, context, ignoreErrors) { var template; context = context || this._getContext(); if (_.isFunction(file)) { template = file; } else { template = Thorax.Util.getTemplate(file, ignoreErrors); } if (!template) { return ''; } else { return template(context, { helpers: this.helpers, data: this._getData(context) }); } }, ensureRendered: function() { !this._renderCount && this.render(); }, shouldRender: function(flag) { // Render if flag is truthy or if we have already rendered and flag is undefined/null return flag || (flag == null && this._renderCount); }, conditionalRender: function(flag) { if (this.shouldRender(flag)) { this.render(); } }, appendTo: function(el) { this.ensureRendered(); $(el).append(this.el); this.trigger('ready', {target: this}); }, html: function(html) { if (_.isUndefined(html)) { return this.el.innerHTML; } else { // Event for IE element fixes this.trigger('before:append'); var element = this._replaceHTML(html); this.trigger('append'); return element; } }, release: function() { --this._referenceCount; if (this._referenceCount <= 0) { this._destroy(); } }, retain: function(owner) { ++this._referenceCount; if (owner) { // Not using listenTo helper as we want to run once the owner is destroyed this.listenTo(owner, 'destroyed', owner.release); } }, _replaceHTML: function(html) { this.el.innerHTML = ""; return this.$el.append(html); }, _anchorClick: function(event) { var target = $(event.currentTarget), href = target.attr('href'); // Route anything that starts with # or / (excluding //domain urls) if (href && (href[0] === '#' || (href[0] === '/' && href[1] !== '/'))) { Backbone.history.navigate(href, { trigger: true }); return false; } return true; } }); Thorax.View.extend = function() { createInheritVars(this); var child = Backbone.View.extend.apply(this, arguments); child.__parent__ = this; resetInheritVars(child); return child; }; createRegistryWrapper(Thorax.View, Thorax.Views); function configureView () { var options = this._constructorArg; var self = this; this._referenceCount = 0; this._objectOptionsByCid = {}; this._boundDataObjectsByCid = {}; // Setup object event tracking _.each(inheritVars, function(obj) { self[obj.name] = []; }); viewsIndexedByCid[this.cid] = this; this.children = {}; this._renderCount = 0; //this.options is removed in Thorax.View, we merge passed //properties directly with the view and template context _.extend(this, options || {}); // Setup helpers bindHelpers.call(this); _.each(inheritVars, function(obj) { if (obj.configure) { obj.configure.call(this); } }, this); this.trigger('configure'); } function bindHelpers() { if (this.helpers) { _.each(this.helpers, function(helper, name) { var view = this; this.helpers[name] = function() { var args = _.toArray(arguments), options = _.last(args); options.context = this; return helper.apply(view, args); }; }, this); } } //$(selector).view() helper $.fn.view = function(options) { options = _.defaults(options || {}, { helper: true }); var selector = '[' + viewCidAttributeName + ']'; if (!options.helper) { selector += ':not([' + viewHelperAttributeName + '])'; } var el = $(this).closest(selector); return (el && viewsIndexedByCid[el.attr(viewCidAttributeName)]) || false; }; ;; /*global createRegistryWrapper:true, cloneEvents: true */ function createErrorMessage(code) { return 'Error "' + code + '". For more information visit http://thoraxjs.org/error-codes.html' + '#' + code; } function createRegistryWrapper(klass, hash) { var $super = klass.extend; klass.extend = function() { var child = $super.apply(this, arguments); if (child.prototype.name) { hash[child.prototype.name] = child; } return child; }; } function registryGet(object, type, name, ignoreErrors) { var target = object[type], value; if (_.indexOf(name, '.') >= 0) { var bits = name.split(/\./); name = bits.pop(); _.each(bits, function(key) { target = target[key]; }); } target && (value = target[name]); if (!value && !ignoreErrors) { throw new Error(type + ': ' + name + ' does not exist.'); } else { return value; } } function assignView(attributeName, options) { var ViewClass; // if attribute is the name of view to fetch if (_.isString(this[attributeName])) { ViewClass = Thorax.Util.getViewClass(this[attributeName], true); // else try and fetch the view based on the name } else if (this.name && !_.isFunction(this[attributeName])) { ViewClass = Thorax.Util.getViewClass(this.name + (options.extension || ''), true); } // if we found something, assign it if (ViewClass && !_.isFunction(this[attributeName])) { this[attributeName] = ViewClass; } // if nothing was found and it's required, throw if (options.required && !_.isFunction(this[attributeName])) { throw new Error('View ' + (this.name || this.cid) + ' requires: ' + attributeName); } } function assignTemplate(attributeName, options) { var template; // if attribute is the name of template to fetch if (_.isString(this[attributeName])) { template = Thorax.Util.getTemplate(this[attributeName], true); // else try and fetch the template based on the name } else if (this.name && !_.isFunction(this[attributeName])) { template = Thorax.Util.getTemplate(this.name + (options.extension || ''), true); } // CollectionView and LayoutView have a defaultTemplate that may be used if none // was found, regular views must have a template if render() is called if (!template && attributeName === 'template' && this._defaultTemplate) { template = this._defaultTemplate; } // if we found something, assign it if (template && !_.isFunction(this[attributeName])) { this[attributeName] = template; } // if nothing was found and it's required, throw if (options.required && !_.isFunction(this[attributeName])) { throw new Error('View ' + (this.name || this.cid) + ' requires: ' + attributeName); } } // getValue is used instead of _.result because we // need an extra scope parameter, and will minify // better than _.result function getValue(object, prop, scope) { if (!(object && object[prop])) { return null; } return _.isFunction(object[prop]) ? object[prop].call(scope || object) : object[prop]; } var inheritVars = {}; function createInheritVars(self) { // Ensure that we have our static event objects _.each(inheritVars, function(obj) { if (!self[obj.name]) { self[obj.name] = []; } }); } function resetInheritVars(self) { // Ensure that we have our static event objects _.each(inheritVars, function(obj) { self[obj.name] = []; }); } function walkInheritTree(source, fieldName, isStatic, callback) { var tree = []; if (_.has(source, fieldName)) { tree.push(source); } var iterate = source; if (isStatic) { while (iterate = iterate.__parent__) { if (_.has(iterate, fieldName)) { tree.push(iterate); } } } else { iterate = iterate.constructor; while (iterate) { if (iterate.prototype && _.has(iterate.prototype, fieldName)) { tree.push(iterate.prototype); } iterate = iterate.__super__ && iterate.__super__.constructor; } } var i = tree.length; while (i--) { _.each(getValue(tree[i], fieldName, source), callback); } } function objectEvents(target, eventName, callback, context) { if (_.isObject(callback)) { var spec = inheritVars[eventName]; if (spec && spec.event) { if (target && target.listenTo && target[eventName] && target[eventName].cid) { addEvents(target, callback, context, eventName); } else { addEvents(target['_' + eventName + 'Events'], callback, context); } return true; } } } // internal listenTo function will error on destroyed // race condition function listenTo(object, target, eventName, callback, context) { // getEventCallback will resolve if it is a string or a method // and return a method var callbackMethod = getEventCallback(callback, object), destroyedCount = 0; function eventHandler() { if (object.el) { callbackMethod.apply(context, arguments); } else { // If our event handler is removed by destroy while another event is processing then we // we might see one latent event percolate through due to caching in the event loop. If we // see multiple events this is a concern and a sign that something was not cleaned properly. if (destroyedCount) { throw new Error('destroyed-event:' + object.name + ':' + eventName); } destroyedCount++; } } eventHandler._callback = callbackMethod._callback || callbackMethod; eventHandler._thoraxBind = true; object.listenTo(target, eventName, eventHandler); } function addEvents(target, source, context, listenToObject) { function addEvent(callback, eventName) { if (listenToObject) { listenTo(target, target[listenToObject], eventName, callback, context || target); } else { target.push([eventName, callback, context]); } } _.each(source, function(callback, eventName) { if (_.isArray(callback)) { _.each(callback, function(cb) { addEvent(cb, eventName); }); } else { addEvent(callback, eventName); } }); } function getOptionsData(options) { if (!options || !options.data) { throw new Error(createErrorMessage('handlebars-no-data')); } return options.data; } // In helpers "tagName" or "tag" may be specified, as well // as "class" or "className". Normalize to "tagName" and // "className" to match the property names used by Backbone // jQuery, etc. Special case for "className" in // Thorax.Util.tag: will be rewritten as "class" in // generated HTML. function normalizeHTMLAttributeOptions(options) { if (options.tag) { options.tagName = options.tag; delete options.tag; } if (options['class']) { options.className = options['class']; delete options['class']; } } Thorax.Util = { getViewInstance: function(name, attributes) { var ViewClass = Thorax.Util.getViewClass(name, true); return ViewClass ? new ViewClass(attributes || {}) : name; }, getViewClass: function(name, ignoreErrors) { if (_.isString(name)) { return registryGet(Thorax, 'Views', name, ignoreErrors); } else if (_.isFunction(name)) { return name; } else { return false; } }, getTemplate: function(file, ignoreErrors) { //append the template path prefix if it is missing var pathPrefix = Thorax.templatePathPrefix, template; if (pathPrefix && file.substr(0, pathPrefix.length) !== pathPrefix) { file = pathPrefix + file; } // Without extension file = file.replace(/\.handlebars$/, ''); template = Handlebars.templates[file]; if (!template) { // With extension file = file + '.handlebars'; template = Handlebars.templates[file]; } if (!template && !ignoreErrors) { throw new Error('templates: ' + file + ' does not exist.'); } return template; }, //'selector' is not present in $('
') //TODO: investigage a better detection method is$: function(obj) { return _.isObject(obj) && ('length' in obj); }, expandToken: function(input, scope) { if (input && input.indexOf && input.indexOf('{{') >= 0) { var re = /(?:\{?[^{]+)|(?:\{\{([^}]+)\}\})/g, match, ret = []; function deref(token, scope) { if (token.match(/^("|')/) && token.match(/("|')$/)) { return token.replace(/(^("|')|('|")$)/g, ''); } var segments = token.split('.'), len = segments.length; for (var i = 0; scope && i < len; i++) { if (segments[i] !== 'this') { scope = scope[segments[i]]; } } return scope; } while (match = re.exec(input)) { if (match[1]) { var params = match[1].split(/\s+/); if (params.length > 1) { var helper = params.shift(); params = _.map(params, function(param) { return deref(param, scope); }); if (Handlebars.helpers[helper]) { ret.push(Handlebars.helpers[helper].apply(scope, params)); } else { // If the helper is not defined do nothing ret.push(match[0]); } } else { ret.push(deref(params[0], scope)); } } else { ret.push(match[0]); } } input = ret.join(''); } return input; }, tag: function(attributes, content, scope) { var htmlAttributes = _.omit(attributes, 'tagName'), tag = attributes.tagName || 'div'; return '<' + tag + ' ' + _.map(htmlAttributes, function(value, key) { if (_.isUndefined(value) || key === 'expand-tokens') { return ''; } var formattedValue = value; if (scope) { formattedValue = Thorax.Util.expandToken(value, scope); } return (key === 'className' ? 'class' : key) + '="' + Handlebars.Utils.escapeExpression(formattedValue) + '"'; }).join(' ') + '>' + (_.isUndefined(content) ? '' : content) + '' + tag + '>'; } }; ;; Thorax.Mixins = {}; _.extend(Thorax.View, { mixin: function(name) { Thorax.Mixins[name](this); }, registerMixin: function(name, callback, methods) { Thorax.Mixins[name] = function(obj) { var isInstance = !!obj.cid; if (methods) { _.extend(isInstance ? obj : obj.prototype, methods); } if (isInstance) { callback.call(obj); } else { obj.on('configure', callback); } }; } }); Thorax.View.prototype.mixin = function(name) { Thorax.Mixins[name](this); }; ;; /*global createInheritVars, inheritVars, listenTo, objectEvents, walkInheritTree */ // Save a copy of the _on method to call as a $super method var _on = Thorax.View.prototype.on; inheritVars.event = { name: '_events', configure: function() { var self = this; walkInheritTree(this.constructor, '_events', true, function(event) { self.on.apply(self, event); }); walkInheritTree(this, 'events', false, function(handler, eventName) { self.on(eventName, handler, self); }); } }; _.extend(Thorax.View, { on: function(eventName, callback) { createInheritVars(this); if (objectEvents(this, eventName, callback)) { return this; } //accept on({"rendered": handler}) if (_.isObject(eventName)) { _.each(eventName, function(value, key) { this.on(key, value); }, this); } else { //accept on({"rendered": [handler, handler]}) if (_.isArray(callback)) { _.each(callback, function(cb) { this._events.push([eventName, cb]); }, this); //accept on("rendered", handler) } else { this._events.push([eventName, callback]); } } return this; } }); _.extend(Thorax.View.prototype, { on: function(eventName, callback, context) { if (objectEvents(this, eventName, callback, context)) { return this; } if (_.isObject(eventName) && arguments.length < 3) { //accept on({"rendered": callback}) _.each(eventName, function(value, key) { this.on(key, value, callback || this); // callback is context in this form of the call }, this); } else { //accept on("rendered", callback, context) //accept on("click a", callback, context) _.each((_.isArray(callback) ? callback : [callback]), function(callback) { var params = eventParamsFromEventItem.call(this, eventName, callback, context || this); if (params.type === 'DOM' && !this._eventsDelegated) { //will call _addEvent during delegateEvents() if (!this._eventsToDelegate) { this._eventsToDelegate = []; } this._eventsToDelegate.push(params); } else { this._addEvent(params); } }, this); } return this; }, delegateEvents: function(events) { this.undelegateEvents(); if (events) { if (_.isFunction(events)) { events = events.call(this); } this._eventsToDelegate = []; this.on(events); } this._eventsToDelegate && _.each(this._eventsToDelegate, this._addEvent, this); this._eventsDelegated = true; }, //params may contain: //- name //- originalName //- selector //- type "view" || "DOM" //- handler _addEvent: function(params) { // If this is recursvie due to listenTo delegate below then pass through to super class if (params.handler._thoraxBind) { return _on.call(this, params.name, params.handler, params.context || this); } var boundHandler = bindEventHandler.call(this, params.type + '-event:', params); if (params.type === 'view') { // If we have our context set to an outside view then listen rather than directly bind so // we can cleanup properly. if (params.context && params.context !== this && params.context instanceof Thorax.View) { listenTo(params.context, this, params.name, boundHandler, params.context); } else { _on.call(this, params.name, boundHandler, params.context || this); } } else { if (!params.nested) { boundHandler = containHandlerToCurentView(boundHandler, this.cid); } var name = params.name + '.delegateEvents' + this.cid; if (params.selector) { this.$el.on(name, params.selector, boundHandler); } else { this.$el.on(name, boundHandler); } } } }); Thorax.View.prototype.bind = Thorax.View.prototype.on; // When view is ready trigger ready event on all // children that are present, then register an // event that will trigger ready on new children // when they are added Thorax.View.on('ready', function(options) { if (!this._isReady) { this._isReady = true; function triggerReadyOnChild(child) { child._isReady || child.trigger('ready', options); } _.each(this.children, triggerReadyOnChild); this.on('child', triggerReadyOnChild); } }); var eventSplitter = /^(nested\s+)?(\S+)(?:\s+(.+))?/; var domEvents = [], domEventRegexp; function pushDomEvents(events) { domEvents.push.apply(domEvents, events); domEventRegexp = new RegExp('^(nested\\s+)?(' + domEvents.join('|') + ')(?:\\s|$)'); } pushDomEvents([ 'mousedown', 'mouseup', 'mousemove', 'mouseover', 'mouseout', 'touchstart', 'touchend', 'touchmove', 'click', 'dblclick', 'keyup', 'keydown', 'keypress', 'submit', 'change', 'input', 'focus', 'blur' ]); function containHandlerToCurentView(handler, cid) { return function(event) { var view = $(event.target).view({helper: false}); if (view && view.cid === cid) { event.originalContext = this; return handler(event); } }; } function bindEventHandler(eventName, params) { eventName += params.originalName; var callback = params.handler, method = _.isFunction(callback) ? callback : this[callback]; if (!method) { throw new Error('Event "' + callback + '" does not exist ' + (this.name || this.cid) + ':' + eventName); } var context = params.context || this; function ret() { try { return method.apply(context, arguments); } catch (e) { Thorax.onException('thorax-exception: ' + (context.name || context.cid) + ':' + eventName, e); } } // Backbone will delegate to _callback in off calls so we should still be able to support // calling off on specific handlers. ret._callback = method; ret._thoraxBind = true; return ret; } function eventParamsFromEventItem(name, handler, context) { var params = { originalName: name, handler: _.isString(handler) ? this[handler] : handler }; if (name.match(domEventRegexp)) { var match = eventSplitter.exec(name); params.nested = !!match[1]; params.name = match[2]; params.type = 'DOM'; params.selector = match[3]; } else { params.name = name; params.type = 'view'; } params.context = context; return params; } ;; /*global getOptionsData, normalizeHTMLAttributeOptions, viewHelperAttributeName */ var viewPlaceholderAttributeName = 'data-view-tmp', viewTemplateOverrides = {}; // Will be shared by HelperView and CollectionHelperView var helperViewPrototype = { _ensureElement: function() { Thorax.View.prototype._ensureElement.apply(this, arguments); this.$el.attr(viewHelperAttributeName, this._helperName); }, _getContext: function() { return this.parent._getContext.apply(this.parent, arguments); } }; Thorax.HelperView = Thorax.View.extend(helperViewPrototype); // Ensure nested inline helpers will always have this.parent // set to the view containing the template function getParent(parent) { // The `view` helper is a special case as it embeds // a view instead of creating a new one while (parent._helperName && parent._helperName !== 'view') { parent = parent.parent; } return parent; } Handlebars.registerViewHelper = function(name, ViewClass, callback) { if (arguments.length === 2) { if (ViewClass.factory) { callback = ViewClass.callback; } else { callback = ViewClass; ViewClass = Thorax.HelperView; } } var viewOptionWhiteList = ViewClass.attributeWhiteList; Handlebars.registerHelper(name, function() { var args = _.toArray(arguments), options = args.pop(), declaringView = getOptionsData(options).view, expandTokens = options.hash['expand-tokens']; if (expandTokens) { delete options.hash['expand-tokens']; _.each(options.hash, function(value, key) { options.hash[key] = Thorax.Util.expandToken(value, this); }, this); } var viewOptions = { inverse: options.inverse, options: options.hash, declaringView: declaringView, parent: getParent(declaringView), _helperName: name, _helperOptions: { options: cloneHelperOptions(options), args: _.clone(args) } }; normalizeHTMLAttributeOptions(options.hash); var htmlAttributes = _.clone(options.hash); if (viewOptionWhiteList) { _.each(viewOptionWhiteList, function(dest, source) { delete htmlAttributes[source]; if (!_.isUndefined(options.hash[source])) { viewOptions[dest] = options.hash[source]; } }); } if(htmlAttributes.tagName) { viewOptions.tagName = htmlAttributes.tagName; } viewOptions.attributes = function() { var attrs = (ViewClass.prototype && ViewClass.prototype.attributes) || {}; if (_.isFunction(attrs)) { attrs = attrs.apply(this, arguments); } _.extend(attrs, _.omit(htmlAttributes, ['tagName'])); // backbone wants "class" if (attrs.className) { attrs['class'] = attrs.className; delete attrs.className; } return attrs; }; if (options.fn) { // Only assign if present, allow helper view class to // declare template viewOptions.template = options.fn; } else if (ViewClass && ViewClass.prototype && !ViewClass.prototype.template) { // ViewClass may also be an instance or object with factory method // so need to do this check viewOptions.template = Handlebars.VM.noop; } // Check to see if we have an existing instance that we can reuse var instance = _.find(declaringView._previousHelpers, function(child) { return compareHelperOptions(viewOptions, child); }); // Create the instance if we don't already have one if (!instance) { if (ViewClass.factory) { instance = ViewClass.factory(args, viewOptions); if (!instance) { return ''; } instance._helperName = viewOptions._helperName; instance._helperOptions = viewOptions._helperOptions; } else { instance = new ViewClass(viewOptions); } if (!instance.el) { // ViewClass.factory may return existing objects which may have been destroyed throw new Error('insert-destroyed-factory'); } // Remove any possible entry in previous helpers in case this is a cached value returned from // slightly different data that does not qualify for the previous helpers direct reuse. // (i.e. when using an array that is modified between renders) declaringView._previousHelpers = _.without(declaringView._previousHelpers, instance); args.push(instance); declaringView._addChild(instance); declaringView.trigger.apply(declaringView, ['helper', name].concat(args)); declaringView.trigger.apply(declaringView, ['helper:' + name].concat(args)); callback && callback.apply(this, args); } else { if (!instance.el) { throw new Error('insert-destroyed'); } declaringView._previousHelpers = _.without(declaringView._previousHelpers, instance); declaringView.children[instance.cid] = instance; } htmlAttributes[viewPlaceholderAttributeName] = instance.cid; if (ViewClass.modifyHTMLAttributes) { ViewClass.modifyHTMLAttributes(htmlAttributes, instance); } return new Handlebars.SafeString(Thorax.Util.tag(htmlAttributes, '', expandTokens ? this : null)); }); var helper = Handlebars.helpers[name]; return helper; }; Thorax.View.on('append', function(scope, callback) { (scope || this.$el).find('[' + viewPlaceholderAttributeName + ']').forEach(function(el) { var placeholderId = el.getAttribute(viewPlaceholderAttributeName), view = this.children[placeholderId]; if (view) { //see if the view helper declared an override for the view //if not, ensure the view has been rendered at least once if (viewTemplateOverrides[placeholderId]) { view.render(viewTemplateOverrides[placeholderId]); delete viewTemplateOverrides[placeholderId]; } else { view.ensureRendered(); } $(el).replaceWith(view.el); callback && callback(view.el); } }, this); }); /** * Clones the helper options, dropping items that are known to change * between rendering cycles as appropriate. */ function cloneHelperOptions(options) { var ret = _.pick(options, 'fn', 'inverse', 'hash', 'data'); ret.data = _.omit(options.data, 'cid', 'view', 'yield'); return ret; } /** * Checks for basic equality between two sets of parameters for a helper view. * * Checked fields include: * - _helperName * - All args * - Hash * - Data * - Function and Invert (id based if possible) * * This method allows us to determine if the inputs to a given view are the same. If they * are then we make the assumption that the rendering will be the same (or the child view will * otherwise rerendering it by monitoring it's parameters as necessary) and reuse the view on * rerender of the parent view. */ function compareHelperOptions(a, b) { function compareValues(a, b) { return _.every(a, function(value, key) { return b[key] === value; }); } if (a._helperName !== b._helperName) { return false; } a = a._helperOptions; b = b._helperOptions; // Implements a first level depth comparison return a.args.length === b.args.length && compareValues(a.args, b.args) && _.isEqual(_.keys(a.options), _.keys(b.options)) && _.every(a.options, function(value, key) { if (key === 'data' || key === 'hash') { return compareValues(a.options[key], b.options[key]); } else if (key === 'fn' || key === 'inverse') { if (b.options[key] === value) { return true; } var other = b.options[key] || {}; return value && _.has(value, 'program') && !value.depth && other.program === value.program; } return b.options[key] === value; }); } ;; /*global getValue, inheritVars, walkInheritTree */ function dataObject(type, spec) { spec = inheritVars[type] = _.defaults({ name: '_' + type + 'Events', event: true }, spec); // Add a callback in the view constructor spec.ctor = function() { if (this[type]) { // Need to null this.model/collection so setModel/Collection will // not treat it as the old model/collection and immediately return var object = this[type]; this[type] = null; this[spec.set](object); } }; function setObject(dataObject, options) { var old = this[type], $el = getValue(this, spec.$el); if (dataObject === old) { return this; } if (old) { this.unbindDataObject(old); } if (dataObject) { this[type] = dataObject; if (spec.loading) { spec.loading.call(this); } this.bindDataObject(type, dataObject, _.extend({}, this.options, options)); $el && $el.attr(spec.cidAttrName, dataObject.cid); dataObject.trigger('set', dataObject, old); } else { this[type] = false; if (spec.change) { spec.change.call(this, false); } $el && $el.removeAttr(spec.cidAttrName); } this.trigger('change:data-object', type, dataObject, old); return this; } Thorax.View.prototype[spec.set] = setObject; } _.extend(Thorax.View.prototype, { getObjectOptions: function(dataObject) { return dataObject && this._objectOptionsByCid[dataObject.cid]; }, bindDataObject: function(type, dataObject, options) { if (this._boundDataObjectsByCid[dataObject.cid]) { return false; } this._boundDataObjectsByCid[dataObject.cid] = dataObject; var options = this._modifyDataObjectOptions(dataObject, _.extend({}, inheritVars[type].defaultOptions, options)); this._objectOptionsByCid[dataObject.cid] = options; bindEvents.call(this, type, dataObject, this.constructor); bindEvents.call(this, type, dataObject, this); var spec = inheritVars[type]; spec.bindCallback && spec.bindCallback.call(this, dataObject, options); if (dataObject.shouldFetch && dataObject.shouldFetch(options)) { loadObject(dataObject, options); } else if (inheritVars[type].change) { // want to trigger built in rendering without triggering event on model inheritVars[type].change.call(this, dataObject, options); } return true; }, unbindDataObject: function (dataObject) { if (!this._boundDataObjectsByCid[dataObject.cid]) { return false; } delete this._boundDataObjectsByCid[dataObject.cid]; this.stopListening(dataObject); delete this._objectOptionsByCid[dataObject.cid]; return true; }, _modifyDataObjectOptions: function(dataObject, options) { return options; } }); function bindEvents(type, target, source) { var context = this; walkInheritTree(source, '_' + type + 'Events', true, function(event) { listenTo(context, target, event[0], event[1], event[2] || context); }); } function loadObject(dataObject, options) { if (dataObject.load) { dataObject.load(function() { options && options.success && options.success(dataObject); }, options); } else { dataObject.fetch(options); } } function getEventCallback(callback, context) { if (_.isFunction(callback)) { return callback; } else { return context[callback]; } } ;; /*global createRegistryWrapper, dataObject, getValue, inheritVars */ var modelCidAttributeName = 'data-model-cid'; Thorax.Model = Backbone.Model.extend({ isEmpty: function() { return !this.isPopulated(); }, isPopulated: function() { // We are populated if we have attributes set var attributes = _.clone(this.attributes), defaults = getValue(this, 'defaults') || {}; for (var default_key in defaults) { if (attributes[default_key] != defaults[default_key]) { return true; } delete attributes[default_key]; } var keys = _.keys(attributes); return keys.length > 1 || (keys.length === 1 && keys[0] !== this.idAttribute); }, shouldFetch: function(options) { // url() will throw if model has no `urlRoot` and no `collection` // or has `collection` and `collection` has no `url` var url; try { url = getValue(this, 'url'); } catch(e) { url = false; } return options.fetch && !!url && !this.isPopulated(); } }); Thorax.Models = {}; createRegistryWrapper(Thorax.Model, Thorax.Models); dataObject('model', { set: 'setModel', defaultOptions: { render: undefined, // Default to deferred rendering fetch: true, success: false, invalid: true }, change: onModelChange, $el: '$el', cidAttrName: modelCidAttributeName }); function onModelChange(model, options) { if (options && options.serializing) { return; } var modelOptions = this.getObjectOptions(model) || {}; // !modelOptions will be true when setModel(false) is called this.conditionalRender(modelOptions.render); } Thorax.View.on({ model: { invalid: function(model, errors) { if (this.getObjectOptions(model).invalid) { this.trigger('invalid', errors, model); } }, error: function(model, resp, options) { this.trigger('error', resp, model); }, change: function(model, options) { // Indirect refernece to allow for overrides inheritVars.model.change.call(this, model, options); } } }); $.fn.model = function(view) { var $this = $(this), modelElement = $this.closest('[' + modelCidAttributeName + ']'), modelCid = modelElement && modelElement.attr(modelCidAttributeName); if (modelCid) { var view = view || $this.view(); if (view && view.model && view.model.cid === modelCid) { return view.model || false; } var collection = $this.collection(view); if (collection) { return collection.get(modelCid); } } return false; }; ;; /*global assignView, assignTemplate, createRegistryWrapper, dataObject, getEventCallback, getValue, modelCidAttributeName, viewCidAttributeName */ var _fetch = Backbone.Collection.prototype.fetch, _set = Backbone.Collection.prototype.set, _replaceHTML = Thorax.View.prototype._replaceHTML, collectionCidAttributeName = 'data-collection-cid', collectionEmptyAttributeName = 'data-collection-empty', collectionElementAttributeName = 'data-collection-element', ELEMENT_NODE_TYPE = 1; Thorax.Collection = Backbone.Collection.extend({ model: Thorax.Model || Backbone.Model, initialize: function() { this.cid = _.uniqueId('collection'); return Backbone.Collection.prototype.initialize.apply(this, arguments); }, isEmpty: function() { if (this.length > 0) { return false; } else { return this.length === 0 && this.isPopulated(); } }, isPopulated: function() { return this._fetched || this.length > 0 || (!this.length && !getValue(this, 'url')); }, shouldFetch: function(options) { return options.fetch && !!getValue(this, 'url') && !this.isPopulated(); }, fetch: function(options) { options = options || {}; var success = options.success; options.success = function(collection, response) { collection._fetched = true; success && success(collection, response); }; return _fetch.apply(this, arguments); }, set: function(models, options) { this._fetched = !!models; return _set.call(this, models, options); } }); _.extend(Thorax.View.prototype, { getCollectionViews: function(collection) { return _.filter(this.children, function(child) { if (!(child instanceof Thorax.CollectionView)) { return false; } return !collection || (child.collection === collection); }); }, updateFilter: function(collection) { _.invoke(this.getCollectionViews(collection), 'updateFilter'); } }); Thorax.Collections = {}; createRegistryWrapper(Thorax.Collection, Thorax.Collections); dataObject('collection', { set: 'setCollection', bindCallback: onSetCollection, defaultOptions: { render: undefined, // Default to deferred rendering fetch: true, success: false, invalid: true, change: true // Wether or not to re-render on model:change }, change: onCollectionReset, $el: 'getCollectionElement', cidAttrName: collectionCidAttributeName }); Thorax.CollectionView = Thorax.View.extend({ _defaultTemplate: Handlebars.VM.noop, _collectionSelector: '[' + collectionElementAttributeName + ']', // preserve collection element if it was not created with {{collection}} helper _replaceHTML: function(html) { if (this.collection && this.getObjectOptions(this.collection) && this._renderCount) { var element; var oldCollectionElement = this.getCollectionElement(); element = _replaceHTML.call(this, html); if (!oldCollectionElement.attr('data-view-cid')) { this.getCollectionElement().replaceWith(oldCollectionElement); } } else { return _replaceHTML.call(this, html); } }, render: function() { var shouldRender = this.shouldRender(); Thorax.View.prototype.render.apply(this, arguments); if (!shouldRender) { this.renderCollection(); } }, //appendItem(model [,index]) //appendItem(html_string, index) //appendItem(view, index) appendItem: function(model, index, options) { //empty item if (!model) { return; } var itemView, $el = this.getCollectionElement(), collection = this.collection; options = _.defaults(options || {}, { filter: true }); //if index argument is a view index && index.el && (index = $el.children().indexOf(index.el) + 1); //if argument is a view, or html string if (model.el || _.isString(model)) { itemView = model; model = false; } else { index = index || collection.indexOf(model) || 0; itemView = this.renderItem(model, index); } if (itemView) { if (itemView.cid) { itemView.ensureRendered(); this._addChild(itemView); } //if the renderer's output wasn't contained in a tag, wrap it in a div //plain text, or a mixture of top level text nodes and element nodes //will get wrapped if (_.isString(itemView) && !itemView.match(/^\s*' + itemView + ''; } var itemElement = itemView.$el || $($.trim(itemView)).filter(function() { //filter out top level whitespace nodes return this.nodeType === ELEMENT_NODE_TYPE; }); if (model) { itemElement.attr(modelCidAttributeName, model.cid); } var previousModel = index > 0 ? collection.at(index - 1) : false; if (!previousModel) { $el.prepend(itemElement); } else { //use last() as appendItem can accept multiple nodes from a template var last = $el.children('[' + modelCidAttributeName + '="' + previousModel.cid + '"]').last(); last.after(itemElement); } this.trigger('append', null, function(el) { el.setAttribute(modelCidAttributeName, model.cid); }); if (!options.silent) { this.trigger('rendered:item', this, collection, model, itemElement, index); } if (options.filter) { applyItemVisiblityFilter.call(this, model); } } return itemView; }, //Â updateItem only useful if there is no item view, otherwise //Â itemView.render() provides the same functionality updateItem: function(model) { var $el = this.getCollectionElement(), viewEl = $el.find('[' + modelCidAttributeName + '="' + model.cid + '"]'); // NOP For views if (viewEl.attr(viewCidAttributeName)) { return; } this.removeItem(viewEl); this.appendItem(model); }, removeItem: function(model) { var viewEl = model; if (model.cid) { var $el = this.getCollectionElement(); viewEl = $el.find('[' + modelCidAttributeName + '="' + model.cid + '"]'); } if (!viewEl.length) { return false; } var viewCids = viewEl.find('[' + viewCidAttributeName + ']').map(function(i, el) { return $(el).attr(viewCidAttributeName); }); viewEl.remove(); viewCids.push(viewEl.attr(viewCidAttributeName)); _.each(viewCids, function(cid) { var child = this.children[cid]; if (child) { this._removeChild(child); } }, this); return true; }, renderCollection: function() { if (this.collection) { if (this.collection.isEmpty()) { handleChangeFromNotEmptyToEmpty.call(this); } else { handleChangeFromEmptyToNotEmpty.call(this); this.collection.forEach(function(item, i) { this.appendItem(item, i); }, this); } this.trigger('rendered:collection', this, this.collection); } else { handleChangeFromNotEmptyToEmpty.call(this); } }, emptyClass: 'empty', renderEmpty: function() { if (!this.emptyView) { assignView.call(this, 'emptyView', { extension: '-empty' }); } if (!this.emptyTemplate && !this.emptyView) { assignTemplate.call(this, 'emptyTemplate', { extension: '-empty', required: false }); } if (this.emptyView) { var viewOptions = {}; if (this.emptyTemplate) { viewOptions.template = this.emptyTemplate; } var view = Thorax.Util.getViewInstance(this.emptyView, viewOptions); view.ensureRendered(); return view; } else { return this.emptyTemplate && this.renderTemplate(this.emptyTemplate); } }, renderItem: function(model, i) { if (!this.itemView) { assignView.call(this, 'itemView', { extension: '-item', required: false }); } if (!this.itemTemplate && !this.itemView) { assignTemplate.call(this, 'itemTemplate', { extension: '-item', // only require an itemTemplate if an itemView // is not present required: !this.itemView }); } if (this.itemView) { var viewOptions = { model: model }; if (this.itemTemplate) { viewOptions.template = this.itemTemplate; } return Thorax.Util.getViewInstance(this.itemView, viewOptions); } else { return this.renderTemplate(this.itemTemplate, this.itemContext(model, i)); } }, itemContext: function(model /*, i */) { return model.attributes; }, appendEmpty: function() { var $el = this.getCollectionElement(); $el.empty(); var emptyContent = this.renderEmpty(); emptyContent && this.appendItem(emptyContent, 0, { silent: true, filter: false }); this.trigger('rendered:empty', this, this.collection); }, getCollectionElement: function() { var element = this.$(this._collectionSelector); return element.length === 0 ? this.$el : element; }, updateFilter: function() { applyVisibilityFilter.call(this); } }); Thorax.CollectionView.on({ collection: { reset: onCollectionReset, sort: onCollectionReset, change: function(model) { var options = this.getObjectOptions(this.collection); if (options && options.change) { this.updateItem(model); } applyItemVisiblityFilter.call(this, model); }, add: function(model) { var $el = this.getCollectionElement(); if ($el.length) { if (this.collection.length === 1) { handleChangeFromEmptyToNotEmpty.call(this); } var index = this.collection.indexOf(model); this.appendItem(model, index); } }, remove: function(model) { var $el = this.getCollectionElement(); this.removeItem(model); this.collection.length === 0 && $el.length && handleChangeFromNotEmptyToEmpty.call(this); } } }); Thorax.View.on({ collection: { invalid: function(collection, message) { if (this.getObjectOptions(collection).invalid) { this.trigger('invalid', message, collection); } }, error: function(collection, resp, options) { this.trigger('error', resp, collection); } } }); function onCollectionReset(collection) { // Undefined to force conditional render var options = this.getObjectOptions(collection) || undefined; if (this.shouldRender(options && options.render)) { this.renderCollection && this.renderCollection(); } } // Even if the view is not a CollectionView // ensureRendered() to provide similar behavior // to a model function onSetCollection(collection) { // Undefined to force conditional render var options = this.getObjectOptions(collection) || undefined; if (this.shouldRender(options && options.render)) { // Ensure that something is there if we are going to render the collection. this.ensureRendered(); } } function applyVisibilityFilter() { if (this.itemFilter) { this.collection.forEach(applyItemVisiblityFilter, this); } } function applyItemVisiblityFilter(model) { var $el = this.getCollectionElement(); this.itemFilter && $el.find('[' + modelCidAttributeName + '="' + model.cid + '"]')[itemShouldBeVisible.call(this, model) ? 'show' : 'hide'](); } function itemShouldBeVisible(model) { return this.itemFilter(model, this.collection.indexOf(model)); } function handleChangeFromEmptyToNotEmpty() { var $el = this.getCollectionElement(); this.emptyClass && $el.removeClass(this.emptyClass); $el.removeAttr(collectionEmptyAttributeName); $el.empty(); } function handleChangeFromNotEmptyToEmpty() { var $el = this.getCollectionElement(); this.emptyClass && $el.addClass(this.emptyClass); $el.attr(collectionEmptyAttributeName, true); this.appendEmpty(); } //$(selector).collection() helper $.fn.collection = function(view) { if (view && view.collection) { return view.collection; } var $this = $(this), collectionElement = $this.closest('[' + collectionCidAttributeName + ']'), collectionCid = collectionElement && collectionElement.attr(collectionCidAttributeName); if (collectionCid) { view = $this.view(); if (view) { return view.collection; } } return false; }; ;; /*global inheritVars */ inheritVars.model.defaultOptions.populate = true; var oldModelChange = inheritVars.model.change; inheritVars.model.change = function(model, options) { this._isChanging = true; oldModelChange.apply(this, arguments); this._isChanging = false; if (options && options.serializing) { return; } var populate = populateOptions(this); if (this._renderCount && populate) { this.populate(!populate.context && this.model.attributes, populate); } }; _.extend(Thorax.View.prototype, { //serializes a form present in the view, returning the serialized data //as an object //pass {set:false} to not update this.model if present //can pass options, callback or event in any order serialize: function() { var callback, options, event; //ignore undefined arguments in case event was null for (var i = 0; i < arguments.length; ++i) { if (_.isFunction(arguments[i])) { callback = arguments[i]; } else if (_.isObject(arguments[i])) { if ('stopPropagation' in arguments[i] && 'preventDefault' in arguments[i]) { event = arguments[i]; } else { options = arguments[i]; } } } if (event && !this._preventDuplicateSubmission(event)) { return; } options = _.extend({ set: true, validate: true, children: true }, options || {}); var attributes = options.attributes || {}; //callback has context of element var view = this; var errors = []; eachNamedInput(this, options, function(element) { var value = view._getInputValue(element, options, errors); if (!_.isUndefined(value)) { objectAndKeyFromAttributesAndName(attributes, element.name, {mode: 'serialize'}, function(object, key) { if (!object[key]) { object[key] = value; } else if (_.isArray(object[key])) { object[key].push(value); } else { object[key] = [object[key], value]; } }); } }); if (!options._silent) { this.trigger('serialize', attributes, options); } if (options.validate) { var validateInputErrors = this.validateInput(attributes); if (validateInputErrors && validateInputErrors.length) { errors = errors.concat(validateInputErrors); } this.trigger('validate', attributes, errors, options); if (errors.length) { this.trigger('invalid', errors); return; } } if (options.set && this.model) { if (!this.model.set(attributes, {silent: options.silent, serializing: true})) { return false; } } callback && callback.call(this, attributes, _.bind(resetSubmitState, this)); return attributes; }, _preventDuplicateSubmission: function(event, callback) { event.preventDefault(); var form = $(event.target); if ((event.target.tagName || '').toLowerCase() !== 'form') { // Handle non-submit events by gating on the form form = $(event.target).closest('form'); } if (!form.attr('data-submit-wait')) { form.attr('data-submit-wait', 'true'); if (callback) { callback.call(this, event); } return true; } else { return false; } }, //populate a form from the passed attributes or this.model if present populate: function(attributes, options) { options = _.extend({ children: true }, options || {}); var value, attributes = attributes || this._getContext(); //callback has context of element eachNamedInput(this, options, function(element) { objectAndKeyFromAttributesAndName(attributes, element.name, {mode: 'populate'}, function(object, key) { value = object && object[key]; if (!_.isUndefined(value)) { //will only execute if we have a name that matches the structure in attributes var isBinary = element.type === 'checkbox' || element.type === 'radio'; if (isBinary && _.isBoolean(value)) { element.checked = value; } else if (isBinary) { element.checked = value == element.value; } else { element.value = value; } } }); }); ++this._populateCount; if (!options._silent) { this.trigger('populate', attributes); } }, //perform form validation, implemented by child class validateInput: function(/* attributes, options, errors */) {}, _getInputValue: function(input /* , options, errors */) { if (input.type === 'checkbox' || input.type === 'radio') { if (input.checked) { return input.getAttribute('value') || true; } } else if (input.multiple === true) { var values = []; $('option', input).each(function() { if (this.selected) { values.push(this.value); } }); return values; } else { return input.value; } }, _populateCount: 0 }); // Keeping state in the views Thorax.View.on({ 'before:rendered': function() { // Do not store previous options if we have not rendered or if we have changed the associated // model since the last render if (!this._renderCount || (this.model && this.model.cid) !== this._formModelCid) { return; } var modelOptions = this.getObjectOptions(this.model); // When we have previously populated and rendered the view, reuse the user data this.previousFormData = filterObject( this.serialize(_.extend({ set: false, validate: false, _silent: true }, modelOptions)), function(value) { return value !== '' && value != null; } ); }, rendered: function() { var populate = populateOptions(this); if (populate && !this._isChanging && !this._populateCount) { this.populate(!populate.context && this.model.attributes, populate); } if (this.previousFormData) { this.populate(this.previousFormData, _.extend({_silent: true}, populate)); } this._formModelCid = this.model && this.model.cid; this.previousFormData = null; } }); function filterObject(object, callback) { _.each(object, function (value, key) { if (_.isObject(value)) { return filterObject(value, callback); } if (callback(value, key, object) === false) { delete object[key]; } }); return object; } Thorax.View.on({ invalid: onErrorOrInvalidData, error: onErrorOrInvalidData, deactivated: function() { if (this.$el) { resetSubmitState.call(this); } } }); function onErrorOrInvalidData () { resetSubmitState.call(this); // If we errored with a model we want to reset the content but leave the UI // intact. If the user updates the data and serializes any overwritten data // will be restored. if (this.model && this.model.previousAttributes) { this.model.set(this.model.previousAttributes(), { silent: true }); } } function eachNamedInput(view, options, iterator) { var i = 0; $('select,input,textarea', options.root || view.el).each(function() { if (!options.children) { if (view !== $(this).view({helper: false})) { return; } } if (this.type !== 'button' && this.type !== 'cancel' && this.type !== 'submit' && this.name) { iterator(this, i); ++i; } }); } //calls a callback with the correct object fragment and key from a compound name function objectAndKeyFromAttributesAndName(attributes, name, options, callback) { var key, object = attributes, keys = name.split('['), mode = options.mode; for (var i = 0; i < keys.length - 1; ++i) { key = keys[i].replace(']', ''); if (!object[key]) { if (mode === 'serialize') { object[key] = {}; } else { return callback(undefined, key); } } object = object[key]; } key = keys[keys.length - 1].replace(']', ''); callback(object, key); } function resetSubmitState() { this.$('form').removeAttr('data-submit-wait'); } function populateOptions(view) { var modelOptions = view.getObjectOptions(view.model) || {}; return modelOptions.populate === true ? {} : modelOptions.populate; } ;; /*global getOptionsData, normalizeHTMLAttributeOptions, createErrorMessage */ var layoutCidAttributeName = 'data-layout-cid'; Thorax.LayoutView = Thorax.View.extend({ _defaultTemplate: Handlebars.VM.noop, render: function() { var response = Thorax.View.prototype.render.apply(this, arguments); if (this.template === Handlebars.VM.noop) { // if there is no template setView will append to this.$el ensureLayoutCid.call(this); } else { // if a template was specified is must declare a layout-element ensureLayoutViewsTargetElement.call(this); } return response; }, setView: function(view, options) { options = _.extend({ scroll: true }, options || {}); if (_.isString(view)) { view = new (Thorax.Util.registryGet(Thorax, 'Views', view, false))(); } this.ensureRendered(); var oldView = this._view, append, remove, complete; if (view === oldView) { return false; } this.trigger('change:view:start', view, oldView, options); remove = _.bind(function() { if (oldView) { oldView.$el && oldView.$el.remove(); triggerLifecycleEvent.call(oldView, 'deactivated', options); this._removeChild(oldView); } }, this); append = _.bind(function() { if (view) { view.ensureRendered(); triggerLifecycleEvent.call(this, 'activated', options); view.trigger('activated', options); this._view = view; var targetElement = getLayoutViewsTargetElement.call(this); this._view.appendTo(targetElement); this._addChild(view); } else { this._view = undefined; } }, this); complete = _.bind(function() { this.trigger('change:view:end', view, oldView, options); }, this); if (!options.transition) { remove(); append(); complete(); } else { options.transition(view, oldView, append, remove, complete); } return view; }, getView: function() { return this._view; } }); Handlebars.registerHelper('layout-element', function(options) { var view = getOptionsData(options).view; // duck type check for LayoutView if (!view.getView) { throw new Error(createErrorMessage('layout-element-helper')); } options.hash[layoutCidAttributeName] = view.cid; normalizeHTMLAttributeOptions(options.hash); return new Handlebars.SafeString(Thorax.Util.tag.call(this, options.hash, '', this)); }); function triggerLifecycleEvent(eventName, options) { options = options || {}; options.target = this; this.trigger(eventName, options); _.each(this.children, function(child) { child.trigger(eventName, options); }); } function ensureLayoutCid() { ++this._renderCount; //set the layoutCidAttributeName on this.$el if there was no template this.$el.attr(layoutCidAttributeName, this.cid); } function ensureLayoutViewsTargetElement() { if (!this.$('[' + layoutCidAttributeName + '="' + this.cid + '"]')[0]) { throw new Error('No layout element found in ' + (this.name || this.cid)); } } function getLayoutViewsTargetElement() { return this.$('[' + layoutCidAttributeName + '="' + this.cid + '"]')[0] || this.el[0] || this.el; } ;; /* global createErrorMessage */ Thorax.CollectionHelperView = Thorax.CollectionView.extend({ // Forward render events to the parent events: { 'rendered:item': forwardRenderEvent('rendered:item'), 'rendered:collection': forwardRenderEvent('rendered:collection'), 'rendered:empty': forwardRenderEvent('rendered:empty') }, // Thorax.CollectionView allows a collectionSelector // to be specified, disallow in a collection helper // as it will cause problems when neseted getCollectionElement: function() { return this.$el; }, constructor: function(options) { // need to fetch templates if template name was passed if (options.options['item-template']) { options.itemTemplate = Thorax.Util.getTemplate(options.options['item-template']); } if (options.options['empty-template']) { options.emptyTemplate = Thorax.Util.getTemplate(options.options['empty-template']); } // Handlebars.VM.noop is passed in the handlebars options object as // a default for fn and inverse, if a block was present. Need to // check to ensure we don't pick the empty / null block up. if (!options.itemTemplate && options.template && options.template !== Handlebars.VM.noop) { options.itemTemplate = options.template; options.template = Handlebars.VM.noop; } if (!options.emptyTemplate && options.inverse && options.inverse !== Handlebars.VM.noop) { options.emptyTemplate = options.inverse; options.inverse = Handlebars.VM.noop; } var shouldBindItemContext = _.isFunction(options.itemContext), shouldBindItemFilter = _.isFunction(options.itemFilter); var response = Thorax.HelperView.call(this, options); if (shouldBindItemContext) { this.itemContext = _.bind(this.itemContext, this.parent); } else if (_.isString(this.itemContext)) { this.itemContext = _.bind(this.parent[this.itemContext], this.parent); } if (shouldBindItemFilter) { this.itemFilter = _.bind(this.itemFilter, this.parent); } else if (_.isString(this.itemFilter)) { this.itemFilter = _.bind(this.parent[this.itemFilter], this.parent); } if (this.parent.name) { if (!this.emptyView && !this.parent.renderEmpty) { this.emptyView = Thorax.Util.getViewClass(this.parent.name + '-empty', true); } if (!this.emptyTemplate && !this.parent.renderEmpty) { this.emptyTemplate = Thorax.Util.getTemplate(this.parent.name + '-empty', true); } if (!this.itemView && !this.parent.renderItem) { this.itemView = Thorax.Util.getViewClass(this.parent.name + '-item', true); } if (!this.itemTemplate && !this.parent.renderItem) { // item template must be present if an itemView is not this.itemTemplate = Thorax.Util.getTemplate(this.parent.name + '-item', !!this.itemView); } } return response; }, setAsPrimaryCollectionHelper: function() { _.each(forwardableProperties, function(propertyName) { forwardMissingProperty.call(this, propertyName); }, this); var self = this; _.each(['itemFilter', 'itemContext', 'renderItem', 'renderEmpty'], function(propertyName) { if (self.parent[propertyName]) { self[propertyName] = function() { return self.parent[propertyName].apply(self.parent, arguments); }; } }); } }); _.extend(Thorax.CollectionHelperView.prototype, helperViewPrototype); Thorax.CollectionHelperView.attributeWhiteList = { 'item-context': 'itemContext', 'item-filter': 'itemFilter', 'item-template': 'itemTemplate', 'empty-template': 'emptyTemplate', 'item-view': 'itemView', 'empty-view': 'emptyView', 'empty-class': 'emptyClass' }; function forwardRenderEvent(eventName) { return function() { var args = _.toArray(arguments); args.unshift(eventName); this.parent.trigger.apply(this.parent, args); }; } var forwardableProperties = [ 'itemTemplate', 'itemView', 'emptyTemplate', 'emptyView' ]; function forwardMissingProperty(propertyName) { var parent = getParent(this); if (!this[propertyName]) { var prop = parent[propertyName]; if (prop){ this[propertyName] = prop; } } } Handlebars.registerViewHelper('collection', Thorax.CollectionHelperView, function(collection, view) { if (arguments.length === 1) { view = collection; collection = view.parent.collection; collection && view.setAsPrimaryCollectionHelper(); view.$el.attr(collectionElementAttributeName, 'true'); // propagate future changes to the parent's collection object // to the helper view view.listenTo(view.parent, 'change:data-object', function(type, dataObject) { if (type === 'collection') { view.setAsPrimaryCollectionHelper(); view.setCollection(dataObject); } }); } collection && view.setCollection(collection); }); Handlebars.registerHelper('collection-element', function(options) { if (!getOptionsData(options).view.renderCollection) { throw new Error(createErrorMessage('collection-element-helper')); } var hash = options.hash; normalizeHTMLAttributeOptions(hash); hash.tagName = hash.tagName || 'div'; hash[collectionElementAttributeName] = true; return new Handlebars.SafeString(Thorax.Util.tag.call(this, hash, '', this)); }); ;; Handlebars.registerHelper('empty', function(dataObject, options) { if (arguments.length === 1) { options = dataObject; } var view = getOptionsData(options).view; if (arguments.length === 1) { dataObject = view.model; } // listeners for the empty helper rather than listeners // that are themselves empty if (!view._emptyListeners) { view._emptyListeners = {}; } // duck type check for collection if (dataObject && !view._emptyListeners[dataObject.cid] && dataObject.models && ('length' in dataObject)) { view._emptyListeners[dataObject.cid] = true; view.listenTo(dataObject, 'remove', function() { if (dataObject.length === 0) { view.render(); } }); view.listenTo(dataObject, 'add', function() { if (dataObject.length === 1) { view.render(); } }); view.listenTo(dataObject, 'reset', function() { view.render(); }); } return !dataObject || dataObject.isEmpty() ? options.fn(this) : options.inverse(this); }); ;; Handlebars.registerHelper('template', function(name, options) { var context = _.extend({fn: options && options.fn}, this, options ? options.hash : {}); var output = getOptionsData(options).view.renderTemplate(name, context); return new Handlebars.SafeString(output); }); Handlebars.registerHelper('yield', function(options) { return getOptionsData(options).yield && options.data.yield(); }); ;; Handlebars.registerHelper('url', function(url) { url = url || ''; var fragment; if (arguments.length > 2) { fragment = _.map(_.head(arguments, arguments.length - 1), encodeURIComponent).join('/'); } else { var options = arguments[1], hash = (options && options.hash) || options; if (hash && hash['expand-tokens']) { fragment = Thorax.Util.expandToken(url, this); } else { fragment = url; } } if (Backbone.history._hasPushState) { var root = Backbone.history.options.root; if (root === '/' && fragment.substr(0, 1) === '/') { return fragment; } else { return root + fragment; } } else { return '#' + fragment; } }); ;; /*global viewTemplateOverrides, createErrorMessage */ Handlebars.registerViewHelper('view', { factory: function(args, options) { var View = args.length >= 1 ? args[0] : Thorax.View; return Thorax.Util.getViewInstance(View, options.options); }, // ensure generated placeholder tag in template // will match tag of view instance modifyHTMLAttributes: function(htmlAttributes, instance) { htmlAttributes.tagName = instance.el.tagName.toLowerCase(); }, callback: function(view) { var instance = arguments[arguments.length-1], options = instance._helperOptions.options, placeholderId = instance.cid; // view will be the argument passed to the helper, if it was // a string, a new instance was created on the fly, ok to pass // hash arguments, otherwise need to throw as templates should // not introduce side effects to existing view instances if (!_.isString(view) && options.hash && _.keys(options.hash).length > 0) { throw new Error(createErrorMessage('view-helper-hash-args')); } if (options.fn) { viewTemplateOverrides[placeholderId] = options.fn; } } }); ;; /* global createErrorMessage */ var callMethodAttributeName = 'data-call-method', triggerEventAttributeName = 'data-trigger-event'; Handlebars.registerHelper('button', function(method, options) { if (arguments.length === 1) { options = method; method = options.hash.method; } var hash = options.hash, expandTokens = hash['expand-tokens']; delete hash['expand-tokens']; if (!method && !options.hash.trigger) { throw new Error(createErrorMessage('button-trigger')); } normalizeHTMLAttributeOptions(hash); hash.tagName = hash.tagName || 'button'; hash.trigger && (hash[triggerEventAttributeName] = hash.trigger); delete hash.trigger; method && (hash[callMethodAttributeName] = method); return new Handlebars.SafeString(Thorax.Util.tag(hash, options.fn ? options.fn(this) : '', expandTokens ? this : null)); }); Handlebars.registerHelper('link', function() { var args = _.toArray(arguments), options = args.pop(), hash = options.hash, // url is an array that will be passed to the url helper url = args.length === 0 ? [hash.href] : args, expandTokens = hash['expand-tokens']; delete hash['expand-tokens']; if (!url[0] && url[0] !== '') { throw new Error(createErrorMessage('link-href')); } normalizeHTMLAttributeOptions(hash); url.push(options); hash.href = Handlebars.helpers.url.apply(this, url); hash.tagName = hash.tagName || 'a'; hash.trigger && (hash[triggerEventAttributeName] = options.hash.trigger); delete hash.trigger; hash[callMethodAttributeName] = '_anchorClick'; return new Handlebars.SafeString(Thorax.Util.tag(hash, options.fn ? options.fn(this) : '', expandTokens ? this : null)); }); var clickSelector = '[' + callMethodAttributeName + '], [' + triggerEventAttributeName + ']'; function handleClick(event) { var $this = $(this), view = $this.view({helper: false}), methodName = $this.attr(callMethodAttributeName), eventName = $this.attr(triggerEventAttributeName), methodResponse = false; methodName && (methodResponse = view[methodName].call(view, event)); eventName && view.trigger(eventName, event); this.tagName === 'A' && methodResponse === false && event.preventDefault(); } var lastClickHandlerEventName; function registerClickHandler() { unregisterClickHandler(); lastClickHandlerEventName = Thorax._fastClickEventName || 'click'; $(document).on(lastClickHandlerEventName, clickSelector, handleClick); } function unregisterClickHandler() { lastClickHandlerEventName && $(document).off(lastClickHandlerEventName, clickSelector, handleClick); } $(document).ready(function() { if (!Thorax._fastClickEventName) { registerClickHandler(); } }); ;; var elementPlaceholderAttributeName = 'data-element-tmp'; Handlebars.registerHelper('element', function(element, options) { normalizeHTMLAttributeOptions(options.hash); var cid = _.uniqueId('element'), declaringView = getOptionsData(options).view; options.hash[elementPlaceholderAttributeName] = cid; declaringView._elementsByCid || (declaringView._elementsByCid = {}); declaringView._elementsByCid[cid] = element; return new Handlebars.SafeString(Thorax.Util.tag(options.hash)); }); Thorax.View.on('append', function(scope, callback) { (scope || this.$el).find('[' + elementPlaceholderAttributeName + ']').forEach(function(el) { var $el = $(el), cid = $el.attr(elementPlaceholderAttributeName), element = this._elementsByCid[cid]; // A callback function may be specified as the value if (_.isFunction(element)) { element = element.call(this); } $el.replaceWith(element); callback && callback(element); }, this); }); ;; /* global createErrorMessage */ Handlebars.registerHelper('super', function(options) { var declaringView = getOptionsData(options).view, parent = declaringView.constructor && declaringView.constructor.__super__; if (parent) { var template = parent.template; if (!template) { if (!parent.name) { throw new Error(createErrorMessage('super-parent')); } template = parent.name; } if (_.isString(template)) { template = Thorax.Util.getTemplate(template, false); } return new Handlebars.SafeString(template(this, options)); } else { return ''; } }); ;; /*global collectionOptionNames, inheritVars, createErrorMessage */ var loadStart = 'load:start', loadEnd = 'load:end', rootObject; Thorax.setRootObject = function(obj) { rootObject = obj; }; Thorax.loadHandler = function(start, end, context) { var loadCounter = _.uniqueId('load'); return function(message, background, object) { var self = context || this; self._loadInfo = self._loadInfo || {}; var loadInfo = self._loadInfo[loadCounter]; function startLoadTimeout() { // If the timeout has been set already but has not triggered yet do nothing // Otherwise set a new timeout (either initial or for going from background to // non-background loading) if (loadInfo.timeout && !loadInfo.run) { return; } var loadingTimeout = self._loadingTimeoutDuration !== undefined ? self._loadingTimeoutDuration : Thorax.View.prototype._loadingTimeoutDuration; loadInfo.timeout = setTimeout(function() { try { // We have a slight race condtion in here where the end event may have occurred // but the end timeout has not executed. Rather than killing a cumulative timeout // immediately we'll protect from that case here if (loadInfo.events.length) { loadInfo.run = true; start.call(self, loadInfo.message, loadInfo.background, loadInfo); } } catch (e) { Thorax.onException('loadStart', e); } }, loadingTimeout * 1000); } if (!loadInfo) { loadInfo = self._loadInfo[loadCounter] = _.extend({ isLoading: function() { return loadInfo.events.length; }, cid: loadCounter, events: [], timeout: 0, message: message, background: !!background }, Backbone.Events); startLoadTimeout(); } else { clearTimeout(loadInfo.endTimeout); loadInfo.message = message; if (!background && loadInfo.background) { loadInfo.background = false; startLoadTimeout(); } } // Prevent binds to the same object multiple times as this can cause very bad things // to happen for the load;load;end;end execution flow. if (_.indexOf(loadInfo.events, object) >= 0) { return; } loadInfo.events.push(object); object.on(loadEnd, function endCallback() { var loadingEndTimeout = self._loadingTimeoutEndDuration; if (loadingEndTimeout === void 0) { // If we are running on a non-view object pull the default timeout loadingEndTimeout = Thorax.View.prototype._loadingTimeoutEndDuration; } var events = loadInfo.events, index = _.indexOf(events, object); if (index >= 0 && !object.isLoading()) { events.splice(index, 1); if (_.indexOf(events, object) < 0) { // Last callback for this particlar object, remove the bind object.off(loadEnd, endCallback); } } if (!events.length) { clearTimeout(loadInfo.endTimeout); loadInfo.endTimeout = setTimeout(function() { try { if (!events.length) { if (loadInfo.run) { // Emit the end behavior, but only if there is a paired start end && end.call(self, loadInfo.background, loadInfo); loadInfo.trigger(loadEnd, loadInfo); } // If stopping make sure we don't run a start clearTimeout(loadInfo.timeout); loadInfo = self._loadInfo[loadCounter] = undefined; } } catch (e) { Thorax.onException('loadEnd', e); } }, loadingEndTimeout * 1000); } }); }; }; /** * Helper method for propagating load:start events to other objects. * * Forwards load:start events that occur on `source` to `dest`. */ Thorax.forwardLoadEvents = function(source, dest, once) { function load(message, backgound, object) { if (once) { source.off(loadStart, load); } dest.trigger(loadStart, message, backgound, object); } source.on(loadStart, load); return { off: function() { source.off(loadStart, load); } }; }; // // Data load event generation // /** * Mixing for generating load:start and load:end events. */ Thorax.mixinLoadable = function(target, useParent) { _.extend(target, { //loading config _loadingClassName: 'loading', _loadingTimeoutDuration: 0.33, _loadingTimeoutEndDuration: 0.10, // Propagates loading view parameters to the AJAX layer onLoadStart: function(message, background, object) { var that = useParent ? this.parent : this; // Protect against race conditions if (!that || !that.el) { return; } if (!that.nonBlockingLoad && !background && rootObject && rootObject !== this) { rootObject.trigger(loadStart, message, background, object); } that._isLoading = true; $(that.el).addClass(that._loadingClassName); // used by loading helpers that.trigger('change:load-state', 'start', background); }, onLoadEnd: function(/* background, object */) { var that = useParent ? this.parent : this; // Protect against race conditions if (!that || !that.el) { return; } that._isLoading = false; $(that.el).removeClass(that._loadingClassName); // used by loading helper that.trigger('change:load-state', 'end'); } }); }; Thorax.mixinLoadableEvents = function(target, useParent) { _.extend(target, { _loadCount: 0, isLoading: function() { return this._loadCount > 0; }, loadStart: function(message, background) { this._loadCount++; var that = useParent ? this.parent : this; that.trigger(loadStart, message, background, that); }, loadEnd: function() { this._loadCount--; var that = useParent ? this.parent : this; that.trigger(loadEnd, that); } }); }; Thorax.mixinLoadable(Thorax.View.prototype); Thorax.mixinLoadableEvents(Thorax.View.prototype); if (Thorax.HelperView) { Thorax.mixinLoadable(Thorax.HelperView.prototype, true); Thorax.mixinLoadableEvents(Thorax.HelperView.prototype, true); } if (Thorax.CollectionHelperView) { Thorax.mixinLoadable(Thorax.CollectionHelperView.prototype, true); Thorax.mixinLoadableEvents(Thorax.CollectionHelperView.prototype, true); } Thorax.sync = function(method, dataObj, options) { var self = this, complete = options.complete; options.complete = function() { self._request = undefined; self._aborted = false; complete && complete.apply(this, arguments); }; this._request = Backbone.sync.apply(this, arguments); return this._request; }; function bindToRoute(callback, failback) { var fragment = Backbone.history.getFragment(), routeChanged = false; function routeHandler() { if (fragment === Backbone.history.getFragment()) { return; } routeChanged = true; res.cancel(); failback && failback(); } Backbone.history.on('route', routeHandler); function finalizer() { Backbone.history.off('route', routeHandler); if (!routeChanged) { callback.apply(this, arguments); } } var res = _.bind(finalizer, this); res.cancel = function() { Backbone.history.off('route', routeHandler); }; return res; } function loadData(callback, failback, options) { if (this.isPopulated()) { // Defer here to maintain async callback behavior for all loading cases return _.defer(callback, this); } if (arguments.length === 2 && !_.isFunction(failback) && _.isObject(failback)) { options = failback; failback = false; } var self = this, routeChanged = false, successCallback = bindToRoute(_.bind(callback, self), function() { routeChanged = true; if (self._request) { self._aborted = true; self._request.abort(); } failback && failback.call(self, false); }); this.fetch(_.defaults({ success: successCallback, error: function() { successCallback.cancel(); if (!routeChanged && failback) { failback.apply(self, [true].concat(_.toArray(arguments))); } } }, options)); } function fetchQueue(options, $super) { if (options.resetQueue) { // WARN: Should ensure that loaders are protected from out of band data // when using this option this.fetchQueue = undefined; } else if (this.fetchQueue) { // concurrent set/reset fetch events are not advised var reset = (this.fetchQueue[0] || {}).reset; if (reset !== options.reset) { // fetch with concurrent set & reset not allowed throw new Error(createErrorMessage('mixed-fetch')); } } if (!this.fetchQueue) { // Kick off the request this.fetchQueue = [options]; options = _.defaults({ success: flushQueue(this, this.fetchQueue, 'success'), error: flushQueue(this, this.fetchQueue, 'error'), complete: flushQueue(this, this.fetchQueue, 'complete') }, options); // Handle callers that do not pass in a super class and wish to implement their own // fetch behavior if ($super) { $super.call(this, options); } return options; } else { // Currently fetching. Queue and process once complete this.fetchQueue.push(options); } } function flushQueue(self, fetchQueue, handler) { return function() { var args = arguments; // Flush the queue. Executes any callback handlers that // may have been passed in the fetch options. _.each(fetchQueue, function(options) { if (options[handler]) { options[handler].apply(this, args); } }, this); // Reset the queue if we are still the active request if (self.fetchQueue === fetchQueue) { self.fetchQueue = undefined; } }; } var klasses = []; Thorax.Model && klasses.push(Thorax.Model); Thorax.Collection && klasses.push(Thorax.Collection); _.each(klasses, function(DataClass) { var $fetch = DataClass.prototype.fetch; Thorax.mixinLoadableEvents(DataClass.prototype, false); _.extend(DataClass.prototype, { sync: Thorax.sync, fetch: function(options) { options = options || {}; if (DataClass === Thorax.Collection) { if (!_.find(['reset', 'remove', 'add', 'update'], function(key) { return !_.isUndefined(options[key]); })) { // use backbone < 1.0 behavior to allow triggering of reset events options.reset = true; } } if (!options.loadTriggered) { var self = this; function endWrapper(method) { var $super = options[method]; options[method] = function() { self.loadEnd(); $super && $super.apply(this, arguments); }; } endWrapper('success'); endWrapper('error'); self.loadStart(undefined, options.background); } return fetchQueue.call(this, options || {}, $fetch); }, load: function(callback, failback, options) { if (arguments.length === 2 && !_.isFunction(failback)) { options = failback; failback = false; } options = options || {}; if (!options.background && !this.isPopulated() && rootObject) { // Make sure that the global scope sees the proper load events here // if we are loading in standalone mode if (this.isLoading()) { // trigger directly because load:start has already been triggered rootObject.trigger(loadStart, options.message, options.background, this); } else { Thorax.forwardLoadEvents(this, rootObject, true); } } loadData.call(this, callback, failback, options); } }); }); Thorax.Util.bindToRoute = bindToRoute; // Propagates loading view parameters to the AJAX layer Thorax.View.prototype._modifyDataObjectOptions = function(dataObject, options) { options.ignoreErrors = this.ignoreFetchError; options.background = this.nonBlockingLoad; return options; }; // Thorax.CollectionHelperView inherits from CollectionView // not HelperView so need to set it manually Thorax.HelperView.prototype._modifyDataObjectOptions = Thorax.CollectionHelperView.prototype._modifyDataObjectOptions = function(dataObject, options) { options.ignoreErrors = this.parent.ignoreFetchError; options.background = this.parent.nonBlockingLoad; return options; }; inheritVars.collection.loading = function() { var loadingView = this.loadingView, loadingTemplate = this.loadingTemplate, loadingPlacement = this.loadingPlacement; //add "loading-view" and "loading-template" options to collection helper if (loadingView || loadingTemplate) { var callback = Thorax.loadHandler(_.bind(function() { var item; if (this.collection.length === 0) { this.$el.empty(); } if (loadingView) { var instance = Thorax.Util.getViewInstance(loadingView); this._addChild(instance); if (loadingTemplate) { instance.render(loadingTemplate); } else { instance.render(); } item = instance; } else { item = this.renderTemplate(loadingTemplate); } var index = loadingPlacement ? loadingPlacement.call(this) : this.collection.length ; this.appendItem(item, index); this.$el.children().eq(index).attr('data-loading-element', this.collection.cid); }, this), _.bind(function() { this.$el.find('[data-loading-element="' + this.collection.cid + '"]').remove(); }, this), this.collection); this.listenTo(this.collection, 'load:start', callback); } }; if (Thorax.CollectionHelperView) { _.extend(Thorax.CollectionHelperView.attributeWhiteList, { 'loading-template': 'loadingTemplate', 'loading-view': 'loadingView', 'loading-placement': 'loadingPlacement' }); } Thorax.View.on({ 'load:start': Thorax.loadHandler( function(message, background, object) { this.onLoadStart(message, background, object); }, function(background, object) { this.onLoadEnd(object); }), collection: { 'load:start': function(message, background, object) { this.trigger(loadStart, message, background, object); } }, model: { 'load:start': function(message, background, object) { this.trigger(loadStart, message, background, object); } } }); ;; Handlebars.registerHelper('loading', function(options) { var view = getOptionsData(options).view; view.off('change:load-state', onLoadStateChange, view); view.on('change:load-state', onLoadStateChange, view); return view._isLoading ? options.fn(this) : options.inverse(this); }); function onLoadStateChange() { this.render(); } ;; /*global pushDomEvents */ var isiOS = navigator.userAgent.match(/(iPhone|iPod|iPad)/i), isAndroid = navigator.userAgent.toLowerCase().indexOf("android") > -1 ? 1 : 0, minimumScrollYOffset = isAndroid ? 1 : 0; Thorax.Util.scrollTo = function(x, y) { y = y || minimumScrollYOffset; function _scrollTo() { window.scrollTo(x, y); } if (isiOS) { // a defer is required for ios _.defer(_scrollTo); } else { _scrollTo(); } return [x, y]; }; Thorax.LayoutView.on('change:view:end', function(newView, oldView, options) { options && options.scroll && Thorax.Util.scrollTo(0, 0); }); Thorax.Util.scrollToTop = function() { // android will use height of 1 because of minimumScrollYOffset in scrollTo() return this.scrollTo(0, 0); }; pushDomEvents([ 'singleTap', 'doubleTap', 'longTap', 'swipe', 'swipeUp', 'swipeDown', 'swipeLeft', 'swipeRight' ]); //built in dom events Thorax.View.on({ 'submit form': function(/* event */) { // Hide any virtual keyboards that may be lingering around var focused = $(':focus')[0]; focused && focused.blur(); } }); ;; /*global isAndroid */ // This doesn't work on HTC devices with Android 4.0. // Not much can be done about it as it seems to be a browser bug // (it doesn't update visual styling while you hold your finger on the screen) $.fn.tapHoldAndEnd = function(selector, callbackStart, callbackEnd) { return this.each(function() { var tapHoldStart, timer, target; function clearTapTimer(event) { clearTimeout(timer); if (tapHoldStart && target) { callbackEnd(target); } target = undefined; tapHoldStart = false; } $(this).on('touchstart', selector, function(event) { clearTapTimer(); target = event.currentTarget; timer = setTimeout(function() { tapHoldStart = true; callbackStart(target); }, 50); }) .on('touchmove touchend', clearTapTimer); $(document).on('touchcancel', clearTapTimer); }); }; //only enable on android var useNativeHighlight = !isAndroid; Thorax.configureTapHighlight = function(useNative, highlightClass) { useNativeHighlight = useNative; highlightClass = highlightClass || 'tap-highlight'; if (!useNative) { function _tapHighlightStart(target) { var tagName = target && target.tagName.toLowerCase(); // User input controls may be visually part of a larger group. For these cases // we want to give priority to any parent that may provide a focus operation. if (tagName === 'input' || tagName === 'select' || tagName === 'textarea') { target = $(target).closest('[data-tappable=true]')[0] || target; } if (target) { $(target).addClass(highlightClass); return false; } } function _tapHighlightEnd() { $('.' + highlightClass).removeClass(highlightClass); } $(document.body).tapHoldAndEnd( '[data-tappable=true], a, input, button, select, textarea', _tapHighlightStart, _tapHighlightEnd); } }; var NATIVE_TAPPABLE = { 'A': true, 'INPUT': true, 'BUTTON': true, 'SELECT': true, 'TEXTAREA': true }; // Out here so we do not retain a scope function NOP(){} function fixupTapHighlight() { _.each(this._domEvents || [], function(bind) { var components = bind.split(' '), selector = components.slice(1).join(' ') || undefined; // Needed to make zepto happy if (components[0] === 'click') { // !selector case is for root click handlers on the view, i.e. 'click' $(selector || this.el, selector && this.el).forEach(function(el) { var $el = $(el).attr('data-tappable', true); if (useNativeHighlight && !NATIVE_TAPPABLE[el.tagName]) { // Add an explicit NOP bind to allow tap-highlight support $el.on('click', NOP); } }); } }, this); } Thorax.View.on({ 'rendered': fixupTapHighlight, 'rendered:collection': fixupTapHighlight, 'rendered:item': fixupTapHighlight, 'rendered:empty': fixupTapHighlight }); var _addEvent = Thorax.View.prototype._addEvent; Thorax.View.prototype._addEvent = function(params) { this._domEvents = this._domEvents || []; if (params.type === "DOM") { this._domEvents.push(params.originalName); } return _addEvent.call(this, params); }; ;; })();