ractive
Version:
Next-generation DOM manipulation
1,931 lines (1,475 loc) • 46.2 kB
JavaScript
/*! anglebars - v0.1.6 - 2013-03-19
* http://rich-harris.github.com/Anglebars/
* Copyright (c) 2013 Rich Harris; Licensed WTFPL */
/*jslint eqeq: true, plusplus: true */
/*global document, HTMLElement */
(function ( global ) {
"use strict";var Anglebars, getEl;
Anglebars = function ( options ) {
var defaults, key;
// Options
// -------
if ( options ) {
for ( key in options ) {
if ( options.hasOwnProperty( key ) ) {
this[ key ] = options[ key ];
}
}
}
defaults = {
preserveWhitespace: false,
append: false,
twoway: true,
formatters: {},
modifyArrays: true
};
for ( key in defaults ) {
if ( this[ key ] === undefined ) {
this[ key ] = defaults[ key ];
}
}
// Initialization
// --------------
if ( this.el !== undefined ) {
this.el = getEl( this.el ); // turn ID string into DOM element
}
// Set up event bus
this._subs = {};
if ( this.viewmodel === undefined ) {
this.viewmodel = new Anglebars.ViewModel();
}
// bind viewmodel to this anglebars instance
this.viewmodel.dependents.push( this );
// Initialise (or update) viewmodel with data
if ( this.data ) {
this.viewmodel.set( this.data );
}
// If we were given uncompiled partials, compile them
if ( this.partials ) {
for ( key in this.partials ) {
if ( this.partials.hasOwnProperty( key ) ) {
if ( typeof this.partials[ key ] === 'string' ) {
if ( !Anglebars.compile ) {
throw new Error( 'Missing Anglebars.compile - cannot compile partial "' + key + '". Either precompile or use the version that includes the compiler' );
}
this.partials[ key ] = Anglebars.compile( this.partials[ key ], this ); // all compiler options are present on `this`, so just passing `this`
}
}
}
}
// Compile template, if it hasn't been compiled already
if ( typeof this.template === 'string' ) {
if ( !Anglebars.compile ) {
throw new Error( 'Missing Anglebars.compile - cannot compile template. Either precompile or use the version that includes the compiler' );
}
this.template = Anglebars.compile( this.template, this );
}
// If passed an element, render immediately
if ( this.el ) {
this.render({ el: this.el, callback: this.callback, append: this.append });
}
};
// Prototype methods
// =================
Anglebars.prototype = {
// Render instance to element specified here or at initialization
render: function ( options ) {
var el = ( options.el ? getEl( options.el ) : this.el );
if ( !el ) {
throw new Error( 'You must specify a DOM element to render to' );
}
// Clear the element, unless `append` is `true`
if ( !options.append ) {
el.innerHTML = '';
}
if ( options.callback ) {
this.callback = options.callback;
}
// Render our *root fragment*
this.rendered = new Anglebars.DomFragment({
model: this.template,
anglebars: this,
parentNode: el
});
},
// Teardown. This goes through the root fragment and all its children, removing observers
// and generally cleaning up after itself
teardown: function () {
this.rendered.teardown();
},
// Proxies for viewmodel `set`, `get`, `update`, `observe` and `unobserve` methods
set: function () {
var oldDisplay = this.el.style.display;
this.viewmodel.set.apply( this.viewmodel, arguments );
return this;
},
get: function () {
return this.viewmodel.get.apply( this.viewmodel, arguments );
},
update: function () {
this.viewmodel.update.apply( this.viewmodel, arguments );
return this;
},
// Internal method to format a value, using formatters passed in at initialization
_format: function ( value, formatters ) {
var i, numFormatters, formatter, name, args, fn;
// If there are no formatters, groovy - just return the value unchanged
if ( !formatters ) {
return value;
}
// Otherwise go through each in turn, applying sequentially
numFormatters = formatters.length;
for ( i=0; i<numFormatters; i+=1 ) {
formatter = formatters[i];
name = formatter.name;
args = formatter.args || [];
// If a formatter was passed in, use it, otherwise see if there's a default
// one with this name
fn = this.formatters[ name ] || Anglebars.formatters[ name ];
if ( fn ) {
value = fn.apply( this, [ value ].concat( args ) );
}
}
return value;
}
};
// helper functions
getEl = function ( input ) {
var output;
if ( !input ) {
throw new Error( 'No container element specified' );
}
// We already have a DOM node - no work to do
if ( input.tagName ) {
return input;
}
// Get node from string
if ( typeof input === 'string' ) {
// try ID first
output = document.getElementById( input );
// then as selector, if possible
if ( !output && document.querySelector ) {
output = document.querySelector( input );
}
// did it work?
if ( output.tagName ) {
return output;
}
}
// If we've been given a collection (jQuery, Zepto etc), extract the first item
if ( input[0] && input[0].tagName ) {
return input[0];
}
throw new Error( 'Could not find container element' );
};
// thanks, http://perfectionkills.com/instanceof-considered-harmful-or-how-to-write-a-robust-isarray/
Anglebars.isArray = function ( obj ) {
return Object.prototype.toString.call( obj ) === '[object Array]';
};
Anglebars.isObject = function ( obj ) {
return ( Object.prototype.toString.call( obj ) === '[object Object]' ) && ( typeof obj !== 'function' );
};
// Mustache types, used in various places
Anglebars.types = {
TEXT: 1,
INTERPOLATOR: 2,
TRIPLE: 3,
SECTION: 4,
INVERTED: 5,
CLOSING: 6,
ELEMENT: 7,
PARTIAL: 8,
COMMENT: 9,
DELIMCHANGE: 10,
MUSTACHE: 11,
TAG: 12,
ATTR_VALUE_TOKEN: 13
};
(function ( proto ) {
'use strict';
proto.on = function ( eventName, callback ) {
var self = this, subscribers;
if ( !this._subs[ eventName ] ) {
this._subs[ eventName ] = [ callback ];
} else {
this._subs[ eventName ].push( callback );
}
return {
cancel: function () {
self.off( eventName, callback );
}
};
};
proto.off = function ( eventName, callback ) {
var subscribers, index;
// if no callback specified, remove all callbacks
if ( !callback ) {
// if no event name specified, remove all callbacks for all events
if ( !eventName ) {
this._subs = {};
} else {
this._subs[ eventName ] = [];
}
}
subscribers = this._subs[ eventName ];
if ( subscribers ) {
index = subscribers.indexOf( callback );
if ( index !== -1 ) {
subscribers.splice( index, 1 );
}
}
};
proto.fire = function ( eventName ) {
var args, i, len, subscribers = this._subs[ eventName ];
if ( !subscribers ) {
return;
}
args = Array.prototype.slice.call( arguments, 1 );
for ( i=0, len=subscribers.length; i<len; i+=1 ) {
subscribers[i].apply( this, args );
}
};
}( Anglebars.prototype ));
// Default formatters
Anglebars.formatters = {
equals: function ( a, b ) {
return a === b;
},
greaterThan: function ( a, b ) {
return a > b;
},
greaterThanEquals: function ( a, b ) {
return a >= b;
},
lessThan: function ( a, b ) {
return a < b;
},
lessThanEquals: function ( a, b ) {
return a <= b;
}
};
(function ( A ) {
var arrayPointer, splitKeypath, keypathNormaliser;
// ViewModel constructor
A.ViewModel = function ( data ) {
// Initialise with supplied data, or create an empty object
this.data = data || {};
// Create empty array for keypaths that can't be resolved initially
this.pendingResolution = [];
// Create empty object for observers
this.observers = {};
// Dependent Anglebars instances
this.dependents = [];
};
A.ViewModel.prototype = {
// Update the `value` of `keypath`, and notify the observers of
// `keypath` and its descendants
set: function ( keypath, value ) {
var k, keys, key, obj, i, unresolved, fullKeypath;
// Allow multiple values to be set in one go
if ( typeof keypath === 'object' ) {
for ( k in keypath ) {
if ( keypath.hasOwnProperty( k ) ) {
this.set( k, keypath[k] );
}
}
return;
}
// fire events
this.dependents.forEach( function ( dep ) {
if ( dep.setting ) {
return; // short-circuit any potential infinite loops
}
dep.setting = true;
dep.fire( 'set', keypath, value );
dep.fire( 'set:' + keypath, value );
dep.setting = false;
});
// Split key path into keys (e.g. `'foo.bar[0]'` -> `['foo','bar',0]`)
keys = splitKeypath( keypath );
// TODO accommodate implicit array generation
obj = this.data;
while ( keys.length > 1 ) {
key = keys.shift();
obj = obj[ key ] || {};
}
key = keys[0];
obj[ key ] = value;
// Trigger updates of views that observe `keypaths` or its descendants
this._notifyObservers( keypath, value );
// See if we can resolve any of the unresolved keypaths (if such there be)
i = this.pendingResolution.length;
while ( i-- ) { // Work backwards, so we don't go in circles!
unresolved = this.pendingResolution.splice( i, 1 )[0];
fullKeypath = this.getFullKeypath( unresolved.view.model.ref, unresolved.view.contextStack );
// If we were able to find a keypath, initialise the view
if ( fullKeypath !== undefined ) {
unresolved.callback( fullKeypath );
}
// Otherwise add to the back of the queue (this is why we're working backwards)
else {
this.registerUnresolvedKeypath( unresolved );
}
}
},
// Get the current value of `keypath`
get: function ( keypath ) {
var keys, result;
if ( !keypath ) {
return undefined;
}
keys = splitKeypath( keypath );
result = this.data;
while ( keys.length ) {
if ( result ) {
result = result[ keys.shift() ];
}
if ( result === undefined ) {
return result;
}
}
return result;
},
// Force notify observers of `keypath` (useful if e.g. an array or object member
// was changed without calling `anglebars.set()`)
update: function ( keypath ) {
if ( keypath ) {
this._notifyObservers( keypath, this.get( keypath ) );
}
// no keypath? update all the things
else {
for ( keypath in this.data ) {
if ( this.data.hasOwnProperty( keypath ) ) {
this._notifyObservers( keypath, this.get( keypath ) );
}
}
}
},
registerView: function ( view ) {
var self = this, fullKeypath, initialUpdate, value, index;
if ( view.parentFragment && ( view.model.ref in view.parentFragment.indexRefs ) ) {
// this isn't a real keypath, it's an index reference
index = view.parentFragment.indexRefs[ view.model.ref ];
value = ( view.model.fmtrs ? view.anglebars._format( index, view.model.fmtrs ) : index );
view.update( value );
return; // this value will never change, and doesn't have a keypath
}
initialUpdate = function ( keypath ) {
view.keypath = keypath;
// create observers
view.observerRefs = self.observe({
keypath: keypath,
priority: view.model.p || 0,
view: view
});
value = self.get( keypath );
// pass value through formatters, if there are any
if ( view.model.fmtrs ) {
value = view.anglebars._format( value, view.model.fmtrs );
}
view.update( value );
};
fullKeypath = this.getFullKeypath( view.model.ref, view.contextStack );
if ( fullKeypath === undefined ) {
this.registerUnresolvedKeypath({
view: view,
callback: initialUpdate
});
} else {
initialUpdate( fullKeypath );
}
},
// Resolve a full keypath from `ref` within the given `contextStack` (e.g.
// `'bar.baz'` within the context stack `['foo']` might resolve to `'foo.bar.baz'`
getFullKeypath: function ( ref, contextStack ) {
var innerMost;
// Implicit iterators - i.e. {{.}} - are a special case
if ( ref === '.' ) {
return contextStack[ contextStack.length - 1 ];
}
// Clone the context stack, so we don't mutate the original
contextStack = contextStack.concat();
// Take each context from the stack, working backwards from the innermost context
while ( contextStack.length ) {
innerMost = contextStack.pop();
if ( this.get( innerMost + '.' + ref ) !== undefined ) {
return innerMost + '.' + ref;
}
}
if ( this.get( ref ) !== undefined ) {
return ref;
}
},
registerUnresolvedKeypath: function ( unresolved ) {
this.pendingResolution[ this.pendingResolution.length ] = unresolved;
},
_notifyObservers: function ( keypath, value ) {
var self = this, observersGroupedByLevel = this.observers[ keypath ] || [], i, j, priority, observer, actualValue;
for ( i=0; i<observersGroupedByLevel.length; i+=1 ) {
priority = observersGroupedByLevel[i];
if ( priority ) {
for ( j=0; j<priority.length; j+=1 ) {
observer = priority[j];
if ( keypath !== observer.originalAddress ) {
actualValue = self.get( observer.originalAddress );
} else {
actualValue = value;
}
if ( observer.view ) {
// apply formatters, if there are any
if ( observer.view.model.fmtrs ) {
actualValue = observer.view.anglebars._format( actualValue, observer.view.model.fmtrs );
}
observer.view.update( actualValue );
}
if ( observer.callback ) {
observer.callback( actualValue );
}
}
}
}
},
observe: function ( options ) {
var self = this, keypath, originalAddress = options.keypath, priority = options.priority, observerRefs = [], observe;
// Allow `observe( keypath, callback )` syntax
if ( arguments.length === 2 && typeof arguments[0] === 'string' && typeof arguments[1] === 'function' ) {
return this.observe({ keypath: arguments[0], callback: arguments[1], priority: 0 });
}
if ( !options.keypath ) {
return undefined;
}
observe = function ( keypath ) {
var observers, observer;
observers = self.observers[ keypath ] = self.observers[ keypath ] || [];
observers = observers[ priority ] = observers[ priority ] || [];
observer = {
originalAddress: originalAddress
};
// if we're given a view to update, add it to the observer - ditto callbacks
if ( options.view ) {
observer.view = options.view;
}
if ( options.callback ) {
observer.callback = options.callback;
}
observers[ observers.length ] = observer;
observerRefs[ observerRefs.length ] = {
keypath: keypath,
priority: priority,
observer: observer
};
};
keypath = options.keypath;
while ( keypath.lastIndexOf( '.' ) !== -1 ) {
observe( keypath );
// remove the last item in the keypath, so that data.set( 'parent', { child: 'newValue' } ) affects views dependent on parent.child
keypath = keypath.substr( 0, keypath.lastIndexOf( '.' ) );
}
observe( keypath );
return observerRefs;
},
unobserve: function ( observerRef ) {
var priorities, observers, index;
priorities = this.observers[ observerRef.keypath ];
if ( !priorities ) {
// nothing to unobserve
return;
}
observers = priorities[ observerRef.priority ];
if ( !observers ) {
// nothing to unobserve
return;
}
if ( observers.indexOf ) {
index = observers.indexOf( observerRef.observer );
} else {
// fuck you IE
for ( var i=0, len=observers.length; i<len; i+=1 ) {
if ( observers[i] === observerRef.observer ) {
index = i;
break;
}
}
}
if ( index === -1 ) {
// nothing to unobserve
return;
}
// remove the observer from the list...
observers.splice( index, 1 );
// ...then tidy up if necessary
if ( observers.length === 0 ) {
delete priorities[ observerRef.priority ];
}
if ( priorities.length === 0 ) {
delete this.observers[ observerRef.keypath ];
}
},
unobserveAll: function ( observerRefs ) {
while ( observerRefs.length ) {
this.unobserve( observerRefs.shift() );
}
}
};
if ( Array.prototype.filter ) { // Browsers that aren't unredeemable pieces of shit
A.ViewModel.prototype.cancelKeypathResolution = function ( view ) {
this.pendingResolution = this.pendingResolution.filter( function ( pending ) {
return pending.view !== view;
});
};
}
else { // Internet Exploder
A.ViewModel.prototype.cancelKeypathResolution = function ( view ) {
var i, filtered = [];
for ( i=0; i<this.pendingResolution.length; i+=1 ) {
if ( this.pendingResolution[i].view !== view ) {
filtered[ filtered.length ] = this.pendingResolution[i];
}
}
this.pendingResolution = filtered;
};
}
keypathNormaliser = /\[\s*([0-9]+)\s*\]/g;
splitKeypath = function ( keypath ) {
var normalised;
// normalise keypath (e.g. 'foo[0]' becomes 'foo.0')
normalised = keypath.replace( keypathNormaliser, '.$1' );
return normalised.split( '.' );
};
}( Anglebars ));
(function ( A, doc ) {
'use strict';
var createView, types, insertHtml, isArray, isObject, elContains,
Text, Element, Partial, Attribute, Mustache, Interpolator, Triple, Section;
types = A.types;
isArray = A.isArray;
isObject = A.isObject;
elContains = function ( haystack, needle ) {
// TODO!
if ( haystack.contains ) {
return haystack.contains( needle );
}
return true;
};
insertHtml = function ( html, parent, anchor ) {
var div, i, len, nodes = [];
anchor = anchor || null;
div = doc.createElement( 'div' );
div.innerHTML = html;
len = div.childNodes.length;
for ( i=0; i<len; i+=1 ) {
nodes[i] = div.childNodes[i];
}
for ( i=0; i<len; i+=1 ) {
parent.insertBefore( nodes[i], anchor );
}
return nodes;
};
// Base Mustache class
Mustache = function ( options ) {
this.model = options.model;
this.anglebars = options.anglebars;
this.viewmodel = options.anglebars.viewmodel;
this.parentNode = options.parentNode;
this.parentFragment = options.parentFragment;
this.contextStack = options.contextStack || [];
this.anchor = options.anchor;
this.index = options.index;
this.type = options.model.type;
this.initialize();
this.viewmodel.registerView( this );
// if we have a failed keypath lookup, and this is an inverted section,
// we need to trigger this.update() so the contents are rendered
if ( !this.keypath && this.model.inv ) { // test both section-hood and inverticity in one go
this.update( false );
}
};
// View types
createView = function ( options ) {
if ( typeof options.model === 'string' ) {
return new Text( options );
}
switch ( options.model.type ) {
case types.INTERPOLATOR: return new Interpolator( options );
case types.SECTION: return new Section( options );
case types.TRIPLE: return new Triple( options );
case types.ELEMENT: return new Element( options );
case types.PARTIAL: return new Partial( options );
default: throw 'WTF? not sure what happened here...';
}
};
// Fragment
A.DomFragment = function ( options ) {
var numModels, i, itemOptions, parentRefs, ref;
// if we have an HTML string, our job is easy.
if ( typeof options.model === 'string' ) {
this.nodes = insertHtml( options.model, options.parentNode, options.anchor );
return;
}
// otherwise we have to do some work
this.owner = options.owner;
this.index = options.index;
this.indexRefs = {};
if ( this.owner && this.owner.parentFragment ) {
parentRefs = this.owner.parentFragment.indexRefs;
for ( ref in parentRefs ) {
if ( parentRefs.hasOwnProperty( ref ) ) {
this.indexRefs[ ref ] = parentRefs[ ref ];
}
}
}
if ( options.indexRef ) {
this.indexRefs[ options.indexRef ] = options.index;
}
itemOptions = {
anglebars: options.anglebars,
parentNode: options.parentNode,
contextStack: options.contextStack,
anchor: options.anchor,
parentFragment: this
};
this.items = [];
this.queue = [];
numModels = options.model.length;
for ( i=0; i<numModels; i+=1 ) {
itemOptions.model = options.model[i];
itemOptions.index = i;
this.items[i] = createView( itemOptions );
}
};
A.DomFragment.prototype = {
teardown: function () {
var node;
// if this was built from HTML, we just need to remove the nodes
if ( this.nodes ) {
while ( this.nodes.length ) {
node = this.nodes.pop();
node.parentNode.removeChild( node );
}
return;
}
// otherwise we need to do a proper teardown
while ( this.items.length ) {
this.items.pop().teardown();
}
},
firstNode: function () {
if ( this.items[0] ) {
return this.items[0].firstNode();
} else {
if ( this.parentSection ) {
return this.parentSection.findNextNode( this );
}
}
return null;
},
findNextNode: function ( item ) {
var index;
index = item.index;
if ( this.items[ index + 1 ] ) {
return this.items[ index + 1 ].firstNode();
} else {
if ( this.parentSection ) {
return this.parentSection.findNextNode( this );
}
}
return null;
}
};
// Partials
Partial = function ( options ) {
this.fragment = new A.DomFragment({
model: options.anglebars.partials[ options.model.ref ] || [],
anglebars: options.anglebars,
parentNode: options.parentNode,
contextStack: options.contextStack,
anchor: options.anchor,
owner: this
});
};
Partial.prototype = {
teardown: function () {
this.fragment.teardown();
}
};
// Plain text
Text = function ( options ) {
this.node = doc.createTextNode( options.model );
this.index = options.index;
this.anglebars = options.anglebars;
this.parentNode = options.parentNode;
// append this.node, either at end of parent element or in front of the anchor (if defined)
this.parentNode.insertBefore( this.node, options.anchor || null );
};
Text.prototype = {
teardown: function () {
if ( elContains( this.anglebars.el, this.node ) ) {
this.parentNode.removeChild( this.node );
}
},
firstNode: function () {
return this.node;
}
};
// Element
Element = function ( options ) {
var binding,
model,
namespace,
attr,
attrName,
attrValue;
// stuff we'll need later
model = this.model = options.model;
this.anglebars = options.anglebars;
this.viewmodel = options.anglebars.viewmodel;
this.parentFragment = options.parentFragment;
this.parentNode = options.parentNode;
this.index = options.index;
// get namespace
if ( model.attrs && model.attrs.xmlns ) {
namespace = model.attrs.xmlns;
// check it's a string!
if ( typeof namespace !== 'string' ) {
throw 'Namespace attribute cannot contain mustaches';
}
} else {
namespace = this.parentNode.namespaceURI;
}
// create the DOM node
this.node = doc.createElementNS( namespace, model.tag );
// set attributes
this.attributes = [];
for ( attrName in model.attrs ) {
if ( model.attrs.hasOwnProperty( attrName ) ) {
attrValue = model.attrs[ attrName ];
attr = new Attribute({
owner: this,
name: attrName,
value: attrValue,
anglebars: options.anglebars,
parentNode: this.node,
contextStack: options.contextStack
});
// if two-way binding is enabled, and we've got a dynamic `value` attribute, and this is an input or textarea, set up two-way binding
if ( attrName === 'value' && this.anglebars.twoway && ( model.tag.toLowerCase() === 'input' || model.tag.toLowerCase() === 'textarea' ) ) {
binding = attr;
}
this.attributes[ this.attributes.length ] = attr;
}
}
if ( binding ) {
this.bind( binding, options.anglebars.lazy );
}
// append children, if there are any
if ( model.frag ) {
if ( typeof model.frag === 'string' ) {
// great! we can use innerHTML
this.node.innerHTML = model.frag;
}
else {
this.children = new A.DomFragment({
model: model.frag,
anglebars: options.anglebars,
parentNode: this.node,
contextStack: options.contextStack,
anchor: null,
owner: this
});
}
}
// append this.node, either at end of parent element or in front of the anchor (if defined)
this.parentNode.insertBefore( this.node, options.anchor || null );
};
Element.prototype = {
bind: function ( attribute, lazy ) {
var viewmodel = this.viewmodel, node = this.node, setValue, valid, interpolator, keypath;
// Check this is a suitable candidate for two-way binding - i.e. it is
// a single interpolator with no formatters
valid = true;
if ( !attribute.fragment ||
( attribute.fragment.items.length !== 1 ) ||
( attribute.fragment.items[0].type !== A.types.INTERPOLATOR ) ||
( attribute.fragment.items[0].model.formatters && attribute.fragment.items[0].model.formatters.length )
) {
throw 'Not a valid two-way data binding candidate - must be a single interpolator with no formatters';
}
interpolator = attribute.fragment.items[0];
// Hmmm. Not sure if this is the best way to handle this ambiguity...
//
// Let's say we were given `value="{{bar}}"`. If the context stack was
// context stack was `["foo"]`, and `foo.bar` *wasn't* `undefined`, the
// keypath would be `foo.bar`. Then, any user input would result in
// `foo.bar` being updated.
//
// If, however, `foo.bar` *was* undefined, and so was `bar`, we would be
// left with an unresolved partial keypath - so we are forced to make an
// assumption. That assumption is that the input in question should
// be forced to resolve to `bar`, and any user input would affect `bar`
// and not `foo.bar`.
//
// Did that make any sense? No? Oh. Sorry. Well the moral of the story is
// be explicit when using two-way data-binding about what keypath you're
// updating. Using it in lists is probably a recipe for confusion...
keypath = interpolator.keypath || interpolator.model.partialKeypath;
setValue = function () {
var value = node.value;
// special cases
if ( value === '0' ) {
value = 0;
}
else if ( value !== '' ) {
value = +value || value;
}
// Note: we're counting on `viewmodel.set` recognising that `value` is
// already what it wants it to be, and short circuiting the process.
// Rather than triggering an infinite loop...
viewmodel.set( keypath, value );
};
// set initial value
setValue();
// TODO support shite browsers like IE and Opera
node.addEventListener( 'change', setValue );
if ( !lazy ) {
node.addEventListener( 'keyup', setValue );
}
},
teardown: function () {
if ( elContains( this.anglebars.el, this.node ) ) {
this.parentNode.removeChild( this.node );
}
if ( this.children ) {
this.children.teardown();
}
while ( this.attributes.length ) {
this.attributes.pop().teardown();
}
},
firstNode: function () {
return this.node;
}
};
// Attribute
Attribute = function ( options ) {
var i, name, value, colonIndex, namespacePrefix, namespace, ancestor;
name = options.name;
value = options.value;
this.owner = options.owner; // the element this belongs to
// are we dealing with a namespaced attribute, e.g. xlink:href?
colonIndex = name.indexOf( ':' );
if ( colonIndex !== -1 ) {
// looks like we are, yes...
namespacePrefix = name.substr( 0, colonIndex );
// ...unless it's a namespace *declaration*
if ( namespacePrefix === 'xmlns' ) {
namespace = null;
}
else {
// we need to find an ancestor element that defines this prefix
ancestor = options.parentNode;
// continue searching until there's nowhere further to go, or we've found the declaration
while ( ancestor && !namespace ) {
namespace = ancestor.getAttribute( 'xmlns:' + namespacePrefix );
// continue searching possible ancestors
ancestor = ancestor.parentNode || options.owner.parentFragment.owner.node || options.owner.parentFragment.owner.parentNode;
}
}
// if we've found a namespace, make a note of it
if ( namespace ) {
this.namespace = namespace;
}
}
// if it's just a straight key-value pair, with no mustache shenanigans, set the attribute accordingly
if ( typeof value === 'string' ) {
if ( namespace ) {
options.parentNode.setAttributeNS( namespace, name.replace( namespacePrefix + ':', '' ), value );
} else {
options.parentNode.setAttribute( name, value );
}
return;
}
// otherwise we need to do some work
this.parentNode = options.parentNode;
this.name = name;
this.children = [];
// share parentFragment with parent element
this.parentFragment = this.owner.parentFragment;
this.fragment = new A.TextFragment({
model: value,
anglebars: options.anglebars,
owner: this,
contextStack: options.contextStack
});
// manually trigger first update
this.ready = true;
this.update();
};
Attribute.prototype = {
teardown: function () {
// ignore non-dynamic attributes
if ( !this.children ) {
return;
}
while ( this.children.length ) {
this.children.pop().teardown();
}
},
bubble: function () {
this.update();
},
update: function () {
var prevValue = this.value;
if ( !this.ready ) {
return; // avoid items bubbling to the surface when we're still initialising
}
this.value = this.fragment.toString();
if ( this.value !== prevValue ) {
if ( this.namespace ) {
this.parentNode.setAttributeNS( this.namespace, this.name, this.value );
} else {
this.parentNode.setAttribute( this.name, this.value );
}
}
}
};
// Interpolator
Interpolator = function ( options ) {
// extend Mustache
Mustache.call( this, options );
};
Interpolator.prototype = {
initialize: function () {
this.node = doc.createTextNode( '' );
this.parentNode.insertBefore( this.node, this.anchor || null );
},
teardown: function () {
if ( !this.observerRefs ) {
this.viewmodel.cancelKeypathResolution( this );
} else {
this.viewmodel.unobserveAll( this.observerRefs );
}
if ( elContains( this.anglebars.el, this.node ) ) {
this.parentNode.removeChild( this.node );
}
},
update: function ( text ) {
if ( text !== this.text ) {
this.text = text;
this.node.data = text;
}
},
firstNode: function () {
return this.node;
}
};
// Triple
Triple = function ( options ) {
Mustache.call( this, options );
};
Triple.prototype = {
initialize: function () {
this.nodes = [];
},
teardown: function () {
// remove child nodes from DOM
if ( elContains( this.anglebars.el, this.parentNode ) ) {
while ( this.nodes.length ) {
this.parentNode.removeChild( this.nodes.pop() );
}
}
// kill observer(s)
if ( !this.observerRefs ) {
this.viewmodel.cancelKeypathResolution( this );
} else {
this.viewmodel.unobserveAll( this.observerRefs );
}
},
firstNode: function () {
if ( this.nodes[0] ) {
return this.nodes[0];
}
return this.parentFragment.findNextNode( this );
},
update: function ( html ) {
var anchor;
if ( html === this.html ) {
return;
} else {
this.html = html;
}
// TODO figure out if this line was supposed to mean something...
//anchor = ( this.initialised ? this.parentFragment.findNextNode( this ) : this.anchor );
// remove existing nodes
while ( this.nodes.length ) {
this.parentNode.removeChild( this.nodes.pop() );
}
anchor = this.anchor || this.parentFragment.findNextNode( this );
// get new nodes
this.nodes = insertHtml( html, this.parentNode, anchor );
}
};
// Section
Section = function ( options ) {
Mustache.call( this, options );
};
Section.prototype = {
initialize: function () {
this.views = [];
this.length = 0; // number of times this section is rendered
},
teardown: function () {
this.unrender();
if ( !this.observerRefs ) {
this.viewmodel.cancelKeypathResolution( this );
} else {
this.viewmodel.unobserveAll( this.observerRefs );
}
},
firstNode: function () {
if ( this.views[0] ) {
return this.views[0].firstNode();
}
return this.parentFragment.findNextNode( this );
},
findNextNode: function ( fragment ) {
if ( this.views[ fragment.index + 1 ] ) {
return this.views[ fragment.index + 1 ].firstNode();
} else {
return this.parentFragment.findNextNode( this );
}
},
unrender: function () {
while ( this.views.length ) {
this.views.shift().teardown();
}
},
update: function ( value ) {
var emptyArray, i, viewsToRemove, anchor, fragmentOptions, valueIsArray, valueIsObject;
fragmentOptions = {
model: this.model.frag,
anglebars: this.anglebars,
parentNode: this.parentNode,
anchor: this.parentFragment.findNextNode( this ),
owner: this
};
valueIsArray = isArray( value );
// modify the array to allow updates via push, pop etc
if ( valueIsArray && this.anglebars.modifyArrays ) {
A.modifyArray( value, this.keypath, this.anglebars.viewmodel );
}
// treat empty arrays as false values
if ( valueIsArray && value.length === 0 ) {
emptyArray = true;
}
// if section is inverted, only check for truthiness/falsiness
if ( this.model.inv ) {
if ( value && !emptyArray ) {
if ( this.length ) {
this.unrender();
this.length = 0;
return;
}
}
else {
if ( !this.length ) {
anchor = this.parentFragment.findNextNode( this );
// no change to context stack in this situation
fragmentOptions.contextStack = this.contextStack;
fragmentOptions.index = 0;
this.views[0] = new A.DomFragment( fragmentOptions );
this.length = 1;
return;
}
}
return;
}
// otherwise we need to work out what sort of section we're dealing with
// if value is an array, iterate through
if ( valueIsArray ) {
// if the array is shorter than it was previously, remove items
if ( value.length < this.length ) {
viewsToRemove = this.views.splice( value.length, this.length - value.length );
while ( viewsToRemove.length ) {
viewsToRemove.pop().teardown();
}
}
// otherwise...
else {
if ( value.length > this.length ) {
// add any new ones
for ( i=this.length; i<value.length; i+=1 ) {
// append list item to context stack
fragmentOptions.contextStack = this.contextStack.concat( this.keypath + '.' + i );
fragmentOptions.index = i;
if ( this.model.i ) {
fragmentOptions.indexRef = this.model.i;
}
this.views[i] = new A.DomFragment( fragmentOptions );
}
}
}
this.length = value.length;
}
// if value is a hash...
else if ( isObject( value ) ) {
// ...then if it isn't rendered, render it, adding this.keypath to the context stack
// (if it is already rendered, then any children dependent on the context stack
// will update themselves without any prompting)
if ( !this.length ) {
// append this section to the context stack
fragmentOptions.contextStack = this.contextStack.concat( this.keypath );
fragmentOptions.index = 0;
this.views[0] = new A.DomFragment( fragmentOptions );
this.length = 1;
}
}
// otherwise render if value is truthy, unrender if falsy
else {
if ( value && !emptyArray ) {
if ( !this.length ) {
// no change to context stack
fragmentOptions.contextStack = this.contextStack;
fragmentOptions.index = 0;
this.views[0] = new A.DomFragment( fragmentOptions );
this.length = 1;
}
}
else {
if ( this.length ) {
this.unrender();
this.length = 0;
}
}
}
}
};
}( Anglebars, document ));
(function ( A ) {
'use strict';
var createView, types, ctors, isArray, isObject,
Text, Mustache, Interpolator, Triple, Section;
types = A.types;
ctors = [];
ctors[ types.TEXT ] = 'Text';
ctors[ types.INTERPOLATOR ] = 'Interpolator';
ctors[ types.TRIPLE ] = 'Triple';
ctors[ types.SECTION ] = 'Section';
isArray = A.isArray;
isObject = A.isObject;
// Base Mustache class
Mustache = function ( options ) {
this.model = options.model;
this.anglebars = options.anglebars;
this.viewmodel = options.anglebars.viewmodel;
this.parent = options.parent;
this.parentFragment = options.parentFragment;
this.contextStack = options.contextStack || [];
this.type = options.model.type;
// If there is an init method, call it
if ( this.initialize ) {
this.initialize();
}
this.viewmodel.registerView( this );
// If we have a failed keypath lookup, and this is an inverted section,
// we need to trigger this.update() so the contents are rendered
if ( !this.keypath && this.model.inv ) { // Test both section-hood and inverticity in one go
this.update( false );
}
};
// Substring types
createView = function ( options ) {
if ( typeof options.model === 'string' ) {
return new Text( options.model );
}
switch ( options.model.type ) {
case types.INTERPOLATOR: return new Interpolator( options );
case types.TRIPLE: return new Triple( options );
case types.SECTION: return new Section( options );
default: throw 'Something went wrong in a rather interesting way';
}
};
// Fragment
A.TextFragment = function ( options ) {
var numItems, i, itemOptions, parentRefs, ref;
this.owner = options.owner;
this.index = options.index;
this.items = [];
this.indexRefs = {};
if ( this.owner && this.owner.parentFragment ) {
parentRefs = this.owner.parentFragment.indexRefs;
for ( ref in parentRefs ) {
if ( parentRefs.hasOwnProperty( ref ) ) {
this.indexRefs[ ref ] = parentRefs[ ref ];
}
}
}
if ( options.indexRef ) {
this.indexRefs[ options.indexRef ] = options.index;
}
itemOptions = {
anglebars: options.anglebars,
parentFragment: this,
parent: this.owner,
contextStack: options.contextStack
};
numItems = ( options.model ? options.model.length : 0 );
for ( i=0; i<numItems; i+=1 ) {
itemOptions.model = options.model[i];
this.items[ this.items.length ] = createView( itemOptions );
}
this.value = this.items.join('');
};
A.TextFragment.prototype = {
bubble: function () {
this.value = this.items.join( '' );
this.parent.bubble();
},
teardown: function () {
var numItems, i;
numItems = this.items.length;
for ( i=0; i<numItems; i+=1 ) {
this.items[i].teardown();
}
},
toString: function () {
return ( this.value === undefined ? '' : this.value );
}
};
// Plain text
Text = function ( text ) {
this.text = text;
};
Text.prototype = {
toString: function () {
return this.text;
},
teardown: function () {} // no-op
};
// Mustaches
// Interpolator or Triple
Interpolator = function ( options ) {
Mustache.call( this, options );
};
Interpolator.prototype = {
update: function ( value ) {
this.value = value;
this.parent.bubble();
},
bubble: function () {
this.parent.bubble();
},
teardown: function () {
if ( !this.observerRefs ) {
this.viewmodel.cancelKeypathResolution( this );
} else {
this.viewmodel.unobserveAll( this.observerRefs );
}
},
toString: function () {
return ( this.value === undefined ? '' : this.value );
}
};
// Triples are the same as Interpolators in this context
Triple = Interpolator;
// Section
Section = function ( options ) {
Mustache.call( this, options );
};
Section.prototype = {
initialize: function () {
this.children = [];
this.length = 0;
},
teardown: function () {
this.unrender();
if ( !this.observerRefs ) {
this.viewmodel.cancelKeypathResolution( this );
} else {
this.viewmodel.unobserveAll( this.observerRefs );
}
},
unrender: function () {
while ( this.children.length ) {
this.children.shift().teardown();
}
this.length = 0;
},
bubble: function () {
this.value = this.children.join( '' );
this.parent.bubble();
},
update: function ( value ) {
var emptyArray, i, childrenToRemove, valueIsArray;
valueIsArray = isArray( value );
// modify the array to allow updates via push, pop etc
if ( valueIsArray && this.anglebars.modifyArrays ) {
A.modifyArray( value, this.keypath, this.anglebars.viewmodel );
}
// treat empty arrays as false values
if ( valueIsArray && value.length === 0 ) {
emptyArray = true;
}
// if section is inverted, only check for truthiness/falsiness
if ( this.model.inv ) {
if ( value && !emptyArray ) {
if ( this.length ) {
this.unrender();
this.length = 0;
}
}
else {
if ( !this.length ) {
this.children[0] = new A.TextFragment( this.model.frag, this.anglebars, this, this.contextStack );
this.length = 1;
}
}
this.value = this.children.join( '' );
this.parent.bubble();
return;
}
// Otherwise we need to work out what sort of section we're dealing with.
if( typeof value === 'object' ) {
// if value is an array, iterate through
if ( valueIsArray ) {
// if the array is shorter than it was previously, remove items
if ( value.length < this.length ) {
childrenToRemove = this.children.splice( value.length, this.length - value.length );
while ( childrenToRemove.length ) {
childrenToRemove.shift().teardown();
}
}
// otherwise...
else {
// first, update existing views
for ( i=0; i<this.length; i+=1 ) {
this.viewmodel.update( this.keypath + '.' + i );
}
if ( value.length > this.length ) {
// then add any new ones
for ( i=this.length; i<value.length; i+=1 ) {
this.children[i] = new A.TextFragment( this.model.frag, this.anglebars, this, this.contextStack.concat( this.keypath + '.' + i ) );
}
}
}
this.length = value.length;
}
// if value is a hash...
else {
// ...then if it isn't rendered, render it, adding this.keypath to the context stack
// (if it is already rendered, then any children dependent on the context stack
// will update themselves without any prompting)
if ( !this.length ) {
this.children[0] = new A.TextFragment( this.model.frag, this.anglebars, this, this.contextStack.concat( this.keypath ) );
this.length = 1;
}
}
}
// otherwise render if value is truthy, unrender if falsy
else {
if ( value && !emptyArray ) {
if ( !this.length ) {
this.children[0] = new A.TextFragment( this.model.frag, this.anglebars, this, this.contextStack );
this.length = 1;
}
}
else {
if ( this.length ) {
this.unrender();
this.length = 0;
}
}
}
this.value = this.children.join( '' );
this.parent.bubble();
},
toString: function () {
return ( this.value === undefined ? '' : this.value );
}
};
}( Anglebars ));
(function ( A ) {
'use strict';
A.extend = function ( childProps ) {
var Parent, Child, key, blacklist;
Parent = this;
Child = function () {
A.apply( this, arguments );
if ( this.init ) {
this.init.apply( this, arguments );
}
};
// extend child with parent methods
for ( key in Parent.prototype ) {
if ( Parent.prototype.hasOwnProperty( key ) ) {
Child.prototype[ key ] = Parent.prototype[ key ];
}
}
// extend child with specified methods, as long as they don't override Anglebars.prototype methods
for ( key in childProps ) {
if ( childProps.hasOwnProperty( key ) ) {
if ( A.prototype.hasOwnProperty( key ) ) {
throw new Error( 'Cannot override "' + key + '" method or property of Anglebars prototype' );
}
Child.prototype[ key ] = childProps[ key ];
}
}
Child.extend = Parent.extend;
return Child;
};
}( Anglebars ));
(function ( A ) {
'use strict';
var wrapMethods;
A.modifyArray = function ( array, keypath, viewmodel ) {
var viewmodels, keypathsByIndex, viewmodelIndex, keypaths;
if ( !array._anglebars ) {
array._anglebars = {
viewmodels: [ viewmodel ],
keypathsByIndex: [ [ keypath ] ]
};
wrapMethods( array );
}
else {
viewmodels = array._anglebars.viewmodels;
keypathsByIndex = array._anglebars.keypathsByIndex;
// see if this viewmodel is currently associated with this array
viewmodelIndex = viewmodels.indexOf( viewmodel );
// if not, associate it
if ( viewmodelIndex === -1 ) {
viewmodelIndex = viewmodels.length;
viewmodels[ viewmodelIndex ] = viewmodel;
}
// find keypaths that reference this array, on this viewmodel
keypaths = keypathsByIndex[ viewmodelIndex ];
// if the current keypath isn't among them, add it
if ( keypaths.indexOf( keypath ) === -1 ) {
keypaths[ keypaths.length ] = keypath;
}
}
};
wrapMethods = function ( array ) {
var notifyDependents = function ( array ) {
var viewmodels, keypathsByIndex;
viewmodels = array._anglebars.viewmodels;
keypathsByIndex = array._anglebars.keypathsByIndex;
viewmodels.forEach( function ( viewmodel, i ) {
var keypaths = keypathsByIndex[i];
keypaths.forEach( function ( keypath ) {
viewmodel.set( keypath, array );
});
});
};
[ 'pop', 'push', 'reverse', 'shift', 'sort', 'splice', 'unshift' ].forEach( function ( method ) {
array[ method ] = function () {
var result = Array.prototype[ method ].apply( this, arguments );
notifyDependents( array );
return result;
};
});
};
}( Anglebars ));
// export
if ( typeof module !== "undefined" && module.exports ) module.exports = Anglebars // Common JS
else if ( typeof define === "function" && define.amd ) define( function () { return Anglebars } ) // AMD
else { global.Anglebars = Anglebars }
}( this ));