Precise DOM morphing
morphing typescript dom

Use for-loops

+373 -319
+358 -315
dist/morphlex.js
··· 1 1 export function morph(node, reference, options = {}) { 2 - if (typeof reference === "string") { 3 - const template = document.createElement("template"); 4 - template.innerHTML = reference.trim(); 5 - reference = template.content.firstChild; 6 - if (!reference) { 7 - throw new Error("The provided string did not contain any nodes."); 8 - } 9 - } 10 - if (isElement(node)) { 11 - node.ariaBusy = "true"; 12 - new Morph(options).morph([node, reference]); 13 - node.ariaBusy = null; 14 - } else { 15 - new Morph(options).morph([node, reference]); 16 - } 2 + if (typeof reference === "string") { 3 + const template = document.createElement("template"); 4 + template.innerHTML = reference.trim(); 5 + reference = template.content.firstChild; 6 + if (!reference) { 7 + throw new Error("The provided string did not contain any nodes."); 8 + } 9 + } 10 + if (isElement(node)) { 11 + node.ariaBusy = "true"; 12 + new Morph(options).morph([node, reference]); 13 + node.ariaBusy = null; 14 + } 15 + else { 16 + new Morph(options).morph([node, reference]); 17 + } 17 18 } 18 19 class Morph { 19 - #idMap; 20 - #sensivityMap; 21 - #ignoreActiveValue; 22 - #preserveModifiedValues; 23 - #beforeNodeMorphed; 24 - #afterNodeMorphed; 25 - #beforeNodeAdded; 26 - #afterNodeAdded; 27 - #beforeNodeRemoved; 28 - #afterNodeRemoved; 29 - #beforeAttributeUpdated; 30 - #afterAttributeUpdated; 31 - #beforePropertyUpdated; 32 - #afterPropertyUpdated; 33 - constructor(options = {}) { 34 - this.#idMap = new WeakMap(); 35 - this.#sensivityMap = new WeakMap(); 36 - this.#ignoreActiveValue = options.ignoreActiveValue || false; 37 - this.#preserveModifiedValues = options.preserveModifiedValues || false; 38 - this.#beforeNodeMorphed = options.beforeNodeMorphed; 39 - this.#afterNodeMorphed = options.afterNodeMorphed; 40 - this.#beforeNodeAdded = options.beforeNodeAdded; 41 - this.#afterNodeAdded = options.afterNodeAdded; 42 - this.#beforeNodeRemoved = options.beforeNodeRemoved; 43 - this.#afterNodeRemoved = options.afterNodeRemoved; 44 - this.#beforeAttributeUpdated = options.beforeAttributeUpdated; 45 - this.#afterAttributeUpdated = options.afterAttributeUpdated; 46 - this.#beforePropertyUpdated = options.beforePropertyUpdated; 47 - this.#afterPropertyUpdated = options.afterPropertyUpdated; 48 - Object.freeze(this); 49 - } 50 - morph(pair) { 51 - if (isParentNodePair(pair)) this.#buildMaps(pair); 52 - this.#morphNode(pair); 53 - } 54 - morphInner(pair) { 55 - this.#buildMaps(pair); 56 - if (isMatchingElementPair(pair)) { 57 - this.#morphMatchingElementContent(pair); 58 - } else { 59 - throw new Error("You can only do an inner morph with matching elements."); 60 - } 61 - } 62 - #buildMaps([node, reference]) { 63 - this.#mapIdSets(node); 64 - this.#mapIdSets(reference); 65 - this.#mapSensivity(node); 66 - Object.freeze(this.#idMap); 67 - Object.freeze(this.#sensivityMap); 68 - } 69 - #mapSensivity(node) { 70 - const sensitiveElements = node.querySelectorAll("audio,canvas,embed,iframe,input,object,textarea,video"); 71 - for (const sensitiveElement of sensitiveElements) { 72 - let sensivity = 0; 73 - if (isInput(sensitiveElement) || isTextArea(sensitiveElement)) { 74 - sensivity++; 75 - if (sensitiveElement.value !== sensitiveElement.defaultValue) sensivity++; 76 - if (sensitiveElement === document.activeElement) sensivity++; 77 - } else { 78 - sensivity += 3; 79 - if (isMedia(sensitiveElement) && !sensitiveElement.ended) { 80 - if (!sensitiveElement.paused) sensivity++; 81 - if (sensitiveElement.currentTime > 0) sensivity++; 82 - } 83 - } 84 - let current = sensitiveElement; 85 - while (current) { 86 - this.#sensivityMap.set(current, (this.#sensivityMap.get(current) || 0) + sensivity); 87 - if (current === node) break; 88 - current = current.parentElement; 89 - } 90 - } 91 - } 92 - // For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. 93 - #mapIdSets(node) { 94 - const elementsWithIds = node.querySelectorAll("[id]"); 95 - for (const elementWithId of elementsWithIds) { 96 - const id = elementWithId.id; 97 - // Ignore empty IDs 98 - if (id === "") continue; 99 - let current = elementWithId; 100 - while (current) { 101 - const idSet = this.#idMap.get(current); 102 - idSet ? idSet.add(id) : this.#idMap.set(current, new Set([id])); 103 - if (current === node) break; 104 - current = current.parentElement; 105 - } 106 - } 107 - } 108 - // This is where we actually morph the nodes. The `morph` function (above) exists only to set up the `idMap`. 109 - #morphNode(pair) { 110 - if (isMatchingElementPair(pair)) this.#morphMatchingElementNode(pair); 111 - else this.#morphOtherNode(pair); 112 - } 113 - #morphMatchingElementNode(pair) { 114 - const [node, reference] = pair; 115 - if (!(this.#beforeNodeMorphed?.(node, writableNode(reference)) ?? true)) return; 116 - if (node.hasAttributes() || reference.hasAttributes()) this.#morphAttributes(pair); 117 - // TODO: Should use a branded pair here. 118 - this.#morphMatchingElementContent(pair); 119 - this.#afterNodeMorphed?.(node, writableNode(reference)); 120 - } 121 - #morphOtherNode([node, reference]) { 122 - if (!(this.#beforeNodeMorphed?.(node, writableNode(reference)) ?? true)) return; 123 - if (node.nodeType === reference.nodeType && node.nodeValue !== null && reference.nodeValue !== null) { 124 - // Handle text nodes, comments, and CDATA sections. 125 - this.#updateProperty(node, "nodeValue", reference.nodeValue); 126 - } else this.#replaceNode(node, reference.cloneNode(true)); 127 - this.#afterNodeMorphed?.(node, writableNode(reference)); 128 - } 129 - #morphMatchingElementContent(pair) { 130 - const [node, reference] = pair; 131 - if (isHead(node)) { 132 - // We can pass the reference as a head here becuase we know it's the same as the node. 133 - this.#morphHeadContents(pair); 134 - } else if (node.hasChildNodes() || reference.hasChildNodes()) this.#morphChildNodes(pair); 135 - } 136 - #morphHeadContents([node, reference]) { 137 - const refChildNodesMap = new Map(); 138 - // Generate a map of the reference head element’s child nodes, keyed by their outerHTML. 139 - for (const child of reference.children) refChildNodesMap.set(child.outerHTML, child); 140 - for (const child of node.children) { 141 - const key = child.outerHTML; 142 - const refChild = refChildNodesMap.get(key); 143 - // If the child is in the reference map already, we don’t need to add it later. 144 - // If it’s not in the map, we need to remove it from the node. 145 - refChild ? refChildNodesMap.delete(key) : this.#removeNode(child); 146 - } 147 - // Any remaining nodes in the map should be appended to the head. 148 - for (const refChild of refChildNodesMap.values()) this.#appendChild(node, refChild.cloneNode(true)); 149 - } 150 - #morphAttributes([element, reference]) { 151 - // Remove any excess attributes from the element that aren’t present in the reference. 152 - for (const { name, value } of element.attributes) { 153 - if (!reference.hasAttribute(name) && (this.#beforeAttributeUpdated?.(element, name, null) ?? true)) { 154 - element.removeAttribute(name); 155 - this.#afterAttributeUpdated?.(element, name, value); 156 - } 157 - } 158 - // Copy attributes from the reference to the element, if they don’t already match. 159 - for (const { name, value } of reference.attributes) { 160 - const previousValue = element.getAttribute(name); 161 - if (previousValue !== value && (this.#beforeAttributeUpdated?.(element, name, value) ?? true)) { 162 - element.setAttribute(name, value); 163 - this.#afterAttributeUpdated?.(element, name, previousValue); 164 - } 165 - } 166 - // For certain types of elements, we need to do some extra work to ensure 167 - // the element’s state matches the reference elements’ state. 168 - if (isInput(element) && isInput(reference)) { 169 - this.#updateProperty(element, "checked", reference.checked); 170 - this.#updateProperty(element, "disabled", reference.disabled); 171 - this.#updateProperty(element, "indeterminate", reference.indeterminate); 172 - if ( 173 - element.type !== "file" && 174 - !(this.#ignoreActiveValue && document.activeElement === element) && 175 - !(this.#preserveModifiedValues && element.name === reference.name && element.value !== element.defaultValue) 176 - ) { 177 - this.#updateProperty(element, "value", reference.value); 178 - } 179 - } else if (isOption(element) && isOption(reference)) { 180 - this.#updateProperty(element, "selected", reference.selected); 181 - } else if ( 182 - isTextArea(element) && 183 - isTextArea(reference) && 184 - !(this.#ignoreActiveValue && document.activeElement === element) && 185 - !(this.#preserveModifiedValues && element.name === reference.name && element.value !== element.defaultValue) 186 - ) { 187 - this.#updateProperty(element, "value", reference.value); 188 - const text = element.firstElementChild; 189 - if (text) this.#updateProperty(text, "textContent", reference.value); 190 - } 191 - } 192 - // Iterates over the child nodes of the reference element, morphing the main element’s child nodes to match. 193 - #morphChildNodes(pair) { 194 - const [element, reference] = pair; 195 - const childNodes = element.childNodes; 196 - const refChildNodes = reference.childNodes; 197 - for (let i = 0; i < refChildNodes.length; i++) { 198 - const child = childNodes[i]; 199 - const refChild = refChildNodes[i]; 200 - if (child && refChild) { 201 - const pair = [child, refChild]; 202 - if (isMatchingElementPair(pair)) { 203 - if (isHead(pair[0])) { 204 - this.#morphHeadContents(pair); 205 - } else { 206 - this.#morphChildElement(pair, element); 207 - } 208 - } else this.#morphOtherNode(pair); 209 - } else if (refChild) { 210 - this.#appendChild(element, refChild.cloneNode(true)); 211 - } else if (child) { 212 - this.#removeNode(child); 213 - } 214 - } 215 - // Clean up any excess nodes that may be left over 216 - while (childNodes.length > refChildNodes.length) { 217 - const child = element.lastChild; 218 - if (child) this.#removeNode(child); 219 - } 220 - } 221 - #morphChildElement([child, reference], parent) { 222 - if (!(this.#beforeNodeMorphed?.(child, writableNode(reference)) ?? true)) return; 223 - const refIdSet = this.#idMap.get(reference); 224 - // Generate the array in advance of the loop 225 - const refSetArray = refIdSet ? [...refIdSet] : []; 226 - let currentNode = child; 227 - let nextMatchByTagName = null; 228 - // Try find a match by idSet, while also looking out for the next best match by tagName. 229 - while (currentNode) { 230 - if (isElement(currentNode)) { 231 - const id = currentNode.id; 232 - if (!nextMatchByTagName && currentNode.localName === reference.localName) { 233 - nextMatchByTagName = currentNode; 234 - } 235 - if (id !== "") { 236 - if (id === reference.id) { 237 - this.#insertBefore(parent, currentNode, child); 238 - return this.#morphNode([currentNode, reference]); 239 - } else { 240 - const currentIdSet = this.#idMap.get(currentNode); 241 - if (currentIdSet && refSetArray.some((it) => currentIdSet.has(it))) { 242 - this.#insertBefore(parent, currentNode, child); 243 - return this.#morphNode([currentNode, reference]); 244 - } 245 - } 246 - } 247 - } 248 - currentNode = currentNode.nextSibling; 249 - } 250 - if (nextMatchByTagName) { 251 - this.#insertBefore(parent, nextMatchByTagName, child); 252 - this.#morphNode([nextMatchByTagName, reference]); 253 - } else { 254 - const newNode = reference.cloneNode(true); 255 - if (this.#beforeNodeAdded?.(newNode) ?? true) { 256 - this.#insertBefore(parent, newNode, child); 257 - this.#afterNodeAdded?.(newNode); 258 - } 259 - } 260 - this.#afterNodeMorphed?.(child, writableNode(reference)); 261 - } 262 - #updateProperty(node, propertyName, newValue) { 263 - const previousValue = node[propertyName]; 264 - if (previousValue !== newValue && (this.#beforePropertyUpdated?.(node, propertyName, newValue) ?? true)) { 265 - node[propertyName] = newValue; 266 - this.#afterPropertyUpdated?.(node, propertyName, previousValue); 267 - } 268 - } 269 - #replaceNode(node, newNode) { 270 - if ((this.#beforeNodeRemoved?.(node) ?? true) && (this.#beforeNodeAdded?.(newNode) ?? true)) { 271 - node.replaceWith(newNode); 272 - this.#afterNodeAdded?.(newNode); 273 - this.#afterNodeRemoved?.(node); 274 - } 275 - } 276 - #insertBefore(parent, node, insertionPoint) { 277 - if (node === insertionPoint) return; 278 - if (isElement(node)) { 279 - const sensitivity = this.#sensivityMap.get(node) ?? 0; 280 - if (sensitivity > 0) { 281 - let previousNode = node.previousSibling; 282 - while (previousNode) { 283 - const previousNodeSensitivity = this.#sensivityMap.get(previousNode) ?? 0; 284 - if (previousNodeSensitivity < sensitivity) { 285 - parent.insertBefore(previousNode, node.nextSibling); 286 - if (previousNode === insertionPoint) return; 287 - previousNode = node.previousSibling; 288 - } else break; 289 - } 290 - } 291 - } 292 - parent.insertBefore(node, insertionPoint); 293 - } 294 - #appendChild(node, newNode) { 295 - if (this.#beforeNodeAdded?.(newNode) ?? true) { 296 - node.appendChild(newNode); 297 - this.#afterNodeAdded?.(newNode); 298 - } 299 - } 300 - #removeNode(node) { 301 - if (this.#beforeNodeRemoved?.(node) ?? true) { 302 - node.remove(); 303 - this.#afterNodeRemoved?.(node); 304 - } 305 - } 20 + #idMap; 21 + #sensivityMap; 22 + #ignoreActiveValue; 23 + #preserveModifiedValues; 24 + #beforeNodeMorphed; 25 + #afterNodeMorphed; 26 + #beforeNodeAdded; 27 + #afterNodeAdded; 28 + #beforeNodeRemoved; 29 + #afterNodeRemoved; 30 + #beforeAttributeUpdated; 31 + #afterAttributeUpdated; 32 + #beforePropertyUpdated; 33 + #afterPropertyUpdated; 34 + constructor(options = {}) { 35 + this.#idMap = new WeakMap(); 36 + this.#sensivityMap = new WeakMap(); 37 + this.#ignoreActiveValue = options.ignoreActiveValue || false; 38 + this.#preserveModifiedValues = options.preserveModifiedValues || false; 39 + this.#beforeNodeMorphed = options.beforeNodeMorphed; 40 + this.#afterNodeMorphed = options.afterNodeMorphed; 41 + this.#beforeNodeAdded = options.beforeNodeAdded; 42 + this.#afterNodeAdded = options.afterNodeAdded; 43 + this.#beforeNodeRemoved = options.beforeNodeRemoved; 44 + this.#afterNodeRemoved = options.afterNodeRemoved; 45 + this.#beforeAttributeUpdated = options.beforeAttributeUpdated; 46 + this.#afterAttributeUpdated = options.afterAttributeUpdated; 47 + this.#beforePropertyUpdated = options.beforePropertyUpdated; 48 + this.#afterPropertyUpdated = options.afterPropertyUpdated; 49 + Object.freeze(this); 50 + } 51 + morph(pair) { 52 + if (isParentNodePair(pair)) 53 + this.#buildMaps(pair); 54 + this.#morphNode(pair); 55 + } 56 + morphInner(pair) { 57 + this.#buildMaps(pair); 58 + if (isMatchingElementPair(pair)) { 59 + this.#morphMatchingElementContent(pair); 60 + } 61 + else { 62 + throw new Error("You can only do an inner morph with matching elements."); 63 + } 64 + } 65 + #buildMaps([node, reference]) { 66 + this.#mapIdSets(node); 67 + this.#mapIdSets(reference); 68 + this.#mapSensivity(node); 69 + Object.freeze(this.#idMap); 70 + Object.freeze(this.#sensivityMap); 71 + } 72 + #mapSensivity(node) { 73 + const sensitiveElements = node.querySelectorAll("audio,canvas,embed,iframe,input,object,textarea,video"); 74 + const sensitiveElementsLength = sensitiveElements.length; 75 + for (let i = 0; i < sensitiveElementsLength; i++) { 76 + const sensitiveElement = sensitiveElements[i]; 77 + let sensivity = 0; 78 + if (isInput(sensitiveElement) || isTextArea(sensitiveElement)) { 79 + sensivity++; 80 + if (sensitiveElement.value !== sensitiveElement.defaultValue) 81 + sensivity++; 82 + if (sensitiveElement === document.activeElement) 83 + sensivity++; 84 + } 85 + else { 86 + sensivity += 3; 87 + if (isMedia(sensitiveElement) && !sensitiveElement.ended) { 88 + if (!sensitiveElement.paused) 89 + sensivity++; 90 + if (sensitiveElement.currentTime > 0) 91 + sensivity++; 92 + } 93 + } 94 + let current = sensitiveElement; 95 + while (current) { 96 + this.#sensivityMap.set(current, (this.#sensivityMap.get(current) || 0) + sensivity); 97 + if (current === node) 98 + break; 99 + current = current.parentElement; 100 + } 101 + } 102 + } 103 + // For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. 104 + #mapIdSets(node) { 105 + const elementsWithIds = node.querySelectorAll("[id]"); 106 + const elementsWithIdsLength = elementsWithIds.length; 107 + for (let i = 0; i < elementsWithIdsLength; i++) { 108 + const elementWithId = elementsWithIds[i]; 109 + const id = elementWithId.id; 110 + // Ignore empty IDs 111 + if (id === "") 112 + continue; 113 + let current = elementWithId; 114 + while (current) { 115 + const idSet = this.#idMap.get(current); 116 + idSet ? idSet.add(id) : this.#idMap.set(current, new Set([id])); 117 + if (current === node) 118 + break; 119 + current = current.parentElement; 120 + } 121 + } 122 + } 123 + // This is where we actually morph the nodes. The `morph` function (above) exists only to set up the `idMap`. 124 + #morphNode(pair) { 125 + if (isMatchingElementPair(pair)) 126 + this.#morphMatchingElementNode(pair); 127 + else 128 + this.#morphOtherNode(pair); 129 + } 130 + #morphMatchingElementNode(pair) { 131 + const [node, reference] = pair; 132 + if (!(this.#beforeNodeMorphed?.(node, writableNode(reference)) ?? true)) 133 + return; 134 + if (node.hasAttributes() || reference.hasAttributes()) 135 + this.#morphAttributes(pair); 136 + // TODO: Should use a branded pair here. 137 + this.#morphMatchingElementContent(pair); 138 + this.#afterNodeMorphed?.(node, writableNode(reference)); 139 + } 140 + #morphOtherNode([node, reference]) { 141 + if (!(this.#beforeNodeMorphed?.(node, writableNode(reference)) ?? true)) 142 + return; 143 + if (node.nodeType === reference.nodeType && node.nodeValue !== null && reference.nodeValue !== null) { 144 + // Handle text nodes, comments, and CDATA sections. 145 + this.#updateProperty(node, "nodeValue", reference.nodeValue); 146 + } 147 + else 148 + this.#replaceNode(node, reference.cloneNode(true)); 149 + this.#afterNodeMorphed?.(node, writableNode(reference)); 150 + } 151 + #morphMatchingElementContent(pair) { 152 + const [node, reference] = pair; 153 + if (isHead(node)) { 154 + // We can pass the reference as a head here becuase we know it's the same as the node. 155 + this.#morphHeadContents(pair); 156 + } 157 + else if (node.hasChildNodes() || reference.hasChildNodes()) 158 + this.#morphChildNodes(pair); 159 + } 160 + #morphHeadContents([node, reference]) { 161 + const refChildNodesMap = new Map(); 162 + // Generate a map of the reference head element’s child nodes, keyed by their outerHTML. 163 + const referenceChildrenLength = reference.children.length; 164 + for (let i = 0; i < referenceChildrenLength; i++) { 165 + const child = reference.children[i]; 166 + refChildNodesMap.set(child.outerHTML, child); 167 + } 168 + const nodeChildrenLength = node.children.length; 169 + for (let i = 0; i < nodeChildrenLength; i++) { 170 + const child = node.children[i]; 171 + const key = child.outerHTML; 172 + const refChild = refChildNodesMap.get(key); 173 + // If the child is in the reference map already, we don’t need to add it later. 174 + // If it’s not in the map, we need to remove it from the node. 175 + refChild ? refChildNodesMap.delete(key) : this.#removeNode(child); 176 + } 177 + // Any remaining nodes in the map should be appended to the head. 178 + for (const refChild of refChildNodesMap.values()) 179 + this.#appendChild(node, refChild.cloneNode(true)); 180 + } 181 + #morphAttributes([element, reference]) { 182 + // Remove any excess attributes from the element that aren’t present in the reference. 183 + for (const { name, value } of element.attributes) { 184 + if (!reference.hasAttribute(name) && (this.#beforeAttributeUpdated?.(element, name, null) ?? true)) { 185 + element.removeAttribute(name); 186 + this.#afterAttributeUpdated?.(element, name, value); 187 + } 188 + } 189 + // Copy attributes from the reference to the element, if they don’t already match. 190 + for (const { name, value } of reference.attributes) { 191 + const previousValue = element.getAttribute(name); 192 + if (previousValue !== value && (this.#beforeAttributeUpdated?.(element, name, value) ?? true)) { 193 + element.setAttribute(name, value); 194 + this.#afterAttributeUpdated?.(element, name, previousValue); 195 + } 196 + } 197 + // For certain types of elements, we need to do some extra work to ensure 198 + // the element’s state matches the reference elements’ state. 199 + if (isInput(element) && isInput(reference)) { 200 + this.#updateProperty(element, "checked", reference.checked); 201 + this.#updateProperty(element, "disabled", reference.disabled); 202 + this.#updateProperty(element, "indeterminate", reference.indeterminate); 203 + if (element.type !== "file" && 204 + !(this.#ignoreActiveValue && document.activeElement === element) && 205 + !(this.#preserveModifiedValues && element.name === reference.name && element.value !== element.defaultValue)) { 206 + this.#updateProperty(element, "value", reference.value); 207 + } 208 + } 209 + else if (isOption(element) && isOption(reference)) { 210 + this.#updateProperty(element, "selected", reference.selected); 211 + } 212 + else if (isTextArea(element) && 213 + isTextArea(reference) && 214 + !(this.#ignoreActiveValue && document.activeElement === element) && 215 + !(this.#preserveModifiedValues && element.name === reference.name && element.value !== element.defaultValue)) { 216 + this.#updateProperty(element, "value", reference.value); 217 + const text = element.firstElementChild; 218 + if (text) 219 + this.#updateProperty(text, "textContent", reference.value); 220 + } 221 + } 222 + // Iterates over the child nodes of the reference element, morphing the main element’s child nodes to match. 223 + #morphChildNodes(pair) { 224 + const [element, reference] = pair; 225 + const childNodes = element.childNodes; 226 + const refChildNodes = reference.childNodes; 227 + for (let i = 0; i < refChildNodes.length; i++) { 228 + const child = childNodes[i]; 229 + const refChild = refChildNodes[i]; 230 + if (child && refChild) { 231 + const pair = [child, refChild]; 232 + if (isMatchingElementPair(pair)) { 233 + if (isHead(pair[0])) { 234 + this.#morphHeadContents(pair); 235 + } 236 + else { 237 + this.#morphChildElement(pair, element); 238 + } 239 + } 240 + else 241 + this.#morphOtherNode(pair); 242 + } 243 + else if (refChild) { 244 + this.#appendChild(element, refChild.cloneNode(true)); 245 + } 246 + else if (child) { 247 + this.#removeNode(child); 248 + } 249 + } 250 + // Clean up any excess nodes that may be left over 251 + while (childNodes.length > refChildNodes.length) { 252 + const child = element.lastChild; 253 + if (child) 254 + this.#removeNode(child); 255 + } 256 + } 257 + #morphChildElement([child, reference], parent) { 258 + if (!(this.#beforeNodeMorphed?.(child, writableNode(reference)) ?? true)) 259 + return; 260 + const refIdSet = this.#idMap.get(reference); 261 + // Generate the array in advance of the loop 262 + const refSetArray = refIdSet ? [...refIdSet] : []; 263 + let currentNode = child; 264 + let nextMatchByTagName = null; 265 + // Try find a match by idSet, while also looking out for the next best match by tagName. 266 + while (currentNode) { 267 + if (isElement(currentNode)) { 268 + const id = currentNode.id; 269 + if (!nextMatchByTagName && currentNode.localName === reference.localName) { 270 + nextMatchByTagName = currentNode; 271 + } 272 + if (id !== "") { 273 + if (id === reference.id) { 274 + this.#insertBefore(parent, currentNode, child); 275 + return this.#morphNode([currentNode, reference]); 276 + } 277 + else { 278 + const currentIdSet = this.#idMap.get(currentNode); 279 + if (currentIdSet && refSetArray.some((it) => currentIdSet.has(it))) { 280 + this.#insertBefore(parent, currentNode, child); 281 + return this.#morphNode([currentNode, reference]); 282 + } 283 + } 284 + } 285 + } 286 + currentNode = currentNode.nextSibling; 287 + } 288 + if (nextMatchByTagName) { 289 + this.#insertBefore(parent, nextMatchByTagName, child); 290 + this.#morphNode([nextMatchByTagName, reference]); 291 + } 292 + else { 293 + const newNode = reference.cloneNode(true); 294 + if (this.#beforeNodeAdded?.(newNode) ?? true) { 295 + this.#insertBefore(parent, newNode, child); 296 + this.#afterNodeAdded?.(newNode); 297 + } 298 + } 299 + this.#afterNodeMorphed?.(child, writableNode(reference)); 300 + } 301 + #updateProperty(node, propertyName, newValue) { 302 + const previousValue = node[propertyName]; 303 + if (previousValue !== newValue && (this.#beforePropertyUpdated?.(node, propertyName, newValue) ?? true)) { 304 + node[propertyName] = newValue; 305 + this.#afterPropertyUpdated?.(node, propertyName, previousValue); 306 + } 307 + } 308 + #replaceNode(node, newNode) { 309 + if ((this.#beforeNodeRemoved?.(node) ?? true) && (this.#beforeNodeAdded?.(newNode) ?? true)) { 310 + node.replaceWith(newNode); 311 + this.#afterNodeAdded?.(newNode); 312 + this.#afterNodeRemoved?.(node); 313 + } 314 + } 315 + #insertBefore(parent, node, insertionPoint) { 316 + if (node === insertionPoint) 317 + return; 318 + if (isElement(node)) { 319 + const sensitivity = this.#sensivityMap.get(node) ?? 0; 320 + if (sensitivity > 0) { 321 + let previousNode = node.previousSibling; 322 + while (previousNode) { 323 + const previousNodeSensitivity = this.#sensivityMap.get(previousNode) ?? 0; 324 + if (previousNodeSensitivity < sensitivity) { 325 + parent.insertBefore(previousNode, node.nextSibling); 326 + if (previousNode === insertionPoint) 327 + return; 328 + previousNode = node.previousSibling; 329 + } 330 + else 331 + break; 332 + } 333 + } 334 + } 335 + parent.insertBefore(node, insertionPoint); 336 + } 337 + #appendChild(node, newNode) { 338 + if (this.#beforeNodeAdded?.(newNode) ?? true) { 339 + node.appendChild(newNode); 340 + this.#afterNodeAdded?.(newNode); 341 + } 342 + } 343 + #removeNode(node) { 344 + if (this.#beforeNodeRemoved?.(node) ?? true) { 345 + node.remove(); 346 + this.#afterNodeRemoved?.(node); 347 + } 348 + } 306 349 } 307 350 // We cannot use `instanceof` when nodes might be from different documents, 308 351 // so we use type guards instead. This keeps TypeScript happy, while doing 309 352 // the necessary checks at runtime. 310 353 function writableNode(node) { 311 - return node; 354 + return node; 312 355 } 313 356 function isMatchingElementPair(pair) { 314 - const [a, b] = pair; 315 - return isElement(a) && isElement(b) && a.localName === b.localName; 357 + const [a, b] = pair; 358 + return isElement(a) && isElement(b) && a.localName === b.localName; 316 359 } 317 360 function isParentNodePair(pair) { 318 - const [a, b] = pair; 319 - return isParentNode(a) && isParentNode(b); 361 + const [a, b] = pair; 362 + return isParentNode(a) && isParentNode(b); 320 363 } 321 364 function isElement(node) { 322 - return node.nodeType === 1; 365 + return node.nodeType === 1; 323 366 } 324 367 function isMedia(element) { 325 - return element.localName === "video" || element.localName === "audio"; 368 + return element.localName === "video" || element.localName === "audio"; 326 369 } 327 370 function isInput(element) { 328 - return element.localName === "input"; 371 + return element.localName === "input"; 329 372 } 330 373 function isOption(element) { 331 - return element.localName === "option"; 374 + return element.localName === "option"; 332 375 } 333 376 function isTextArea(element) { 334 - return element.localName === "textarea"; 377 + return element.localName === "textarea"; 335 378 } 336 379 function isHead(element) { 337 - return element.localName === "head"; 380 + return element.localName === "head"; 338 381 } 339 382 const parentNodeTypes = new Set([1, 9, 11]); 340 383 function isParentNode(node) { 341 - return parentNodeTypes.has(node.nodeType); 384 + return parentNodeTypes.has(node.nodeType); 342 385 } 343 - //# sourceMappingURL=morphlex.js.map 386 + //# sourceMappingURL=morphlex.js.map
+15 -4
src/morphlex.ts
··· 131 131 132 132 #mapSensivity(node: ReadonlyNode<ParentNode>): void { 133 133 const sensitiveElements = node.querySelectorAll("audio,canvas,embed,iframe,input,object,textarea,video"); 134 - for (const sensitiveElement of sensitiveElements) { 134 + 135 + const sensitiveElementsLength = sensitiveElements.length; 136 + for (let i = 0; i < sensitiveElementsLength; i++) { 137 + const sensitiveElement = sensitiveElements[i]; 135 138 let sensivity = 0; 136 139 137 140 if (isInput(sensitiveElement) || isTextArea(sensitiveElement)) { ··· 161 164 #mapIdSets(node: ReadonlyNode<ParentNode>): void { 162 165 const elementsWithIds = node.querySelectorAll("[id]"); 163 166 164 - for (const elementWithId of elementsWithIds) { 167 + const elementsWithIdsLength = elementsWithIds.length; 168 + for (let i = 0; i < elementsWithIdsLength; i++) { 169 + const elementWithId = elementsWithIds[i]; 165 170 const id = elementWithId.id; 166 171 167 172 // Ignore empty IDs ··· 221 226 const refChildNodesMap: Map<string, ReadonlyNode<Element>> = new Map(); 222 227 223 228 // Generate a map of the reference head element’s child nodes, keyed by their outerHTML. 224 - for (const child of reference.children) refChildNodesMap.set(child.outerHTML, child); 229 + const referenceChildrenLength = reference.children.length; 230 + for (let i = 0; i < referenceChildrenLength; i++) { 231 + const child = reference.children[i]; 232 + refChildNodesMap.set(child.outerHTML, child); 233 + } 225 234 226 - for (const child of node.children) { 235 + const nodeChildrenLength = node.children.length; 236 + for (let i = 0; i < nodeChildrenLength; i++) { 237 + const child = node.children[i]; 227 238 const key = child.outerHTML; 228 239 const refChild = refChildNodesMap.get(key); 229 240