Precise DOM morphing
morphing typescript dom
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at cb64636eae73e4012cc0d75bc052d43c45192f19 810 lines 25 kB view raw
1const SUPPORTS_MOVE_BEFORE = "moveBefore" in Element.prototype 2const ELEMENT_NODE_TYPE = 1 3const TEXT_NODE_TYPE = 3 4 5const IS_PARENT_NODE_TYPE = [ 6 0, // 0: (unused) 7 1, // 1: Element 8 0, // 2: Attribute (deprecated) 9 0, // 3: Text 10 0, // 4: CDATASection (deprecated) 11 0, // 5: EntityReference (deprecated) 12 0, // 6: Entity (deprecated) 13 0, // 7: ProcessingInstruction 14 0, // 8: Comment 15 1, // 9: Document 16 0, // 10: DocumentType 17 1, // 11: DocumentFragment 18 0, // 12: Notation (deprecated) 19] 20 21const Operation = { 22 EqualNode: 0, 23 SameElement: 1, 24 SameNode: 2, 25} as const 26 27type Operation = (typeof Operation)[keyof typeof Operation] 28 29const candidateNodes: Set<number> = new Set() 30const candidateElements: Set<number> = new Set() 31const unmatchedNodes: Set<number> = new Set() 32const unmatchedElements: Set<number> = new Set() 33const whitespaceNodes: Set<number> = new Set() 34 35type IdSetMap = WeakMap<Node, Set<string>> 36type IdArrayMap = WeakMap<Node, Array<string>> 37 38/** 39 * Configuration options for morphing operations. 40 */ 41export interface Options { 42 /** 43 * When `true`, preserves modified form inputs during morphing. 44 * This prevents user-entered data from being overwritten. 45 * @default false 46 */ 47 preserveChanges?: boolean 48 49 /** 50 * Called before a node is visited during morphing. 51 * @param fromNode The existing node in the DOM 52 * @param toNode The new node to morph to 53 * @returns `false` to skip morphing this node, `true` to continue 54 */ 55 beforeNodeVisited?: (fromNode: Node, toNode: Node) => boolean 56 57 /** 58 * Called after a node has been visited and morphed. 59 * @param fromNode The morphed node in the DOM 60 * @param toNode The source node that was morphed from 61 */ 62 afterNodeVisited?: (fromNode: Node, toNode: Node) => void 63 64 /** 65 * Called before a new node is added to the DOM. 66 * @param parent The parent node where the child will be added 67 * @param node The node to be added 68 * @param insertionPoint The node before which the new node will be inserted, or `null` to append 69 * @returns `false` to prevent adding the node, `true` to continue 70 */ 71 beforeNodeAdded?: (parent: ParentNode, node: Node, insertionPoint: ChildNode | null) => boolean 72 73 /** 74 * Called after a node has been added to the DOM. 75 * @param node The node that was added 76 */ 77 afterNodeAdded?: (node: Node) => void 78 79 /** 80 * Called before a node is removed from the DOM. 81 * @param node The node to be removed 82 * @returns `false` to prevent removal, `true` to continue 83 */ 84 beforeNodeRemoved?: (node: Node) => boolean 85 86 /** 87 * Called after a node has been removed from the DOM. 88 * @param node The node that was removed 89 */ 90 afterNodeRemoved?: (node: Node) => void 91 92 /** 93 * Called before an attribute is updated on an element. 94 * @param element The element whose attribute will be updated 95 * @param attributeName The name of the attribute 96 * @param newValue The new value for the attribute, or `null` if being removed 97 * @returns `false` to prevent the update, `true` to continue 98 */ 99 beforeAttributeUpdated?: (element: Element, attributeName: string, newValue: string | null) => boolean 100 101 /** 102 * Called after an attribute has been updated on an element. 103 * @param element The element whose attribute was updated 104 * @param attributeName The name of the attribute 105 * @param previousValue The previous value of the attribute, or `null` if it didn't exist 106 */ 107 afterAttributeUpdated?: (element: Element, attributeName: string, previousValue: string | null) => void 108 109 /** 110 * Called before an element's children are visited during morphing. 111 * @param parent The parent node whose children will be visited 112 * @returns `false` to skip visiting children, `true` to continue 113 */ 114 beforeChildrenVisited?: (parent: ParentNode) => boolean 115 116 /** 117 * Called after an element's children have been visited and morphed. 118 * @param parent The parent node whose children were visited 119 */ 120 afterChildrenVisited?: (parent: ParentNode) => void 121} 122 123type NodeWithMoveBefore = ParentNode & { 124 moveBefore: (node: ChildNode, before: ChildNode | null) => void 125} 126 127/** 128 * Morph one document to another. If the `to` document is a string, it will be parsed with a DOMParser. 129 * 130 * @param from The source document to morph from. 131 * @param to The target document or string to morph to. 132 * @param options Optional configuration for the morphing behavior. 133 * @example 134 * ```ts 135 * morphDocument(document, "<html>...</html>", { preserveChanges: true }) 136 * ``` 137 */ 138export function morphDocument(from: Document, to: Document | string, options?: Options): void { 139 if (typeof to === "string") to = parseDocument(to) 140 morph(from.documentElement, to.documentElement, options) 141} 142 143/** 144 * Morph one `ChildNode` to another. If the `to` node is a string, it will be parsed with a `<template>` element. 145 * 146 * @param from The source node to morph from. 147 * @param to The target node, node list or string to morph to. 148 * @example 149 * ```ts 150 * morph(originalDom, newDom) 151 * ``` 152 */ 153export function morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode> | string, options: Options = {}): void { 154 if (typeof to === "string") to = parseFragment(to).childNodes 155 156 if (!options.preserveChanges && isParentNode(from)) flagDirtyInputs(from) 157 158 new Morph(options).morph(from, to) 159} 160 161/** 162 * Morph the inner content of one ChildNode to the inner content of another. 163 * If the `to` node is a string, it will be parsed with a `<template>` element. 164 * 165 * @param from The source node to morph from. 166 * @param to The target node, node list or string to morph to. 167 * @example 168 * ```ts 169 * morphInner(originalDom, newDom) 170 * ``` 171 */ 172export function morphInner(from: ChildNode, to: ChildNode | string, options: Options = {}): void { 173 if (typeof to === "string") { 174 const fragment = parseFragment(to) 175 176 if (fragment.firstChild && fragment.childNodes.length === 1 && fragment.firstChild.nodeType === ELEMENT_NODE_TYPE) { 177 to = fragment.firstChild 178 } else { 179 throw new Error("[Morphlex] The string was not a valid HTML element.") 180 } 181 } 182 183 if ( 184 from.nodeType === ELEMENT_NODE_TYPE && 185 to.nodeType === ELEMENT_NODE_TYPE && 186 (from as Element).localName === (to as Element).localName 187 ) { 188 if (isParentNode(from)) flagDirtyInputs(from) 189 new Morph(options).visitChildNodes(from as Element, to as Element) 190 } else { 191 throw new Error("[Morphlex] You can only do an inner morph with matching elements.") 192 } 193} 194 195function flagDirtyInputs(node: ParentNode): void { 196 for (const input of node.querySelectorAll("input")) { 197 if ((input.name && input.value !== input.defaultValue) || input.checked !== input.defaultChecked) { 198 input.setAttribute("morphlex-dirty", "") 199 } 200 } 201 202 for (const element of node.querySelectorAll("option")) { 203 if (element.value && element.selected !== element.defaultSelected) { 204 element.setAttribute("morphlex-dirty", "") 205 } 206 } 207 208 for (const element of node.querySelectorAll("textarea")) { 209 if (element.value !== element.defaultValue) { 210 element.setAttribute("morphlex-dirty", "") 211 } 212 } 213} 214 215function parseFragment(string: string): DocumentFragment { 216 const template = document.createElement("template") 217 template.innerHTML = string.trim() 218 219 return template.content 220} 221 222function parseDocument(string: string): Document { 223 const parser = new DOMParser() 224 return parser.parseFromString(string.trim(), "text/html") 225} 226 227function moveBefore(parent: ParentNode, node: ChildNode, insertionPoint: ChildNode | null): void { 228 if (node === insertionPoint) return 229 if (node.parentNode === parent) { 230 if (node.nextSibling === insertionPoint) return 231 if (SUPPORTS_MOVE_BEFORE) { 232 ;(parent as NodeWithMoveBefore).moveBefore(node, insertionPoint) 233 return 234 } 235 } 236 237 parent.insertBefore(node, insertionPoint) 238} 239 240class Morph { 241 readonly #idArrayMap: IdArrayMap = new WeakMap() 242 readonly #idSetMap: IdSetMap = new WeakMap() 243 readonly #options: Options 244 245 constructor(options: Options = {}) { 246 this.#options = options 247 } 248 249 // Find longest increasing subsequence to minimize moves during reordering 250 // Returns the indices in the sequence that form the LIS 251 #longestIncreasingSubsequence(sequence: Array<number | undefined>): Array<number> { 252 const n = sequence.length 253 if (n === 0) return [] 254 255 // smallestEnding[i] = smallest ending value of any increasing subsequence of length i+1 256 const smallestEnding: Array<number> = [] 257 // indices[i] = index in sequence where smallestEnding[i] occurs 258 const indices: Array<number> = [] 259 // prev[i] = previous index in the LIS ending at sequence[i] 260 const prev: Array<number> = new Array(n) 261 262 // Build the LIS by processing each value 263 for (let i = 0; i < n; i++) { 264 const val = sequence[i] 265 if (val === undefined) continue // Skip new nodes (not in original sequence) 266 267 // Binary search: find where this value fits in smallestEnding 268 let left = 0 269 let right = smallestEnding.length 270 271 while (left < right) { 272 const mid = Math.floor((left + right) / 2) 273 if (smallestEnding[mid]! < val) left = mid + 1 274 else right = mid 275 } 276 277 // Link this element to the previous one in the subsequence 278 prev[i] = left > 0 ? indices[left - 1]! : -1 279 280 // Either extend the sequence or update an existing position 281 if (left === smallestEnding.length) { 282 // Extend: this value is larger than all previous endings 283 smallestEnding.push(val) 284 indices.push(i) 285 } else { 286 // Update: found a better (smaller) ending for this length 287 smallestEnding[left] = val 288 indices[left] = i 289 } 290 } 291 292 // Reconstruct the actual indices that form the LIS 293 const result: Array<number> = [] 294 if (indices.length === 0) return result 295 296 // Walk backwards through prev links to build the LIS 297 let curr: number | undefined = indices[indices.length - 1] 298 while (curr !== undefined && curr !== -1) { 299 result.unshift(curr) 300 curr = prev[curr] 301 } 302 303 return result 304 } 305 306 morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode>): void { 307 if (isParentNode(from)) { 308 this.#mapIdSets(from) 309 } 310 311 if (to instanceof NodeList) { 312 this.#mapIdArraysForEach(to) 313 this.#morphOneToMany(from, to) 314 } else if (isParentNode(to)) { 315 this.#mapIdArrays(to) 316 this.#morphOneToOne(from, to) 317 } 318 } 319 320 #morphOneToMany(from: ChildNode, to: NodeListOf<ChildNode>): void { 321 const length = to.length 322 323 if (length === 0) { 324 this.#removeNode(from) 325 } else if (length === 1) { 326 this.#morphOneToOne(from, to[0]!) 327 } else if (length > 1) { 328 const newNodes = [...to] 329 this.#morphOneToOne(from, newNodes.shift()!) 330 const insertionPoint = from.nextSibling 331 const parent = from.parentNode || document 332 333 for (let i = 0; i < newNodes.length; i++) { 334 const newNode = newNodes[i]! 335 if (this.#options.beforeNodeAdded?.(parent, newNode, insertionPoint) ?? true) { 336 parent.insertBefore(newNode, insertionPoint) 337 this.#options.afterNodeAdded?.(newNode) 338 } 339 } 340 } 341 } 342 343 #morphOneToOne(from: ChildNode, to: ChildNode): void { 344 // Fast path: if nodes are exactly the same object, skip morphing 345 if (from === to) return 346 if (from.isEqualNode(to)) return 347 348 if (from.nodeType === ELEMENT_NODE_TYPE && to.nodeType === ELEMENT_NODE_TYPE) { 349 if ((from as Element).localName === (to as Element).localName) { 350 this.#morphMatchingElements(from as Element, to as Element) 351 } else { 352 this.#morphNonMatchingElements(from as Element, to as Element) 353 } 354 } else { 355 this.#morphOtherNode(from, to) 356 } 357 } 358 359 #morphMatchingElements(from: Element, to: Element): void { 360 if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return 361 362 if (from.hasAttributes() || to.hasAttributes()) { 363 this.#visitAttributes(from, to) 364 } 365 366 if ("textarea" === from.localName && "textarea" === to.localName) { 367 this.#visitTextArea(from as HTMLTextAreaElement, to as HTMLTextAreaElement) 368 } else if (from.hasChildNodes() || to.hasChildNodes()) { 369 this.visitChildNodes(from, to) 370 } 371 372 this.#options.afterNodeVisited?.(from, to) 373 } 374 375 #morphNonMatchingElements(from: Element, to: Element): void { 376 if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return 377 378 this.#replaceNode(from, to) 379 380 this.#options.afterNodeVisited?.(from, to) 381 } 382 383 #morphOtherNode(from: ChildNode, to: ChildNode): void { 384 if (!(this.#options.beforeNodeVisited?.(from, to) ?? true)) return 385 386 if (from.nodeType === to.nodeType && from.nodeValue !== null && to.nodeValue !== null) { 387 from.nodeValue = to.nodeValue 388 } else { 389 this.#replaceNode(from, to) 390 } 391 392 this.#options.afterNodeVisited?.(from, to) 393 } 394 395 #visitAttributes(from: Element, to: Element): void { 396 if (from.hasAttribute("morphlex-dirty")) { 397 from.removeAttribute("morphlex-dirty") 398 } 399 400 // First pass: update/add attributes from reference (iterate forwards) 401 for (const { name, value } of to.attributes) { 402 if (name === "value") { 403 if (isInputElement(from) && from.value !== value) { 404 if (!this.#options.preserveChanges || from.value === from.defaultValue) { 405 from.value = value 406 } 407 } 408 } 409 410 if (name === "selected") { 411 if (isOptionElement(from) && !from.selected) { 412 if (!this.#options.preserveChanges || from.selected === from.defaultSelected) { 413 from.selected = true 414 } 415 } 416 } 417 418 if (name === "checked") { 419 if (isInputElement(from) && !from.checked) { 420 if (!this.#options.preserveChanges || from.checked === from.defaultChecked) { 421 from.checked = true 422 } 423 } 424 } 425 426 const oldValue = from.getAttribute(name) 427 428 if (oldValue !== value && (this.#options.beforeAttributeUpdated?.(from, name, value) ?? true)) { 429 from.setAttribute(name, value) 430 this.#options.afterAttributeUpdated?.(from, name, oldValue) 431 } 432 } 433 434 // Second pass: remove excess attributes 435 for (const { name, value } of from.attributes) { 436 if (!to.hasAttribute(name)) { 437 if (name === "selected") { 438 if (isOptionElement(from) && from.selected) { 439 if (!this.#options.preserveChanges || from.selected === from.defaultSelected) { 440 from.selected = false 441 } 442 } 443 } 444 445 if (name === "checked") { 446 if (isInputElement(from) && from.checked) { 447 if (!this.#options.preserveChanges || from.checked === from.defaultChecked) { 448 from.checked = false 449 } 450 } 451 } 452 453 if (this.#options.beforeAttributeUpdated?.(from, name, null) ?? true) { 454 from.removeAttribute(name) 455 this.#options.afterAttributeUpdated?.(from, name, value) 456 } 457 } 458 } 459 } 460 461 #visitTextArea(from: HTMLTextAreaElement, to: HTMLTextAreaElement): void { 462 const newTextContent = to.textContent || "" 463 const isModified = from.value !== from.defaultValue 464 465 // Update text content (which updates defaultValue) 466 if (from.textContent !== newTextContent) { 467 from.textContent = newTextContent 468 } 469 470 if (this.#options.preserveChanges && isModified) return 471 472 from.value = from.defaultValue 473 } 474 475 visitChildNodes(from: Element, to: Element): void { 476 if (!(this.#options.beforeChildrenVisited?.(from) ?? true)) return 477 const parent = from 478 479 const fromChildNodes = [...from.childNodes] 480 const toChildNodes = [...to.childNodes] 481 482 candidateNodes.clear() 483 candidateElements.clear() 484 unmatchedNodes.clear() 485 unmatchedElements.clear() 486 whitespaceNodes.clear() 487 488 const seq: Array<number> = [] 489 const matches: Array<number> = [] 490 const op: Array<Operation> = [] 491 const nodeTypeMap: Array<number> = [] 492 const candidateNodeTypeMap: Array<number> = [] 493 const localNameMap: Array<string> = [] 494 const candidateLocalNameMap: Array<string> = [] 495 496 for (let i = 0; i < fromChildNodes.length; i++) { 497 const candidate = fromChildNodes[i]! 498 const nodeType = candidate.nodeType 499 candidateNodeTypeMap[i] = nodeType 500 501 if (nodeType === ELEMENT_NODE_TYPE) { 502 candidateLocalNameMap[i] = (candidate as Element).localName 503 candidateElements.add(i) 504 } else if (nodeType === TEXT_NODE_TYPE && candidate.textContent?.trim() === "") { 505 whitespaceNodes.add(i) 506 } else { 507 candidateNodes.add(i) 508 } 509 } 510 511 for (let i = 0; i < toChildNodes.length; i++) { 512 const node = toChildNodes[i]! 513 const nodeType = node.nodeType 514 nodeTypeMap[i] = nodeType 515 516 if (nodeType === ELEMENT_NODE_TYPE) { 517 localNameMap[i] = (node as Element).localName 518 unmatchedElements.add(i) 519 } else if (nodeType === TEXT_NODE_TYPE && node.textContent?.trim() === "") { 520 continue 521 } else { 522 unmatchedNodes.add(i) 523 } 524 } 525 526 // Match elements by isEqualNode 527 for (const unmatchedIndex of unmatchedElements) { 528 const localName = localNameMap[unmatchedIndex] 529 const element = toChildNodes[unmatchedIndex] as Element 530 531 for (const candidateIndex of candidateElements) { 532 if (localName !== candidateLocalNameMap[candidateIndex]) continue 533 const candidate = fromChildNodes[candidateIndex] as Element 534 535 if (candidate.isEqualNode(element)) { 536 matches[unmatchedIndex] = candidateIndex 537 op[unmatchedIndex] = Operation.EqualNode 538 seq[candidateIndex] = unmatchedIndex 539 candidateElements.delete(candidateIndex) 540 unmatchedElements.delete(unmatchedIndex) 541 break 542 } 543 } 544 } 545 546 // Match by exact id or idSet 547 for (const unmatchedIndex of unmatchedElements) { 548 const element = toChildNodes[unmatchedIndex] as Element 549 550 const id = element.id 551 const idArray = this.#idArrayMap.get(element) 552 553 if (id === "" && !idArray) continue 554 555 candidateLoop: for (const candidateIndex of candidateElements) { 556 const candidate = fromChildNodes[candidateIndex] as Element 557 558 if (localNameMap[unmatchedIndex] === candidateLocalNameMap[candidateIndex]) { 559 // Match by exact id 560 if (id !== "" && id === candidate.id) { 561 matches[unmatchedIndex] = candidateIndex 562 op[unmatchedIndex] = Operation.SameElement 563 seq[candidateIndex] = unmatchedIndex 564 candidateElements.delete(candidateIndex) 565 unmatchedElements.delete(unmatchedIndex) 566 break candidateLoop 567 } 568 569 // Match by idArray (to) against idSet (from) 570 if (idArray) { 571 const candidateIdSet = this.#idSetMap.get(candidate) 572 if (candidateIdSet) { 573 for (let i = 0; i < idArray.length; i++) { 574 const arrayId = idArray[i]! 575 if (candidateIdSet.has(arrayId)) { 576 matches[unmatchedIndex] = candidateIndex 577 op[unmatchedIndex] = Operation.SameElement 578 seq[candidateIndex] = unmatchedIndex 579 candidateElements.delete(candidateIndex) 580 unmatchedElements.delete(unmatchedIndex) 581 break candidateLoop 582 } 583 } 584 } 585 } 586 } 587 } 588 } 589 590 // Match by heuristics 591 for (const unmatchedIndex of unmatchedElements) { 592 const element = toChildNodes[unmatchedIndex] as Element 593 594 const name = element.getAttribute("name") 595 const href = element.getAttribute("href") 596 const src = element.getAttribute("src") 597 598 for (const candidateIndex of candidateElements) { 599 const candidate = fromChildNodes[candidateIndex] as Element 600 if ( 601 localNameMap[unmatchedIndex] === candidateLocalNameMap[candidateIndex] && 602 ((name && name === candidate.getAttribute("name")) || 603 (href && href === candidate.getAttribute("href")) || 604 (src && src === candidate.getAttribute("src"))) 605 ) { 606 matches[unmatchedIndex] = candidateIndex 607 seq[candidateIndex] = unmatchedIndex 608 op[unmatchedIndex] = Operation.SameElement 609 candidateElements.delete(candidateIndex) 610 unmatchedElements.delete(unmatchedIndex) 611 break 612 } 613 } 614 } 615 616 // Match by tagName 617 for (const unmatchedIndex of unmatchedElements) { 618 const element = toChildNodes[unmatchedIndex] as Element 619 620 const localName = localNameMap[unmatchedIndex] 621 622 for (const candidateIndex of candidateElements) { 623 const candidate = fromChildNodes[candidateIndex] as Element 624 const candidateLocalName = candidateLocalNameMap[candidateIndex] 625 626 if (localName === candidateLocalName) { 627 if (localName === "input" && (candidate as HTMLInputElement).type !== (element as HTMLInputElement).type) { 628 // Treat inputs with different type as though they are different tags. 629 continue 630 } 631 matches[unmatchedIndex] = candidateIndex 632 seq[candidateIndex] = unmatchedIndex 633 op[unmatchedIndex] = Operation.SameElement 634 candidateElements.delete(candidateIndex) 635 unmatchedElements.delete(unmatchedIndex) 636 break 637 } 638 } 639 } 640 641 // Match nodes by isEqualNode (skip whitespace-only text nodes) 642 for (const unmatchedIndex of unmatchedNodes) { 643 const node = toChildNodes[unmatchedIndex]! 644 645 for (const candidateIndex of candidateNodes) { 646 const candidate = fromChildNodes[candidateIndex]! 647 if (candidate.isEqualNode(node)) { 648 matches[unmatchedIndex] = candidateIndex 649 op[unmatchedIndex] = Operation.EqualNode 650 seq[candidateIndex] = unmatchedIndex 651 candidateNodes.delete(candidateIndex) 652 unmatchedNodes.delete(unmatchedIndex) 653 break 654 } 655 } 656 } 657 658 // Match by nodeType 659 for (const unmatchedIndex of unmatchedNodes) { 660 const nodeType = nodeTypeMap[unmatchedIndex] 661 662 for (const candidateIndex of candidateNodes) { 663 if (nodeType === candidateNodeTypeMap[candidateIndex]) { 664 matches[unmatchedIndex] = candidateIndex 665 op[unmatchedIndex] = Operation.SameNode 666 seq[candidateIndex] = unmatchedIndex 667 candidateNodes.delete(candidateIndex) 668 unmatchedNodes.delete(unmatchedIndex) 669 break 670 } 671 } 672 } 673 674 // Remove any unmatched candidates first, before calculating LIS and repositioning 675 for (const i of candidateNodes) this.#removeNode(fromChildNodes[i]!) 676 for (const i of whitespaceNodes) this.#removeNode(fromChildNodes[i]!) 677 for (const i of candidateElements) this.#removeNode(fromChildNodes[i]!) 678 679 // Find LIS - these nodes don't need to move 680 // matches already contains the fromChildNodes indices, so we can use it directly 681 const lisIndices = this.#longestIncreasingSubsequence(matches) 682 683 const shouldNotMove: Array<boolean> = new Array(fromChildNodes.length) 684 for (let i = 0; i < lisIndices.length; i++) { 685 shouldNotMove[matches[lisIndices[i]!]!] = true 686 } 687 688 let insertionPoint: ChildNode | null = parent.firstChild 689 for (let i = 0; i < toChildNodes.length; i++) { 690 const node = toChildNodes[i]! 691 const matchInd = matches[i] 692 if (matchInd !== undefined) { 693 const match = fromChildNodes[matchInd]! 694 const operation = op[matchInd]! 695 696 if (!shouldNotMove[matchInd]) { 697 moveBefore(parent, match, insertionPoint) 698 } 699 700 if (operation === Operation.EqualNode) { 701 } else if (operation === Operation.SameElement) { 702 // this.#morphMatchingElements(match as Element, node as Element) 703 this.#morphMatchingElements(match as Element, node as Element) 704 } else { 705 this.#morphOneToOne(match, node) 706 } 707 708 insertionPoint = match.nextSibling 709 } else { 710 if (this.#options.beforeNodeAdded?.(parent, node, insertionPoint) ?? true) { 711 parent.insertBefore(node, insertionPoint) 712 this.#options.afterNodeAdded?.(node) 713 insertionPoint = node.nextSibling 714 } 715 } 716 } 717 718 this.#options.afterChildrenVisited?.(from) 719 } 720 721 #replaceNode(node: ChildNode, newNode: ChildNode): void { 722 const parent = node.parentNode || document 723 const insertionPoint = node 724 // Check if both removal and addition are allowed before starting the replacement 725 if ( 726 (this.#options.beforeNodeRemoved?.(node) ?? true) && 727 (this.#options.beforeNodeAdded?.(parent, newNode, insertionPoint) ?? true) 728 ) { 729 parent.insertBefore(newNode, insertionPoint) 730 this.#options.afterNodeAdded?.(newNode) 731 node.remove() 732 this.#options.afterNodeRemoved?.(node) 733 } 734 } 735 736 #removeNode(node: ChildNode): void { 737 if (this.#options.beforeNodeRemoved?.(node) ?? true) { 738 node.remove() 739 this.#options.afterNodeRemoved?.(node) 740 } 741 } 742 743 #mapIdArraysForEach(nodeList: NodeList): void { 744 for (const childNode of nodeList) { 745 if (isParentNode(childNode)) { 746 this.#mapIdArrays(childNode) 747 } 748 } 749 } 750 751 // For each node with an ID, push that ID into the IdArray on the IdArrayMap, for each of its parent elements. 752 #mapIdArrays(node: ParentNode): void { 753 const idArrayMap = this.#idArrayMap 754 755 for (const element of node.querySelectorAll("[id]")) { 756 const id = element.id 757 758 if (id === "") continue 759 760 let currentElement: Element | null = element 761 762 while (currentElement) { 763 const idArray = idArrayMap.get(currentElement) 764 if (idArray) { 765 idArray.push(id) 766 } else { 767 idArrayMap.set(currentElement, [id]) 768 } 769 if (currentElement === node) break 770 currentElement = currentElement.parentElement 771 } 772 } 773 } 774 775 // For each node with an ID, add that ID into the IdSet on the IdSetMap, for each of its parent elements. 776 #mapIdSets(node: ParentNode): void { 777 const idSetMap = this.#idSetMap 778 779 for (const element of node.querySelectorAll("[id]")) { 780 const id = element.id 781 782 if (id === "") continue 783 784 let currentElement: Element | null = element 785 786 while (currentElement) { 787 const idSet = idSetMap.get(currentElement) 788 if (idSet) { 789 idSet.add(id) 790 } else { 791 idSetMap.set(currentElement, new Set([id])) 792 } 793 if (currentElement === node) break 794 currentElement = currentElement.parentElement 795 } 796 } 797 } 798} 799 800function isInputElement(element: Element): element is HTMLInputElement { 801 return element.localName === "input" 802} 803 804function isOptionElement(element: Element): element is HTMLOptionElement { 805 return element.localName === "option" 806} 807 808function isParentNode(node: Node): node is ParentNode { 809 return !!IS_PARENT_NODE_TYPE[node.nodeType] 810}