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