/**
* The jQuery reference
* @external jQuery
* @property {external:jQuery-Deferred} Deferred
* @see {@link https://api.jquery.com/category/selectors/|jQuery}
*/
/**
* The jQuery Deferred reference
* @external jQuery-Deferred
* @see {@link https://api.jquery.com/category/deferred-object/|jQuery.Deferred}
*/
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
define(['underscore', 'backbone'], factory);
} else if (typeof exports === 'object') {
module.exports = factory(require('underscore'), require('backbone'));
} else {
root.Torso = root.Torso || {};
root.Torso.Utils = root.Torso.Utils || {};
root.Torso.Utils.templateRenderer = factory(root._, root.Backbone);
}
}(this, function(_, Backbone) {
'use strict';
var $ = Backbone.$;
/**
* Changes DOM Nodes that are different, and leaves others untouched.
*
* Algorithm:
* Delegates to a particular swapMethod, depending on the Node type.
* Recurses for nested Element Nodes only.
* There is always room for optimizing this method.
*
* @memberof templateRenderer
* @param {Node} currentNode The DOM Node corresponding to the existing page content to update
* @param {Node} newNode The detached DOM Node representing the desired DOM subtree
* @param {Array} ignoreElements Array of jQuery selectors for DOM Elements to ignore during render. Can be an expensive check.
*/
function hotswap(currentNode, newNode, ignoreElements) {
var newNodeType = newNode.nodeType,
currentNodeType = currentNode.nodeType,
swapMethod;
if(newNodeType !== currentNodeType) {
$(currentNode).replaceWith(newNode);
} else {
swapMethod = swapMethods[newNodeType] || swapMethods['default'];
swapMethod(currentNode, newNode, ignoreElements);
}
}
/**
* Stickit will rely on the 'stickit-bind-val' jQuery data attribute to determine the value to use for a given option.
* If the value DOM attribute is not the same as the stickit-bind-val, then this will clear the jquery data attribute
* so that stickit will use the value DOM attribute of the option. This happens when templateRenderer merges
* the attributes of the newNode into a current node of the same type when the current node has the stickit-bind-val
* jQuery data attribute set.
*
* If the node value is not set, then the stickit-bind-val might be how the view is communicating the value for stickit to use
* (possibly in the case of non-string values). In this case trust the stickit-bind-val.
*
* @param {Node} node the DoM element to test and fix the stickit data on.
*/
function cleanupStickitData(node) {
var $node = $(node);
var stickitValue = $node.data('stickit-bind-val');
if (node.tagName === 'OPTION' && node.value !== undefined && stickitValue !== node.value) {
$node.removeData('stickit-bind-val');
}
}
/*
* Swap method for Element Nodes
* @param {Element} currentNode The pre-existing DOM Element to update
* @param {Element} newNode The detached DOM Element representing the desired DOM Element subtree
* @param {Array} ignoreElements Array of jQuery selectors for DOM Elements to ignore during render. Can be an expensive check.
*/
function swapElementNodes(currentNode, newNode, ignoreElements) {
var currentAttr, shouldIgnore, $currChildNodes, $newChildNodes, currentAttributes,
$currentNode = $(currentNode),
$newNode = $(newNode),
idx = 0;
shouldIgnore = _.some(ignoreElements, function(selector) {
return $currentNode.is(selector);
});
if (shouldIgnore) {
return;
}
// Handle tagname changes with full replacement
if (newNode.tagName !== currentNode.tagName) {
$currentNode.replaceWith(newNode);
return;
}
// Remove current attributes that have changed
// This is necessary, because some types of attributes cannot be removed
// without causing a browser error.
currentAttributes = currentNode.attributes;
var prevLength = currentAttributes.length;
while (idx < currentAttributes.length) {
currentAttr = currentAttributes[idx].name;
if (newNode.getAttribute(currentAttr)) {
idx++;
} else {
currentNode.removeAttribute(currentAttr);
if (prevLength === currentAttributes.length) {
// bail since we can't remove the attribute.
$currentNode.replaceWith(newNode);
return;
}
prevLength = currentAttributes.length;
}
}
// Set new attributes
_.each(newNode.attributes, function(attrib) {
currentNode.setAttribute(attrib.name, attrib.value);
});
cleanupStickitData(currentNode);
// Quick check to see if we need to bother comparing sub-levels
if ($currentNode.html() === $newNode.html()) {
return;
}
// Include all child nodes, including text and comment nodes
$newChildNodes = $newNode.contents();
$currChildNodes = $currentNode.contents();
// If the DOM lists are different sizes, perform a hard refresh
if ($newChildNodes.length !== $currChildNodes.length) {
$currentNode.html($newNode.html());
return;
}
// Perform a recursive hotswap for all children nodes
$currChildNodes.each(function(index, currChildNode) {
hotswap(currChildNode, $newChildNodes.get(index), ignoreElements);
});
}
/*
* Swap method for Text, Comment, and CDATA Section Nodes
* @param {Node} currentNode The pre-existing DOM Node to update
* @param {Node} newNode The detached DOM Node representing the desired DOM Node subtree
*/
function updateIfNodeValueChanged(currentNode, newNode){
var nodeValueChanged = newNode.nodeValue !== currentNode.nodeValue;
if (nodeValueChanged) {
$(currentNode).replaceWith(newNode);
}
}
/*
* Map of nodeType to hot swap implementations.
* NodeTypes are hard-coded integers per the DOM Level 2 specification instead of
* references to constants defined on the window.Node object for IE8 compatibility
*/
var swapMethods = {
1: swapElementNodes, // ELEMENT_NODE
3: updateIfNodeValueChanged, // TEXT_NODE
4: updateIfNodeValueChanged, // CDATA_SECTION_NODE
8: updateIfNodeValueChanged, // COMMENT_NODE
default: function(currentNode, newNode) {
$(currentNode).replaceWith(newNode);
}
};
/**
* Static Template Engine.
* All template renders should be piped through this method.
*
* @namespace templateRenderer
*
* @author ariel.wexler@vecna.com
*
* @see <a href="../annotated/modules/templateRenderer.html">templateRenderer Annotated Source</a>
*/
var templateRenderer = /** @lends templateRenderer */ {
/**
* Performs efficient re-rendering of a template.
* @param {external:jQuery} $el The Element to render into
* @param {external:Handlebars-Template} template The HBS template to apply
* @param {Object} context The context object to pass to the template
* @param {Object} [opts] Other options
* @param {boolean} [opts.force=false] Will forcefully do a fresh render and not a diff-render
* @param {string} [opts.newHTML] If you pass in newHTML, it will not use the template or context, but use this instead.
* @param {Array} [opts.ignoreElements] jQuery selectors of DOM elements to ignore during render. Can be an expensive check
*/
render: function($el, template, context, opts) {
var newDOM, newHTML,
el = $el.get(0);
opts = opts || {};
newHTML = opts.newHTML || template(context);
if (opts.force) {
$el.html(newHTML);
} else {
newDOM = this.copyTopElement(el);
$(newDOM).html(newHTML);
this.hotswapKeepCaret(el, newDOM, opts.ignoreElements);
}
},
/**
* Call this.hotswap but also keeps the caret position the same
* @param {Node} currentNode The DOM Node corresponding to the existing page content to update
* @param {Node} newNode The detached DOM Node representing the desired DOM subtree
* @param {Array} ignoreElements Array of jQuery selectors for DOM Elements to ignore during render. Can be an expensive check.
*/
hotswapKeepCaret: function(currentNode, newNode, ignoreElements) {
var currentCaret, activeElement,
currentNodeContainsActiveElement = false;
try {
activeElement = document.activeElement;
} catch (error) {
activeElement = null;
}
if (activeElement && currentNode && $.contains(activeElement, currentNode)) {
currentNodeContainsActiveElement = true;
}
if (currentNodeContainsActiveElement && this.supportsSelection(activeElement)) {
currentCaret = this.getCaretPosition(activeElement);
}
this.hotswap(currentNode, newNode, ignoreElements);
if (currentNodeContainsActiveElement && this.supportsSelection(activeElement)) {
this.setCaretPosition(activeElement, currentCaret);
}
},
// See above function declaration for method-level documentation
hotswap: hotswap,
/**
* Produces a copy of the element tag with attributes but with no contents
* @param {Element} el the DOM element to be copied
* @returns {Element} a shallow copy of the element with no children but with attributes
*/
copyTopElement: function(el) {
var newDOM = document.createElement(el.tagName);
_.each(el.attributes, function(attrib) {
newDOM.setAttribute(attrib.name, attrib.value);
});
return newDOM;
},
/**
* Determines if the element supports selection. As per spec, https://html.spec.whatwg.org/multipage/forms.html#do-not-apply
* selection is only allowed for text, search, tel, url, password. Other input types will throw an exception in chrome
* @param {Element} el the DOM element to check
* @returns {boolean} boolean indicating whether or not the selection is allowed for {Element} el
*/
supportsSelection : function (el) {
return (/text|password|search|tel|url/).test(el.type);
},
/**
* Method that returns the current caret (cursor) position of a given element.
* Source: http://stackoverflow.com/questions/2897155/get-cursor-position-in-characters-within-a-text-input-field
* @param {element} elem the DOM element to check caret position
* @returns {Integer} the cursor index of the given element.
*/
getCaretPosition: function(elem) {
// range {IE selection object}
// iCaretPos {Integer} will store the final caret position
var range,
iCaretPos = 0;
// IE Support
if (document.selection) {
// Set focus on the element
elem.focus();
// To get cursor position, get empty selection range
range = document.selection.createRange();
// Move selection start to 0 position
range.moveStart('character', -elem.value.length);
// The caret position is selection length
iCaretPos = range.text.length;
} else if (elem.selectionStart || elem.selectionStart === 0) {
// Firefox support
iCaretPos = elem.selectionStart;
}
// Return results
return iCaretPos;
},
/**
* Method that returns sets the current caret (cursor) position of a given element and puts it in focus.
* Source: http://stackoverflow.com/questions/512528/set-cursor-position-in-html-textbox
* @param {element} elem
* @param {Integer} caretPos The caret index to set
* @returns {Integer} the cursor index of the given element.
*/
setCaretPosition: function(elem, caretPos) {
var range;
if (elem) {
if (elem.createTextRange) {
// IE support
range = elem.createTextRange();
range.move('character', caretPos);
range.select();
} else if (elem.selectionStart || elem.selectionStart === 0) {
// Firefox support
elem.focus();
elem.setSelectionRange(caretPos, caretPos);
} else {
// At least focus the element if nothing else
elem.focus();
}
}
}
};
return templateRenderer;
}));