(function($) { // Backbone.Stickit Namespace // -------------------------- Backbone.Stickit = { _handlers: [], addHandler: function(handlers) { // Fill-in default values. handlers = _.map(_.flatten([handlers]), function(handler) { return _.extend({ updateModel: true, updateView: true, updateMethod: 'text' }, handler); }); this._handlers = this._handlers.concat(handlers); } }; // Backbone.View Mixins // -------------------- _.extend(Backbone.View.prototype, { // Collection of model event bindings. // [{model,event,fn}, ...] _modelBindings: null, // Unbind the model and event bindings from `this._modelBindings` and // `this.$el`. If the optional `model` parameter is defined, then only // delete bindings for the given `model` and its corresponding view events. unstickit: function(model) { _.each(this._modelBindings, _.bind(function(binding, i) { if (model && binding.model !== model) return false; binding.model.off(binding.event, binding.fn); delete this._modelBindings[i]; }, this)); this._modelBindings = _.compact(this._modelBindings); this.$el.off('.stickit' + (model ? '.' + model.cid : '')); }, // Using `this.bindings` configuration or the `optionalBindingsConfig`, binds `this.model` // or the `optionalModel` to elements in the view. stickit: function(optionalModel, optionalBindingsConfig) { var self = this, model = optionalModel || this.model, namespace = '.stickit.' + model.cid, bindings = optionalBindingsConfig || this.bindings || {}; this._modelBindings || (this._modelBindings = []); this.unstickit(model); // Iterate through the selectors in the bindings configuration and configure // the various options for each field. _.each(_.keys(bindings), function(selector) { var $el, options, modelAttr, config, binding = bindings[selector] || {}, bindKey = _.uniqueId(); // Support ':el' selector - special case selector for the view managed delegate. if (selector != ':el') $el = self.$(selector); else { $el = self.$el; selector = ''; } // Fail fast if the selector didn't match an element. if (!$el.length) return; // Allow shorthand setting of model attributes - `'selector':'observe'`. if (_.isString(binding)) binding = {observe:binding}; config = getConfiguration($el, binding); modelAttr = config.observe; // Create the model set options with a unique `bindKey` so that we // can avoid double-binding in the `change:attribute` event handler. options = _.extend({bindKey:bindKey}, config.setOptions || {}); initializeAttributes(self, $el, config, model, modelAttr); initializeVisible(self, $el, config, model, modelAttr); if (modelAttr) { // Setup one-way, form element to model, bindings. _.each(config.events || [], function(type) { var event = type + namespace; var method = function(event) { var val = config.getVal.call(self, $el, event, config); // Don't update the model if false is returned from the `updateModel` configuration. if (evaluateBoolean(self, config.updateModel, val, config)) setAttr(model, modelAttr, val, options, self, config); }; if (selector === '') self.$el.on(event, method); else self.$el.on(event, selector, method); }); // Setup a `change:modelAttr` observer to keep the view element in sync. // `modelAttr` may be an array of attributes or a single string value. _.each(_.flatten([modelAttr]), function(attr) { observeModelEvent(model, self, 'change:'+attr, function(model, val, options) { if (options == null || options.bindKey != bindKey) updateViewBindEl(self, $el, config, getAttr(model, modelAttr, config, self), model); }); }); updateViewBindEl(self, $el, config, getAttr(model, modelAttr, config, self), model, true); } // After each binding is setup, call the `initialize` callback. applyViewFn(self, config.initialize, $el, model, config); }); // Wrap `view.remove` to unbind stickit model and dom events. this.remove = _.wrap(this.remove, function(oldRemove) { self.unstickit(); if (oldRemove) oldRemove.call(self); }); } }); // Helpers // ------- // Evaluates the given `path` (in object/dot-notation) relative to the given // `obj`. If the path is null/undefined, then the given `obj` is returned. var evaluatePath = function(obj, path) { var parts = (path || '').split('.'); var result = _.reduce(parts, function(memo, i) { return memo[i]; }, obj); return result == null ? obj : result; }; // If the given `fn` is a string, then view[fn] is called, otherwise it is // a function that should be executed. var applyViewFn = function(view, fn) { if (fn) return (_.isString(fn) ? view[fn] : fn).apply(view, _.toArray(arguments).slice(2)); }; var getSelectedOption = function($select) { return $select.find('option').not(function(){ return !this.selected; }); }; // Given a function, string (view function reference), or a boolean // value, returns the truthy result. Any other types evaluate as false. var evaluateBoolean = function(view, reference) { if (_.isBoolean(reference)) return reference; else if (_.isFunction(reference) || _.isString(reference)) return applyViewFn.apply(this, _.toArray(arguments)); return false; }; // Setup a model event binding with the given function, and track the event // in the view's _modelBindings. var observeModelEvent = function(model, view, event, fn) { model.on(event, fn, view); view._modelBindings.push({model:model, event:event, fn:fn}); }; // Prepares the given `val`ue and sets it into the `model`. var setAttr = function(model, attr, val, options, context, config) { if (config.onSet) val = applyViewFn(context, config.onSet, val, config); model.set(attr, val, options); }; // Returns the given `attr`'s value from the `model`, escaping and // formatting if necessary. If `attr` is an array, then an array of // respective values will be returned. var getAttr = function(model, attr, config, context) { var val, retrieveVal = function(field) { var retrieved = config.escape ? model.escape(field) : model.get(field); return _.isUndefined(retrieved) ? '' : retrieved; }; val = _.isArray(attr) ? _.map(attr, retrieveVal) : retrieveVal(attr); return config.onGet ? applyViewFn(context, config.onGet, val, config) : val; }; // Find handlers in `Backbone.Stickit._handlers` with selectors that match // `$el` and generate a configuration by mixing them in the order that they // were found with the with the givne `binding`. var getConfiguration = function($el, binding) { var handlers = [{ updateModel: false, updateView: true, updateMethod: 'text', update: function($el, val, m, opts) { $el[opts.updateMethod](val); }, getVal: function($el, e, opts) { return $el[opts.updateMethod](); } }]; _.each(Backbone.Stickit._handlers, function(handler) { if ($el.is(handler.selector)) handlers.push(handler); }); handlers.push(binding); var config = _.extend.apply(_, handlers); delete config.selector; return config; }; // Setup the attributes configuration - a list that maps an attribute or // property `name`, to an `observe`d model attribute, using an optional // `onGet` formatter. // // attributes: [{ // name: 'attributeOrPropertyName', // observe: 'modelAttrName' // onGet: function(modelAttrVal, modelAttrName) { ... } // }, ...] // var initializeAttributes = function(view, $el, config, model, modelAttr) { var props = ['autofocus', 'autoplay', 'async', 'checked', 'controls', 'defer', 'disabled', 'hidden', 'loop', 'multiple', 'open', 'readonly', 'required', 'scoped', 'selected']; _.each(config.attributes || [], function(attrConfig) { var lastClass = '', observed = attrConfig.observe || (attrConfig.observe = modelAttr), updateAttr = function() { var updateType = _.indexOf(props, attrConfig.name, true) > -1 ? 'prop' : 'attr', val = getAttr(model, observed, attrConfig, view); // If it is a class then we need to remove the last value and add the new. if (attrConfig.name == 'class') { $el.removeClass(lastClass).addClass(val); lastClass = val; } else $el[updateType](attrConfig.name, val); }; _.each(_.flatten([observed]), function(attr) { observeModelEvent(model, view, 'change:' + attr, updateAttr); }); updateAttr(); }); }; // If `visible` is configured, then the view element will be shown/hidden // based on the truthiness of the modelattr's value or the result of the // given callback. If a `visibleFn` is also supplied, then that callback // will be executed to manually handle showing/hiding the view element. // // observe: 'isRight', // visible: true, // or function(val, options) {} // visibleFn: function($el, isVisible, options) {} // optional handler // var initializeVisible = function(view, $el, config, model, modelAttr) { if (config.visible == null) return; var visibleCb = function() { var visible = config.visible, visibleFn = config.visibleFn, val = getAttr(model, modelAttr, config, view), isVisible = !!val; // If `visible` is a function then it should return a boolean result to show/hide. if (_.isFunction(visible) || _.isString(visible)) isVisible = applyViewFn(view, visible, val, config); // Either use the custom `visibleFn`, if provided, or execute the standard show/hide. if (visibleFn) applyViewFn(view, visibleFn, $el, isVisible, config); else { if (isVisible) $el.show(); else $el.hide(); } }; _.each(_.flatten([modelAttr]), function(attr) { observeModelEvent(model, view, 'change:' + attr, visibleCb); }); visibleCb(); }; // Update the value of `$el` using the given configuration and trigger the // `afterUpdate` callback. This action may be blocked by `config.updateView`. // // update: function($el, val, model, options) {}, // handler for updating // updateView: true, // defaults to true // afterUpdate: function($el, val, options) {} // optional callback // var updateViewBindEl = function(view, $el, config, val, model, isInitializing) { if (!evaluateBoolean(view, config.updateView, val, config)) return; config.update.call(view, $el, val, model, config); if (!isInitializing) applyViewFn(view, config.afterUpdate, $el, val, config); }; // Default Handlers // ---------------- Backbone.Stickit.addHandler([{ selector: '[contenteditable="true"]', updateMethod: 'html', events: ['keyup', 'change', 'paste', 'cut'] }, { selector: 'input', events: ['keyup', 'change', 'paste', 'cut'], update: function($el, val) { $el.val(val); }, getVal: function($el) { var val = $el.val(); if ($el.is('[type="number"]')) return val == null ? val : Number(val); else return val; } }, { selector: 'textarea', events: ['keyup', 'change', 'paste', 'cut'], update: function($el, val) { $el.val(val); }, getVal: function($el) { return $el.val(); } }, { selector: 'input[type="radio"]', events: ['change'], update: function($el, val) { $el.filter('[value="'+val+'"]').prop('checked', true); }, getVal: function($el) { return $el.filter(':checked').val(); } }, { selector: 'input[type="checkbox"]', events: ['change'], update: function($el, val, model, options) { if ($el.length > 1) { // There are multiple checkboxes so we need to go through them and check // any that have value attributes that match what's in the array of `val`s. val || (val = []); _.each($el, function(el) { if (_.indexOf(val, $(el).val()) > -1) $(el).prop('checked', true); else $(el).prop('checked', false); }); } else { if (_.isBoolean(val)) $el.prop('checked', val); else $el.prop('checked', val == $el.val()); } }, getVal: function($el) { var val; if ($el.length > 1) { val = _.reduce($el, function(memo, el) { if ($(el).prop('checked')) memo.push($(el).val()); return memo; }, []); } else { val = $el.prop('checked'); // If the checkbox has a value attribute defined, then // use that value. Most browsers use "on" as a default. var boxval = $el.val(); if (boxval != 'on' && boxval != null) { if (val) val = $el.val(); else val = null; } } return val; } }, { selector: 'select', events: ['change'], update: function($el, val, model, options) { var optList, selectConfig = options.selectOptions, list = selectConfig && selectConfig.collection || undefined, isMultiple = $el.prop('multiple'); // If there are no `selectOptions` then we assume that the `