(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global.morphdom = factory()); }(this, (function () { 'use strict'; var range; // Create a range object for efficently rendering strings to elements. var NS_XHTML = 'http://www.w3.org/1999/xhtml'; var doc = typeof document === 'undefined' ? undefined : document; var testEl = doc ? doc.body || doc.createElement('div') : {}; // Fixes // (IE7+ support) <=IE7 does not support el.hasAttribute(name) var actualHasAttributeNS; if (testEl.hasAttributeNS) { actualHasAttributeNS = function(el, namespaceURI, name) { return el.hasAttributeNS(namespaceURI, name); }; } else if (testEl.hasAttribute) { actualHasAttributeNS = function(el, namespaceURI, name) { return el.hasAttribute(name); }; } else { actualHasAttributeNS = function(el, namespaceURI, name) { return el.getAttributeNode(namespaceURI, name) != null; }; } var hasAttributeNS = actualHasAttributeNS; function toElement(str) { if (!range && doc.createRange) { range = doc.createRange(); range.selectNode(doc.body); } var fragment; if (range && range.createContextualFragment) { fragment = range.createContextualFragment(str); } else { fragment = doc.createElement('body'); fragment.innerHTML = str; } return fragment.childNodes[0]; } /** * Returns true if two node's names are the same. * * NOTE: We don't bother checking `namespaceURI` because you will never find two HTML elements with the same * nodeName and different namespace URIs. * * @param {Element} a * @param {Element} b The target element * @return {boolean} */ function compareNodeNames(fromEl, toEl) { var fromNodeName = fromEl.nodeName; var toNodeName = toEl.nodeName; if (fromNodeName === toNodeName) { return true; } if (toEl.actualize && fromNodeName.charCodeAt(0) < 91 && /* from tag name is upper case */ toNodeName.charCodeAt(0) > 90 /* target tag name is lower case */) { // If the target element is a virtual DOM node then we may need to normalize the tag name // before comparing. Normal HTML elements that are in the "http://www.w3.org/1999/xhtml" // are converted to upper case return fromNodeName === toNodeName.toUpperCase(); } else { return false; } } /** * Create an element, optionally with a known namespace URI. * * @param {string} name the element name, e.g. 'div' or 'svg' * @param {string} [namespaceURI] the element's namespace URI, i.e. the value of * its `xmlns` attribute or its inferred namespace. * * @return {Element} */ function createElementNS(name, namespaceURI) { return !namespaceURI || namespaceURI === NS_XHTML ? doc.createElement(name) : doc.createElementNS(namespaceURI, name); } /** * Copies the children of one DOM element to another DOM element */ function moveChildren(fromEl, toEl) { var curChild = fromEl.firstChild; while (curChild) { var nextChild = curChild.nextSibling; toEl.appendChild(curChild); curChild = nextChild; } return toEl; } function morphAttrs(fromNode, toNode) { var attrs = toNode.attributes; var i; var attr; var attrName; var attrNamespaceURI; var attrValue; var fromValue; for (i = attrs.length - 1; i >= 0; --i) { attr = attrs[i]; attrName = attr.name; attrNamespaceURI = attr.namespaceURI; attrValue = attr.value; if (attrNamespaceURI) { attrName = attr.localName || attrName; fromValue = fromNode.getAttributeNS(attrNamespaceURI, attrName); if (fromValue !== attrValue) { fromNode.setAttributeNS(attrNamespaceURI, attrName, attrValue); } } else { fromValue = fromNode.getAttribute(attrName); if (fromValue !== attrValue) { fromNode.setAttribute(attrName, attrValue); } } } // Remove any extra attributes found on the original DOM element that // weren't found on the target element. attrs = fromNode.attributes; for (i = attrs.length - 1; i >= 0; --i) { attr = attrs[i]; if (attr.specified !== false) { attrName = attr.name; attrNamespaceURI = attr.namespaceURI; if (attrNamespaceURI) { attrName = attr.localName || attrName; if (!hasAttributeNS(toNode, attrNamespaceURI, attrName)) { fromNode.removeAttributeNS(attrNamespaceURI, attrName); } } else { if (!hasAttributeNS(toNode, null, attrName)) { fromNode.removeAttribute(attrName); } } } } } function syncBooleanAttrProp(fromEl, toEl, name) { if (fromEl[name] !== toEl[name]) { fromEl[name] = toEl[name]; if (fromEl[name]) { fromEl.setAttribute(name, ''); } else { fromEl.removeAttribute(name, ''); } } } var specialElHandlers = { /** * Needed for IE. Apparently IE doesn't think that "selected" is an * attribute when reading over the attributes using selectEl.attributes */ OPTION: function(fromEl, toEl) { syncBooleanAttrProp(fromEl, toEl, 'selected'); }, /** * The "value" attribute is special for the element since it sets * the initial value. Changing the "value" attribute without changing the * "value" property will have no effect since it is only used to the set the * initial value. Similar for the "checked" attribute, and "disabled". */ INPUT: function(fromEl, toEl) { syncBooleanAttrProp(fromEl, toEl, 'checked'); syncBooleanAttrProp(fromEl, toEl, 'disabled'); if (fromEl.value !== toEl.value) { fromEl.value = toEl.value; } if (!hasAttributeNS(toEl, null, 'value')) { fromEl.removeAttribute('value'); } }, TEXTAREA: function(fromEl, toEl) { var newValue = toEl.value; if (fromEl.value !== newValue) { fromEl.value = newValue; } if (fromEl.firstChild) { // Needed for IE. Apparently IE sets the placeholder as the // node value and vise versa. This ignores an empty update. if (newValue === '' && fromEl.firstChild.nodeValue === fromEl.placeholder) { return; } fromEl.firstChild.nodeValue = newValue; } }, SELECT: function(fromEl, toEl) { if (!hasAttributeNS(toEl, null, 'multiple')) { var selectedIndex = -1; var i = 0; var curChild = toEl.firstChild; while(curChild) { var nodeName = curChild.nodeName; if (nodeName && nodeName.toUpperCase() === 'OPTION') { if (hasAttributeNS(curChild, null, 'selected')) { selectedIndex = i; break; } i++; } curChild = curChild.nextSibling; } fromEl.selectedIndex = i; } } }; var ELEMENT_NODE = 1; var TEXT_NODE = 3; var COMMENT_NODE = 8; function noop() {} function defaultGetNodeKey(node) { return node.id; } function morphdomFactory(morphAttrs) { return function morphdom(fromNode, toNode, options) { if (!options) { options = {}; } if (typeof toNode === 'string') { if (fromNode.nodeName === '#document' || fromNode.nodeName === 'HTML') { var toNodeHtml = toNode; toNode = doc.createElement('html'); toNode.innerHTML = toNodeHtml; } else { toNode = toElement(toNode); } } var getNodeKey = options.getNodeKey || defaultGetNodeKey; var onBeforeNodeAdded = options.onBeforeNodeAdded || noop; var onNodeAdded = options.onNodeAdded || noop; var onBeforeElUpdated = options.onBeforeElUpdated || noop; var onElUpdated = options.onElUpdated || noop; var onBeforeNodeDiscarded = options.onBeforeNodeDiscarded || noop; var onNodeDiscarded = options.onNodeDiscarded || noop; var onBeforeElChildrenUpdated = options.onBeforeElChildrenUpdated || noop; var childrenOnly = options.childrenOnly === true; // This object is used as a lookup to quickly find all keyed elements in the original DOM tree. var fromNodesLookup = {}; var keyedRemovalList; function addKeyedRemoval(key) { if (keyedRemovalList) { keyedRemovalList.push(key); } else { keyedRemovalList = [key]; } } function walkDiscardedChildNodes(node, skipKeyedNodes) { if (node.nodeType === ELEMENT_NODE) { var curChild = node.firstChild; while (curChild) { var key = undefined; if (skipKeyedNodes && (key = getNodeKey(curChild))) { // If we are skipping keyed nodes then we add the key // to a list so that it can be handled at the very end. addKeyedRemoval(key); } else { // Only report the node as discarded if it is not keyed. We do this because // at the end we loop through all keyed elements that were unmatched // and then discard them in one final pass. onNodeDiscarded(curChild); if (curChild.firstChild) { walkDiscardedChildNodes(curChild, skipKeyedNodes); } } curChild = curChild.nextSibling; } } } /** * Removes a DOM node out of the original DOM * * @param {Node} node The node to remove * @param {Node} parentNode The nodes parent * @param {Boolean} skipKeyedNodes If true then elements with keys will be skipped and not discarded. * @return {undefined} */ function removeNode(node, parentNode, skipKeyedNodes) { if (onBeforeNodeDiscarded(node) === false) { return; } if (parentNode) { parentNode.removeChild(node); } onNodeDiscarded(node); walkDiscardedChildNodes(node, skipKeyedNodes); } // // TreeWalker implementation is no faster, but keeping this around in case this changes in the future // function indexTree(root) { // var treeWalker = document.createTreeWalker( // root, // NodeFilter.SHOW_ELEMENT); // // var el; // while((el = treeWalker.nextNode())) { // var key = getNodeKey(el); // if (key) { // fromNodesLookup[key] = el; // } // } // } // // NodeIterator implementation is no faster, but keeping this around in case this changes in the future // // function indexTree(node) { // var nodeIterator = document.createNodeIterator(node, NodeFilter.SHOW_ELEMENT); // var el; // while((el = nodeIterator.nextNode())) { // var key = getNodeKey(el); // if (key) { // fromNodesLookup[key] = el; // } // } // } function indexTree(node) { if (node.nodeType === ELEMENT_NODE) { var curChild = node.firstChild; while (curChild) { var key = getNodeKey(curChild); if (key) { fromNodesLookup[key] = curChild; } // Walk recursively indexTree(curChild); curChild = curChild.nextSibling; } } } indexTree(fromNode); function handleNodeAdded(el) { onNodeAdded(el); var curChild = el.firstChild; while (curChild) { var nextSibling = curChild.nextSibling; var key = getNodeKey(curChild); if (key) { var unmatchedFromEl = fromNodesLookup[key]; if (unmatchedFromEl && compareNodeNames(curChild, unmatchedFromEl)) { curChild.parentNode.replaceChild(unmatchedFromEl, curChild); morphEl(unmatchedFromEl, curChild); } } handleNodeAdded(curChild); curChild = nextSibling; } } function morphEl(fromEl, toEl, childrenOnly) { var toElKey = getNodeKey(toEl); var curFromNodeKey; if (toElKey) { // If an element with an ID is being morphed then it is will be in the final // DOM so clear it out of the saved elements collection delete fromNodesLookup[toElKey]; } if (toNode.isSameNode && toNode.isSameNode(fromNode)) { return; } if (!childrenOnly) { if (onBeforeElUpdated(fromEl, toEl) === false) { return; } morphAttrs(fromEl, toEl); onElUpdated(fromEl); if (onBeforeElChildrenUpdated(fromEl, toEl) === false) { return; } } if (fromEl.nodeName !== 'TEXTAREA') { var curToNodeChild = toEl.firstChild; var curFromNodeChild = fromEl.firstChild; var curToNodeKey; var fromNextSibling; var toNextSibling; var matchingFromEl; outer: while (curToNodeChild) { toNextSibling = curToNodeChild.nextSibling; curToNodeKey = getNodeKey(curToNodeChild); while (curFromNodeChild) { fromNextSibling = curFromNodeChild.nextSibling; if (curToNodeChild.isSameNode && curToNodeChild.isSameNode(curFromNodeChild)) { curToNodeChild = toNextSibling; curFromNodeChild = fromNextSibling; continue outer; } curFromNodeKey = getNodeKey(curFromNodeChild); var curFromNodeType = curFromNodeChild.nodeType; var isCompatible = undefined; if (curFromNodeType === curToNodeChild.nodeType) { if (curFromNodeType === ELEMENT_NODE) { // Both nodes being compared are Element nodes if (curToNodeKey) { // The target node has a key so we want to match it up with the correct element // in the original DOM tree if (curToNodeKey !== curFromNodeKey) { // The current element in the original DOM tree does not have a matching key so // let's check our lookup to see if there is a matching element in the original // DOM tree if ((matchingFromEl = fromNodesLookup[curToNodeKey])) { if (curFromNodeChild.nextSibling === matchingFromEl) { // Special case for single element removals. To avoid removing the original // DOM node out of the tree (since that can break CSS transitions, etc.), // we will instead discard the current node and wait until the next // iteration to properly match up the keyed target element with its matching // element in the original tree isCompatible = false; } else { // We found a matching keyed element somewhere in the original DOM tree. // Let's moving the original DOM node into the current position and morph // it. // NOTE: We use insertBefore instead of replaceChild because we want to go through // the `removeNode()` function for the node that is being discarded so that // all lifecycle hooks are correctly invoked fromEl.insertBefore(matchingFromEl, curFromNodeChild); fromNextSibling = curFromNodeChild.nextSibling; if (curFromNodeKey) { // Since the node is keyed it might be matched up later so we defer // the actual removal to later addKeyedRemoval(curFromNodeKey); } else { // NOTE: we skip nested keyed nodes from being removed since there is // still a chance they will be matched up later removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */); } curFromNodeChild = matchingFromEl; } } else { // The nodes are not compatible since the "to" node has a key and there // is no matching keyed node in the source tree isCompatible = false; } } } else if (curFromNodeKey) { // The original has a key isCompatible = false; } isCompatible = isCompatible !== false && compareNodeNames(curFromNodeChild, curToNodeChild); if (isCompatible) { // We found compatible DOM elements so transform // the current "from" node to match the current // target DOM node. morphEl(curFromNodeChild, curToNodeChild); } } else if (curFromNodeType === TEXT_NODE || curFromNodeType == COMMENT_NODE) { // Both nodes being compared are Text or Comment nodes isCompatible = true; // Simply update nodeValue on the original node to // change the text value curFromNodeChild.nodeValue = curToNodeChild.nodeValue; } } if (isCompatible) { // Advance both the "to" child and the "from" child since we found a match curToNodeChild = toNextSibling; curFromNodeChild = fromNextSibling; continue outer; } // No compatible match so remove the old node from the DOM and continue trying to find a // match in the original DOM. However, we only do this if the from node is not keyed // since it is possible that a keyed node might match up with a node somewhere else in the // target tree and we don't want to discard it just yet since it still might find a // home in the final DOM tree. After everything is done we will remove any keyed nodes // that didn't find a home if (curFromNodeKey) { // Since the node is keyed it might be matched up later so we defer // the actual removal to later addKeyedRemoval(curFromNodeKey); } else { // NOTE: we skip nested keyed nodes from being removed since there is // still a chance they will be matched up later removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */); } curFromNodeChild = fromNextSibling; } // If we got this far then we did not find a candidate match for // our "to node" and we exhausted all of the children "from" // nodes. Therefore, we will just append the current "to" node // to the end if (curToNodeKey && (matchingFromEl = fromNodesLookup[curToNodeKey]) && compareNodeNames(matchingFromEl, curToNodeChild)) { fromEl.appendChild(matchingFromEl); morphEl(matchingFromEl, curToNodeChild); } else { var onBeforeNodeAddedResult = onBeforeNodeAdded(curToNodeChild); if (onBeforeNodeAddedResult !== false) { if (onBeforeNodeAddedResult) { curToNodeChild = onBeforeNodeAddedResult; } if (curToNodeChild.actualize) { curToNodeChild = curToNodeChild.actualize(fromEl.ownerDocument || doc); } fromEl.appendChild(curToNodeChild); handleNodeAdded(curToNodeChild); } } curToNodeChild = toNextSibling; curFromNodeChild = fromNextSibling; } // We have processed all of the "to nodes". If curFromNodeChild is // non-null then we still have some from nodes left over that need // to be removed while (curFromNodeChild) { fromNextSibling = curFromNodeChild.nextSibling; if ((curFromNodeKey = getNodeKey(curFromNodeChild))) { // Since the node is keyed it might be matched up later so we defer // the actual removal to later addKeyedRemoval(curFromNodeKey); } else { // NOTE: we skip nested keyed nodes from being removed since there is // still a chance they will be matched up later removeNode(curFromNodeChild, fromEl, true /* skip keyed nodes */); } curFromNodeChild = fromNextSibling; } } var specialElHandler = specialElHandlers[fromEl.nodeName]; if (specialElHandler) { specialElHandler(fromEl, toEl); } } // END: morphEl(...) var morphedNode = fromNode; var morphedNodeType = morphedNode.nodeType; var toNodeType = toNode.nodeType; if (!childrenOnly) { // Handle the case where we are given two DOM nodes that are not // compatible (e.g.
--> or
--> TEXT) if (morphedNodeType === ELEMENT_NODE) { if (toNodeType === ELEMENT_NODE) { if (!compareNodeNames(fromNode, toNode)) { onNodeDiscarded(fromNode); morphedNode = moveChildren(fromNode, createElementNS(toNode.nodeName, toNode.namespaceURI)); } } else { // Going from an element node to a text node morphedNode = toNode; } } else if (morphedNodeType === TEXT_NODE || morphedNodeType === COMMENT_NODE) { // Text or comment node if (toNodeType === morphedNodeType) { morphedNode.nodeValue = toNode.nodeValue; return morphedNode; } else { // Text node to something else morphedNode = toNode; } } } if (morphedNode === toNode) { // The "to node" was not compatible with the "from node" so we had to // toss out the "from node" and use the "to node" onNodeDiscarded(fromNode); } else { morphEl(morphedNode, toNode, childrenOnly); // We now need to loop over any keyed nodes that might need to be // removed. We only do the removal if we know that the keyed node // never found a match. When a keyed node is matched up we remove // it out of fromNodesLookup and we use fromNodesLookup to determine // if a keyed node has been matched up or not if (keyedRemovalList) { for (var i=0, len=keyedRemovalList.length; i