visual-dom-diff
Version:
Highlight differences between two DOM trees.
491 lines (490 loc) • 19.5 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
var diff_match_patch_1 = require("diff-match-patch");
var config_1 = require("./config");
var domIterator_1 = require("./domIterator");
var util_1 = require("./util");
/**
* A simple helper which allows us to treat TH as TD in certain situations.
*/
var nodeNameOverride = function (nodeName) {
return nodeName === 'TH' ? 'TD' : nodeName;
};
/**
* Stringifies a DOM node recursively. Text nodes are represented by their `data`,
* while all other nodes are represented by a single Unicode code point
* from the Private Use Area of the Basic Multilingual Plane.
*/
var serialize = function (root, config) {
return new domIterator_1.DomIterator(root, config).reduce(function (text, node) {
return text +
(util_1.isText(node)
? node.data
: util_1.charForNodeName(nodeNameOverride(node.nodeName)));
}, '');
};
var getLength = function (node) { return (util_1.isText(node) ? node.length : 1); };
var isTr = function (node) { return node.nodeName === 'TR'; };
var isNotTr = function (node) { return !isTr(node); };
var trIteratorOptions = {
skipChildren: isTr,
skipSelf: isNotTr,
};
function visualDomDiff(oldRootNode, newRootNode, options) {
var _a, _b;
if (options === void 0) { options = {}; }
// Define config and simple helpers.
var document = newRootNode.ownerDocument || newRootNode;
var config = config_1.optionsToConfig(options);
var addedClass = config.addedClass, diffText = config.diffText, modifiedClass = config.modifiedClass, removedClass = config.removedClass, skipSelf = config.skipSelf, skipChildren = config.skipChildren;
var notSkipSelf = function (node) { return !skipSelf(node); };
var getDepth = function (node, rootNode) {
return util_1.getAncestors(node, rootNode).filter(notSkipSelf).length;
};
var isFormattingNode = function (node) {
return util_1.isElement(node) && skipSelf(node);
};
var getFormattingAncestors = function (node, rootNode) {
return util_1.getAncestors(node, rootNode)
.filter(isFormattingNode)
.reverse();
};
var getColumnValue = function (node) {
return addedNodes.has(node) ? 1 : removedNodes.has(node) ? -1 : 0;
};
// Input iterators.
var diffArray = diffText(serialize(oldRootNode, config), serialize(newRootNode, config));
var diffIndex = 0;
var oldIterator = new domIterator_1.DomIterator(oldRootNode, config);
var newIterator = new domIterator_1.DomIterator(newRootNode, config);
// Input variables produced by the input iterators.
var oldDone;
var newDone;
var diffItem;
var oldNode;
var newNode;
var diffOffset = 0;
var oldOffset = 0;
var newOffset = 0;
diffItem = diffArray[diffIndex++];
(_a = oldIterator.next(), oldDone = _a.done, oldNode = _a.value);
(_b = newIterator.next(), newDone = _b.done, newNode = _b.value);
// Output variables.
var rootOutputNode = document.createDocumentFragment();
var oldOutputNode = rootOutputNode;
var oldOutputDepth = 0;
var newOutputNode = rootOutputNode;
var newOutputDepth = 0;
var removedNode = null;
var addedNode = null;
var removedNodes = new Set();
var addedNodes = new Set();
var modifiedNodes = new Set();
var formattingMap = new Map();
var equalTables = new Array();
var equalRows = new Map();
function prepareOldOutput() {
var depth = getDepth(oldNode, oldRootNode);
while (oldOutputDepth > depth) {
/* istanbul ignore if */
if (!oldOutputNode.parentNode) {
return util_1.never();
}
if (oldOutputNode === removedNode) {
removedNode = null;
}
oldOutputNode = oldOutputNode.parentNode;
oldOutputDepth--;
}
/* istanbul ignore if */
if (oldOutputDepth !== depth) {
return util_1.never();
}
}
function prepareNewOutput() {
var depth = getDepth(newNode, newRootNode);
while (newOutputDepth > depth) {
/* istanbul ignore if */
if (!newOutputNode.parentNode) {
return util_1.never();
}
if (newOutputNode === addedNode) {
addedNode = null;
}
newOutputNode = newOutputNode.parentNode;
newOutputDepth--;
}
/* istanbul ignore if */
if (newOutputDepth !== depth) {
return util_1.never();
}
}
function appendCommonChild(node) {
/* istanbul ignore if */
if (oldOutputNode !== newOutputNode || addedNode || removedNode) {
return util_1.never();
}
if (util_1.isText(node)) {
var oldFormatting = getFormattingAncestors(oldNode, oldRootNode);
var newFormatting = getFormattingAncestors(newNode, newRootNode);
formattingMap.set(node, newFormatting);
var length_1 = oldFormatting.length;
if (length_1 !== newFormatting.length) {
modifiedNodes.add(node);
}
else {
for (var i = 0; i < length_1; ++i) {
if (!util_1.areNodesEqual(oldFormatting[i], newFormatting[i])) {
modifiedNodes.add(node);
break;
}
}
}
}
else {
if (!util_1.areNodesEqual(oldNode, newNode)) {
modifiedNodes.add(node);
}
var nodeName = oldNode.nodeName;
if (nodeName === 'TABLE') {
equalTables.push({
newTable: newNode,
oldTable: oldNode,
outputTable: node,
});
}
else if (nodeName === 'TR') {
equalRows.set(node, {
newRow: newNode,
oldRow: oldNode,
});
}
}
newOutputNode.appendChild(node);
oldOutputNode = node;
newOutputNode = node;
oldOutputDepth++;
newOutputDepth++;
}
function appendOldChild(node) {
if (!removedNode) {
removedNode = node;
removedNodes.add(node);
}
if (util_1.isText(node)) {
var oldFormatting = getFormattingAncestors(oldNode, oldRootNode);
formattingMap.set(node, oldFormatting);
}
oldOutputNode.appendChild(node);
oldOutputNode = node;
oldOutputDepth++;
}
function appendNewChild(node) {
if (!addedNode) {
addedNode = node;
addedNodes.add(node);
}
if (util_1.isText(node)) {
var newFormatting = getFormattingAncestors(newNode, newRootNode);
formattingMap.set(node, newFormatting);
}
newOutputNode.appendChild(node);
newOutputNode = node;
newOutputDepth++;
}
function nextDiff(step) {
var length = diffItem[1].length;
diffOffset += step;
if (diffOffset === length) {
diffItem = diffArray[diffIndex++];
diffOffset = 0;
}
else {
/* istanbul ignore if */
if (diffOffset > length) {
return util_1.never();
}
}
}
function nextOld(step) {
var _a;
var length = getLength(oldNode);
oldOffset += step;
if (oldOffset === length) {
;
(_a = oldIterator.next(), oldDone = _a.done, oldNode = _a.value);
oldOffset = 0;
}
else {
/* istanbul ignore if */
if (oldOffset > length) {
return util_1.never();
}
}
}
function nextNew(step) {
var _a;
var length = getLength(newNode);
newOffset += step;
if (newOffset === length) {
;
(_a = newIterator.next(), newDone = _a.done, newNode = _a.value);
newOffset = 0;
}
else {
/* istanbul ignore if */
if (newOffset > length) {
return util_1.never();
}
}
}
// Copy all content from oldRootNode and newRootNode to rootOutputNode,
// while deduplicating identical content.
// Difference markers and formatting are excluded at this stage.
while (diffItem) {
if (diffItem[0] === diff_match_patch_1.DIFF_DELETE) {
/* istanbul ignore if */
if (oldDone) {
return util_1.never();
}
prepareOldOutput();
var length_2 = Math.min(diffItem[1].length - diffOffset, getLength(oldNode) - oldOffset);
var text = diffItem[1].substring(diffOffset, diffOffset + length_2);
appendOldChild(util_1.isText(oldNode)
? document.createTextNode(text)
: oldNode.cloneNode(false));
nextDiff(length_2);
nextOld(length_2);
}
else if (diffItem[0] === diff_match_patch_1.DIFF_INSERT) {
/* istanbul ignore if */
if (newDone) {
return util_1.never();
}
prepareNewOutput();
var length_3 = Math.min(diffItem[1].length - diffOffset, getLength(newNode) - newOffset);
var text = diffItem[1].substring(diffOffset, diffOffset + length_3);
appendNewChild(util_1.isText(newNode)
? document.createTextNode(text)
: newNode.cloneNode(false));
nextDiff(length_3);
nextNew(length_3);
}
else {
/* istanbul ignore if */
if (oldDone || newDone) {
return util_1.never();
}
prepareOldOutput();
prepareNewOutput();
var length_4 = Math.min(diffItem[1].length - diffOffset, getLength(oldNode) - oldOffset, getLength(newNode) - newOffset);
var text = diffItem[1].substring(diffOffset, diffOffset + length_4);
if (oldOutputNode === newOutputNode &&
((util_1.isText(oldNode) && util_1.isText(newNode)) ||
(nodeNameOverride(oldNode.nodeName) ===
nodeNameOverride(newNode.nodeName) &&
!skipChildren(oldNode) &&
!skipChildren(newNode)) ||
util_1.areNodesEqual(oldNode, newNode))) {
appendCommonChild(util_1.isText(newNode)
? document.createTextNode(text)
: newNode.cloneNode(false));
}
else {
appendOldChild(util_1.isText(oldNode)
? document.createTextNode(text)
: oldNode.cloneNode(false));
appendNewChild(util_1.isText(newNode)
? document.createTextNode(text)
: newNode.cloneNode(false));
}
nextDiff(length_4);
nextOld(length_4);
nextNew(length_4);
}
}
// Move deletes before inserts.
removedNodes.forEach(function (node) {
var parentNode = node.parentNode;
var previousSibling = node.previousSibling;
while (previousSibling && addedNodes.has(previousSibling)) {
parentNode.insertBefore(node, previousSibling);
previousSibling = node.previousSibling;
}
});
// Ensure a user friendly result for tables.
equalTables.forEach(function (equalTable) {
var newTable = equalTable.newTable, oldTable = equalTable.oldTable, outputTable = equalTable.outputTable;
// Handle tables which can't be diffed nicely.
if (!util_1.isTableValid(oldTable, true) ||
!util_1.isTableValid(newTable, true) ||
!util_1.isTableValid(outputTable, false)) {
// Remove all values which were previously recorded for outputTable.
new domIterator_1.DomIterator(outputTable).forEach(function (node) {
addedNodes.delete(node);
removedNodes.delete(node);
modifiedNodes.delete(node);
formattingMap.delete(node);
});
// Display both the old and new table.
var parentNode = outputTable.parentNode;
var oldTableClone = oldTable.cloneNode(true);
var newTableClone = newTable.cloneNode(true);
parentNode.insertBefore(oldTableClone, outputTable);
parentNode.insertBefore(newTableClone, outputTable);
parentNode.removeChild(outputTable);
removedNodes.add(oldTableClone);
addedNodes.add(newTableClone);
return;
}
// Figure out which columns have been added or removed
// based on the first row appearing in both tables.
//
// - 1: column added
// - 0: column equal
// - -1: column removed
var columns = [];
new domIterator_1.DomIterator(outputTable, trIteratorOptions).some(function (row) {
var diffedRows = equalRows.get(row);
if (!diffedRows) {
return false;
}
var oldRow = diffedRows.oldRow, newRow = diffedRows.newRow;
var oldColumnCount = oldRow.childNodes.length;
var newColumnCount = newRow.childNodes.length;
var maxColumnCount = Math.max(oldColumnCount, newColumnCount);
var minColumnCount = Math.min(oldColumnCount, newColumnCount);
if (row.childNodes.length === maxColumnCount) {
// The generic diff algorithm worked properly in this case,
// so we can rely on its results.
var cells = row.childNodes;
for (var i = 0, l = cells.length; i < l; ++i) {
columns.push(getColumnValue(cells[i]));
}
}
else {
// Fallback to a simple but correct algorithm.
var i = 0;
var columnValue = 0;
while (i < minColumnCount) {
columns[i++] = columnValue;
}
columnValue = oldColumnCount < newColumnCount ? 1 : -1;
while (i < maxColumnCount) {
columns[i++] = columnValue;
}
}
return true;
});
var columnCount = columns.length;
/* istanbul ignore if */
if (columnCount === 0) {
return util_1.never();
}
// Fix up the rows which do not align with `columns`.
new domIterator_1.DomIterator(outputTable, trIteratorOptions).forEach(function (row) {
var cells = row.childNodes;
if (addedNodes.has(row) || addedNodes.has(row.parentNode)) {
if (cells.length < columnCount) {
for (var i = 0; i < columnCount; ++i) {
if (columns[i] === -1) {
var td = document.createElement('TD');
row.insertBefore(td, cells[i]);
removedNodes.add(td);
}
}
}
}
else if (removedNodes.has(row) ||
removedNodes.has(row.parentNode)) {
if (cells.length < columnCount) {
for (var i = 0; i < columnCount; ++i) {
if (columns[i] === 1) {
var td = document.createElement('TD');
row.insertBefore(td, cells[i]);
}
}
}
}
else {
// Check, if the columns in this row are aligned with those in the reference row.
var isAligned = true;
for (var i = 0, l = cells.length; i < l; ++i) {
if (getColumnValue(cells[i]) !== columns[i]) {
isAligned = false;
break;
}
}
if (!isAligned) {
// Remove all values which were previously recorded for row's content.
var iterator = new domIterator_1.DomIterator(row);
iterator.next(); // Skip the row itself.
iterator.forEach(function (node) {
addedNodes.delete(node);
removedNodes.delete(node);
modifiedNodes.delete(node);
formattingMap.delete(node);
});
// Remove the row's content.
while (row.firstChild) {
row.removeChild(row.firstChild);
}
// Diff the individual cells.
var _a = equalRows.get(row), newRow = _a.newRow, oldRow = _a.oldRow;
var newCells = newRow.childNodes;
var oldCells = oldRow.childNodes;
var oldIndex = 0;
var newIndex = 0;
for (var i = 0; i < columnCount; ++i) {
if (columns[i] === 1) {
var newCellClone = newCells[newIndex++].cloneNode(true);
row.appendChild(newCellClone);
addedNodes.add(newCellClone);
}
else if (columns[i] === -1) {
var oldCellClone = oldCells[oldIndex++].cloneNode(true);
row.appendChild(oldCellClone);
removedNodes.add(oldCellClone);
}
else {
row.appendChild(visualDomDiff(oldCells[oldIndex++], newCells[newIndex++], options));
}
}
}
}
});
return;
});
// Mark up the content which has been removed.
removedNodes.forEach(function (node) {
util_1.markUpNode(node, 'DEL', removedClass);
});
// Mark up the content which has been added.
addedNodes.forEach(function (node) {
util_1.markUpNode(node, 'INS', addedClass);
});
// Mark up the content which has been modified.
if (!config.skipModified) {
modifiedNodes.forEach(function (modifiedNode) {
util_1.markUpNode(modifiedNode, 'INS', modifiedClass);
});
}
// Add formatting.
formattingMap.forEach(function (formattingNodes, textNode) {
formattingNodes.forEach(function (formattingNode) {
var parentNode = textNode.parentNode;
var previousSibling = textNode.previousSibling;
if (previousSibling &&
util_1.areNodesEqual(previousSibling, formattingNode)) {
previousSibling.appendChild(textNode);
}
else {
var clonedFormattingNode = formattingNode.cloneNode(false);
parentNode.insertBefore(clonedFormattingNode, textNode);
clonedFormattingNode.appendChild(textNode);
}
});
});
return rootOutputNode;
}
exports.visualDomDiff = visualDomDiff;