blob: fa80ade20f5fc7ed2df3a23530c471b56c2b05d3 [file] [log] [blame]
/*!
* backbone.layoutmanager.js v0.9.5
* Copyright 2013, Tim Branyen (@tbranyen)
* backbone.layoutmanager.js may be freely distributed under the MIT license.
*/
(function(window, factory) {
"use strict";
var Backbone = window.Backbone;
// AMD. Register as an anonymous module. Wrap in function so we have access
// to root via `this`.
if (typeof define === "function" && define.amd) {
return define(["backbone", "underscore", "jquery"], function() {
return factory.apply(window, arguments);
});
}
// Browser globals.
Backbone.Layout = factory.call(window, Backbone, window._, Backbone.$);
}(typeof global === "object" ? global : this, function (Backbone, _, $) {
"use strict";
// Create a reference to the global object. In browsers, it will map to the
// `window` object; in Node, it will be `global`.
var window = this;
// Maintain reference to the original constructor.
var ViewConstructor = Backbone.View;
// Cache these methods for performance.
var aPush = Array.prototype.push;
var aConcat = Array.prototype.concat;
var aSplice = Array.prototype.splice;
var trim = String.prototype.trim ?
_.bind(String.prototype.trim.call, String.prototype.trim) :
$.trim;
// LayoutManager is a wrapper around a `Backbone.View`.
// Backbone.View.extend takes options (protoProps, staticProps)
var LayoutManager = Backbone.View.extend({
_render: function() {
// Keep the view consistent between callbacks and deferreds.
var view = this;
// Shorthand the manager.
var manager = view.__manager__;
// Cache these properties.
var beforeRender = view.beforeRender;
// Create a deferred instead of going off
var def = view.deferred();
// Ensure all nested Views are properly scrubbed if re-rendering.
if (view.hasRendered) {
view._removeViews();
}
// This continues the render flow after `beforeRender` has completed.
manager.callback = function() {
// Clean up asynchronous manager properties.
delete manager.isAsync;
delete manager.callback;
// Always emit a beforeRender event.
view.trigger("beforeRender", view);
// Render!
view._viewRender(manager).render().then(function() {
// Complete this deferred once resolved.
def.resolve();
});
};
// If a beforeRender function is defined, call it.
if (beforeRender) {
beforeRender.call(view, view);
}
if (!manager.isAsync) {
manager.callback();
}
// Return this intermediary promise.
return def.promise();
},
// This function is responsible for pairing the rendered template into the
// DOM element.
_applyTemplate: function(rendered, manager, def) {
// Actually put the rendered contents into the element.
if (_.isString(rendered)) {
// If no container is specified, we must replace the content.
if (manager.noel) {
rendered = $.parseHTML(rendered, true);
// Remove extra root elements.
this.$el.slice(1).remove();
// Swap out the View on the first top level element to avoid
// duplication.
this.$el.replaceWith(rendered);
// Don't delegate events here - we'll do that in resolve()
this.setElement(rendered, false);
} else {
this.html(this.$el, rendered);
}
}
// Resolve only after fetch and render have succeeded.
def.resolveWith(this, [this]);
},
// Creates a deferred and returns a function to call when finished.
// This gets passed to all _render methods. The `root` value here is passed
// from the `manage(this).render()` line in the `_render` function
_viewRender: function(manager) {
var url, contents, def;
var root = this;
// Once the template is successfully fetched, use its contents to proceed.
// Context argument is first, since it is bound for partial application
// reasons.
function done(context, template) {
// Store the rendered template someplace so it can be re-assignable.
var rendered;
// Trigger this once the render method has completed.
manager.callback = function(rendered) {
// Clean up asynchronous manager properties.
delete manager.isAsync;
delete manager.callback;
root._applyTemplate(rendered, manager, def);
};
// Ensure the cache is up-to-date.
LayoutManager.cache(url, template);
// Render the View into the el property.
if (template) {
rendered = root.renderTemplate.call(root, template, context);
}
// If the function was synchronous, continue execution.
if (!manager.isAsync) {
root._applyTemplate(rendered, manager, def);
}
}
return {
// This `render` function is what gets called inside of the View render,
// when `manage(this).render` is called. Returns a promise that can be
// used to know when the element has been rendered into its parent.
render: function() {
var context = root.serialize;
var template = root.template;
// Create a deferred specifically for fetching.
def = root.deferred();
// If data is a function, immediately call it.
if (_.isFunction(context)) {
context = context.call(root);
}
// Set the internal callback to trigger once the asynchronous or
// synchronous behavior has completed.
manager.callback = function(contents) {
// Clean up asynchronous manager properties.
delete manager.isAsync;
delete manager.callback;
done(context, contents);
};
// Set the url to the prefix + the view's template property.
if (typeof template === "string") {
url = root.prefix + template;
}
// Check if contents are already cached and if they are, simply process
// the template with the correct data.
if (contents = LayoutManager.cache(url)) {
done(context, contents, url);
return def;
}
// Fetch layout and template contents.
if (typeof template === "string") {
contents = root.fetchTemplate.call(root, root.prefix +
template);
// If the template is already a function, simply call it.
} else if (typeof template === "function") {
contents = template;
// If its not a string and not undefined, pass the value to `fetch`.
} else if (template != null) {
contents = root.fetchTemplate.call(root, template);
}
// If the function was synchronous, continue execution.
if (!manager.isAsync) {
done(context, contents);
}
return def;
}
};
},
// This named function allows for significantly easier debugging.
constructor: function Layout(options) {
// Grant this View superpowers.
this.manage = true;
// Give this View access to all passed options as instance properties.
_.extend(this, options);
// Have Backbone set up the rest of this View.
Backbone.View.apply(this, arguments);
},
// This method is used within specific methods to indicate that they should
// be treated as asynchronous. This method should only be used within the
// render chain, otherwise unexpected behavior may occur.
async: function() {
var manager = this.__manager__;
// Set this View's action to be asynchronous.
manager.isAsync = true;
// Return the callback.
return manager.callback;
},
promise: function() {
return this.__manager__.renderDeferred.promise();
},
// Sometimes it's desirable to only render the child views under the parent.
// This is typical for a layout that does not change. This method will
// iterate over the child Views and aggregate all child render promises and
// return the parent View. The internal `promise()` method will return the
// aggregate promise that resolves once all children have completed their
// render.
renderViews: function() {
var root = this;
var manager = root.__manager__;
var newDeferred = root.deferred();
// Collect all promises from rendering the child views and wait till they
// all complete.
var promises = root.getViews().map(function(view) {
return view.render().__manager__.renderDeferred;
}).value();
// Simulate a parent render to remain consistent.
manager.renderDeferred = newDeferred.promise();
// Once all child views have completed rendering, resolve parent deferred
// with the correct context.
root.when(promises).then(function() {
newDeferred.resolveWith(root, [root]);
});
// Allow this method to be chained.
return root;
},
// Shorthand to `setView` function with the `insert` flag set.
insertView: function(selector, view) {
// If the `view` argument exists, then a selector was passed in. This code
// path will forward the selector on to `setView`.
if (view) {
return this.setView(selector, view, true);
}
// If no `view` argument is defined, then assume the first argument is the
// View, somewhat now confusingly named `selector`.
return this.setView(selector, true);
},
// Iterate over an object and ensure every value is wrapped in an array to
// ensure they will be inserted, then pass that object to `setViews`.
insertViews: function(views) {
// If an array of views was passed it should be inserted into the
// root view. Much like calling insertView without a selector.
if (_.isArray(views)) {
return this.setViews({ "": views });
}
_.each(views, function(view, selector) {
views[selector] = _.isArray(view) ? view : [view];
});
return this.setViews(views);
},
// Returns the View that matches the `getViews` filter function.
getView: function(fn) {
// If `getView` is invoked with undefined as the first argument, then the
// second argument will be used instead. This is to allow
// `getViews(undefined, fn)` to work as `getViews(fn)`. Useful for when
// you are allowing an optional selector.
if (fn == null) {
fn = arguments[1];
}
return this.getViews(fn).first().value();
},
// Provide a filter function to get a flattened array of all the subviews.
// If the filter function is omitted it will return all subviews. If a
// String is passed instead, it will return the Views for that selector.
getViews: function(fn) {
var views;
// If the filter argument is a String, then return a chained Version of the
// elements. The value at the specified filter may be undefined, a single
// view, or an array of views; in all cases, chain on a flat array.
if (typeof fn === "string") {
fn = this.sections[fn] || fn;
views = this.views[fn] || [];
// If Views is undefined you are concatenating an `undefined` to an array
// resulting in a value being returned. Defaulting to an array prevents
// this.
//return _.chain([].concat(views || []));
return _.chain([].concat(views));
}
// Generate an array of all top level (no deeply nested) Views flattened.
views = _.chain(this.views).map(function(view) {
return _.isArray(view) ? view : [view];
}, this).flatten();
// If the argument passed is an Object, then pass it to `_.where`.
if (typeof fn === "object") {
return views.where(fn);
}
// If a filter function is provided, run it on all Views and return a
// wrapped chain. Otherwise, simply return a wrapped chain of all Views.
return typeof fn === "function" ? views.filter(fn) : views;
},
// Use this to remove Views, internally uses `getViews` so you can pass the
// same argument here as you would to that method.
removeView: function(fn) {
// Allow an optional selector or function to find the right model and
// remove nested Views based off the results of the selector or filter.
return this.getViews(fn).each(function(nestedView) {
nestedView.remove();
});
},
// This takes in a partial name and view instance and assigns them to
// the internal collection of views. If a view is not a LayoutManager
// instance, then mix in the LayoutManager prototype. This ensures
// all Views can be used successfully.
//
// Must definitely wrap any render method passed in or defaults to a
// typical render function `return layout(this).render()`.
setView: function(name, view, insert) {
var manager, selector;
// Parent view, the one you are setting a View on.
var root = this;
// If no name was passed, use an empty string and shift all arguments.
if (typeof name !== "string") {
insert = view;
view = name;
name = "";
}
// Shorthand the `__manager__` property.
manager = view.__manager__;
// If the View has not been properly set up, throw an Error message
// indicating that the View needs `manage: true` set.
if (!manager) {
throw new Error("The argument associated with selector '" + name +
"' is defined and a View. Set `manage` property to true for " +
"Backbone.View instances.");
}
// Add reference to the parentView.
manager.parent = root;
// Add reference to the placement selector used.
selector = manager.selector = root.sections[name] || name;
// Code path is less complex for Views that are not being inserted. Simply
// remove existing Views and bail out with the assignment.
if (!insert) {
// Ensure remove is called only when swapping in a new view (when the
// view is the same, it does not need to be removed or cleaned up).
if (root.getView(name) !== view) {
root.removeView(name);
}
// Assign to main views object and return for chainability.
return root.views[selector] = view;
}
// Ensure this.views[selector] is an array and push this View to
// the end.
root.views[selector] = aConcat.call([], root.views[name] || [], view);
// Put the parent view into `insert` mode.
root.__manager__.insert = true;
return view;
},
// Allows the setting of multiple views instead of a single view.
setViews: function(views) {
// Iterate over all the views and use the View's view method to assign.
_.each(views, function(view, name) {
// If the view is an array put all views into insert mode.
if (_.isArray(view)) {
return _.each(view, function(view) {
this.insertView(name, view);
}, this);
}
// Assign each view using the view function.
this.setView(name, view);
}, this);
// Allow for chaining
return this;
},
// By default this should find all nested views and render them into
// the this.el and call done once all of them have successfully been
// resolved.
//
// This function returns a promise that can be chained to determine
// once all subviews and main view have been rendered into the view.el.
render: function() {
var root = this;
var manager = root.__manager__;
var parent = manager.parent;
var rentManager = parent && parent.__manager__;
var def = root.deferred();
// Triggered once the render has succeeded.
function resolve() {
var next;
// Insert all subViews into the parent at once.
_.each(root.views, function(views, selector) {
// Fragments aren't used on arrays of subviews.
if (_.isArray(views)) {
root.htmlBatch(root, views, selector);
}
});
// If there is a parent and we weren't attached to it via the previous
// method (single view), attach.
if (parent && !manager.insertedViaFragment) {
if (!root.contains(parent.el, root.el)) {
// Apply the partial using parent's html() method.
parent.partial(parent.$el, root.$el, rentManager, manager);
}
}
// Ensure events are always correctly bound after rendering.
root.delegateEvents();
// Set this View as successfully rendered.
root.hasRendered = true;
// Only process the queue if it exists.
if (next = manager.queue.shift()) {
// Ensure that the next render is only called after all other
// `done` handlers have completed. This will prevent `render`
// callbacks from firing out of order.
next();
} else {
// Once the queue is depleted, remove it, the render process has
// completed.
delete manager.queue;
}
// Reusable function for triggering the afterRender callback and event
// and setting the hasRendered flag.
function completeRender() {
var console = window.console;
var afterRender = root.afterRender;
if (afterRender) {
afterRender.call(root, root);
}
// Always emit an afterRender event.
root.trigger("afterRender", root);
// If there are multiple top level elements and `el: false` is used,
// display a warning message and a stack trace.
if (manager.noel && root.$el.length > 1) {
// Do not display a warning while testing or if warning suppression
// is enabled.
if (_.isFunction(console.warn) && !root.suppressWarnings) {
console.warn("`el: false` with multiple top level elements is " +
"not supported.");
// Provide a stack trace if available to aid with debugging.
if (_.isFunction(console.trace)) {
console.trace();
}
}
}
}
// If the parent is currently rendering, wait until it has completed
// until calling the nested View's `afterRender`.
if (rentManager && rentManager.queue) {
// Wait until the parent View has finished rendering, which could be
// asynchronous, and trigger afterRender on this View once it has
// compeleted.
parent.once("afterRender", completeRender);
} else {
// This View and its parent have both rendered.
completeRender();
}
return def.resolveWith(root, [root]);
}
// Actually facilitate a render.
function actuallyRender() {
// The `_viewRender` method is broken out to abstract away from having
// too much code in `actuallyRender`.
root._render().done(function() {
// If there are no children to worry about, complete the render
// instantly.
if (!_.keys(root.views).length) {
return resolve();
}
// Create a list of promises to wait on until rendering is done.
// Since this method will run on all children as well, its sufficient
// for a full hierarchical.
var promises = _.map(root.views, function(view) {
var insert = _.isArray(view);
// If items are being inserted, they will be in a non-zero length
// Array.
if (insert && view.length) {
// Mark each subview's manager so they don't attempt to attach by
// themselves. Return a single promise representing the entire
// render.
return root.when(_.map(view, function(subView) {
subView.__manager__.insertedViaFragment = true;
return subView.render().__manager__.renderDeferred;
}));
}
// Only return the fetch deferred, resolve the main deferred after
// the element has been attached to it's parent.
return !insert ? view.render().__manager__.renderDeferred : view;
});
// Once all nested Views have been rendered, resolve this View's
// deferred.
root.when(promises).done(resolve);
});
}
// Another render is currently happening if there is an existing queue, so
// push a closure to render later into the queue.
if (manager.queue) {
aPush.call(manager.queue, actuallyRender);
} else {
manager.queue = [];
// This the first `render`, preceeding the `queue` so render
// immediately.
actuallyRender(root, def);
}
// Put the deferred inside of the `__manager__` object, since we don't want
// end users accessing this directly anymore in favor of the `afterRender`
// event. So instead of doing `render().then(...` do
// `render().once("afterRender", ...`.
root.__manager__.renderDeferred = def;
// Return the actual View for chainability purposes.
return root;
},
// Ensure the cleanup function is called whenever remove is called.
remove: function() {
// Force remove itself from its parent.
LayoutManager._removeView(this, true);
// Call the original remove function.
return this._remove.apply(this, arguments);
}
},
// Static Properties
{
// Clearable cache.
_cache: {},
// Remove all nested Views.
_removeViews: function(root, force) {
// Shift arguments around.
if (typeof root === "boolean") {
force = root;
root = this;
}
// Allow removeView to be called on instances.
root = root || this;
// Iterate over all of the nested View's and remove.
root.getViews().each(function(view) {
// Force doesn't care about if a View has rendered or not.
if (view.hasRendered || force) {
LayoutManager._removeView(view, force);
}
});
},
// Remove a single nested View.
_removeView: function(view, force) {
var parentViews;
// Shorthand the managers for easier access.
var manager = view.__manager__;
var rentManager = manager.parent && manager.parent.__manager__;
// Test for keep.
var keep = typeof view.keep === "boolean" ? view.keep : view.options.keep;
// In insert mode, remove views that do not have `keep` attribute set,
// unless the force flag is set.
if ((!keep && rentManager && rentManager.insert === true) || force) {
// Clean out the events.
LayoutManager.cleanViews(view);
// Since we are removing this view, force subviews to remove
view._removeViews(true);
// Remove the View completely.
view.$el.remove();
// Bail out early if no parent exists.
if (!manager.parent) { return; }
// Assign (if they exist) the sibling Views to a property.
parentViews = manager.parent.views[manager.selector];
// If this is an array of items remove items that are not marked to
// keep.
if (_.isArray(parentViews)) {
// Remove duplicate Views.
return _.each(_.clone(parentViews), function(view, i) {
// If the managers match, splice off this View.
if (view && view.__manager__ === manager) {
aSplice.call(parentViews, i, 1);
}
});
}
// Otherwise delete the parent selector.
delete manager.parent.views[manager.selector];
}
},
// Cache templates into LayoutManager._cache.
cache: function(path, contents) {
// If template path is found in the cache, return the contents.
if (path in this._cache && contents == null) {
return this._cache[path];
// Ensure path and contents aren't undefined.
} else if (path != null && contents != null) {
return this._cache[path] = contents;
}
// If the template is not in the cache, return undefined.
},
// Accept either a single view or an array of views to clean of all DOM
// events internal model and collection references and all Backbone.Events.
cleanViews: function(views) {
// Clear out all existing views.
_.each(aConcat.call([], views), function(view) {
// Remove all custom events attached to this View.
view.unbind();
// Automatically unbind `model`.
if (view.model instanceof Backbone.Model) {
view.model.off(null, null, view);
}
// Automatically unbind `collection`.
if (view.collection instanceof Backbone.Collection) {
view.collection.off(null, null, view);
}
// Automatically unbind events bound to this View.
view.stopListening();
// If a custom cleanup method was provided on the view, call it after
// the initial cleanup is done
if (_.isFunction(view.cleanup)) {
view.cleanup();
}
});
},
// This static method allows for global configuration of LayoutManager.
configure: function(options) {
_.extend(LayoutManager.prototype, options);
// Allow LayoutManager to manage Backbone.View.prototype.
if (options.manage) {
Backbone.View.prototype.manage = true;
}
// Disable the element globally.
if (options.el === false) {
Backbone.View.prototype.el = false;
}
// Allow global configuration of `suppressWarnings`.
if (options.suppressWarnings === true) {
Backbone.View.prototype.suppressWarnings = true;
}
},
// Configure a View to work with the LayoutManager plugin.
setupView: function(views, options) {
// Ensure that options is always an object, and clone it so that
// changes to the original object don't screw up this view.
options = _.extend({}, options);
// Set up all Views passed.
_.each(aConcat.call([], views), function(view) {
// If the View has already been setup, no need to do it again.
if (view.__manager__) {
return;
}
var views, declaredViews;
var proto = LayoutManager.prototype;
// Ensure necessary properties are set.
_.defaults(view, {
// Ensure a view always has a views object.
views: {},
// Ensure a view always has a sections object.
sections: {},
// Internal state object used to store whether or not a View has been
// taken over by layout manager and if it has been rendered into the
// DOM.
__manager__: {},
// Add the ability to remove all Views.
_removeViews: LayoutManager._removeViews,
// Add the ability to remove itself.
_removeView: LayoutManager._removeView
// Mix in all LayoutManager prototype properties as well.
}, LayoutManager.prototype);
// Assign passed options.
view.options = options;
// Merge the View options into the View.
_.extend(view, options);
// By default the original Remove function is the Backbone.View one.
view._remove = Backbone.View.prototype.remove;
// Ensure the render is always set correctly.
view.render = LayoutManager.prototype.render;
// If the user provided their own remove override, use that instead of
// the default.
if (view.remove !== proto.remove) {
view._remove = view.remove;
view.remove = proto.remove;
}
// Normalize views to exist on either instance or options, default to
// options.
views = options.views || view.views;
// Set the internal views, only if selectors have been provided.
if (_.keys(views).length) {
// Keep original object declared containing Views.
declaredViews = views;
// Reset the property to avoid duplication or overwritting.
view.views = {};
// If any declared view is wrapped in a function, invoke it.
_.each(declaredViews, function(declaredView, key) {
if (typeof declaredView === "function") {
declaredViews[key] = declaredView.call(view, view);
}
});
// Set the declared Views.
view.setViews(declaredViews);
}
});
}
});
LayoutManager.VERSION = "0.9.5";
// Expose through Backbone object.
Backbone.Layout = LayoutManager;
// Override _configure to provide extra functionality that is necessary in
// order for the render function reference to be bound during initialize.
Backbone.View = function(options) {
var noel;
// Ensure options is always an object.
options = options || {};
// Remove the container element provided by Backbone.
if ("el" in options ? options.el === false : this.el === false) {
noel = true;
}
// If manage is set, do it!
if (options.manage || this.manage) {
// Set up this View.
LayoutManager.setupView(this, options);
}
// Assign the `noel` property once we're sure the View we're working with is
// managed by LayoutManager.
if (this.__manager__) {
this.__manager__.noel = noel;
this.__manager__.suppressWarnings = options.suppressWarnings;
}
// Act like nothing happened.
ViewConstructor.apply(this, arguments);
};
// Copy over the extend method.
Backbone.View.extend = ViewConstructor.extend;
// Copy over the prototype as well.
Backbone.View.prototype = ViewConstructor.prototype;
// Default configuration options; designed to be overriden.
var defaultOptions = {
// Prefix template/layout paths.
prefix: "",
// Can be used to supply a different deferred implementation.
deferred: function() {
return $.Deferred();
},
// Fetch is passed a path and is expected to return template contents as a
// function or string.
fetchTemplate: function(path) {
return _.template($(path).html());
},
// By default, render using underscore's templating and trim output.
renderTemplate: function(template, context) {
return trim(template.call(this, context));
},
// By default, pass model attributes to the templates
serialize: function() {
return this.model ? _.clone(this.model.attributes) : {};
},
// This is the most common way you will want to partially apply a view into
// a layout.
partial: function($root, $el, rentManager, manager) {
var $filtered;
// If selector is specified, attempt to find it.
if (manager.selector) {
if (rentManager.noel) {
$filtered = $root.filter(manager.selector);
$root = $filtered.length ? $filtered : $root.find(manager.selector);
} else {
$root = $root.find(manager.selector);
}
}
// Use the insert method if the parent's `insert` argument is true.
if (rentManager.insert) {
this.insert($root, $el);
} else {
this.html($root, $el);
}
},
// Override this with a custom HTML method, passed a root element and content
// (a jQuery collection or a string) to replace the innerHTML with.
html: function($root, content) {
$root.html(content);
},
// Used for inserting subViews in a single batch. This gives a small
// performance boost as we write to a disconnected fragment instead of to the
// DOM directly. Smarter browsers like Chrome will batch writes internally
// and layout as seldom as possible, but even in that case this provides a
// decent boost. jQuery will use a DocumentFragment for the batch update,
// but Cheerio in Node will not.
htmlBatch: function(rootView, subViews, selector) {
// Shorthand the parent manager object.
var rentManager = rootView.__manager__;
// Create a simplified manager object that tells partial() where
// place the elements.
var manager = { selector: selector };
// Get the elements to be inserted into the root view.
var els = _.reduce(subViews, function(memo, sub) {
// Check if keep is present - do boolean check in case the user
// has created a `keep` function.
var keep = typeof sub.keep === "boolean" ? sub.keep : sub.options.keep;
// If a subView is present, don't push it. This can only happen if
// `keep: true`. We do the keep check for speed as $.contains is not
// cheap.
var exists = keep && $.contains(rootView.el, sub.el);
// If there is an element and it doesn't already exist in our structure
// attach it.
if (sub.el && !exists) {
memo.push(sub.el);
}
return memo;
}, []);
// Use partial to apply the elements. Wrap els in jQ obj for cheerio.
return this.partial(rootView.$el, $(els), rentManager, manager);
},
// Very similar to HTML except this one will appendChild by default.
insert: function($root, $el) {
$root.append($el);
},
// Return a deferred for when all promises resolve/reject.
when: function(promises) {
return $.when.apply(null, promises);
},
// A method to determine if a View contains another.
contains: function(parent, child) {
return $.contains(parent, child);
}
};
// Extend LayoutManager with default options.
_.extend(LayoutManager.prototype, defaultOptions);
// Assign `LayoutManager` object for AMD loaders.
return LayoutManager;
}));