diff --git a/README.md b/README.md index b0669d2..0acd910 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # Backbone-Rails [![Build Status](https://secure.travis-ci.org/codebrew/backbone-rails.png)](http://travis-ci.org/codebrew/backbone-rails)[![Gem Version](https://badge.fury.io/rb/rails-backbone.png)](http://badge.fury.io/rb/rails-backbone) -Easily setup and use backbone.js (1.1.2) with Rails 3.1 and greater +Easily setup and use backbone.js (1.2.0) with Rails 3.1 and greater ##Version## ###Github master branch### -Gem version : 1.1.2 +Gem version : 1.2.0 -Backbone version : 1.1.2 +Backbone version : 1.2.0 -Underscore version : 1.8.2 +Underscore version : 1.8.3 ###Rubygems### diff --git a/backbone-rails.gemspec b/backbone-rails.gemspec index 3412ac5..ecff620 100644 --- a/backbone-rails.gemspec +++ b/backbone-rails.gemspec @@ -1,8 +1,8 @@ # Provide a simple gemspec so you can easily use your # project in your rails apps through git. Gem::Specification.new do |s| - s.name = "rails-backbone" - s.version = "1.1.2" + s.name = "backbone-rails" + s.version = "1.2.0" s.authors = ["Ryan Fitzgerald", "Code Brew Studios", "Manu S Ajith"] s.email = ["ryan@codebrewstudios.com", "neo@codingarena.in"] s.homepage = "http://github.com/codebrew/backbone-rails" diff --git a/vendor/assets/javascripts/backbone.js b/vendor/assets/javascripts/backbone.js index 24a550a..dd6425e 100644 --- a/vendor/assets/javascripts/backbone.js +++ b/vendor/assets/javascripts/backbone.js @@ -1,11 +1,16 @@ -// Backbone.js 1.1.2 +// Backbone.js 1.2.0 -// (c) 2010-2014 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors +// (c) 2010-2015 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors // Backbone may be freely distributed under the MIT license. // For all details and documentation: // http://backbonejs.org -(function(root, factory) { +(function(factory) { + + // Establish the root object, `window` (`self`) in the browser, or `global` on the server. + // We use `self` instead of `window` for `WebWorker` support. + var root = (typeof self == 'object' && self.self == self && self) || + (typeof global == 'object' && global.global == global && global); // Set up Backbone appropriately for the environment. Start with AMD. if (typeof define === 'function' && define.amd) { @@ -17,15 +22,16 @@ // Next for Node.js or CommonJS. jQuery may not be needed as a module. } else if (typeof exports !== 'undefined') { - var _ = require('underscore'); - factory(root, exports, _); + var _ = require('underscore'), $; + try { $ = require('jquery'); } catch(e) {} + factory(root, exports, _, $); // Finally, as a browser global. } else { root.Backbone = factory(root, {}, root._, (root.jQuery || root.Zepto || root.ender || root.$)); } -}(this, function(root, Backbone, _, $) { +}(function(root, Backbone, _, $) { // Initial Setup // ------------- @@ -36,12 +42,10 @@ // Create local references to array methods we'll want to use later. var array = []; - var push = array.push; var slice = array.slice; - var splice = array.splice; // Current version of the library. Keep in sync with `package.json`. - Backbone.VERSION = '1.1.2'; + Backbone.VERSION = '1.2.0'; // For Backbone's purposes, jQuery, Zepto, Ender, or My Library (kidding) owns // the `$` variable. @@ -60,7 +64,7 @@ Backbone.emulateHTTP = false; // Turn on `emulateJSON` to support legacy servers that can't deal with direct - // `application/json` requests ... will encode the body as + // `application/json` requests ... this will encode the body as // `application/x-www-form-urlencoded` instead and will send the model in a // form param named `model`. Backbone.emulateJSON = false; @@ -78,123 +82,235 @@ // object.on('expand', function(){ alert('expanded'); }); // object.trigger('expand'); // - var Events = Backbone.Events = { - - // Bind an event to a `callback` function. Passing `"all"` will bind - // the callback to all events fired. - on: function(name, callback, context) { - if (!eventsApi(this, 'on', name, [callback, context]) || !callback) return this; - this._events || (this._events = {}); - var events = this._events[name] || (this._events[name] = []); - events.push({callback: callback, context: context, ctx: context || this}); - return this; - }, + var Events = Backbone.Events = {}; - // Bind an event to only be triggered a single time. After the first time - // the callback is invoked, it will be removed. - once: function(name, callback, context) { - if (!eventsApi(this, 'once', name, [callback, context]) || !callback) return this; - var self = this; - var once = _.once(function() { - self.off(name, once); - callback.apply(this, arguments); - }); - once._callback = callback; - return this.on(name, once, context); - }, - - // Remove one or many callbacks. If `context` is null, removes all - // callbacks with that function. If `callback` is null, removes all - // callbacks for the event. If `name` is null, removes all bound - // callbacks for all events. - off: function(name, callback, context) { - var retain, ev, events, names, i, l, j, k; - if (!this._events || !eventsApi(this, 'off', name, [callback, context])) return this; - if (!name && !callback && !context) { - this._events = void 0; - return this; + // Regular expression used to split event strings. + var eventSplitter = /\s+/; + + // Iterates over the standard `event, callback` (as well as the fancy multiple + // space-separated events `"change blur", callback` and jQuery-style event + // maps `{event: callback}`), reducing them by manipulating `memo`. + // Passes a normalized single event name and callback, as well as any + // optional `opts`. + var eventsApi = function(iteratee, memo, name, callback, opts) { + var i = 0, names; + if (name && typeof name === 'object') { + // Handle event maps. + for (names = _.keys(name); i < names.length ; i++) { + memo = iteratee(memo, names[i], name[names[i]], opts); } - names = name ? [name] : _.keys(this._events); - for (i = 0, l = names.length; i < l; i++) { - name = names[i]; - if (events = this._events[name]) { - this._events[name] = retain = []; - if (callback || context) { - for (j = 0, k = events.length; j < k; j++) { - ev = events[j]; - if ((callback && callback !== ev.callback && callback !== ev.callback._callback) || - (context && context !== ev.context)) { - retain.push(ev); - } - } - } - if (!retain.length) delete this._events[name]; - } + } else if (name && eventSplitter.test(name)) { + // Handle space separated event names. + for (names = name.split(eventSplitter); i < names.length; i++) { + memo = iteratee(memo, names[i], callback, opts); } + } else { + memo = iteratee(memo, name, callback, opts); + } + return memo; + }; - return this; - }, + // Bind an event to a `callback` function. Passing `"all"` will bind + // the callback to all events fired. + Events.on = function(name, callback, context) { + return internalOn(this, name, callback, context); + }; - // Trigger one or many events, firing all bound callbacks. Callbacks are - // passed the same arguments as `trigger` is, apart from the event name - // (unless you're listening on `"all"`, which will cause your callback to - // receive the true name of the event as the first argument). - trigger: function(name) { - if (!this._events) return this; - var args = slice.call(arguments, 1); - if (!eventsApi(this, 'trigger', name, args)) return this; - var events = this._events[name]; - var allEvents = this._events.all; - if (events) triggerEvents(events, args); - if (allEvents) triggerEvents(allEvents, arguments); - return this; - }, + // An internal use `on` function, used to guard the `listening` argument from + // the public API. + var internalOn = function(obj, name, callback, context, listening) { + obj._events = eventsApi(onApi, obj._events || {}, name, callback, { + context: context, + ctx: obj, + listening: listening + }); - // Tell this object to stop listening to either specific events ... or - // to every object it's currently listening to. - stopListening: function(obj, name, callback) { - var listeningTo = this._listeningTo; - if (!listeningTo) return this; - var remove = !name && !callback; - if (!callback && typeof name === 'object') callback = this; - if (obj) (listeningTo = {})[obj._listenId] = obj; - for (var id in listeningTo) { - obj = listeningTo[id]; - obj.off(name, callback, this); - if (remove || _.isEmpty(obj._events)) delete this._listeningTo[id]; - } - return this; + if (listening) { + var listeners = obj._listeners || (obj._listeners = {}); + listeners[listening.id] = listening; } + return obj; }; - // Regular expression used to split event strings. - var eventSplitter = /\s+/; + // Inversion-of-control versions of `on`. Tell *this* object to listen to + // an event in another object... keeping track of what it's listening to. + Events.listenTo = function(obj, name, callback) { + if (!obj) return this; + var id = obj._listenId || (obj._listenId = _.uniqueId('l')); + var listeningTo = this._listeningTo || (this._listeningTo = {}); + var listening = listeningTo[id]; + + // This object is not listening to any other events on `obj` yet. + // Setup the necessary references to track the listening callbacks. + if (!listening) { + var thisId = this._listenId || (this._listenId = _.uniqueId('l')); + listening = listeningTo[id] = {obj: obj, objId: id, id: thisId, listeningTo: listeningTo, count: 0}; + } - // Implement fancy features of the Events API such as multiple event - // names `"change blur"` and jQuery-style event maps `{change: action}` - // in terms of the existing API. - var eventsApi = function(obj, action, name, rest) { - if (!name) return true; + // Bind callbacks on obj, and keep track of them on listening. + internalOn(obj, name, callback, this, listening); + return this; + }; + + // The reducing API that adds a callback to the `events` object. + var onApi = function(events, name, callback, options) { + if (callback) { + var handlers = events[name] || (events[name] = []); + var context = options.context, ctx = options.ctx, listening = options.listening; + if (listening) listening.count++; + + handlers.push({ callback: callback, context: context, ctx: context || ctx, listening: listening }); + } + return events; + }; + + // Remove one or many callbacks. If `context` is null, removes all + // callbacks with that function. If `callback` is null, removes all + // callbacks for the event. If `name` is null, removes all bound + // callbacks for all events. + Events.off = function(name, callback, context) { + if (!this._events) return this; + this._events = eventsApi(offApi, this._events, name, callback, { + context: context, + listeners: this._listeners + }); + return this; + }; + + // Tell this object to stop listening to either specific events ... or + // to every object it's currently listening to. + Events.stopListening = function(obj, name, callback) { + var listeningTo = this._listeningTo; + if (!listeningTo) return this; + + var ids = obj ? [obj._listenId] : _.keys(listeningTo); + + for (var i = 0; i < ids.length; i++) { + var listening = listeningTo[ids[i]]; - // Handle event maps. - if (typeof name === 'object') { - for (var key in name) { - obj[action].apply(obj, [key, name[key]].concat(rest)); + // If listening doesn't exist, this object is not currently + // listening to obj. Break out early. + if (!listening) break; + + listening.obj.off(name, callback, this); + } + if (_.isEmpty(listeningTo)) this._listeningTo = void 0; + + return this; + }; + + // The reducing API that removes a callback from the `events` object. + var offApi = function(events, name, callback, options) { + // No events to consider. + if (!events) return; + + var i = 0, length, listening; + var context = options.context, listeners = options.listeners; + + // Delete all events listeners and "drop" events. + if (!name && !callback && !context) { + var ids = _.keys(listeners); + for (; i < ids.length; i++) { + listening = listeners[ids[i]]; + delete listeners[listening.id]; + delete listening.listeningTo[listening.objId]; } - return false; + return; } - // Handle space separated event names. - if (eventSplitter.test(name)) { - var names = name.split(eventSplitter); - for (var i = 0, l = names.length; i < l; i++) { - obj[action].apply(obj, [names[i]].concat(rest)); + var names = name ? [name] : _.keys(events); + for (; i < names.length; i++) { + name = names[i]; + var handlers = events[name]; + + // Bail out if there are no events stored. + if (!handlers) break; + + // Replace events if there are any remaining. Otherwise, clean up. + var remaining = []; + for (var j = 0; j < handlers.length; j++) { + var handler = handlers[j]; + if ( + callback && callback !== handler.callback && + callback !== handler.callback._callback || + context && context !== handler.context + ) { + remaining.push(handler); + } else { + listening = handler.listening; + if (listening && --listening.count === 0) { + delete listeners[listening.id]; + delete listening.listeningTo[listening.objId]; + } + } + } + + // Update tail event if the list has any events. Otherwise, clean up. + if (remaining.length) { + events[name] = remaining; + } else { + delete events[name]; } - return false; } + if (_.size(events)) return events; + }; - return true; + // Bind an event to only be triggered a single time. After the first time + // the callback is invoked, it will be removed. When multiple events are + // passed in using the space-separated syntax, the event will fire once for every + // event you passed in, not once for a combination of all events + Events.once = function(name, callback, context) { + // Map the event into a `{event: once}` object. + var events = eventsApi(onceMap, {}, name, callback, _.bind(this.off, this)); + return this.on(events, void 0, context); + }; + + // Inversion-of-control versions of `once`. + Events.listenToOnce = function(obj, name, callback) { + // Map the event into a `{event: once}` object. + var events = eventsApi(onceMap, {}, name, callback, _.bind(this.stopListening, this, obj)); + return this.listenTo(obj, events); + }; + + // Reduces the event callbacks into a map of `{event: onceWrapper}`. + // `offer` unbinds the `onceWrapper` after it as been called. + var onceMap = function(map, name, callback, offer) { + if (callback) { + var once = map[name] = _.once(function() { + offer(name, once); + callback.apply(this, arguments); + }); + once._callback = callback; + } + return map; + }; + + // Trigger one or many events, firing all bound callbacks. Callbacks are + // passed the same arguments as `trigger` is, apart from the event name + // (unless you're listening on `"all"`, which will cause your callback to + // receive the true name of the event as the first argument). + Events.trigger = function(name) { + if (!this._events) return this; + + var length = Math.max(0, arguments.length - 1); + var args = Array(length); + for (var i = 0; i < length; i++) args[i] = arguments[i + 1]; + + eventsApi(triggerApi, this._events, name, void 0, args); + return this; + }; + + // Handles triggering the appropriate event callbacks. + var triggerApi = function(objEvents, name, cb, args) { + if (objEvents) { + var events = objEvents[name]; + var allEvents = objEvents.all; + if (events && allEvents) allEvents = allEvents.slice(); + if (events) triggerEvents(events, args); + if (allEvents) triggerEvents(allEvents, [name].concat(args)); + } + return objEvents; }; // A difficult-to-believe, but optimized internal dispatch function for @@ -211,21 +327,34 @@ } }; - var listenMethods = {listenTo: 'on', listenToOnce: 'once'}; - - // Inversion-of-control versions of `on` and `once`. Tell *this* object to - // listen to an event in another object ... keeping track of what it's - // listening to. - _.each(listenMethods, function(implementation, method) { - Events[method] = function(obj, name, callback) { - var listeningTo = this._listeningTo || (this._listeningTo = {}); - var id = obj._listenId || (obj._listenId = _.uniqueId('l')); - listeningTo[id] = obj; - if (!callback && typeof name === 'object') callback = this; - obj[implementation](name, callback, this); - return this; - }; - }); + // Proxy Underscore methods to a Backbone class' prototype using a + // particular attribute as the data argument + var addMethod = function(length, method, attribute) { + switch (length) { + case 1: return function() { + return _[method](this[attribute]); + }; + case 2: return function(value) { + return _[method](this[attribute], value); + }; + case 3: return function(iteratee, context) { + return _[method](this[attribute], iteratee, context); + }; + case 4: return function(iteratee, defaultVal, context) { + return _[method](this[attribute], iteratee, defaultVal, context); + }; + default: return function() { + var args = slice.call(arguments); + args.unshift(this[attribute]); + return _[method].apply(_, args); + }; + } + }; + var addUnderscoreMethods = function(Class, methods, attribute) { + _.each(methods, function(length, method) { + if (_[method]) Class.prototype[method] = addMethod(length, method, attribute); + }); + }; // Aliases for backwards compatibility. Events.bind = Events.on; @@ -248,7 +377,7 @@ var Model = Backbone.Model = function(attributes, options) { var attrs = attributes || {}; options || (options = {}); - this.cid = _.uniqueId('c'); + this.cid = _.uniqueId(this.cidPrefix); this.attributes = {}; if (options.collection) this.collection = options.collection; if (options.parse) attrs = this.parse(attrs, options) || {}; @@ -271,6 +400,10 @@ // CouchDB users may want to set this to `"_id"`. idAttribute: 'id', + // The prefix is used to create the client id which is used to identify models locally. + // You may want to override this if you're experiencing name clashes with model ids. + cidPrefix: 'c', + // Initialize is an empty function by default. Override it with your own // initialization logic. initialize: function(){}, @@ -302,6 +435,11 @@ return this.get(attr) != null; }, + // Special-cased proxy to underscore's `_.matches` method. + matches: function(attrs) { + return !!_.iteratee(attrs, this)(this.attributes); + }, + // Set a hash of model attributes on the object, firing `"change"`. This is // the core primitive operation of a model, updating the data and notifying // anyone who needs to know about the change in state. The heart of the beast. @@ -353,7 +491,7 @@ // Trigger all relevant attribute changes. if (!silent) { if (changes.length) this._pending = options; - for (var i = 0, l = changes.length; i < l; i++) { + for (var i = 0; i < changes.length; i++) { this.trigger('change:' + changes[i], this, current[changes[i]], options); } } @@ -423,9 +561,8 @@ return _.clone(this._previousAttributes); }, - // Fetch the model from the server. If the server's representation of the - // model differs from its current attributes, they will be overridden, - // triggering a `"change"` event. + // Fetch the model from the server, merging the response with the model's + // local attributes. Any changed attributes will trigger a "change" event. fetch: function(options) { options = options ? _.clone(options) : {}; if (options.parse === void 0) options.parse = true; @@ -433,7 +570,7 @@ var success = options.success; options.success = function(resp) { if (!model.set(model.parse(resp, options), options)) return false; - if (success) success(model, resp, options); + if (success) success.call(options.context, model, resp, options); model.trigger('sync', model, resp, options); }; wrapError(this, options); @@ -444,7 +581,7 @@ // If the server returns an attributes hash that differs, the model's // state will be `set` again. save: function(key, val, options) { - var attrs, method, xhr, attributes = this.attributes; + var attrs, method, xhr, attributes = this.attributes, wait; // Handle both `"key", value` and `{key: value}` -style arguments. if (key == null || typeof key === 'object') { @@ -455,18 +592,19 @@ } options = _.extend({validate: true}, options); + wait = options.wait; // If we're not waiting and attributes exist, save acts as // `set(attr).save(null, opts)` with validation. Otherwise, check if // the model will be valid when the attributes, if any, are set. - if (attrs && !options.wait) { + if (attrs && !wait) { if (!this.set(attrs, options)) return false; } else { if (!this._validate(attrs, options)) return false; } // Set temporary attributes if `{wait: true}`. - if (attrs && options.wait) { + if (attrs && wait) { this.attributes = _.extend({}, attributes, attrs); } @@ -478,22 +616,22 @@ options.success = function(resp) { // Ensure attributes are restored during synchronous saves. model.attributes = attributes; - var serverAttrs = model.parse(resp, options); - if (options.wait) serverAttrs = _.extend(attrs || {}, serverAttrs); + var serverAttrs = options.parse ? model.parse(resp, options) : resp; + if (wait) serverAttrs = _.extend(attrs || {}, serverAttrs); if (_.isObject(serverAttrs) && !model.set(serverAttrs, options)) { return false; } - if (success) success(model, resp, options); + if (success) success.call(options.context, model, resp, options); model.trigger('sync', model, resp, options); }; wrapError(this, options); method = this.isNew() ? 'create' : (options.patch ? 'patch' : 'update'); - if (method === 'patch') options.attrs = attrs; + if (method === 'patch' && !options.attrs) options.attrs = attrs; xhr = this.sync(method, this, options); // Restore attributes. - if (attrs && options.wait) this.attributes = attributes; + if (attrs && wait) this.attributes = attributes; return xhr; }, @@ -505,25 +643,27 @@ options = options ? _.clone(options) : {}; var model = this; var success = options.success; + var wait = options.wait; var destroy = function() { + model.stopListening(); model.trigger('destroy', model, model.collection, options); }; options.success = function(resp) { - if (options.wait || model.isNew()) destroy(); - if (success) success(model, resp, options); + if (wait) destroy(); + if (success) success.call(options.context, model, resp, options); if (!model.isNew()) model.trigger('sync', model, resp, options); }; + var xhr = false; if (this.isNew()) { - options.success(); - return false; + _.defer(options.success); + } else { + wrapError(this, options); + xhr = this.sync('delete', this, options); } - wrapError(this, options); - - var xhr = this.sync('delete', this, options); - if (!options.wait) destroy(); + if (!wait) destroy(); return xhr; }, @@ -536,7 +676,8 @@ _.result(this.collection, 'url') || urlError(); if (this.isNew()) return base; - return base.replace(/([^\/])$/, '$1/') + encodeURIComponent(this.id); + var id = this.id || this.attributes[this.idAttribute]; + return base.replace(/([^\/])$/, '$1/') + encodeURIComponent(id); }, // **parse** converts a response into the hash of attributes to be `set` on @@ -574,22 +715,17 @@ }); // Underscore methods that we want to implement on the Model. - var modelMethods = ['keys', 'values', 'pairs', 'invert', 'pick', 'omit']; + var modelMethods = { keys: 1, values: 1, pairs: 1, invert: 1, pick: 0, + omit: 0, chain: 1, isEmpty: 1 }; // Mix in each Underscore method as a proxy to `Model#attributes`. - _.each(modelMethods, function(method) { - Model.prototype[method] = function() { - var args = slice.call(arguments); - args.unshift(this.attributes); - return _[method].apply(_, args); - }; - }); + addUnderscoreMethods(Model, modelMethods, 'attributes'); // Backbone.Collection // ------------------- // If models tend to represent a single row of data, a Backbone Collection is - // more analagous to a table full of data ... or a small slice or page of that + // more analogous to a table full of data ... or a small slice or page of that // table, or a collection of rows that belong together for a particular reason // -- all of the messages in this particular folder, all of the documents // belonging to this particular author, and so on. Collections maintain @@ -640,24 +776,11 @@ // Remove a model, or a list of models from the set. remove: function(models, options) { - var singular = !_.isArray(models); + var singular = !_.isArray(models), removed; models = singular ? [models] : _.clone(models); options || (options = {}); - var i, l, index, model; - for (i = 0, l = models.length; i < l; i++) { - model = models[i] = this.get(models[i]); - if (!model) continue; - delete this._byId[model.id]; - delete this._byId[model.cid]; - index = this.indexOf(model); - this.models.splice(index, 1); - this.length--; - if (!options.silent) { - options.index = index; - model.trigger('remove', model, this, options); - } - this._removeReference(model, options); - } + removed = this._removeModels(models, options); + if (!options.silent && removed) this.trigger('update', this, options); return singular ? models[0] : models; }, @@ -669,32 +792,29 @@ options = _.defaults({}, options, setOptions); if (options.parse) models = this.parse(models, options); var singular = !_.isArray(models); - models = singular ? (models ? [models] : []) : _.clone(models); - var i, l, id, model, attrs, existing, sort; + models = singular ? (models ? [models] : []) : models.slice(); + var id, model, attrs, existing, sort; var at = options.at; - var targetModel = this.model; + if (at != null) at = +at; + if (at < 0) at += this.length + 1; var sortable = this.comparator && (at == null) && options.sort !== false; var sortAttr = _.isString(this.comparator) ? this.comparator : null; var toAdd = [], toRemove = [], modelMap = {}; var add = options.add, merge = options.merge, remove = options.remove; var order = !sortable && add && remove ? [] : false; + var orderChanged = false; // Turn bare objects into model references, and prevent invalid models // from being added. - for (i = 0, l = models.length; i < l; i++) { - attrs = models[i] || {}; - if (attrs instanceof Model) { - id = model = attrs; - } else { - id = attrs[targetModel.prototype.idAttribute || 'id']; - } + for (var i = 0; i < models.length; i++) { + attrs = models[i]; // If a duplicate is found, prevent it from being added and // optionally merge it into the existing model. - if (existing = this.get(id)) { + if (existing = this.get(attrs)) { if (remove) modelMap[existing.cid] = true; - if (merge) { - attrs = attrs === model ? model.attributes : attrs; + if (merge && attrs !== existing) { + attrs = this._isModel(attrs) ? attrs.attributes : attrs; if (options.parse) attrs = existing.parse(attrs, options); existing.set(attrs, options); if (sortable && !sort && existing.hasChanged(sortAttr)) sort = true; @@ -711,30 +831,38 @@ // Do not add multiple models with the same `id`. model = existing || model; - if (order && (model.isNew() || !modelMap[model.id])) order.push(model); - modelMap[model.id] = true; + if (!model) continue; + id = this.modelId(model.attributes); + if (order && (model.isNew() || !modelMap[id])) { + order.push(model); + + // Check to see if this is actually a new model at this index. + orderChanged = orderChanged || !this.models[i] || model.cid !== this.models[i].cid; + } + + modelMap[id] = true; } // Remove nonexistent models if appropriate. if (remove) { - for (i = 0, l = this.length; i < l; ++i) { + for (var i = 0; i < this.length; i++) { if (!modelMap[(model = this.models[i]).cid]) toRemove.push(model); } - if (toRemove.length) this.remove(toRemove, options); + if (toRemove.length) this._removeModels(toRemove, options); } // See if sorting is needed, update `length` and splice in new models. - if (toAdd.length || (order && order.length)) { + if (toAdd.length || orderChanged) { if (sortable) sort = true; this.length += toAdd.length; if (at != null) { - for (i = 0, l = toAdd.length; i < l; i++) { + for (var i = 0; i < toAdd.length; i++) { this.models.splice(at + i, 0, toAdd[i]); } } else { if (order) this.models.length = 0; var orderedModels = order || toAdd; - for (i = 0, l = orderedModels.length; i < l; i++) { + for (var i = 0; i < orderedModels.length; i++) { this.models.push(orderedModels[i]); } } @@ -745,10 +873,13 @@ // Unless silenced, it's time to fire all appropriate add/sort events. if (!options.silent) { - for (i = 0, l = toAdd.length; i < l; i++) { - (model = toAdd[i]).trigger('add', model, this, options); + var addOpts = at != null ? _.clone(options) : options; + for (var i = 0; i < toAdd.length; i++) { + if (at != null) addOpts.index = at + i; + (model = toAdd[i]).trigger('add', model, this, addOpts); } - if (sort || (order && order.length)) this.trigger('sort', this, options); + if (sort || orderChanged) this.trigger('sort', this, options); + if (toAdd.length || toRemove.length) this.trigger('update', this, options); } // Return the added (or merged) model (or models). @@ -760,8 +891,8 @@ // any granular `add` or `remove` events. Fires `reset` when finished. // Useful for bulk operations and optimizations. reset: function(models, options) { - options || (options = {}); - for (var i = 0, l = this.models.length; i < l; i++) { + options = options ? _.clone(options) : {}; + for (var i = 0; i < this.models.length; i++) { this._removeReference(this.models[i], options); } options.previousModels = this.models; @@ -803,23 +934,22 @@ // Get a model from the set by id. get: function(obj) { if (obj == null) return void 0; - return this._byId[obj] || this._byId[obj.id] || this._byId[obj.cid]; + var id = this.modelId(this._isModel(obj) ? obj.attributes : obj); + return this._byId[obj] || this._byId[id] || this._byId[obj.cid]; }, // Get the model at the given index. at: function(index) { + if (index < 0) index += this.length; return this.models[index]; }, // Return models with matching attributes. Useful for simple cases of // `filter`. where: function(attrs, first) { - if (_.isEmpty(attrs)) return first ? void 0 : []; + var matches = _.matches(attrs); return this[first ? 'find' : 'filter'](function(model) { - for (var key in attrs) { - if (attrs[key] !== model.get(key)) return false; - } - return true; + return matches(model.attributes); }); }, @@ -863,7 +993,7 @@ options.success = function(resp) { var method = options.reset ? 'reset' : 'set'; collection[method](resp, options); - if (success) success(collection, resp, options); + if (success) success.call(options.context, collection, resp, options); collection.trigger('sync', collection, resp, options); }; wrapError(this, options); @@ -875,13 +1005,14 @@ // wait for the server to agree. create: function(model, options) { options = options ? _.clone(options) : {}; + var wait = options.wait; if (!(model = this._prepareModel(model, options))) return false; - if (!options.wait) this.add(model, options); + if (!wait) this.add(model, options); var collection = this; var success = options.success; - options.success = function(model, resp) { - if (options.wait) collection.add(model, options); - if (success) success(model, resp, options); + options.success = function(model, resp, callbackOpts) { + if (wait) collection.add(model, callbackOpts); + if (success) success.call(callbackOpts.context, model, resp, callbackOpts); }; model.save(null, options); return model; @@ -895,7 +1026,15 @@ // Create a new collection with an identical list of models as this one. clone: function() { - return new this.constructor(this.models); + return new this.constructor(this.models, { + model: this.model, + comparator: this.comparator + }); + }, + + // Define how to uniquely identify models in the collection. + modelId: function (attrs) { + return attrs[this.model.prototype.idAttribute || 'id']; }, // Private method to reset all internal state. Called when the collection @@ -909,7 +1048,10 @@ // Prepare a hash of attributes (or other model) to be added to this // collection. _prepareModel: function(attrs, options) { - if (attrs instanceof Model) return attrs; + if (this._isModel(attrs)) { + if (!attrs.collection) attrs.collection = this; + return attrs; + } options = options ? _.clone(options) : {}; options.collection = this; var model = new this.model(attrs, options); @@ -918,11 +1060,44 @@ return false; }, + // Internal method called by both remove and set. Does not trigger any + // additional events. Returns true if anything was actually removed. + _removeModels: function(models, options) { + var i, l, index, model, removed = false; + for (var i = 0, j = 0; i < models.length; i++) { + var model = models[i] = this.get(models[i]); + if (!model) continue; + var id = this.modelId(model.attributes); + if (id != null) delete this._byId[id]; + delete this._byId[model.cid]; + var index = this.indexOf(model); + this.models.splice(index, 1); + this.length--; + if (!options.silent) { + options.index = index; + model.trigger('remove', model, this, options); + } + models[j++] = model; + this._removeReference(model, options); + removed = true; + } + // We only need to slice if models array should be smaller, which is + // caused by some models not actually getting removed. + if (models.length !== j) models = models.slice(0, j); + return removed; + }, + + // Method for checking whether an object should be considered a model for + // the purposes of adding to the collection. + _isModel: function (model) { + return model instanceof Model; + }, + // Internal method to create a model's ties to a collection. _addReference: function(model, options) { this._byId[model.cid] = model; - if (model.id != null) this._byId[model.id] = model; - if (!model.collection) model.collection = this; + var id = this.modelId(model.attributes); + if (id != null) this._byId[id] = model; model.on('all', this._onModelEvent, this); }, @@ -939,9 +1114,13 @@ _onModelEvent: function(event, model, collection, options) { if ((event === 'add' || event === 'remove') && collection !== this) return; if (event === 'destroy') this.remove(model, options); - if (model && event === 'change:' + model.idAttribute) { - delete this._byId[model.previous(model.idAttribute)]; - if (model.id != null) this._byId[model.id] = model; + if (event === 'change') { + var prevId = this.modelId(model.previousAttributes()); + var id = this.modelId(model.attributes); + if (prevId !== id) { + if (prevId != null) delete this._byId[prevId]; + if (id != null) this._byId[id] = model; + } } this.trigger.apply(this, arguments); } @@ -951,27 +1130,23 @@ // Underscore methods that we want to implement on the Collection. // 90% of the core usefulness of Backbone Collections is actually implemented // right here: - var methods = ['forEach', 'each', 'map', 'collect', 'reduce', 'foldl', - 'inject', 'reduceRight', 'foldr', 'find', 'detect', 'filter', 'select', - 'reject', 'every', 'all', 'some', 'any', 'include', 'contains', 'invoke', - 'max', 'min', 'toArray', 'size', 'first', 'head', 'take', 'initial', 'rest', - 'tail', 'drop', 'last', 'without', 'difference', 'indexOf', 'shuffle', - 'lastIndexOf', 'isEmpty', 'chain', 'sample']; + var collectionMethods = { forEach: 3, each: 3, map: 3, collect: 3, reduce: 4, + foldl: 4, inject: 4, reduceRight: 4, foldr: 4, find: 3, detect: 3, filter: 3, + select: 3, reject: 3, every: 3, all: 3, some: 3, any: 3, include: 2, + contains: 2, invoke: 2, max: 3, min: 3, toArray: 1, size: 1, first: 3, + head: 3, take: 3, initial: 3, rest: 3, tail: 3, drop: 3, last: 3, + without: 0, difference: 0, indexOf: 3, shuffle: 1, lastIndexOf: 3, + isEmpty: 1, chain: 1, sample: 3, partition: 3 }; // Mix in each Underscore method as a proxy to `Collection#models`. - _.each(methods, function(method) { - Collection.prototype[method] = function() { - var args = slice.call(arguments); - args.unshift(this.models); - return _[method].apply(_, args); - }; - }); + addUnderscoreMethods(Collection, collectionMethods, 'models'); // Underscore methods that take a property name as an argument. var attributeMethods = ['groupBy', 'countBy', 'sortBy', 'indexBy']; // Use attributes instead of properties. _.each(attributeMethods, function(method) { + if (!_[method]) return; Collection.prototype[method] = function(value, context) { var iterator = _.isFunction(value) ? value : function(model) { return model.get(value); @@ -999,7 +1174,6 @@ _.extend(this, _.pick(options, viewOptions)); this._ensureElement(); this.initialize.apply(this, arguments); - this.delegateEvents(); }; // Cached regex to split keys for `delegate`. @@ -1034,21 +1208,37 @@ // Remove this view by taking the element out of the DOM, and removing any // applicable Backbone.Events listeners. remove: function() { - this.$el.remove(); + this._removeElement(); this.stopListening(); return this; }, - // Change the view's element (`this.el` property), including event - // re-delegation. - setElement: function(element, delegate) { - if (this.$el) this.undelegateEvents(); - this.$el = element instanceof Backbone.$ ? element : Backbone.$(element); - this.el = this.$el[0]; - if (delegate !== false) this.delegateEvents(); + // Remove this view's element from the document and all event listeners + // attached to it. Exposed for subclasses using an alternative DOM + // manipulation API. + _removeElement: function() { + this.$el.remove(); + }, + + // Change the view's element (`this.el` property) and re-delegate the + // view's events on the new element. + setElement: function(element) { + this.undelegateEvents(); + this._setElement(element); + this.delegateEvents(); return this; }, + // Creates the `this.el` and `this.$el` references for this view using the + // given `el`. `el` can be a CSS selector or an HTML string, a jQuery + // context or an element. Subclasses can override this to utilize an + // alternative DOM manipulation API and are only required to set the + // `this.el` property. + _setElement: function(el) { + this.$el = el instanceof Backbone.$ ? el : Backbone.$(el); + this.el = this.$el[0]; + }, + // Set callbacks, where `this.events` is a hash of // // *{"event selector": "callback"}* @@ -1062,8 +1252,6 @@ // pairs. Callbacks will be bound to the view, with `this` set properly. // Uses event delegation for efficiency. // Omitting the selector binds the event to `this.el`. - // This only works for delegate-able events: not `focus`, `blur`, and - // not `change`, `submit`, and `reset` in Internet Explorer. delegateEvents: function(events) { if (!(events || (events = _.result(this, 'events')))) return this; this.undelegateEvents(); @@ -1071,28 +1259,39 @@ var method = events[key]; if (!_.isFunction(method)) method = this[events[key]]; if (!method) continue; - var match = key.match(delegateEventSplitter); - var eventName = match[1], selector = match[2]; - method = _.bind(method, this); - eventName += '.delegateEvents' + this.cid; - if (selector === '') { - this.$el.on(eventName, method); - } else { - this.$el.on(eventName, selector, method); - } + this.delegate(match[1], match[2], _.bind(method, this)); } return this; }, - // Clears all callbacks previously bound to the view with `delegateEvents`. + // Add a single event listener to the view's element (or a child element + // using `selector`). This only works for delegate-able events: not `focus`, + // `blur`, and not `change`, `submit`, and `reset` in Internet Explorer. + delegate: function(eventName, selector, listener) { + this.$el.on(eventName + '.delegateEvents' + this.cid, selector, listener); + }, + + // Clears all callbacks previously bound to the view by `delegateEvents`. // You usually don't need to use this, but may wish to if you have multiple // Backbone views attached to the same DOM element. undelegateEvents: function() { - this.$el.off('.delegateEvents' + this.cid); + if (this.$el) this.$el.off('.delegateEvents' + this.cid); return this; }, + // A finer-grained `undelegateEvents` for removing a single delegated event. + // `selector` and `listener` are both optional. + undelegate: function(eventName, selector, listener) { + this.$el.off(eventName + '.delegateEvents' + this.cid, selector, listener); + }, + + // Produces a DOM element to be assigned to your view. Exposed for + // subclasses using an alternative DOM manipulation API. + _createElement: function(tagName) { + return document.createElement(tagName); + }, + // Ensure that the View has a DOM element to render into. // If `this.el` is a string, pass it through `$()`, take the first // matching element, and re-assign it to `el`. Otherwise, create @@ -1102,11 +1301,17 @@ var attrs = _.extend({}, _.result(this, 'attributes')); if (this.id) attrs.id = _.result(this, 'id'); if (this.className) attrs['class'] = _.result(this, 'className'); - var $el = Backbone.$('<' + _.result(this, 'tagName') + '>').attr(attrs); - this.setElement($el, false); + this.setElement(this._createElement(_.result(this, 'tagName'))); + this._setAttributes(attrs); } else { - this.setElement(_.result(this, 'el'), false); + this.setElement(_.result(this, 'el')); } + }, + + // Set attributes from a hash on this view's element. Exposed for + // subclasses using an alternative DOM manipulation API. + _setAttributes: function(attributes) { + this.$el.attr(attributes); } }); @@ -1175,14 +1380,13 @@ params.processData = false; } - // If we're sending a `PATCH` request, and we're in an old Internet Explorer - // that still has ActiveX enabled by default, override jQuery to use that - // for XHR instead. Remove this line when jQuery supports `PATCH` on IE8. - if (params.type === 'PATCH' && noXhrPatch) { - params.xhr = function() { - return new ActiveXObject("Microsoft.XMLHTTP"); - }; - } + // Pass along `textStatus` and `errorThrown` from jQuery. + var error = options.error; + options.error = function(xhr, textStatus, errorThrown) { + options.textStatus = textStatus; + options.errorThrown = errorThrown; + if (error) error.call(options.context, xhr, textStatus, errorThrown); + }; // Make the request, allowing the user to override any Ajax options. var xhr = options.xhr = Backbone.ajax(_.extend(params, options)); @@ -1190,10 +1394,6 @@ return xhr; }; - var noXhrPatch = - typeof window !== 'undefined' && !!window.ActiveXObject && - !(window.XMLHttpRequest && (new XMLHttpRequest).dispatchEvent); - // Map from CRUD to HTTP for our default `Backbone.sync` implementation. var methodMap = { 'create': 'POST', @@ -1251,17 +1451,18 @@ var router = this; Backbone.history.route(route, function(fragment) { var args = router._extractParameters(route, fragment); - router.execute(callback, args); - router.trigger.apply(router, ['route:' + name].concat(args)); - router.trigger('route', name, args); - Backbone.history.trigger('route', router, name, args); + if (router.execute(callback, args, name) !== false) { + router.trigger.apply(router, ['route:' + name].concat(args)); + router.trigger('route', name, args); + Backbone.history.trigger('route', router, name, args); + } }); return this; }, // Execute a route handler with the provided parameters. This is an // excellent place to do pre-route setup or post-route cleanup. - execute: function(callback, args) { + execute: function(callback, args, name) { if (callback) callback.apply(this, args); }, @@ -1334,12 +1535,6 @@ // Cached regex for stripping leading and trailing slashes. var rootStripper = /^\/+|\/+$/g; - // Cached regex for detecting MSIE. - var isExplorer = /msie [\w.]+/; - - // Cached regex for removing a trailing slash. - var trailingSlash = /\/$/; - // Cached regex for stripping urls of hash. var pathStripper = /#.*$/; @@ -1355,7 +1550,29 @@ // Are we at the app root? atRoot: function() { - return this.location.pathname.replace(/[^\/]$/, '$&/') === this.root; + var path = this.location.pathname.replace(/[^\/]$/, '$&/'); + return path === this.root && !this.getSearch(); + }, + + // Does the pathname match the root? + matchRoot: function() { + var path = this.decodeFragment(this.location.pathname); + var root = path.slice(0, this.root.length - 1) + '/'; + return root === this.root; + }, + + // Unicode characters in `location.pathname` are percent encoded so they're + // decoded for comparison. `%25` should not be decoded since it may be part + // of an encoded parameter. + decodeFragment: function(fragment) { + return decodeURI(fragment.replace(/%25/g, '%2525')); + }, + + // In IE6, the hash fragment and search params are incorrect if the + // fragment contains `?`. + getSearch: function() { + var match = this.location.href.replace(/#.*/, '').match(/\?.+/); + return match ? match[0] : ''; }, // Gets the true hash value. Cannot use location.hash directly due to bug @@ -1365,14 +1582,19 @@ return match ? match[1] : ''; }, - // Get the cross-browser normalized URL fragment, either from the URL, - // the hash, or the override. - getFragment: function(fragment, forcePushState) { + // Get the pathname and search params, without the root. + getPath: function() { + var path = this.decodeFragment( + this.location.pathname + this.getSearch() + ).slice(this.root.length - 1); + return path.charAt(0) === '/' ? path.slice(1) : path; + }, + + // Get the cross-browser normalized URL fragment from the path or hash. + getFragment: function(fragment) { if (fragment == null) { - if (this._hasPushState || !this._wantsHashChange || forcePushState) { - fragment = decodeURI(this.location.pathname + this.location.search); - var root = this.root.replace(trailingSlash, ''); - if (!fragment.indexOf(root)) fragment = fragment.slice(root.length); + if (this._usePushState || !this._wantsHashChange) { + fragment = this.getPath(); } else { fragment = this.getHash(); } @@ -1383,7 +1605,7 @@ // Start the hash change handling, returning `true` if the current URL matches // an existing route, and `false` otherwise. start: function(options) { - if (History.started) throw new Error("Backbone.history has already been started"); + if (History.started) throw new Error('Backbone.history has already been started'); History.started = true; // Figure out the initial configuration. Do we need an iframe? @@ -1391,36 +1613,16 @@ this.options = _.extend({root: '/'}, this.options, options); this.root = this.options.root; this._wantsHashChange = this.options.hashChange !== false; + this._hasHashChange = 'onhashchange' in window; + this._useHashChange = this._wantsHashChange && this._hasHashChange; this._wantsPushState = !!this.options.pushState; - this._hasPushState = !!(this.options.pushState && this.history && this.history.pushState); - var fragment = this.getFragment(); - var docMode = document.documentMode; - var oldIE = (isExplorer.exec(navigator.userAgent.toLowerCase()) && (!docMode || docMode <= 7)); + this._hasPushState = !!(this.history && this.history.pushState); + this._usePushState = this._wantsPushState && this._hasPushState; + this.fragment = this.getFragment(); // Normalize root to always include a leading and trailing slash. this.root = ('/' + this.root + '/').replace(rootStripper, '/'); - if (oldIE && this._wantsHashChange) { - var frame = Backbone.$('