diff --git a/dist/morphlex.js b/dist/morphlex.js index be44e16..e26c3d7 100644 --- a/dist/morphlex.js +++ b/dist/morphlex.js @@ -1,221 +1,235 @@ export function morph(node, reference, options = {}) { - const readonlyReference = reference; - const idMap = new WeakMap(); - const sensitivityMap = new WeakMap(); - if (isParentNode(node) && isParentNode(readonlyReference)) { - populateIdSets(node, idMap); - populateIdSets(readonlyReference, idMap); - populateSensivityMap(node, sensitivityMap); - } - morphNode(node, readonlyReference, { ...options, idMap, sensitivityMap }); + new Morph(options).morph(node, reference); } -function populateSensivityMap(node, sensivityMap) { - const sensitiveElements = node.querySelectorAll("iframe,video,object,embed,audio,input,textarea,canvas"); - for (const sensitiveElement of sensitiveElements) { - let sensivity = 0; - if (isInput(sensitiveElement) || isTextArea(sensitiveElement)) { - sensivity += 1; - if (sensitiveElement.value !== sensitiveElement.defaultValue) sensivity += 1; - if (sensitiveElement === document.activeElement) sensivity += 1; - } else { - sensivity += 3; - if (sensitiveElement instanceof HTMLMediaElement && !sensitiveElement.ended) { - if (!sensitiveElement.paused) sensivity += 1; - if (sensitiveElement.currentTime > 0) sensivity += 1; - } - } - let current = sensitiveElement; - while (current) { - sensivityMap.set(current, (sensivityMap.get(current) || 0) + sensivity); - if (current === node) break; - current = current.parentElement; - } +class Morph { + #idMap; + #sensivityMap; + #options; + constructor(options = {}) { + this.#options = options; + this.#idMap = new WeakMap(); + this.#sensivityMap = new WeakMap(); } -} -// For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. -function populateIdSets(node, idMap) { - const elementsWithIds = node.querySelectorAll("[id]"); - for (const elementWithId of elementsWithIds) { - const id = elementWithId.id; - // Ignore empty IDs - if (id === "") continue; - let current = elementWithId; - while (current) { - const idSet = idMap.get(current); - idSet ? idSet.add(id) : idMap.set(current, new Set([id])); - if (current === node) break; - current = current.parentElement; + morph(node, reference) { + const readonlyReference = reference; + if (isParentNode(node) && isParentNode(readonlyReference)) { + this.#populateIdSets(node); + this.#populateIdSets(readonlyReference); + this.#populateSensivityMap(node); } + this.#morphNode(node, readonlyReference); } -} -// This is where we actually morph the nodes. The `morph` function (above) exists only to set up the `idMap`. -function morphNode(node, ref, context) { - if (!(context.beforeNodeMorphed?.({ node, referenceNode: ref }) ?? true)) return; - if (isElement(node) && isElement(ref) && node.tagName === ref.tagName) { - if (node.hasAttributes() || ref.hasAttributes()) morphAttributes(node, ref, context); - if (isHead(node) && isHead(ref)) { - const refChildNodes = new Map(); - for (const child of ref.children) refChildNodes.set(child.outerHTML, child); - for (const child of node.children) { - const key = child.outerHTML; - const refChild = refChildNodes.get(key); - refChild ? refChildNodes.delete(key) : removeNode(child, context); + #populateSensivityMap(node) { + const sensitiveElements = node.querySelectorAll("iframe,video,object,embed,audio,input,textarea,canvas"); + for (const sensitiveElement of sensitiveElements) { + let sensivity = 0; + if (isInput(sensitiveElement) || isTextArea(sensitiveElement)) { + sensivity += 1; + if (sensitiveElement.value !== sensitiveElement.defaultValue) sensivity += 1; + if (sensitiveElement === document.activeElement) sensivity += 1; + } else { + sensivity += 3; + if (sensitiveElement instanceof HTMLMediaElement && !sensitiveElement.ended) { + if (!sensitiveElement.paused) sensivity += 1; + if (sensitiveElement.currentTime > 0) sensivity += 1; + } + } + let current = sensitiveElement; + while (current) { + this.#sensivityMap.set(current, (this.#sensivityMap.get(current) || 0) + sensivity); + if (current === node) break; + current = current.parentElement; } - for (const refChild of refChildNodes.values()) appendChild(node, refChild.cloneNode(true), context); - } else if (node.hasChildNodes() || ref.hasChildNodes()) morphChildNodes(node, ref, context); - } else { - if (isText(node) && isText(ref)) { - updateProperty(node, "textContent", ref.textContent, context); - } else if (isComment(node) && isComment(ref)) { - updateProperty(node, "nodeValue", ref.nodeValue, context); - } else replaceNode(node, ref.cloneNode(true), context); - } - context.afterNodeMorphed?.({ node }); -} -function morphAttributes(element, ref, context) { - // Remove any excess attributes from the element that aren’t present in the reference. - for (const { name, value } of element.attributes) { - if (!ref.hasAttribute(name) && (context.beforeAttributeUpdated?.({ element, attributeName: name, newValue: null }) ?? true)) { - element.removeAttribute(name); - context.afterAttributeUpdated?.({ element, attributeName: name, previousValue: value }); } } - // Copy attributes from the reference to the element, if they don’t already match. - for (const { name, value } of ref.attributes) { - const previousValue = element.getAttribute(name); - if ( - previousValue !== value && - (context.beforeAttributeUpdated?.({ element, attributeName: name, newValue: value }) ?? true) - ) { - element.setAttribute(name, value); - context.afterAttributeUpdated?.({ element, attributeName: name, previousValue }); + // For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. + #populateIdSets(node) { + const elementsWithIds = node.querySelectorAll("[id]"); + for (const elementWithId of elementsWithIds) { + const id = elementWithId.id; + // Ignore empty IDs + if (id === "") continue; + let current = elementWithId; + while (current) { + const idSet = this.#idMap.get(current); + idSet ? idSet.add(id) : this.#idMap.set(current, new Set([id])); + if (current === node) break; + current = current.parentElement; + } } } - // For certain types of elements, we need to do some extra work to ensure - // the element’s state matches the reference elements’ state. - if (isInput(element) && isInput(ref)) { - updateProperty(element, "checked", ref.checked, context); - updateProperty(element, "disabled", ref.disabled, context); - updateProperty(element, "indeterminate", ref.indeterminate, context); - if ( - element.type !== "file" && - !(context.ignoreActiveValue && document.activeElement === element) && - !(context.preserveModifiedValues && element.value !== element.defaultValue) - ) - updateProperty(element, "value", ref.value, context); - } else if (isOption(element) && isOption(ref)) updateProperty(element, "selected", ref.selected, context); - else if (isTextArea(element) && isTextArea(ref)) { - updateProperty(element, "value", ref.value, context); - // TODO: Do we need this? If so, how do we integrate with the callback? - const text = element.firstChild; - if (text && isText(text)) updateProperty(text, "textContent", ref.value, context); - } -} -// Iterates over the child nodes of the reference element, morphing the main element’s child nodes to match. -function morphChildNodes(element, ref, context) { - const childNodes = element.childNodes; - const refChildNodes = ref.childNodes; - for (let i = 0; i < refChildNodes.length; i++) { - const child = childNodes[i]; - const refChild = refChildNodes[i]; //as ReadonlyNode | null; - if (child && refChild) { - if (isElement(child) && isElement(refChild)) morphChildElement(child, refChild, element, context); - else morphNode(child, refChild, context); - } else if (refChild) { - appendChild(element, refChild.cloneNode(true), context); - } else if (child) { - removeNode(child, context); + // This is where we actually morph the nodes. The `morph` function (above) exists only to set up the `idMap`. + #morphNode(node, ref) { + if (!(this.#options.beforeNodeMorphed?.({ node, referenceNode: ref }) ?? true)) return; + if (isElement(node) && isElement(ref) && node.tagName === ref.tagName) { + if (node.hasAttributes() || ref.hasAttributes()) this.#morphAttributes(node, ref); + if (isHead(node) && isHead(ref)) { + const refChildNodes = new Map(); + for (const child of ref.children) refChildNodes.set(child.outerHTML, child); + for (const child of node.children) { + const key = child.outerHTML; + const refChild = refChildNodes.get(key); + refChild ? refChildNodes.delete(key) : this.#removeNode(child); + } + for (const refChild of refChildNodes.values()) this.#appendChild(node, refChild.cloneNode(true)); + } else if (node.hasChildNodes() || ref.hasChildNodes()) this.#morphChildNodes(node, ref); + } else { + if (isText(node) && isText(ref)) { + this.#updateProperty(node, "textContent", ref.textContent); + } else if (isComment(node) && isComment(ref)) { + this.#updateProperty(node, "nodeValue", ref.nodeValue); + } else this.#replaceNode(node, ref.cloneNode(true)); } + this.#options.afterNodeMorphed?.({ node }); } - // Clean up any excess nodes that may be left over - while (childNodes.length > refChildNodes.length) { - const child = element.lastChild; - if (child) removeNode(child, context); - } -} -function updateProperty(node, propertyName, newValue, context) { - const previousValue = node[propertyName]; - if (previousValue !== newValue && (context.beforePropertyUpdated?.({ node, propertyName, newValue }) ?? true)) { - node[propertyName] = newValue; - context.afterPropertyUpdated?.({ node, propertyName, previousValue }); + #morphAttributes(element, ref) { + // Remove any excess attributes from the element that aren’t present in the reference. + for (const { name, value } of element.attributes) { + if ( + !ref.hasAttribute(name) && + (this.#options.beforeAttributeUpdated?.({ element, attributeName: name, newValue: null }) ?? true) + ) { + element.removeAttribute(name); + this.#options.afterAttributeUpdated?.({ element, attributeName: name, previousValue: value }); + } + } + // Copy attributes from the reference to the element, if they don’t already match. + for (const { name, value } of ref.attributes) { + const previousValue = element.getAttribute(name); + if ( + previousValue !== value && + (this.#options.beforeAttributeUpdated?.({ element, attributeName: name, newValue: value }) ?? true) + ) { + element.setAttribute(name, value); + this.#options.afterAttributeUpdated?.({ element, attributeName: name, previousValue }); + } + } + // For certain types of elements, we need to do some extra work to ensure + // the element’s state matches the reference elements’ state. + if (isInput(element) && isInput(ref)) { + this.#updateProperty(element, "checked", ref.checked); + this.#updateProperty(element, "disabled", ref.disabled); + this.#updateProperty(element, "indeterminate", ref.indeterminate); + if ( + element.type !== "file" && + !(this.#options.ignoreActiveValue && document.activeElement === element) && + !(this.#options.preserveModifiedValues && element.value !== element.defaultValue) + ) + this.#updateProperty(element, "value", ref.value); + } else if (isOption(element) && isOption(ref)) this.#updateProperty(element, "selected", ref.selected); + else if (isTextArea(element) && isTextArea(ref)) { + this.#updateProperty(element, "value", ref.value); + // TODO: Do we need this? If so, how do we integrate with the callback? + const text = element.firstChild; + if (text && isText(text)) this.#updateProperty(text, "textContent", ref.value); + } } -} -function morphChildElement(child, ref, parent, context) { - const refIdSet = context.idMap.get(ref); - // Generate the array in advance of the loop - const refSetArray = refIdSet ? [...refIdSet] : []; - let currentNode = child; - let nextMatchByTagName = null; - // Try find a match by idSet, while also looking out for the next best match by tagName. - while (currentNode) { - if (isElement(currentNode)) { - const id = currentNode.id; - if (!nextMatchByTagName && currentNode.tagName === ref.tagName) { - nextMatchByTagName = currentNode; + // Iterates over the child nodes of the reference element, morphing the main element’s child nodes to match. + #morphChildNodes(element, ref) { + const childNodes = element.childNodes; + const refChildNodes = ref.childNodes; + for (let i = 0; i < refChildNodes.length; i++) { + const child = childNodes[i]; + const refChild = refChildNodes[i]; //as ReadonlyNode | null; + if (child && refChild) { + if (isElement(child) && isElement(refChild)) this.#morphChildElement(child, refChild, element); + else this.#morphNode(child, refChild); + } else if (refChild) { + this.#appendChild(element, refChild.cloneNode(true)); + } else if (child) { + this.#removeNode(child); } - if (id !== "") { - if (id === ref.id) { - insertBefore(parent, currentNode, child, context); - return morphNode(currentNode, ref, context); - } else { - const currentIdSet = context.idMap.get(currentNode); - if (currentIdSet && refSetArray.some((it) => currentIdSet.has(it))) { - insertBefore(parent, currentNode, child, context); - return morphNode(currentNode, ref, context); + } + // Clean up any excess nodes that may be left over + while (childNodes.length > refChildNodes.length) { + const child = element.lastChild; + if (child) this.#removeNode(child); + } + } + #morphChildElement(child, ref, parent) { + const refIdSet = this.#idMap.get(ref); + // Generate the array in advance of the loop + const refSetArray = refIdSet ? [...refIdSet] : []; + let currentNode = child; + let nextMatchByTagName = null; + // Try find a match by idSet, while also looking out for the next best match by tagName. + while (currentNode) { + if (isElement(currentNode)) { + const id = currentNode.id; + if (!nextMatchByTagName && currentNode.tagName === ref.tagName) { + nextMatchByTagName = currentNode; + } + if (id !== "") { + if (id === ref.id) { + this.#insertBefore(parent, currentNode, child); + return this.#morphNode(currentNode, ref); + } else { + const currentIdSet = this.#idMap.get(currentNode); + if (currentIdSet && refSetArray.some((it) => currentIdSet.has(it))) { + this.#insertBefore(parent, currentNode, child); + return this.#morphNode(currentNode, ref); + } } } } + currentNode = currentNode.nextSibling; + } + if (nextMatchByTagName) { + this.#insertBefore(parent, nextMatchByTagName, child); + this.#morphNode(nextMatchByTagName, ref); + } else { + // TODO: this is missing an added callback + this.#insertBefore(parent, ref.cloneNode(true), child); } - currentNode = currentNode.nextSibling; } - if (nextMatchByTagName) { - insertBefore(parent, nextMatchByTagName, child, context); - morphNode(nextMatchByTagName, ref, context); - } else { - // TODO: this is missing an added callback - insertBefore(parent, ref.cloneNode(true), child, context); + #updateProperty(node, propertyName, newValue) { + const previousValue = node[propertyName]; + if (previousValue !== newValue && (this.#options.beforePropertyUpdated?.({ node, propertyName, newValue }) ?? true)) { + node[propertyName] = newValue; + this.#options.afterPropertyUpdated?.({ node, propertyName, previousValue }); + } } -} -function replaceNode(node, newNode, context) { - if ( - (context.beforeNodeRemoved?.({ oldNode: node }) ?? true) && - (context.beforeNodeAdded?.({ newNode, parentNode: node.parentNode }) ?? true) - ) { - node.replaceWith(newNode); - context.afterNodeAdded?.({ newNode }); - context.afterNodeRemoved?.({ oldNode: node }); + #replaceNode(node, newNode) { + if ( + (this.#options.beforeNodeRemoved?.({ oldNode: node }) ?? true) && + (this.#options.beforeNodeAdded?.({ newNode, parentNode: node.parentNode }) ?? true) + ) { + node.replaceWith(newNode); + this.#options.afterNodeAdded?.({ newNode }); + this.#options.afterNodeRemoved?.({ oldNode: node }); + } } -} -function insertBefore(parent, node, insertionPoint, context) { - if (node === insertionPoint) return; - if (isElement(node)) { - const sensitivity = context.sensitivityMap.get(node) ?? 0; - if (sensitivity > 0) { - let previousNode = node.previousSibling; - while (previousNode) { - const previousNodeSensitivity = context.sensitivityMap.get(previousNode) ?? 0; - if (previousNodeSensitivity < sensitivity) { - parent.insertBefore(previousNode, node.nextSibling); - if (previousNode === insertionPoint) return; - previousNode = node.previousSibling; - } else { - break; + #insertBefore(parent, node, insertionPoint) { + if (node === insertionPoint) return; + if (isElement(node)) { + const sensitivity = this.#sensivityMap.get(node) ?? 0; + if (sensitivity > 0) { + let previousNode = node.previousSibling; + while (previousNode) { + const previousNodeSensitivity = this.#sensivityMap.get(previousNode) ?? 0; + if (previousNodeSensitivity < sensitivity) { + parent.insertBefore(previousNode, node.nextSibling); + if (previousNode === insertionPoint) return; + previousNode = node.previousSibling; + } else { + break; + } } } } + parent.insertBefore(node, insertionPoint); } - parent.insertBefore(node, insertionPoint); -} -function appendChild(node, newNode, context) { - if (context.beforeNodeAdded?.({ newNode, parentNode: node }) ?? true) { - node.appendChild(newNode); - context.afterNodeAdded?.({ newNode }); + #appendChild(node, newNode) { + if (this.#options.beforeNodeAdded?.({ newNode, parentNode: node }) ?? true) { + node.appendChild(newNode); + this.#options.afterNodeAdded?.({ newNode }); + } } -} -function removeNode(node, context) { - if (context.beforeNodeRemoved?.({ oldNode: node }) ?? true) { - node.remove(); - context.afterNodeRemoved?.({ oldNode: node }); + #removeNode(node) { + if (this.#options.beforeNodeRemoved?.({ oldNode: node }) ?? true) { + node.remove(); + this.#options.afterNodeRemoved?.({ oldNode: node }); + } } } function isText(node) { diff --git a/src/morphlex.ts b/src/morphlex.ts index c1aa3c8..781361f 100644 --- a/src/morphlex.ts +++ b/src/morphlex.ts @@ -83,269 +83,284 @@ export interface Options { }) => void; } -type Context = Options & { idMap: IdMap; sensitivityMap: SensivityMap }; - export function morph(node: ChildNode, reference: ChildNode, options: Options = {}): void { - const readonlyReference = reference as ReadonlyNode; - const idMap: IdMap = new WeakMap(); - const sensitivityMap: SensivityMap = new WeakMap(); - - if (isParentNode(node) && isParentNode(readonlyReference)) { - populateIdSets(node, idMap); - populateIdSets(readonlyReference, idMap); - populateSensivityMap(node, sensitivityMap); - } - - morphNode(node, readonlyReference, { ...options, idMap, sensitivityMap }); + new Morph(options).morph(node, reference); } -function populateSensivityMap(node: ReadonlyNode, sensivityMap: SensivityMap): void { - const sensitiveElements = node.querySelectorAll("iframe,video,object,embed,audio,input,textarea,canvas"); - for (const sensitiveElement of sensitiveElements) { - let sensivity = 0; +class Morph { + readonly #idMap: IdMap; + readonly #sensivityMap: SensivityMap; + readonly #options: Options; - if (isInput(sensitiveElement) || isTextArea(sensitiveElement)) { - sensivity += 1; + constructor(options: Options = {}) { + this.#options = options; + this.#idMap = new WeakMap(); + this.#sensivityMap = new WeakMap(); + } - if (sensitiveElement.value !== sensitiveElement.defaultValue) sensivity += 1; - if (sensitiveElement === document.activeElement) sensivity += 1; - } else { - sensivity += 3; + morph(node: ChildNode, reference: ChildNode): void { + const readonlyReference = reference as ReadonlyNode; - if (sensitiveElement instanceof HTMLMediaElement && !sensitiveElement.ended) { - if (!sensitiveElement.paused) sensivity += 1; - if (sensitiveElement.currentTime > 0) sensivity += 1; - } + if (isParentNode(node) && isParentNode(readonlyReference)) { + this.#populateIdSets(node); + this.#populateIdSets(readonlyReference); + this.#populateSensivityMap(node); } - let current: ReadonlyNode | null = sensitiveElement; - while (current) { - sensivityMap.set(current, (sensivityMap.get(current) || 0) + sensivity); - if (current === node) break; - current = current.parentElement; - } + this.#morphNode(node, readonlyReference); } -} -// For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. -function populateIdSets(node: ReadonlyNode, idMap: IdMap): void { - const elementsWithIds = node.querySelectorAll("[id]"); + #populateSensivityMap(node: ReadonlyNode): void { + const sensitiveElements = node.querySelectorAll("iframe,video,object,embed,audio,input,textarea,canvas"); + for (const sensitiveElement of sensitiveElements) { + let sensivity = 0; - for (const elementWithId of elementsWithIds) { - const id = elementWithId.id; + if (isInput(sensitiveElement) || isTextArea(sensitiveElement)) { + sensivity += 1; - // Ignore empty IDs - if (id === "") continue; + if (sensitiveElement.value !== sensitiveElement.defaultValue) sensivity += 1; + if (sensitiveElement === document.activeElement) sensivity += 1; + } else { + sensivity += 3; - let current: ReadonlyNode | null = elementWithId; + if (sensitiveElement instanceof HTMLMediaElement && !sensitiveElement.ended) { + if (!sensitiveElement.paused) sensivity += 1; + if (sensitiveElement.currentTime > 0) sensivity += 1; + } + } - while (current) { - const idSet: IdSet | undefined = idMap.get(current); - idSet ? idSet.add(id) : idMap.set(current, new Set([id])); - if (current === node) break; - current = current.parentElement; + let current: ReadonlyNode | null = sensitiveElement; + while (current) { + this.#sensivityMap.set(current, (this.#sensivityMap.get(current) || 0) + sensivity); + if (current === node) break; + current = current.parentElement; + } } } -} -// This is where we actually morph the nodes. The `morph` function (above) exists only to set up the `idMap`. -function morphNode(node: ChildNode, ref: ReadonlyNode, context: Context): void { - if (!(context.beforeNodeMorphed?.({ node, referenceNode: ref as ChildNode }) ?? true)) return; - - if (isElement(node) && isElement(ref) && node.tagName === ref.tagName) { - if (node.hasAttributes() || ref.hasAttributes()) morphAttributes(node, ref, context); - if (isHead(node) && isHead(ref)) { - const refChildNodes: Map> = new Map(); - for (const child of ref.children) refChildNodes.set(child.outerHTML, child); - for (const child of node.children) { - const key = child.outerHTML; - const refChild = refChildNodes.get(key); - refChild ? refChildNodes.delete(key) : removeNode(child, context); - } - for (const refChild of refChildNodes.values()) appendChild(node, refChild.cloneNode(true), context); - } else if (node.hasChildNodes() || ref.hasChildNodes()) morphChildNodes(node, ref, context); - } else { - if (isText(node) && isText(ref)) { - updateProperty(node, "textContent", ref.textContent, context); - } else if (isComment(node) && isComment(ref)) { - updateProperty(node, "nodeValue", ref.nodeValue, context); - } else replaceNode(node, ref.cloneNode(true), context); - } + // For each node with an ID, push that ID into the IdSet on the IdMap, for each of its parent elements. + #populateIdSets(node: ReadonlyNode): void { + const elementsWithIds = node.querySelectorAll("[id]"); - context.afterNodeMorphed?.({ node }); -} + for (const elementWithId of elementsWithIds) { + const id = elementWithId.id; + + // Ignore empty IDs + if (id === "") continue; -function morphAttributes(element: Element, ref: ReadonlyNode, context: Context): void { - // Remove any excess attributes from the element that aren’t present in the reference. - for (const { name, value } of element.attributes) { - if (!ref.hasAttribute(name) && (context.beforeAttributeUpdated?.({ element, attributeName: name, newValue: null }) ?? true)) { - element.removeAttribute(name); - context.afterAttributeUpdated?.({ element, attributeName: name, previousValue: value }); + let current: ReadonlyNode | null = elementWithId; + + while (current) { + const idSet: IdSet | undefined = this.#idMap.get(current); + idSet ? idSet.add(id) : this.#idMap.set(current, new Set([id])); + if (current === node) break; + current = current.parentElement; + } } } - // Copy attributes from the reference to the element, if they don’t already match. - for (const { name, value } of ref.attributes) { - const previousValue = element.getAttribute(name); - if ( - previousValue !== value && - (context.beforeAttributeUpdated?.({ element, attributeName: name, newValue: value }) ?? true) - ) { - element.setAttribute(name, value); - context.afterAttributeUpdated?.({ element, attributeName: name, previousValue }); + // This is where we actually morph the nodes. The `morph` function (above) exists only to set up the `idMap`. + #morphNode(node: ChildNode, ref: ReadonlyNode): void { + if (!(this.#options.beforeNodeMorphed?.({ node, referenceNode: ref as ChildNode }) ?? true)) return; + + if (isElement(node) && isElement(ref) && node.tagName === ref.tagName) { + if (node.hasAttributes() || ref.hasAttributes()) this.#morphAttributes(node, ref); + if (isHead(node) && isHead(ref)) { + const refChildNodes: Map> = new Map(); + for (const child of ref.children) refChildNodes.set(child.outerHTML, child); + for (const child of node.children) { + const key = child.outerHTML; + const refChild = refChildNodes.get(key); + refChild ? refChildNodes.delete(key) : this.#removeNode(child); + } + for (const refChild of refChildNodes.values()) this.#appendChild(node, refChild.cloneNode(true)); + } else if (node.hasChildNodes() || ref.hasChildNodes()) this.#morphChildNodes(node, ref); + } else { + if (isText(node) && isText(ref)) { + this.#updateProperty(node, "textContent", ref.textContent); + } else if (isComment(node) && isComment(ref)) { + this.#updateProperty(node, "nodeValue", ref.nodeValue); + } else this.#replaceNode(node, ref.cloneNode(true)); } - } - // For certain types of elements, we need to do some extra work to ensure - // the element’s state matches the reference elements’ state. - if (isInput(element) && isInput(ref)) { - updateProperty(element, "checked", ref.checked, context); - updateProperty(element, "disabled", ref.disabled, context); - updateProperty(element, "indeterminate", ref.indeterminate, context); - if ( - element.type !== "file" && - !(context.ignoreActiveValue && document.activeElement === element) && - !(context.preserveModifiedValues && element.value !== element.defaultValue) - ) - updateProperty(element, "value", ref.value, context); - } else if (isOption(element) && isOption(ref)) updateProperty(element, "selected", ref.selected, context); - else if (isTextArea(element) && isTextArea(ref)) { - updateProperty(element, "value", ref.value, context); - - // TODO: Do we need this? If so, how do we integrate with the callback? - const text = element.firstChild; - if (text && isText(text)) updateProperty(text, "textContent", ref.value, context); + this.#options.afterNodeMorphed?.({ node }); } -} -// Iterates over the child nodes of the reference element, morphing the main element’s child nodes to match. -function morphChildNodes(element: Element, ref: ReadonlyNode, context: Context): void { - const childNodes = element.childNodes; - const refChildNodes = ref.childNodes; - - for (let i = 0; i < refChildNodes.length; i++) { - const child = childNodes[i] as ChildNode | null; - const refChild = refChildNodes[i]; //as ReadonlyNode | null; - - if (child && refChild) { - if (isElement(child) && isElement(refChild)) morphChildElement(child, refChild, element, context); - else morphNode(child, refChild, context); - } else if (refChild) { - appendChild(element, refChild.cloneNode(true), context); - } else if (child) { - removeNode(child, context); + #morphAttributes(element: Element, ref: ReadonlyNode): void { + // Remove any excess attributes from the element that aren’t present in the reference. + for (const { name, value } of element.attributes) { + if ( + !ref.hasAttribute(name) && + (this.#options.beforeAttributeUpdated?.({ element, attributeName: name, newValue: null }) ?? true) + ) { + element.removeAttribute(name); + this.#options.afterAttributeUpdated?.({ element, attributeName: name, previousValue: value }); + } } - } - // Clean up any excess nodes that may be left over - while (childNodes.length > refChildNodes.length) { - const child = element.lastChild; - if (child) removeNode(child, context); - } -} + // Copy attributes from the reference to the element, if they don’t already match. + for (const { name, value } of ref.attributes) { + const previousValue = element.getAttribute(name); + if ( + previousValue !== value && + (this.#options.beforeAttributeUpdated?.({ element, attributeName: name, newValue: value }) ?? true) + ) { + element.setAttribute(name, value); + this.#options.afterAttributeUpdated?.({ element, attributeName: name, previousValue }); + } + } -function updateProperty(node: N, propertyName: P, newValue: N[P], context: Context): void { - const previousValue = node[propertyName]; - if (previousValue !== newValue && (context.beforePropertyUpdated?.({ node, propertyName, newValue }) ?? true)) { - node[propertyName] = newValue; - context.afterPropertyUpdated?.({ node, propertyName, previousValue }); + // For certain types of elements, we need to do some extra work to ensure + // the element’s state matches the reference elements’ state. + if (isInput(element) && isInput(ref)) { + this.#updateProperty(element, "checked", ref.checked); + this.#updateProperty(element, "disabled", ref.disabled); + this.#updateProperty(element, "indeterminate", ref.indeterminate); + if ( + element.type !== "file" && + !(this.#options.ignoreActiveValue && document.activeElement === element) && + !(this.#options.preserveModifiedValues && element.value !== element.defaultValue) + ) + this.#updateProperty(element, "value", ref.value); + } else if (isOption(element) && isOption(ref)) this.#updateProperty(element, "selected", ref.selected); + else if (isTextArea(element) && isTextArea(ref)) { + this.#updateProperty(element, "value", ref.value); + + // TODO: Do we need this? If so, how do we integrate with the callback? + const text = element.firstChild; + if (text && isText(text)) this.#updateProperty(text, "textContent", ref.value); + } } -} -function morphChildElement(child: Element, ref: ReadonlyNode, parent: Element, context: Context): void { - const refIdSet = context.idMap.get(ref); + // Iterates over the child nodes of the reference element, morphing the main element’s child nodes to match. + #morphChildNodes(element: Element, ref: ReadonlyNode): void { + const childNodes = element.childNodes; + const refChildNodes = ref.childNodes; + + for (let i = 0; i < refChildNodes.length; i++) { + const child = childNodes[i] as ChildNode | null; + const refChild = refChildNodes[i]; //as ReadonlyNode | null; + + if (child && refChild) { + if (isElement(child) && isElement(refChild)) this.#morphChildElement(child, refChild, element); + else this.#morphNode(child, refChild); + } else if (refChild) { + this.#appendChild(element, refChild.cloneNode(true)); + } else if (child) { + this.#removeNode(child); + } + } + + // Clean up any excess nodes that may be left over + while (childNodes.length > refChildNodes.length) { + const child = element.lastChild; + if (child) this.#removeNode(child); + } + } - // Generate the array in advance of the loop - const refSetArray = refIdSet ? [...refIdSet] : []; + #morphChildElement(child: Element, ref: ReadonlyNode, parent: Element): void { + const refIdSet = this.#idMap.get(ref); - let currentNode: ChildNode | null = child; - let nextMatchByTagName: ChildNode | null = null; + // Generate the array in advance of the loop + const refSetArray = refIdSet ? [...refIdSet] : []; - // Try find a match by idSet, while also looking out for the next best match by tagName. - while (currentNode) { - if (isElement(currentNode)) { - const id = currentNode.id; + let currentNode: ChildNode | null = child; + let nextMatchByTagName: ChildNode | null = null; - if (!nextMatchByTagName && currentNode.tagName === ref.tagName) { - nextMatchByTagName = currentNode; - } + // Try find a match by idSet, while also looking out for the next best match by tagName. + while (currentNode) { + if (isElement(currentNode)) { + const id = currentNode.id; - if (id !== "") { - if (id === ref.id) { - insertBefore(parent, currentNode, child, context); - return morphNode(currentNode, ref, context); - } else { - const currentIdSet = context.idMap.get(currentNode); + if (!nextMatchByTagName && currentNode.tagName === ref.tagName) { + nextMatchByTagName = currentNode; + } - if (currentIdSet && refSetArray.some((it) => currentIdSet.has(it))) { - insertBefore(parent, currentNode, child, context); - return morphNode(currentNode, ref, context); + if (id !== "") { + if (id === ref.id) { + this.#insertBefore(parent, currentNode, child); + return this.#morphNode(currentNode, ref); + } else { + const currentIdSet = this.#idMap.get(currentNode); + + if (currentIdSet && refSetArray.some((it) => currentIdSet.has(it))) { + this.#insertBefore(parent, currentNode, child); + return this.#morphNode(currentNode, ref); + } } } } + + currentNode = currentNode.nextSibling; } - currentNode = currentNode.nextSibling; + if (nextMatchByTagName) { + this.#insertBefore(parent, nextMatchByTagName, child); + this.#morphNode(nextMatchByTagName, ref); + } else { + // TODO: this is missing an added callback + this.#insertBefore(parent, ref.cloneNode(true), child); + } } - if (nextMatchByTagName) { - insertBefore(parent, nextMatchByTagName, child, context); - morphNode(nextMatchByTagName, ref, context); - } else { - // TODO: this is missing an added callback - insertBefore(parent, ref.cloneNode(true), child, context); + #updateProperty(node: N, propertyName: P, newValue: N[P]): void { + const previousValue = node[propertyName]; + if (previousValue !== newValue && (this.#options.beforePropertyUpdated?.({ node, propertyName, newValue }) ?? true)) { + node[propertyName] = newValue; + this.#options.afterPropertyUpdated?.({ node, propertyName, previousValue }); + } } -} -function replaceNode(node: ChildNode, newNode: Node, context: Context): void { - if ( - (context.beforeNodeRemoved?.({ oldNode: node }) ?? true) && - (context.beforeNodeAdded?.({ newNode, parentNode: node.parentNode }) ?? true) - ) { - node.replaceWith(newNode); - context.afterNodeAdded?.({ newNode }); - context.afterNodeRemoved?.({ oldNode: node }); + #replaceNode(node: ChildNode, newNode: Node): void { + if ( + (this.#options.beforeNodeRemoved?.({ oldNode: node }) ?? true) && + (this.#options.beforeNodeAdded?.({ newNode, parentNode: node.parentNode }) ?? true) + ) { + node.replaceWith(newNode); + this.#options.afterNodeAdded?.({ newNode }); + this.#options.afterNodeRemoved?.({ oldNode: node }); + } } -} -function insertBefore(parent: ParentNode, node: Node, insertionPoint: ChildNode, context: Context): void { - if (node === insertionPoint) return; + #insertBefore(parent: ParentNode, node: Node, insertionPoint: ChildNode): void { + if (node === insertionPoint) return; - if (isElement(node)) { - const sensitivity = context.sensitivityMap.get(node) ?? 0; + if (isElement(node)) { + const sensitivity = this.#sensivityMap.get(node) ?? 0; - if (sensitivity > 0) { - let previousNode = node.previousSibling; + if (sensitivity > 0) { + let previousNode = node.previousSibling; - while (previousNode) { - const previousNodeSensitivity = context.sensitivityMap.get(previousNode) ?? 0; + while (previousNode) { + const previousNodeSensitivity = this.#sensivityMap.get(previousNode) ?? 0; - if (previousNodeSensitivity < sensitivity) { - parent.insertBefore(previousNode, node.nextSibling); + if (previousNodeSensitivity < sensitivity) { + parent.insertBefore(previousNode, node.nextSibling); - if (previousNode === insertionPoint) return; - previousNode = node.previousSibling; - } else { - break; + if (previousNode === insertionPoint) return; + previousNode = node.previousSibling; + } else { + break; + } } } } - } - parent.insertBefore(node, insertionPoint); -} + parent.insertBefore(node, insertionPoint); + } -function appendChild(node: ParentNode, newNode: Node, context: Context): void { - if (context.beforeNodeAdded?.({ newNode, parentNode: node }) ?? true) { - node.appendChild(newNode); - context.afterNodeAdded?.({ newNode }); + #appendChild(node: ParentNode, newNode: Node): void { + if (this.#options.beforeNodeAdded?.({ newNode, parentNode: node }) ?? true) { + node.appendChild(newNode); + this.#options.afterNodeAdded?.({ newNode }); + } } -} -function removeNode(node: ChildNode, context: Context): void { - if (context.beforeNodeRemoved?.({ oldNode: node }) ?? true) { - node.remove(); - context.afterNodeRemoved?.({ oldNode: node }); + #removeNode(node: ChildNode): void { + if (this.#options.beforeNodeRemoved?.({ oldNode: node }) ?? true) { + node.remove(); + this.#options.afterNodeRemoved?.({ oldNode: node }); + } } } diff --git a/terser-config.json b/terser-config.json index 2870300..afc4984 100644 --- a/terser-config.json +++ b/terser-config.json @@ -1,9 +1,5 @@ { - "compress": true, - "mangle": { - "properties": { - "regex": "^_" - } - }, - "module": true + "mangle": true, + "module": true, + "compress": true } diff --git a/tsconfig.json b/tsconfig.json index 599ce21..8d56e20 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "noUnusedLocals": true, "rootDir": "src", "strict": true, - "target": "es2020", + "target": "es2022", "removeComments": false, "outDir": "dist", "baseUrl": ".",