1054 lines
32 KiB
JavaScript
1054 lines
32 KiB
JavaScript
/*! JsViews v1.0pre: http://github.com/BorisMoore/jsviews */
|
|
/*
|
|
* jQuery plugin providing interactive data-driven views, based on integration between jQuery Templates and jQuery Data Link.
|
|
* Requires jquery.render.js (optimized version of jQuery Templates, for rendering to string)
|
|
* See JsRender at http://github.com/BorisMoore/jsrender
|
|
* and jquery.observable.js also at http://github.com/BorisMoore/jsviews
|
|
*
|
|
* Copyright 2011, Boris Moore
|
|
* Released under the MIT License.
|
|
*/
|
|
(function( $, undefined ) {
|
|
|
|
//===============
|
|
// event handlers
|
|
//===============
|
|
|
|
// var TEST_EVENTS = { total:0, change:0, arrayChange:0, propertyChange:0 };
|
|
var FALSE = false, TRUE = true,
|
|
topView, viewsNs, decl,
|
|
oldCleanData = $.cleanData,
|
|
fnSetters = {
|
|
value: "val",
|
|
html: "html",
|
|
text: "text"
|
|
},
|
|
jsvData = "_jsvData";
|
|
|
|
function elemChangeHandler( ev ) {
|
|
var setter, cancel, fromAttr, toPath, linkToInfo, linkContext, sourceValue, link, cnvt, target,
|
|
data = this.target,
|
|
source = ev.target,
|
|
$source = $( source ),
|
|
view = $.view( source ),
|
|
links = this.links,
|
|
context = view.ctx,
|
|
beforeChange = context.beforeChange,
|
|
l = links.length;
|
|
|
|
while ( l-- && !cancel ) {
|
|
link = links[ l ];
|
|
if ( !link.filter || (link.filter && $source.is( link.filter ))) {
|
|
fromAttr = link.fromAttr;
|
|
if ( !fromAttr ) {
|
|
// Merge in the default attribute bindings for this source element
|
|
fromAttr = viewsNs.merge[ source.nodeName.toLowerCase() ];
|
|
fromAttr = fromAttr ? fromAttr.from.fromAttr : "text";
|
|
}
|
|
setter = fnSetters[ fromAttr ];
|
|
sourceValue = $.isFunction( fromAttr ) ? fromAttr( source ) : setter ? $source[setter]() : $source.attr( fromAttr );
|
|
if ((!beforeChange || !(cancel = beforeChange.call( view, ev ) === FALSE )) && sourceValue !== undefined ) {
|
|
// Find linkToInfo using link.to, or if not specified, use declarative specification
|
|
// provided by decl.applyLinkToInfo, applied to source element
|
|
linkToInfo = link.to || $.trim( source.getAttribute( viewsNs.linkToAttr ));
|
|
// linkTo does not support multiple targets - use imperative linking for that scenario...
|
|
if ( linkToInfo ) {
|
|
cnvt = link.convert;
|
|
linkToInfo = splitParams( linkToInfo );
|
|
target = linkToInfo[ 0 ].slice( 0, -1 );
|
|
data = (view && view.data) || data;
|
|
// data is the current view data, or the top-level target of linkTo.
|
|
// get the target object
|
|
target = getTargetObject( data, view, target );
|
|
cnvt = linkToInfo[ 3 ];
|
|
toPath = $.trim( linkToInfo[ 1 ].slice( 0, -1 ));
|
|
linkContext = {
|
|
source: source,
|
|
target: target,
|
|
convert: cnvt,
|
|
path: toPath
|
|
};
|
|
if ( cnvt ) {
|
|
sourceValue = getConvertedValue( linkContext, data, view, cnvt, sourceValue );
|
|
}
|
|
if ( sourceValue !== undefined && target ) {
|
|
$.observable( target ).setProperty( toPath, sourceValue );
|
|
if ( context.afterChange ) { //TODO only call this if the target property changed
|
|
context.afterChange.call( linkContext, ev );
|
|
}
|
|
}
|
|
ev.stopPropagation(); // Stop bubbling
|
|
}
|
|
}
|
|
if ( cancel ) {
|
|
ev.stopImmediatePropagation();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function propertyChangeHandler( ev, eventArgs ) {
|
|
var pathInfo, setter, cancel, changed, attr, sourceValue, css,
|
|
link = this,
|
|
target = link.target,
|
|
$target = $( target ),
|
|
source = ev.target,
|
|
sourcePath = (eventArgs && eventArgs.path) || ev.path,
|
|
view = $.view( target ),
|
|
context = view.ctx,
|
|
beforeChange = context.beforeChange,
|
|
pathInfos = getLinkFromDataInfo( target, source ).paths[ sourcePath ],
|
|
l = pathInfos && pathInfos.length;
|
|
|
|
while ( l-- ) {
|
|
pathInfo = pathInfos[ l ];
|
|
if ((!beforeChange || !(eventArgs && (cancel = beforeChange.call( this, ev, eventArgs ) === FALSE )))
|
|
&& (!view || view.onDataChanged( eventArgs ) !== FALSE )) {
|
|
|
|
attr = pathInfo.attr;
|
|
|
|
sourceValue = getConvertedValue( link, link.source, view, pathInfo.expr, eventArgs && eventArgs.value );
|
|
|
|
if ( $.isFunction( sourceValue )) {
|
|
sourceValue = sourceValue.call( source );
|
|
}
|
|
|
|
if ( !attr ) {
|
|
// Merge in the default attribute bindings for this target element
|
|
attr = viewsNs.merge[ target.nodeName.toLowerCase() ];
|
|
attr = attr ? attr.to.toAttr : "text";
|
|
}
|
|
|
|
if ( css = attr.indexOf( "css-" ) === 0 && attr.substr( 4 )) {
|
|
if ( changed = $target.css( css ) !== sourceValue ) {
|
|
$target.css( css, sourceValue );
|
|
}
|
|
} else {
|
|
setter = fnSetters[ attr ];
|
|
if ( setter ) {
|
|
if ( changed = $target[setter]() !== sourceValue ) {
|
|
$target[setter]( sourceValue );
|
|
}
|
|
} else if ( changed = $target.attr( attr ) !== sourceValue ) {
|
|
$target.attr( attr, sourceValue );
|
|
}
|
|
}
|
|
|
|
if ( eventArgs && changed && context.afterChange ) {
|
|
context.afterChange.call( link, ev, eventArgs );
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function arrayChangeHandler( ev, eventArgs ) {
|
|
var cancel,
|
|
context = this.ctx,
|
|
beforeChange = context.beforeChange,
|
|
sourceValue = eventArgs ? eventArgs.change : viewsNs.linkToAttr; // linkToAttr used as a marker of trigger events
|
|
|
|
if ((!beforeChange || !(cancel = beforeChange.call( this, ev, eventArgs ) === FALSE ))
|
|
&& sourceValue !== undefined ) {
|
|
this.onDataChanged( eventArgs );
|
|
if ( context.afterChange ) {
|
|
context.afterChange.call( this, ev, eventArgs );
|
|
}
|
|
}
|
|
}
|
|
|
|
function setArrayChangeLink( view ) {
|
|
var handler,
|
|
data = view.data,
|
|
onArrayChange = view._onArrayChange;
|
|
|
|
if ( onArrayChange ) {
|
|
if ( onArrayChange[ 1 ] === data ) {
|
|
return;
|
|
}
|
|
$([ onArrayChange[ 1 ]]).unbind( "arrayChange", onArrayChange[ 0 ]);
|
|
// $( "#console" ).append( --TEST_EVENTS.total + " - arrayChange " + --(TEST_EVENTS.arrayChange) + "<br/>");
|
|
}
|
|
|
|
if ( $.isArray( data )) {
|
|
handler = function() {
|
|
arrayChangeHandler.apply( view, arguments );
|
|
};
|
|
$([ data ]).bind( "arrayChange", handler );
|
|
view._onArrayChange = [ handler, data ];
|
|
// $( "#console" ).append( ++TEST_EVENTS.total + " + arrayChange " + ++(TEST_EVENTS.arrayChange) + "<br/>");
|
|
}
|
|
}
|
|
|
|
//===============
|
|
// view hierarchy
|
|
//===============
|
|
|
|
function setViewContext( view, context, merge ) {
|
|
var parentContext = view.parent && view.parent.ctx,
|
|
viewContext = view._ctx;
|
|
// Propagate inherited context through children
|
|
view.ctx = merge
|
|
? (viewContext && parentContext ? $.extend( {}, parentContext, viewContext ) : parentContext || viewContext)
|
|
: context && context !== parentContext
|
|
// Set additional context on this view (which will modify the context inherited from the parent, and be inherited by child views)
|
|
? (view._ctx = context, (parentContext ? $.extend( {}, parentContext, context ) : context))
|
|
: parentContext;
|
|
}
|
|
|
|
function View( context, node, path, template, parentView, parentElViews, data ) {
|
|
var views, index, viewCount, tagInfo, ctx, presenter, options,
|
|
self = this;
|
|
|
|
$.extend( self, {
|
|
views: [],
|
|
nodes: node ? [] : [ document.body ],
|
|
tmpl: template || (parentView && parentView.tmpl),
|
|
path: path,
|
|
parent: parentView,
|
|
prevNode: node
|
|
});
|
|
if ( parentView ) {
|
|
views = parentView.views;
|
|
parentElViews.push( self );
|
|
data = data || parentView.data;
|
|
if ( $.isArray( parentView.data )) {
|
|
self.index = index = path;
|
|
views.splice( index, 0, self );
|
|
viewCount = views.length;
|
|
data = data[ index ];
|
|
while ( index++ < viewCount-1 ) {
|
|
$.observable( views[ index ] ).setProperty( "index", index );
|
|
}
|
|
self.tag = parentView.tag;
|
|
} else {
|
|
if ( path ) {
|
|
// TODO getDataAndContext and passing of context and data from tags needs work.
|
|
// Also, consider more 'codeless' approach, and more consistent syntax with codeless tag markup
|
|
data = getDataAndContext( data, parentView, path );
|
|
context = context || data[ 1 ];
|
|
data = data[ 0 ];
|
|
}
|
|
self.index = views.length;
|
|
views.push( self );
|
|
if ( context.tag ) {
|
|
self.tag = [context.tag];
|
|
} else {
|
|
template = template.split( "=" );
|
|
if ( template.shift() === "tag" ) {
|
|
self.tag = template;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.data = data;
|
|
setViewContext( self, context );
|
|
setArrayChangeLink( self );
|
|
|
|
if ( (tagInfo = self.tag) && !$.isArray( data )) {
|
|
// This view is from a registered presenter
|
|
presenter = viewsNs.tags[ tagInfo[ 0 ]];
|
|
if ( presenter && presenter.presenter ) {
|
|
ctx = presenter.ctx || {};
|
|
options = tagInfo[1]
|
|
? getTargetObject( parentView.data, parentView, "{" + tagInfo[ 1 ] + "}" )
|
|
// Declarative presenter {{foo}}
|
|
: parentView.ctx;
|
|
// Imperative: link( data, foo )
|
|
$.extend( options, presenter.options || {} );
|
|
// attach plugin to content of view
|
|
|
|
ctx[ tagInfo[ 0 ]] = new presenter.presenter( options, self );
|
|
// Make presenter available off the ctx object
|
|
self.context( ctx );
|
|
}
|
|
}
|
|
}
|
|
|
|
function createNestedViews( node, parent, nextNode, depth, data, context, prevNode, index ) {
|
|
var tokens, parentElViews, view, existing, parentNode, elem, elem2, params, presenter, tagInfo, ctx,
|
|
currentView = parent,
|
|
viewDepth = depth;
|
|
context = context || {};
|
|
index = index || 0;
|
|
node = prevNode || node;
|
|
|
|
if ( !prevNode && node.nodeType === 1 ) {
|
|
if ( viewDepth++ === 0 ) {
|
|
// Add top-level element nodes to view.nodes
|
|
currentView.nodes.push( node );
|
|
}
|
|
var linkFromInfo = node.getAttribute( viewsNs.linkFromAttr ),
|
|
getFromInfo = node.getAttribute( viewsNs.getFromAttr );
|
|
|
|
if ( linkFromInfo || getFromInfo ) {
|
|
addLinksFromData( data || parent.data, node, getFromInfo, linkFromInfo, TRUE );
|
|
}
|
|
node = node.firstChild;
|
|
} else {
|
|
node = node.nextSibling;
|
|
}
|
|
|
|
while ( node && node !== nextNode ) {
|
|
if ( node.nodeType === 1 ) {
|
|
createNestedViews( node, currentView, nextNode, viewDepth, data, context );
|
|
} else if ( node.nodeType === 8 && (tokens = /^(\/?)(?:(item)|(?:(tmpl)(?:\((.*)\))?(?:\s+([^\s]+))?))$/.exec( node.nodeValue ))) {
|
|
// tokens: [ commentText, "/", "item", dataParams, tmplParam ]
|
|
|
|
parentNode = node.parentNode;
|
|
if ( tokens[ 1 ]) {
|
|
// <!--/item--> or <!--/tmpl-->
|
|
currentView.nextNode = node;
|
|
if (( tagInfo = currentView.tag ) && ( ctx = currentView.ctx[ tagInfo[ 0 ]]) && ctx.onAfterCreate ) {
|
|
// This view is from a registered presenter which has registered an onAfterCreate callback
|
|
ctx.onAfterCreate();
|
|
}
|
|
if ( currentView.ctx.onAfterCreate ) {
|
|
currentView.ctx.onAfterCreate.call( currentView, currentView );
|
|
}
|
|
if ( tokens[ 2 ]) {
|
|
// An item close tag: <!--/item-->
|
|
currentView = parent;
|
|
} else {
|
|
// A tmpl close tag: <!--/tmpl-->
|
|
return node;
|
|
}
|
|
} else {
|
|
// <!--item--> or <!--tmpl-->
|
|
parentElViews = parentElViews || jsViewsData( parentNode, "view", TRUE );
|
|
if ( tokens[ 2 ]) {
|
|
// An item open tag: <!--item-->
|
|
currentView = new View( context, node, index++, undefined, currentView, parentElViews );
|
|
} else {
|
|
// A tmpl open tag: <!--tmpl(path) name-->
|
|
view = $.view( node );
|
|
if ( view && view.prevNode === node ) {
|
|
if ( view.data === data ) {
|
|
existing = view.nextNode;
|
|
} else {
|
|
view.data = data;
|
|
view.render( context );
|
|
return view.nextNode;
|
|
}
|
|
} else {
|
|
view = new View( context, node, tokens[ 4 ], tokens[ 5 ], currentView, parentElViews, data );
|
|
}
|
|
// Jump to the nextNode of the tmpl view
|
|
node = existing || createNestedViews( node, view, nextNode, 0 );
|
|
}
|
|
}
|
|
} else if ( viewDepth === 0 ) {
|
|
// Add top-level non-element nodes to view.nodes
|
|
currentView.nodes.push( node );
|
|
}
|
|
node = node.nextSibling;
|
|
}
|
|
}
|
|
|
|
//=======================
|
|
// Expression evaluation
|
|
//=======================
|
|
|
|
function getDataAndContext( source, view, paramString ) {
|
|
return Function( "$", "$data", "$view", "$ctx",
|
|
"with($data){return [" + paramString + "];}")( $, source, view, view.ctx );
|
|
}
|
|
|
|
function getConvertedValue( context, source, view, expression, value ) {
|
|
try {
|
|
return Function( "$", "$data", "$view", "$ctx", "$value",
|
|
"with($data){return " + expression + ";}")
|
|
.call( context, $, source, view, view.ctx, value );
|
|
} catch(e) {
|
|
// in debug mode, throw 'bad syntax error';
|
|
throw e.message;
|
|
}
|
|
}
|
|
|
|
function getTargetObject( source, view, expression ) {
|
|
try {
|
|
return expression
|
|
? Function( "$", "$data", "$view", "$ctx",
|
|
"with($data){return " + expression + ";}")( $, source, view, view.ctx )
|
|
: source;
|
|
} catch(e) {
|
|
// in debug mode, throw 'bad syntax error';
|
|
throw e.message;
|
|
}
|
|
}
|
|
|
|
//===============
|
|
// data linking
|
|
//===============
|
|
|
|
function link( from, to, links, context ) {
|
|
var lnk, filter, targetElems, toLinks, fromLinks, linksToData, i;
|
|
|
|
if ( links ) {
|
|
|
|
links = $.isArray( links ) ? links : [ links ];
|
|
|
|
toLinks = [];
|
|
fromLinks = [];
|
|
i = links.length;
|
|
|
|
while ( i-- ) {
|
|
lnk = links[ i ];
|
|
if ( lnk.to ) {
|
|
toLinks.push({ to: lnk.to, filter: lnk.filter });
|
|
}
|
|
if ( lnk.from || lnk.getFrom ) {
|
|
fromLinks.push( lnk );
|
|
}
|
|
}
|
|
i = fromLinks.length;
|
|
while ( i-- ) {
|
|
lnk = fromLinks[ i ];
|
|
filter = lnk.filter;
|
|
targetElems = filter ? from.find( filter ).add( $( this ).filter( filter )) : from; // Use future findFilter method in jQuery 1.7?
|
|
|
|
targetElems.each( function() {
|
|
// If 'from' path points to a property of a descendant 'leaf object',
|
|
// link not only from leaf object, but also from intermediate objects
|
|
addLinksFromData( to, this, lnk.getFrom, lnk.from );
|
|
});
|
|
}
|
|
}
|
|
if ( !links || toLinks.length ) {
|
|
from.each( function() {
|
|
if ( !links ) {
|
|
// DECLARATIVE DATA LINKING
|
|
|
|
// Linking HTML to object or array
|
|
linksToData = jsViewsData( this, "to" );
|
|
i = linksToData.length;
|
|
while ( i-- ) {
|
|
lnk = linksToData[ i ];
|
|
if ( lnk.links === declLinkTo ) {
|
|
if ( lnk.target === to ) {
|
|
// Already declaratively linked to the same object
|
|
return;
|
|
}
|
|
// Already linked to a different object, so unlink from previous object
|
|
removeLinksToData( this, declLinkTo );
|
|
linksToData.splice( i, 1 );
|
|
}
|
|
}
|
|
toLinks = declLinkTo; // For declarative case
|
|
|
|
// Linking object or array to HTML
|
|
createNestedViews( this, $.view( this ), undefined, undefined, to, context );
|
|
}
|
|
addLinksToData( this, to, toLinks );
|
|
});
|
|
}
|
|
return from;
|
|
}
|
|
|
|
//function unlink( from, to, links ) { // TODO
|
|
//}
|
|
|
|
function addLinksFromData( source, target, getFrom, linkFrom ) {
|
|
var param, cnvtParam, i, l, lastChar, attr, openParenIndex, get, object, cnvtParams, view,
|
|
triggers = [],
|
|
cnvt = "";
|
|
|
|
linkFrom = splitParams( (linkFrom ? linkFrom + "," : "") + (getFrom ? "|," + getFrom + ",": ""), TRUE );
|
|
while ( param = linkFrom.shift() ) {
|
|
l = param.length;
|
|
lastChar = param.charAt( l - 1 );
|
|
param = param.slice( 0, -1 );
|
|
switch ( lastChar ) {
|
|
case ':':
|
|
attr = $.trim( param );
|
|
break;
|
|
case '[':
|
|
cnvt = $.trim( param );
|
|
break;
|
|
case ')':
|
|
cnvt = cnvt || param;
|
|
break;
|
|
case ']':
|
|
triggers = [[ cnvt, param ]];
|
|
cnvt = cnvt ? (cnvt + "." + param) : param;
|
|
cnvt += $.isFunction( source[ param ]) ? "()": "";
|
|
break;
|
|
case '\r':
|
|
openParenIndex = ++param; // Convert to integer and increment
|
|
break;
|
|
case ',':
|
|
if ( param === '|') {
|
|
get = TRUE;
|
|
} else {
|
|
// Apply binding
|
|
if ( openParenIndex ) {
|
|
cnvtParams = cnvt.slice( openParenIndex );
|
|
cnvtParams = splitParams( cnvtParams );
|
|
for ( i = 0, l = cnvtParams.length; i < l; i++ ) {
|
|
cnvtParam = $.trim(cnvtParams[ i ]);
|
|
lastChar = cnvtParam.charAt( cnvtParam.length - 1 );
|
|
cnvtParam = cnvtParam.slice( 0, -1 );
|
|
if ( lastChar === '[') {
|
|
cnvtParams[ i ] = object = cnvtParam;
|
|
} else if ( lastChar === ']') {
|
|
triggers.push([ object, cnvtParam ]);
|
|
cnvtParams[ i ] = object ? ("." + cnvtParam) : cnvtParam;
|
|
cnvtParams[ i ] += $.isFunction( source[ cnvtParam ]) ? "()": "";
|
|
object = "";
|
|
}
|
|
}
|
|
cnvt = cnvt.slice( 0, openParenIndex ) + cnvtParams.join("") + ")";
|
|
}
|
|
|
|
cnvt += param;
|
|
view = $.view( target );
|
|
l = triggers.length;
|
|
while ( l-- ) {
|
|
var trigger = triggers[ l ],
|
|
path = trigger[ 1 ],
|
|
fromOb = getTargetObject( source, view, trigger[ 0 ]),
|
|
link = { source: source, target: target },
|
|
innerPath = path.split("."),
|
|
innerOb = fromOb;
|
|
|
|
// If 'from' path points to a property of a descendant 'leaf object',
|
|
// link not only from leaf object, but also from intermediate objects
|
|
while ( innerPath.length > 1 ) {
|
|
innerOb = innerOb[ innerPath.shift() ];
|
|
if ( innerOb ) {
|
|
addLinkFromData( innerOb, link, innerPath.join( "." ), cnvt, attr );
|
|
}
|
|
}
|
|
// The last trigger of get bindings will be called on adding the link (to get/initialize the value)
|
|
addLinkFromData( fromOb, link, path, cnvt, attr, !l && get );
|
|
}
|
|
openParenIndex = 0;
|
|
triggers = [];
|
|
attr = cnvt = "";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function addLinkFromData( source, link, path, expr, attr, get ) {
|
|
var paths, pathInfos, handler,
|
|
target = link.target,
|
|
linkInfo = getLinkFromDataInfo( target, source ),
|
|
pathInfo = { attr: attr, expr: expr};
|
|
|
|
if ( linkInfo ) {
|
|
// Set path info for this path
|
|
pathInfos = linkInfo.paths[ path ] = linkInfo.paths[ path ] || [];
|
|
pathInfos.push( pathInfo );
|
|
// get handler
|
|
handler = linkInfo.handler;
|
|
} else {
|
|
handler = function() {
|
|
propertyChangeHandler.apply( link, arguments );
|
|
};
|
|
|
|
// Store handler for unlinking
|
|
if ( target ) {
|
|
paths = {};
|
|
paths[ path ] = [ pathInfo ];
|
|
jsViewsData( target, "from", TRUE ).push({ source: source, paths: paths, handler: handler });
|
|
}
|
|
$( source ).bind( "propertyChange", handler );
|
|
}
|
|
// $( "#console" ).append( ++TEST_EVENTS.total + " + propertyChange " + ++(TEST_EVENTS.propertyChange) + "<br/>");
|
|
if ( get ) {
|
|
handler({ target: source, path: path });
|
|
}
|
|
}
|
|
|
|
function addLinksToData( source, target, links ) {
|
|
var handler = function() {
|
|
elemChangeHandler.apply( { target: target, links: links }, arguments );
|
|
};
|
|
|
|
// Store handler for unlinking
|
|
jsViewsData( source, "to", TRUE ).push({ target: target, links: links, handler: handler });
|
|
$( source ).bind( "change", handler );
|
|
}
|
|
|
|
function removeLinksToData( source, links ) {
|
|
var prevLinkInfo,
|
|
prevLinkInfos = jsViewsData( source, "to" ),
|
|
l = prevLinkInfos.length;
|
|
while( l-- ) {
|
|
prevLinkInfo = prevLinkInfos[ l ];
|
|
if ( prevLinkInfo.links === links ) {
|
|
$( source ).unbind( "change", prevLinkInfo.handler);
|
|
prevLinkInfos.splice( l, 1 );
|
|
// $( "#console" ).append( --TEST_EVENTS.total + " - change " + --(TEST_EVENTS.change) + "<br/>");
|
|
}
|
|
}
|
|
}
|
|
|
|
function getLinkFromDataInfo( target, source ) {
|
|
var link,
|
|
links = jsViewsData( target, "from" ),
|
|
l = links.length;
|
|
while( l-- ) {
|
|
link = links[ l ];
|
|
if ( link.source === source ) {
|
|
// Set path info for this path
|
|
return link;
|
|
}
|
|
}
|
|
}
|
|
|
|
//===============
|
|
// helpers
|
|
//===============
|
|
|
|
function clean( i, el ) { // TODO optimize for perf
|
|
var link, links , l, views, parentView, view;
|
|
|
|
if ( jsViewsData( el, "to" ).length ) {
|
|
$( el ).unbind( "change" );
|
|
// $( "#console" ).append( --TEST_EVENTS.total + " - change " + --(TEST_EVENTS.change) + "<br/>");
|
|
}
|
|
|
|
links = jsViewsData( el, "from" );
|
|
l = links.length;
|
|
|
|
while( l-- ) {
|
|
link = links[ l ];
|
|
$( link.source ).unbind( "propertyChange", link.handler );
|
|
// $( "#console" ).append( --TEST_EVENTS.total + " - propertyChange " + --(TEST_EVENTS.propertyChange) + "<br/>");
|
|
}
|
|
|
|
views = jsViewsData( el, "view" );
|
|
if ( l = views.length ) {
|
|
parentView = $.view( el );
|
|
while( l-- ) {
|
|
view = views[ l ];
|
|
if ( view.parent === parentView ) {
|
|
parentView.removeViews( view.index, 1 ); // NO - ONLY remove view if its top-level nodes are all.. (TODO)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function jsViewsData( el, type, create ) {
|
|
var jqData = $.data( el, jsvData ) || (create && $.data( el, jsvData, { "view": [], "from": [], "to": [] }));
|
|
return jqData ? jqData[ type ] : [];
|
|
}
|
|
|
|
function splitParams( paramString, markParen ) {
|
|
// Split into params (or values in an array literal, or keys and values in an object literal)
|
|
// (Achieved by splitting before top-level ':' or ',' chars)
|
|
var openParenIndex,
|
|
startIndex = 0,
|
|
parenDepth = 0,
|
|
quoted = FALSE, // boolean for string content in double qoutes
|
|
aposed = FALSE; // boolean for string content in single qoutes
|
|
|
|
paramString = paramString.replace( /\s+/g, " " );
|
|
|
|
return paramString
|
|
.replace( /(\))|([\:\,])|(\')|(\")|([\(\[\{])|([\}\]])/g, function( all, cnvt, colon, apos, quot, leftParen, rightParen, index ) {
|
|
if ( aposed ) {
|
|
// within single-quoted string
|
|
aposed = !apos;
|
|
return all;
|
|
}
|
|
if ( quoted ) {
|
|
// within double-quoted string
|
|
quoted = !quot;
|
|
return all;
|
|
}
|
|
if ( cnvt ) {
|
|
// follow top-level ':' or ',' with '\t'
|
|
return --parenDepth
|
|
? all
|
|
: (markParen && paramString.charAt( index + 1 ) === ',' )
|
|
? (openParenIndex -= startIndex, startIndex = index,
|
|
all + "\t" + openParenIndex + "\r\t")
|
|
:all;
|
|
}
|
|
if ( colon ) {
|
|
// follow top-level ':' or ',' with '\t'
|
|
return parenDepth
|
|
? all
|
|
: (startIndex = index+1, all + "\t");
|
|
}
|
|
if ( leftParen ) {
|
|
if ( parenDepth++ ) {
|
|
return all;
|
|
}
|
|
if ( all === '(' ) {
|
|
openParenIndex = index;
|
|
}
|
|
// follow top-level '[' by '\t'
|
|
return all !== '[' ? all : "[\t";
|
|
}
|
|
if ( rightParen ) {
|
|
// follow top-level ']' by '\t'
|
|
return ( --parenDepth || all !== ']' ) ? all : "]\t";
|
|
}
|
|
aposed = apos;
|
|
quoted = quot;
|
|
return all;
|
|
})
|
|
.split( "\t" );
|
|
}
|
|
|
|
function inputAttrib( elem ) {
|
|
return elem.type === "checkbox" ? elem.checked : $( elem ).val();
|
|
}
|
|
|
|
$.extend({
|
|
|
|
//=======================
|
|
// jQuery $.view() plugin
|
|
//=======================
|
|
|
|
view: function( node, inner ) {
|
|
// $.view() returns top node
|
|
// $.view( node ) returns view that contains node
|
|
var returnView, view, parentElViews, i, finish,
|
|
startTagReg = /^item|^tmpl(\(\$?[\w\.]*\))?(\s+[^\s]+)?$/,
|
|
topNode = document.body,
|
|
startNode = node;
|
|
|
|
if ( inner ) {
|
|
// Treat supplied node as a container element, step through content, and return the first view encountered.
|
|
finish = node.nextSibling || node.parentNode; //
|
|
while ( finish !== (node = node.firstChild || node.nextSibling || node.parentNode.nextSibling )) {
|
|
if ( node.nodeType === 8 && startTagReg.test( node.nodeValue )) {
|
|
view = $.view( node );
|
|
if ( view.prevNode === node ) {
|
|
return view;
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
node = node || topNode;
|
|
if ( topView && !topView.views.length ) {
|
|
returnView = topView; // Perf optimization for common case
|
|
} else {
|
|
// Step up through parents to find an element which is a views container, or if none found, create the top-level view for the page
|
|
while( !(parentElViews = jsViewsData( finish = node.parentNode || topNode, "view" )).length ) {
|
|
if ( !finish || node === topNode ) {
|
|
jsViewsData( topNode.parentNode, "view", TRUE ).push( returnView = topView = new View());
|
|
topView.ctx = {};
|
|
break;
|
|
}
|
|
node = finish;
|
|
}
|
|
if ( !returnView && node === topNode ) {
|
|
returnView = parentElViews[0];
|
|
}
|
|
while ( !returnView && node ) {
|
|
// Step back through the nodes, until we find an item or tmpl open tag - in which case that is the view we want
|
|
if ( node.nodeType === 8 ) {
|
|
if ( /^\/item|^\/tmpl$/.test( node.nodeValue )) {
|
|
// A tmpl or item close tag: <!--/tmpl--> or <!--/item-->
|
|
i = parentElViews.length;
|
|
while ( i-- ) {
|
|
view = parentElViews[ i ];
|
|
if ( view.nextNode === node ) {
|
|
// If this was the node originally passed in, this is the view we want.
|
|
returnView = (node === startNode && view);
|
|
// If not, jump to the beginning of this item/tmpl and continue from there
|
|
node = view.prevNode;
|
|
break;
|
|
}
|
|
}
|
|
} else if ( startTagReg.test( node.nodeValue )) {
|
|
// A tmpl or item open tag: <!--tmpl--> or <!--item-->
|
|
i = parentElViews.length;
|
|
while ( i-- ) {
|
|
view = parentElViews[ i ];
|
|
if ( view.prevNode === node ) {
|
|
returnView = view;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
node = node.previousSibling;
|
|
}
|
|
// If not within any of the views in the current parentElViews collection, move up through parent nodes to find next parentElViews collection
|
|
returnView = returnView || $.view( finish );
|
|
}
|
|
return returnView;
|
|
},
|
|
|
|
//=======================
|
|
// override cleanData
|
|
//=======================
|
|
|
|
cleanData: function( elems ) {
|
|
$( elems ).each( clean ); // TODO - look at perf optimization on this
|
|
oldCleanData.call( $, elems );
|
|
}
|
|
});
|
|
|
|
//=======================
|
|
// $.views (namespace)
|
|
//=======================
|
|
|
|
viewsNs = $.views = $.views || {};
|
|
|
|
$.extend( viewsNs, {
|
|
presenters: {},
|
|
activeViews: true,
|
|
getProperty: function( data, value ) {
|
|
// support for property getter on data
|
|
return $.isFunction( value ) ? value.call(data) : value;
|
|
},
|
|
linkToAttr: "data-to",
|
|
linkFromAttr: "data-from",
|
|
getFromAttr: "data-getfrom",
|
|
merge: {
|
|
input: {
|
|
from: {
|
|
fromAttr: inputAttrib
|
|
},
|
|
to: {
|
|
toAttr: "value"
|
|
}
|
|
}
|
|
},
|
|
|
|
//===============
|
|
// registerPresenters
|
|
//===============
|
|
|
|
// Register a 'control' - which associates a Presenter object with a template
|
|
// Optionally associate with a tag, for declarative use. (Rendering of template and instantiation/attaching of presenter).
|
|
registerPresenters: registerPresenters = function( name, presenter ) {
|
|
var key, tag;
|
|
|
|
if ( typeof name === "object" ) {
|
|
for ( key in name ) {
|
|
registerPresenters( key, name[ key ]);
|
|
}
|
|
} else {
|
|
// Simple single property case.
|
|
presenter.ctx = presenter.ctx || {};
|
|
|
|
tag = (tag = presenter.tag) === undefined ? name : tag;
|
|
// If tag not set to null or empty string, then register a tag for this control
|
|
if ( tag ) {
|
|
presenter.tag = tag;
|
|
viewsNs.tags[ tag ] = presenter;
|
|
}
|
|
|
|
plugin = (plugin = presenter.plugin) === undefined ? name : plugin;
|
|
// If plugin not set to null or empty string, then register create a generated jQuery plugin for this control
|
|
if ( plugin ) {
|
|
//Generated jQuery plugin
|
|
$.fn[ plugin ] = $.fn[ plugin ] || function( data, options ) {
|
|
return this.link( data, presenter, options );
|
|
}
|
|
presenter.plugin = plugin;
|
|
}
|
|
viewsNs.presenters[ name ] = presenter;
|
|
}
|
|
return this;
|
|
},
|
|
|
|
//=======================
|
|
// view prototype
|
|
//=======================
|
|
|
|
view: {
|
|
onDataChanged: function( eventArgs ) {
|
|
if ( eventArgs ) {
|
|
// This is an observable action (not a trigger/handler call from pushValues, or similar, for which eventArgs will be null)
|
|
var self = this,
|
|
action = eventArgs.change,
|
|
index = eventArgs.index,
|
|
items = eventArgs.items;
|
|
switch ( action ) {
|
|
case "insert":
|
|
self.addViews( index, items );
|
|
break;
|
|
case "remove":
|
|
self.removeViews( index, items.length );
|
|
break;
|
|
case "move":
|
|
self.render(); // Could optimize this
|
|
break;
|
|
case "refresh":
|
|
self.render();
|
|
// Othercases: (e.g.undefined, for setProperty on observable object) etc. do nothing
|
|
}
|
|
}
|
|
return TRUE;
|
|
},
|
|
render: function() {
|
|
var prevNode = this.prevNode,
|
|
nextNode = this.nextNode,
|
|
parentNode = prevNode.parentNode;
|
|
|
|
$( this.nodes ).remove();
|
|
this.removeViews( 0, this.views.length );
|
|
this.nodes = [];
|
|
$( prevNode ).after( $.render( this.data, this.tmpl, this.ctx ) );
|
|
parentNode.removeChild( prevNode.nextSibling );
|
|
parentNode.removeChild( nextNode.previousSibling );
|
|
createNestedViews( parentNode, this, nextNode, 0, undefined, undefined, prevNode, 0 ); //this.index
|
|
setArrayChangeLink( this );
|
|
return this;
|
|
},
|
|
addViews: function( index, dataItems, tmpl ) {
|
|
var parent,
|
|
itemsCount = dataItems.length,
|
|
context = this.ctx,
|
|
views = this.views;
|
|
|
|
if ( index && !views[index-1] ) {
|
|
return; // If subview for provided index does not exist, do nothing
|
|
}
|
|
if ( itemsCount ) {
|
|
if ( this.path ) {
|
|
parent = this.parent;
|
|
context = getDataAndContext( parent.data, parent, this.path )[1];
|
|
}
|
|
var html = $.render( dataItems, tmpl || this.tmpl, context, this, TRUE ),
|
|
// Use passed-in template if provided, since this added view may use a different template than the original one used to render the array.
|
|
|
|
prevNode = index ? views[ index-1 ].nextNode : this.prevNode,
|
|
nextNode = prevNode.nextSibling,
|
|
parentNode = prevNode.parentNode;
|
|
$( prevNode ).after( html );
|
|
parentNode.removeChild( prevNode.nextSibling );
|
|
parentNode.removeChild( nextNode.previousSibling );
|
|
createNestedViews( parentNode, this, nextNode, 0, undefined, undefined, prevNode, index );
|
|
}
|
|
return this;
|
|
},
|
|
removeViews: function( index, itemsCount, keepHtml ) {
|
|
if ( itemsCount ) {
|
|
var parentElViews, parentViewsIndex, viewCount,
|
|
views = this.views,
|
|
current = index + itemsCount;
|
|
|
|
while ( current-- > index ) {
|
|
var view = views[ current ],
|
|
i = view.views.length,
|
|
node = view.prevNode,
|
|
nextNode = view.nextNode,
|
|
nodes = [ node ];
|
|
|
|
if ( i ) {
|
|
view.removeViews( 0, i, keepHtml );
|
|
}
|
|
|
|
// Remove this view from the parentElViews collection
|
|
parentElViews = parentElViews || jsViewsData( view.nextNode.parentNode, "view" );
|
|
i = parentElViews.length;
|
|
while ( i-- && parentViewsIndex === undefined ) {
|
|
if ( parentElViews[ i ] === view ) {
|
|
parentViewsIndex = i;
|
|
}
|
|
}
|
|
parentElViews.splice( parentViewsIndex, 1 );
|
|
|
|
if ( !keepHtml ) {
|
|
while ( node !== nextNode ) {
|
|
node = node.nextSibling;
|
|
nodes.push( node );
|
|
}
|
|
$( nodes ).remove();
|
|
}
|
|
view.data = undefined;
|
|
setArrayChangeLink( view );
|
|
}
|
|
views.splice( index, itemsCount );
|
|
viewCount = views.length;
|
|
|
|
while ( index < viewCount ) {
|
|
$.observable( views[ index ] ).setProperty( "index", index++ );
|
|
}
|
|
}
|
|
return this;
|
|
},
|
|
context: function( context ) {
|
|
var self = this,
|
|
parent = self.parent,
|
|
parentCtx = parent ? parent.ctx : {};
|
|
if ( !context ) {
|
|
// Clear context
|
|
self.each( function( view ) {
|
|
view.ctx = parentCtx;
|
|
view._ctx = undefined;
|
|
});
|
|
} else if ( context !== self.ctx ) {
|
|
self.each( function( view ) {
|
|
setViewContext( view, context, view !== self );
|
|
});
|
|
}
|
|
return this;
|
|
},
|
|
each: function( callback ) {
|
|
callback( this );
|
|
var l = this.views.length;
|
|
while ( l-- ) {
|
|
this.views[ l ].each( callback );
|
|
}
|
|
return this;
|
|
},
|
|
content: function( select ) {
|
|
return select ? $( select, this.nodes ) : $( this.nodes );
|
|
}
|
|
}
|
|
});
|
|
|
|
//=======================
|
|
// jQuery plugins
|
|
//=======================
|
|
|
|
$.fn.extend({
|
|
view: function( inner ) {
|
|
return $.view( this[0], inner );
|
|
},
|
|
addLinks: function( data, links, context ) {
|
|
// Explicit Linking
|
|
return link( this, data, links, context );
|
|
},
|
|
// removeLinks: function( data, links, context ) { //TODO
|
|
// return unlink( this, data, links, context );
|
|
// },
|
|
link: function( data, tmpl, context ) {
|
|
// Declarative Linking
|
|
// If context is a function, cb - shorthand for { beforeChange: cb }
|
|
// if tmpl not a map, corresponds to $("#container").html( $.render( data, tmpl )).link( data );
|
|
if ( !this.length ) {
|
|
return this;
|
|
}
|
|
|
|
if ( tmpl ) {
|
|
tmpl = tmpl.tag && tmpl === $.views.tags[ tmpl.tag ]
|
|
? (context.tag = tmpl.tag, context.tmpl || tmpl.tmpl)
|
|
// Special case: tmpl is a presenter
|
|
: $.isPlainObject( tmpl )
|
|
? (context = tmpl, FALSE)
|
|
// Linking only. (context was passed in as second parameter, no template parameter passed)
|
|
: tmpl;
|
|
// Linking and rendering (passing a template)
|
|
|
|
if ( tmpl && (tmpl = $.template( tmpl ))) {
|
|
removeLinksToData( this[0], declLinkTo );
|
|
this.empty();
|
|
if ( data ) {
|
|
this.append( $.render( data, tmpl, context ));
|
|
// Using append, rather than html, as workaround for issues in IE compat mode. (Using innerHTML leads to initial comments being stripped)
|
|
}
|
|
}
|
|
}
|
|
return link( this, data, undefined, context );
|
|
}
|
|
});
|
|
|
|
View.prototype = viewsNs.view;
|
|
decl = viewsNs.decl;
|
|
declLinkTo = [{ filter: "input[" + viewsNs.linkToAttr + "]" }];
|
|
})( jQuery );
|