PageRenderTime 10ms CodeModel.GetById 3ms app.highlight 123ms RepoModel.GetById 1ms app.codeStats 1ms

/thorax-lumbar-client/src/main/thorax/js/lib/thorax.js

https://github.com/gigfork/spring-mobile-samples
JavaScript | 1282 lines | 1049 code | 149 blank | 84 comment | 298 complexity | f04c5bfe1006020a86d2fbc146543163 MD5 | raw file
   1(function(outerScope){
   2  if (typeof this.$ === 'undefined') {
   3    throw new Error('jquery.js/zepto.js required to run Thorax');
   4  } else {
   5    if (!$.fn.forEach) {
   6      // support jquery/zepto iterators
   7      $.fn.forEach = $.fn.each;
   8     }
   9  }
  10
  11  if (typeof this._ === 'undefined') {
  12    throw new Error('Underscore.js required to run Thorax');
  13  }
  14
  15  if (typeof this.Backbone === 'undefined') {
  16    throw new Error('Backbone.js required to run Thorax');
  17  }
  18
  19  var TemplateEngine = {
  20    extension: 'handlebars',
  21    safeString: function(string) {
  22      return new Handlebars.SafeString(string);
  23    },
  24    registerHelper: function(name, callback) {
  25      return Handlebars.registerHelper(name, callback);
  26    }
  27  };
  28  TemplateEngine.extensionRegExp = new RegExp('\\.' + TemplateEngine.extension + '$');
  29
  30  var Thorax, scope, templatePathPrefix;
  31
  32  this.Thorax = Thorax = {
  33    configure: function(options) {
  34      scope = (options && options.scope) || (typeof exports !== 'undefined' && exports);
  35
  36      if (!scope) {
  37        scope = outerScope.Application = {};
  38      }
  39
  40      _.extend(scope, Backbone.Events, {
  41        templates: {},
  42        Views: {},
  43        Mixins: {},
  44        Models: {},
  45        Collections: {},
  46        Routers: {}
  47      });
  48
  49      templatePathPrefix = options && typeof options.templatePathPrefix !== 'undefined' ? options.templatePathPrefix : '';
  50      
  51      Backbone.history || (Backbone.history = new Backbone.History);
  52
  53      scope.layout = new Thorax.Layout({
  54        el: options && options.layout || '.layout'
  55      });
  56
  57    },
  58    //used by "template" and "view" template helpers, not thread safe though it shouldn't matter in browser land
  59    _currentTemplateContext: false
  60  };
  61
  62  //private vars for Thorax.View
  63  var view_name_attribute_name = 'data-view-name',
  64      view_cid_attribute_name = 'data-view-cid',
  65      view_placeholder_attribute_name = 'data-view-tmp',
  66      model_cid_attribute_name = 'data-model-cid',
  67      collection_cid_attribute_name = 'data-collection-cid',
  68      default_collection_selector = '[' + collection_cid_attribute_name + ']',
  69      old_backbone_view = Backbone.View,
  70      //android scrollTo(0, 0) shows url bar, scrollTo(0, 1) hides it
  71      minimumScrollYOffset = (navigator.userAgent.toLowerCase().indexOf("android") > -1) ? 1 : 0,
  72      ELEMENT_NODE_TYPE = 1;
  73
  74  //wrap Backbone.View constructor to support initialize event
  75  Backbone.View = function(options) {
  76    this._childEvents = [];
  77    this.cid = _.uniqueId('view');
  78    this._configure(options || {});
  79    this._ensureElement();
  80    this.delegateEvents();
  81    this.trigger('initialize:before', options);
  82    this.initialize.apply(this, arguments);
  83    this.trigger('initialize:after', options);
  84  };
  85
  86  Backbone.View.prototype = old_backbone_view.prototype;
  87  Backbone.View.extend = old_backbone_view.extend;
  88
  89  Thorax.View = Backbone.View.extend({
  90    _configure: function(options) {
  91      //this.options is removed in Thorax.View, we merge passed
  92      //properties directly with the view and template context
  93      _.extend(this, options || {});
  94            
  95      //will be called again by Backbone.View(), after _configure() is complete but safe to call twice
  96      this._ensureElement();
  97
  98      //model and collection events
  99      bindModelAndCollectionEvents.call(this, this.constructor.events);
 100      if (this.events) {
 101        bindModelAndCollectionEvents.call(this, this.events);
 102      }
 103
 104      //mixins
 105      for (var i = 0; i < this.constructor.mixins.length; ++i) {
 106        applyMixin.call(this, this.constructor.mixins[i]);
 107      }
 108      if (this.mixins) {
 109        for (var i = 0; i < this.mixins.length; ++i) {
 110          applyMixin.call(this, this.mixins[i]);
 111        }
 112      }
 113
 114      //views
 115      this._views = {};
 116      if (this.views) {
 117        for (var local_name in this.views) {
 118          if (_.isArray(this.views[local_name])) {
 119            this[local_name] = this.view.apply(this, this.views[local_name]);
 120          } else {
 121            this[local_name] = this.view(this.views[local_name]);
 122          }
 123        }
 124      }
 125    },
 126
 127    _ensureElement : function() {
 128      Backbone.View.prototype._ensureElement.call(this);
 129      (this.el[0] || this.el).setAttribute(view_name_attribute_name, this.name || this.cid);
 130      (this.el[0] || this.el).setAttribute(view_cid_attribute_name, this.cid);      
 131    },
 132
 133    mixin: function(name) {
 134      if (!this._appliedMixins) {
 135        this._appliedMixins = [];
 136      }
 137      if (this._appliedMixins.indexOf(name) == -1) {
 138        this._appliedMixins.push(name);
 139        if (typeof name === 'function') {
 140          name.call(this);
 141        } else {
 142          var mixin = scope.Mixins[name];
 143          _.extend(this, mixin[1]);
 144          //mixin callback may be an array of [callback, arguments]
 145          if (_.isArray(mixin[0])) {
 146            mixin[0][0].apply(this, mixin[0][1]);
 147          } else {
 148            mixin[0].apply(this, _.toArray(arguments).slice(1));
 149          }
 150        }
 151      }
 152    },
 153  
 154    view: function(name, options) {
 155      var instance;
 156      if (typeof name === 'object' && name.hash && name.hash.name) {
 157        // named parameters
 158        options = name.hash;
 159        name = name.hash.name;
 160        delete options.name;
 161      }
 162
 163      if (typeof name === 'string') {
 164        if (!scope.Views[name]) {
 165          throw new Error('view: ' + name + ' does not exist.');
 166        }
 167        instance = new scope.Views[name](options);
 168      } else {
 169        instance = name;
 170      }
 171      this._views[instance.cid] = instance;
 172      this._childEvents.forEach(function(params) {
 173        params = _.clone(params);
 174        if (!params.parent) {
 175          params.parent = this;
 176        }
 177        instance._addEvent(params);
 178      }, this);
 179      return instance;
 180    },
 181    
 182    template: function(file, data, ignoreErrors) {
 183      Thorax._currentTemplateContext = this;
 184      
 185      var view_context = {};
 186      for (var key in this) {
 187        if (typeof this[key] !== 'function') {
 188          view_context[key] = this[key];
 189        }
 190      }
 191      data = _.extend({}, view_context, data || {}, {
 192        cid: _.uniqueId('t')
 193      });
 194
 195      var template = this.loadTemplate(file, data, scope);
 196      if (!template) {
 197        if (ignoreErrors) {
 198          return ''
 199        } else {
 200          throw new Error('Unable to find template ' + file);
 201        }
 202      } else {
 203        return template(data);
 204      }
 205    },
 206
 207    loadTemplate: function(file, data, scope) {
 208      var fileName = templatePathPrefix + file + (file.match(TemplateEngine.extensionRegExp) ? '' : '.' + TemplateEngine.extension);
 209      return scope.templates[fileName];
 210    },
 211  
 212    html: function(html) {
 213      if (typeof html === 'undefined') {
 214        return this.el.innerHTML;
 215      } else {
 216        var element;
 217        if (this._collectionOptions && this._renderCount) {
 218          //preserveCollectionElement calls the callback after it has a reference
 219          //to the collection element, calls the callback, then re-appends the element
 220          preserveCollectionElement.call(this, function() {
 221            element = $(this.el).html(html);
 222          });
 223        } else {
 224          element = $(this.el).html(html);
 225        }
 226        appendViews.call(this);
 227        return element;
 228      }
 229    },
 230  
 231    //allow events hash to specify view, collection and model events
 232    //as well as DOM events. Merges Thorax.View.events with this.events
 233    delegateEvents: function(events) {
 234      this.undelegateEvents && this.undelegateEvents();
 235      //bindModelAndCollectionEvents on this.constructor.events and this.events
 236      //done in _configure
 237      this.registerEvents(this.constructor.events);
 238      if (this.events) {
 239        this.registerEvents(this.events);
 240      }
 241      if (events) {
 242        this.registerEvents(events);
 243        bindModelAndCollectionEvents.call(this, events);
 244      }
 245    },
 246
 247    registerEvents: function(events) {
 248      processEvents.call(this, events).forEach(this._addEvent, this);
 249    },
 250
 251    //params may contain:
 252    //- name
 253    //- originalName
 254    //- selector
 255    //- type "view" || "DOM"
 256    //- handler
 257    _addEvent: function(params) {
 258      if (params.nested) {
 259        this._childEvents.push(params);
 260      }
 261      if (params.type === 'view') {
 262        if (params.nested) {
 263          this.bind(params.name, _.bind(params.handler, params.parent || this, this));
 264        } else {
 265          this.bind(params.name, params.handler, this);
 266        }
 267      } else {
 268        var boundHandler = containHandlerToCurentView(bindEventHandler.call(this, params.handler), this.cid);
 269        if (params.selector) {
 270          $(this.el).delegate(params.selector, params.name, boundHandler);
 271        } else {
 272          $(this.el).bind(params.name, boundHandler);
 273        }
 274      }
 275    },
 276
 277    _shouldFetch: function(model_or_collection, options) {
 278      return model_or_collection.url && options.fetch && (
 279        typeof model_or_collection.isPopulated === 'undefined' || !model_or_collection.isPopulated()
 280      );
 281    },
 282  
 283    setModel: function(model, options) {
 284      (this.el[0] || this.el).setAttribute(model_cid_attribute_name, model.cid);
 285  
 286      var old_model = this.model;
 287
 288      this.freeze({
 289        model: old_model, //may be false
 290        collection: false
 291      });
 292    
 293      this.model = model;
 294      this.setModelOptions(options);
 295  
 296      if (this.model) {
 297        this._events.model.forEach(function(event) {
 298          this.model.bind(event[0], event[1]);
 299        }, this);
 300
 301        this.model.trigger('set', this.model, old_model);
 302    
 303        if (this._shouldFetch(this.model, this._modelOptions)) {
 304          var success = this._modelOptions.success;
 305          this.model.load(function(){
 306              success && success(model);
 307            }, this._modelOptions);
 308        } else {
 309          //want to trigger built in event handler (render() + populate())
 310          //without triggering event on model
 311          onModelChange.call(this);
 312        }
 313      }
 314  
 315      return this;
 316    },
 317
 318    setModelOptions: function(options) {
 319      if (!this._modelOptions) {
 320        this._modelOptions = {
 321          fetch: true,
 322          success: false,
 323          render: true,
 324          populate: true,
 325          errors: true
 326        };
 327      }
 328      _.extend(this._modelOptions, options || {});
 329      return this._modelOptions;
 330    },
 331      
 332    setCollection: function(collection, options) {
 333      var old_collection = this.collection;
 334
 335      this.freeze({
 336        model: false, //may be false
 337        collection: old_collection
 338      });
 339      
 340      this.collection = collection;
 341      this.collection.cid = _.uniqueId('collection');
 342      this.setCollectionOptions(options);
 343  
 344      if (this.collection) {
 345        this._events.collection.forEach(function(event) {
 346          this.collection.bind(event[0], event[1]);
 347        }, this);
 348      
 349        this.collection.trigger('set', this.collection, old_collection);
 350
 351        if (this._shouldFetch(this.collection, this._collectionOptions)) {
 352          var success = this._collectionOptions.success;
 353          this.collection.load(function(){
 354              success && success(this.collection);
 355            }, this._collectionOptions);
 356        } else {
 357          //want to trigger built in event handler (render())
 358          //without triggering event on collection
 359          onCollectionReset.call(this);
 360        }
 361      }
 362  
 363      return this;
 364    },
 365
 366    setCollectionOptions: function(options) {
 367      if (!this._collectionOptions) {
 368        this._collectionOptions = {
 369          fetch: true,
 370          success: false,
 371          errors: true
 372        };
 373      }
 374      _.extend(this._collectionOptions, options || {});
 375      return this._collectionOptions;
 376    },
 377
 378    context: function(model) {
 379      return model ? model.attributes : {};
 380    },
 381
 382    itemContext: function(item, i) {
 383      return item.attributes;
 384    },
 385
 386    emptyContext: function() {},
 387
 388    render: function(output) {
 389      if (typeof output === 'undefined' || (!_.isElement(output) && !_.isArray(output) && !(output && output.el) && typeof output !== 'string')) {
 390        ensureViewHasName.call(this);
 391        output = this.template(this.name, this.context(this.model));
 392      }
 393      //accept a view, string, or DOM element
 394      this.html((output && output.el) || output);
 395      if (!this._renderCount) {
 396        this._renderCount = 1;
 397      } else {
 398        ++this._renderCount;
 399      }
 400      this.trigger('rendered');
 401      return output;
 402    },
 403
 404    renderCollection: function() {
 405      this.render();
 406      var collection_element = getCollectionElement.call(this).empty();
 407      collection_element.attr(collection_cid_attribute_name, this.collection.cid);
 408      if (this.collection.length === 0 && this.collection.isPopulated()) {
 409        appendEmpty.call(this);
 410      } else {
 411        this.collection.forEach(this.appendItem, this);
 412      }
 413      this.trigger('rendered:collection', collection_element);
 414    },
 415
 416    renderItem: function(item, i) {
 417      ensureViewHasName.call(this);
 418      return this.template(this.name + '-item', this.itemContext(item, i));
 419    },
 420  
 421    renderEmpty: function() {
 422      ensureViewHasName.call(this);
 423      return this.template(this.name + '-empty', this.emptyContext());
 424    },
 425
 426    //appendItem(model [,index])
 427    //appendItem(html_string, index)
 428    //appendItem(view, index)
 429    appendItem: function(model, index, options) {
 430      //empty item
 431      if (!model) {
 432        return;
 433      }
 434
 435      var item_view,
 436          collection_element = getCollectionElement.call(this);
 437
 438      options = options || {};
 439
 440      //if index argument is a view
 441      if (index && index.el) {
 442        index = collection_element.find('> *').indexOf(index.el) + 1;
 443      }
 444
 445      //if argument is a view, or html string
 446      if (model.el || typeof model === 'string') {
 447        item_view = model;
 448      } else {
 449        index = index || this.collection.indexOf(model) || 0;
 450        item_view = this.renderItem(model, index);
 451      }
 452
 453      if (item_view) {
 454
 455        if (item_view.cid) {
 456          this._views[item_view.cid] = item_view;
 457        }
 458
 459        var item_element = item_view.el ? [item_view.el] : _.filter($(item_view), function(node) {
 460          //filter out top level whitespace nodes
 461          return node.nodeType === ELEMENT_NODE_TYPE;
 462        });
 463
 464        $(item_element).attr(model_cid_attribute_name, model.cid);
 465        var previous_model = index > 0 ? this.collection.at(index - 1) : false;
 466        if (!previous_model) {
 467          collection_element.prepend(item_element);
 468        } else {
 469          //use last() as appendItem can accept multiple nodes from a template
 470          collection_element.find('[' + model_cid_attribute_name + '="' + previous_model.cid + '"]').last().after(item_element);
 471        }
 472
 473        appendViews.call(this, item_element);
 474
 475        if (!options.silent) {
 476          this.trigger('rendered:item', item_element);
 477        }
 478      }
 479      return item_view;
 480    },
 481  
 482    freeze: function(options) {
 483      var model, collection;
 484      if (typeof options === 'undefined') {
 485        model = this.model;
 486        collection = this.collection;
 487      } else {
 488        model = options.model;
 489        collection = options.collection;
 490      }
 491
 492      if (collection && this._events && this._events.collection) {
 493        this._events.collection.forEach(function(event) {
 494          collection.unbind(event[0], event[1]);
 495        }, this);
 496      }
 497
 498      if (model && this._events && this._events.model) {
 499        this._events.model.forEach(function(event) {
 500          model.unbind(event[0], event[1]);
 501        }, this);
 502      }
 503    },
 504  
 505    //serializes a form present in the view, returning the serialized data
 506    //as an object
 507    //pass {set:false} to not update this.model if present
 508    //can pass options, callback or event in any order
 509    //if event is passed, _preventDuplicateSubmission is called
 510    serialize: function() {
 511      var callback, options, event;
 512      //ignore undefined arguments in case event was null
 513      for (var i = 0; i < arguments.length; ++i) {
 514        if (typeof arguments[i] === 'function') {
 515          callback = arguments[i];
 516        } else if (typeof arguments[i] === 'object') {
 517          if ('stopPropagation' in arguments[i] && 'preventDefault' in arguments[i]) {
 518            event = arguments[i];
 519          } else {
 520            options = arguments[i];
 521          }
 522        }
 523      }
 524
 525      if (event && !this._preventDuplicateSubmission(event)) {
 526        return;
 527      }
 528
 529      options = _.extend({
 530        set: true,
 531        validate: true
 532      },options || {});
 533  
 534      var attributes = options.attributes || {};
 535      
 536      //callback has context of element
 537      eachNamedInput.call(this, options, function() {
 538        var value = getInputValue.call(this);
 539        if (typeof value !== 'undefined') {
 540          objectAndKeyFromAttributesAndName(attributes, this.name, {mode: 'serialize'}, function(object, key) {
 541            object[key] = value;
 542          });
 543        }
 544      });
 545  
 546      this.trigger('serialize', attributes);
 547
 548      if (options.validate) {
 549        var errors = this.validateInput(attributes) || [];
 550        this.trigger('validate', attributes, errors);
 551        if (errors.length) {
 552          this.trigger('error', errors);
 553          return;
 554        }
 555      }
 556  
 557      if (options.set && this.model) {
 558        if (!this.model.set(attributes, {silent: true})) {
 559          return false;
 560        };
 561      }
 562      
 563      callback && callback.call(this,attributes);
 564      return attributes;
 565    },
 566  
 567    _preventDuplicateSubmission: function(event, callback) {
 568      event.preventDefault();
 569
 570      var form = $(event.target);
 571      if ((event.target.tagName || '').toLowerCase() !== 'form') {
 572        // Handle non-submit events by gating on the form
 573        form = $(event.target).closest('form');
 574      }
 575
 576      if (!form.attr('data-submit-wait')) {
 577        form.attr('data-submit-wait', 'true');
 578        if (callback) {
 579          callback.call(this, event);
 580        }
 581        return true;
 582      } else {
 583        return false;
 584      }
 585    },
 586
 587    //populate a form from the passed attributes or this.model if present
 588    populate: function(attributes) {
 589      if (!this.$('form').length) {
 590        return;
 591      }
 592      var value, attributes = attributes || this.context(this.model);
 593      
 594      //callback has context of element
 595      eachNamedInput.call(this, {}, function() {
 596        objectAndKeyFromAttributesAndName.call(this, attributes, this.name, {mode: 'populate'}, function(object, key) {
 597          if (object && typeof (value = object[key]) !== 'undefined') {
 598            //will only execute if we have a name that matches the structure in attributes
 599            if (this.type === 'checkbox' && _.isBoolean(value)) {
 600              this.checked = value;
 601            } else if (this.type === 'checkbox' || this.type === 'radio') {
 602              this.checked = value == this.value;
 603            } else {
 604              this.value = value;
 605            }
 606          }
 607        });
 608      });
 609
 610      this.trigger('populate', attributes);
 611    },
 612  
 613    //perform form validation, implemented by child class
 614    validateInput: function() {},
 615  
 616    destroy: function(){
 617      this.freeze();
 618      this.trigger('destroyed');
 619      if (this.undelegateEvents) {
 620        this.undelegateEvents();
 621      }
 622      this.unbind();
 623      this._events = {};
 624      this.el = null;
 625      this.collection = null;
 626      this.model = null;
 627      destroyChildViews.call(this);
 628    },
 629
 630    scrollTo: function(x, y) {
 631      y = y || minimumScrollYOffset;
 632      window.scrollTo(x, y);
 633      return [x, y];
 634    }
 635  }, {
 636    registerHelper: function(name, callback) {
 637      this[name] = callback;
 638      TemplateEngine.registerHelper(name, this[name]);
 639    },
 640    registerMixin: function(name, callback, methods) {
 641      scope.Mixins[name] = [callback, methods];
 642    },
 643    mixins: [],
 644    mixin: function(mixin) {
 645      this.mixins.push(mixin);
 646    },
 647    //events for all views
 648    events: {
 649      model: {},
 650      collection: {}
 651    },
 652    registerEvents: function(events) {
 653      for(var name in events) {
 654        if (name === 'model' || name === 'collection') {
 655          for (var _name in events[name]) {
 656            addEvent(this.events[name], _name, events[name][_name]);
 657          }
 658        } else {
 659          addEvent(this.events, name, events[name]);
 660        }
 661      }
 662    },
 663    unregisterEvents: function(events) {
 664      if (typeof events === 'undefined') {
 665        this.events = {
 666          model: {},
 667          collection: {}
 668        };
 669      } else if (typeof events === 'string' && arguments.length === 1) {
 670        if (events === 'model' || events === 'collection') {
 671          this.events[events] = {};
 672        } else {
 673          this.events[events] = [];
 674        }
 675      //remove collection or model events
 676      } else if (arguments.length === 2) {
 677        this.events[arguments[0]][arguments[1]] = [];
 678      }
 679    }
 680  });
 681
 682  //events and mixins properties need act as inheritable, not static / shared
 683  Thorax.View.extend = function(protoProps, classProps) {
 684    var child = Backbone.View.extend.call(this, protoProps, classProps);
 685    if (child.prototype.name) {
 686      scope.Views[child.prototype.name] = child;
 687    }
 688    child.mixins = _.clone(this.mixins);
 689    cloneEvents(this, child, 'events');
 690    cloneEvents(this.events, child.events, 'model');
 691    cloneEvents(this.events, child.events, 'collection');
 692    return child;
 693  };
 694
 695  function cloneEvents(source, target, key) {
 696    source[key] = _.clone(target[key]);
 697    //need to deep clone events array
 698    _.each(source[key], function(value, _key) {
 699      if (_.isArray(value)) {
 700        target[key][_key] = _.clone(value);
 701      }
 702    });
 703  }
 704
 705  Thorax.View.registerEvents({
 706    //built in dom events
 707    'submit form': function(event) {
 708      // Hide any virtual keyboards that may be lingering around
 709      var focused = $(':focus')[0];
 710      focused && focused.blur();
 711    },
 712
 713    'initialize:after': function(options) {
 714      //bind model or collection if passed to constructor
 715      if (options && options.model) {
 716        this.setModel(options.model);
 717      }
 718      if (options && options.collection) {
 719        this.setCollection(options.collection);
 720      }
 721    },
 722
 723    error: function() {  
 724      resetSubmitState.call(this);
 725    
 726      // If we errored with a model we want to reset the content but leave the UI
 727      // intact. If the user updates the data and serializes any overwritten data
 728      // will be restored.
 729      if (this.model && this.model.previousAttributes) {
 730        this.model.set(this.model.previousAttributes(), {
 731          silent: true
 732        });
 733      }
 734    },
 735    deactivated: function() {
 736      resetSubmitState.call(this);
 737    },
 738    model: {
 739      error: function(model, errors){
 740        if (this._modelOptions.errors) {
 741          this.trigger('error', errors);
 742        }
 743      },
 744      change: function() {
 745        onModelChange.call(this);
 746      }
 747    },
 748    collection: {
 749      add: function(model, collection) {
 750        //if collection was empty, clear empty view
 751        if (this.collection.length === 1) {
 752          getCollectionElement.call(this).empty();
 753        }
 754        this.appendItem(model, collection.indexOf(model));
 755      },
 756      remove: function(model) {
 757        this.$('[' + model_cid_attribute_name + '="' + model.cid + '"]').remove();
 758        for (var cid in this._views) {
 759          if (this._views[cid].model && this._views[cid].model.cid === model.cid) {
 760            this._views[cid].destroy();
 761            delete this._views[cid];
 762            break;
 763          }
 764        }
 765        if (this.collection.length === 0) {
 766          appendEmpty.call(this);
 767        }
 768      },
 769      reset: function() {
 770        onCollectionReset.call(this);
 771      },
 772      error: function(collection, message) {
 773        if (this._collectionOptions.errors) {
 774          this.trigger('error', message);
 775        }
 776      }
 777    }
 778  });
 779
 780  Thorax.View.registerHelper('view', function(view, options) {
 781    if (!view) {
 782      return '';
 783    }
 784    var instance = Thorax._currentTemplateContext.view(view, options ? options.hash : {});
 785    return TemplateEngine.safeString('<div ' + view_placeholder_attribute_name + '="' + instance.cid + '"></div>');
 786  });
 787  
 788  Thorax.View.registerHelper('template', function(name, options) {
 789    var context = _.extend({}, this, options ? options.hash : {});
 790    var output = Thorax.View.prototype.template.call(Thorax._currentTemplateContext, name, context);
 791    return TemplateEngine.safeString(output);
 792  });
 793
 794  Thorax.View.registerHelper('collection', function(options) {
 795    var collectionHelperOptions = _.clone(options.hash),
 796        tag = (collectionHelperOptions.tag || 'div');
 797    collectionHelperOptions[collection_cid_attribute_name] = "";
 798    if (collectionHelperOptions.tag) {
 799      delete collectionHelperOptions.tag;
 800    }
 801    var htmlAttributes = _.map(collectionHelperOptions, function(value, key) {
 802      return key + '="' + value + '"';
 803    }).join(' ');
 804    return TemplateEngine.safeString('<' + tag + ' ' + htmlAttributes + '></' + tag + '>');
 805  });
 806
 807  Thorax.View.registerHelper('link', function(url) {
 808    return (Backbone.history._hasPushState ? Backbone.history.options.root : '#') + url;
 809  });
 810
 811  //private Thorax.View methods
 812
 813  function ensureViewHasName() {
 814    if (!this.name) {
 815      throw new Error(this.cid + " requires a 'name' attribute.");
 816    }
 817  }
 818
 819  function onModelChange() {
 820    if (this._modelOptions.render) {
 821      this.render();
 822    }
 823    if (this._modelOptions.populate) {
 824      this.populate();
 825    }
 826  }
 827
 828  function onCollectionReset() {
 829    this.renderCollection();
 830  }
 831
 832  function containHandlerToCurentView(handler, cid) {
 833    return function(event) {
 834      var containing_view_element = $(event.target).closest('[' + view_name_attribute_name + ']');
 835      if (!containing_view_element.length || containing_view_element[0].getAttribute(view_cid_attribute_name) == cid) {
 836        handler(event);
 837      }
 838    };
 839  }
 840
 841  //model/collection events, to be bound/unbound on setModel/setCollection
 842  function processModelOrCollectionEvent(events, type) {
 843    for (var _name in events[type] || {}) {
 844      if (_.isArray(events[type][_name])) {
 845        for (var i = 0; i < events[type][_name].length; ++i) {
 846          this._events[type].push([_name, bindEventHandler.call(this, events[type][_name][i])]);
 847        }
 848      } else {
 849        this._events[type].push([_name, bindEventHandler.call(this, events[type][_name])]);
 850      }
 851    }
 852  }
 853
 854  //used by processEvents
 855  var domEvents = [
 856    'mousedown', 'mouseup', 'mousemove', 'mouseover', 'mouseout',
 857    'touchstart', 'touchend', 'touchmove',
 858    'click', 'dblclick',
 859    'keyup', 'keydown', 'keypress',
 860    'submit', 'change',
 861    'focus', 'blur'
 862  ];
 863
 864  function bindEventHandler(callback) {
 865    var method = typeof callback === 'function' ? callback : this[callback];
 866    if (!method) {
 867      throw new Error('Event "' + callback + '" does not exist');
 868    }
 869    return _.bind(method, this);
 870  }
 871
 872  function processEvents(events) {
 873    if (_.isFunction(events)) {
 874      events = events.call(this);
 875    }
 876    var processedEvents = [];
 877    for (var name in events) {
 878      if (name !== 'model' && name !== 'collection') {
 879        if (name.match(/,/)) {
 880          name.split(/,/).forEach(function(fragment) {
 881            processEventItem.call(this, fragment.replace(/(^[\s]+|[\s]+$)/g, ''), events[name], processedEvents);
 882          }, this);
 883        } else {
 884          processEventItem.call(this, name, events[name], processedEvents);
 885        }
 886      }
 887    }
 888    return processedEvents;
 889  }
 890
 891  function processEventItem(name, handler, target) {
 892    if (_.isArray(handler)) {
 893      for (var i = 0; i < handler.length; ++i) {
 894        target.push(eventParamsFromEventItem.call(this, name, handler[i]));
 895      }
 896    } else {
 897      target.push(eventParamsFromEventItem.call(this, name, handler));
 898    }
 899  }
 900
 901  var eventSplitter = /^(nested\s+)?(\S+)(?:\s+(.+))?/;
 902
 903  function eventParamsFromEventItem(name, handler) {
 904    var params = {
 905      originalName: name,
 906      handler: typeof handler === 'string' ? this[handler] : handler
 907    };
 908    var match = eventSplitter.exec(name);
 909    params.nested = !!match[1];
 910    params.name = match[2];
 911    if (isDOMEvent(params.name)) {
 912      params.type = 'DOM';
 913      params.name += '.delegateEvents' + this.cid;
 914      params.selector = match[3];
 915    } else {
 916      params.type = 'view';
 917    }
 918    return params;
 919  }
 920
 921  function isDOMEvent(name) {
 922    return !(!name.match(/\s+/) && domEvents.indexOf(name) === -1);
 923  }
 924
 925  //used by Thorax.View.registerEvents for global event registration
 926  function addEvent(target, name, handler) {
 927    if (!target[name]) {
 928      target[name] = [];
 929    }
 930    if (_.isArray(handler)) {
 931      for (var i = 0; i < handler.length; ++i) {
 932        target[name].push(handler[i]);
 933      }
 934    } else {
 935      target[name].push(handler);
 936    }
 937  }
 938
 939  function resetSubmitState() {
 940    this.$('form').removeAttr('data-submit-wait');
 941  }
 942
 943  //called with context of input
 944  function getInputValue() {
 945    if (this.type === 'checkbox' || this.type === 'radio') {
 946      if ($(this).attr('data-onOff')) {
 947        return this.checked;
 948      } else if (this.checked) {
 949        return this.value;
 950      }
 951    } else if (this.multiple === true) {
 952      var values = [];
 953      $('option',this).each(function(){
 954        if (this.selected) {
 955          values.push(this.value);
 956        }
 957      });
 958      return values;
 959    } else {
 960      return this.value;
 961    }
 962  }
 963
 964  //calls a callback with the correct object fragment and key from a compound name
 965  function objectAndKeyFromAttributesAndName(attributes, name, options, callback) {
 966    var key, i, object = attributes, keys = name.split('['), mode = options.mode;
 967    for(i = 0; i < keys.length - 1; ++i) {
 968      key = keys[i].replace(']','');
 969      if (!object[key]) {
 970        if (mode == 'serialize') {
 971          object[key] = {};
 972        } else {
 973          return callback.call(this, false, key);
 974        }
 975      }
 976      object = object[key];
 977    }
 978    key = keys[keys.length - 1].replace(']', '');
 979    callback.call(this, object, key);
 980  }
 981
 982  function eachNamedInput(options, iterator, context) {
 983    var i = 0;
 984    $('select,input,textarea', options.root || this.el).each(function() {
 985      if (this.type !== 'button' && this.type !== 'cancel' && this.type !== 'submit' && this.name && this.name !== '') {
 986        iterator.call(context || this, i, this);
 987        ++i;
 988      }
 989    });
 990  }
 991
 992  function bindModelAndCollectionEvents(events) {
 993    if (!this._events) {
 994      this._events = {
 995        model: [],
 996        collection: []
 997      };
 998    }
 999    processModelOrCollectionEvent.call(this, events, 'model');
1000    processModelOrCollectionEvent.call(this, events, 'collection');
1001  }
1002
1003  function getCollectionElement() {
1004    var selector = this._collectionSelector || default_collection_selector;
1005    var element = this.$(selector);
1006    if (element.length === 0) {
1007      return $(this.el);
1008    } else {
1009      return element;
1010    }
1011  }
1012
1013  function preserveCollectionElement(callback) {
1014    var old_collection_element = getCollectionElement.call(this);
1015    callback.call(this);
1016    var new_collection_element = getCollectionElement.call(this);
1017    if (old_collection_element.length && new_collection_element.length) {
1018      new_collection_element[0].parentNode.insertBefore(old_collection_element[0], new_collection_element[0]);
1019      new_collection_element[0].parentNode.removeChild(new_collection_element[0]);
1020    }
1021  }
1022
1023  function appendViews(scope) {
1024    var self = this;
1025    if (!self._views) {
1026      return;
1027    }
1028
1029    $('[' + view_placeholder_attribute_name + ']', scope || self.el).forEach(function(el) {
1030      var view = self._views[el.getAttribute(view_placeholder_attribute_name)];
1031      if (view) {
1032        //has the view been rendered at least once? if not call render().
1033        //subclasses overriding render() that do not call the parent's render()
1034        //or set _rendered may be rendered twice but will not error
1035        if (!view._renderCount) {
1036          view.render();
1037        }
1038        el.parentNode.insertBefore(view.el, el);
1039        el.parentNode.removeChild(el);
1040      }
1041    });
1042  }
1043
1044  function destroyChildViews() {
1045    for (var id in this._views || {}) {
1046      if (this._views[id].destroy) {
1047        this._views[id].destroy();
1048      }
1049      this._views[id] = null;
1050    }
1051  }
1052
1053  function appendEmpty() {
1054    getCollectionElement.call(this).empty();
1055    this.appendItem(this.renderEmpty(), 0, {silent: true});
1056    this.trigger('rendered:empty');
1057  }
1058
1059  function applyMixin(mixin) {
1060    if (_.isArray(mixin)) {
1061      this.mixin.apply(this, mixin);
1062    } else {
1063      this.mixin(mixin);
1064    }
1065  }
1066  
1067  //main layout class, instance of which is available on scope.layout
1068  Thorax.Layout = Backbone.View.extend({
1069    events: {
1070      'click a': 'anchorClick'
1071    },
1072
1073    initialize: function() {
1074      this.el = $(this.el)[0];
1075      this.views = this.make('div', {
1076        'class': 'views'
1077      });
1078      this.el.appendChild(this.views);
1079    },
1080    
1081    setView: function(view, params){
1082      var old_view = this.view;
1083      if (view == old_view){
1084        return false;
1085      }
1086      this.trigger('change:view:start', view, old_view);
1087      old_view && old_view.trigger('deactivated');
1088      view && view.trigger('activated', params || {});
1089      if (old_view && old_view.el && old_view.el.parentNode) {
1090        $(old_view.el).remove();
1091      }
1092      //make sure the view has been rendered at least once
1093      view && !view._renderCount && view.render();
1094      view && this.views.appendChild(view.el);
1095      window.scrollTo(0, minimumScrollYOffset);
1096      this.view = view;
1097      old_view && old_view.destroy();
1098      this.view && this.view.trigger('ready');
1099      this.trigger('change:view:end', view, old_view);
1100      return view;
1101    },
1102
1103    anchorClick: function(event) {
1104      var target = $(event.currentTarget);
1105      if (target.attr("data-external")) {
1106        return;
1107      }
1108      var href = target.attr("href");
1109      // Route anything that starts with # or / (excluding //domain urls)
1110      if (href && (href[0] === '#' || (href[0] === '/' && href[1] !== '/'))) {
1111        Backbone.history.navigate(href, {trigger: true});
1112        event.preventDefault();
1113      }
1114    }
1115  });
1116
1117  Thorax.Router = Backbone.Router.extend({
1118    view: function(name, attributes) {
1119      if (!scope.Views[name]) {
1120        throw new Error('view: ' + name + ' does not exist.');
1121      }
1122      return new scope.Views[name](attributes);
1123    },
1124    setView: function() {
1125      return scope.layout.setView.apply(scope.layout, arguments);
1126    },
1127    bindToRoute: bindToRoute
1128  },{
1129    create: function(module, protoProps, classProps) {
1130      return scope.Routers[module.name] = new (this.extend(_.extend({}, module, protoProps), classProps));
1131    },
1132    bindToRoute: bindToRoute
1133  });
1134
1135  function bindToRoute(callback, failback) {
1136    var fragment = Backbone.history.getFragment(),
1137        completed;
1138
1139    function finalizer(isCanceled) {
1140      var same = fragment === Backbone.history.getFragment();
1141
1142      if (completed) {
1143        // Prevent multiple execution, i.e. we were canceled but the success callback still runs
1144        return;
1145      }
1146
1147      if (isCanceled && same) {
1148        // Ignore the first route event if we are running in newer versions of backbone
1149        // where the route operation is a postfix operation.
1150        return;
1151      }
1152
1153      completed = true;
1154      Backbone.history.unbind('route', resetLoader);
1155
1156      var args = Array.prototype.slice.call(arguments, 1);
1157      if (!isCanceled && same) {
1158        callback.apply(this, args);
1159      } else {
1160        failback && failback.apply(this, args);
1161      }
1162    }
1163
1164    var resetLoader = _.bind(finalizer, this, true);
1165    Backbone.history.bind('route', resetLoader);
1166
1167    return _.bind(finalizer, this, false);
1168  }
1169
1170  Thorax.Model = Backbone.Model.extend({
1171    isPopulated: function() {
1172      // We are populated if we have attributes set
1173      var attributes = _.clone(this.attributes);
1174      var defaults = _.isFunction(this.defaults) ? this.defaults() : (this.defaults || {});
1175      for (var default_key in defaults) {
1176        if (attributes[default_key] != defaults[default_key]) {
1177          return true;
1178        }
1179        delete attributes[default_key];
1180      }
1181      var keys = _.keys(attributes);
1182      return keys.length > 1 || (keys.length === 1 && keys[0] !== 'id');
1183    },
1184    fetch: function(options) {
1185      fetchQueue.call(this, options || {}, Backbone.Model.prototype.fetch);
1186    },
1187    load: loadData
1188  });
1189
1190  Thorax.Model.extend = function(protoProps, classProps) {
1191    var child = Backbone.Model.extend.call(this, protoProps, classProps);
1192    if (child.prototype.name) {
1193      scope.Models[child.prototype.name] = child;
1194    }
1195    return child;
1196  };
1197
1198  Thorax.Collection = Backbone.Collection.extend({
1199    model: Thorax.Model,
1200    isPopulated: function() {
1201      return this._fetched || this.length > 0;
1202    },
1203    fetch: function(options) {
1204      options = options || {};
1205      var success = options.success;
1206      options.success = function(collection, response) {
1207        collection._fetched = true;
1208        success && success(collection, response);
1209      };
1210      fetchQueue.call(this, options || {}, Backbone.Collection.prototype.fetch);
1211    },
1212    reset: function(models, options) {
1213      this._fetched = !!models;
1214      return Backbone.Collection.prototype.reset.call(this, models, options);
1215    },
1216    load: loadData
1217  });
1218
1219  Thorax.Collection.extend = function(protoProps, classProps) {
1220    var child = Backbone.Collection.extend.call(this, protoProps, classProps);
1221    if (child.prototype.name) {
1222      scope.Collections[child.prototype.name] = child;
1223    }
1224    return child;
1225  };
1226
1227  function loadData(callback, failback, options) {
1228    if (this.isPopulated()) {
1229      return callback(this);
1230    }
1231
1232    if (arguments.length === 2 && typeof failback !== 'function' && _.isObject(failback)) {
1233      options = failback;
1234      failback = false;
1235    }
1236
1237    this.fetch(_.defaults({
1238      success: bindToRoute(callback, failback && _.bind(failback, this, false)),
1239      error: failback && _.bind(failback, this, true)
1240    }, options));
1241  }
1242
1243  function fetchQueue(options, $super) {
1244    if (options.resetQueue) {
1245      // WARN: Should ensure that loaders are protected from out of band data
1246      //    when using this option
1247      this.fetchQueue = undefined;
1248    }
1249
1250    if (!this.fetchQueue) {
1251      // Kick off the request
1252      this.fetchQueue = [options];
1253      options = _.defaults({
1254        success: flushQueue(this, this.fetchQueue, 'success'),
1255        error: flushQueue(this, this.fetchQueue, 'error')
1256      }, options);
1257      $super.call(this, options);
1258    } else {
1259      // Currently fetching. Queue and process once complete
1260      this.fetchQueue.push(options);
1261    }
1262  }
1263
1264  function flushQueue(self, fetchQueue, handler) {
1265    return function() {
1266      var args = arguments;
1267
1268      // Flush the queue. Executes any callback handlers that
1269      // may have been passed in the fetch options.
1270      fetchQueue.forEach(function(options) {
1271        if (options[handler]) {
1272          options[handler].apply(this, args);
1273        }
1274      }, this);
1275
1276      // Reset the queue if we are still the active request
1277      if (self.fetchQueue === fetchQueue) {
1278        self.fetchQueue = undefined;
1279      }
1280    }
1281  }
1282}).call(this, this);