Precise DOM morphing
morphing typescript dom
0
fork

Configure Feed

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

Don't disturb sensitive elements (#11)

authored by

Joel Drapper and committed by
GitHub
f14b0028 efaf8297

+168 -49
+66 -24
dist/morphlex.js
··· 88 88 } 89 89 // Iterates over the child nodes of the reference element, morphing the main element’s child nodes to match. 90 90 function morphChildNodes(element, ref, context) { 91 - const childNodes = [...element.childNodes]; 92 - const refChildNodes = [...ref.childNodes]; 91 + const childNodes = element.childNodes; 92 + const refChildNodes = ref.childNodes; 93 93 for (let i = 0; i < refChildNodes.length; i++) { 94 - const child = childNodes.at(i); 95 - const refChild = refChildNodes.at(i); 96 - if (child && refChild) morphChildNode(child, refChild, element, context); 97 - else if (refChild) { 94 + const child = childNodes[i]; 95 + const refChild = refChildNodes[i]; //as ReadonlyNode<ChildNode> | null; 96 + if (child && refChild) { 97 + if (isElement(child) && isElement(refChild)) morphChildElement(child, refChild, element, context); 98 + else morphNode(child, refChild, context); 99 + } else if (refChild) { 98 100 appendChild(element, refChild.cloneNode(true), context); 99 - } else if (child) removeNode(child, context); 101 + } else if (child) { 102 + removeNode(child, context); 103 + } 100 104 } 101 - // Remove any excess child nodes from the main element. This is separate because 102 - // the loop above might modify the length of the main element’s child nodes. 103 - while (element.childNodes.length > ref.childNodes.length) { 105 + // Clean up any excess nodes that may be left over 106 + while (childNodes.length > refChildNodes.length) { 104 107 const child = element.lastChild; 105 108 if (child) removeNode(child, context); 106 109 } ··· 112 115 context.afterPropertyUpdated?.({ node, propertyName, previousValue }); 113 116 } 114 117 } 115 - function morphChildNode(child, ref, parent, context) { 116 - if (isElement(child) && isElement(ref)) morphChildElement(child, ref, parent, context); 117 - else morphNode(child, ref, context); 118 - } 119 118 function morphChildElement(child, ref, parent, context) { 120 119 const refIdSet = context.idMap.get(ref); 121 120 // Generate the array in advance of the loop ··· 125 124 // Try find a match by idSet, while also looking out for the next best match by tagName. 126 125 while (currentNode) { 127 126 if (isElement(currentNode)) { 128 - if (currentNode.id === ref.id) { 129 - insertBefore(parent, currentNode, child); 130 - return morphNode(currentNode, ref, context); 131 - } else { 132 - if (currentNode.id !== "") { 127 + const id = currentNode.id; 128 + if (!nextMatchByTagName && currentNode.tagName === ref.tagName) { 129 + nextMatchByTagName = currentNode; 130 + } 131 + if (id !== "") { 132 + if (id === ref.id) { 133 + insertBefore(parent, currentNode, child); 134 + return morphNode(currentNode, ref, context); 135 + } else { 133 136 const currentIdSet = context.idMap.get(currentNode); 134 137 if (currentIdSet && refSetArray.some((it) => currentIdSet.has(it))) { 135 138 insertBefore(parent, currentNode, child); 136 139 return morphNode(currentNode, ref, context); 137 - } else if (!nextMatchByTagName && currentNode.tagName === ref.tagName) { 138 - nextMatchByTagName = currentNode; 139 140 } 140 141 } 141 142 } ··· 145 146 if (nextMatchByTagName) { 146 147 insertBefore(parent, nextMatchByTagName, child); 147 148 morphNode(nextMatchByTagName, ref, context); 148 - } else replaceNode(child, ref.cloneNode(true), context); 149 + } else { 150 + // TODO: this is missing an inserted callback 151 + // TODO: we'll need to clean up the list again after this 152 + insertBefore(parent, ref.cloneNode(true), child); 153 + } 149 154 } 150 155 function replaceNode(node, newNode, context) { 151 156 if ( ··· 157 162 context.afterNodeRemoved?.({ oldNode: node }); 158 163 } 159 164 } 160 - function insertBefore(parent, node, before) { 161 - if (node !== before) parent.insertBefore(node, before); 165 + function insertBefore(parent, node, insertionPoint) { 166 + if (node === insertionPoint) return; 167 + if (isElement(node)) { 168 + const sensitivity = nodeSensitivity(node); 169 + if (sensitivity > 0) { 170 + let previousNode = node.previousSibling; 171 + while (previousNode) { 172 + const previousNodeSensitivity = nodeSensitivity(previousNode); 173 + if (previousNodeSensitivity < sensitivity) { 174 + parent.insertBefore(previousNode, node.nextSibling); 175 + if (previousNode === insertionPoint) return; 176 + previousNode = node.previousSibling; 177 + } else { 178 + break; 179 + } 180 + } 181 + } 182 + } 183 + parent.insertBefore(node, insertionPoint); 184 + } 185 + const sensitiveElements = new Set(["iframe", "audio", "video", "embed", "object", "canvas"]); 186 + const inputElements = new Set(["input", "select", "textarea"]); 187 + function nodeSensitivity(node) { 188 + let sensitivity = 0; 189 + if (!isElement(node)) return sensitivity; 190 + const localName = node.localName; 191 + if (inputElements.has(localName) || node.getAttribute("contenteditable")) { 192 + sensitivity += 1; 193 + if (node === document.activeElement) sensitivity += 1; 194 + if (node instanceof HTMLInputElement && node.value !== node.defaultValue) sensitivity += 1; 195 + } 196 + if (sensitiveElements.has(localName)) { 197 + sensitivity += 3; 198 + if (node instanceof HTMLMediaElement && !node.ended) { 199 + if (!node.paused) sensitivity += 1; 200 + if (node.currentTime > 0) sensitivity += 1; 201 + } 202 + } 203 + return sensitivity; 162 204 } 163 205 function appendChild(node, newNode, context) { 164 206 if (context.beforeNodeAdded?.({ newNode, parentNode: node }) ?? true) {
+1
package.json
··· 13 13 }, 14 14 "scripts": { 15 15 "test": "web-test-runner test/**/*.test.js --node-resolve", 16 + "t": "web-test-runner --node-resolve", 16 17 "build": "tsc && prettier --write ./src ./dist", 17 18 "watch": "tsc -w", 18 19 "test:watch": "npm run test -- --watch",
+81 -25
src/morphlex.ts
··· 189 189 190 190 // Iterates over the child nodes of the reference element, morphing the main element’s child nodes to match. 191 191 function morphChildNodes(element: Element, ref: ReadonlyNode<Element>, context: Context): void { 192 - const childNodes = [...element.childNodes]; 193 - const refChildNodes = [...ref.childNodes]; 192 + const childNodes = element.childNodes; 193 + const refChildNodes = ref.childNodes; 194 194 195 195 for (let i = 0; i < refChildNodes.length; i++) { 196 - const child = childNodes.at(i); 197 - const refChild = refChildNodes.at(i); 196 + const child = childNodes[i] as ChildNode | null; 197 + const refChild = refChildNodes[i]; //as ReadonlyNode<ChildNode> | null; 198 198 199 - if (child && refChild) morphChildNode(child, refChild, element, context); 200 - else if (refChild) { 199 + if (child && refChild) { 200 + if (isElement(child) && isElement(refChild)) morphChildElement(child, refChild, element, context); 201 + else morphNode(child, refChild, context); 202 + } else if (refChild) { 201 203 appendChild(element, refChild.cloneNode(true), context); 202 - } else if (child) removeNode(child, context); 204 + } else if (child) { 205 + removeNode(child, context); 206 + } 203 207 } 204 208 205 - // Remove any excess child nodes from the main element. This is separate because 206 - // the loop above might modify the length of the main element’s child nodes. 207 - while (element.childNodes.length > ref.childNodes.length) { 209 + // Clean up any excess nodes that may be left over 210 + while (childNodes.length > refChildNodes.length) { 208 211 const child = element.lastChild; 209 212 if (child) removeNode(child, context); 210 213 } ··· 218 221 } 219 222 } 220 223 221 - function morphChildNode(child: ChildNode, ref: ReadonlyNode<ChildNode>, parent: Element, context: Context): void { 222 - if (isElement(child) && isElement(ref)) morphChildElement(child, ref, parent, context); 223 - else morphNode(child, ref, context); 224 - } 225 - 226 224 function morphChildElement(child: Element, ref: ReadonlyNode<Element>, parent: Element, context: Context): void { 227 225 const refIdSet = context.idMap.get(ref); 228 226 ··· 235 233 // Try find a match by idSet, while also looking out for the next best match by tagName. 236 234 while (currentNode) { 237 235 if (isElement(currentNode)) { 238 - if (currentNode.id === ref.id) { 239 - insertBefore(parent, currentNode, child); 240 - return morphNode(currentNode, ref, context); 241 - } else { 242 - if (currentNode.id !== "") { 236 + const id = currentNode.id; 237 + 238 + if (!nextMatchByTagName && currentNode.tagName === ref.tagName) { 239 + nextMatchByTagName = currentNode; 240 + } 241 + 242 + if (id !== "") { 243 + if (id === ref.id) { 244 + insertBefore(parent, currentNode, child); 245 + return morphNode(currentNode, ref, context); 246 + } else { 243 247 const currentIdSet = context.idMap.get(currentNode); 244 248 245 249 if (currentIdSet && refSetArray.some((it) => currentIdSet.has(it))) { 246 250 insertBefore(parent, currentNode, child); 247 251 return morphNode(currentNode, ref, context); 248 - } else if (!nextMatchByTagName && currentNode.tagName === ref.tagName) { 249 - nextMatchByTagName = currentNode; 250 252 } 251 253 } 252 254 } ··· 258 260 if (nextMatchByTagName) { 259 261 insertBefore(parent, nextMatchByTagName, child); 260 262 morphNode(nextMatchByTagName, ref, context); 261 - } else replaceNode(child, ref.cloneNode(true), context); 263 + } else { 264 + // TODO: this is missing an added callback 265 + insertBefore(parent, ref.cloneNode(true), child); 266 + } 262 267 } 263 268 264 269 function replaceNode(node: ChildNode, newNode: Node, context: Context): void { ··· 272 277 } 273 278 } 274 279 275 - function insertBefore(parent: ParentNode, node: ChildNode, before: ChildNode): void { 276 - if (node !== before) parent.insertBefore(node, before); 280 + function insertBefore(parent: ParentNode, node: Node, insertionPoint: ChildNode): void { 281 + if (node === insertionPoint) return; 282 + 283 + if (isElement(node)) { 284 + const sensitivity = nodeSensitivity(node); 285 + 286 + if (sensitivity > 0) { 287 + let previousNode = node.previousSibling; 288 + 289 + while (previousNode) { 290 + const previousNodeSensitivity = nodeSensitivity(previousNode); 291 + 292 + if (previousNodeSensitivity < sensitivity) { 293 + parent.insertBefore(previousNode, node.nextSibling); 294 + 295 + if (previousNode === insertionPoint) return; 296 + previousNode = node.previousSibling; 297 + } else { 298 + break; 299 + } 300 + } 301 + } 302 + } 303 + 304 + parent.insertBefore(node, insertionPoint); 305 + } 306 + 307 + const sensitiveElements = new Set(["iframe", "audio", "video", "embed", "object", "canvas"]); 308 + const inputElements = new Set(["input", "select", "textarea"]); 309 + 310 + function nodeSensitivity(node: Node): number { 311 + let sensitivity = 0; 312 + if (!isElement(node)) return sensitivity; 313 + 314 + const localName = node.localName; 315 + 316 + if (inputElements.has(localName) || node.getAttribute("contenteditable")) { 317 + sensitivity += 1; 318 + 319 + if (node === document.activeElement) sensitivity += 1; 320 + if (node instanceof HTMLInputElement && node.value !== node.defaultValue) sensitivity += 1; 321 + } 322 + 323 + if (sensitiveElements.has(localName)) { 324 + sensitivity += 3; 325 + 326 + if (node instanceof HTMLMediaElement && !node.ended) { 327 + if (!node.paused) sensitivity += 1; 328 + if (node.currentTime > 0) sensitivity += 1; 329 + } 330 + } 331 + 332 + return sensitivity; 277 333 } 278 334 279 335 function appendChild(node: ParentNode, newNode: Node, context: Context): void {
+20
test/morphlex.test.js
··· 2 2 import { morph } from "../"; 3 3 4 4 describe("morph", () => { 5 + it.only("doesn't cause iframes to reload", async () => { 6 + const original = await fixture( 7 + `<div> 8 + <h1></h1> 9 + <iframe id="1" src="https://www.youtube.com/embed/dQw4w9WgXcQ"></iframe> 10 + </div>`, 11 + ); 12 + 13 + const reference = await fixture( 14 + `<div> 15 + <iframe id="1" src="https://www.youtube.com/embed/dQw4w9WgXcQ"></iframe> 16 + <h1></h1> 17 + </div>`, 18 + ); 19 + 20 + const originalIframe = original.querySelector("iframe"); 21 + morph(original, reference); 22 + expect(original.outerHTML).to.equal(reference.outerHTML); 23 + }); 24 + 5 25 it("supports nodes from iframes", async () => { 6 26 const iframe = await fixture(html`<iframe></iframe>`); 7 27