Precise DOM morphing
morphing typescript dom

Introduce Morph class (#17)

authored by joel.drapper.me and committed by

GitHub af23aae1 7505f2f8

+423 -398
+206 -192
dist/morphlex.js
··· 1 1 export function morph(node, reference, options = {}) { 2 - const readonlyReference = reference; 3 - const idMap = new WeakMap(); 4 - const sensitivityMap = new WeakMap(); 5 - if (isParentNode(node) && isParentNode(readonlyReference)) { 6 - populateIdSets(node, idMap); 7 - populateIdSets(readonlyReference, idMap); 8 - populateSensivityMap(node, sensitivityMap); 9 - } 10 - morphNode(node, readonlyReference, { ...options, idMap, sensitivityMap }); 2 + new Morph(options).morph(node, reference); 11 3 } 12 - function populateSensivityMap(node, sensivityMap) { 13 - const sensitiveElements = node.querySelectorAll("iframe,video,object,embed,audio,input,textarea,canvas"); 14 - for (const sensitiveElement of sensitiveElements) { 15 - let sensivity = 0; 16 - if (isInput(sensitiveElement) || isTextArea(sensitiveElement)) { 17 - sensivity += 1; 18 - if (sensitiveElement.value !== sensitiveElement.defaultValue) sensivity += 1; 19 - if (sensitiveElement === document.activeElement) sensivity += 1; 20 - } else { 21 - sensivity += 3; 22 - if (sensitiveElement instanceof HTMLMediaElement && !sensitiveElement.ended) { 23 - if (!sensitiveElement.paused) sensivity += 1; 24 - if (sensitiveElement.currentTime > 0) sensivity += 1; 25 - } 26 - } 27 - let current = sensitiveElement; 28 - while (current) { 29 - sensivityMap.set(current, (sensivityMap.get(current) || 0) + sensivity); 30 - if (current === node) break; 31 - current = current.parentElement; 32 - } 4 + class Morph { 5 + #idMap; 6 + #sensivityMap; 7 + #options; 8 + constructor(options = {}) { 9 + this.#options = options; 10 + this.#idMap = new WeakMap(); 11 + this.#sensivityMap = new WeakMap(); 33 12 } 34 - } 35 - // For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. 36 - function populateIdSets(node, idMap) { 37 - const elementsWithIds = node.querySelectorAll("[id]"); 38 - for (const elementWithId of elementsWithIds) { 39 - const id = elementWithId.id; 40 - // Ignore empty IDs 41 - if (id === "") continue; 42 - let current = elementWithId; 43 - while (current) { 44 - const idSet = idMap.get(current); 45 - idSet ? idSet.add(id) : idMap.set(current, new Set([id])); 46 - if (current === node) break; 47 - current = current.parentElement; 13 + morph(node, reference) { 14 + const readonlyReference = reference; 15 + if (isParentNode(node) && isParentNode(readonlyReference)) { 16 + this.#populateIdSets(node); 17 + this.#populateIdSets(readonlyReference); 18 + this.#populateSensivityMap(node); 48 19 } 20 + this.#morphNode(node, readonlyReference); 49 21 } 50 - } 51 - // This is where we actually morph the nodes. The `morph` function (above) exists only to set up the `idMap`. 52 - function morphNode(node, ref, context) { 53 - if (!(context.beforeNodeMorphed?.({ node, referenceNode: ref }) ?? true)) return; 54 - if (isElement(node) && isElement(ref) && node.tagName === ref.tagName) { 55 - if (node.hasAttributes() || ref.hasAttributes()) morphAttributes(node, ref, context); 56 - if (isHead(node) && isHead(ref)) { 57 - const refChildNodes = new Map(); 58 - for (const child of ref.children) refChildNodes.set(child.outerHTML, child); 59 - for (const child of node.children) { 60 - const key = child.outerHTML; 61 - const refChild = refChildNodes.get(key); 62 - refChild ? refChildNodes.delete(key) : removeNode(child, context); 22 + #populateSensivityMap(node) { 23 + const sensitiveElements = node.querySelectorAll("iframe,video,object,embed,audio,input,textarea,canvas"); 24 + for (const sensitiveElement of sensitiveElements) { 25 + let sensivity = 0; 26 + if (isInput(sensitiveElement) || isTextArea(sensitiveElement)) { 27 + sensivity += 1; 28 + if (sensitiveElement.value !== sensitiveElement.defaultValue) sensivity += 1; 29 + if (sensitiveElement === document.activeElement) sensivity += 1; 30 + } else { 31 + sensivity += 3; 32 + if (sensitiveElement instanceof HTMLMediaElement && !sensitiveElement.ended) { 33 + if (!sensitiveElement.paused) sensivity += 1; 34 + if (sensitiveElement.currentTime > 0) sensivity += 1; 35 + } 63 36 } 64 - for (const refChild of refChildNodes.values()) appendChild(node, refChild.cloneNode(true), context); 65 - } else if (node.hasChildNodes() || ref.hasChildNodes()) morphChildNodes(node, ref, context); 66 - } else { 67 - if (isText(node) && isText(ref)) { 68 - updateProperty(node, "textContent", ref.textContent, context); 69 - } else if (isComment(node) && isComment(ref)) { 70 - updateProperty(node, "nodeValue", ref.nodeValue, context); 71 - } else replaceNode(node, ref.cloneNode(true), context); 72 - } 73 - context.afterNodeMorphed?.({ node }); 74 - } 75 - function morphAttributes(element, ref, context) { 76 - // Remove any excess attributes from the element that aren’t present in the reference. 77 - for (const { name, value } of element.attributes) { 78 - if (!ref.hasAttribute(name) && (context.beforeAttributeUpdated?.({ element, attributeName: name, newValue: null }) ?? true)) { 79 - element.removeAttribute(name); 80 - context.afterAttributeUpdated?.({ element, attributeName: name, previousValue: value }); 37 + let current = sensitiveElement; 38 + while (current) { 39 + this.#sensivityMap.set(current, (this.#sensivityMap.get(current) || 0) + sensivity); 40 + if (current === node) break; 41 + current = current.parentElement; 42 + } 81 43 } 82 44 } 83 - // Copy attributes from the reference to the element, if they don’t already match. 84 - for (const { name, value } of ref.attributes) { 85 - const previousValue = element.getAttribute(name); 86 - if ( 87 - previousValue !== value && 88 - (context.beforeAttributeUpdated?.({ element, attributeName: name, newValue: value }) ?? true) 89 - ) { 90 - element.setAttribute(name, value); 91 - context.afterAttributeUpdated?.({ element, attributeName: name, previousValue }); 45 + // For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. 46 + #populateIdSets(node) { 47 + const elementsWithIds = node.querySelectorAll("[id]"); 48 + for (const elementWithId of elementsWithIds) { 49 + const id = elementWithId.id; 50 + // Ignore empty IDs 51 + if (id === "") continue; 52 + let current = elementWithId; 53 + while (current) { 54 + const idSet = this.#idMap.get(current); 55 + idSet ? idSet.add(id) : this.#idMap.set(current, new Set([id])); 56 + if (current === node) break; 57 + current = current.parentElement; 58 + } 92 59 } 93 60 } 94 - // For certain types of elements, we need to do some extra work to ensure 95 - // the element’s state matches the reference elements’ state. 96 - if (isInput(element) && isInput(ref)) { 97 - updateProperty(element, "checked", ref.checked, context); 98 - updateProperty(element, "disabled", ref.disabled, context); 99 - updateProperty(element, "indeterminate", ref.indeterminate, context); 100 - if ( 101 - element.type !== "file" && 102 - !(context.ignoreActiveValue && document.activeElement === element) && 103 - !(context.preserveModifiedValues && element.value !== element.defaultValue) 104 - ) 105 - updateProperty(element, "value", ref.value, context); 106 - } else if (isOption(element) && isOption(ref)) updateProperty(element, "selected", ref.selected, context); 107 - else if (isTextArea(element) && isTextArea(ref)) { 108 - updateProperty(element, "value", ref.value, context); 109 - // TODO: Do we need this? If so, how do we integrate with the callback? 110 - const text = element.firstChild; 111 - if (text && isText(text)) updateProperty(text, "textContent", ref.value, context); 112 - } 113 - } 114 - // Iterates over the child nodes of the reference element, morphing the main element’s child nodes to match. 115 - function morphChildNodes(element, ref, context) { 116 - const childNodes = element.childNodes; 117 - const refChildNodes = ref.childNodes; 118 - for (let i = 0; i < refChildNodes.length; i++) { 119 - const child = childNodes[i]; 120 - const refChild = refChildNodes[i]; //as ReadonlyNode<ChildNode> | null; 121 - if (child && refChild) { 122 - if (isElement(child) && isElement(refChild)) morphChildElement(child, refChild, element, context); 123 - else morphNode(child, refChild, context); 124 - } else if (refChild) { 125 - appendChild(element, refChild.cloneNode(true), context); 126 - } else if (child) { 127 - removeNode(child, context); 61 + // This is where we actually morph the nodes. The `morph` function (above) exists only to set up the `idMap`. 62 + #morphNode(node, ref) { 63 + if (!(this.#options.beforeNodeMorphed?.({ node, referenceNode: ref }) ?? true)) return; 64 + if (isElement(node) && isElement(ref) && node.tagName === ref.tagName) { 65 + if (node.hasAttributes() || ref.hasAttributes()) this.#morphAttributes(node, ref); 66 + if (isHead(node) && isHead(ref)) { 67 + const refChildNodes = new Map(); 68 + for (const child of ref.children) refChildNodes.set(child.outerHTML, child); 69 + for (const child of node.children) { 70 + const key = child.outerHTML; 71 + const refChild = refChildNodes.get(key); 72 + refChild ? refChildNodes.delete(key) : this.#removeNode(child); 73 + } 74 + for (const refChild of refChildNodes.values()) this.#appendChild(node, refChild.cloneNode(true)); 75 + } else if (node.hasChildNodes() || ref.hasChildNodes()) this.#morphChildNodes(node, ref); 76 + } else { 77 + if (isText(node) && isText(ref)) { 78 + this.#updateProperty(node, "textContent", ref.textContent); 79 + } else if (isComment(node) && isComment(ref)) { 80 + this.#updateProperty(node, "nodeValue", ref.nodeValue); 81 + } else this.#replaceNode(node, ref.cloneNode(true)); 128 82 } 129 - } 130 - // Clean up any excess nodes that may be left over 131 - while (childNodes.length > refChildNodes.length) { 132 - const child = element.lastChild; 133 - if (child) removeNode(child, context); 83 + this.#options.afterNodeMorphed?.({ node }); 134 84 } 135 - } 136 - function updateProperty(node, propertyName, newValue, context) { 137 - const previousValue = node[propertyName]; 138 - if (previousValue !== newValue && (context.beforePropertyUpdated?.({ node, propertyName, newValue }) ?? true)) { 139 - node[propertyName] = newValue; 140 - context.afterPropertyUpdated?.({ node, propertyName, previousValue }); 85 + #morphAttributes(element, ref) { 86 + // Remove any excess attributes from the element that aren’t present in the reference. 87 + for (const { name, value } of element.attributes) { 88 + if ( 89 + !ref.hasAttribute(name) && 90 + (this.#options.beforeAttributeUpdated?.({ element, attributeName: name, newValue: null }) ?? true) 91 + ) { 92 + element.removeAttribute(name); 93 + this.#options.afterAttributeUpdated?.({ element, attributeName: name, previousValue: value }); 94 + } 95 + } 96 + // Copy attributes from the reference to the element, if they don’t already match. 97 + for (const { name, value } of ref.attributes) { 98 + const previousValue = element.getAttribute(name); 99 + if ( 100 + previousValue !== value && 101 + (this.#options.beforeAttributeUpdated?.({ element, attributeName: name, newValue: value }) ?? true) 102 + ) { 103 + element.setAttribute(name, value); 104 + this.#options.afterAttributeUpdated?.({ element, attributeName: name, previousValue }); 105 + } 106 + } 107 + // For certain types of elements, we need to do some extra work to ensure 108 + // the element’s state matches the reference elements’ state. 109 + if (isInput(element) && isInput(ref)) { 110 + this.#updateProperty(element, "checked", ref.checked); 111 + this.#updateProperty(element, "disabled", ref.disabled); 112 + this.#updateProperty(element, "indeterminate", ref.indeterminate); 113 + if ( 114 + element.type !== "file" && 115 + !(this.#options.ignoreActiveValue && document.activeElement === element) && 116 + !(this.#options.preserveModifiedValues && element.value !== element.defaultValue) 117 + ) 118 + this.#updateProperty(element, "value", ref.value); 119 + } else if (isOption(element) && isOption(ref)) this.#updateProperty(element, "selected", ref.selected); 120 + else if (isTextArea(element) && isTextArea(ref)) { 121 + this.#updateProperty(element, "value", ref.value); 122 + // TODO: Do we need this? If so, how do we integrate with the callback? 123 + const text = element.firstChild; 124 + if (text && isText(text)) this.#updateProperty(text, "textContent", ref.value); 125 + } 141 126 } 142 - } 143 - function morphChildElement(child, ref, parent, context) { 144 - const refIdSet = context.idMap.get(ref); 145 - // Generate the array in advance of the loop 146 - const refSetArray = refIdSet ? [...refIdSet] : []; 147 - let currentNode = child; 148 - let nextMatchByTagName = null; 149 - // Try find a match by idSet, while also looking out for the next best match by tagName. 150 - while (currentNode) { 151 - if (isElement(currentNode)) { 152 - const id = currentNode.id; 153 - if (!nextMatchByTagName && currentNode.tagName === ref.tagName) { 154 - nextMatchByTagName = currentNode; 127 + // Iterates over the child nodes of the reference element, morphing the main element’s child nodes to match. 128 + #morphChildNodes(element, ref) { 129 + const childNodes = element.childNodes; 130 + const refChildNodes = ref.childNodes; 131 + for (let i = 0; i < refChildNodes.length; i++) { 132 + const child = childNodes[i]; 133 + const refChild = refChildNodes[i]; //as ReadonlyNode<ChildNode> | null; 134 + if (child && refChild) { 135 + if (isElement(child) && isElement(refChild)) this.#morphChildElement(child, refChild, element); 136 + else this.#morphNode(child, refChild); 137 + } else if (refChild) { 138 + this.#appendChild(element, refChild.cloneNode(true)); 139 + } else if (child) { 140 + this.#removeNode(child); 155 141 } 156 - if (id !== "") { 157 - if (id === ref.id) { 158 - insertBefore(parent, currentNode, child, context); 159 - return morphNode(currentNode, ref, context); 160 - } else { 161 - const currentIdSet = context.idMap.get(currentNode); 162 - if (currentIdSet && refSetArray.some((it) => currentIdSet.has(it))) { 163 - insertBefore(parent, currentNode, child, context); 164 - return morphNode(currentNode, ref, context); 142 + } 143 + // Clean up any excess nodes that may be left over 144 + while (childNodes.length > refChildNodes.length) { 145 + const child = element.lastChild; 146 + if (child) this.#removeNode(child); 147 + } 148 + } 149 + #morphChildElement(child, ref, parent) { 150 + const refIdSet = this.#idMap.get(ref); 151 + // Generate the array in advance of the loop 152 + const refSetArray = refIdSet ? [...refIdSet] : []; 153 + let currentNode = child; 154 + let nextMatchByTagName = null; 155 + // Try find a match by idSet, while also looking out for the next best match by tagName. 156 + while (currentNode) { 157 + if (isElement(currentNode)) { 158 + const id = currentNode.id; 159 + if (!nextMatchByTagName && currentNode.tagName === ref.tagName) { 160 + nextMatchByTagName = currentNode; 161 + } 162 + if (id !== "") { 163 + if (id === ref.id) { 164 + this.#insertBefore(parent, currentNode, child); 165 + return this.#morphNode(currentNode, ref); 166 + } else { 167 + const currentIdSet = this.#idMap.get(currentNode); 168 + if (currentIdSet && refSetArray.some((it) => currentIdSet.has(it))) { 169 + this.#insertBefore(parent, currentNode, child); 170 + return this.#morphNode(currentNode, ref); 171 + } 165 172 } 166 173 } 167 174 } 175 + currentNode = currentNode.nextSibling; 168 176 } 169 - currentNode = currentNode.nextSibling; 177 + if (nextMatchByTagName) { 178 + this.#insertBefore(parent, nextMatchByTagName, child); 179 + this.#morphNode(nextMatchByTagName, ref); 180 + } else { 181 + // TODO: this is missing an added callback 182 + this.#insertBefore(parent, ref.cloneNode(true), child); 183 + } 170 184 } 171 - if (nextMatchByTagName) { 172 - insertBefore(parent, nextMatchByTagName, child, context); 173 - morphNode(nextMatchByTagName, ref, context); 174 - } else { 175 - // TODO: this is missing an added callback 176 - insertBefore(parent, ref.cloneNode(true), child, context); 185 + #updateProperty(node, propertyName, newValue) { 186 + const previousValue = node[propertyName]; 187 + if (previousValue !== newValue && (this.#options.beforePropertyUpdated?.({ node, propertyName, newValue }) ?? true)) { 188 + node[propertyName] = newValue; 189 + this.#options.afterPropertyUpdated?.({ node, propertyName, previousValue }); 190 + } 177 191 } 178 - } 179 - function replaceNode(node, newNode, context) { 180 - if ( 181 - (context.beforeNodeRemoved?.({ oldNode: node }) ?? true) && 182 - (context.beforeNodeAdded?.({ newNode, parentNode: node.parentNode }) ?? true) 183 - ) { 184 - node.replaceWith(newNode); 185 - context.afterNodeAdded?.({ newNode }); 186 - context.afterNodeRemoved?.({ oldNode: node }); 192 + #replaceNode(node, newNode) { 193 + if ( 194 + (this.#options.beforeNodeRemoved?.({ oldNode: node }) ?? true) && 195 + (this.#options.beforeNodeAdded?.({ newNode, parentNode: node.parentNode }) ?? true) 196 + ) { 197 + node.replaceWith(newNode); 198 + this.#options.afterNodeAdded?.({ newNode }); 199 + this.#options.afterNodeRemoved?.({ oldNode: node }); 200 + } 187 201 } 188 - } 189 - function insertBefore(parent, node, insertionPoint, context) { 190 - if (node === insertionPoint) return; 191 - if (isElement(node)) { 192 - const sensitivity = context.sensitivityMap.get(node) ?? 0; 193 - if (sensitivity > 0) { 194 - let previousNode = node.previousSibling; 195 - while (previousNode) { 196 - const previousNodeSensitivity = context.sensitivityMap.get(previousNode) ?? 0; 197 - if (previousNodeSensitivity < sensitivity) { 198 - parent.insertBefore(previousNode, node.nextSibling); 199 - if (previousNode === insertionPoint) return; 200 - previousNode = node.previousSibling; 201 - } else { 202 - break; 202 + #insertBefore(parent, node, insertionPoint) { 203 + if (node === insertionPoint) return; 204 + if (isElement(node)) { 205 + const sensitivity = this.#sensivityMap.get(node) ?? 0; 206 + if (sensitivity > 0) { 207 + let previousNode = node.previousSibling; 208 + while (previousNode) { 209 + const previousNodeSensitivity = this.#sensivityMap.get(previousNode) ?? 0; 210 + if (previousNodeSensitivity < sensitivity) { 211 + parent.insertBefore(previousNode, node.nextSibling); 212 + if (previousNode === insertionPoint) return; 213 + previousNode = node.previousSibling; 214 + } else { 215 + break; 216 + } 203 217 } 204 218 } 205 219 } 220 + parent.insertBefore(node, insertionPoint); 206 221 } 207 - parent.insertBefore(node, insertionPoint); 208 - } 209 - function appendChild(node, newNode, context) { 210 - if (context.beforeNodeAdded?.({ newNode, parentNode: node }) ?? true) { 211 - node.appendChild(newNode); 212 - context.afterNodeAdded?.({ newNode }); 222 + #appendChild(node, newNode) { 223 + if (this.#options.beforeNodeAdded?.({ newNode, parentNode: node }) ?? true) { 224 + node.appendChild(newNode); 225 + this.#options.afterNodeAdded?.({ newNode }); 226 + } 213 227 } 214 - } 215 - function removeNode(node, context) { 216 - if (context.beforeNodeRemoved?.({ oldNode: node }) ?? true) { 217 - node.remove(); 218 - context.afterNodeRemoved?.({ oldNode: node }); 228 + #removeNode(node) { 229 + if (this.#options.beforeNodeRemoved?.({ oldNode: node }) ?? true) { 230 + node.remove(); 231 + this.#options.afterNodeRemoved?.({ oldNode: node }); 232 + } 219 233 } 220 234 } 221 235 function isText(node) {
+213 -198
src/morphlex.ts
··· 83 83 }) => void; 84 84 } 85 85 86 - type Context = Options & { idMap: IdMap; sensitivityMap: SensivityMap }; 87 - 88 86 export function morph(node: ChildNode, reference: ChildNode, options: Options = {}): void { 89 - const readonlyReference = reference as ReadonlyNode<ChildNode>; 90 - const idMap: IdMap = new WeakMap(); 91 - const sensitivityMap: SensivityMap = new WeakMap(); 92 - 93 - if (isParentNode(node) && isParentNode(readonlyReference)) { 94 - populateIdSets(node, idMap); 95 - populateIdSets(readonlyReference, idMap); 96 - populateSensivityMap(node, sensitivityMap); 97 - } 98 - 99 - morphNode(node, readonlyReference, { ...options, idMap, sensitivityMap }); 87 + new Morph(options).morph(node, reference); 100 88 } 101 89 102 - function populateSensivityMap(node: ReadonlyNode<ParentNode>, sensivityMap: SensivityMap): void { 103 - const sensitiveElements = node.querySelectorAll("iframe,video,object,embed,audio,input,textarea,canvas"); 104 - for (const sensitiveElement of sensitiveElements) { 105 - let sensivity = 0; 90 + class Morph { 91 + readonly #idMap: IdMap; 92 + readonly #sensivityMap: SensivityMap; 93 + readonly #options: Options; 106 94 107 - if (isInput(sensitiveElement) || isTextArea(sensitiveElement)) { 108 - sensivity += 1; 95 + constructor(options: Options = {}) { 96 + this.#options = options; 97 + this.#idMap = new WeakMap(); 98 + this.#sensivityMap = new WeakMap(); 99 + } 109 100 110 - if (sensitiveElement.value !== sensitiveElement.defaultValue) sensivity += 1; 111 - if (sensitiveElement === document.activeElement) sensivity += 1; 112 - } else { 113 - sensivity += 3; 101 + morph(node: ChildNode, reference: ChildNode): void { 102 + const readonlyReference = reference as ReadonlyNode<ChildNode>; 114 103 115 - if (sensitiveElement instanceof HTMLMediaElement && !sensitiveElement.ended) { 116 - if (!sensitiveElement.paused) sensivity += 1; 117 - if (sensitiveElement.currentTime > 0) sensivity += 1; 118 - } 104 + if (isParentNode(node) && isParentNode(readonlyReference)) { 105 + this.#populateIdSets(node); 106 + this.#populateIdSets(readonlyReference); 107 + this.#populateSensivityMap(node); 119 108 } 120 109 121 - let current: ReadonlyNode<Element> | null = sensitiveElement; 122 - while (current) { 123 - sensivityMap.set(current, (sensivityMap.get(current) || 0) + sensivity); 124 - if (current === node) break; 125 - current = current.parentElement; 126 - } 110 + this.#morphNode(node, readonlyReference); 127 111 } 128 - } 129 112 130 - // For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. 131 - function populateIdSets(node: ReadonlyNode<ParentNode>, idMap: IdMap): void { 132 - const elementsWithIds = node.querySelectorAll("[id]"); 113 + #populateSensivityMap(node: ReadonlyNode<ParentNode>): void { 114 + const sensitiveElements = node.querySelectorAll("iframe,video,object,embed,audio,input,textarea,canvas"); 115 + for (const sensitiveElement of sensitiveElements) { 116 + let sensivity = 0; 133 117 134 - for (const elementWithId of elementsWithIds) { 135 - const id = elementWithId.id; 118 + if (isInput(sensitiveElement) || isTextArea(sensitiveElement)) { 119 + sensivity += 1; 136 120 137 - // Ignore empty IDs 138 - if (id === "") continue; 121 + if (sensitiveElement.value !== sensitiveElement.defaultValue) sensivity += 1; 122 + if (sensitiveElement === document.activeElement) sensivity += 1; 123 + } else { 124 + sensivity += 3; 139 125 140 - let current: ReadonlyNode<Element> | null = elementWithId; 126 + if (sensitiveElement instanceof HTMLMediaElement && !sensitiveElement.ended) { 127 + if (!sensitiveElement.paused) sensivity += 1; 128 + if (sensitiveElement.currentTime > 0) sensivity += 1; 129 + } 130 + } 141 131 142 - while (current) { 143 - const idSet: IdSet | undefined = idMap.get(current); 144 - idSet ? idSet.add(id) : idMap.set(current, new Set([id])); 145 - if (current === node) break; 146 - current = current.parentElement; 132 + let current: ReadonlyNode<Element> | null = sensitiveElement; 133 + while (current) { 134 + this.#sensivityMap.set(current, (this.#sensivityMap.get(current) || 0) + sensivity); 135 + if (current === node) break; 136 + current = current.parentElement; 137 + } 147 138 } 148 139 } 149 - } 150 140 151 - // This is where we actually morph the nodes. The `morph` function (above) exists only to set up the `idMap`. 152 - function morphNode(node: ChildNode, ref: ReadonlyNode<ChildNode>, context: Context): void { 153 - if (!(context.beforeNodeMorphed?.({ node, referenceNode: ref as ChildNode }) ?? true)) return; 141 + // For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. 142 + #populateIdSets(node: ReadonlyNode<ParentNode>): void { 143 + const elementsWithIds = node.querySelectorAll("[id]"); 154 144 155 - if (isElement(node) && isElement(ref) && node.tagName === ref.tagName) { 156 - if (node.hasAttributes() || ref.hasAttributes()) morphAttributes(node, ref, context); 157 - if (isHead(node) && isHead(ref)) { 158 - const refChildNodes: Map<string, ReadonlyNode<Element>> = new Map(); 159 - for (const child of ref.children) refChildNodes.set(child.outerHTML, child); 160 - for (const child of node.children) { 161 - const key = child.outerHTML; 162 - const refChild = refChildNodes.get(key); 163 - refChild ? refChildNodes.delete(key) : removeNode(child, context); 145 + for (const elementWithId of elementsWithIds) { 146 + const id = elementWithId.id; 147 + 148 + // Ignore empty IDs 149 + if (id === "") continue; 150 + 151 + let current: ReadonlyNode<Element> | null = elementWithId; 152 + 153 + while (current) { 154 + const idSet: IdSet | undefined = this.#idMap.get(current); 155 + idSet ? idSet.add(id) : this.#idMap.set(current, new Set([id])); 156 + if (current === node) break; 157 + current = current.parentElement; 164 158 } 165 - for (const refChild of refChildNodes.values()) appendChild(node, refChild.cloneNode(true), context); 166 - } else if (node.hasChildNodes() || ref.hasChildNodes()) morphChildNodes(node, ref, context); 167 - } else { 168 - if (isText(node) && isText(ref)) { 169 - updateProperty(node, "textContent", ref.textContent, context); 170 - } else if (isComment(node) && isComment(ref)) { 171 - updateProperty(node, "nodeValue", ref.nodeValue, context); 172 - } else replaceNode(node, ref.cloneNode(true), context); 159 + } 173 160 } 174 161 175 - context.afterNodeMorphed?.({ node }); 176 - } 162 + // This is where we actually morph the nodes. The `morph` function (above) exists only to set up the `idMap`. 163 + #morphNode(node: ChildNode, ref: ReadonlyNode<ChildNode>): void { 164 + if (!(this.#options.beforeNodeMorphed?.({ node, referenceNode: ref as ChildNode }) ?? true)) return; 177 165 178 - function morphAttributes(element: Element, ref: ReadonlyNode<Element>, context: Context): void { 179 - // Remove any excess attributes from the element that aren’t present in the reference. 180 - for (const { name, value } of element.attributes) { 181 - if (!ref.hasAttribute(name) && (context.beforeAttributeUpdated?.({ element, attributeName: name, newValue: null }) ?? true)) { 182 - element.removeAttribute(name); 183 - context.afterAttributeUpdated?.({ element, attributeName: name, previousValue: value }); 166 + if (isElement(node) && isElement(ref) && node.tagName === ref.tagName) { 167 + if (node.hasAttributes() || ref.hasAttributes()) this.#morphAttributes(node, ref); 168 + if (isHead(node) && isHead(ref)) { 169 + const refChildNodes: Map<string, ReadonlyNode<Element>> = new Map(); 170 + for (const child of ref.children) refChildNodes.set(child.outerHTML, child); 171 + for (const child of node.children) { 172 + const key = child.outerHTML; 173 + const refChild = refChildNodes.get(key); 174 + refChild ? refChildNodes.delete(key) : this.#removeNode(child); 175 + } 176 + for (const refChild of refChildNodes.values()) this.#appendChild(node, refChild.cloneNode(true)); 177 + } else if (node.hasChildNodes() || ref.hasChildNodes()) this.#morphChildNodes(node, ref); 178 + } else { 179 + if (isText(node) && isText(ref)) { 180 + this.#updateProperty(node, "textContent", ref.textContent); 181 + } else if (isComment(node) && isComment(ref)) { 182 + this.#updateProperty(node, "nodeValue", ref.nodeValue); 183 + } else this.#replaceNode(node, ref.cloneNode(true)); 184 184 } 185 + 186 + this.#options.afterNodeMorphed?.({ node }); 185 187 } 186 188 187 - // Copy attributes from the reference to the element, if they don’t already match. 188 - for (const { name, value } of ref.attributes) { 189 - const previousValue = element.getAttribute(name); 190 - if ( 191 - previousValue !== value && 192 - (context.beforeAttributeUpdated?.({ element, attributeName: name, newValue: value }) ?? true) 193 - ) { 194 - element.setAttribute(name, value); 195 - context.afterAttributeUpdated?.({ element, attributeName: name, previousValue }); 189 + #morphAttributes(element: Element, ref: ReadonlyNode<Element>): void { 190 + // Remove any excess attributes from the element that aren’t present in the reference. 191 + for (const { name, value } of element.attributes) { 192 + if ( 193 + !ref.hasAttribute(name) && 194 + (this.#options.beforeAttributeUpdated?.({ element, attributeName: name, newValue: null }) ?? true) 195 + ) { 196 + element.removeAttribute(name); 197 + this.#options.afterAttributeUpdated?.({ element, attributeName: name, previousValue: value }); 198 + } 199 + } 200 + 201 + // Copy attributes from the reference to the element, if they don’t already match. 202 + for (const { name, value } of ref.attributes) { 203 + const previousValue = element.getAttribute(name); 204 + if ( 205 + previousValue !== value && 206 + (this.#options.beforeAttributeUpdated?.({ element, attributeName: name, newValue: value }) ?? true) 207 + ) { 208 + element.setAttribute(name, value); 209 + this.#options.afterAttributeUpdated?.({ element, attributeName: name, previousValue }); 210 + } 196 211 } 197 - } 198 212 199 - // For certain types of elements, we need to do some extra work to ensure 200 - // the element’s state matches the reference elements’ state. 201 - if (isInput(element) && isInput(ref)) { 202 - updateProperty(element, "checked", ref.checked, context); 203 - updateProperty(element, "disabled", ref.disabled, context); 204 - updateProperty(element, "indeterminate", ref.indeterminate, context); 205 - if ( 206 - element.type !== "file" && 207 - !(context.ignoreActiveValue && document.activeElement === element) && 208 - !(context.preserveModifiedValues && element.value !== element.defaultValue) 209 - ) 210 - updateProperty(element, "value", ref.value, context); 211 - } else if (isOption(element) && isOption(ref)) updateProperty(element, "selected", ref.selected, context); 212 - else if (isTextArea(element) && isTextArea(ref)) { 213 - updateProperty(element, "value", ref.value, context); 213 + // For certain types of elements, we need to do some extra work to ensure 214 + // the element’s state matches the reference elements’ state. 215 + if (isInput(element) && isInput(ref)) { 216 + this.#updateProperty(element, "checked", ref.checked); 217 + this.#updateProperty(element, "disabled", ref.disabled); 218 + this.#updateProperty(element, "indeterminate", ref.indeterminate); 219 + if ( 220 + element.type !== "file" && 221 + !(this.#options.ignoreActiveValue && document.activeElement === element) && 222 + !(this.#options.preserveModifiedValues && element.value !== element.defaultValue) 223 + ) 224 + this.#updateProperty(element, "value", ref.value); 225 + } else if (isOption(element) && isOption(ref)) this.#updateProperty(element, "selected", ref.selected); 226 + else if (isTextArea(element) && isTextArea(ref)) { 227 + this.#updateProperty(element, "value", ref.value); 214 228 215 - // TODO: Do we need this? If so, how do we integrate with the callback? 216 - const text = element.firstChild; 217 - if (text && isText(text)) updateProperty(text, "textContent", ref.value, context); 229 + // TODO: Do we need this? If so, how do we integrate with the callback? 230 + const text = element.firstChild; 231 + if (text && isText(text)) this.#updateProperty(text, "textContent", ref.value); 232 + } 218 233 } 219 - } 220 234 221 - // Iterates over the child nodes of the reference element, morphing the main element’s child nodes to match. 222 - function morphChildNodes(element: Element, ref: ReadonlyNode<Element>, context: Context): void { 223 - const childNodes = element.childNodes; 224 - const refChildNodes = ref.childNodes; 235 + // Iterates over the child nodes of the reference element, morphing the main element’s child nodes to match. 236 + #morphChildNodes(element: Element, ref: ReadonlyNode<Element>): void { 237 + const childNodes = element.childNodes; 238 + const refChildNodes = ref.childNodes; 225 239 226 - for (let i = 0; i < refChildNodes.length; i++) { 227 - const child = childNodes[i] as ChildNode | null; 228 - const refChild = refChildNodes[i]; //as ReadonlyNode<ChildNode> | null; 240 + for (let i = 0; i < refChildNodes.length; i++) { 241 + const child = childNodes[i] as ChildNode | null; 242 + const refChild = refChildNodes[i]; //as ReadonlyNode<ChildNode> | null; 229 243 230 - if (child && refChild) { 231 - if (isElement(child) && isElement(refChild)) morphChildElement(child, refChild, element, context); 232 - else morphNode(child, refChild, context); 233 - } else if (refChild) { 234 - appendChild(element, refChild.cloneNode(true), context); 235 - } else if (child) { 236 - removeNode(child, context); 244 + if (child && refChild) { 245 + if (isElement(child) && isElement(refChild)) this.#morphChildElement(child, refChild, element); 246 + else this.#morphNode(child, refChild); 247 + } else if (refChild) { 248 + this.#appendChild(element, refChild.cloneNode(true)); 249 + } else if (child) { 250 + this.#removeNode(child); 251 + } 237 252 } 238 - } 239 253 240 - // Clean up any excess nodes that may be left over 241 - while (childNodes.length > refChildNodes.length) { 242 - const child = element.lastChild; 243 - if (child) removeNode(child, context); 254 + // Clean up any excess nodes that may be left over 255 + while (childNodes.length > refChildNodes.length) { 256 + const child = element.lastChild; 257 + if (child) this.#removeNode(child); 258 + } 244 259 } 245 - } 246 260 247 - function updateProperty<N extends Node, P extends keyof N>(node: N, propertyName: P, newValue: N[P], context: Context): void { 248 - const previousValue = node[propertyName]; 249 - if (previousValue !== newValue && (context.beforePropertyUpdated?.({ node, propertyName, newValue }) ?? true)) { 250 - node[propertyName] = newValue; 251 - context.afterPropertyUpdated?.({ node, propertyName, previousValue }); 252 - } 253 - } 261 + #morphChildElement(child: Element, ref: ReadonlyNode<Element>, parent: Element): void { 262 + const refIdSet = this.#idMap.get(ref); 254 263 255 - function morphChildElement(child: Element, ref: ReadonlyNode<Element>, parent: Element, context: Context): void { 256 - const refIdSet = context.idMap.get(ref); 264 + // Generate the array in advance of the loop 265 + const refSetArray = refIdSet ? [...refIdSet] : []; 257 266 258 - // Generate the array in advance of the loop 259 - const refSetArray = refIdSet ? [...refIdSet] : []; 267 + let currentNode: ChildNode | null = child; 268 + let nextMatchByTagName: ChildNode | null = null; 260 269 261 - let currentNode: ChildNode | null = child; 262 - let nextMatchByTagName: ChildNode | null = null; 270 + // Try find a match by idSet, while also looking out for the next best match by tagName. 271 + while (currentNode) { 272 + if (isElement(currentNode)) { 273 + const id = currentNode.id; 263 274 264 - // Try find a match by idSet, while also looking out for the next best match by tagName. 265 - while (currentNode) { 266 - if (isElement(currentNode)) { 267 - const id = currentNode.id; 275 + if (!nextMatchByTagName && currentNode.tagName === ref.tagName) { 276 + nextMatchByTagName = currentNode; 277 + } 268 278 269 - if (!nextMatchByTagName && currentNode.tagName === ref.tagName) { 270 - nextMatchByTagName = currentNode; 271 - } 279 + if (id !== "") { 280 + if (id === ref.id) { 281 + this.#insertBefore(parent, currentNode, child); 282 + return this.#morphNode(currentNode, ref); 283 + } else { 284 + const currentIdSet = this.#idMap.get(currentNode); 272 285 273 - if (id !== "") { 274 - if (id === ref.id) { 275 - insertBefore(parent, currentNode, child, context); 276 - return morphNode(currentNode, ref, context); 277 - } else { 278 - const currentIdSet = context.idMap.get(currentNode); 279 - 280 - if (currentIdSet && refSetArray.some((it) => currentIdSet.has(it))) { 281 - insertBefore(parent, currentNode, child, context); 282 - return morphNode(currentNode, ref, context); 286 + if (currentIdSet && refSetArray.some((it) => currentIdSet.has(it))) { 287 + this.#insertBefore(parent, currentNode, child); 288 + return this.#morphNode(currentNode, ref); 289 + } 283 290 } 284 291 } 285 292 } 293 + 294 + currentNode = currentNode.nextSibling; 286 295 } 287 296 288 - currentNode = currentNode.nextSibling; 297 + if (nextMatchByTagName) { 298 + this.#insertBefore(parent, nextMatchByTagName, child); 299 + this.#morphNode(nextMatchByTagName, ref); 300 + } else { 301 + // TODO: this is missing an added callback 302 + this.#insertBefore(parent, ref.cloneNode(true), child); 303 + } 289 304 } 290 305 291 - if (nextMatchByTagName) { 292 - insertBefore(parent, nextMatchByTagName, child, context); 293 - morphNode(nextMatchByTagName, ref, context); 294 - } else { 295 - // TODO: this is missing an added callback 296 - insertBefore(parent, ref.cloneNode(true), child, context); 306 + #updateProperty<N extends Node, P extends keyof N>(node: N, propertyName: P, newValue: N[P]): void { 307 + const previousValue = node[propertyName]; 308 + if (previousValue !== newValue && (this.#options.beforePropertyUpdated?.({ node, propertyName, newValue }) ?? true)) { 309 + node[propertyName] = newValue; 310 + this.#options.afterPropertyUpdated?.({ node, propertyName, previousValue }); 311 + } 297 312 } 298 - } 299 313 300 - function replaceNode(node: ChildNode, newNode: Node, context: Context): void { 301 - if ( 302 - (context.beforeNodeRemoved?.({ oldNode: node }) ?? true) && 303 - (context.beforeNodeAdded?.({ newNode, parentNode: node.parentNode }) ?? true) 304 - ) { 305 - node.replaceWith(newNode); 306 - context.afterNodeAdded?.({ newNode }); 307 - context.afterNodeRemoved?.({ oldNode: node }); 314 + #replaceNode(node: ChildNode, newNode: Node): void { 315 + if ( 316 + (this.#options.beforeNodeRemoved?.({ oldNode: node }) ?? true) && 317 + (this.#options.beforeNodeAdded?.({ newNode, parentNode: node.parentNode }) ?? true) 318 + ) { 319 + node.replaceWith(newNode); 320 + this.#options.afterNodeAdded?.({ newNode }); 321 + this.#options.afterNodeRemoved?.({ oldNode: node }); 322 + } 308 323 } 309 - } 310 324 311 - function insertBefore(parent: ParentNode, node: Node, insertionPoint: ChildNode, context: Context): void { 312 - if (node === insertionPoint) return; 325 + #insertBefore(parent: ParentNode, node: Node, insertionPoint: ChildNode): void { 326 + if (node === insertionPoint) return; 313 327 314 - if (isElement(node)) { 315 - const sensitivity = context.sensitivityMap.get(node) ?? 0; 328 + if (isElement(node)) { 329 + const sensitivity = this.#sensivityMap.get(node) ?? 0; 316 330 317 - if (sensitivity > 0) { 318 - let previousNode = node.previousSibling; 331 + if (sensitivity > 0) { 332 + let previousNode = node.previousSibling; 319 333 320 - while (previousNode) { 321 - const previousNodeSensitivity = context.sensitivityMap.get(previousNode) ?? 0; 334 + while (previousNode) { 335 + const previousNodeSensitivity = this.#sensivityMap.get(previousNode) ?? 0; 322 336 323 - if (previousNodeSensitivity < sensitivity) { 324 - parent.insertBefore(previousNode, node.nextSibling); 337 + if (previousNodeSensitivity < sensitivity) { 338 + parent.insertBefore(previousNode, node.nextSibling); 325 339 326 - if (previousNode === insertionPoint) return; 327 - previousNode = node.previousSibling; 328 - } else { 329 - break; 340 + if (previousNode === insertionPoint) return; 341 + previousNode = node.previousSibling; 342 + } else { 343 + break; 344 + } 330 345 } 331 346 } 332 347 } 348 + 349 + parent.insertBefore(node, insertionPoint); 333 350 } 334 351 335 - parent.insertBefore(node, insertionPoint); 336 - } 337 - 338 - function appendChild(node: ParentNode, newNode: Node, context: Context): void { 339 - if (context.beforeNodeAdded?.({ newNode, parentNode: node }) ?? true) { 340 - node.appendChild(newNode); 341 - context.afterNodeAdded?.({ newNode }); 352 + #appendChild(node: ParentNode, newNode: Node): void { 353 + if (this.#options.beforeNodeAdded?.({ newNode, parentNode: node }) ?? true) { 354 + node.appendChild(newNode); 355 + this.#options.afterNodeAdded?.({ newNode }); 356 + } 342 357 } 343 - } 344 358 345 - function removeNode(node: ChildNode, context: Context): void { 346 - if (context.beforeNodeRemoved?.({ oldNode: node }) ?? true) { 347 - node.remove(); 348 - context.afterNodeRemoved?.({ oldNode: node }); 359 + #removeNode(node: ChildNode): void { 360 + if (this.#options.beforeNodeRemoved?.({ oldNode: node }) ?? true) { 361 + node.remove(); 362 + this.#options.afterNodeRemoved?.({ oldNode: node }); 363 + } 349 364 } 350 365 } 351 366
+3 -7
terser-config.json
··· 1 1 { 2 - "compress": true, 3 - "mangle": { 4 - "properties": { 5 - "regex": "^_" 6 - } 7 - }, 8 - "module": true 2 + "mangle": true, 3 + "module": true, 4 + "compress": true 9 5 }
+1 -1
tsconfig.json
··· 6 6 "noUnusedLocals": true, 7 7 "rootDir": "src", 8 8 "strict": true, 9 - "target": "es2020", 9 + "target": "es2022", 10 10 "removeComments": false, 11 11 "outDir": "dist", 12 12 "baseUrl": ".",