Precise DOM morphing
morphing typescript dom
0
fork

Configure Feed

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

Improvements

+156 -139
+156 -139
src/morphlex.ts
··· 1 1 const SupportsMoveBefore = "moveBefore" in Element.prototype 2 2 const ParentNodeTypes = new Set([1, 9, 11]) 3 - const DisablableElements = new Set(["input", "button", "select", "textarea", "option", "optgroup", "fieldset"]) 4 - const ValuableElements = new Set(["input", "select", "textarea"]) 5 - const ValueAttributes = new Set([ 6 - "value", 7 - "selected", 8 - "checked", 9 - "indeterminate", 10 - "morph-value", 11 - "morph-selected", 12 - "morph-checked", 13 - "morph-indeterminate", 14 - ]) 15 3 16 4 type IdSet = Set<string> 17 5 type IdMap = WeakMap<Node, IdSet> ··· 21 9 22 10 type PairOfNodes<N extends Node> = [N, N] 23 11 type PairOfMatchingElements<E extends Element> = Branded<PairOfNodes<E>, "MatchingElementPair"> 24 - 25 - type DisablableElement = 26 - | HTMLInputElement 27 - | HTMLButtonElement 28 - | HTMLSelectElement 29 - | HTMLTextAreaElement 30 - | HTMLOptionElement 31 - | HTMLOptGroupElement 32 - | HTMLFieldSetElement 33 - 34 - type ValuableElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement 35 12 36 13 interface Options { 14 + preserveModified?: boolean 37 15 beforeNodeVisited?: (fromNode: Node, toNode: Node) => boolean 38 16 afterNodeVisited?: (fromNode: Node, toNode: Node) => void 39 17 beforeNodeAdded?: (parent: ParentNode, node: Node, insertionPoint: ChildNode | null) => boolean ··· 52 30 53 31 export function morph(from: ChildNode, to: ChildNode | NodeListOf<ChildNode> | string, options: Options = {}): void { 54 32 if (typeof to === "string") to = parseString(to).childNodes 33 + 34 + if (isParentNode(from)) flagDirtyInputs(from) 35 + 55 36 new Morph(options).morph(from, to) 56 37 } 57 38 ··· 68 49 69 50 const pair: PairOfNodes<Node> = [from, to] 70 51 if (isElementPair(pair) && isMatchingElementPair(pair)) { 52 + if (isParentNode(from)) flagDirtyInputs(from) 71 53 new Morph(options).visitChildNodes(pair) 72 54 } else { 73 55 throw new Error("[Morphlex] You can only do an inner morph with matching elements.") 56 + } 57 + } 58 + 59 + function flagDirtyInputs(node: ParentNode): void { 60 + for (const el of node.querySelectorAll("input")) { 61 + const currentValue = el.value 62 + 63 + if (currentValue !== el.defaultValue) { 64 + el.setAttribute("morphlex-dirty", "") 65 + } 66 + } 67 + 68 + for (const el of node.querySelectorAll("option")) { 69 + const currentSelected = el.selected 70 + 71 + if (currentSelected !== el.defaultSelected) { 72 + el.setAttribute("morphlex-dirty", "") 73 + } 74 74 } 75 75 } 76 76 ··· 185 185 } 186 186 187 187 private visitAttributes([from, to]: PairOfMatchingElements<Element>): void { 188 - const toAttrs = to.attributes 189 188 const fromAttrs = from.attributes 190 189 191 190 // First pass: update/add attributes from reference (iterate forwards) 192 - for (let i = 0; i < toAttrs.length; i++) { 193 - const attr = toAttrs[i]! 194 - const name = attr.name 195 - const value = attr.value 191 + for (const { name, value } of to.attributes) { 192 + console.log(from, name) 196 193 const oldValue = from.getAttribute(name) 197 194 198 - if (ValueAttributes.has(name)) { 199 - if (isValuableElement(from) && isValuableElement(to)) { 200 - if (name === "value") { 201 - continue 202 - } else if (name === "morph-value" && (this.options.beforeAttributeUpdated?.(from, name, value) ?? true)) { 203 - from.setAttribute(name, value) 195 + // This attribute was only added to trigger attribute morphing. 196 + if (name === "morphlex-dirty") { 197 + from.removeAttribute(name) 198 + continue 199 + } 200 + 201 + if (name === "value") { 202 + if (isInputElement(from) && from.value !== value) { 203 + if (!this.options.preserveModified || from.value === from.defaultValue) { 204 204 from.value = value 205 - this.options.afterAttributeUpdated?.(from, name, oldValue) 206 - continue 207 205 } 208 206 } 207 + } 209 208 210 - if (isInputElement(from) && isInputElement(to)) { 211 - if (name === "checked" || name === "indeterminate") { 212 - continue 213 - } else if (name === "morph-checked" && (this.options.beforeAttributeUpdated?.(from, name, value) ?? true)) { 214 - from.setAttribute(name, value) 215 - from.checked = value === "true" 216 - this.options.afterAttributeUpdated?.(from, name, oldValue) 217 - continue 218 - } else if (name === "morph-indeterminate" && (this.options.beforeAttributeUpdated?.(from, name, value) ?? true)) { 219 - from.setAttribute(name, value) 220 - from.indeterminate = value === "true" 221 - this.options.afterAttributeUpdated?.(from, name, oldValue) 222 - continue 209 + if (name === "selected") { 210 + if (isOptionElement(from) && !from.selected) { 211 + if (!this.options.preserveModified || from.selected === from.defaultSelected) { 212 + from.selected = true 223 213 } 224 214 } 215 + } 225 216 226 - if (isOptionElement(from) && isOptionElement(to)) { 227 - if (name === "selected") { 228 - continue 229 - } else if (name === "morph-selected") { 230 - from.setAttribute(name, value) 231 - from.selected = value === "true" 232 - this.options.afterAttributeUpdated?.(from, name, oldValue) 233 - continue 217 + if (name === "checked") { 218 + if (isInputElement(from) && !from.checked) { 219 + if (!this.options.preserveModified || from.checked === from.defaultChecked) { 220 + from.checked = true 234 221 } 235 222 } 236 223 } 237 224 238 225 if (oldValue !== value && (this.options.beforeAttributeUpdated?.(from, name, value) ?? true)) { 239 226 from.setAttribute(name, value) 240 - 241 - if (name === "disabled" && isDisablableElement(from) && isDisablableElement(to) && from.disabled !== to.disabled) { 242 - from.disabled = to.disabled 243 - } 244 - 245 227 this.options.afterAttributeUpdated?.(from, name, oldValue) 246 228 } 247 229 } 248 230 249 231 // Second pass: remove excess attributes (iterate backwards for efficiency) 250 232 for (let i = fromAttrs.length - 1; i >= 0; i--) { 251 - const attr = fromAttrs[i]! 252 - const name = attr.name 253 - const value = attr.value 233 + const { name, value } = fromAttrs[i]! 254 234 255 - if (!to.hasAttribute(name) && (this.options.beforeAttributeUpdated?.(from, name, null) ?? true)) { 256 - from.removeAttribute(name) 257 - this.options.afterAttributeUpdated?.(from, name, value) 235 + if (!to.hasAttribute(name)) { 236 + if (name === "selected") { 237 + if (isOptionElement(from) && from.selected) { 238 + if (!this.options.preserveModified || from.selected === from.defaultSelected) { 239 + from.selected = false 240 + } 241 + } 242 + } 243 + 244 + if (name === "checked") { 245 + if (isInputElement(from) && from.checked) { 246 + if (!this.options.preserveModified || from.checked === from.defaultChecked) { 247 + from.checked = false 248 + } 249 + } 250 + } 251 + 252 + if (this.options.beforeAttributeUpdated?.(from, name, null) ?? true) { 253 + from.removeAttribute(name) 254 + this.options.afterAttributeUpdated?.(from, name, value) 255 + } 258 256 } 259 257 } 260 258 } ··· 266 264 const fromChildNodes = from.childNodes 267 265 const toChildNodes = Array.from(to.childNodes) 268 266 269 - const candidates: Set<ChildNode> = new Set(fromChildNodes) 270 - const unmatched: Set<ChildNode> = new Set(toChildNodes) 267 + const candidateNodes: Set<ChildNode> = new Set() 268 + const candidateElements: Set<Element> = new Set() 269 + 270 + const unmatchedNodes: Set<ChildNode> = new Set() 271 + const unmatchedElements: Set<Element> = new Set() 271 272 272 273 const matches: Map<ChildNode, ChildNode> = new Map() 273 274 274 - // Match by isEqualNode 275 - for (const node of unmatched) { 276 - for (const candidate of candidates) { 277 - if (candidate.isEqualNode(node)) { 278 - matches.set(node, candidate) 279 - unmatched.delete(node) 280 - candidates.delete(candidate) 275 + for (const candidate of fromChildNodes) { 276 + if (isElement(candidate)) candidateElements.add(candidate) 277 + else candidateNodes.add(candidate) 278 + } 279 + 280 + for (const node of toChildNodes) { 281 + if (isElement(node)) unmatchedElements.add(node) 282 + else unmatchedNodes.add(node) 283 + } 284 + 285 + // Match elements by isEqualNode 286 + for (const element of unmatchedElements) { 287 + for (const candidate of candidateElements) { 288 + if (candidate.isEqualNode(element)) { 289 + matches.set(element, candidate) 290 + unmatchedElements.delete(element) 291 + candidateElements.delete(candidate) 281 292 break 282 293 } 283 294 } 284 295 } 285 296 286 297 // Match by exact id 287 - for (const node of unmatched) { 288 - if (!isElement(node)) continue 289 - const id = node.id 298 + for (const element of unmatchedElements) { 299 + const id = element.id 290 300 if (id === "") continue 291 301 292 - for (const candidate of candidates) { 293 - if (isElement(candidate) && node.localName === candidate.localName && id === candidate.id) { 294 - matches.set(node, candidate) 295 - unmatched.delete(node) 296 - candidates.delete(candidate) 302 + for (const candidate of candidateElements) { 303 + if (element.localName === candidate.localName && id === candidate.id) { 304 + matches.set(element, candidate) 305 + unmatchedElements.delete(element) 306 + candidateElements.delete(candidate) 297 307 break 298 308 } 299 309 } 300 310 } 301 311 302 312 // Match by idSet 303 - for (const node of unmatched) { 304 - if (!isElement(node)) continue 305 - const idSet = this.idMap.get(node) 313 + for (const element of unmatchedElements) { 314 + if (!isElement(element)) continue 315 + const idSet = this.idMap.get(element) 306 316 if (!idSet) continue 307 317 308 - candidateLoop: for (const candidate of candidates) { 318 + candidateLoop: for (const candidate of candidateElements) { 309 319 if (isElement(candidate)) { 310 320 const candidateIdSet = this.idMap.get(candidate) 311 321 if (candidateIdSet) { 312 322 for (const id of idSet) { 313 323 if (candidateIdSet.has(id)) { 314 - matches.set(node, candidate) 315 - unmatched.delete(node) 316 - candidates.delete(candidate) 324 + matches.set(element, candidate) 325 + unmatchedElements.delete(element) 326 + candidateElements.delete(candidate) 317 327 break candidateLoop 318 328 } 319 329 } ··· 323 333 } 324 334 325 335 // Match by huristics 326 - for (const node of unmatched) { 327 - if (!isElement(node)) continue 328 - const className = node.className 329 - const name = node.getAttribute("name") 330 - const href = node.getAttribute("href") 331 - const src = node.getAttribute("src") 336 + for (const element of unmatchedElements) { 337 + if (!isElement(element)) continue 338 + const name = element.getAttribute("name") 339 + const href = element.getAttribute("href") 340 + const src = element.getAttribute("src") 332 341 333 - for (const candidate of candidates) { 342 + for (const candidate of candidateElements) { 334 343 if ( 335 344 isElement(candidate) && 336 - node.localName === candidate.localName && 337 - ((className !== "" && className === candidate.className) || 338 - (name !== "" && name === candidate.getAttribute("name")) || 345 + element.localName === candidate.localName && 346 + ((name !== "" && name === candidate.getAttribute("name")) || 339 347 (href !== "" && href === candidate.getAttribute("href")) || 340 348 (src !== "" && src === candidate.getAttribute("src"))) 341 349 ) { 350 + matches.set(element, candidate) 351 + unmatchedElements.delete(element) 352 + candidateElements.delete(candidate) 353 + break 354 + } 355 + } 356 + } 357 + 358 + // Match by tagName 359 + for (const element of unmatchedElements) { 360 + const localName = element.localName 361 + 362 + for (const candidate of candidateElements) { 363 + if (localName === candidate.localName) { 364 + if (isInputElement(candidate) && isInputElement(element) && candidate.type !== element.type) { 365 + // Treat inputs with different type as though they are different tags. 366 + continue 367 + } 368 + matches.set(element, candidate) 369 + unmatchedElements.delete(element) 370 + candidateElements.delete(candidate) 371 + break 372 + } 373 + } 374 + } 375 + 376 + // Match nodes by isEqualNode 377 + for (const node of unmatchedNodes) { 378 + for (const candidate of candidateNodes) { 379 + if (candidate.isEqualNode(node)) { 342 380 matches.set(node, candidate) 343 - unmatched.delete(node) 344 - candidates.delete(candidate) 381 + unmatchedNodes.delete(node) 382 + candidateNodes.delete(candidate) 345 383 break 346 384 } 347 385 } 348 386 } 349 387 350 388 // Match by nodeType 351 - for (const node of unmatched) { 389 + for (const node of unmatchedNodes) { 352 390 const nodeType = node.nodeType 353 391 354 - if (isElement(node)) { 355 - const localName = node.localName 356 - 357 - for (const candidate of candidates) { 358 - if (isElement(candidate) && localName === candidate.localName) { 359 - if (isInputElement(candidate) && isInputElement(node) && candidate.type !== node.type) { 360 - // Treat inputs with different type as though they are different tags. 361 - continue 362 - } 363 - matches.set(node, candidate) 364 - unmatched.delete(node) 365 - candidates.delete(candidate) 366 - break 367 - } 368 - } 369 - } else { 370 - for (const candidate of candidates) { 371 - if (nodeType === candidate.nodeType) { 372 - matches.set(node, candidate) 373 - unmatched.delete(node) 374 - candidates.delete(candidate) 375 - break 376 - } 392 + for (const candidate of candidateNodes) { 393 + if (nodeType === candidate.nodeType) { 394 + matches.set(node, candidate) 395 + unmatchedNodes.delete(node) 396 + candidateNodes.delete(candidate) 397 + break 377 398 } 378 399 } 379 400 } ··· 388 409 this.morphOneToOne(match, node) 389 410 insertionPoint = match.nextSibling 390 411 // Skip over any nodes that will be removed to avoid unnecessary moves 391 - while (insertionPoint && candidates.has(insertionPoint)) { 412 + while (insertionPoint && candidateNodes.has(insertionPoint)) { 392 413 insertionPoint = insertionPoint.nextSibling 393 414 } 394 415 } else { ··· 397 418 this.options.afterNodeAdded?.(node) 398 419 insertionPoint = node.nextSibling 399 420 // Skip over any nodes that will be removed to avoid unnecessary moves 400 - while (insertionPoint && candidates.has(insertionPoint)) { 421 + while (insertionPoint && candidateNodes.has(insertionPoint)) { 401 422 insertionPoint = insertionPoint.nextSibling 402 423 } 403 424 } ··· 405 426 } 406 427 407 428 // Remove any remaining unmatched candidates 408 - for (const candidate of candidates) { 429 + for (const candidate of candidateNodes) { 430 + this.removeNode(candidate) 431 + } 432 + 433 + for (const candidate of candidateElements) { 409 434 this.removeNode(candidate) 410 435 } 411 436 ··· 459 484 460 485 function supportsMoveBefore(_node: ParentNode): _node is NodeWithMoveBefore { 461 486 return SupportsMoveBefore 462 - } 463 - 464 - function isDisablableElement(element: Element): element is DisablableElement { 465 - return DisablableElements.has(element.localName) 466 - } 467 - 468 - function isValuableElement(element: Element): element is ValuableElement { 469 - return ValuableElements.has(element.localName) 470 487 } 471 488 472 489 function isMatchingElementPair(pair: PairOfNodes<Element>): pair is PairOfMatchingElements<Element> {