// Backbone.Stickit v0.8.0, MIT Licensed // Copyright (c) 2012 The New York Times, CMS Group, Matthew DeLambo (function (factory) { // Set up Stickit appropriately for the environment. Start with AMD. if (typeof define === 'function' && define.amd) { define(['underscore', 'backbone', 'exports'], factory); } // Next for Node.js or CommonJS. else if (typeof exports === 'object') { factory(require('underscore'), require('backbone'), exports); } // Finally, as a browser global. else { factory(_, Backbone, {}); } }(function (_, Backbone, Stickit) { // Stickit Namespace // -------------------------- Stickit._handlers = []; Stickit.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 // -------------------- Stickit.ViewMixin = { // Collection of model event bindings. // [{model,event,fn,config}, ...] _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, bindingSelector) { // Support bindings hash in place of selector. if (_.isObject(bindingSelector)) { _.each(_.keys(bindingSelector), function(selector) { this.unstickit(model, selector); }, this); return; } var models = [], destroyFns = []; _.each(this._modelBindings, function(binding, i) { if (model && binding.model !== model) { return; } if (bindingSelector && binding.config.selector != bindingSelector) return; destroyFns.push(binding.config._destroy); binding.model.off(binding.event, binding.fn); models.push(binding.model); delete this._modelBindings[i]; }, this); // Trigger an event for each model that was unbound. _.invoke(_.uniq(models), 'trigger', 'stickit:unstuck', this.cid); // Call `_destroy` on a unique list of the binding callbacks. _.each(_.uniq(destroyFns), function(fn) { fn.call(this); }, this); // Cleanup the null values. this._modelBindings = _.compact(this._modelBindings); this.$el.off('.stickit' + (model ? '.' + model.cid : ''), bindingSelector); }, // Using `this.bindings` configuration or the `optionalBindingsConfig`, binds `this.model` // or the `optionalModel` to elements in the view. stickit: function(optionalModel, optionalBindingsConfig) { var model = optionalModel || this.model, bindings = optionalBindingsConfig || _.result(this, "bindings") || {}; this._modelBindings || (this._modelBindings = []); // Iterate through the selectors in the bindings configuration and configure // the various options for each field. this.addBinding(model, bindings); // Wrap `view.remove` to unbind stickit model and dom events. var remove = this.remove; if (!remove.stickitWrapped) { this.remove = function() { var ret = this; this.unstickit(); if (remove) ret = remove.apply(this, arguments); return ret; }; } this.remove.stickitWrapped = true; return this; }, // Add a single model binding to the view addBinding: function(optionalModel, second, _binding) { var $el, options, modelAttr, config, selector, model = optionalModel || this.model, namespace = '.stickit.' + model.cid, binding = _binding || {}, bindId = _.uniqueId(); // Allow jQuery-style {key: val} event maps if (_.isString(second)) { selector = second; } else { var bindings = second; _.each(bindings, function(v, selector) { this.addBinding(model, selector, bindings[selector]); }, this); return; } // Support ':el' selector - special case selector for the view managed delegate. $el = selector === ':el' ? this.$el : this.$(selector); this.unstickit(model, 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}; // Handle case where `observe` is in the form of a function. if (_.isFunction(binding.observe)) binding.observe = binding.observe.call(this); config = getConfiguration($el, binding); config.selector = selector; modelAttr = config.observe; // Create the model set options with a unique `bindId` so that we // can avoid double-binding in the `change:attribute` event handler. config.bindId = bindId; // Add a reference to the view for handlers of stickitChange events config.view = this; options = _.extend({stickitChange:config}, config.setOptions); // Add a `_destroy` callback to the configuration, in case `destroy` // is a named function and we need a unique function when unsticking. config._destroy = function() { applyViewFn(this, config.destroy, $el, model, config); }; initializeAttributes(this, $el, config, model, modelAttr); initializeClasses(this, $el, config, model, modelAttr); initializeVisible(this, $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(this, $el, event, config, _.rest(arguments)); // Don't update the model if false is returned from the `updateModel` configuration. if (evaluateBoolean(this, config.updateModel, val, event, config)) setAttr(model, modelAttr, val, options, this, config); }; method = _.bind(method, this); if (selector === ':el') this.$el.on(event, method); else this.$el.on(event, selector, method); }, this); // 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, this, 'change:'+attr, config, function(model, val, options) { var changeId = options && options.stickitChange && options.stickitChange.bindId || null; if (changeId !== bindId) updateViewBindEl(this, $el, config, getAttr(model, modelAttr, config, this), model); }); }, this); updateViewBindEl(this, $el, config, getAttr(model, modelAttr, config, this), model, true); } // After each binding is setup, call the `initialize` callback. applyViewFn(this, config.initialize, $el, model, config); } }; _.extend(Backbone.View.prototype, Stickit.ViewMixin); // 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) ? evaluatePath(view,fn) : fn).apply(view, _.rest(arguments, 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, 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, config, fn) { model.on(event, fn, view); view._modelBindings.push({model:model, event:event, fn:fn, config:config}); }; // Prepares the given `val`ue and sets it into the `model`. var setAttr = function(model, attr, val, options, context, config) { var value = {}; if (config.onSet) val = applyViewFn(context, config.onSet, val, config); if (config.set) applyViewFn(context, config.set, attr, val, options, config); else { value[attr] = val; // If `observe` is defined as an array and `onSet` returned // an array, then map attributes to their values. if (_.isArray(attr) && _.isArray(val)) { value = _.reduce(attr, function(memo, attribute, index) { memo[attribute] = _.has(val, index) ? val[index] : null; return memo; }, {}); } model.set(value, 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) { return model[config.escape ? 'escape' : 'get'](field); }, sanitizeVal = function(val) { return val == null ? '' : val; }; val = _.isArray(attr) ? _.map(attr, retrieveVal) : retrieveVal(attr); if (config.onGet) val = applyViewFn(context, config.onGet, val, config); return _.isArray(val) ? _.map(val, sanitizeVal) : sanitizeVal(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 given `binding`. var getConfiguration = Stickit.getConfiguration = function($el, binding) { var handlers = [{ updateModel: false, updateMethod: 'text', update: function($el, val, m, opts) { if ($el[opts.updateMethod]) $el[opts.updateMethod](val); }, getVal: function($el, e, opts) { return $el[opts.updateMethod](); } }]; handlers = handlers.concat(_.filter(Stickit._handlers, function(handler) { return $el.is(handler.selector); })); handlers.push(binding); var config = _.extend.apply(_, handlers); // `updateView` is defaulted to false for configutrations with // `visible`; otherwise, `updateView` is defaulted to true. if (config.visible && !_.has(config, 'updateView')) config.updateView = false; else if (!_.has(config, 'updateView')) config.updateView = true; 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', 'indeterminate', 'loop', 'multiple', 'open', 'readonly', 'required', 'scoped', 'selected']; _.each(config.attributes || [], function(attrConfig) { var lastClass = '', observed, updateAttr; attrConfig = _.clone(attrConfig); 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, config, updateAttr); }); updateAttr(); }); }; var initializeClasses = function(view, $el, config, model, modelAttr) { _.each(config.classes || [], function(classConfig, name) { var observed, updateClass; observed = classConfig.observe || classConfig; updateClass = function() { var val = getAttr(model, observed, classConfig, view); $el.toggleClass(name, !!val); }; _.each(_.flatten([observed]), function(attr) { observeModelEvent(model, view, 'change:' + attr, config, updateClass); }); updateClass(); }); }; // 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 { $el.toggle(isVisible); } }; _.each(_.flatten([modelAttr]), function(attr) { observeModelEvent(model, view, 'change:' + attr, config, 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; applyViewFn(view, config.update, $el, val, model, config); if (!isInitializing) applyViewFn(view, config.afterUpdate, $el, val, config); }; // Default Handlers // ---------------- Stickit.addHandler([{ selector: '[contenteditable="true"]', updateMethod: 'html', events: ['input', 'change'] }, { selector: 'input', events: ['propertychange', 'input', 'change'], update: function($el, val) { $el.val(val); }, getVal: function($el) { return $el.val(); } }, { selector: 'textarea', events: ['propertychange', 'input', 'change'], 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 = []); $el.each(function(i, el) { var checkbox = Backbone.$(el); var checked = _.contains(val, checkbox.val()); checkbox.prop('checked', checked); }); } else { var checked = _.isBoolean(val) ? val : val === $el.val(); $el.prop('checked', checked); } }, getVal: function($el) { var val; if ($el.length > 1) { val = _.reduce($el, function(memo, el) { var checkbox = Backbone.$(el); if (checkbox.prop('checked')) memo.push(checkbox.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) { val = val ? $el.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 `