diff-dom
Version:
A diff for DOM elements, as client-side JavaScript code. Gets all modifications, insertions and removals between two DOM fragments.
350 lines (326 loc) • 12.9 kB
text/typescript
import { DiffDOMOptions, elementNodeType, nodeType, subsetType } from "../types"
import { Diff } from "../helpers"
import { cleanNode } from "./helpers"
// ===== Apply a virtual diff =====
function getFromVirtualRoute(tree: elementNodeType, route: number[]) {
let node = tree
let parentNode
let nodeIndex
route = route.slice()
while (route.length > 0) {
nodeIndex = route.splice(0, 1)[0]
parentNode = node
node = node.childNodes ? node.childNodes[nodeIndex] : undefined
}
return {
node,
parentNode,
nodeIndex,
}
}
function applyVirtualDiff(
tree: elementNodeType,
diff: Diff,
options: DiffDOMOptions, // {preVirtualDiffApply, postVirtualDiffApply, _const}
) {
let node, parentNode, nodeIndex
if (
![options._const.addElement, options._const.addTextElement].includes(
diff[options._const.action],
)
) {
// For adding nodes, we calculate the route later on. It's different because it includes the position of the newly added item.
const routeInfo = getFromVirtualRoute(tree, diff[options._const.route])
node = routeInfo.node
parentNode = routeInfo.parentNode
nodeIndex = routeInfo.nodeIndex
}
const newSubsets: subsetType[] = []
// pre-diff hook
const info = {
diff,
node,
}
if (options.preVirtualDiffApply(info)) {
return true
}
let newNode
let nodeArray
let route
switch (diff[options._const.action]) {
case options._const.addAttribute:
if (!node.attributes) {
node.attributes = {}
}
node.attributes[diff[options._const.name]] =
diff[options._const.value]
if (diff[options._const.name] === "checked") {
node.checked = true
} else if (diff[options._const.name] === "selected") {
node.selected = true
} else if (
node.nodeName === "INPUT" &&
diff[options._const.name] === "value"
) {
node.value = diff[options._const.value]
}
break
case options._const.modifyAttribute:
node.attributes[diff[options._const.name]] =
diff[options._const.newValue]
break
case options._const.removeAttribute:
delete node.attributes[diff[options._const.name]]
if (Object.keys(node.attributes).length === 0) {
delete node.attributes
}
if (diff[options._const.name] === "checked") {
node.checked = false
} else if (diff[options._const.name] === "selected") {
delete node.selected
} else if (
node.nodeName === "INPUT" &&
diff[options._const.name] === "value"
) {
delete node.value
}
break
case options._const.modifyTextElement:
node.data = diff[options._const.newValue]
if (parentNode.nodeName === "TEXTAREA") {
parentNode.value = diff[options._const.newValue]
}
break
case options._const.modifyValue:
node.value = diff[options._const.newValue]
break
case options._const.modifyComment:
node.data = diff[options._const.newValue]
break
case options._const.modifyChecked:
node.checked = diff[options._const.newValue]
break
case options._const.modifySelected:
node.selected = diff[options._const.newValue]
break
case options._const.replaceElement:
newNode = cleanNode(diff[options._const.newValue])
parentNode.childNodes[nodeIndex] = newNode
break
case options._const.relocateGroup:
nodeArray = node.childNodes
.splice(
diff[options._const.from],
diff[options._const.groupLength],
)
.reverse()
nodeArray.forEach((movedNode: nodeType) =>
node.childNodes.splice(diff[options._const.to], 0, movedNode),
)
if (node.subsets) {
node.subsets.forEach((map: subsetType) => {
if (
diff[options._const.from] < diff[options._const.to] &&
map.oldValue <= diff[options._const.to] &&
map.oldValue > diff[options._const.from]
) {
map.oldValue -= diff[options._const.groupLength]
const splitLength =
map.oldValue + map.length - diff[options._const.to]
if (splitLength > 0) {
// new insertion splits map.
newSubsets.push({
oldValue:
diff[options._const.to] +
diff[options._const.groupLength],
newValue:
map.newValue + map.length - splitLength,
length: splitLength,
})
map.length -= splitLength
}
} else if (
diff[options._const.from] > diff[options._const.to] &&
map.oldValue > diff[options._const.to] &&
map.oldValue < diff[options._const.from]
) {
map.oldValue += diff[options._const.groupLength]
const splitLength =
map.oldValue + map.length - diff[options._const.to]
if (splitLength > 0) {
// new insertion splits map.
newSubsets.push({
oldValue:
diff[options._const.to] +
diff[options._const.groupLength],
newValue:
map.newValue + map.length - splitLength,
length: splitLength,
})
map.length -= splitLength
}
} else if (map.oldValue === diff[options._const.from]) {
map.oldValue = diff[options._const.to]
}
})
}
break
case options._const.removeElement:
parentNode.childNodes.splice(nodeIndex, 1)
if (parentNode.subsets) {
parentNode.subsets.forEach((map: subsetType) => {
if (map.oldValue > nodeIndex) {
map.oldValue -= 1
} else if (map.oldValue === nodeIndex) {
map.delete = true
} else if (
map.oldValue < nodeIndex &&
map.oldValue + map.length > nodeIndex
) {
if (map.oldValue + map.length - 1 === nodeIndex) {
map.length--
} else {
newSubsets.push({
newValue:
map.newValue + nodeIndex - map.oldValue,
oldValue: nodeIndex,
length:
map.length - nodeIndex + map.oldValue - 1,
})
map.length = nodeIndex - map.oldValue
}
}
})
}
node = parentNode
break
case options._const.addElement: {
route = diff[options._const.route].slice()
const c: number = route.splice(route.length - 1, 1)[0]
node = getFromVirtualRoute(tree, route)?.node
newNode = cleanNode(diff[options._const.element])
if (!node.childNodes) {
node.childNodes = []
}
if (c >= node.childNodes.length) {
node.childNodes.push(newNode)
} else {
node.childNodes.splice(c, 0, newNode)
}
if (node.subsets) {
node.subsets.forEach((map: subsetType) => {
if (map.oldValue >= c) {
map.oldValue += 1
} else if (
map.oldValue < c &&
map.oldValue + map.length > c
) {
const splitLength = map.oldValue + map.length - c
newSubsets.push({
newValue: map.newValue + map.length - splitLength,
oldValue: c + 1,
length: splitLength,
})
map.length -= splitLength
}
})
}
break
}
case options._const.removeTextElement:
parentNode.childNodes.splice(nodeIndex, 1)
if (parentNode.nodeName === "TEXTAREA") {
delete parentNode.value
}
if (parentNode.subsets) {
parentNode.subsets.forEach((map: subsetType) => {
if (map.oldValue > nodeIndex) {
map.oldValue -= 1
} else if (map.oldValue === nodeIndex) {
map.delete = true
} else if (
map.oldValue < nodeIndex &&
map.oldValue + map.length > nodeIndex
) {
if (map.oldValue + map.length - 1 === nodeIndex) {
map.length--
} else {
newSubsets.push({
newValue:
map.newValue + nodeIndex - map.oldValue,
oldValue: nodeIndex,
length:
map.length - nodeIndex + map.oldValue - 1,
})
map.length = nodeIndex - map.oldValue
}
}
})
}
node = parentNode
break
case options._const.addTextElement: {
route = diff[options._const.route].slice()
const c: number = route.splice(route.length - 1, 1)[0]
newNode = {
nodeName: "#text",
data: diff[options._const.value],
}
node = getFromVirtualRoute(tree, route).node
if (!node.childNodes) {
node.childNodes = []
}
if (c >= node.childNodes.length) {
node.childNodes.push(newNode)
} else {
node.childNodes.splice(c, 0, newNode)
}
if (node.nodeName === "TEXTAREA") {
node.value = diff[options._const.newValue]
}
if (node.subsets) {
node.subsets.forEach((map: subsetType) => {
if (map.oldValue >= c) {
map.oldValue += 1
}
if (map.oldValue < c && map.oldValue + map.length > c) {
const splitLength = map.oldValue + map.length - c
newSubsets.push({
newValue: map.newValue + map.length - splitLength,
oldValue: c + 1,
length: splitLength,
})
map.length -= splitLength
}
})
}
break
}
default:
console.log("unknown action")
}
if (node.subsets) {
node.subsets = node.subsets.filter(
(map: subsetType) => !map.delete && map.oldValue !== map.newValue,
)
if (newSubsets.length) {
node.subsets = node.subsets.concat(newSubsets)
}
}
options.postVirtualDiffApply({
node: info.node,
diff: info.diff,
newNode,
})
return
}
export function applyVirtual(
tree: elementNodeType,
diffs: Diff[],
options: DiffDOMOptions,
) {
diffs.forEach((diff: Diff) => {
applyVirtualDiff(tree, diff, options)
})
return true
}