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