Precise DOM morphing
morphing typescript dom
0
fork

Configure Feed

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

Various improvements

+75 -53
+32 -25
dist/morphlex.js
··· 1 1 export function morph(node, guide) { 2 2 const idMap = new Map(); 3 - if (isElement(node) && isElement(guide)) { 3 + if (isParentNode(node) && isParentNode(guide)) { 4 4 populateIdMapForNode(node, idMap); 5 5 populateIdMapForNode(guide, idMap); 6 6 } 7 7 morphNodes(node, guide, idMap); 8 8 } 9 + function populateIdMapForNode(node, idMap) { 10 + const elementsWithIds = node.querySelectorAll("[id]"); 11 + for (const elementWithId of elementsWithIds) { 12 + const id = elementWithId.id; 13 + if (id === "") 14 + continue; 15 + let current = elementWithId; 16 + while (current) { 17 + const idSet = idMap.get(current); 18 + idSet ? idSet.add(id) : idMap.set(current, new Set([id])); 19 + if (current === elementWithId) 20 + break; 21 + current = current.parentElement; 22 + } 23 + } 24 + } 9 25 function morphNodes(node, guide, idMap, insertBefore, parent) { 10 26 if (parent && insertBefore && insertBefore !== node) 11 27 parent.insertBefore(guide, insertBefore); ··· 26 42 for (const { name } of elem.attributes) 27 43 guide.hasAttribute(name) || elem.removeAttribute(name); 28 44 for (const { name, value } of guide.attributes) 29 - elem.getAttribute(name) !== value && elem.setAttribute(name, value); 45 + elem.getAttribute(name) === value || elem.setAttribute(name, value); 30 46 if (isInput(elem) && isInput(guide) && elem.value !== guide.value) 31 47 elem.value = guide.value; 32 48 else if (isOption(elem) && isOption(guide) && elem.selected !== guide.selected) ··· 44 60 morphChildNode(child, guideChild, elem, idMap); 45 61 else if (guideChild) 46 62 elem.appendChild(guideChild.cloneNode(true)); 63 + else if (child) 64 + child.remove(); 47 65 } 48 66 while (elem.childNodes.length > guide.childNodes.length) 49 67 elem.lastChild?.remove(); ··· 61 79 let nextMatchByTagName = null; 62 80 while (currentNode) { 63 81 if (isElement(currentNode)) { 64 - if (currentNode.id !== "" && currentNode.id === guide.id) { 82 + if (currentNode.id === guide.id) { 65 83 return morphNodes(currentNode, guide, idMap, child, parent); 66 84 } 67 - else { 85 + else if (currentNode.id !== "") { 68 86 const currentIdSet = idMap.get(currentNode); 69 87 if (currentIdSet && guideSetArray.some((it) => currentIdSet.has(it))) { 70 88 return morphNodes(currentNode, guide, idMap, child, parent); 71 89 } 72 - else if (!nextMatchByTagName && currentNode.tagName === guide.tagName) { 73 - nextMatchByTagName = currentNode; 74 - } 90 + } 91 + else if (!nextMatchByTagName && currentNode.tagName === guide.tagName) { 92 + nextMatchByTagName = currentNode; 75 93 } 76 94 } 77 95 currentNode = currentNode.nextSibling; ··· 81 99 else 82 100 child.replaceWith(guide.cloneNode(true)); 83 101 } 84 - function populateIdMapForNode(node, idMap) { 85 - const elementsWithIds = node.querySelectorAll("[id]"); 86 - for (const elementWithId of elementsWithIds) { 87 - const id = elementWithId.id; 88 - if (id === "") 89 - continue; 90 - let current = elementWithId; 91 - while (current) { 92 - const idSet = idMap.get(current); 93 - idSet ? idSet.add(id) : idMap.set(current, new Set([id])); 94 - if (current === elementWithId) 95 - break; 96 - current = current.parentElement; 97 - } 98 - } 99 - } 100 102 function isText(node) { 101 - return node.nodeType === 3; 103 + return node.nodeType === Node.TEXT_NODE; 102 104 } 103 105 function isElement(node) { 104 - return node.nodeType === 1; 106 + return node.nodeType === Node.ELEMENT_NODE; 105 107 } 106 108 function isInput(element) { 107 109 return element.localName === "input"; ··· 111 113 } 112 114 function isTextArea(element) { 113 115 return element.localName === "textarea"; 116 + } 117 + function isParentNode(node) { 118 + return (node.nodeType === Node.ELEMENT_NODE || 119 + node.nodeType === Node.DOCUMENT_NODE || 120 + node.nodeType === Node.DOCUMENT_FRAGMENT_NODE); 114 121 } 115 122 //# sourceMappingURL=morphlex.js.map
+43 -28
src/morphlex.ts
··· 4 4 export function morph(node: ChildNode, guide: ChildNode): void { 5 5 const idMap: IdMap = new Map(); 6 6 7 - if (isElement(node) && isElement(guide)) { 7 + if (isParentNode(node) && isParentNode(guide)) { 8 8 populateIdMapForNode(node, idMap); 9 9 populateIdMapForNode(guide, idMap); 10 10 } ··· 12 12 morphNodes(node, guide, idMap); 13 13 } 14 14 15 + function populateIdMapForNode(node: ParentNode, idMap: IdMap): void { 16 + const elementsWithIds: NodeListOf<Element> = node.querySelectorAll("[id]"); 17 + 18 + for (const elementWithId of elementsWithIds) { 19 + const id = elementWithId.id; 20 + if (id === "") continue; 21 + let current: Element | null = elementWithId; 22 + 23 + while (current) { 24 + const idSet: IdSet | undefined = idMap.get(current); 25 + idSet ? idSet.add(id) : idMap.set(current, new Set([id])); 26 + if (current === elementWithId) break; 27 + current = current.parentElement; 28 + } 29 + } 30 + } 31 + 32 + // This is where we actually morph the nodes. The `morph` function exists to set up the `idMap`. 15 33 function morphNodes(node: ChildNode, guide: ChildNode, idMap: IdMap, insertBefore?: Node, parent?: Node): void { 34 + // TODO: We should extract this into a separate function. 16 35 if (parent && insertBefore && insertBefore !== node) parent.insertBefore(guide, insertBefore); 17 36 18 37 if (isText(node) && isText(guide)) { ··· 24 43 } 25 44 26 45 function morphAttributes(elem: Element, guide: Element): void { 46 + // Remove any excess attributes from the element that aren’t present in the guide. 27 47 for (const { name } of elem.attributes) guide.hasAttribute(name) || elem.removeAttribute(name); 28 - for (const { name, value } of guide.attributes) elem.getAttribute(name) !== value && elem.setAttribute(name, value); 29 48 49 + // Copy attributes from the guide to the element, if they don’t already match. 50 + for (const { name, value } of guide.attributes) elem.getAttribute(name) === value || elem.setAttribute(name, value); 51 + 52 + // For certain types of elements, we need to do some extra work to ensure the element’s state matches the guide’s state. 30 53 if (isInput(elem) && isInput(guide) && elem.value !== guide.value) elem.value = guide.value; 31 54 else if (isOption(elem) && isOption(guide) && elem.selected !== guide.selected) elem.selected = guide.selected; 32 55 else if (isTextArea(elem) && isTextArea(guide) && elem.value !== guide.value) elem.value = guide.value; 33 56 } 34 57 58 + // Iterates over the child nodes of the guide element, morphing the main element’s child nodes to match. 35 59 function morphChildNodes(elem: Element, guide: Element, idMap: IdMap): void { 36 60 const childNodes = [...elem.childNodes]; 37 61 const guideChildNodes = [...guide.childNodes]; ··· 42 66 43 67 if (child && guideChild) morphChildNode(child, guideChild, elem, idMap); 44 68 else if (guideChild) elem.appendChild(guideChild.cloneNode(true)); 69 + else if (child) child.remove(); 45 70 } 46 71 47 - // This is separate because the loop above might modify the length of the element's child nodes. 72 + // Remove any excess child nodes from the main element. This is separate because 73 + // the loop above might modify the length of the main element’s child nodes. 48 74 while (elem.childNodes.length > guide.childNodes.length) elem.lastChild?.remove(); 49 75 } 50 76 ··· 65 91 // Try find a match by idSet, while also looking out for the next best match by tagName. 66 92 while (currentNode) { 67 93 if (isElement(currentNode)) { 68 - if (currentNode.id !== "" && currentNode.id === guide.id) { 69 - // Exact match by id. 94 + if (currentNode.id === guide.id) { 70 95 return morphNodes(currentNode, guide, idMap, child, parent); 71 - } else { 96 + } else if (currentNode.id !== "") { 72 97 const currentIdSet = idMap.get(currentNode); 73 98 74 99 if (currentIdSet && guideSetArray.some((it) => currentIdSet.has(it))) { 75 - // Match by idSet. 76 100 return morphNodes(currentNode, guide, idMap, child, parent); 77 - } else if (!nextMatchByTagName && currentNode.tagName === guide.tagName) { 78 - nextMatchByTagName = currentNode; 79 101 } 102 + } else if (!nextMatchByTagName && currentNode.tagName === guide.tagName) { 103 + nextMatchByTagName = currentNode; 80 104 } 81 105 } 82 106 ··· 87 111 else child.replaceWith(guide.cloneNode(true)); 88 112 } 89 113 90 - function populateIdMapForNode(node: ParentNode, idMap: IdMap): void { 91 - const elementsWithIds: NodeListOf<Element> = node.querySelectorAll("[id]"); 92 - 93 - for (const elementWithId of elementsWithIds) { 94 - const id = elementWithId.id; 95 - if (id === "") continue; 96 - let current: Element | null = elementWithId; 97 - 98 - while (current) { 99 - const idSet: IdSet | undefined = idMap.get(current); 100 - idSet ? idSet.add(id) : idMap.set(current, new Set([id])); 101 - if (current === elementWithId) break; 102 - current = current.parentElement; 103 - } 104 - } 105 - } 106 - 107 114 // We cannot use `instanceof` when nodes might be from different documents, 108 115 // so we use type guards instead. This keeps TypeScript happy, while doing 109 116 // the necessary checks at runtime. 110 117 111 118 function isText(node: Node): node is Text { 112 - return node.nodeType === 3; 119 + return node.nodeType === Node.TEXT_NODE; 113 120 } 114 121 115 122 function isElement(node: Node): node is Element { 116 - return node.nodeType === 1; 123 + return node.nodeType === Node.ELEMENT_NODE; 117 124 } 118 125 119 126 function isInput(element: Element): element is HTMLInputElement { ··· 127 134 function isTextArea(element: Element): element is HTMLTextAreaElement { 128 135 return element.localName === "textarea"; 129 136 } 137 + 138 + function isParentNode(node: Node): node is ParentNode { 139 + return ( 140 + node.nodeType === Node.ELEMENT_NODE || 141 + node.nodeType === Node.DOCUMENT_NODE || 142 + node.nodeType === Node.DOCUMENT_FRAGMENT_NODE 143 + ); 144 + }