diff --git a/src/language/HTMLDOMDiff.js b/src/language/HTMLDOMDiff.js index f7bc9c894f2..a61e45b8578 100644 --- a/src/language/HTMLDOMDiff.js +++ b/src/language/HTMLDOMDiff.js @@ -29,10 +29,12 @@ define(function (require, exports, module) { "use strict"; - function generateAttributeEdits(edits, oldNode, newNode) { + function generateAttributeEdits(oldNode, newNode) { // shallow copy the old attributes object so that we can modify it var oldAttributes = $.extend({}, oldNode.attributes), - newAttributes = newNode.attributes; + newAttributes = newNode.attributes, + edits = []; + Object.keys(newAttributes).forEach(function (attributeName) { if (oldAttributes[attributeName] !== newAttributes[attributeName]) { var type = oldAttributes.hasOwnProperty(attributeName) ? "attrChange" : "attrAdd"; @@ -45,6 +47,7 @@ define(function (require, exports, module) { } delete oldAttributes[attributeName]; }); + Object.keys(oldAttributes).forEach(function (attributeName) { edits.push({ type: "attrDelete", @@ -52,6 +55,8 @@ define(function (require, exports, module) { attribute: attributeName }); }); + + return edits; } /** @@ -65,463 +70,472 @@ define(function (require, exports, module) { } /** - * Generate a list of edits that will mutate oldNode to look like newNode. - * Currently, there are the following possible edit operations: + * When the main loop (see below) determines that something has changed with + * an element's immediate children, it calls this function to create edit + * operations for those changes. * - * * elementInsert - * * elementDelete - * * elementMove - * * textInsert - * * textDelete - * * textReplace - * * attrDelete - * * attrChange - * * attrAdd - * * rememberNodes (a special instruction that reflects the need to hang on to moved nodes) + * This adds to the edit list in place and does not return anything. * - * @param {Object} oldNode SimpleDOM node with the original content - * @param {Object} newNode SimpleDOM node with the new content - * @return {Array.{Object}} list of edit operations + * @param {?Object} oldParent SimpleDOM node for the previous state of this element, null/undefined if the element is new + * @param {Object} newParent SimpleDOM node for the current state of the element */ - function domdiff(oldNode, newNode) { - var queue = [], + var generateChildEdits = function (oldParent, oldNodeMap, newParent, newNodeMap) { + /*jslint continue: true */ + + var newIndex = 0, + oldIndex = 0, + newChildren = newParent.children, + oldChildren = oldParent ? oldParent.children : [], + newChild, + oldChild, + newEdits = [], + newEdit, + textAfterID, edits = [], - matches = {}, - elementInserts = {}, - textInserts = {}, - textChanges = {}, - elementsWithTextChanges = {}, - currentElement, - oldElement, moves = [], - elementDeletes = {}, - oldNodeMap = oldNode ? oldNode.nodeMap : {}; + newElements = []; /** - * When the main loop (see below) determines that something has changed with - * an element's immediate children, it calls this function to create edit - * operations for those changes. + * We initially put new edit objects into the `newEdits` array so that we + * can fix them up with proper positioning information. This function is + * responsible for doing that fixup. + * + * The `beforeID` that appears in many edits tells the browser to make the + * change before the element with the given ID. In other words, an + * elementInsert with a `beforeID` of 32 would result in something like + * `parentElement.insertBefore(newChildElement, _queryBracketsID(32))` * - * This adds to the edit list in place and does not return anything. + * Many new edits are captured in the `newEdits` array so that a suitable + * `beforeID` can be added to them before they are added to the main edits + * list. This function sets the `beforeID` on any pending edits and adds + * them to the main list. * - * @param {Object} currentParent SimpleDOM node for the current state of the element - * @param {?Object} oldParent SimpleDOM node for the previous state of this element, undefined if the element is new + * The beforeID set here will then be used as the `afterID` for text edits + * that follow. + * + * @param {int} beforeID ID to set on the pending edits */ - var generateChildEdits = function (currentParent, oldParent) { - /*jslint continue: true */ - - var currentIndex = 0, - oldIndex = 0, - currentChildren = currentParent.children, - oldChildren = oldParent ? oldParent.children : [], - currentChild, - oldChild, - newEdits = [], - newEdit, - textAfterID; - - /** - * We initially put new edit objects into the `newEdits` array so that we - * can fix them up with proper positioning information. This function is - * responsible for doing that fixup. - * - * The `beforeID` that appears in many edits tells the browser to make the - * change before the element with the given ID. In other words, an - * elementInsert with a `beforeID` of 32 would result in something like - * `parentElement.insertBefore(newChildElement, _queryBracketsID(32))` - * - * Many new edits are captured in the `newEdits` array so that a suitable - * `beforeID` can be added to them before they are added to the main edits - * list. This function sets the `beforeID` on any pending edits and adds - * them to the main list. - * - * The beforeID set here will then be used as the `afterID` for text edits - * that follow. - * - * @param {int} beforeID ID to set on the pending edits - */ - var finalizeNewEdits = function (beforeID) { - newEdits.forEach(function (edit) { - // elementDeletes don't need any positioning information - if (edit.type !== "elementDelete") { - edit.beforeID = beforeID; - } - }); - edits.push.apply(edits, newEdits); - newEdits = []; - textAfterID = beforeID; - }; - - /** - * If the current element was not in the old DOM, then we will create - * an elementInsert edit for it. - * - * If the element was in the old DOM, this will return false and the - * main loop will either spot this element later in the child list - * or the element has been moved. - * - * @return {boolean} true if an elementInsert was created - */ - var addElementInsert = function () { - if (!oldNodeMap[currentChild.tagID]) { - newEdit = { - type: "elementInsert", - tag: currentChild.tag, - tagID: currentChild.tagID, - parentID: currentChild.parent.tagID, - attributes: currentChild.attributes - }; - - newEdits.push(newEdit); - - // This newly inserted node needs to have edits generated for its - // children, so we add it to the queue. - queue.push(currentChild); - - // A textInsert edit that follows this elementInsert should use - // this element's ID. - textAfterID = currentChild.tagID; - - // new element means we need to move on to compare the next - // of the current tree with the one from the old tree that we - // just compared - currentIndex++; - return true; + var finalizeNewEdits = function (beforeID) { + newEdits.forEach(function (edit) { + // elementDeletes don't need any positioning information + if (edit.type !== "elementDelete") { + edit.beforeID = beforeID; } - return false; + }); + edits.push.apply(edits, newEdits); + newEdits = []; + textAfterID = beforeID; + }; + + /** + * If the current element was not in the old DOM, then we will create + * an elementInsert edit for it. + * + * If the element was in the old DOM, this will return false and the + * main loop will either spot this element later in the child list + * or the element has been moved. + * + * @return {boolean} true if an elementInsert was created + */ + var addElementInsert = function () { + if (!oldNodeMap[newChild.tagID]) { + newEdit = { + type: "elementInsert", + tag: newChild.tag, + tagID: newChild.tagID, + parentID: newChild.parent.tagID, + attributes: newChild.attributes + }; + + newEdits.push(newEdit); + + // This newly inserted node needs to have edits generated for its + // children, so we add it to the queue. + newElements.push(newChild); + + // A textInsert edit that follows this elementInsert should use + // this element's ID. + textAfterID = newChild.tagID; + + // new element means we need to move on to compare the next + // of the current tree with the one from the old tree that we + // just compared + newIndex++; + return true; + } + return false; + }; + + /** + * If the old element that we're looking at does not appear in the new + * DOM, that means it was deleted and we'll create an elementDelete edit. + * + * If the element is in the new DOM, then this will return false and + * the main loop with either spot this node later on or the element + * has been moved. + * + * @return {boolean} true if elementDelete was generated + */ + var addElementDelete = function () { + if (!newNodeMap[oldChild.tagID]) { + newEdit = { + type: "elementDelete", + tagID: oldChild.tagID + }; + newEdits.push(newEdit); + + // deleted element means we need to move on to compare the next + // of the old tree with the one from the current tree that we + // just compared + oldIndex++; + return true; + } + return false; + }; + + /** + * Adds a textInsert edit for a newly created text node. + */ + var addTextInsert = function () { + newEdit = { + type: "textInsert", + content: newChild.content, + parentID: newChild.parent.tagID }; - /** - * If the old element that we're looking at does not appear in the new - * DOM, that means it was deleted and we'll create an elementDelete edit. - * - * If the element is in the new DOM, then this will return false and - * the main loop with either spot this node later on or the element - * has been moved. - * - * @return {boolean} true if elementDelete was generated - */ - var addElementDelete = function () { - if (!newNode.nodeMap[oldChild.tagID]) { - newEdit = { - type: "elementDelete", - tagID: oldChild.tagID - }; - newEdits.push(newEdit); - - // deleted element means we need to move on to compare the next - // of the old tree with the one from the current tree that we - // just compared - oldIndex++; - return true; - } - return false; - }; + // text changes will generally have afterID and beforeID, but we make + // special note if it's the first child. + if (textAfterID) { + newEdit.afterID = textAfterID; + } else { + newEdit.firstChild = true; + } + newEdits.push(newEdit); - /** - * Adds a textInsert edit for a newly created text node. - */ - var addTextInsert = function () { + // The text node is in the new tree, so we move to the next new tree item + newIndex++; + }; + + /** + * Finds the previous child of the new tree. + * + * @return {?Object} previous child or null if there wasn't one + */ + var prevNode = function () { + if (newIndex > 0) { + return newParent.children[newIndex - 1]; + } + return null; + }; + + /** + * Adds a textDelete edit for text node that is not in the new tree. + * Note that we actually create a textReplace rather than a textDelete + * if the previous node in current tree was a text node. We do this because + * text nodes are not individually addressable and a delete event would + * end up clearing out both that previous text node that we want to keep + * and this text node that we want to eliminate. Instead, we just log + * a textReplace which will result in the deletion of this node and + * the maintaining of the old content. + */ + var addTextDelete = function () { + var prev = prevNode(); + if (prev && !prev.children) { newEdit = { - type: "textInsert", - content: currentChild.content, - parentID: currentChild.parent.tagID + type: "textReplace", + content: prev.content }; - - // text changes will generally have afterID and beforeID, but we make - // special note if it's the first child. + } else { + newEdit = { + type: "textDelete" + }; + } + + // When elements are deleted or moved from the old set of children, you + // can end up with multiple text nodes in a row. A single textReplace edit + // will take care of those (and will contain all of the right content since + // the text nodes between elements in the new DOM are merged together). + // The check below looks to see if we're already in the process of adding + // a textReplace edit following the same element. + var previousEdit = newEdits.length > 0 && newEdits[newEdits.length - 1]; + if (previousEdit && previousEdit.type === "textReplace" && + previousEdit.afterID === textAfterID) { + oldIndex++; + return; + } + + newEdit.parentID = oldChild.parent.tagID; + + // If there was only one child previously, we just pass along + // textDelete/textReplace with the parentID and the browser will + // clear all of the children + if (oldChild.parent.children.length === 1) { + newEdits.push(newEdit); + } else { if (textAfterID) { newEdit.afterID = textAfterID; - } else { - newEdit.firstChild = true; } newEdits.push(newEdit); - - // The text node is in the new tree, so we move to the next new tree item - currentIndex++; - }; + } - /** - * Finds the previous child of the new tree. - * - * @return {?Object} previous child or null if there wasn't one - */ - var prevNode = function () { - if (currentIndex > 0) { - return currentParent.children[currentIndex - 1]; - } - return null; - }; + // This text appeared in the old tree but not the new one, so we + // increment the old children counter. + oldIndex++; + }; + + /** + * Adds an elementMove edit if the parent has changed between the old and new trees. + * These are fairly infrequent and generally occur if you make a change across + * tag boundaries. + * + * @return {boolean} true if an elementMove was generated + */ + var addElementMove = function () { - /** - * Adds a textDelete edit for text node that is not in the new tree. - * Note that we actually create a textReplace rather than a textDelete - * if the previous node in current tree was a text node. We do this because - * text nodes are not individually addressable and a delete event would - * end up clearing out both that previous text node that we want to keep - * and this text node that we want to eliminate. Instead, we just log - * a textReplace which will result in the deletion of this node and - * the maintaining of the old content. - */ - var addTextDelete = function () { - var prev = prevNode(); - if (prev && !prev.children) { - newEdit = { - type: "textReplace", - content: prev.content - }; - } else { - newEdit = { - type: "textDelete" - }; - } - - // When elements are deleted or moved from the old set of children, you - // can end up with multiple text nodes in a row. A single textReplace edit - // will take care of those (and will contain all of the right content since - // the text nodes between elements in the new DOM are merged together). - // The check below looks to see if we're already in the process of adding - // a textReplace edit following the same element. - var previousEdit = newEdits.length > 0 && newEdits[newEdits.length - 1]; - if (previousEdit && previousEdit.type === "textReplace" && - previousEdit.afterID === textAfterID) { - oldIndex++; - return; - } - - newEdit.parentID = oldChild.parent.tagID; + // This check looks a little strange, but it suits what we're trying + // to do: as we're walking through the children, a child node that has moved + // from one parent to another will be found but would look like some kind + // of insert. The check that we're doing here is looking up the current + // child's ID in the *old* map and seeing if this child used to have a + // different parent. + var possiblyMovedElement = oldNodeMap[newChild.tagID]; + if (possiblyMovedElement && + newParent.tagID !== getParentID(possiblyMovedElement)) { + newEdit = { + type: "elementMove", + tagID: newChild.tagID, + parentID: newChild.parent.tagID + }; + moves.push(newEdit.tagID); + newEdits.push(newEdit); - // If there was only one child previously, we just pass along - // textDelete/textReplace with the parentID and the browser will - // clear all of the children - if (oldChild.parent.children.length === 1) { - newEdits.push(newEdit); - } else { - if (textAfterID) { - newEdit.afterID = textAfterID; - } - newEdits.push(newEdit); + // this element in the new tree was a move to this spot, so we can move + // on to the next child in the new tree. + newIndex++; + return true; + } + return false; + }; + + /** + * If there have been elementInserts before an unchanged text, we need to + * let the browser side code know that these inserts should happen *before* + * that unchanged text. + */ + var fixupElementInsert = function () { + newEdits.forEach(function (edit) { + if (edit.type === "elementInsert") { + edit.beforeText = true; } - - // This text appeared in the old tree but not the new one, so we - // increment the old children counter. - oldIndex++; - }; + }); + }; + + /** + * Looks to see if the element in the old tree has moved by checking its + * current and former parents. + * + * @return {boolean} true if the element has moved + */ + var hasMoved = function (oldChild) { + var oldChildInNewTree = newNodeMap[oldChild.tagID]; - /** - * Adds an elementMove edit if the parent has changed between the old and new trees. - * These are fairly infrequent and generally occur if you make a change across - * tag boundaries. - * - * @return {boolean} true if an elementMove was generated - */ - var addElementMove = function () { - - // This check looks a little strange, but it suits what we're trying - // to do: as we're walking through the children, a child node that has moved - // from one parent to another will be found but would look like some kind - // of insert. The check that we're doing here is looking up the current - // child's ID in the *old* map and seeing if this child used to have a - // different parent. - var possiblyMovedElement = oldNodeMap[currentChild.tagID]; - if (possiblyMovedElement && - currentParent.tagID !== getParentID(possiblyMovedElement)) { - newEdit = { - type: "elementMove", - tagID: currentChild.tagID, - parentID: currentChild.parent.tagID - }; - moves.push(newEdit.tagID); - newEdits.push(newEdit); - - // this element in the new tree was a move to this spot, so we can move - // on to the next child in the new tree. - currentIndex++; - return true; - } - return false; - }; + return oldChild.children && oldChildInNewTree && getParentID(oldChild) !== getParentID(oldChildInNewTree); + }; + + // Loop through the current and old children, comparing them one by one. + while (newIndex < newChildren.length && oldIndex < oldChildren.length) { + newChild = newChildren[newIndex]; - /** - * If there have been elementInserts before an unchanged text, we need to - * let the browser side code know that these inserts should happen *before* - * that unchanged text. - */ - var fixupElementInsert = function () { - newEdits.forEach(function (edit) { - if (edit.type === "elementInsert") { - edit.beforeText = true; - } - }); - }; + // Check to see if the currentChild has been reparented from somewhere + // else in the old tree + if (newChild.children && addElementMove()) { + continue; + } - /** - * Looks to see if the element in the old tree has moved by checking its - * current and former parents. - * - * @return {boolean} true if the element has moved - */ - var hasMoved = function (oldChild) { - var oldChildInNewTree = newNode.nodeMap[oldChild.tagID]; - - return oldChild.children && oldChildInNewTree && getParentID(oldChild) !== getParentID(oldChildInNewTree); - }; + oldChild = oldChildren[oldIndex]; - // Loop through the current and old children, comparing them one by one. - while (currentIndex < currentChildren.length && oldIndex < oldChildren.length) { - currentChild = currentChildren[currentIndex]; - - // Check to see if the currentChild has been reparented from somewhere - // else in the old tree - if (currentChild.children && addElementMove()) { - continue; - } + // Check to see if the oldChild has been moved to another parent. + // If it has, we deal with it on the other side (see above) + if (hasMoved(oldChild)) { + oldIndex++; + continue; + } + + if (newChild.isElement() || oldChild.isElement()) { - oldChild = oldChildren[oldIndex]; + if (newChild.isElement() && oldChild.isText()) { + addTextDelete(); + + // If this element is new, add it and move to the next child + // in the current tree. Otherwise, we'll compare this same + // current element with the next old element on the next pass + // through the loop. + addElementInsert(); - // Check to see if the oldChild has been moved to another parent. - // If it has, we deal with it on the other side (see above) - if (hasMoved(oldChild)) { - oldIndex++; - continue; - } + } else if (oldChild.isElement() && newChild.isText()) { + // If the old child has *not* been deleted, we assume that we've + // inserted some text and will still encounter the old node + if (!addElementDelete()) { + addTextInsert(); + } - // First check: is one an element? - if (currentChild.children || oldChild.children) { - - // Current child is an element, old child is a text node - if (currentChild.children && !oldChild.children) { - addTextDelete(); + // both children are elements + } else { + if (newChild.tagID !== oldChild.tagID) { - // If this element is new, add it and move to the next child - // in the current tree. Otherwise, we'll compare this same - // current element with the next old element on the next pass - // through the loop. - addElementInsert(); - - // Current child is a text node, old child is an element - } else if (oldChild.children && !currentChild.children) { - // If the old child has *not* been deleted, we assume that we've - // inserted some text and will still encounter the old node - if (!addElementDelete()) { - addTextInsert(); + // These are different elements, so we will add an insert and/or delete + // as appropriate + if (!addElementInsert() && !addElementDelete()) { + console.error("HTML Instrumentation: This should not happen. Two elements have different tag IDs and there was no insert/delete. This generally means there was a reordering of elements."); + newIndex++; + oldIndex++; } - // both children are elements + // There has been no change in the tag we're looking at. } else { - if (currentChild.tagID !== oldChild.tagID) { - - // These are different elements, so we will add an insert and/or delete - // as appropriate - if (!addElementInsert() && !addElementDelete()) { - console.error("HTML Instrumentation: This should not happen. Two elements have different tag IDs and there was no insert/delete. This generally means there was a reordering of elements."); - currentIndex++; - oldIndex++; - } - - // There has been no change in the tag we're looking at. - } else { - // Since this element hasn't moved, it is a suitable "beforeID" - // for the edits we've logged. - finalizeNewEdits(oldChild.tagID); - currentIndex++; - oldIndex++; - } + // Since this element hasn't moved, it is a suitable "beforeID" + // for the edits we've logged. + finalizeNewEdits(oldChild.tagID); + newIndex++; + oldIndex++; } - - // We know we're comparing two texts. Just match up their signatures. - } else { - if (currentChild.textSignature !== oldChild.textSignature) { - newEdit = { - type: "textReplace", - content: currentChild.content, - parentID: currentChild.parent.tagID - }; - if (textAfterID) { - newEdit.afterID = textAfterID; - } - newEdits.push(newEdit); - } else { - // This is a special case: if an element is being inserted but - // there is an unchanged text that follows it, the element being - // inserted may end up in the wrong place because it will get a - // beforeID of the next element when it really needs to come - // before this unchanged text. - fixupElementInsert(); + } + + // We know we're comparing two texts. Just match up their signatures. + } else { + if (newChild.textSignature !== oldChild.textSignature) { + newEdit = { + type: "textReplace", + content: newChild.content, + parentID: newChild.parent.tagID + }; + if (textAfterID) { + newEdit.afterID = textAfterID; } - - // Either we've done a text replace or both sides matched. In either - // case we're ready to move forward among both the old and new children. - currentIndex++; - oldIndex++; + newEdits.push(newEdit); + } else { + // This is a special case: if an element is being inserted but + // there is an unchanged text that follows it, the element being + // inserted may end up in the wrong place because it will get a + // beforeID of the next element when it really needs to come + // before this unchanged text. + fixupElementInsert(); } + + // Either we've done a text replace or both sides matched. In either + // case we're ready to move forward among both the old and new children. + newIndex++; + oldIndex++; } + } + + // At this point, we've used up all of the children in at least one of the + // two sets of children. + + /** + * Take care of any remaining children in the old tree. + */ + while (oldIndex < oldChildren.length) { + oldChild = oldChildren[oldIndex]; - // At this point, we've used up all of the children in at least one of the - // two sets of children. + // Check for an element that has moved + if (hasMoved(oldChild)) { + // This element has moved, so we skip it on this side (the move + // is handled on the new tree side). + oldIndex++; - /** - * Take care of any remaining children in the old tree. - */ - while (oldIndex < oldChildren.length) { - oldChild = oldChildren[oldIndex]; - - // Check for an element that has moved - if (hasMoved(oldChild)) { - // This element has moved, so we skip it on this side (the move - // is handled on the new tree side). + // is this an element? if so, delete it + } else if (oldChild.isElement()) { + if (!addElementDelete()) { + console.error("HTML Instrumentation: failed to add elementDelete for remaining element in the original DOM. This should not happen.", oldChild); oldIndex++; - - // is this an element? if so, delete it - } else if (oldChild.children) { - if (!addElementDelete()) { - console.error("HTML Instrumentation: failed to add elementDelete for remaining element in the original DOM. This should not happen.", oldChild); - oldIndex++; - } - - // must be text. delete that. - } else { - addTextDelete(); } + + // must be text. delete that. + } else { + addTextDelete(); } + } + + /** + * Take care of the remaining children in the new tree. + */ + while (newIndex < newChildren.length) { + newChild = newChildren[newIndex]; - /** - * Take care of the remaining children in the new tree. - */ - while (currentIndex < currentChildren.length) { - currentChild = currentChildren[currentIndex]; + // Is this an element? + if (newChild.isElement()) { - // Is this an element? - if (currentChild.children) { - - // Look to see if the element has moved here. - if (!addElementMove()) { - // Not a move, so we insert this element. - if (!addElementInsert()) { - console.error("HTML Instrumentation: failed to add elementInsert for remaining element in the updated DOM. This should not happen."); - currentIndex++; - } + // Look to see if the element has moved here. + if (!addElementMove()) { + // Not a move, so we insert this element. + if (!addElementInsert()) { + console.error("HTML Instrumentation: failed to add elementInsert for remaining element in the updated DOM. This should not happen."); + newIndex++; } - - // not a new element, so it must be new text. - } else { - addTextInsert(); } - } - /** - * Finalize remaining edits. For inserts and moves, we can set the `lastChild` - * flag and the browser can simply use `appendChild` to add these items. - */ - newEdits.forEach(function (edit) { - if (edit.type === "textInsert" || edit.type === "elementInsert" || edit.type === "elementMove") { - edit.lastChild = true; - delete edit.firstChild; - delete edit.afterID; - } - }); - edits.push.apply(edits, newEdits); + // not a new element, so it must be new text. + } else { + addTextInsert(); + } + } + + /** + * Finalize remaining edits. For inserts and moves, we can set the `lastChild` + * flag and the browser can simply use `appendChild` to add these items. + */ + newEdits.forEach(function (edit) { + if (edit.type === "textInsert" || edit.type === "elementInsert" || edit.type === "elementMove") { + edit.lastChild = true; + delete edit.firstChild; + delete edit.afterID; + } + }); + edits.push.apply(edits, newEdits); + + return { + edits: edits, + moves: moves, + newElements: newElements }; + }; + + /** + * Generate a list of edits that will mutate oldNode to look like newNode. + * Currently, there are the following possible edit operations: + * + * * elementInsert + * * elementDelete + * * elementMove + * * textInsert + * * textDelete + * * textReplace + * * attrDelete + * * attrChange + * * attrAdd + * * rememberNodes (a special instruction that reflects the need to hang on to moved nodes) + * + * @param {Object} oldNode SimpleDOM node with the original content + * @param {Object} newNode SimpleDOM node with the new content + * @return {Array.{Object}} list of edit operations + */ + function domdiff(oldNode, newNode) { + var queue = [], + edits = [], + matches = {}, + elementInserts = {}, + textInserts = {}, + textChanges = {}, + elementsWithTextChanges = {}, + newElement, + oldElement, + moves = [], + elementDeletes = {}, + oldNodeMap = oldNode ? oldNode.nodeMap : {}, + newNodeMap = newNode.nodeMap, + delta; + /** * Adds elements to the queue for generateChildEdits. @@ -535,31 +549,42 @@ define(function (require, exports, module) { } }; + /** + * Aggregates the child edits in the proper data structures. + * + * @param {Object} delta edits, moves and newElements to add + */ + var addEdits = function (delta) { + edits.push.apply(edits, delta.edits); + moves.push.apply(moves, delta.moves); + queue.push.apply(queue, delta.newElements); + }; + // Start at the root of the current tree. queue.push(newNode); do { - currentElement = queue.pop(); - oldElement = oldNodeMap[currentElement.tagID]; + newElement = queue.pop(); + oldElement = oldNodeMap[newElement.tagID]; // Do we need to compare elements? if (oldElement) { // Are attributes different? - if (currentElement.attributeSignature !== oldElement.attributeSignature) { + if (newElement.attributeSignature !== oldElement.attributeSignature) { // generate attribute edits - generateAttributeEdits(edits, oldElement, currentElement); + edits.push.apply(edits, generateAttributeEdits(oldElement, newElement)); } // Has there been a change to this node's immediate children? - if (currentElement.childSignature !== oldElement.childSignature) { - generateChildEdits(currentElement, oldElement); + if (newElement.childSignature !== oldElement.childSignature) { + addEdits(generateChildEdits(oldElement, oldNodeMap, newElement, newNodeMap)); } // If there's a change farther down in the tree, add the children to the queue. // If not, we can skip that whole subtree. - if (currentElement.subtreeSignature !== oldElement.subtreeSignature) { - currentElement.children.forEach(queuePush); + if (newElement.subtreeSignature !== oldElement.subtreeSignature) { + newElement.children.forEach(queuePush); } // This is a new element, so go straight to generating child edits (which will @@ -569,17 +594,17 @@ define(function (require, exports, module) { // because it isn't the child of any other node. The browser-side code doesn't // care about parentage/positioning in this case, and will handle just setting the // ID on the existing implied HTML tag in the browser without actually creating it. - if (!currentElement.parent) { + if (!newElement.parent) { edits.push({ type: "elementInsert", - tag: currentElement.tag, - tagID: currentElement.tagID, + tag: newElement.tag, + tagID: newElement.tagID, parentID: null, - attributes: currentElement.attributes + attributes: newElement.attributes }); } - generateChildEdits(currentElement, null); + addEdits(generateChildEdits(null, oldNodeMap, newElement, newNodeMap)); } } while (queue.length); diff --git a/src/language/HTMLInstrumentation.js b/src/language/HTMLInstrumentation.js index 351a33c25d5..4634a6660d8 100644 --- a/src/language/HTMLInstrumentation.js +++ b/src/language/HTMLInstrumentation.js @@ -379,7 +379,7 @@ define(function (require, exports, module) { // Update the signatures for all parents of the new subtree. var curParent = parent; while (curParent) { - HTMLSimpleDOM._updateHash(curParent); + curParent.update(); curParent = curParent.parent; } @@ -468,14 +468,14 @@ define(function (require, exports, module) { if (child.children) { _processElement(child); } else if (child.content) { - HTMLSimpleDOM._updateHash(child); + child.update(); child.tagID = HTMLSimpleDOM.getTextNodeID(child); nodeMap[child.tagID] = child; } }); - HTMLSimpleDOM._updateHash(elem); + elem.update(); nodeMap[elem.tagID] = elem; diff --git a/src/language/HTMLSimpleDOM.js b/src/language/HTMLSimpleDOM.js index 4b250d6445c..81234e0edaa 100644 --- a/src/language/HTMLSimpleDOM.js +++ b/src/language/HTMLSimpleDOM.js @@ -124,33 +124,70 @@ define(function (require, exports, module) { this.startOffsetPos = startOffsetPos || {line: 0, ch: 0}; } - function _updateHash(node) { - if (node.children) { - var i, - subtreeHashes = "", - childHashes = "", - child; - for (i = 0; i < node.children.length; i++) { - child = node.children[i]; - if (child.children) { - childHashes += String(child.tagID); - subtreeHashes += String(child.tagID) + child.attributeSignature + child.subtreeSignature; - } else { - childHashes += child.textSignature; - subtreeHashes += child.textSignature; + function SimpleNode(properties) { + $.extend(this, properties); + } + + SimpleNode.prototype = { + + /** + * Updates signatures used to optimize the number of comparisons done during + * diffing. This is important to call if you change: + * + * * children + * * child node attributes + * * text content of a text node + * * child node text + */ + update: function () { + if (this.children) { + var i, + subtreeHashes = "", + childHashes = "", + child; + for (i = 0; i < this.children.length; i++) { + child = this.children[i]; + if (child.children) { + childHashes += String(child.tagID); + subtreeHashes += String(child.tagID) + child.attributeSignature + child.subtreeSignature; + } else { + childHashes += child.textSignature; + subtreeHashes += child.textSignature; + } } + this.childSignature = MurmurHash3.hashString(childHashes, childHashes.length, seed); + this.subtreeSignature = MurmurHash3.hashString(subtreeHashes, subtreeHashes.length, seed); + } else { + this.textSignature = MurmurHash3.hashString(this.content, this.content.length, seed); } - node.childSignature = MurmurHash3.hashString(childHashes, childHashes.length, seed); - node.subtreeSignature = MurmurHash3.hashString(subtreeHashes, subtreeHashes.length, seed); - } else { - node.textSignature = MurmurHash3.hashString(node.content, node.content.length, seed); + }, + + /** + * Updates the signature of this node's attributes. Call this after making attribute changes. + */ + updateAttributeSignature: function () { + var attributeString = JSON.stringify(this.attributes); + this.attributeSignature = MurmurHash3.hashString(attributeString, attributeString.length, seed); + }, + + /** + * Is this node an element node? + * + * @return {bool} true if it is an element + */ + isElement: function () { + return !!this.children; + }, + + /** + * Is this node a text node? + * + * @return {bool} true if it is text + */ + isText: function () { + return !this.children; } - } - - function _updateAttributeHash(node) { - var attributeString = JSON.stringify(node.attributes); - node.attributeSignature = MurmurHash3.hashString(attributeString, attributeString.length, seed); - } + }; /** * Generates a synthetic ID for text nodes. These IDs are only used @@ -200,7 +237,7 @@ define(function (require, exports, module) { function closeTag(endIndex, endPos) { lastClosedTag = stack[stack.length - 1]; stack.pop(); - _updateHash(lastClosedTag); + lastClosedTag.update(); lastClosedTag.end = self.startOffset + endIndex; lastClosedTag.endPos = addPos(self.startOffsetPos, endPos); @@ -231,14 +268,14 @@ define(function (require, exports, module) { } } - newTag = { + newTag = new SimpleNode({ tag: token.contents.toLowerCase(), children: [], attributes: {}, parent: (stack.length ? stack[stack.length - 1] : null), start: this.startOffset + token.start - 1, startPos: addPos(this.startOffsetPos, offsetPos(token.startPos, -1)) // ok because we know the previous char was a "<" - }; + }); newTag.tagID = this.getID(newTag, markCache); // During undo in particular, it's possible that tag IDs may be reused and @@ -256,7 +293,7 @@ define(function (require, exports, module) { if (voidElements.hasOwnProperty(newTag.tag)) { // This is a self-closing element. - _updateHash(newTag); + newTag.update(); } else { stack.push(newTag); } @@ -275,7 +312,7 @@ define(function (require, exports, module) { this.currentTag.end = this.startOffset + token.end; this.currentTag.endPos = addPos(this.startOffsetPos, token.endPos); lastClosedTag = this.currentTag; - _updateAttributeHash(this.currentTag); + this.currentTag.updateAttributeSignature(); this.currentTag = null; } } else if (token.type === "closetag") { @@ -337,17 +374,17 @@ define(function (require, exports, module) { newNode = lastTextNode; newNode.content += token.contents; } else { - newNode = { + newNode = new SimpleNode({ parent: stack[stack.length - 1], content: token.contents - }; + }); parent.children.push(newNode); newNode.tagID = getTextNodeID(newNode); nodeMap[newNode.tagID] = newNode; lastTextNode = newNode; } - _updateHash(newNode); + newNode.update(); } } lastIndex = token.end; @@ -435,7 +472,6 @@ define(function (require, exports, module) { exports._dumpDOM = _dumpDOM; exports.build = build; exports._offsetPos = offsetPos; - exports._updateHash = _updateHash; exports._getTextNodeID = getTextNodeID; exports._seed = seed; exports.Builder = Builder;