diff --git a/www/documentation.html b/www/documentation.html index a7db6b4..d73e93b 100644 --- a/www/documentation.html +++ b/www/documentation.html @@ -34,7 +34,7 @@ @@ -124,6 +124,7 @@ @@ -1094,7 +1095,24 @@

addHandler

  • handler.clean() : optional. Called during handler disposal; offers a cleanup hook to remove any custom binding configuration as the handler is deprecated.
  • - + +
    +

    allowedParams

    + Backbone.Epoxy.binding.allowedParams +

    A hash defining all non-handler attributes that are allowed within binding declarations. When validating bindings, Epoxy will throw an error for binding declarations that do not have a handler method or an allowedParams key. By default, allowedParams defines the following allowed keys:

    + + +

    If you define a custom binding handler that utilizes additional params within the binding declaration, then you must specifically add these additional parameter names into the allowedParams hash.

    + +
    Epoxy.binding.allowedParams.myCustomParam = true;
    + +

    In the above example, Epoxy will no longer throw an error when it encounters a myCustomParam definition within a binding declaration.

    +
    +

    config

    Backbone.Epoxy.binding.config( settings ) diff --git a/www/index.html b/www/index.html index 46551f6..2b952a3 100644 --- a/www/index.html +++ b/www/index.html @@ -33,7 +33,7 @@ diff --git a/www/js/backbone.epoxy.js b/www/js/backbone.epoxy.js new file mode 100644 index 0000000..cb237a1 --- /dev/null +++ b/www/js/backbone.epoxy.js @@ -0,0 +1,1324 @@ +// Backbone.Epoxy + +// (c) 2013 Greg MacWilliam +// Freely distributed under the MIT license +// For usage and documentation: +// http://epoxyjs.org + +(function(root, factory) { + + + if (typeof exports !== 'undefined') { + // Define as CommonJS export: + module.exports = factory(require("underscore"), require("backbone")); + } else if (typeof define === 'function' && define.amd) { + // Define as AMD: + define(["underscore", "backbone"], factory); + } else { + // Just run it: + factory(root._, root.Backbone); + } + +}(this, function(_, Backbone) { + + // Epoxy namespace: + var Epoxy = Backbone.Epoxy = {}; + + // Object-type utils: + var array = Array.prototype; + var isUndefined = _.isUndefined; + var isFunction = _.isFunction; + var isObject = _.isObject; + var isArray = _.isArray; + var isModel = function(obj) { return obj instanceof Backbone.Model; }; + var isCollection = function(obj) { return obj instanceof Backbone.Collection; }; + var blankMethod = function() {}; + + // Static mixins API: + // added as a static member to Epoxy class objects (Model & View); + // generates a set of class attributes for mixin with other objects. + var mixins = { + mixin: function(extend) { + extend = extend || {}; + + for (var i in this.prototype) { + if (this.prototype.hasOwnProperty(i) && i !== 'constructor') { + extend[i] = this.prototype[i]; + } + } + return extend; + } + }; + + // Partial application for calling method implementations of a super-class object: + function superClass(sup) { + return function(instance, method, args) { + return sup.prototype[ method ].apply(instance, args); + }; + } + + + // Epoxy.Model + // ----------- + var modelMap; + var modelSuper = superClass(Backbone.Model); + var modelProps = ['computeds']; + + Epoxy.Model = Backbone.Model.extend({ + + // Backbone.Model constructor override: + // configures computed model attributes around the underlying native Backbone model. + constructor: function(attributes, options) { + _.extend(this, _.pick(options||{}, modelProps)); + modelSuper(this, 'constructor', arguments); + this.initComputeds(attributes, options); + }, + + // Gets a copy of a model attribute value: + // Array and Object values will return a shallow copy, + // primitive values will be returned directly. + getCopy: function(attribute) { + return _.clone(this.get(attribute)); + }, + + // Backbone.Model.get() override: + // provides access to computed attributes, + // and maps computed dependency references while establishing bindings. + get: function(attribute) { + + // Automatically register bindings while building out computed dependency graphs: + modelMap && modelMap.push(['change:'+attribute, this]); + + // Return a computed property value, if available: + if (this.hasComputed(attribute)) { + return this.c()[ attribute ].get(); + } + + // Default to native Backbone.Model get operation: + return modelSuper(this, 'get', arguments); + }, + + // Backbone.Model.set() override: + // will process any computed attribute setters, + // and then pass along all results to the underlying model. + set: function(key, value, options) { + var params = key; + + // Convert key/value arguments into {key:value} format: + if (params && !isObject(params)) { + params = {}; + params[ key ] = value; + } else { + options = value; + } + + // Default options definition: + options = options || {}; + + // Attempt to set computed attributes while not unsetting: + if (!options.unset) { + // All param properties are tested against computed setters, + // properties set to computeds will be removed from the params table. + // Optionally, an computed setter may return key/value pairs to be merged into the set. + params = deepModelSet(this, params, {}, []); + } + + // Pass all resulting set params along to the underlying Backbone Model. + return modelSuper(this, 'set', [params, options]); + }, + + // Backbone.Model.toJSON() override: + // adds a 'computed' option, specifying to include computed attributes. + toJSON: function(options) { + var json = modelSuper(this, 'toJSON', arguments); + + if (options && options.computed) { + _.each(this.c(), function(computed, attribute) { + json[ attribute ] = computed.value; + }); + } + + return json; + }, + + // Backbone.Model.destroy() override: + // clears all computed attributes before destroying. + destroy: function() { + this.clearComputeds(); + return modelSuper(this, 'destroy', arguments); + }, + + // Computed namespace manager: + // Allows the model to operate as a mixin. + c: function() { + return this._c || (this._c = {}); + }, + + // Initializes the Epoxy model: + // called automatically by the native constructor, + // or may be called manually when adding Epoxy as a mixin. + initComputeds: function(attributes, options) { + this.clearComputeds(); + + // Resolve computeds hash, and extend it with any preset attribute keys: + // TODO: write test. + var computeds = _.result(this, 'computeds')||{}; + computeds = _.extend(computeds, _.pick(attributes||{}, _.keys(computeds))); + + // Add all computed attributes: + _.each(computeds, function(params, attribute) { + params._init = 1; + this.addComputed(attribute, params); + }, this); + + // Initialize all computed attributes: + // all presets have been constructed and may reference each other now. + _.invoke(this.c(), 'init'); + }, + + // Adds a computed attribute to the model: + // computed attribute will assemble and return customized values. + // @param attribute (string) + // @param getter (function) OR params (object) + // @param [setter (function)] + // @param [dependencies ...] + addComputed: function(attribute, getter, setter) { + this.removeComputed(attribute); + + var params = getter; + var delayInit = params._init; + + // Test if getter and/or setter are provided: + if (isFunction(getter)) { + var depsIndex = 2; + + // Add getter param: + params = {}; + params._get = getter; + + // Test for setter param: + if (isFunction(setter)) { + params._set = setter; + depsIndex++; + } + + // Collect all additional arguments as dependency definitions: + params.deps = array.slice.call(arguments, depsIndex); + } + + // Create a new computed attribute: + this.c()[ attribute ] = new EpoxyComputedModel(this, attribute, params, delayInit); + return this; + }, + + // Tests the model for a computed attribute definition: + hasComputed: function(attribute) { + return this.c().hasOwnProperty(attribute); + }, + + // Removes an computed attribute from the model: + removeComputed: function(attribute) { + if (this.hasComputed(attribute)) { + this.c()[ attribute ].dispose(); + delete this.c()[ attribute ]; + } + return this; + }, + + // Removes all computed attributes: + clearComputeds: function() { + for (var attribute in this.c()) { + this.removeComputed(attribute); + } + return this; + }, + + // Internal array value modifier: + // performs array ops on a stored array value, then fires change. + // No action is taken if the specified attribute value is not an array. + modifyArray: function(attribute, method, options) { + var obj = this.get(attribute); + + if (isArray(obj) && isFunction(array[method])) { + var args = array.slice.call(arguments, 2); + var result = array[ method ].apply(obj, args); + options = options || {}; + + if (!options.silent) { + this.trigger('change:'+attribute+' change', this, array, options); + } + return result; + } + return null; + }, + + // Internal object value modifier: + // sets new property values on a stored object value, then fires change. + // No action is taken if the specified attribute value is not an object. + modifyObject: function(attribute, property, value, options) { + var obj = this.get(attribute); + var change = false; + + // If property is Object: + if (isObject(obj)) { + + options = options || {}; + + // Delete existing property in response to undefined values: + if (isUndefined(value) && obj.hasOwnProperty(property)) { + delete obj[property]; + change = true; + } + // Set new and/or changed property values: + else if (obj[ property ] !== value) { + obj[ property ] = value; + change = true; + } + + // Trigger model change: + if (change && !options.silent) { + this.trigger('change:'+attribute+' change', this, obj, options); + } + + // Return the modified object: + return obj; + } + return null; + } + }, mixins); + + // Epoxy.Model -> Private + // ---------------------- + + // Model deep-setter: + // Attempts to set a collection of key/value attribute pairs to computed attributes. + // Observable setters may digest values, and then return mutated key/value pairs for inclusion into the set operation. + // Values returned from computed setters will be recursively deep-set, allowing computeds to set other computeds. + // The final collection of resolved key/value pairs (after setting all computeds) will be returned to the native model. + // @param model: target Epoxy model on which to operate. + // @param toSet: an object of key/value pairs to attempt to set within the computed model. + // @param toReturn: resolved non-ovservable attribute values to be returned back to the native model. + // @param trace: property stack trace (prevents circular setter loops). + function deepModelSet(model, toSet, toReturn, stack) { + + // Loop through all setter properties: + for (var attribute in toSet) { + if (toSet.hasOwnProperty(attribute)) { + + // Pull each setter value: + var value = toSet[ attribute ]; + + if (model.hasComputed(attribute)) { + + // Has a computed attribute: + // comfirm attribute does not already exist within the stack trace. + if (!stack.length || !_.contains(stack, attribute)) { + + // Non-recursive: + // set and collect value from computed attribute. + value = model.c()[attribute].set(value); + + // Recursively set new values for a returned params object: + // creates a new copy of the stack trace for each new search branch. + if (value && isObject(value)) { + toReturn = deepModelSet(model, value, toReturn, stack.concat(attribute)); + } + + } else { + // Recursive: + // Throw circular reference error. + throw('Recursive setter: '+stack.join(' > ')); + } + + } else { + // No computed attribute: + // set the value to the keeper values. + toReturn[ attribute ] = value; + } + } + } + + return toReturn; + } + + + // Epoxy.Model -> Computed + // ----------------------- + // Computed objects store model values independently from the model's attributes table. + // Computeds define custom getter/setter functions to manage their value. + + function EpoxyComputedModel(model, name, params, delayInit) { + params = params || {}; + + // Rewrite getter param: + if (params.get && isFunction(params.get)) { + params._get = params.get; + } + + // Rewrite setter param: + if (params.set && isFunction(params.set)) { + params._set = params.set; + } + + // Prohibit override of 'get()' and 'set()', then extend: + delete params.get; + delete params.set; + _.extend(this, params); + + // Set model, name, and default dependencies array: + this.model = model; + this.name = name; + this.deps = this.deps || []; + + // Skip init while parent model is initializing: + // Model will initialize in two passes... + // the first pass sets up all computed attributes, + // then the second pass initializes all bindings. + if (!delayInit) this.init(); + } + + _.extend(EpoxyComputedModel.prototype, Backbone.Events, { + + // Initializes the computed's value and bindings: + // this method is called independently from the object constructor, + // allowing computeds to build and initialize in two passes by the parent model. + init: function() { + + // Configure dependency map, then update the computed's value: + // All Epoxy.Model attributes accessed while getting the initial value + // will automatically register themselves within the model bindings map. + var bindings = {}; + var deps = modelMap = []; + this.get(true); + modelMap = null; + + // If the computed has dependencies, then proceed to binding it: + if (deps.length) { + + // Compile normalized bindings table: + // Ultimately, we want a table of event types, each with an array of their associated targets: + // {'change:name':[], 'change:status':[,]} + + // Compile normalized bindings map: + _.each(deps, function(value) { + var attribute = value[0]; + var target = value[1]; + + // Populate event target arrays: + if (!bindings[attribute]) { + bindings[attribute] = [ target ]; + + } else if (!_.contains(bindings[attribute], target)) { + bindings[attribute].push(target); + } + }); + + // Bind all event declarations to their respective targets: + _.each(bindings, function(targets, binding) { + for (var i=0, len=targets.length; i < len; i++) { + this.listenTo(targets[i], binding, _.bind(this.get, this, true)); + } + }, this); + } + }, + + // Gets an attribute value from the parent model. + val: function(attribute) { + return this.model.get(attribute); + }, + + // Gets the computed's current value: + // Computed values flagged as dirty will need to regenerate themselves. + // Note: 'update' is strongly checked as TRUE to prevent unintended arguments (handler events, etc) from qualifying. + get: function(update) { + if (update === true && this._get) { + var val = this._get.apply(this.model, _.map(this.deps, this.val, this)); + this.change(val); + } + return this.value; + }, + + // Sets the computed's current value: + // computed values (have a custom getter method) require a custom setter. + // Custom setters should return an object of key/values pairs; + // key/value pairs returned to the parent model will be merged into its main .set() operation. + set: function(val) { + if (this._get) { + if (this._set) return this._set.apply(this.model, arguments); + else throw('Cannot set read-only computed attribute.'); + } + this.change(val); + return null; + }, + + // Changes the computed's value: + // new values are cached, then fire an update event. + change: function(value) { + if (!_.isEqual(value, this.value)) { + this.value = value; + this.model.trigger('change:'+this.name+' change', this.model); + } + }, + + // Disposal: + // cleans up events and releases references. + dispose: function() { + this.stopListening(); + this.off(); + this.model = this.value = null; + } + }); + + + // Epoxy.binding -> Binding API + // ---------------------------- + + var bindingSettings = { + optionText: 'label', + optionValue: 'value' + }; + + + // Cache for storing binding parser functions: + // Cuts down on redundancy when building repetitive binding views. + var bindingCache = {}; + + + // Reads value from an accessor: + // Accessors come in three potential forms: + // => A function to call for the requested value. + // => An object with a collection of attribute accessors. + // => A primitive (string, number, boolean, etc). + // This function unpacks an accessor and returns its underlying value(s). + + function readAccessor(accessor) { + + if (isFunction(accessor)) { + // Accessor is function: return invoked value. + return accessor(); + } + else if (isObject(accessor)) { + // Accessor is object/array: return copy with all attributes read. + accessor = _.clone(accessor); + + _.each(accessor, function(value, key) { + accessor[ key ] = readAccessor(value); + }); + } + // return formatted value, or pass through primitives: + return accessor; + } + + + // Binding Handlers + // ---------------- + // Handlers define set/get methods for exchanging data with the DOM. + + // Formatting function for defining new handler objects: + function makeHandler(handler) { + return isFunction(handler) ? {set: handler} : handler; + } + + var bindingHandlers = { + // Attribute: write-only. Sets element attributes. + attr: makeHandler(function($element, value) { + $element.attr(value); + }), + + // Checked: read-write. Toggles the checked status of a form element. + checked: makeHandler({ + get: function($element, currentValue) { + var checked = !!$element.prop('checked'); + var value = $element.val(); + + if (this.isRadio($element)) { + // Radio button: return value directly. + return value; + + } else if (isArray(currentValue)) { + // Checkbox array: add/remove value from list. + currentValue = currentValue.slice(); + var index = _.indexOf(currentValue, value); + + if (checked && index < 0) { + currentValue.push(value); + } else if (!checked && index > -1) { + currentValue.splice(index, 1); + } + return currentValue; + } + // Checkbox: return boolean toggle. + return checked; + }, + set: function($element, value) { + // Default as loosely-typed boolean: + var checked = !!value; + + if (this.isRadio($element)) { + // Radio button: match checked state to radio value. + checked = (value == $element.val()); + + } else if (isArray(value)) { + // Checkbox array: match checked state to checkbox value in array contents. + checked = _.contains(value, $element.val()); + } + + // Set checked property to element: + $element.prop('checked', checked); + }, + // Is radio button: avoids '.is(":radio");' check for basic Zepto compatibility. + isRadio: function($element) { + return $element.attr('type').toLowerCase() === 'radio'; + } + }), + + // Class Name: write-only. Toggles a collection of class name definitions. + classes: makeHandler(function($element, value) { + _.each(value, function(enabled, className) { + $element.toggleClass(className, !!enabled); + }); + }), + + // Collection: write-only. Manages a list of views bound to a Backbone.Collection. + collection: makeHandler({ + init: function($element, collection) { + if (!isCollection(collection) || !isFunction(collection.view)) { + throw('Binding "collection" requires a Collection with a "view" constructor.'); + } + this.v = {}; + }, + set: function($element, collection, target) { + + var view; + var views = this.v; + var models = collection.models; + + // Cache and reset the current dependency graph state: + // sub-views may be created (each with their own dependency graph), + // therefore we need to suspend the working graph map here before making children... + var mapCache = viewMap; + viewMap = null; + + // Default target to the bound collection object: + // during init (or failure), the binding will reset. + target = target || collection; + + if (isModel(target)) { + + // ADD/REMOVE Event (from a Model): + // test if view exists within the binding... + if (!views.hasOwnProperty(target.cid)) { + + // Add new view: + views[ target.cid ] = view = new collection.view({model: target}); + var index = _.indexOf(models, target); + var $children = $element.children(); + + // Attempt to add at proper index, + // otherwise just append into the element. + if (index < $children.length) { + $children.eq(index).before(view.$el); + } else { + $element.append(view.$el); + } + + } else { + + // Remove existing view: + views[ target.cid ].remove(); + delete views[ target.cid ]; + } + + } else if (isCollection(target)) { + + // SORT/RESET Event (from a Collection): + // First test if we're sorting... + // (number of models has not changed and all their views are present) + var sort = models.length === _.size(views) && collection.every(function(model) { + return views.hasOwnProperty(model.cid); + }); + + // Hide element before manipulating: + $element.children().detach(); + var frag = document.createDocumentFragment(); + + if (sort) { + // Sort existing views: + collection.each(function(model) { + frag.appendChild(views[model.cid].el); + }); + + } else { + // Reset with new views: + this.clean(); + collection.each(function(model) { + views[ model.cid ] = view = new collection.view({model: model}); + frag.appendChild(view.el); + }); + } + + $element.append(frag); + } + + // Restore cached dependency graph configuration: + viewMap = mapCache; + }, + clean: function() { + for (var id in this.v) { + if (this.v.hasOwnProperty(id)) { + this.v[ id ].remove(); + delete this.v[ id ]; + } + } + } + }), + + // CSS: write-only. Sets a collection of CSS styles to an element. + css: makeHandler(function($element, value) { + $element.css(value); + }), + + // Disabled: write-only. Sets the 'disabled' status of a form element (true :: disabled). + disabled: makeHandler(function($element, value) { + $element.prop('disabled', !!value); + }), + + // Enabled: write-only. Sets the 'disabled' status of a form element (true :: !disabled). + enabled: makeHandler(function($element, value) { + $element.prop('disabled', !value); + }), + + // HTML: write-only. Sets the inner HTML value of an element. + html: makeHandler(function($element, value) { + $element.html(value); + }), + + // Options: write-only. Sets option items to a