UNPKG

diff-dom

Version:

A diff for DOM elements, as client-side JavaScript code. Gets all modifications, insertions and removals between two DOM fragments.

462 lines (417 loc) 13.8 kB
import { diffNodeType, elementDiffNodeType, elementNodeType, nodeType, subsetType, textDiffNodeType, textNodeType, } from "../types" import { Diff } from "../helpers" const elementDescriptors = (el: diffNodeType) => { const output = [] output.push(el.nodeName) if (el.nodeName !== "#text" && el.nodeName !== "#comment") { el = el as elementDiffNodeType if (el.attributes) { if (el.attributes["class"]) { output.push( `${el.nodeName}.${el.attributes["class"].replace( / /g, ".", )}`, ) } if (el.attributes.id) { output.push(`${el.nodeName}#${el.attributes.id}`) } } } return output } const findUniqueDescriptors = (li: diffNodeType[]) => { const uniqueDescriptors = {} const duplicateDescriptors = {} li.forEach((node: nodeType) => { elementDescriptors(node).forEach((descriptor) => { const inUnique = descriptor in uniqueDescriptors const inDupes = descriptor in duplicateDescriptors if (!inUnique && !inDupes) { uniqueDescriptors[descriptor] = true } else if (inUnique) { delete uniqueDescriptors[descriptor] duplicateDescriptors[descriptor] = true } }) }) return uniqueDescriptors } export const uniqueInBoth = (l1: diffNodeType[], l2: diffNodeType[]) => { const l1Unique = findUniqueDescriptors(l1) const l2Unique = findUniqueDescriptors(l2) const inBoth = {} Object.keys(l1Unique).forEach((key) => { if (l2Unique[key]) { inBoth[key] = true } }) return inBoth } export const removeDone = (tree: elementDiffNodeType) => { delete tree.outerDone delete tree.innerDone delete tree.valueDone if (tree.childNodes) { return tree.childNodes.every(removeDone) } else { return true } } export const cleanNode = (diffNode: diffNodeType) => { if (Object.prototype.hasOwnProperty.call(diffNode, "data")) { const textNode: textNodeType = { nodeName: diffNode.nodeName === "#text" ? "#text" : "#comment", data: (diffNode as textDiffNodeType).data, } return textNode } else { const elementNode: elementNodeType = { nodeName: diffNode.nodeName, } diffNode = diffNode as elementDiffNodeType if (Object.prototype.hasOwnProperty.call(diffNode, "attributes")) { elementNode.attributes = { ...diffNode.attributes } } if (Object.prototype.hasOwnProperty.call(diffNode, "checked")) { elementNode.checked = diffNode.checked } if (Object.prototype.hasOwnProperty.call(diffNode, "value")) { elementNode.value = diffNode.value } if (Object.prototype.hasOwnProperty.call(diffNode, "selected")) { elementNode.selected = diffNode.selected } if (Object.prototype.hasOwnProperty.call(diffNode, "childNodes")) { elementNode.childNodes = diffNode.childNodes.map((diffChildNode) => cleanNode(diffChildNode), ) } return elementNode } } export const isEqual = (e1: diffNodeType, e2: diffNodeType) => { if ( !["nodeName", "value", "checked", "selected", "data"].every( (element) => { if (e1[element] !== e2[element]) { return false } return true }, ) ) { return false } if (Object.prototype.hasOwnProperty.call(e1, "data")) { // Comment or Text return true } e1 = e1 as elementDiffNodeType e2 = e2 as elementDiffNodeType if (Boolean(e1.attributes) !== Boolean(e2.attributes)) { return false } if (Boolean(e1.childNodes) !== Boolean(e2.childNodes)) { return false } if (e1.attributes) { const e1Attributes = Object.keys(e1.attributes) const e2Attributes = Object.keys(e2.attributes) if (e1Attributes.length !== e2Attributes.length) { return false } if ( !e1Attributes.every((attribute) => { if ( (e1 as elementDiffNodeType).attributes[attribute] !== (e2 as elementDiffNodeType).attributes[attribute] ) { return false } return true }) ) { return false } } if (e1.childNodes) { if (e1.childNodes.length !== e2.childNodes.length) { return false } if ( !e1.childNodes.every((childNode: nodeType, index: number) => isEqual(childNode, e2.childNodes[index]), ) ) { return false } } return true } export const roughlyEqual = ( e1: diffNodeType, e2: diffNodeType, uniqueDescriptors: { [key: string]: boolean }, sameSiblings: boolean, preventRecursion = false, ) => { if (!e1 || !e2) { return false } if (e1.nodeName !== e2.nodeName) { return false } if (["#text", "#comment"].includes(e1.nodeName)) { // Note that we initially don't care what the text content of a node is, // the mere fact that it's the same tag and "has text" means it's roughly // equal, and then we can find out the true text difference later. return preventRecursion ? true : (e1 as textDiffNodeType).data === (e2 as textDiffNodeType).data } e1 = e1 as elementDiffNodeType e2 = e2 as elementDiffNodeType if (e1.nodeName in uniqueDescriptors) { return true } if (e1.attributes && e2.attributes) { if (e1.attributes.id) { if (e1.attributes.id !== e2.attributes.id) { return false } else { const idDescriptor = `${e1.nodeName}#${e1.attributes.id}` if (idDescriptor in uniqueDescriptors) { return true } } } if ( e1.attributes["class"] && e1.attributes["class"] === e2.attributes["class"] ) { const classDescriptor = `${e1.nodeName}.${e1.attributes[ "class" ].replace(/ /g, ".")}` if (classDescriptor in uniqueDescriptors) { return true } } } if (sameSiblings) { return true } const nodeList1 = e1.childNodes ? e1.childNodes.slice().reverse() : [] const nodeList2 = e2.childNodes ? e2.childNodes.slice().reverse() : [] if (nodeList1.length !== nodeList2.length) { return false } if (preventRecursion) { return nodeList1.every( (element: nodeType, index: number) => element.nodeName === nodeList2[index].nodeName, ) } else { // note: we only allow one level of recursion at any depth. If 'preventRecursion' // was not set, we must explicitly force it to true for child iterations. const childUniqueDescriptors = uniqueInBoth(nodeList1, nodeList2) return nodeList1.every((element: nodeType, index: number) => roughlyEqual( element, nodeList2[index], childUniqueDescriptors, true, true, ), ) } } /** * based on https://en.wikibooks.org/wiki/Algorithm_implementation/Strings/Longest_common_substring#JavaScript */ const findCommonSubsets = ( c1: diffNodeType[], c2: diffNodeType[], marked1: boolean[], marked2: boolean[], ) => { let lcsSize = 0 let index: number[] = [] const c1Length = c1.length const c2Length = c2.length const // set up the matching table matches = [...new Array(c1Length + 1)].map(() => []) const uniqueDescriptors = uniqueInBoth(c1, c2) let // If all of the elements are the same tag, id and class, then we can // consider them roughly the same even if they have a different number of // children. This will reduce removing and re-adding similar elements. subsetsSame = c1Length === c2Length if (subsetsSame) { c1.some((element: nodeType, i: number) => { const c1Desc = elementDescriptors(element) const c2Desc = elementDescriptors(c2[i]) if (c1Desc.length !== c2Desc.length) { subsetsSame = false return true } c1Desc.some((description, i) => { if (description !== c2Desc[i]) { subsetsSame = false return true } }) if (!subsetsSame) { return true } }) } // fill the matches with distance values for (let c1Index = 0; c1Index < c1Length; c1Index++) { const c1Element = c1[c1Index] for (let c2Index = 0; c2Index < c2Length; c2Index++) { const c2Element = c2[c2Index] if ( !marked1[c1Index] && !marked2[c2Index] && roughlyEqual( c1Element, c2Element, uniqueDescriptors, subsetsSame, ) ) { matches[c1Index + 1][c2Index + 1] = matches[c1Index][c2Index] ? matches[c1Index][c2Index] + 1 : 1 if (matches[c1Index + 1][c2Index + 1] >= lcsSize) { lcsSize = matches[c1Index + 1][c2Index + 1] index = [c1Index + 1, c2Index + 1] } } else { matches[c1Index + 1][c2Index + 1] = 0 } } } if (lcsSize === 0) { return false } return { oldValue: index[0] - lcsSize, newValue: index[1] - lcsSize, length: lcsSize, } } const makeBooleanArray = (n: number, v: boolean) => [...new Array(n)].map(() => v) /** * Generate arrays that indicate which node belongs to which subset, * or whether it's actually an orphan node, existing in only one * of the two trees, rather than somewhere in both. * * So if t1 = <img><canvas><br>, t2 = <canvas><br><img>. * The longest subset is "<canvas><br>" (length 2), so it will group 0. * The second longest is "<img>" (length 1), so it will be group 1. * gaps1 will therefore be [1,0,0] and gaps2 [0,0,1]. * * If an element is not part of any group, it will stay being 'true', which * is the initial value. For example: * t1 = <img><p></p><br><canvas>, t2 = <b></b><br><canvas><img> * * The "<p></p>" and "<b></b>" do only show up in one of the two and will * therefore be marked by "true". The remaining parts are parts of the * groups 0 and 1: * gaps1 = [1, true, 0, 0], gaps2 = [true, 0, 0, 1] * */ export const getGapInformation = ( t1: elementDiffNodeType, t2: elementDiffNodeType, stable: subsetType[], ) => { const gaps1: (true | number)[] = t1.childNodes ? (makeBooleanArray(t1.childNodes.length, true) as true[]) : [] const gaps2: (true | number)[] = t2.childNodes ? (makeBooleanArray(t2.childNodes.length, true) as true[]) : [] let group = 0 // give elements from the same subset the same group number stable.forEach((subset: subsetType) => { const endOld = subset.oldValue + subset.length const endNew = subset.newValue + subset.length for (let j = subset.oldValue; j < endOld; j += 1) { gaps1[j] = group } for (let j = subset.newValue; j < endNew; j += 1) { gaps2[j] = group } group += 1 }) return { gaps1, gaps2, } } /** * Find all matching subsets, based on immediate child differences only. */ const markBoth = (marked1, marked2, subset: subsetType, i: number) => { marked1[subset.oldValue + i] = true marked2[subset.newValue + i] = true } export const markSubTrees = ( oldTree: elementDiffNodeType, newTree: elementDiffNodeType, ) => { // note: the child lists are views, and so update as we update old/newTree const oldChildren = oldTree.childNodes ? oldTree.childNodes : [] const newChildren = newTree.childNodes ? newTree.childNodes : [] const marked1 = makeBooleanArray(oldChildren.length, false) const marked2 = makeBooleanArray(newChildren.length, false) const subsets = [] const returnIndex = function () { return arguments[1] } let foundAllSubsets = false while (!foundAllSubsets) { const subset = findCommonSubsets( oldChildren, newChildren, marked1, marked2, ) if (subset) { subsets.push(subset) const subsetArray = [...new Array(subset.length)].map(returnIndex) subsetArray.forEach((item) => markBoth(marked1, marked2, subset, item), ) } else { foundAllSubsets = true } } oldTree.subsets = subsets oldTree.subsetsAge = 100 return subsets } export class DiffTracker { list: Diff[] constructor() { this.list = [] } add(diffs: Diff[]) { this.list.push(...diffs) } forEach(fn: (Diff) => void) { this.list.forEach((li: Diff) => fn(li)) } }