· 9 years ago · Nov 10, 2016, 06:50 PM
1// Backbone.Epoxy
2
3// (c) 2013 Greg MacWilliam
4// Freely distributed under the MIT license
5// For usage and documentation:
6// http://epoxyjs.org
7
8(function(root, factory) {
9
10 if (typeof exports !== 'undefined') {
11 // Define as CommonJS export:
12 module.exports = factory(require("underscore"), require("backbone"));
13 } else if (typeof define === 'function' && define.amd) {
14 // Define as AMD:
15 define(["underscore", "backbone"], factory);
16 } else {
17 // Just run it:
18 factory(root._, root.Backbone);
19 }
20
21}(this, function(_, Backbone) {
22
23 // Epoxy namespace:
24 var Epoxy = Backbone.Epoxy = {};
25
26 // Object-type utils:
27 var array = Array.prototype;
28 var isUndefined = _.isUndefined;
29 var isFunction = _.isFunction;
30 var isObject = _.isObject;
31 var isArray = _.isArray;
32 var isModel = function(obj) { return obj instanceof Backbone.Model; };
33 var isCollection = function(obj) { return obj instanceof Backbone.Collection; };
34 var blankMethod = function() {};
35
36 // Static mixins API:
37 // added as a static member to Epoxy class objects (Model & View);
38 // generates a set of class attributes for mixin with other objects.
39 var mixins = {
40 mixin: function(extend) {
41 extend = extend || {};
42
43 for (var i in this.prototype) {
44 // Skip override on pre-defined binding declarations:
45 if (i === 'bindings' && extend.bindings) continue;
46
47 // Assimilate non-constructor Epoxy prototype properties onto extended object:
48 if (this.prototype.hasOwnProperty(i) && i !== 'constructor') {
49 extend[i] = this.prototype[i];
50 }
51 }
52 return extend;
53 }
54 };
55
56 // Calls method implementations of a super-class object:
57 function _super(instance, method, args) {
58 return instance._super.prototype[method].apply(instance, args);
59 }
60
61 // Epoxy.Model
62 // -----------
63 var modelMap;
64 var modelProps = ['computeds'];
65
66 Epoxy.Model = Backbone.Model.extend({
67 _super: Backbone.Model,
68
69 // Backbone.Model constructor override:
70 // configures computed model attributes around the underlying native Backbone model.
71 constructor: function(attributes, options) {
72 _.extend(this, _.pick(options||{}, modelProps));
73 _super(this, 'constructor', arguments);
74 this.initComputeds(attributes, options);
75 },
76
77 // Gets a copy of a model attribute value:
78 // Array and Object values will return a shallow copy,
79 // primitive values will be returned directly.
80 getCopy: function(attribute) {
81 return _.clone(this.get(attribute));
82 },
83
84 // Backbone.Model.get() override:
85 // provides access to computed attributes,
86 // and maps computed dependency references while establishing bindings.
87 get: function(attribute) {
88
89 // Automatically register bindings while building out computed dependency graphs:
90 modelMap && modelMap.push(['change:'+attribute, this]);
91
92 // Return a computed property value, if available:
93 if (this.hasComputed(attribute)) {
94 return this.c()[ attribute ].get();
95 }
96
97 // Default to native Backbone.Model get operation:
98 return _super(this, 'get', arguments);
99 },
100
101 // Backbone.Model.set() override:
102 // will process any computed attribute setters,
103 // and then pass along all results to the underlying model.
104 set: function(key, value, options) {
105 var params = key;
106
107 // Convert key/value arguments into {key:value} format:
108 if (params && !isObject(params)) {
109 params = {};
110 params[ key ] = value;
111 } else {
112 options = value;
113 }
114
115 // Default options definition:
116 options = options || {};
117
118 // Create store for capturing computed change events:
119 var computedEvents = this._setting = [];
120
121 // Attempt to set computed attributes while not unsetting:
122 if (!options.unset) {
123 // All param properties are tested against computed setters,
124 // properties set to computeds will be removed from the params table.
125 // Optionally, an computed setter may return key/value pairs to be merged into the set.
126 params = deepModelSet(this, params, {}, []);
127 }
128
129 // Remove computed change events store:
130 delete this._setting;
131
132 // Pass all resulting set params along to the underlying Backbone Model.
133 var result = _super(this, 'set', [params, options]);
134
135 // Dispatch all outstanding computed events:
136 if (!options.silent) {
137 // Make sure computeds get a "change" event:
138 if (!this.hasChanged() && computedEvents.length) {
139 this.trigger('change', this);
140 }
141
142 // Trigger each individual computed attribute change:
143 // NOTE: computeds now officially fire AFTER basic "change"...
144 // We can't really fire them earlier without duplicating the Backbone "set" method here.
145 _.each(computedEvents, function(evt) {
146 this.trigger.apply(this, evt);
147 }, this);
148 }
149 return result;
150 },
151
152 // Backbone.Model.toJSON() override:
153 // adds a 'computed' option, specifying to include computed attributes.
154 toJSON: function(options) {
155 var json = _super(this, 'toJSON', arguments);
156
157 if (options && options.computed) {
158 _.each(this.c(), function(computed, attribute) {
159 json[ attribute ] = computed.value;
160 });
161 }
162
163 return json;
164 },
165
166 // Backbone.Model.destroy() override:
167 // clears all computed attributes before destroying.
168 destroy: function() {
169 this.clearComputeds();
170 return _super(this, 'destroy', arguments);
171 },
172
173 // Computed namespace manager:
174 // Allows the model to operate as a mixin.
175 c: function() {
176 return this._c || (this._c = {});
177 },
178
179 // Initializes the Epoxy model:
180 // called automatically by the native constructor,
181 // or may be called manually when adding Epoxy as a mixin.
182 initComputeds: function(attributes, options) {
183 this.clearComputeds();
184
185 // Resolve computeds hash, and extend it with any preset attribute keys:
186 // TODO: write test.
187 var computeds = _.result(this, 'computeds')||{};
188 computeds = _.extend(computeds, _.pick(attributes||{}, _.keys(computeds)));
189
190 // Add all computed attributes:
191 _.each(computeds, function(params, attribute) {
192 params._init = 1;
193 this.addComputed(attribute, params);
194 }, this);
195
196 // Initialize all computed attributes:
197 // all presets have been constructed and may reference each other now.
198 _.invoke(this.c(), 'init');
199 },
200
201 // Adds a computed attribute to the model:
202 // computed attribute will assemble and return customized values.
203 // @param attribute (string)
204 // @param getter (function) OR params (object)
205 // @param [setter (function)]
206 // @param [dependencies ...]
207 addComputed: function(attribute, getter, setter) {
208 this.removeComputed(attribute);
209
210 var params = getter;
211 var delayInit = params._init;
212
213 // Test if getter and/or setter are provided:
214 if (isFunction(getter)) {
215 var depsIndex = 2;
216
217 // Add getter param:
218 params = {};
219 params._get = getter;
220
221 // Test for setter param:
222 if (isFunction(setter)) {
223 params._set = setter;
224 depsIndex++;
225 }
226
227 // Collect all additional arguments as dependency definitions:
228 params.deps = array.slice.call(arguments, depsIndex);
229 }
230
231 // Create a new computed attribute:
232 this.c()[ attribute ] = new EpoxyComputedModel(this, attribute, params, delayInit);
233 return this;
234 },
235
236 // Tests the model for a computed attribute definition:
237 hasComputed: function(attribute) {
238 return this.c().hasOwnProperty(attribute);
239 },
240
241 // Removes an computed attribute from the model:
242 removeComputed: function(attribute) {
243 if (this.hasComputed(attribute)) {
244 this.c()[ attribute ].dispose();
245 delete this.c()[ attribute ];
246 }
247 return this;
248 },
249
250 // Removes all computed attributes:
251 clearComputeds: function() {
252 for (var attribute in this.c()) {
253 this.removeComputed(attribute);
254 }
255 return this;
256 },
257
258 // Internal array value modifier:
259 // performs array ops on a stored array value, then fires change.
260 // No action is taken if the specified attribute value is not an array.
261 modifyArray: function(attribute, method, options) {
262 var obj = this.get(attribute);
263
264 if (isArray(obj) && isFunction(array[method])) {
265 var args = array.slice.call(arguments, 2);
266 var result = array[ method ].apply(obj, args);
267 options = options || {};
268
269 if (!options.silent) {
270 this.trigger('change:'+attribute+' change', this, array, options);
271 }
272 return result;
273 }
274 return null;
275 },
276
277 // Internal object value modifier:
278 // sets new property values on a stored object value, then fires change.
279 // No action is taken if the specified attribute value is not an object.
280 modifyObject: function(attribute, property, value, options) {
281 var obj = this.get(attribute);
282 var change = false;
283
284 // If property is Object:
285 if (isObject(obj)) {
286
287 options = options || {};
288
289 // Delete existing property in response to undefined values:
290 if (isUndefined(value) && obj.hasOwnProperty(property)) {
291 delete obj[property];
292 change = true;
293 }
294 // Set new and/or changed property values:
295 else if (obj[ property ] !== value) {
296 obj[ property ] = value;
297 change = true;
298 }
299
300 // Trigger model change:
301 if (change && !options.silent) {
302 this.trigger('change:'+attribute+' change', this, obj, options);
303 }
304
305 // Return the modified object:
306 return obj;
307 }
308 return null;
309 }
310 }, mixins);
311
312 // Epoxy.Model -> Private
313 // ----------------------
314
315 // Model deep-setter:
316 // Attempts to set a collection of key/value attribute pairs to computed attributes.
317 // Observable setters may digest values, and then return mutated key/value pairs for inclusion into the set operation.
318 // Values returned from computed setters will be recursively deep-set, allowing computeds to set other computeds.
319 // The final collection of resolved key/value pairs (after setting all computeds) will be returned to the native model.
320 // @param model: target Epoxy model on which to operate.
321 // @param toSet: an object of key/value pairs to attempt to set within the computed model.
322 // @param toReturn: resolved non-ovservable attribute values to be returned back to the native model.
323 // @param trace: property stack trace (prevents circular setter loops).
324 function deepModelSet(model, toSet, toReturn, stack) {
325
326 // Loop through all setter properties:
327 for (var attribute in toSet) {
328 if (toSet.hasOwnProperty(attribute)) {
329
330 // Pull each setter value:
331 var value = toSet[ attribute ];
332
333 if (model.hasComputed(attribute)) {
334
335 // Has a computed attribute:
336 // comfirm attribute does not already exist within the stack trace.
337 if (!stack.length || !_.contains(stack, attribute)) {
338
339 // Non-recursive:
340 // set and collect value from computed attribute.
341 value = model.c()[attribute].set(value);
342
343 // Recursively set new values for a returned params object:
344 // creates a new copy of the stack trace for each new search branch.
345 if (value && isObject(value)) {
346 toReturn = deepModelSet(model, value, toReturn, stack.concat(attribute));
347 }
348
349 } else {
350 // Recursive:
351 // Throw circular reference error.
352 throw('Recursive setter: '+stack.join(' > '));
353 }
354
355 } else {
356 // No computed attribute:
357 // set the value to the keeper values.
358 toReturn[ attribute ] = value;
359 }
360 }
361 }
362
363 return toReturn;
364 }
365
366
367 // Epoxy.Model -> Computed
368 // -----------------------
369 // Computed objects store model values independently from the model's attributes table.
370 // Computeds define custom getter/setter functions to manage their value.
371
372 function EpoxyComputedModel(model, name, params, delayInit) {
373 params = params || {};
374
375 // Rewrite getter param:
376 if (params.get && isFunction(params.get)) {
377 params._get = params.get;
378 }
379
380 // Rewrite setter param:
381 if (params.set && isFunction(params.set)) {
382 params._set = params.set;
383 }
384
385 // Prohibit override of 'get()' and 'set()', then extend:
386 delete params.get;
387 delete params.set;
388 _.extend(this, params);
389
390 // Set model, name, and default dependencies array:
391 this.model = model;
392 this.name = name;
393 this.deps = this.deps || [];
394
395 // Skip init while parent model is initializing:
396 // Model will initialize in two passes...
397 // the first pass sets up all computed attributes,
398 // then the second pass initializes all bindings.
399 if (!delayInit) this.init();
400 }
401
402 _.extend(EpoxyComputedModel.prototype, Backbone.Events, {
403
404 // Initializes the computed's value and bindings:
405 // this method is called independently from the object constructor,
406 // allowing computeds to build and initialize in two passes by the parent model.
407 init: function() {
408
409 // Configure dependency map, then update the computed's value:
410 // All Epoxy.Model attributes accessed while getting the initial value
411 // will automatically register themselves within the model bindings map.
412 var bindings = {};
413 var deps = modelMap = [];
414 this.get(true);
415 modelMap = null;
416
417 // If the computed has dependencies, then proceed to binding it:
418 if (deps.length) {
419
420 // Compile normalized bindings table:
421 // Ultimately, we want a table of event types, each with an array of their associated targets:
422 // {'change:name':[<model1>], 'change:status':[<model1>,<model2>]}
423
424 // Compile normalized bindings map:
425 _.each(deps, function(value) {
426 var attribute = value[0];
427 var target = value[1];
428
429 // Populate event target arrays:
430 if (!bindings[attribute]) {
431 bindings[attribute] = [ target ];
432
433 } else if (!_.contains(bindings[attribute], target)) {
434 bindings[attribute].push(target);
435 }
436 });
437
438 // Bind all event declarations to their respective targets:
439 _.each(bindings, function(targets, binding) {
440 for (var i=0, len=targets.length; i < len; i++) {
441 this.listenTo(targets[i], binding, _.bind(this.get, this, true));
442 }
443 }, this);
444 }
445 },
446
447 // Gets an attribute value from the parent model.
448 val: function(attribute) {
449 return this.model.get(attribute);
450 },
451
452 // Gets the computed's current value:
453 // Computed values flagged as dirty will need to regenerate themselves.
454 // Note: 'update' is strongly checked as TRUE to prevent unintended arguments (handler events, etc) from qualifying.
455 get: function(update) {
456 if (update === true && this._get) {
457 var val = this._get.apply(this.model, _.map(this.deps, this.val, this));
458 this.change(val);
459 }
460 return this.value;
461 },
462
463 // Sets the computed's current value:
464 // computed values (have a custom getter method) require a custom setter.
465 // Custom setters should return an object of key/values pairs;
466 // key/value pairs returned to the parent model will be merged into its main .set() operation.
467 set: function(val) {
468 if (this._get) {
469 if (this._set) return this._set.apply(this.model, arguments);
470 else throw('Cannot set read-only computed attribute.');
471 }
472 this.change(val);
473 return null;
474 },
475
476 // Changes the computed's value:
477 // new values are cached, then fire an update event.
478 change: function(value) {
479 if (!_.isEqual(value, this.value)) {
480 this.value = value;
481 var evt = ['change:'+this.name, this.model, value];
482
483 if (this.model._setting) {
484 this.model._setting.push(evt);
485 } else {
486 evt[0] += ' change';
487 this.model.trigger.apply(this.model, evt);
488 }
489 }
490 },
491
492 // Disposal:
493 // cleans up events and releases references.
494 dispose: function() {
495 this.stopListening();
496 this.off();
497 this.model = this.value = null;
498 }
499 });
500
501
502 // Epoxy.binding -> Binding API
503 // ----------------------------
504
505 var bindingSettings = {
506 optionText: 'label',
507 optionValue: 'value'
508 };
509
510
511 // Cache for storing binding parser functions:
512 // Cuts down on redundancy when building repetitive binding views.
513 var bindingCache = {};
514
515
516 // Reads value from an accessor:
517 // Accessors come in three potential forms:
518 // => A function to call for the requested value.
519 // => An object with a collection of attribute accessors.
520 // => A primitive (string, number, boolean, etc).
521 // This function unpacks an accessor and returns its underlying value(s).
522
523 function readAccessor(accessor) {
524
525 if (isFunction(accessor)) {
526 // Accessor is function: return invoked value.
527 return accessor();
528 }
529 else if (isObject(accessor)) {
530 // Accessor is object/array: return copy with all attributes read.
531 accessor = _.clone(accessor);
532
533 _.each(accessor, function(value, key) {
534 accessor[ key ] = readAccessor(value);
535 });
536 }
537 // return formatted value, or pass through primitives:
538 return accessor;
539 }
540
541
542 // Binding Handlers
543 // ----------------
544 // Handlers define set/get methods for exchanging data with the DOM.
545
546 // Formatting function for defining new handler objects:
547 function makeHandler(handler) {
548 return isFunction(handler) ? {set: handler} : handler;
549 }
550
551 var bindingHandlers = {
552 // Attribute: write-only. Sets element attributes.
553 attr: makeHandler(function($element, value) {
554 $element.attr(value);
555 }),
556
557 // Checked: read-write. Toggles the checked status of a form element.
558 checked: makeHandler({
559 get: function($element, currentValue) {
560 var checked = !!$element.prop('checked');
561 var value = $element.val();
562
563 if (this.isRadio($element)) {
564 // Radio button: return value directly.
565 return value;
566
567 } else if (isArray(currentValue)) {
568 // Checkbox array: add/remove value from list.
569 currentValue = currentValue.slice();
570 var index = _.indexOf(currentValue, value);
571
572 if (checked && index < 0) {
573 currentValue.push(value);
574 } else if (!checked && index > -1) {
575 currentValue.splice(index, 1);
576 }
577 return currentValue;
578 }
579 // Checkbox: return boolean toggle.
580 return checked;
581 },
582 set: function($element, value) {
583 // Default as loosely-typed boolean:
584 var checked = !!value;
585
586 if (this.isRadio($element)) {
587 // Radio button: match checked state to radio value.
588 checked = (value == $element.val());
589
590 } else if (isArray(value)) {
591 // Checkbox array: match checked state to checkbox value in array contents.
592 checked = _.contains(value, $element.val());
593 }
594
595 // Set checked property to element:
596 $element.prop('checked', checked);
597 },
598 // Is radio button: avoids '.is(":radio");' check for basic Zepto compatibility.
599 isRadio: function($element) {
600 return $element.attr('type').toLowerCase() === 'radio';
601 }
602 }),
603
604 // Class Name: write-only. Toggles a collection of class name definitions.
605 classes: makeHandler(function($element, value) {
606 _.each(value, function(enabled, className) {
607 $element.toggleClass(className, !!enabled);
608 });
609 }),
610
611 // Collection: write-only. Manages a list of views bound to a Backbone.Collection.
612 collection: makeHandler({
613 init: function($element, collection, context, bindings) {
614 this.i = bindings.itemView ? this.view[bindings.itemView] : this.view.itemView;
615 if (!isCollection(collection)) throw('Binding "collection" requires a Collection.');
616 if (!isFunction(this.i)) throw('Binding "collection" requires an itemView.');
617 this.v = {};
618 },
619 set: function($element, collection, target) {
620
621 var view;
622 var views = this.v;
623 var ItemView = this.i;
624 var models = collection.models;
625
626 // Cache and reset the current dependency graph state:
627 // sub-views may be created (each with their own dependency graph),
628 // therefore we need to suspend the working graph map here before making children...
629 var mapCache = viewMap;
630 viewMap = null;
631
632 // Default target to the bound collection object:
633 // during init (or failure), the binding will reset.
634 target = target || collection;
635
636 if (isModel(target)) {
637
638 // ADD/REMOVE Event (from a Model):
639 // test if view exists within the binding...
640 if (!views.hasOwnProperty(target.cid)) {
641
642 // Add new view:
643 views[ target.cid ] = view = new ItemView({model: target, collectionView: this.view});
644 var index = _.indexOf(models, target);
645 var $children = $element.children();
646
647 // Attempt to add at proper index,
648 // otherwise just append into the element.
649 if (index < $children.length) {
650 $children.eq(index).before(view.$el);
651 } else {
652 $element.append(view.$el);
653 }
654
655 } else {
656
657 // Remove existing view:
658 views[ target.cid ].remove();
659 delete views[ target.cid ];
660 }
661
662 } else if (isCollection(target)) {
663
664 // SORT/RESET Event (from a Collection):
665 // First test if we're sorting...
666 // (number of models has not changed and all their views are present)
667 var sort = models.length === _.size(views) && collection.every(function(model) {
668 return views.hasOwnProperty(model.cid);
669 });
670
671 // Hide element before manipulating:
672 $element.children().detach();
673 var frag = document.createDocumentFragment();
674
675 if (sort) {
676 // Sort existing views:
677 collection.each(function(model) {
678 frag.appendChild(views[model.cid].el);
679 });
680
681 } else {
682 // Reset with new views:
683 this.clean();
684 collection.each(function(model) {
685 views[ model.cid ] = view = new ItemView({model: model, collectionView: this.view});
686 frag.appendChild(view.el);
687 }, this);
688 }
689
690 $element.append(frag);
691 }
692
693 // Restore cached dependency graph configuration:
694 viewMap = mapCache;
695 },
696 clean: function() {
697 for (var id in this.v) {
698 if (this.v.hasOwnProperty(id)) {
699 this.v[ id ].remove();
700 delete this.v[ id ];
701 }
702 }
703 }
704 }),
705
706 // CSS: write-only. Sets a collection of CSS styles to an element.
707 css: makeHandler(function($element, value) {
708 $element.css(value);
709 }),
710
711 // Disabled: write-only. Sets the 'disabled' status of a form element (true :: disabled).
712 disabled: makeHandler(function($element, value) {
713 $element.prop('disabled', !!value);
714 }),
715
716 // Enabled: write-only. Sets the 'disabled' status of a form element (true :: !disabled).
717 enabled: makeHandler(function($element, value) {
718 $element.prop('disabled', !value);
719 }),
720
721 // HTML: write-only. Sets the inner HTML value of an element.
722 html: makeHandler(function($element, value) {
723 $element.html(value);
724 }),
725
726 // Options: write-only. Sets option items to a <select> element, then updates the value.
727 options: makeHandler({
728 init: function($element, value, context, bindings) {
729 this.e = bindings.optionsEmpty;
730 this.d = bindings.optionsDefault;
731 this.v = bindings.value;
732 },
733 set: function($element, value) {
734
735 // Pre-compile empty and default option values:
736 // both values MUST be accessed, for two reasons:
737 // 1) we need to need to guarentee that both values are reached for mapping purposes.
738 // 2) we'll need their values anyway to determine their defined/undefined status.
739 var self = this;
740 var optionsEmpty = readAccessor(self.e);
741 var optionsDefault = readAccessor(self.d);
742 var currentValue = readAccessor(self.v);
743 var options = isCollection(value) ? value.models : value;
744 var numOptions = options.length;
745 var enabled = true;
746 var html = '';
747
748 // No options or default, and has an empty options placeholder:
749 // display placeholder and disable select menu.
750 if (!numOptions && !optionsDefault && optionsEmpty) {
751
752 html += self.opt(optionsEmpty, numOptions);
753 enabled = false;
754
755 } else {
756 // Try to populate default option and options list:
757
758 // Configure list with a default first option, if defined:
759 if (optionsDefault) {
760 options = [ optionsDefault ].concat(options);
761 }
762
763 // Create all option items:
764 _.each(options, function(option, index) {
765 html += self.opt(option, numOptions);
766 });
767 }
768
769 // Set new HTML to the element and toggle disabled status:
770 $element.html(html).prop('disabled', !enabled).val(currentValue);
771
772 // Pull revised value with new options selection state:
773 var revisedValue = $element.val();
774
775 // Test if the current value was successfully applied:
776 // if not, set the new selection state into the model.
777 if (self.v && !_.isEqual(currentValue, revisedValue)) {
778 self.v(revisedValue);
779 }
780 },
781 opt: function(option, numOptions) {
782 // Set both label and value as the raw option object by default:
783 var label = option;
784 var value = option;
785 var textAttr = bindingSettings.optionText;
786 var valueAttr = bindingSettings.optionValue;
787
788 // Dig deeper into label/value settings for non-primitive values:
789 if (isObject(option)) {
790 // Extract a label and value from each object:
791 // a model's 'get' method is used to access potential computed values.
792 label = isModel(option) ? option.get(textAttr) : option[ textAttr ];
793 value = isModel(option) ? option.get(valueAttr) : option[ valueAttr ];
794 }
795
796 return ['<option value="', value, '">', label, '</option>'].join('');
797 },
798 clean: function() {
799 this.d = this.e = this.v = 0;
800 }
801 }),
802
803 // Template: write-only. Renders the bound element with an Underscore template.
804 template: makeHandler({
805 init: function($element, value, context) {
806 var raw = $element.find('script,template');
807 this.t = _.template(raw.length ? raw.html() : $element.html());
808
809 // If an array of template attributes was provided,
810 // then replace array with a compiled hash of attribute accessors:
811 if (isArray(value)) {
812 return _.pick(context, value);
813 }
814 },
815 set: function($element, value) {
816 value = isModel(value) ? value.toJSON({computed:true}) : value;
817 $element.html(this.t(value));
818 },
819 clean: function() {
820 this.t = null;
821 }
822 }),
823
824 // Text: read-write. Gets and sets the text value of an element.
825 text: makeHandler({
826 get: function($element) {
827 return $element.text();
828 },
829 set: function($element, value) {
830 $element.text(value);
831 }
832 }),
833
834 // Toggle: write-only. Toggles the visibility of an element.
835 toggle: makeHandler(function($element, value) {
836 $element.toggle(!!value);
837 }),
838
839 // Value: read-write. Gets and sets the value of a form element.
840 value: makeHandler({
841 get: function($element) {
842 return $element.val();
843 },
844 set: function($element, value) {
845 try {
846 if ($element.val() + '' != value + '') $element.val(value);
847 } catch (error) {
848 // Error setting value: IGNORE.
849 // This occurs in IE6 while attempting to set an undefined multi-select option.
850 // unfortuantely, jQuery doesn't gracefully handle this error for us.
851 // remove this try/catch block when IE6 is officially deprecated.
852 }
853 }
854 })
855 };
856
857
858 // Binding Filters
859 // ---------------
860 // Filters are special binding handlers that may be invoked while binding;
861 // they will return a wrapper function used to modify how accessors are read.
862
863 // Partial application wrapper for creating binding filters:
864 function makeFilter(handler) {
865 return function() {
866 var params = arguments;
867 var read = isFunction(handler) ? handler : handler.get;
868 var write = handler.set;
869 return function(value) {
870 return isUndefined(value) ?
871 read.apply(this, _.map(params, readAccessor)) :
872 params[0]((write ? write : read).call(this, value));
873 };
874 };
875 }
876
877 var bindingFilters = {
878 // Positive collection assessment [read-only]:
879 // Tests if all of the provided accessors are truthy (and).
880 all: makeFilter(function() {
881 var params = arguments;
882 for (var i=0, len=params.length; i < len; i++) {
883 if (!params[i]) return false;
884 }
885 return true;
886 }),
887
888 // Partial collection assessment [read-only]:
889 // tests if any of the provided accessors are truthy (or).
890 any: makeFilter(function() {
891 var params = arguments;
892 for (var i=0, len=params.length; i < len; i++) {
893 if (params[i]) return true;
894 }
895 return false;
896 }),
897
898 // Collection length accessor [read-only]:
899 // assumes accessor value to be an Array or Collection; defaults to 0.
900 length: makeFilter(function(value) {
901 return value.length || 0;
902 }),
903
904 // Negative collection assessment [read-only]:
905 // tests if none of the provided accessors are truthy (and not).
906 none: makeFilter(function() {
907 var params = arguments;
908 for (var i=0, len=params.length; i < len; i++) {
909 if (params[i]) return false;
910 }
911 return true;
912 }),
913
914 // Negation [read-only]:
915 not: makeFilter(function(value) {
916 return !value;
917 }),
918
919 // Formats one or more accessors into a text string:
920 // ('$1 $2 did $3', firstName, lastName, action)
921 format: makeFilter(function(str) {
922 var params = arguments;
923
924 for (var i=1, len=params.length; i < len; i++) {
925 // TODO: need to make something like this work: (?<!\\)\$1
926 str = str.replace(new RegExp('\\$'+i, 'g'), params[i]);
927 }
928 return str;
929 }),
930
931 // Provides one of two values based on a ternary condition:
932 // uses first param (a) as condition, and returns either b (truthy) or c (falsey).
933 select: makeFilter(function(condition, truthy, falsey) {
934 return condition ? truthy : falsey;
935 }),
936
937 // CSV array formatting [read-write]:
938 csv: makeFilter({
939 get: function(value) {
940 value = String(value);
941 return value ? value.split(',') : [];
942 },
943 set: function(value) {
944 return isArray(value) ? value.join(',') : value;
945 }
946 }),
947
948 // Integer formatting [read-write]:
949 integer: makeFilter(function(value) {
950 return value ? parseInt(value, 10) : 0;
951 }),
952
953 // Float formatting [read-write]:
954 decimal: makeFilter(function(value) {
955 return value ? parseFloat(value) : 0;
956 })
957 };
958
959 // Define allowed binding parameters:
960 // These params may be included in binding handlers without throwing errors.
961 var allowedParams = {
962 events: 1,
963 itemView: 1,
964 optionsDefault: 1,
965 optionsEmpty: 1
966 };
967
968 // Define binding API:
969 Epoxy.binding = {
970 allowedParams: allowedParams,
971 addHandler: function(name, handler) {
972 bindingHandlers[ name ] = makeHandler(handler);
973 },
974 addFilter: function(name, handler) {
975 bindingFilters[ name ] = makeFilter(handler);
976 },
977 config: function(settings) {
978 _.extend(bindingSettings, settings);
979 },
980 emptyCache: function() {
981 bindingCache = {};
982 }
983 };
984
985
986 // Epoxy.View
987 // ----------
988 var viewMap;
989 var viewProps = ['viewModel', 'bindings', 'bindingFilters', 'bindingHandlers', 'bindingSources', 'computeds'];
990
991 Epoxy.View = Backbone.View.extend({
992 _super: Backbone.View,
993
994 // Backbone.View constructor override:
995 // sets up binding controls around call to super.
996 constructor: function(options) {
997 _.extend(this, _.pick(options||{}, viewProps));
998 _super(this, 'constructor', arguments);
999 console.time("bindingsTime");
1000 this.applyBindings();
1001 console.timeEnd("bindingsTime");
1002 },
1003
1004 // Bindings list accessor:
1005 b: function() {
1006 return this._b || (this._b = []);
1007 },
1008
1009 // Bindings definition:
1010 // this setting defines a DOM attribute name used to query for bindings.
1011 // Alternatively, this be replaced with a hash table of key/value pairs,
1012 // where 'key' is a DOM query and 'value' is its binding declaration.
1013 bindings: 'data-bind',
1014
1015 // Setter options:
1016 // Defines an optional hashtable of options to be passed to setter operations.
1017 // Accepts a custom option '{save:true}' that will write to the model via ".save()".
1018 setterOptions: null,
1019
1020 // Compiles a model context, then applies bindings to the view:
1021 // All Model->View relationships will be baked at the time of applying bindings;
1022 // changes in configuration to source attributes or view bindings will require a complete re-bind.
1023 applyBindings: function() {
1024 this.removeBindings();
1025
1026 var self = this;
1027 var sources = _.clone(_.result(self, 'bindingSources'));
1028 var declarations = self.bindings;
1029 var options = self.setterOptions;
1030 var handlers = _.clone(bindingHandlers);
1031 var filters = _.clone(bindingFilters);
1032 var context = self._c = {};
1033
1034 // Compile a complete set of binding handlers for the view:
1035 // mixes all custom handlers into a copy of default handlers.
1036 // Custom handlers defined as plain functions are registered as read-only setters.
1037 _.each(_.result(self, 'bindingHandlers')||{}, function(handler, name) {
1038 handlers[ name ] = makeHandler(handler);
1039 });
1040
1041 // Compile a complete set of binding filters for the view:
1042 // mixes all custom filters into a copy of default filters.
1043 _.each(_.result(self, 'bindingFilters')||{}, function(filter, name) {
1044 filters[ name ] = makeFilter(filter);
1045 });
1046
1047 // Add native 'model' and 'collection' data sources:
1048 self.model = addSourceToViewContext(self, context, options, 'model');
1049 self.viewModel = addSourceToViewContext(self, context, options, 'viewModel');
1050 self.collection = addSourceToViewContext(self, context, options, 'collection');
1051
1052 // Support legacy "collection.view" API for rendering list items:
1053 // **Deprecated: will be removed after next release*.*
1054 if (self.collection && self.collection.view) {
1055 self.itemView = self.collection.view;
1056 }
1057
1058 // Add all additional data sources:
1059 if (sources) {
1060 _.each(sources, function(source, sourceName) {
1061 sources[ sourceName ] = addSourceToViewContext(sources, context, options, sourceName, sourceName);
1062 });
1063
1064 // Reapply resulting sources to view instance.
1065 self.bindingSources = sources;
1066 }
1067
1068 // Add all computed view properties:
1069 _.each(_.result(self, 'computeds')||{}, function(computed, name) {
1070 var getter = isFunction(computed) ? computed : computed.get;
1071 var setter = computed.set;
1072 var deps = computed.deps;
1073
1074 context[ name ] = function(value) {
1075 return (!isUndefined(value) && setter) ?
1076 setter.call(self, value) :
1077 getter.apply(self, getDepsFromViewContext(self._c, deps));
1078 };
1079 });
1080
1081 // Create all bindings:
1082 // bindings are created from an object hash of query/binding declarations,
1083 // OR based on queried DOM attributes.
1084 if (isObject(declarations)) {
1085
1086 // Object declaration method:
1087 // {'span.my-element': 'text:attribute'}
1088
1089 _.each(declarations, function(elementDecs, selector) {
1090 // Get DOM jQuery reference:
1091 var $element = queryViewForSelector(self, selector);
1092
1093 // Ignore empty DOM queries (without errors):
1094 if ($element.length) {
1095 bindElementToView(self, $element, elementDecs, context, handlers, filters);
1096 }
1097 });
1098
1099 } else {
1100
1101 // DOM attributes declaration method:
1102 // <span data-bind='text:attribute'></span>
1103
1104 // Create bindings for each matched element:
1105 queryViewForSelector(self, '['+declarations+']').each(function() {
1106 var $element = Backbone.$(this);
1107 bindElementToView(self, $element, $element.attr(declarations), context, handlers, filters);
1108 });
1109 }
1110 },
1111
1112 // Gets a value from the binding context:
1113 getBinding: function(attribute) {
1114 return accessViewContext(this._c, attribute);
1115 },
1116
1117 // Sets a value to the binding context:
1118 setBinding: function(attribute, value) {
1119 return accessViewContext(this._c, attribute, value);
1120 },
1121
1122 // Disposes of all view bindings:
1123 removeBindings: function() {
1124 this._c = null;
1125
1126 if (this._b) {
1127 while (this._b.length) {
1128 this._b.pop().dispose();
1129 }
1130 }
1131 },
1132
1133 // Backbone.View.remove() override:
1134 // unbinds the view before performing native removal tasks.
1135 remove: function() {
1136 this.removeBindings();
1137 _super(this, 'remove', arguments);
1138 }
1139
1140 }, mixins);
1141
1142 // Epoxy.View -> Private
1143 // ---------------------
1144
1145 // Adds a data source to a view:
1146 // Data sources are Backbone.Model and Backbone.Collection instances.
1147 // @param source: a source instance, or a function that returns a source.
1148 // @param context: the working binding context. All bindings in a view share a context.
1149 function addSourceToViewContext(source, context, options, name, prefix) {
1150
1151 // Resolve source instance:
1152 source = _.result(source, name);
1153
1154 // Ignore missing sources, and invoke non-instances:
1155 if (!source) return;
1156
1157 // Add Backbone.Model source instance:
1158 if (isModel(source)) {
1159
1160 // Establish source prefix:
1161 prefix = prefix ? prefix+'_' : '';
1162
1163 // Create a read-only accessor for the model instance:
1164 context['$'+name] = function() {
1165 viewMap && viewMap.push([source, 'change']);
1166 return source;
1167 };
1168
1169 // Compile all model attributes as accessors within the context:
1170 _.each(source.toJSON({computed:true}), function(value, attribute) {
1171
1172 // Create named accessor functions:
1173 // -> Attributes from 'view.model' use their normal names.
1174 // -> Attributes from additional sources are named as 'source_attribute'.
1175 context[prefix+attribute] = function(value) {
1176 return accessViewDataAttribute(source, attribute, value, options);
1177 };
1178 });
1179 }
1180 // Add Backbone.Collection source instance:
1181 else if (isCollection(source)) {
1182
1183 // Create a read-only accessor for the collection instance:
1184 context['$'+name] = function() {
1185 viewMap && viewMap.push([source, 'reset add remove sort update']);
1186 return source;
1187 };
1188 }
1189
1190 // Return original object, or newly constructed data source:
1191 return source;
1192 }
1193
1194 // Attribute data accessor:
1195 // exchanges individual attribute values with model sources.
1196 // This function is separated out from the accessor creation process for performance.
1197 // @param source: the model data source to interact with.
1198 // @param attribute: the model attribute to read/write.
1199 // @param value: the value to set, or 'undefined' to get the current value.
1200 function accessViewDataAttribute(source, attribute, value, options) {
1201 // Register the attribute to the bindings map, if enabled:
1202 viewMap && viewMap.push([source, 'change:'+attribute]);
1203
1204 // Set attribute value when accessor is invoked with an argument:
1205 if (!isUndefined(value)) {
1206
1207 // Set Object (non-null, non-array) hashtable value:
1208 if (!isObject(value) || isArray(value) || _.isDate(value)) {
1209 var val = value;
1210 value = {};
1211 value[attribute] = val;
1212 }
1213
1214 // Set value:
1215 return options && options.save ? source.save(value, options) : source.set(value, options);
1216 }
1217
1218 // Get the attribute value by default:
1219 return source.get(attribute);
1220 }
1221
1222
1223var queryViewForSelectorTotalTime = 0;
1224 // Queries element selectors within a view:
1225 // matches elements within the view, and the view's container element.
1226 function queryViewForSelector(view, selector) {
1227
1228 var timeIni = performance.now();
1229 // console.time("queryViewForSelector");
1230
1231 if (selector === ':el') return view.$el;
1232 var $elements = view.$(selector);
1233
1234 // Include top-level view in bindings search:
1235 if (view.$el.is(selector)) {
1236 $elements = $elements.add(view.$el);
1237 }
1238
1239 var timeEnd = performance.now();
1240 queryViewForSelectorTotalTime += timeEnd - timeIni;
1241
1242 console.log( queryViewForSelectorTotalTime );
1243 //console.timeEnd("queryViewForSelector");
1244 return $elements;
1245 }
1246
1247 // Binds an element into a view:
1248 // The element's declarations are parsed, then a binding is created for each declared handler.
1249 // @param view: the parent View to bind into.
1250 // @param $element: the target element (as jQuery) to bind.
1251 // @param declarations: the string of binding declarations provided for the element.
1252 // @param context: a compiled binding context with all availabe view data.
1253 // @param handlers: a compiled handlers table with all native/custom handlers.
1254 function bindElementToView(view, $element, declarations, context, handlers, filters) {
1255
1256 // Parse localized binding context:
1257 // parsing function is invoked with 'filters' and 'context' properties made available,
1258 // yeilds a native context object with element-specific bindings defined.
1259 try {
1260 var parserFunct = bindingCache[declarations] || (bindingCache[declarations] = new Function('$f','$c','with($f){with($c){return{'+ declarations +'}}}'));
1261 var bindings = parserFunct(filters, context);
1262 } catch (error) {
1263 throw('Error parsing bindings: "'+declarations +'"\n>> '+error);
1264 }
1265
1266 // Format the 'events' option:
1267 // include events from the binding declaration along with a default 'change' trigger,
1268 // then format all event names with a '.epoxy' namespace.
1269 var events = _.map(_.union(bindings.events || [], ['change']), function(name) {
1270 return name+'.epoxy';
1271 }).join(' ');
1272
1273 // Apply bindings from native context:
1274 _.each(bindings, function(accessor, handlerName) {
1275
1276 // Validate that each defined handler method exists before binding:
1277 if (handlers.hasOwnProperty(handlerName)) {
1278 // Create and add binding to the view's list of handlers:
1279 view.b().push(new EpoxyBinding(view, $element, handlers[handlerName], accessor, events, context, bindings));
1280 } else if (!allowedParams.hasOwnProperty(handlerName)) {
1281 throw('binding handler "'+ handlerName +'" is not defined.');
1282 }
1283 });
1284 }
1285
1286 // Gets and sets view context data attributes:
1287 // used by the implementations of "getBinding" and "setBinding".
1288 function accessViewContext(context, attribute, value) {
1289 if (context && context.hasOwnProperty(attribute)) {
1290 return isUndefined(value) ? readAccessor(context[attribute]) : context[attribute](value);
1291 }
1292 }
1293
1294 // Accesses an array of dependency properties from a view context:
1295 // used for mapping view dependencies by manual declaration.
1296 function getDepsFromViewContext(context, attributes) {
1297 var values = [];
1298 if (attributes && context) {
1299 for (var i=0, len=attributes.length; i < len; i++) {
1300 values.push(attributes[i] in context ? context[ attributes[i] ]() : null);
1301 }
1302 }
1303 return values;
1304 }
1305
1306
1307 // Epoxy.View -> Binding
1308 // ---------------------
1309 // The binding object connects an element to a bound handler.
1310 // @param view: the view object this binding is attached to.
1311 // @param $element: the target element (as jQuery) to bind.
1312 // @param handler: the handler object to apply (include all handler methods).
1313 // @param accessor: an accessor method from the binding context that exchanges data with the model.
1314 // @param events:
1315 // @param context:
1316 // @param bindings:
1317 function EpoxyBinding(view, $element, handler, accessor, events, context, bindings) {
1318
1319 var self = this;
1320 var tag = ($element[0].tagName).toLowerCase();
1321 var changable = (tag == 'input' || tag == 'select' || tag == 'textarea' || $element.prop('contenteditable') == 'true');
1322 var triggers = [];
1323 var reset = function(target) {
1324 self.$el && self.set(self.$el, readAccessor(accessor), target);
1325 };
1326
1327 self.view = view;
1328 self.$el = $element;
1329 self.evt = events;
1330 _.extend(self, handler);
1331
1332 // Initialize the binding:
1333 // allow the initializer to redefine/modify the attribute accessor if needed.
1334 accessor = self.init(self.$el, readAccessor(accessor), context, bindings) || accessor;
1335
1336 // Set default binding, then initialize & map bindings:
1337 // each binding handler is invoked to populate its initial value.
1338 // While running a handler, all accessed attributes will be added to the handler's dependency map.
1339 viewMap = triggers;
1340 reset();
1341 viewMap = null;
1342
1343 // Configure READ/GET-able binding. Requires:
1344 // => Form element.
1345 // => Binding handler has a getter method.
1346 // => Value accessor is a function.
1347 if (changable && handler.get && isFunction(accessor)) {
1348 self.$el.on(events, function(evt) {
1349 accessor(self.get(self.$el, readAccessor(accessor), evt));
1350 });
1351 }
1352
1353 // Configure WRITE/SET-able binding. Requires:
1354 // => One or more events triggers.
1355 if (triggers.length) {
1356 for (var i=0, len=triggers.length; i < len; i++) {
1357 self.listenTo(triggers[i][0], triggers[i][1], reset);
1358 }
1359 }
1360 }
1361
1362 _.extend(EpoxyBinding.prototype, Backbone.Events, {
1363
1364 // Pass-through binding methods:
1365 // for override by actual implementations.
1366 init: blankMethod,
1367 get: blankMethod,
1368 set: blankMethod,
1369 clean: blankMethod,
1370
1371 // Destroys the binding:
1372 // all events and managed sub-views are killed.
1373 dispose: function() {
1374 this.clean();
1375 this.stopListening();
1376 this.$el.off(this.evt);
1377 this.$el = this.view = null;
1378 }
1379 });
1380
1381 return Epoxy;
1382}));