Precise DOM morphing
morphing typescript dom

Add coverage to .gitignore

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