From 826b9eb341c754bef576f42d03b6f55eec05378c Mon Sep 17 00:00:00 2001 From: Micah Geisel Date: Sun, 23 Aug 2026 10:41:25 +0200 Subject: [PATCH] move the htmx extension out of tree into htmx-ext-morph. closes #111 --- CHANGELOG.md | 3 + README.md | 53 -- ROADMAP.md | 2 +- dist/idiomorph-ext.esm.js | 1490 ------------------------------------ dist/idiomorph-ext.js | 1486 ----------------------------------- dist/idiomorph-ext.min.js | 1 - dist/idiomorph-htmx.js | 24 - package-lock.json | 7 - package.json | 14 +- src/idiomorph-htmx.js | 24 - test/demo/scratch.html | 14 +- test/htmx-integration.js | 215 ------ test/htmx/above.html | 12 - test/htmx/below.html | 12 - test/htmx/htmx-demo.html | 138 ---- test/htmx/htmx-demo2.html | 23 - test/index.html | 5 +- web-test-runner.config.mjs | 4 +- 18 files changed, 11 insertions(+), 3516 deletions(-) delete mode 100644 dist/idiomorph-ext.esm.js delete mode 100644 dist/idiomorph-ext.js delete mode 100644 dist/idiomorph-ext.min.js delete mode 100644 dist/idiomorph-htmx.js delete mode 100644 src/idiomorph-htmx.js delete mode 100644 test/htmx-integration.js delete mode 100644 test/htmx/above.html delete mode 100644 test/htmx/below.html delete mode 100644 test/htmx/htmx-demo.html delete mode 100644 test/htmx/htmx-demo2.html diff --git a/CHANGELOG.md b/CHANGELOG.md index a82b008..84ff268 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +* Removed: + * Move the htmx extension out of tree into [htmx-ext-morph](https://github.com/bigskysoftware/htmx-extensions/tree/main/src/morph). (@botandrose) #111 + ## [0.8.0] - 2026-09-05 * Removed: diff --git a/README.md b/README.md index dc8abcb..1677ba8 100644 --- a/README.md +++ b/README.md @@ -161,59 +161,6 @@ The `head` object also offers callbacks for configuring head merging specifics. All the behaviors specified above can be set to a different default by mutating the `Idiomorph.defaults` object, including the `Idiomorph.defaults.callbacks` and `Idiomorph.defaults.head` objects. -### htmx - -Idiomorph was created to integrate with [htmx](https://htmx.org) and can be used as a swapping mechanism by including -the `dist/idiomorph-ext.js` file in your HTML: - -```html - -
- - - - - - - -
-``` - -or by importing the "idiomorph/htmx" module: - -```html -import "idiomorph/htmx"; -``` - -Note that this file includes both Idiomorph and the htmx extension. - -#### Configuring Morphing Behavior in htmx - -The Idiomorph extension for htmx supports three different syntaxes for specifying behavior: - -* `hx-swap='morph'` - This will perform a morph on the outerHTML of the target -* `hx-swap='morph:outerHTML'` - This will perform a morph on the outerHTML of the target (explicit) -* `hx-swap='morph:innerHTML'` - This will perform a morph on the innerHTML of the target (i.e. the children) -* `hx-swap='morph:'` - In this form, `` can be any valid JavaScript expression. The results of the expression - will be passed into the `Idiomorph.morph()` method as the configuration. - -The last form gives you access to all the configuration options of Idiomorph. So, for example, if you wanted to ignore -the input value in a given morph, you could use the following swap specification: - -```html - -``` - ## Performance Idiomorph is not designed to be as fast as either morphdom or nanomorph. Rather, its goals are: diff --git a/ROADMAP.md b/ROADMAP.md index 37a770b..763fc47 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,7 +11,7 @@ This document outlines the development roadmap for Idiomorph. It provides a high ## Milestones ### 0.9.0 -- [ ] Move idiomorph/htmx.js out of tree into an htmx extension https://github.com/bigskysoftware/idiomorph/issues/111 +- [x] Move idiomorph/htmx.js out of tree into an htmx extension https://github.com/bigskysoftware/idiomorph/issues/111 - [ ] Settle input value semantics, and the subtree-skipping speedup they unblock https://github.com/bigskysoftware/idiomorph/issues/144 - [ ] Improve anonymous node matching, perhaps using Merkle trees, or fuzzy synthetic ids? https://github.com/bigskysoftware/idiomorph/issues/143 - [ ] Plugin system? https://github.com/bigskysoftware/idiomorph/issues/109 diff --git a/dist/idiomorph-ext.esm.js b/dist/idiomorph-ext.esm.js deleted file mode 100644 index 8431b71..0000000 --- a/dist/idiomorph-ext.esm.js +++ /dev/null @@ -1,1490 +0,0 @@ -import htmx from "htmx.org"; - -/** - * @typedef {object} ConfigHead - * - * @property {'merge' | 'append' | 'morph' | 'none'} [style] - * @property {boolean} [block] - * @property {function(Element): boolean} [shouldPreserve] - * @property {function(Element): boolean} [shouldReAppend] - * @property {function(Element): boolean} [shouldRemove] - * @property {function(Element, {added: Node[], kept: Element[], removed: Element[]}): void} [afterHeadMorphed] - */ - -/** - * @typedef {object} ConfigCallbacks - * - * @property {function(Node): boolean} [beforeNodeAdded] - * @property {function(Node): void} [afterNodeAdded] - * @property {function(Element, Node): boolean} [beforeNodeMorphed] - * @property {function(Element, Node): void} [afterNodeMorphed] - * @property {function(Element): boolean} [beforeNodeRemoved] - * @property {function(Element): void} [afterNodeRemoved] - * @property {function(string, Element, "update" | "remove"): boolean} [beforeAttributeUpdated] - */ - -/** - * @typedef {object} Config - * - * @property {'outerHTML' | 'innerHTML'} [morphStyle] - * @property {boolean} [ignoreActive] - * @property {boolean} [ignoreActiveValue] - * @property {boolean} [restoreFocus] - * @property {ConfigCallbacks} [callbacks] - * @property {ConfigHead} [head] - */ - -/** - * @callback NoOp - * - * @returns {void} - */ - -/** - * @typedef {object} ConfigHeadInternal - * - * @property {'merge' | 'append' | 'morph' | 'none'} style - * @property {boolean} [block] - * @property {(function(Element): boolean) | NoOp} shouldPreserve - * @property {(function(Element): boolean) | NoOp} shouldReAppend - * @property {(function(Element): boolean) | NoOp} shouldRemove - * @property {(function(Element, {added: Node[], kept: Element[], removed: Element[]}): void) | NoOp} afterHeadMorphed - */ - -/** - * @typedef {object} ConfigCallbacksInternal - * - * @property {(function(Node): boolean) | NoOp} beforeNodeAdded - * @property {(function(Node): void) | NoOp} afterNodeAdded - * @property {(function(Node, Node): boolean) | NoOp} beforeNodeMorphed - * @property {(function(Node, Node): void) | NoOp} afterNodeMorphed - * @property {(function(Node): boolean) | NoOp} beforeNodeRemoved - * @property {(function(Node): void) | NoOp} afterNodeRemoved - * @property {(function(string, Element, "update" | "remove"): boolean) | NoOp} beforeAttributeUpdated - */ - -/** - * @typedef {object} ConfigInternal - * - * @property {'outerHTML' | 'innerHTML'} morphStyle - * @property {boolean} [ignoreActive] - * @property {boolean} [ignoreActiveValue] - * @property {boolean} [restoreFocus] - * @property {ConfigCallbacksInternal} callbacks - * @property {ConfigHeadInternal} head - */ - -/** - * @typedef {Object} IdElement - * @property {Element} elt - * @property {string} id - */ - -/** - * @typedef {Object} IdSets - * @property {Set} persistentIds - * @property {Map>} idMap - */ - -/** - * @callback Morph - * - * @param {Node} oldNode - * @param {Node | HTMLCollection | Node[] | string | null} newContent - * @param {Config} [config] - * @returns {Promise | Node[]} - */ - -// base IIFE to define idiomorph -/** - * - * @type {{defaults: ConfigInternal, morph: Morph}} - */ -var Idiomorph = (function () { - "use strict"; - - /** - * @typedef {object} MorphContext - * - * @property {Element} target - * @property {Element} newContent - * @property {Document} doc - * @property {ConfigInternal} config - * @property {ConfigInternal['morphStyle']} morphStyle - * @property {ConfigInternal['ignoreActive']} ignoreActive - * @property {ConfigInternal['ignoreActiveValue']} ignoreActiveValue - * @property {ConfigInternal['restoreFocus']} restoreFocus - * @property {Map>} idMap - * @property {Set} persistentIds - * @property {ConfigInternal['callbacks']} callbacks - * @property {ConfigInternal['head']} head - * @property {HTMLDivElement} pantry - * @property {Element[]} activeElementAndParents - */ - - //============================================================================= - // AND NOW IT BEGINS... - //============================================================================= - - const noOp = () => {}; - /** - * Default configuration values, updatable by users now - * @type {ConfigInternal} - */ - const defaults = { - morphStyle: "outerHTML", - callbacks: { - beforeNodeAdded: noOp, - afterNodeAdded: noOp, - beforeNodeMorphed: noOp, - afterNodeMorphed: noOp, - beforeNodeRemoved: noOp, - afterNodeRemoved: noOp, - beforeAttributeUpdated: noOp, - }, - head: { - style: "merge", - shouldPreserve: (elt) => elt.getAttribute("im-preserve") === "true", - shouldReAppend: (elt) => elt.getAttribute("im-re-append") === "true", - shouldRemove: noOp, - afterHeadMorphed: noOp, - }, - restoreFocus: true, - }; - - /** - * Core idiomorph function for morphing one DOM tree to another - * - * @param {Node} oldNode - * @param {Node | HTMLCollection | Node[] | string | null} newContent - * @param {Config} [config] - * @returns {Promise | Node[]} - */ - function morph(oldNode, newContent, config = {}) { - const oldElt = normalizeElement(oldNode); - const newNode = normalizeParent(newContent); - const ctx = createMorphContext(oldElt, newNode, config); - - return withHeadBlocking( - ctx, - oldElt, - newNode, - /** @param {MorphContext} ctx */ (ctx) => { - const morphedNodes = saveAndRestoreFocus(ctx, () => { - if (ctx.morphStyle === "innerHTML") { - morphChildren(ctx, oldElt, newNode); - return Array.from(oldElt.childNodes); - } else { - return morphOuterHTML(ctx, oldElt, newNode); - } - }); - ctx.pantry.remove(); - return morphedNodes; - }, - ); - } - - /** - * Morph just the outerHTML of the oldNode to the newContent - * We have to be careful because the oldNode could have siblings which need to be untouched - * @param {MorphContext} ctx - * @param {Element} oldNode - * @param {Element} newNode - * @returns {Node[]} - */ - function morphOuterHTML(ctx, oldNode, newNode) { - const oldParent = normalizeParent(oldNode); - morphChildren( - ctx, - oldParent, - newNode, - // these two optional params are the secret sauce - oldNode, // start point for iteration - oldNode.nextSibling, // end point for iteration - ); - // this is safe even with siblings, because normalizeParent returns a SlicedParentNode if needed. - return Array.from(oldParent.childNodes); - } - - /** - * @param {MorphContext} ctx - * @param {Function} fn - * @returns {Node[]} - */ - function saveAndRestoreFocus(ctx, fn) { - if (!ctx.config.restoreFocus) return fn(); - let activeElement = - /** @type {HTMLInputElement|HTMLTextAreaElement|null} */ ( - ctx.doc.activeElement - ); - - // don't bother if the active element is not an input or textarea - if ( - !(is.inputElement(activeElement) || is.textAreaElement(activeElement)) - ) { - return fn(); - } - - const { id: activeElementId, selectionStart, selectionEnd } = activeElement; - - const results = fn(); - - if ( - activeElementId && - activeElementId !== ctx.doc.activeElement?.getAttribute("id") - ) { - activeElement = ctx.target.querySelector( - `[id="${CSS.escape(activeElementId)}"]`, - ); - activeElement?.focus(); - } - if (activeElement && !activeElement.selectionEnd && selectionEnd) { - try { - activeElement.setSelectionRange(selectionStart, selectionEnd); - } catch { - // the element may not support setSelectionRange: it's no longer an - // input/textarea after the morph, or it's an input type (number, - // email, date, ...) that doesn't support text selection - } - } - - return results; - } - - const morphChildren = (function () { - /** - * This is the core algorithm for matching up children. The idea is to use id sets to try to match up - * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but - * by using id sets, we are able to better match up with content deeper in the DOM. - * - * Basic algorithm: - * - for each node in the new content: - * - search self and siblings for an id set match, falling back to a soft match - * - if match found - * - remove any nodes up to the match: - * - pantry persistent nodes - * - delete the rest - * - morph the match - * - elsif no match found, and node is persistent - * - find its match by querying the old root (future) and pantry (past) - * - move it and its children here - * - morph it - * - else - * - create a new node from scratch as a last result - * - * @param {MorphContext} ctx the merge context - * @param {Element} oldParent the old content that we are merging the new content into - * @param {Element} newParent the parent element of the new content - * @param {Node|null} [insertionPoint] the point in the DOM we start morphing at (defaults to first child) - * @param {Node|null} [endPoint] the point in the DOM we stop morphing at (defaults to after last child) - */ - function morphChildren( - ctx, - oldParent, - newParent, - insertionPoint = null, - endPoint = null, - ) { - // normalize - if (is.templateElement(oldParent) && is.templateElement(newParent)) { - // @ts-ignore we can pretend the DocumentFragment is an Element - oldParent = oldParent.content; - // @ts-ignore ditto - newParent = newParent.content; - } - insertionPoint ||= oldParent.firstChild; - - // run through all the new content - for (const newChild of newParent.childNodes) { - // once we reach the end of the old parent content skip to the end and insert the rest - if (insertionPoint && insertionPoint != endPoint) { - const bestMatch = findBestMatch( - ctx, - newChild, - insertionPoint, - endPoint, - ); - if (bestMatch) { - // if the node to morph is not at the insertion point then remove/move up to it - if (bestMatch !== insertionPoint) { - removeNodesBetween(ctx, insertionPoint, bestMatch); - } - morphNode(bestMatch, newChild, ctx); - insertionPoint = bestMatch.nextSibling; - continue; - } - } - - // if the matching node is elsewhere in the original content - if (is.element(newChild)) { - // we can pretend the id is non-null because the next `.has` line will reject it if not - const newChildId = /** @type {String} */ ( - newChild.getAttribute("id") - ); - if (ctx.persistentIds.has(newChildId)) { - // move it and all its children here and morph - const movedChild = moveBeforeById( - oldParent, - newChildId, - insertionPoint, - ctx, - ); - morphNode(movedChild, newChild, ctx); - insertionPoint = movedChild.nextSibling; - continue; - } - } - - // last resort: insert the new node from scratch - const insertedNode = createNode( - oldParent, - newChild, - insertionPoint, - ctx, - ); - // could be null if beforeNodeAdded prevented insertion - if (insertedNode) { - insertionPoint = insertedNode.nextSibling; - } - } - - // remove any remaining old nodes that didn't match up with new content - while (insertionPoint && insertionPoint != endPoint) { - const tempNode = insertionPoint; - insertionPoint = insertionPoint.nextSibling; - removeNode(ctx, tempNode); - } - } - - /** - * This performs the action of inserting a new node while handling situations where the node contains - * elements with persistent ids and possible state info we can still preserve by moving in and then morphing - * - * @param {Element} oldParent - * @param {Node} newChild - * @param {Node|null} insertionPoint - * @param {MorphContext} ctx - * @returns {Node|null} - */ - function createNode(oldParent, newChild, insertionPoint, ctx) { - if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null; - if (ctx.idMap.has(newChild)) { - // node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm - // createElementNS doesn't case-normalize, so localName rather than tagName - const newEmptyChild = ctx.doc.createElementNS( - /** @type {Element} */ (newChild).namespaceURI, - /** @type {Element} */ (newChild).localName, - ); - oldParent.insertBefore(newEmptyChild, insertionPoint); - morphNode(newEmptyChild, newChild, ctx); - ctx.callbacks.afterNodeAdded(newEmptyChild); - return newEmptyChild; - } else { - // optimisation: no id state to preserve so we can just insert a clone of the newChild and its descendants - const newClonedChild = ctx.doc.importNode(newChild, true); // importNode to not mutate newParent - oldParent.insertBefore(newClonedChild, insertionPoint); - ctx.callbacks.afterNodeAdded(newClonedChild); - return newClonedChild; - } - } - - //============================================================================= - // Matching Functions - //============================================================================= - const findBestMatch = (function () { - /** - * Scans forward from the startPoint to the endPoint looking for a match - * for the node. It looks for an id set match first, then a soft match. - * We abort softmatching if we find two future soft matches, to reduce churn. - * @param {Node} node - * @param {MorphContext} ctx - * @param {Node | null} startPoint - * @param {Node | null} endPoint - * @returns {Node | null} - */ - function findBestMatch(ctx, node, startPoint, endPoint) { - let softMatch = null; - let nextSibling = node.nextSibling; - let siblingSoftMatchCount = 0; - - let cursor = startPoint; - while (cursor && cursor != endPoint) { - // soft matching is a prerequisite for id set matching - if (isSoftMatch(cursor, node)) { - if (isIdSetMatch(ctx, cursor, node)) { - return cursor; // found an id set match, we're done! - } - - // we haven't yet saved a soft match fallback - if (softMatch === null) { - // the current soft match will hard match something else in the future, leave it - if (!ctx.idMap.has(cursor)) { - // save this as the fallback if we get through the loop without finding a hard match - softMatch = cursor; - } - } - } - if ( - softMatch === null && - nextSibling && - isSoftMatch(cursor, nextSibling) - ) { - // The next new node has a soft match with this node, so - // increment the count of future soft matches - siblingSoftMatchCount++; - nextSibling = nextSibling.nextSibling; - - // If there are two future soft matches, block soft matching for this node to allow - // future siblings to soft match. This is to reduce churn in the DOM when an element - // is prepended. - if (siblingSoftMatchCount >= 2) { - softMatch = undefined; - } - } - - // if the current node contains active element, stop looking for better future matches, - // because if one is found, this node will be moved to the pantry, reparenting it and thus losing focus - // @ts-ignore pretend cursor is Element rather than Node, we're just testing for array inclusion - if (ctx.activeElementAndParents.includes(cursor)) break; - - cursor = cursor.nextSibling; - } - - return softMatch || null; - } - - /** - * - * @param {MorphContext} ctx - * @param {Node} oldNode - * @param {Node} newNode - * @returns {boolean} - */ - function isIdSetMatch(ctx, oldNode, newNode) { - let oldSet = ctx.idMap.get(oldNode); - let newSet = ctx.idMap.get(newNode); - - if (!newSet || !oldSet) return false; - - for (const id of oldSet) { - // a potential match is an id in the new and old nodes that - // has not already been merged into the DOM - // But the newNode content we call this on has not been - // merged yet and we don't allow duplicate IDs so it is simple - if (newSet.has(id)) { - return true; - } - } - return false; - } - - /** - * - * @param {Node} oldNode - * @param {Node} newNode - * @returns {boolean} - */ - function isSoftMatch(oldNode, newNode) { - // ok to cast: if one is not element, `id` and `tagName` will be undefined and we'll just compare that. - const oldElt = /** @type {Element} */ (oldNode); - const newElt = /** @type {Element} */ (newNode); - - return ( - oldElt.nodeType === newElt.nodeType && - oldElt.tagName === newElt.tagName && - // If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing. - // We'll still match an anonymous node with an IDed newElt, though, because if it got this far, - // its not persistent, and new nodes can't have any hidden state. - // We can't use .id because of form input shadowing, and we can't count on .getAttribute's presence because it could be a document-fragment - (!oldElt.getAttribute?.("id") || - oldElt.getAttribute?.("id") === newElt.getAttribute?.("id")) - ); - } - - return findBestMatch; - })(); - - //============================================================================= - // DOM Manipulation Functions - //============================================================================= - - /** - * Gets rid of an unwanted DOM node; strategy depends on nature of its reuse: - * - Persistent nodes will be moved to the pantry for later reuse - * - Other nodes will have their hooks called, and then are removed - * @param {MorphContext} ctx - * @param {Node} node - */ - function removeNode(ctx, node) { - // don't accidentally morph the pantry out of existence when morphing the full document - if (node === ctx.pantry) return; - // are we going to id set match this later? - if (ctx.idMap.has(node)) { - // skip callbacks and move to pantry - moveBefore(ctx.pantry, node, null); - } else { - // remove for realsies - if (ctx.callbacks.beforeNodeRemoved(node) === false) return; - node.parentNode?.removeChild(node); - ctx.callbacks.afterNodeRemoved(node); - } - } - - /** - * Remove nodes between the start and end nodes - * @param {MorphContext} ctx - * @param {Node} startInclusive - * @param {Node} endExclusive - * @returns {Node|null} - */ - function removeNodesBetween(ctx, startInclusive, endExclusive) { - /** @type {Node | null} */ - let cursor = startInclusive; - // remove nodes until the endExclusive node - while (cursor && cursor !== endExclusive) { - let tempNode = /** @type {Node} */ (cursor); - cursor = cursor.nextSibling; - removeNode(ctx, tempNode); - } - return cursor; - } - - /** - * Search for an element by id within the document and pantry, and move it using moveBefore. - * - * @param {Element} parentNode - The parent node to which the element will be moved. - * @param {string} id - The ID of the element to be moved. - * @param {Node | null} after - The reference node to insert the element before. - * If `null`, the element is appended as the last child. - * @param {MorphContext} ctx - * @returns {Element} The found element - */ - function moveBeforeById(parentNode, id, after, ctx) { - const selector = `[id="${CSS.escape(id)}"]`; - const target = - /** @type {Element} - will always be found */ - ( - // ctx.target.id unsafe because of form input shadowing - // ctx.target could be a document fragment which doesn't have `getAttribute` - (ctx.target.getAttribute?.("id") === id && ctx.target) || - ctx.target.querySelector(selector) || - ctx.pantry.querySelector(selector) - ); - removeElementFromAncestorsIdMaps(target, ctx); - moveBefore(parentNode, target, after); - return target; - } - - /** - * Removes an element from its ancestors' id maps. This is needed when an element is moved from the - * "future" via `moveBeforeId`. Otherwise, its erstwhile ancestors could be mistakenly moved to the - * pantry rather than being deleted, preventing their removal hooks from being called. - * - * @param {Element} element - element to remove from its ancestors' id maps - * @param {MorphContext} ctx - */ - function removeElementFromAncestorsIdMaps(element, ctx) { - // we know id is non-null String, because this function is only called on elements with ids - const id = /** @type {String} */ (element.getAttribute("id")); - /** @ts-ignore - safe to loop in this way **/ - while ((element = element.parentNode)) { - let idSet = ctx.idMap.get(element); - if (idSet) { - idSet.delete(id); - if (!idSet.size) { - ctx.idMap.delete(element); - } - } - } - } - - /** - * Moves an element before another element within the same parent. - * Uses the `moveBefore` API if available (and working), otherwise falls back to `insertBefore`. - * This is essentialy a forward-compat wrapper. - * - * @param {Element} parentNode - The parent node containing the after element. - * @param {Node} element - The element to be moved. - * @param {Node | null} after - The reference node to insert `element` before. - * If `null`, `element` is appended as the last child. - */ - function moveBefore(parentNode, element, after) { - // @ts-ignore - use proposed moveBefore feature - if (parentNode.moveBefore) { - try { - // @ts-ignore - use proposed moveBefore feature - parentNode.moveBefore(element, after); - } catch (e) { - // moveBefore throws unless both nodes share a root, e.g. when morphing a detached subtree - parentNode.insertBefore(element, after); - } - } else { - parentNode.insertBefore(element, after); - } - } - - return morphChildren; - })(); - - //============================================================================= - // Single Node Morphing Code - //============================================================================= - const morphNode = (function () { - /** - * @param {Node} oldNode root node to merge content into - * @param {Node} newContent new content to merge - * @param {MorphContext} ctx the merge context - * @returns {Node | null} the element that ended up in the DOM - */ - function morphNode(oldNode, newContent, ctx) { - if (ctx.ignoreActive && oldNode === ctx.doc.activeElement) { - // don't morph focused element - return null; - } - - if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) { - return oldNode; - } - - if (is.headElement(oldNode) && ctx.head.style === "none") { - // ignore the head element - } else if (is.headElement(oldNode) && ctx.head.style !== "morph") { - // ok to cast: if newContent wasn't also a , it would've got caught in the `!isSoftMatch` branch above - handleHeadElement( - oldNode, - /** @type {HTMLHeadElement} */ (newContent), - ctx, - ); - } else { - morphAttributes(oldNode, newContent, ctx); - if (!ignoreValueOfActiveElement(oldNode, ctx)) { - // @ts-ignore newContent can be a node here because .firstChild will be null - morphChildren(ctx, oldNode, newContent); - } - } - ctx.callbacks.afterNodeMorphed(oldNode, newContent); - return oldNode; - } - - /** - * syncs the oldNode to the newNode, copying over all attributes and - * inner element state from the newNode to the oldNode - * - * @param {Node} oldNode the node to copy attributes & state to - * @param {Node} newNode the node to copy attributes & state from - * @param {MorphContext} ctx the merge context - */ - function morphAttributes(oldNode, newNode, ctx) { - let type = newNode.nodeType; - - // if is an element type, sync the attributes from the - // new node into the new node - if (type === 1 /* element type */) { - const oldElt = /** @type {Element} */ (oldNode); - const newElt = /** @type {Element} */ (newNode); - - const oldAttributes = oldElt.attributes; - const newAttributes = newElt.attributes; - for (const newAttribute of newAttributes) { - if (oldElt.getAttribute(newAttribute.name) === newAttribute.value) { - continue; - } - if (ignoreAttribute(newAttribute.name, oldElt, "update", ctx)) { - continue; - } - // setAttribute drops namespaces and rejects names like `@click` - oldElt.setAttributeNode( - /** @type {Attr} */ (newAttribute.cloneNode()), - ); - } - // iterate backwards to avoid skipping over items when a delete occurs - for (let i = oldAttributes.length - 1; 0 <= i; i--) { - const oldAttribute = oldAttributes[i]; - - // toAttributes is a live NamedNodeMap, so iteration+mutation is unsafe - // e.g. custom element attribute callbacks can remove other attributes - if (!oldAttribute) continue; - - if (!newElt.hasAttribute(oldAttribute.name)) { - if (ignoreAttribute(oldAttribute.name, oldElt, "remove", ctx)) { - continue; - } - oldElt.removeAttribute(oldAttribute.name); - } - } - - if (!ignoreValueOfActiveElement(oldElt, ctx)) { - syncInputValue(oldElt, newElt, ctx); - } - } - - // sync text nodes - if (type === 8 /* comment */ || type === 3 /* text */) { - if (oldNode.nodeValue !== newNode.nodeValue) { - oldNode.nodeValue = newNode.nodeValue; - } - } - } - - /** - * NB: many bothans died to bring us information: - * - * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js - * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113 - * - * @param {Element} oldElement the element to sync the input value to - * @param {Element} newElement the element to sync the input value from - * @param {MorphContext} ctx the merge context - */ - function syncInputValue(oldElement, newElement, ctx) { - if ( - is.inputElement(oldElement) && - is.inputElement(newElement) && - newElement.type !== "file" - ) { - let newValue = newElement.value; - let oldValue = oldElement.value; - - // sync boolean attributes - syncBooleanAttribute(oldElement, newElement, "checked", ctx); - syncBooleanAttribute(oldElement, newElement, "disabled", ctx); - - if (!newElement.hasAttribute("value")) { - if (!ignoreAttribute("value", oldElement, "remove", ctx)) { - oldElement.value = ""; - oldElement.removeAttribute("value"); - } - } else if (oldValue !== newValue) { - if (!ignoreAttribute("value", oldElement, "update", ctx)) { - oldElement.setAttribute("value", newValue); - oldElement.value = newValue; - } - } - // TODO: QUESTION(1cg): this used to only check `newElement` unlike the other branches -- why? - // did I break something? - } else if (is.optionElement(oldElement) && is.optionElement(newElement)) { - syncBooleanAttribute(oldElement, newElement, "selected", ctx); - } else if ( - is.textAreaElement(oldElement) && - is.textAreaElement(newElement) - ) { - let newValue = newElement.value; - let oldValue = oldElement.value; - if (ignoreAttribute("value", oldElement, "update", ctx)) { - return; - } - if (newValue !== oldValue) { - oldElement.value = newValue; - } - if ( - oldElement.firstChild && - oldElement.firstChild.nodeValue !== newValue - ) { - oldElement.firstChild.nodeValue = newValue; - } - } - } - - /** - * @param {Element} oldElement element to write the value to - * @param {Element} newElement element to read the value from - * @param {string} attributeName the attribute name - * @param {MorphContext} ctx the merge context - */ - function syncBooleanAttribute(oldElement, newElement, attributeName, ctx) { - // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties - const newLiveValue = newElement[attributeName], - // @ts-ignore ditto - oldLiveValue = oldElement[attributeName]; - if (newLiveValue !== oldLiveValue) { - const ignoreUpdate = ignoreAttribute( - attributeName, - oldElement, - "update", - ctx, - ); - if (!ignoreUpdate) { - // update attribute's associated DOM property - // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties - oldElement[attributeName] = newElement[attributeName]; - } - if (newLiveValue) { - if (!ignoreUpdate) { - // https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML - // this is the correct way to set a boolean attribute to "true" - oldElement.setAttribute(attributeName, ""); - } - } else { - if (!ignoreAttribute(attributeName, oldElement, "remove", ctx)) { - oldElement.removeAttribute(attributeName); - } - } - } - } - - /** - * @param {string} attr the attribute to be mutated - * @param {Element} element the element that is going to be updated - * @param {"update" | "remove"} updateType - * @param {MorphContext} ctx the merge context - * @returns {boolean} true if the attribute should be ignored, false otherwise - */ - function ignoreAttribute(attr, element, updateType, ctx) { - if ( - attr === "value" && - ctx.ignoreActiveValue && - element === ctx.doc.activeElement - ) { - return true; - } - return ( - ctx.callbacks.beforeAttributeUpdated(attr, element, updateType) === - false - ); - } - - /** - * @param {Node} possibleActiveElement - * @param {MorphContext} ctx - * @returns {boolean} - */ - function ignoreValueOfActiveElement(possibleActiveElement, ctx) { - return ( - !!ctx.ignoreActiveValue && - possibleActiveElement === ctx.doc.activeElement && - possibleActiveElement !== ctx.doc.body - ); - } - - return morphNode; - })(); - - //============================================================================= - // Head Management Functions - //============================================================================= - /** - * @param {MorphContext} ctx - * @param {Element} oldNode - * @param {Element} newNode - * @param {function} callback - * @returns {Node[] | Promise} - */ - function withHeadBlocking(ctx, oldNode, newNode, callback) { - if (ctx.head.block && ctx.head.style !== "none") { - const oldHead = oldNode.querySelector("head"); - const newHead = newNode.querySelector("head"); - if (oldHead && newHead) { - const promises = handleHeadElement(oldHead, newHead, ctx); - // when head promises resolve, proceed ignoring the head tag - return Promise.all(promises).then(() => { - const newCtx = Object.assign(ctx, { - head: { - block: false, - style: "none", - }, - }); - return callback(newCtx); - }); - } - } - // just proceed if we not head blocking - return callback(ctx); - } - - /** - * The HEAD tag can be handled specially, either w/ a 'merge' or 'append' style - * - * @param {Element} oldHead - * @param {Element} newHead - * @param {MorphContext} ctx - * @returns {Promise[]} - */ - function handleHeadElement(oldHead, newHead, ctx) { - let added = []; - let removed = []; - let preserved = []; - let nodesToAppend = []; - - // put all new head elements into a Map, by their outerHTML - let srcToNewHeadNodes = new Map(); - for (const newHeadChild of newHead.children) { - srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); - } - - // for each elt in the current head - for (const currentHeadElt of oldHead.children) { - // If the current head element is in the map - let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML); - let isReAppended = ctx.head.shouldReAppend(currentHeadElt); - let isPreserved = ctx.head.shouldPreserve(currentHeadElt); - if (inNewContent || isPreserved) { - if (isReAppended) { - // remove the current version and let the new version replace it and re-execute - removed.push(currentHeadElt); - } else { - // this element already exists and should not be re-appended, so remove it from - // the new content map, preserving it in the DOM - srcToNewHeadNodes.delete(currentHeadElt.outerHTML); - preserved.push(currentHeadElt); - } - } else { - if (ctx.head.style === "append") { - // we are appending and this existing element is not new content - // so if and only if it is marked for re-append do we do anything - if (isReAppended) { - removed.push(currentHeadElt); - nodesToAppend.push(currentHeadElt); - } - } else { - // if this is a merge, we remove this content since it is not in the new head - if (ctx.head.shouldRemove(currentHeadElt) !== false) { - removed.push(currentHeadElt); - } - } - } - } - - // Push the remaining new head elements in the Map into the - // nodes to append to the head tag - nodesToAppend.push(...srcToNewHeadNodes.values()); - - let promises = []; - for (const newNode of nodesToAppend) { - // TODO: This could theoretically be null, based on type - let newElt = /** @type {ChildNode} */ ( - ctx.doc.createRange().createContextualFragment(newNode.outerHTML) - .firstChild - ); - if (ctx.callbacks.beforeNodeAdded(newElt) !== false) { - if ( - ("href" in newElt && newElt.href) || - ("src" in newElt && newElt.src) - ) { - /** @type {(result?: any) => void} */ let resolve; - let promise = new Promise(function (_resolve) { - resolve = _resolve; - }); - newElt.addEventListener("load", function () { - resolve(); - }); - promises.push(promise); - } - oldHead.appendChild(newElt); - ctx.callbacks.afterNodeAdded(newElt); - added.push(newElt); - } - } - - // remove all removed elements, after we have appended the new elements to avoid - // additional network requests for things like style sheets - for (const removedElement of removed) { - if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) { - oldHead.removeChild(removedElement); - ctx.callbacks.afterNodeRemoved(removedElement); - } - } - - ctx.head.afterHeadMorphed(oldHead, { - added: added, - kept: preserved, - removed: removed, - }); - return promises; - } - - //============================================================================= - // Create Morph Context Functions - //============================================================================= - const createMorphContext = (function () { - /** - * - * @param {Element} oldNode - * @param {Element} newContent - * @param {Config} config - * @returns {MorphContext} - */ - function createMorphContext(oldNode, newContent, config) { - const { persistentIds, idMap } = createIdMaps(oldNode, newContent); - - const mergedConfig = mergeDefaults(config); - const morphStyle = mergedConfig.morphStyle || "outerHTML"; - if (!["innerHTML", "outerHTML"].includes(morphStyle)) { - throw `Do not understand how to morph style ${morphStyle}`; - } - // Text and Comment have no ParentNode methods, so they cannot take innerHTML - if (morphStyle === "innerHTML" && !oldNode.append) { - throw `Cannot morph the innerHTML of a ${oldNode.nodeName} node, as it cannot have children`; - } - - const headStyle = mergedConfig.head.style || "merge"; - if (!["merge", "append", "morph", "none"].includes(headStyle)) { - throw `Do not understand how to morph head style ${headStyle}`; - } - - const doc = oldNode.ownerDocument; - - return { - target: oldNode, - newContent: newContent, - doc: doc, - config: mergedConfig, - morphStyle: morphStyle, - ignoreActive: mergedConfig.ignoreActive, - ignoreActiveValue: mergedConfig.ignoreActiveValue, - restoreFocus: mergedConfig.restoreFocus, - idMap: idMap, - persistentIds: persistentIds, - pantry: createPantry(doc), - activeElementAndParents: createActiveElementAndParents(oldNode, doc), - callbacks: mergedConfig.callbacks, - head: mergedConfig.head, - }; - } - - /** - * Deep merges the config object and the Idiomorph.defaults object to - * produce a final configuration object - * @param {Config} config - * @returns {ConfigInternal} - */ - function mergeDefaults(config) { - let finalConfig = Object.assign({}, defaults); - - // copy top level stuff into final config - Object.assign(finalConfig, config); - - // copy callbacks into final config (do this to deep merge the callbacks) - finalConfig.callbacks = Object.assign( - {}, - defaults.callbacks, - config.callbacks, - ); - - // copy head config into final config (do this to deep merge the head) - finalConfig.head = Object.assign({}, defaults.head, config.head); - - return finalConfig; - } - - /** - * @param {Document} doc - * @returns {HTMLDivElement} - */ - function createPantry(doc) { - const pantry = doc.createElement("div"); - pantry.hidden = true; - doc.documentElement.append(pantry); - return pantry; - } - - /** - * @param {Element} oldNode - * @param {Document} doc - * @returns {Element[]} - */ - function createActiveElementAndParents(oldNode, doc) { - /** @type {Element[]} */ - let activeElementAndParents = []; - let elt = doc.activeElement; - if (elt?.tagName !== "BODY" && oldNode.contains(elt)) { - while (elt) { - activeElementAndParents.push(elt); - if (elt === oldNode) break; - elt = elt.parentElement; - } - } - return activeElementAndParents; - } - - /** - * Returns all elements with a non-empty ID contained within the root node and its - * descendants, each paired with its id so that it only has to be read once. - * - * @param {Node} root - * @returns {IdElement[]} - */ - function findIdElements(root) { - /** @type {IdElement[]} */ - let elements = []; - // root could be a text or comment node which has no `querySelectorAll`, - // or a document fragment which has no `getAttribute` - const rootElt = /** @type {Partial} */ (root); - for (const elt of rootElt.querySelectorAll?.("[id]") ?? []) { - // elt.id is unsafe because of form input shadowing, and `id=""` is not persistable - const id = elt.getAttribute("id"); - if (id) elements.push({ elt, id }); - } - const rootId = rootElt.getAttribute?.("id"); - if (rootId) - elements.push({ elt: /** @type {Element} */ (root), id: rootId }); - return elements; - } - - /** - * A bottom-up algorithm that populates a map of Element -> IdSet. - * The idSet for a given element is the set of all IDs contained within its subtree. - * As an optimzation, we filter these IDs through the given list of persistent IDs, - * because we don't need to bother considering IDed elements that won't be in the new content. - * - * @param {Map>} idMap - * @param {Set} persistentIds - * @param {Element} root - * @param {IdElement[]} elements - */ - function populateIdMapWithTree(idMap, persistentIds, root, elements) { - for (const { elt, id } of elements) { - if (persistentIds.has(id)) { - /** @type {Element|null} */ - let current = elt; - // walk up the parent hierarchy of that element, adding the id - // of element to the parent's id set - while (current) { - let idSet = idMap.get(current); - // if the id set doesn't exist, create it and insert it in the map - if (idSet == null) { - idSet = new Set(); - idMap.set(current, idSet); - } - idSet.add(id); - - if (current === root) break; - current = current.parentElement; - } - } - } - } - - /** - * This function computes a map of nodes to all ids contained within that node (inclusive of the - * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows - * for a looser definition of "matching" than tradition id matching, and allows child nodes - * to contribute to a parent nodes matching. - * - * @param {Element} oldContent the old content that will be morphed - * @param {Element} newContent the new content to morph to - * @returns {IdSets} - */ - function createIdMaps(oldContent, newContent) { - const oldIdElements = findIdElements(oldContent); - const newIdElements = findIdElements(newContent); - - const persistentIds = createPersistentIds(oldIdElements, newIdElements); - - /** @type {Map>} */ - let idMap = new Map(); - populateIdMapWithTree(idMap, persistentIds, oldContent, oldIdElements); - - /** @ts-ignore - if newContent is a duck-typed parent, pass its single child node as the root to halt upwards iteration */ - const newRoot = newContent.__idiomorphRoot || newContent; - populateIdMapWithTree(idMap, persistentIds, newRoot, newIdElements); - - return { persistentIds, idMap }; - } - - /** - * This function computes the set of ids that persist between the two contents excluding duplicates - * - * @param {IdElement[]} oldIdElements - * @param {IdElement[]} newIdElements - * @returns {Set} - */ - function createPersistentIds(oldIdElements, newIdElements) { - let duplicateIds = new Set(); - - /** @type {Map} */ - let oldIdTagNameMap = new Map(); - for (const { elt, id } of oldIdElements) { - if (oldIdTagNameMap.has(id)) { - duplicateIds.add(id); - } else { - oldIdTagNameMap.set(id, elt.tagName); - } - } - - let persistentIds = new Set(); - for (const { elt, id } of newIdElements) { - if (persistentIds.has(id)) { - duplicateIds.add(id); - } else if (oldIdTagNameMap.get(id) === elt.tagName) { - persistentIds.add(id); - } - // skip if tag types mismatch because its not possible to morph one tag into another - } - - for (const id of duplicateIds) { - persistentIds.delete(id); - } - if (duplicateIds.size) { - console.warn( - "[Warning] duplicate ids found during morph, state loss within these elements is possible:", - Array.from(duplicateIds), - ); - } - return persistentIds; - } - - return createMorphContext; - })(); - - //============================================================================= - // HTML Normalization Functions - //============================================================================= - const { normalizeElement, normalizeParent } = (function () { - /** @type {WeakSet} */ - const generatedByIdiomorph = new WeakSet(); - - /** - * - * @param {Node} content - * @returns {Element} - */ - function normalizeElement(content) { - if (is.document(content)) { - return content.documentElement; - } else { - // a Text or Comment node is not an Element, but morphOuterHTML only ever reads Node members off it - return /** @type {Element} */ (content); - } - } - - /** - * - * @param {null | string | Node | HTMLCollection | Node[]} newContent - * @returns {Element} - */ - function normalizeParent(newContent) { - if (newContent == null) { - return document.createElement("div"); // dummy parent element - } else if (typeof newContent === "string") { - return normalizeParent(parseContent(newContent)); - } else if ( - generatedByIdiomorph.has(/** @type {Element} */ (newContent)) - ) { - // the template tag created by idiomorph parsing can serve as a dummy parent - return /** @type {Element} */ (newContent); - } else if (is.node(newContent)) { - if (newContent.parentNode) { - // we can't use the parent directly because newContent may have siblings - // that we don't want in the morph, and reparenting might be expensive (TODO is it?), - // so instead we create a fake parent node that only sees a slice of its children. - /** @type {Element} */ - return /** @type {any} */ (new SlicedParentNode(newContent)); - } else { - // a single node is added as a child to a dummy parent - const dummyParent = document.createElement("div"); - dummyParent.append(newContent); - return dummyParent; - } - } else { - // all nodes in the array or HTMLElement collection are consolidated under - // a single dummy parent element - const dummyParent = document.createElement("div"); - for (const elt of [...newContent]) { - dummyParent.append(elt); - } - return dummyParent; - } - } - - /** - * A fake duck-typed parent element to wrap a single node, without actually reparenting it. - * This is useful because the node may have siblings that we don't want in the morph, and it may also be moved - * or replaced with one or more elements during the morph. This class effectively allows us a window into - * a slice of a node's children. - * "If it walks like a duck, and quacks like a duck, then it must be a duck!" -- James Whitcomb Riley (1849–1916) - */ - class SlicedParentNode { - /** @param {Node} node */ - constructor(node) { - this.originalNode = node; - this.realParentNode = /** @type {Element} */ (node.parentNode); - this.previousSibling = node.previousSibling; - this.nextSibling = node.nextSibling; - } - - /** @returns {Node[]} */ - get childNodes() { - // return slice of realParent's current childNodes, based on previousSibling and nextSibling - const nodes = []; - let cursor = this.previousSibling - ? this.previousSibling.nextSibling - : this.realParentNode.firstChild; - while (cursor && cursor != this.nextSibling) { - nodes.push(cursor); - cursor = cursor.nextSibling; - } - return nodes; - } - - /** - * @param {string} selector - * @returns {Element[]} - */ - querySelectorAll(selector) { - return this.childNodes.reduce((results, node) => { - if (is.element(node)) { - if (node.matches(selector)) results.push(node); - const nodeList = node.querySelectorAll(selector); - for (let i = 0; i < nodeList.length; i++) { - results.push(nodeList[i]); - } - } - return results; - }, /** @type {Element[]} */ ([])); - } - - /** - * @param {Node} node - * @param {Node} referenceNode - * @returns {Node} - */ - insertBefore(node, referenceNode) { - return this.realParentNode.insertBefore(node, referenceNode); - } - - /** - * @param {Node} node - * @param {Node} referenceNode - * @returns {Node} - */ - moveBefore(node, referenceNode) { - // @ts-ignore - use new moveBefore feature - return this.realParentNode.moveBefore(node, referenceNode); - } - - /** - * for later use with populateIdMapWithTree to halt upwards iteration - * @returns {Node} - */ - get __idiomorphRoot() { - return this.originalNode; - } - } - - /** - * - * @param {string} newContent - * @returns {Node | null | DocumentFragment} - */ - function parseContent(newContent) { - let parser = new DOMParser(); - - // remove svgs to avoid false-positive matches on head, etc. - let contentWithSvgsRemoved = newContent.replace( - /]*>|>)([\s\S]*?)<\/svg>/gim, - "", - ); - - // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping - if ( - contentWithSvgsRemoved.match(/<\/html>/) || - contentWithSvgsRemoved.match(/<\/head>/) || - contentWithSvgsRemoved.match(/<\/body>/) - ) { - let content = parser.parseFromString(newContent, "text/html"); - // a doctype can neither be morphed nor inserted, and would displace the parent container below - content.doctype?.remove(); - // if it is a full HTML document, return the document itself as the parent container - if (contentWithSvgsRemoved.match(/<\/html>/)) { - generatedByIdiomorph.add(content); - return content; - } else { - // otherwise return the html element as the parent container - let htmlElement = content.firstChild; - if (htmlElement) { - generatedByIdiomorph.add(htmlElement); - } - return htmlElement; - } - } else { - // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help - // deal with touchy tags like tr, tbody, etc. - let responseDoc = parser.parseFromString( - "", - "text/html", - ); - let content = /** @type {HTMLTemplateElement} */ ( - responseDoc.body.querySelector("template") - ).content; - generatedByIdiomorph.add(content); - return content; - } - } - - return { normalizeElement, normalizeParent }; - })(); - - //============================================================================= - // Realm-safe node type checks - //============================================================================= - const is = (function () { - /** @param {Node | null | undefined} value @returns {value is Element} */ - const element = (value) => - value instanceof Element || value?.nodeType === Node.ELEMENT_NODE; - - /** - * @param {Node | null | undefined} value - * @param {string} localName - * @returns {value is Element} - */ - const htmlElement = (value, localName) => - element(value) && - value.localName === localName && - value.namespaceURI === "http://www.w3.org/1999/xhtml"; - - return { - element, - /** @param {unknown} value @returns {value is Node} */ - node: (value) => - value instanceof Node || - typeof (/** @type {any} */ (value)?.nodeType) === "number", - /** @param {Node | null | undefined} value @returns {value is Document} */ - document: (value) => - value instanceof Document || value?.nodeType === Node.DOCUMENT_NODE, - /** @param {Node | null | undefined} value @returns {value is HTMLTemplateElement} */ - templateElement: (value) => htmlElement(value, "template"), - /** @param {Node | null | undefined} value @returns {value is HTMLHeadElement} */ - headElement: (value) => htmlElement(value, "head"), - /** @param {Node | null | undefined} value @returns {value is HTMLInputElement} */ - inputElement: (value) => htmlElement(value, "input"), - /** @param {Node | null | undefined} value @returns {value is HTMLOptionElement} */ - optionElement: (value) => htmlElement(value, "option"), - /** @param {Node | null | undefined} value @returns {value is HTMLTextAreaElement} */ - textAreaElement: (value) => htmlElement(value, "textarea"), - }; - })(); - - //============================================================================= - // This is what ends up becoming the Idiomorph global object - //============================================================================= - return { - morph, - defaults, - }; -})(); -(function () { - function createMorphConfig(swapStyle) { - if (swapStyle === "morph" || swapStyle === "morph:outerHTML") { - return { morphStyle: "outerHTML" }; - } else if (swapStyle === "morph:innerHTML") { - return { morphStyle: "innerHTML" }; - } else if (swapStyle.startsWith("morph:")) { - return Function("return (" + swapStyle.slice(6) + ")")(); - } - } - - htmx.defineExtension("morph", { - isInlineSwap: function (swapStyle) { - let config = createMorphConfig(swapStyle); - return config?.morphStyle === "outerHTML" || config?.morphStyle == null; - }, - handleSwap: function (swapStyle, target, fragment) { - let config = createMorphConfig(swapStyle); - if (config) { - return Idiomorph.morph(target, fragment.children, config); - } - }, - }); -})(); - -export {Idiomorph}; diff --git a/dist/idiomorph-ext.js b/dist/idiomorph-ext.js deleted file mode 100644 index 8b3550e..0000000 --- a/dist/idiomorph-ext.js +++ /dev/null @@ -1,1486 +0,0 @@ -/** - * @typedef {object} ConfigHead - * - * @property {'merge' | 'append' | 'morph' | 'none'} [style] - * @property {boolean} [block] - * @property {function(Element): boolean} [shouldPreserve] - * @property {function(Element): boolean} [shouldReAppend] - * @property {function(Element): boolean} [shouldRemove] - * @property {function(Element, {added: Node[], kept: Element[], removed: Element[]}): void} [afterHeadMorphed] - */ - -/** - * @typedef {object} ConfigCallbacks - * - * @property {function(Node): boolean} [beforeNodeAdded] - * @property {function(Node): void} [afterNodeAdded] - * @property {function(Element, Node): boolean} [beforeNodeMorphed] - * @property {function(Element, Node): void} [afterNodeMorphed] - * @property {function(Element): boolean} [beforeNodeRemoved] - * @property {function(Element): void} [afterNodeRemoved] - * @property {function(string, Element, "update" | "remove"): boolean} [beforeAttributeUpdated] - */ - -/** - * @typedef {object} Config - * - * @property {'outerHTML' | 'innerHTML'} [morphStyle] - * @property {boolean} [ignoreActive] - * @property {boolean} [ignoreActiveValue] - * @property {boolean} [restoreFocus] - * @property {ConfigCallbacks} [callbacks] - * @property {ConfigHead} [head] - */ - -/** - * @callback NoOp - * - * @returns {void} - */ - -/** - * @typedef {object} ConfigHeadInternal - * - * @property {'merge' | 'append' | 'morph' | 'none'} style - * @property {boolean} [block] - * @property {(function(Element): boolean) | NoOp} shouldPreserve - * @property {(function(Element): boolean) | NoOp} shouldReAppend - * @property {(function(Element): boolean) | NoOp} shouldRemove - * @property {(function(Element, {added: Node[], kept: Element[], removed: Element[]}): void) | NoOp} afterHeadMorphed - */ - -/** - * @typedef {object} ConfigCallbacksInternal - * - * @property {(function(Node): boolean) | NoOp} beforeNodeAdded - * @property {(function(Node): void) | NoOp} afterNodeAdded - * @property {(function(Node, Node): boolean) | NoOp} beforeNodeMorphed - * @property {(function(Node, Node): void) | NoOp} afterNodeMorphed - * @property {(function(Node): boolean) | NoOp} beforeNodeRemoved - * @property {(function(Node): void) | NoOp} afterNodeRemoved - * @property {(function(string, Element, "update" | "remove"): boolean) | NoOp} beforeAttributeUpdated - */ - -/** - * @typedef {object} ConfigInternal - * - * @property {'outerHTML' | 'innerHTML'} morphStyle - * @property {boolean} [ignoreActive] - * @property {boolean} [ignoreActiveValue] - * @property {boolean} [restoreFocus] - * @property {ConfigCallbacksInternal} callbacks - * @property {ConfigHeadInternal} head - */ - -/** - * @typedef {Object} IdElement - * @property {Element} elt - * @property {string} id - */ - -/** - * @typedef {Object} IdSets - * @property {Set} persistentIds - * @property {Map>} idMap - */ - -/** - * @callback Morph - * - * @param {Node} oldNode - * @param {Node | HTMLCollection | Node[] | string | null} newContent - * @param {Config} [config] - * @returns {Promise | Node[]} - */ - -// base IIFE to define idiomorph -/** - * - * @type {{defaults: ConfigInternal, morph: Morph}} - */ -var Idiomorph = (function () { - "use strict"; - - /** - * @typedef {object} MorphContext - * - * @property {Element} target - * @property {Element} newContent - * @property {Document} doc - * @property {ConfigInternal} config - * @property {ConfigInternal['morphStyle']} morphStyle - * @property {ConfigInternal['ignoreActive']} ignoreActive - * @property {ConfigInternal['ignoreActiveValue']} ignoreActiveValue - * @property {ConfigInternal['restoreFocus']} restoreFocus - * @property {Map>} idMap - * @property {Set} persistentIds - * @property {ConfigInternal['callbacks']} callbacks - * @property {ConfigInternal['head']} head - * @property {HTMLDivElement} pantry - * @property {Element[]} activeElementAndParents - */ - - //============================================================================= - // AND NOW IT BEGINS... - //============================================================================= - - const noOp = () => {}; - /** - * Default configuration values, updatable by users now - * @type {ConfigInternal} - */ - const defaults = { - morphStyle: "outerHTML", - callbacks: { - beforeNodeAdded: noOp, - afterNodeAdded: noOp, - beforeNodeMorphed: noOp, - afterNodeMorphed: noOp, - beforeNodeRemoved: noOp, - afterNodeRemoved: noOp, - beforeAttributeUpdated: noOp, - }, - head: { - style: "merge", - shouldPreserve: (elt) => elt.getAttribute("im-preserve") === "true", - shouldReAppend: (elt) => elt.getAttribute("im-re-append") === "true", - shouldRemove: noOp, - afterHeadMorphed: noOp, - }, - restoreFocus: true, - }; - - /** - * Core idiomorph function for morphing one DOM tree to another - * - * @param {Node} oldNode - * @param {Node | HTMLCollection | Node[] | string | null} newContent - * @param {Config} [config] - * @returns {Promise | Node[]} - */ - function morph(oldNode, newContent, config = {}) { - const oldElt = normalizeElement(oldNode); - const newNode = normalizeParent(newContent); - const ctx = createMorphContext(oldElt, newNode, config); - - return withHeadBlocking( - ctx, - oldElt, - newNode, - /** @param {MorphContext} ctx */ (ctx) => { - const morphedNodes = saveAndRestoreFocus(ctx, () => { - if (ctx.morphStyle === "innerHTML") { - morphChildren(ctx, oldElt, newNode); - return Array.from(oldElt.childNodes); - } else { - return morphOuterHTML(ctx, oldElt, newNode); - } - }); - ctx.pantry.remove(); - return morphedNodes; - }, - ); - } - - /** - * Morph just the outerHTML of the oldNode to the newContent - * We have to be careful because the oldNode could have siblings which need to be untouched - * @param {MorphContext} ctx - * @param {Element} oldNode - * @param {Element} newNode - * @returns {Node[]} - */ - function morphOuterHTML(ctx, oldNode, newNode) { - const oldParent = normalizeParent(oldNode); - morphChildren( - ctx, - oldParent, - newNode, - // these two optional params are the secret sauce - oldNode, // start point for iteration - oldNode.nextSibling, // end point for iteration - ); - // this is safe even with siblings, because normalizeParent returns a SlicedParentNode if needed. - return Array.from(oldParent.childNodes); - } - - /** - * @param {MorphContext} ctx - * @param {Function} fn - * @returns {Node[]} - */ - function saveAndRestoreFocus(ctx, fn) { - if (!ctx.config.restoreFocus) return fn(); - let activeElement = - /** @type {HTMLInputElement|HTMLTextAreaElement|null} */ ( - ctx.doc.activeElement - ); - - // don't bother if the active element is not an input or textarea - if ( - !(is.inputElement(activeElement) || is.textAreaElement(activeElement)) - ) { - return fn(); - } - - const { id: activeElementId, selectionStart, selectionEnd } = activeElement; - - const results = fn(); - - if ( - activeElementId && - activeElementId !== ctx.doc.activeElement?.getAttribute("id") - ) { - activeElement = ctx.target.querySelector( - `[id="${CSS.escape(activeElementId)}"]`, - ); - activeElement?.focus(); - } - if (activeElement && !activeElement.selectionEnd && selectionEnd) { - try { - activeElement.setSelectionRange(selectionStart, selectionEnd); - } catch { - // the element may not support setSelectionRange: it's no longer an - // input/textarea after the morph, or it's an input type (number, - // email, date, ...) that doesn't support text selection - } - } - - return results; - } - - const morphChildren = (function () { - /** - * This is the core algorithm for matching up children. The idea is to use id sets to try to match up - * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but - * by using id sets, we are able to better match up with content deeper in the DOM. - * - * Basic algorithm: - * - for each node in the new content: - * - search self and siblings for an id set match, falling back to a soft match - * - if match found - * - remove any nodes up to the match: - * - pantry persistent nodes - * - delete the rest - * - morph the match - * - elsif no match found, and node is persistent - * - find its match by querying the old root (future) and pantry (past) - * - move it and its children here - * - morph it - * - else - * - create a new node from scratch as a last result - * - * @param {MorphContext} ctx the merge context - * @param {Element} oldParent the old content that we are merging the new content into - * @param {Element} newParent the parent element of the new content - * @param {Node|null} [insertionPoint] the point in the DOM we start morphing at (defaults to first child) - * @param {Node|null} [endPoint] the point in the DOM we stop morphing at (defaults to after last child) - */ - function morphChildren( - ctx, - oldParent, - newParent, - insertionPoint = null, - endPoint = null, - ) { - // normalize - if (is.templateElement(oldParent) && is.templateElement(newParent)) { - // @ts-ignore we can pretend the DocumentFragment is an Element - oldParent = oldParent.content; - // @ts-ignore ditto - newParent = newParent.content; - } - insertionPoint ||= oldParent.firstChild; - - // run through all the new content - for (const newChild of newParent.childNodes) { - // once we reach the end of the old parent content skip to the end and insert the rest - if (insertionPoint && insertionPoint != endPoint) { - const bestMatch = findBestMatch( - ctx, - newChild, - insertionPoint, - endPoint, - ); - if (bestMatch) { - // if the node to morph is not at the insertion point then remove/move up to it - if (bestMatch !== insertionPoint) { - removeNodesBetween(ctx, insertionPoint, bestMatch); - } - morphNode(bestMatch, newChild, ctx); - insertionPoint = bestMatch.nextSibling; - continue; - } - } - - // if the matching node is elsewhere in the original content - if (is.element(newChild)) { - // we can pretend the id is non-null because the next `.has` line will reject it if not - const newChildId = /** @type {String} */ ( - newChild.getAttribute("id") - ); - if (ctx.persistentIds.has(newChildId)) { - // move it and all its children here and morph - const movedChild = moveBeforeById( - oldParent, - newChildId, - insertionPoint, - ctx, - ); - morphNode(movedChild, newChild, ctx); - insertionPoint = movedChild.nextSibling; - continue; - } - } - - // last resort: insert the new node from scratch - const insertedNode = createNode( - oldParent, - newChild, - insertionPoint, - ctx, - ); - // could be null if beforeNodeAdded prevented insertion - if (insertedNode) { - insertionPoint = insertedNode.nextSibling; - } - } - - // remove any remaining old nodes that didn't match up with new content - while (insertionPoint && insertionPoint != endPoint) { - const tempNode = insertionPoint; - insertionPoint = insertionPoint.nextSibling; - removeNode(ctx, tempNode); - } - } - - /** - * This performs the action of inserting a new node while handling situations where the node contains - * elements with persistent ids and possible state info we can still preserve by moving in and then morphing - * - * @param {Element} oldParent - * @param {Node} newChild - * @param {Node|null} insertionPoint - * @param {MorphContext} ctx - * @returns {Node|null} - */ - function createNode(oldParent, newChild, insertionPoint, ctx) { - if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null; - if (ctx.idMap.has(newChild)) { - // node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm - // createElementNS doesn't case-normalize, so localName rather than tagName - const newEmptyChild = ctx.doc.createElementNS( - /** @type {Element} */ (newChild).namespaceURI, - /** @type {Element} */ (newChild).localName, - ); - oldParent.insertBefore(newEmptyChild, insertionPoint); - morphNode(newEmptyChild, newChild, ctx); - ctx.callbacks.afterNodeAdded(newEmptyChild); - return newEmptyChild; - } else { - // optimisation: no id state to preserve so we can just insert a clone of the newChild and its descendants - const newClonedChild = ctx.doc.importNode(newChild, true); // importNode to not mutate newParent - oldParent.insertBefore(newClonedChild, insertionPoint); - ctx.callbacks.afterNodeAdded(newClonedChild); - return newClonedChild; - } - } - - //============================================================================= - // Matching Functions - //============================================================================= - const findBestMatch = (function () { - /** - * Scans forward from the startPoint to the endPoint looking for a match - * for the node. It looks for an id set match first, then a soft match. - * We abort softmatching if we find two future soft matches, to reduce churn. - * @param {Node} node - * @param {MorphContext} ctx - * @param {Node | null} startPoint - * @param {Node | null} endPoint - * @returns {Node | null} - */ - function findBestMatch(ctx, node, startPoint, endPoint) { - let softMatch = null; - let nextSibling = node.nextSibling; - let siblingSoftMatchCount = 0; - - let cursor = startPoint; - while (cursor && cursor != endPoint) { - // soft matching is a prerequisite for id set matching - if (isSoftMatch(cursor, node)) { - if (isIdSetMatch(ctx, cursor, node)) { - return cursor; // found an id set match, we're done! - } - - // we haven't yet saved a soft match fallback - if (softMatch === null) { - // the current soft match will hard match something else in the future, leave it - if (!ctx.idMap.has(cursor)) { - // save this as the fallback if we get through the loop without finding a hard match - softMatch = cursor; - } - } - } - if ( - softMatch === null && - nextSibling && - isSoftMatch(cursor, nextSibling) - ) { - // The next new node has a soft match with this node, so - // increment the count of future soft matches - siblingSoftMatchCount++; - nextSibling = nextSibling.nextSibling; - - // If there are two future soft matches, block soft matching for this node to allow - // future siblings to soft match. This is to reduce churn in the DOM when an element - // is prepended. - if (siblingSoftMatchCount >= 2) { - softMatch = undefined; - } - } - - // if the current node contains active element, stop looking for better future matches, - // because if one is found, this node will be moved to the pantry, reparenting it and thus losing focus - // @ts-ignore pretend cursor is Element rather than Node, we're just testing for array inclusion - if (ctx.activeElementAndParents.includes(cursor)) break; - - cursor = cursor.nextSibling; - } - - return softMatch || null; - } - - /** - * - * @param {MorphContext} ctx - * @param {Node} oldNode - * @param {Node} newNode - * @returns {boolean} - */ - function isIdSetMatch(ctx, oldNode, newNode) { - let oldSet = ctx.idMap.get(oldNode); - let newSet = ctx.idMap.get(newNode); - - if (!newSet || !oldSet) return false; - - for (const id of oldSet) { - // a potential match is an id in the new and old nodes that - // has not already been merged into the DOM - // But the newNode content we call this on has not been - // merged yet and we don't allow duplicate IDs so it is simple - if (newSet.has(id)) { - return true; - } - } - return false; - } - - /** - * - * @param {Node} oldNode - * @param {Node} newNode - * @returns {boolean} - */ - function isSoftMatch(oldNode, newNode) { - // ok to cast: if one is not element, `id` and `tagName` will be undefined and we'll just compare that. - const oldElt = /** @type {Element} */ (oldNode); - const newElt = /** @type {Element} */ (newNode); - - return ( - oldElt.nodeType === newElt.nodeType && - oldElt.tagName === newElt.tagName && - // If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing. - // We'll still match an anonymous node with an IDed newElt, though, because if it got this far, - // its not persistent, and new nodes can't have any hidden state. - // We can't use .id because of form input shadowing, and we can't count on .getAttribute's presence because it could be a document-fragment - (!oldElt.getAttribute?.("id") || - oldElt.getAttribute?.("id") === newElt.getAttribute?.("id")) - ); - } - - return findBestMatch; - })(); - - //============================================================================= - // DOM Manipulation Functions - //============================================================================= - - /** - * Gets rid of an unwanted DOM node; strategy depends on nature of its reuse: - * - Persistent nodes will be moved to the pantry for later reuse - * - Other nodes will have their hooks called, and then are removed - * @param {MorphContext} ctx - * @param {Node} node - */ - function removeNode(ctx, node) { - // don't accidentally morph the pantry out of existence when morphing the full document - if (node === ctx.pantry) return; - // are we going to id set match this later? - if (ctx.idMap.has(node)) { - // skip callbacks and move to pantry - moveBefore(ctx.pantry, node, null); - } else { - // remove for realsies - if (ctx.callbacks.beforeNodeRemoved(node) === false) return; - node.parentNode?.removeChild(node); - ctx.callbacks.afterNodeRemoved(node); - } - } - - /** - * Remove nodes between the start and end nodes - * @param {MorphContext} ctx - * @param {Node} startInclusive - * @param {Node} endExclusive - * @returns {Node|null} - */ - function removeNodesBetween(ctx, startInclusive, endExclusive) { - /** @type {Node | null} */ - let cursor = startInclusive; - // remove nodes until the endExclusive node - while (cursor && cursor !== endExclusive) { - let tempNode = /** @type {Node} */ (cursor); - cursor = cursor.nextSibling; - removeNode(ctx, tempNode); - } - return cursor; - } - - /** - * Search for an element by id within the document and pantry, and move it using moveBefore. - * - * @param {Element} parentNode - The parent node to which the element will be moved. - * @param {string} id - The ID of the element to be moved. - * @param {Node | null} after - The reference node to insert the element before. - * If `null`, the element is appended as the last child. - * @param {MorphContext} ctx - * @returns {Element} The found element - */ - function moveBeforeById(parentNode, id, after, ctx) { - const selector = `[id="${CSS.escape(id)}"]`; - const target = - /** @type {Element} - will always be found */ - ( - // ctx.target.id unsafe because of form input shadowing - // ctx.target could be a document fragment which doesn't have `getAttribute` - (ctx.target.getAttribute?.("id") === id && ctx.target) || - ctx.target.querySelector(selector) || - ctx.pantry.querySelector(selector) - ); - removeElementFromAncestorsIdMaps(target, ctx); - moveBefore(parentNode, target, after); - return target; - } - - /** - * Removes an element from its ancestors' id maps. This is needed when an element is moved from the - * "future" via `moveBeforeId`. Otherwise, its erstwhile ancestors could be mistakenly moved to the - * pantry rather than being deleted, preventing their removal hooks from being called. - * - * @param {Element} element - element to remove from its ancestors' id maps - * @param {MorphContext} ctx - */ - function removeElementFromAncestorsIdMaps(element, ctx) { - // we know id is non-null String, because this function is only called on elements with ids - const id = /** @type {String} */ (element.getAttribute("id")); - /** @ts-ignore - safe to loop in this way **/ - while ((element = element.parentNode)) { - let idSet = ctx.idMap.get(element); - if (idSet) { - idSet.delete(id); - if (!idSet.size) { - ctx.idMap.delete(element); - } - } - } - } - - /** - * Moves an element before another element within the same parent. - * Uses the `moveBefore` API if available (and working), otherwise falls back to `insertBefore`. - * This is essentialy a forward-compat wrapper. - * - * @param {Element} parentNode - The parent node containing the after element. - * @param {Node} element - The element to be moved. - * @param {Node | null} after - The reference node to insert `element` before. - * If `null`, `element` is appended as the last child. - */ - function moveBefore(parentNode, element, after) { - // @ts-ignore - use proposed moveBefore feature - if (parentNode.moveBefore) { - try { - // @ts-ignore - use proposed moveBefore feature - parentNode.moveBefore(element, after); - } catch (e) { - // moveBefore throws unless both nodes share a root, e.g. when morphing a detached subtree - parentNode.insertBefore(element, after); - } - } else { - parentNode.insertBefore(element, after); - } - } - - return morphChildren; - })(); - - //============================================================================= - // Single Node Morphing Code - //============================================================================= - const morphNode = (function () { - /** - * @param {Node} oldNode root node to merge content into - * @param {Node} newContent new content to merge - * @param {MorphContext} ctx the merge context - * @returns {Node | null} the element that ended up in the DOM - */ - function morphNode(oldNode, newContent, ctx) { - if (ctx.ignoreActive && oldNode === ctx.doc.activeElement) { - // don't morph focused element - return null; - } - - if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) { - return oldNode; - } - - if (is.headElement(oldNode) && ctx.head.style === "none") { - // ignore the head element - } else if (is.headElement(oldNode) && ctx.head.style !== "morph") { - // ok to cast: if newContent wasn't also a , it would've got caught in the `!isSoftMatch` branch above - handleHeadElement( - oldNode, - /** @type {HTMLHeadElement} */ (newContent), - ctx, - ); - } else { - morphAttributes(oldNode, newContent, ctx); - if (!ignoreValueOfActiveElement(oldNode, ctx)) { - // @ts-ignore newContent can be a node here because .firstChild will be null - morphChildren(ctx, oldNode, newContent); - } - } - ctx.callbacks.afterNodeMorphed(oldNode, newContent); - return oldNode; - } - - /** - * syncs the oldNode to the newNode, copying over all attributes and - * inner element state from the newNode to the oldNode - * - * @param {Node} oldNode the node to copy attributes & state to - * @param {Node} newNode the node to copy attributes & state from - * @param {MorphContext} ctx the merge context - */ - function morphAttributes(oldNode, newNode, ctx) { - let type = newNode.nodeType; - - // if is an element type, sync the attributes from the - // new node into the new node - if (type === 1 /* element type */) { - const oldElt = /** @type {Element} */ (oldNode); - const newElt = /** @type {Element} */ (newNode); - - const oldAttributes = oldElt.attributes; - const newAttributes = newElt.attributes; - for (const newAttribute of newAttributes) { - if (oldElt.getAttribute(newAttribute.name) === newAttribute.value) { - continue; - } - if (ignoreAttribute(newAttribute.name, oldElt, "update", ctx)) { - continue; - } - // setAttribute drops namespaces and rejects names like `@click` - oldElt.setAttributeNode( - /** @type {Attr} */ (newAttribute.cloneNode()), - ); - } - // iterate backwards to avoid skipping over items when a delete occurs - for (let i = oldAttributes.length - 1; 0 <= i; i--) { - const oldAttribute = oldAttributes[i]; - - // toAttributes is a live NamedNodeMap, so iteration+mutation is unsafe - // e.g. custom element attribute callbacks can remove other attributes - if (!oldAttribute) continue; - - if (!newElt.hasAttribute(oldAttribute.name)) { - if (ignoreAttribute(oldAttribute.name, oldElt, "remove", ctx)) { - continue; - } - oldElt.removeAttribute(oldAttribute.name); - } - } - - if (!ignoreValueOfActiveElement(oldElt, ctx)) { - syncInputValue(oldElt, newElt, ctx); - } - } - - // sync text nodes - if (type === 8 /* comment */ || type === 3 /* text */) { - if (oldNode.nodeValue !== newNode.nodeValue) { - oldNode.nodeValue = newNode.nodeValue; - } - } - } - - /** - * NB: many bothans died to bring us information: - * - * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js - * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113 - * - * @param {Element} oldElement the element to sync the input value to - * @param {Element} newElement the element to sync the input value from - * @param {MorphContext} ctx the merge context - */ - function syncInputValue(oldElement, newElement, ctx) { - if ( - is.inputElement(oldElement) && - is.inputElement(newElement) && - newElement.type !== "file" - ) { - let newValue = newElement.value; - let oldValue = oldElement.value; - - // sync boolean attributes - syncBooleanAttribute(oldElement, newElement, "checked", ctx); - syncBooleanAttribute(oldElement, newElement, "disabled", ctx); - - if (!newElement.hasAttribute("value")) { - if (!ignoreAttribute("value", oldElement, "remove", ctx)) { - oldElement.value = ""; - oldElement.removeAttribute("value"); - } - } else if (oldValue !== newValue) { - if (!ignoreAttribute("value", oldElement, "update", ctx)) { - oldElement.setAttribute("value", newValue); - oldElement.value = newValue; - } - } - // TODO: QUESTION(1cg): this used to only check `newElement` unlike the other branches -- why? - // did I break something? - } else if (is.optionElement(oldElement) && is.optionElement(newElement)) { - syncBooleanAttribute(oldElement, newElement, "selected", ctx); - } else if ( - is.textAreaElement(oldElement) && - is.textAreaElement(newElement) - ) { - let newValue = newElement.value; - let oldValue = oldElement.value; - if (ignoreAttribute("value", oldElement, "update", ctx)) { - return; - } - if (newValue !== oldValue) { - oldElement.value = newValue; - } - if ( - oldElement.firstChild && - oldElement.firstChild.nodeValue !== newValue - ) { - oldElement.firstChild.nodeValue = newValue; - } - } - } - - /** - * @param {Element} oldElement element to write the value to - * @param {Element} newElement element to read the value from - * @param {string} attributeName the attribute name - * @param {MorphContext} ctx the merge context - */ - function syncBooleanAttribute(oldElement, newElement, attributeName, ctx) { - // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties - const newLiveValue = newElement[attributeName], - // @ts-ignore ditto - oldLiveValue = oldElement[attributeName]; - if (newLiveValue !== oldLiveValue) { - const ignoreUpdate = ignoreAttribute( - attributeName, - oldElement, - "update", - ctx, - ); - if (!ignoreUpdate) { - // update attribute's associated DOM property - // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties - oldElement[attributeName] = newElement[attributeName]; - } - if (newLiveValue) { - if (!ignoreUpdate) { - // https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML - // this is the correct way to set a boolean attribute to "true" - oldElement.setAttribute(attributeName, ""); - } - } else { - if (!ignoreAttribute(attributeName, oldElement, "remove", ctx)) { - oldElement.removeAttribute(attributeName); - } - } - } - } - - /** - * @param {string} attr the attribute to be mutated - * @param {Element} element the element that is going to be updated - * @param {"update" | "remove"} updateType - * @param {MorphContext} ctx the merge context - * @returns {boolean} true if the attribute should be ignored, false otherwise - */ - function ignoreAttribute(attr, element, updateType, ctx) { - if ( - attr === "value" && - ctx.ignoreActiveValue && - element === ctx.doc.activeElement - ) { - return true; - } - return ( - ctx.callbacks.beforeAttributeUpdated(attr, element, updateType) === - false - ); - } - - /** - * @param {Node} possibleActiveElement - * @param {MorphContext} ctx - * @returns {boolean} - */ - function ignoreValueOfActiveElement(possibleActiveElement, ctx) { - return ( - !!ctx.ignoreActiveValue && - possibleActiveElement === ctx.doc.activeElement && - possibleActiveElement !== ctx.doc.body - ); - } - - return morphNode; - })(); - - //============================================================================= - // Head Management Functions - //============================================================================= - /** - * @param {MorphContext} ctx - * @param {Element} oldNode - * @param {Element} newNode - * @param {function} callback - * @returns {Node[] | Promise} - */ - function withHeadBlocking(ctx, oldNode, newNode, callback) { - if (ctx.head.block && ctx.head.style !== "none") { - const oldHead = oldNode.querySelector("head"); - const newHead = newNode.querySelector("head"); - if (oldHead && newHead) { - const promises = handleHeadElement(oldHead, newHead, ctx); - // when head promises resolve, proceed ignoring the head tag - return Promise.all(promises).then(() => { - const newCtx = Object.assign(ctx, { - head: { - block: false, - style: "none", - }, - }); - return callback(newCtx); - }); - } - } - // just proceed if we not head blocking - return callback(ctx); - } - - /** - * The HEAD tag can be handled specially, either w/ a 'merge' or 'append' style - * - * @param {Element} oldHead - * @param {Element} newHead - * @param {MorphContext} ctx - * @returns {Promise[]} - */ - function handleHeadElement(oldHead, newHead, ctx) { - let added = []; - let removed = []; - let preserved = []; - let nodesToAppend = []; - - // put all new head elements into a Map, by their outerHTML - let srcToNewHeadNodes = new Map(); - for (const newHeadChild of newHead.children) { - srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); - } - - // for each elt in the current head - for (const currentHeadElt of oldHead.children) { - // If the current head element is in the map - let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML); - let isReAppended = ctx.head.shouldReAppend(currentHeadElt); - let isPreserved = ctx.head.shouldPreserve(currentHeadElt); - if (inNewContent || isPreserved) { - if (isReAppended) { - // remove the current version and let the new version replace it and re-execute - removed.push(currentHeadElt); - } else { - // this element already exists and should not be re-appended, so remove it from - // the new content map, preserving it in the DOM - srcToNewHeadNodes.delete(currentHeadElt.outerHTML); - preserved.push(currentHeadElt); - } - } else { - if (ctx.head.style === "append") { - // we are appending and this existing element is not new content - // so if and only if it is marked for re-append do we do anything - if (isReAppended) { - removed.push(currentHeadElt); - nodesToAppend.push(currentHeadElt); - } - } else { - // if this is a merge, we remove this content since it is not in the new head - if (ctx.head.shouldRemove(currentHeadElt) !== false) { - removed.push(currentHeadElt); - } - } - } - } - - // Push the remaining new head elements in the Map into the - // nodes to append to the head tag - nodesToAppend.push(...srcToNewHeadNodes.values()); - - let promises = []; - for (const newNode of nodesToAppend) { - // TODO: This could theoretically be null, based on type - let newElt = /** @type {ChildNode} */ ( - ctx.doc.createRange().createContextualFragment(newNode.outerHTML) - .firstChild - ); - if (ctx.callbacks.beforeNodeAdded(newElt) !== false) { - if ( - ("href" in newElt && newElt.href) || - ("src" in newElt && newElt.src) - ) { - /** @type {(result?: any) => void} */ let resolve; - let promise = new Promise(function (_resolve) { - resolve = _resolve; - }); - newElt.addEventListener("load", function () { - resolve(); - }); - promises.push(promise); - } - oldHead.appendChild(newElt); - ctx.callbacks.afterNodeAdded(newElt); - added.push(newElt); - } - } - - // remove all removed elements, after we have appended the new elements to avoid - // additional network requests for things like style sheets - for (const removedElement of removed) { - if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) { - oldHead.removeChild(removedElement); - ctx.callbacks.afterNodeRemoved(removedElement); - } - } - - ctx.head.afterHeadMorphed(oldHead, { - added: added, - kept: preserved, - removed: removed, - }); - return promises; - } - - //============================================================================= - // Create Morph Context Functions - //============================================================================= - const createMorphContext = (function () { - /** - * - * @param {Element} oldNode - * @param {Element} newContent - * @param {Config} config - * @returns {MorphContext} - */ - function createMorphContext(oldNode, newContent, config) { - const { persistentIds, idMap } = createIdMaps(oldNode, newContent); - - const mergedConfig = mergeDefaults(config); - const morphStyle = mergedConfig.morphStyle || "outerHTML"; - if (!["innerHTML", "outerHTML"].includes(morphStyle)) { - throw `Do not understand how to morph style ${morphStyle}`; - } - // Text and Comment have no ParentNode methods, so they cannot take innerHTML - if (morphStyle === "innerHTML" && !oldNode.append) { - throw `Cannot morph the innerHTML of a ${oldNode.nodeName} node, as it cannot have children`; - } - - const headStyle = mergedConfig.head.style || "merge"; - if (!["merge", "append", "morph", "none"].includes(headStyle)) { - throw `Do not understand how to morph head style ${headStyle}`; - } - - const doc = oldNode.ownerDocument; - - return { - target: oldNode, - newContent: newContent, - doc: doc, - config: mergedConfig, - morphStyle: morphStyle, - ignoreActive: mergedConfig.ignoreActive, - ignoreActiveValue: mergedConfig.ignoreActiveValue, - restoreFocus: mergedConfig.restoreFocus, - idMap: idMap, - persistentIds: persistentIds, - pantry: createPantry(doc), - activeElementAndParents: createActiveElementAndParents(oldNode, doc), - callbacks: mergedConfig.callbacks, - head: mergedConfig.head, - }; - } - - /** - * Deep merges the config object and the Idiomorph.defaults object to - * produce a final configuration object - * @param {Config} config - * @returns {ConfigInternal} - */ - function mergeDefaults(config) { - let finalConfig = Object.assign({}, defaults); - - // copy top level stuff into final config - Object.assign(finalConfig, config); - - // copy callbacks into final config (do this to deep merge the callbacks) - finalConfig.callbacks = Object.assign( - {}, - defaults.callbacks, - config.callbacks, - ); - - // copy head config into final config (do this to deep merge the head) - finalConfig.head = Object.assign({}, defaults.head, config.head); - - return finalConfig; - } - - /** - * @param {Document} doc - * @returns {HTMLDivElement} - */ - function createPantry(doc) { - const pantry = doc.createElement("div"); - pantry.hidden = true; - doc.documentElement.append(pantry); - return pantry; - } - - /** - * @param {Element} oldNode - * @param {Document} doc - * @returns {Element[]} - */ - function createActiveElementAndParents(oldNode, doc) { - /** @type {Element[]} */ - let activeElementAndParents = []; - let elt = doc.activeElement; - if (elt?.tagName !== "BODY" && oldNode.contains(elt)) { - while (elt) { - activeElementAndParents.push(elt); - if (elt === oldNode) break; - elt = elt.parentElement; - } - } - return activeElementAndParents; - } - - /** - * Returns all elements with a non-empty ID contained within the root node and its - * descendants, each paired with its id so that it only has to be read once. - * - * @param {Node} root - * @returns {IdElement[]} - */ - function findIdElements(root) { - /** @type {IdElement[]} */ - let elements = []; - // root could be a text or comment node which has no `querySelectorAll`, - // or a document fragment which has no `getAttribute` - const rootElt = /** @type {Partial} */ (root); - for (const elt of rootElt.querySelectorAll?.("[id]") ?? []) { - // elt.id is unsafe because of form input shadowing, and `id=""` is not persistable - const id = elt.getAttribute("id"); - if (id) elements.push({ elt, id }); - } - const rootId = rootElt.getAttribute?.("id"); - if (rootId) - elements.push({ elt: /** @type {Element} */ (root), id: rootId }); - return elements; - } - - /** - * A bottom-up algorithm that populates a map of Element -> IdSet. - * The idSet for a given element is the set of all IDs contained within its subtree. - * As an optimzation, we filter these IDs through the given list of persistent IDs, - * because we don't need to bother considering IDed elements that won't be in the new content. - * - * @param {Map>} idMap - * @param {Set} persistentIds - * @param {Element} root - * @param {IdElement[]} elements - */ - function populateIdMapWithTree(idMap, persistentIds, root, elements) { - for (const { elt, id } of elements) { - if (persistentIds.has(id)) { - /** @type {Element|null} */ - let current = elt; - // walk up the parent hierarchy of that element, adding the id - // of element to the parent's id set - while (current) { - let idSet = idMap.get(current); - // if the id set doesn't exist, create it and insert it in the map - if (idSet == null) { - idSet = new Set(); - idMap.set(current, idSet); - } - idSet.add(id); - - if (current === root) break; - current = current.parentElement; - } - } - } - } - - /** - * This function computes a map of nodes to all ids contained within that node (inclusive of the - * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows - * for a looser definition of "matching" than tradition id matching, and allows child nodes - * to contribute to a parent nodes matching. - * - * @param {Element} oldContent the old content that will be morphed - * @param {Element} newContent the new content to morph to - * @returns {IdSets} - */ - function createIdMaps(oldContent, newContent) { - const oldIdElements = findIdElements(oldContent); - const newIdElements = findIdElements(newContent); - - const persistentIds = createPersistentIds(oldIdElements, newIdElements); - - /** @type {Map>} */ - let idMap = new Map(); - populateIdMapWithTree(idMap, persistentIds, oldContent, oldIdElements); - - /** @ts-ignore - if newContent is a duck-typed parent, pass its single child node as the root to halt upwards iteration */ - const newRoot = newContent.__idiomorphRoot || newContent; - populateIdMapWithTree(idMap, persistentIds, newRoot, newIdElements); - - return { persistentIds, idMap }; - } - - /** - * This function computes the set of ids that persist between the two contents excluding duplicates - * - * @param {IdElement[]} oldIdElements - * @param {IdElement[]} newIdElements - * @returns {Set} - */ - function createPersistentIds(oldIdElements, newIdElements) { - let duplicateIds = new Set(); - - /** @type {Map} */ - let oldIdTagNameMap = new Map(); - for (const { elt, id } of oldIdElements) { - if (oldIdTagNameMap.has(id)) { - duplicateIds.add(id); - } else { - oldIdTagNameMap.set(id, elt.tagName); - } - } - - let persistentIds = new Set(); - for (const { elt, id } of newIdElements) { - if (persistentIds.has(id)) { - duplicateIds.add(id); - } else if (oldIdTagNameMap.get(id) === elt.tagName) { - persistentIds.add(id); - } - // skip if tag types mismatch because its not possible to morph one tag into another - } - - for (const id of duplicateIds) { - persistentIds.delete(id); - } - if (duplicateIds.size) { - console.warn( - "[Warning] duplicate ids found during morph, state loss within these elements is possible:", - Array.from(duplicateIds), - ); - } - return persistentIds; - } - - return createMorphContext; - })(); - - //============================================================================= - // HTML Normalization Functions - //============================================================================= - const { normalizeElement, normalizeParent } = (function () { - /** @type {WeakSet} */ - const generatedByIdiomorph = new WeakSet(); - - /** - * - * @param {Node} content - * @returns {Element} - */ - function normalizeElement(content) { - if (is.document(content)) { - return content.documentElement; - } else { - // a Text or Comment node is not an Element, but morphOuterHTML only ever reads Node members off it - return /** @type {Element} */ (content); - } - } - - /** - * - * @param {null | string | Node | HTMLCollection | Node[]} newContent - * @returns {Element} - */ - function normalizeParent(newContent) { - if (newContent == null) { - return document.createElement("div"); // dummy parent element - } else if (typeof newContent === "string") { - return normalizeParent(parseContent(newContent)); - } else if ( - generatedByIdiomorph.has(/** @type {Element} */ (newContent)) - ) { - // the template tag created by idiomorph parsing can serve as a dummy parent - return /** @type {Element} */ (newContent); - } else if (is.node(newContent)) { - if (newContent.parentNode) { - // we can't use the parent directly because newContent may have siblings - // that we don't want in the morph, and reparenting might be expensive (TODO is it?), - // so instead we create a fake parent node that only sees a slice of its children. - /** @type {Element} */ - return /** @type {any} */ (new SlicedParentNode(newContent)); - } else { - // a single node is added as a child to a dummy parent - const dummyParent = document.createElement("div"); - dummyParent.append(newContent); - return dummyParent; - } - } else { - // all nodes in the array or HTMLElement collection are consolidated under - // a single dummy parent element - const dummyParent = document.createElement("div"); - for (const elt of [...newContent]) { - dummyParent.append(elt); - } - return dummyParent; - } - } - - /** - * A fake duck-typed parent element to wrap a single node, without actually reparenting it. - * This is useful because the node may have siblings that we don't want in the morph, and it may also be moved - * or replaced with one or more elements during the morph. This class effectively allows us a window into - * a slice of a node's children. - * "If it walks like a duck, and quacks like a duck, then it must be a duck!" -- James Whitcomb Riley (1849–1916) - */ - class SlicedParentNode { - /** @param {Node} node */ - constructor(node) { - this.originalNode = node; - this.realParentNode = /** @type {Element} */ (node.parentNode); - this.previousSibling = node.previousSibling; - this.nextSibling = node.nextSibling; - } - - /** @returns {Node[]} */ - get childNodes() { - // return slice of realParent's current childNodes, based on previousSibling and nextSibling - const nodes = []; - let cursor = this.previousSibling - ? this.previousSibling.nextSibling - : this.realParentNode.firstChild; - while (cursor && cursor != this.nextSibling) { - nodes.push(cursor); - cursor = cursor.nextSibling; - } - return nodes; - } - - /** - * @param {string} selector - * @returns {Element[]} - */ - querySelectorAll(selector) { - return this.childNodes.reduce((results, node) => { - if (is.element(node)) { - if (node.matches(selector)) results.push(node); - const nodeList = node.querySelectorAll(selector); - for (let i = 0; i < nodeList.length; i++) { - results.push(nodeList[i]); - } - } - return results; - }, /** @type {Element[]} */ ([])); - } - - /** - * @param {Node} node - * @param {Node} referenceNode - * @returns {Node} - */ - insertBefore(node, referenceNode) { - return this.realParentNode.insertBefore(node, referenceNode); - } - - /** - * @param {Node} node - * @param {Node} referenceNode - * @returns {Node} - */ - moveBefore(node, referenceNode) { - // @ts-ignore - use new moveBefore feature - return this.realParentNode.moveBefore(node, referenceNode); - } - - /** - * for later use with populateIdMapWithTree to halt upwards iteration - * @returns {Node} - */ - get __idiomorphRoot() { - return this.originalNode; - } - } - - /** - * - * @param {string} newContent - * @returns {Node | null | DocumentFragment} - */ - function parseContent(newContent) { - let parser = new DOMParser(); - - // remove svgs to avoid false-positive matches on head, etc. - let contentWithSvgsRemoved = newContent.replace( - /]*>|>)([\s\S]*?)<\/svg>/gim, - "", - ); - - // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping - if ( - contentWithSvgsRemoved.match(/<\/html>/) || - contentWithSvgsRemoved.match(/<\/head>/) || - contentWithSvgsRemoved.match(/<\/body>/) - ) { - let content = parser.parseFromString(newContent, "text/html"); - // a doctype can neither be morphed nor inserted, and would displace the parent container below - content.doctype?.remove(); - // if it is a full HTML document, return the document itself as the parent container - if (contentWithSvgsRemoved.match(/<\/html>/)) { - generatedByIdiomorph.add(content); - return content; - } else { - // otherwise return the html element as the parent container - let htmlElement = content.firstChild; - if (htmlElement) { - generatedByIdiomorph.add(htmlElement); - } - return htmlElement; - } - } else { - // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help - // deal with touchy tags like tr, tbody, etc. - let responseDoc = parser.parseFromString( - "", - "text/html", - ); - let content = /** @type {HTMLTemplateElement} */ ( - responseDoc.body.querySelector("template") - ).content; - generatedByIdiomorph.add(content); - return content; - } - } - - return { normalizeElement, normalizeParent }; - })(); - - //============================================================================= - // Realm-safe node type checks - //============================================================================= - const is = (function () { - /** @param {Node | null | undefined} value @returns {value is Element} */ - const element = (value) => - value instanceof Element || value?.nodeType === Node.ELEMENT_NODE; - - /** - * @param {Node | null | undefined} value - * @param {string} localName - * @returns {value is Element} - */ - const htmlElement = (value, localName) => - element(value) && - value.localName === localName && - value.namespaceURI === "http://www.w3.org/1999/xhtml"; - - return { - element, - /** @param {unknown} value @returns {value is Node} */ - node: (value) => - value instanceof Node || - typeof (/** @type {any} */ (value)?.nodeType) === "number", - /** @param {Node | null | undefined} value @returns {value is Document} */ - document: (value) => - value instanceof Document || value?.nodeType === Node.DOCUMENT_NODE, - /** @param {Node | null | undefined} value @returns {value is HTMLTemplateElement} */ - templateElement: (value) => htmlElement(value, "template"), - /** @param {Node | null | undefined} value @returns {value is HTMLHeadElement} */ - headElement: (value) => htmlElement(value, "head"), - /** @param {Node | null | undefined} value @returns {value is HTMLInputElement} */ - inputElement: (value) => htmlElement(value, "input"), - /** @param {Node | null | undefined} value @returns {value is HTMLOptionElement} */ - optionElement: (value) => htmlElement(value, "option"), - /** @param {Node | null | undefined} value @returns {value is HTMLTextAreaElement} */ - textAreaElement: (value) => htmlElement(value, "textarea"), - }; - })(); - - //============================================================================= - // This is what ends up becoming the Idiomorph global object - //============================================================================= - return { - morph, - defaults, - }; -})(); -(function () { - function createMorphConfig(swapStyle) { - if (swapStyle === "morph" || swapStyle === "morph:outerHTML") { - return { morphStyle: "outerHTML" }; - } else if (swapStyle === "morph:innerHTML") { - return { morphStyle: "innerHTML" }; - } else if (swapStyle.startsWith("morph:")) { - return Function("return (" + swapStyle.slice(6) + ")")(); - } - } - - htmx.defineExtension("morph", { - isInlineSwap: function (swapStyle) { - let config = createMorphConfig(swapStyle); - return config?.morphStyle === "outerHTML" || config?.morphStyle == null; - }, - handleSwap: function (swapStyle, target, fragment) { - let config = createMorphConfig(swapStyle); - if (config) { - return Idiomorph.morph(target, fragment.children, config); - } - }, - }); -})(); diff --git a/dist/idiomorph-ext.min.js b/dist/idiomorph-ext.min.js deleted file mode 100644 index 263983b..0000000 --- a/dist/idiomorph-ext.min.js +++ /dev/null @@ -1 +0,0 @@ -var Idiomorph=function(){"use strict";const e=()=>{};const n={morphStyle:"outerHTML",callbacks:{beforeNodeAdded:e,afterNodeAdded:e,beforeNodeMorphed:e,afterNodeMorphed:e,beforeNodeRemoved:e,afterNodeRemoved:e,beforeAttributeUpdated:e},head:{style:"merge",shouldPreserve:e=>e.getAttribute("im-preserve")==="true",shouldReAppend:e=>e.getAttribute("im-re-append")==="true",shouldRemove:e,afterHeadMorphed:e},restoreFocus:true};function t(e,t,n={}){const r=u(e);const o=f(t);const i=d(r,o,n);return c(i,r,o,e=>{const t=a(e,()=>{if(e.morphStyle==="innerHTML"){s(e,r,o);return Array.from(r.childNodes)}else{return l(e,r,o)}});e.pantry.remove();return t})}function l(e,t,n){const r=f(t);s(e,r,n,t,t.nextSibling);return Array.from(r.childNodes)}function a(e,t){if(!e.config.restoreFocus)return t();let n=e.doc.activeElement;if(!(g.inputElement(n)||g.textAreaElement(n))){return t()}const{id:r,selectionStart:o,selectionEnd:i}=n;const l=t();if(r&&r!==e.doc.activeElement?.getAttribute("id")){n=e.target.querySelector(`[id="${CSS.escape(r)}"]`);n?.focus()}if(n&&!n.selectionEnd&&i){try{n.setSelectionRange(o,i)}catch{}}return l}const s=function(){function e(e,t,n,r=null,o=null){if(g.templateElement(t)&&g.templateElement(n)){t=t.content;n=n.content}r||=t.firstChild;for(const i of n.childNodes){if(r&&r!=o){const a=f(e,i,r,o);if(a){if(a!==r){m(e,r,a)}b(a,i,e);r=a.nextSibling;continue}}if(g.element(i)){const s=i.getAttribute("id");if(e.persistentIds.has(s)){const c=p(t,s,r,e);b(c,i,e);r=c.nextSibling;continue}}const l=u(t,i,r,e);if(l){r=l.nextSibling}}while(r&&r!=o){const d=r;r=r.nextSibling;h(e,d)}}function u(e,t,n,r){if(r.callbacks.beforeNodeAdded(t)===false)return null;if(r.idMap.has(t)){const o=r.doc.createElementNS(t.namespaceURI,t.localName);e.insertBefore(o,n);b(o,t,r);r.callbacks.afterNodeAdded(o);return o}else{const i=r.doc.importNode(t,true);e.insertBefore(i,n);r.callbacks.afterNodeAdded(i);return i}}const f=function(){function e(e,t,n,r){let o=null;let i=t.nextSibling;let l=0;let a=n;while(a&&a!=r){if(c(a,t)){if(s(e,a,t)){return a}if(o===null){if(!e.idMap.has(a)){o=a}}}if(o===null&&i&&c(a,i)){l++;i=i.nextSibling;if(l>=2){o=undefined}}if(e.activeElementAndParents.includes(a))break;a=a.nextSibling}return o||null}function s(e,t,n){let r=e.idMap.get(t);let o=e.idMap.get(n);if(!o||!r)return false;for(const i of r){if(o.has(i)){return true}}return false}function c(e,t){const n=e;const r=t;return n.nodeType===r.nodeType&&n.tagName===r.tagName&&(!n.getAttribute?.("id")||n.getAttribute?.("id")===r.getAttribute?.("id"))}return e}();function h(e,t){if(t===e.pantry)return;if(e.idMap.has(t)){a(e.pantry,t,null)}else{if(e.callbacks.beforeNodeRemoved(t)===false)return;t.parentNode?.removeChild(t);e.callbacks.afterNodeRemoved(t)}}function m(t,e,n){let r=e;while(r&&r!==n){let e=r;r=r.nextSibling;h(t,e)}return r}function p(e,t,n,r){const o=`[id="${CSS.escape(t)}"]`;const i=r.target.getAttribute?.("id")===t&&r.target||r.target.querySelector(o)||r.pantry.querySelector(o);l(i,r);a(e,i,n);return i}function l(t,n){const r=t.getAttribute("id");while(t=t.parentNode){let e=n.idMap.get(t);if(e){e.delete(r);if(!e.size){n.idMap.delete(t)}}}}function a(t,n,r){if(t.moveBefore){try{t.moveBefore(n,r)}catch(e){t.insertBefore(n,r)}}else{t.insertBefore(n,r)}}return e}();const b=function(){function e(e,t,n){if(n.ignoreActive&&e===n.doc.activeElement){return null}if(n.callbacks.beforeNodeMorphed(e,t)===false){return e}if(g.headElement(e)&&n.head.style==="none"){}else if(g.headElement(e)&&n.head.style!=="morph"){h(e,t,n)}else{r(e,t,n);if(!f(e,n)){s(n,e,t)}}n.callbacks.afterNodeMorphed(e,t);return e}function r(e,t,n){let r=t.nodeType;if(r===1){const o=e;const i=t;const l=o.attributes;const a=i.attributes;for(const s of a){if(o.getAttribute(s.name)===s.value){continue}if(u(s.name,o,"update",n)){continue}o.setAttributeNode(s.cloneNode())}for(let e=l.length-1;0<=e;e--){const c=l[e];if(!c)continue;if(!i.hasAttribute(c.name)){if(u(c.name,o,"remove",n)){continue}o.removeAttribute(c.name)}}if(!f(o,n)){d(o,i,n)}}if(r===8||r===3){if(e.nodeValue!==t.nodeValue){e.nodeValue=t.nodeValue}}}function d(n,r,o){if(g.inputElement(n)&&g.inputElement(r)&&r.type!=="file"){let e=r.value;let t=n.value;i(n,r,"checked",o);i(n,r,"disabled",o);if(!r.hasAttribute("value")){if(!u("value",n,"remove",o)){n.value="";n.removeAttribute("value")}}else if(t!==e){if(!u("value",n,"update",o)){n.setAttribute("value",e);n.value=e}}}else if(g.optionElement(n)&&g.optionElement(r)){i(n,r,"selected",o)}else if(g.textAreaElement(n)&&g.textAreaElement(r)){let e=r.value;let t=n.value;if(u("value",n,"update",o)){return}if(e!==t){n.value=e}if(n.firstChild&&n.firstChild.nodeValue!==e){n.firstChild.nodeValue=e}}}function i(e,t,n,r){const o=t[n],i=e[n];if(o!==i){const l=u(n,e,"update",r);if(!l){e[n]=t[n]}if(o){if(!l){e.setAttribute(n,"")}}else{if(!u(n,e,"remove",r)){e.removeAttribute(n)}}}}function u(e,t,n,r){if(e==="value"&&r.ignoreActiveValue&&t===r.doc.activeElement){return true}return r.callbacks.beforeAttributeUpdated(e,t,n)===false}function f(e,t){return!!t.ignoreActiveValue&&e===t.doc.activeElement&&e!==t.doc.body}return e}();function c(t,e,n,r){if(t.head.block&&t.head.style!=="none"){const o=e.querySelector("head");const i=n.querySelector("head");if(o&&i){const l=h(o,i,t);return Promise.all(l).then(()=>{const e=Object.assign(t,{head:{block:false,style:"none"}});return r(e)})}}return r(t)}function h(e,t,r){let o=[];let i=[];let l=[];let a=[];let s=new Map;for(const n of t.children){s.set(n.outerHTML,n)}for(const d of e.children){let e=s.has(d.outerHTML);let t=r.head.shouldReAppend(d);let n=r.head.shouldPreserve(d);if(e||n){if(t){i.push(d)}else{s.delete(d.outerHTML);l.push(d)}}else{if(r.head.style==="append"){if(t){i.push(d);a.push(d)}}else{if(r.head.shouldRemove(d)!==false){i.push(d)}}}}a.push(...s.values());let c=[];for(const u of a){let n=r.doc.createRange().createContextualFragment(u.outerHTML).firstChild;if(r.callbacks.beforeNodeAdded(n)!==false){if("href"in n&&n.href||"src"in n&&n.src){let t;let e=new Promise(function(e){t=e});n.addEventListener("load",function(){t()});c.push(e)}e.appendChild(n);r.callbacks.afterNodeAdded(n);o.push(n)}}for(const f of i){if(r.callbacks.beforeNodeRemoved(f)!==false){e.removeChild(f);r.callbacks.afterNodeRemoved(f)}}r.head.afterHeadMorphed(e,{added:o,kept:l,removed:i});return c}const d=function(){function e(e,t,n){const{persistentIds:r,idMap:o}=f(e,t);const i=c(n);const l=i.morphStyle||"outerHTML";if(!["innerHTML","outerHTML"].includes(l)){throw`Do not understand how to morph style ${l}`}if(l==="innerHTML"&&!e.append){throw`Cannot morph the innerHTML of a ${e.nodeName} node, as it cannot have children`}const a=i.head.style||"merge";if(!["merge","append","morph","none"].includes(a)){throw`Do not understand how to morph head style ${a}`}const s=e.ownerDocument;return{target:e,newContent:t,doc:s,config:i,morphStyle:l,ignoreActive:i.ignoreActive,ignoreActiveValue:i.ignoreActiveValue,restoreFocus:i.restoreFocus,idMap:o,persistentIds:r,pantry:d(s),activeElementAndParents:u(e,s),callbacks:i.callbacks,head:i.head}}function c(e){let t=Object.assign({},n);Object.assign(t,e);t.callbacks=Object.assign({},n.callbacks,e.callbacks);t.head=Object.assign({},n.head,e.head);return t}function d(e){const t=e.createElement("div");t.hidden=true;e.documentElement.append(t);return t}function u(e,t){let n=[];let r=t.activeElement;if(r?.tagName!=="BODY"&&e.contains(r)){while(r){n.push(r);if(r===e)break;r=r.parentElement}}return n}function a(e){let t=[];const n=e;for(const o of n.querySelectorAll?.("[id]")??[]){const i=o.getAttribute("id");if(i)t.push({elt:o,id:i})}const r=n.getAttribute?.("id");if(r)t.push({elt:e,id:r});return t}function s(n,e,r,t){for(const{elt:o,id:i}of t){if(e.has(i)){let t=o;while(t){let e=n.get(t);if(e==null){e=new Set;n.set(t,e)}e.add(i);if(t===r)break;t=t.parentElement}}}}function f(e,t){const n=a(e);const r=a(t);const o=h(n,r);let i=new Map;s(i,o,e,n);const l=t.__idiomorphRoot||t;s(i,o,l,r);return{persistentIds:o,idMap:i}}function h(e,t){let n=new Set;let r=new Map;for(const{elt:i,id:l}of e){if(r.has(l)){n.add(l)}else{r.set(l,i.tagName)}}let o=new Set;for(const{elt:i,id:l}of t){if(o.has(l)){n.add(l)}else if(r.get(l)===i.tagName){o.add(l)}}for(const l of n){o.delete(l)}if(n.size){console.warn("[Warning] duplicate ids found during morph, state loss within these elements is possible:",Array.from(n))}return o}return e}();const{normalizeElement:u,normalizeParent:f}=function(){const o=new WeakSet;function e(e){if(g.document(e)){return e.documentElement}else{return e}}function r(e){if(e==null){return document.createElement("div")}else if(typeof e==="string"){return r(l(e))}else if(o.has(e)){return e}else if(g.node(e)){if(e.parentNode){return new i(e)}else{const t=document.createElement("div");t.append(e);return t}}else{const t=document.createElement("div");for(const n of[...e]){t.append(n)}return t}}class i{constructor(e){this.originalNode=e;this.realParentNode=e.parentNode;this.previousSibling=e.previousSibling;this.nextSibling=e.nextSibling}get childNodes(){const e=[];let t=this.previousSibling?this.previousSibling.nextSibling:this.realParentNode.firstChild;while(t&&t!=this.nextSibling){e.push(t);t=t.nextSibling}return e}querySelectorAll(r){return this.childNodes.reduce((t,e)=>{if(g.element(e)){if(e.matches(r))t.push(e);const n=e.querySelectorAll(r);for(let e=0;e]*>|>)([\s\S]*?)<\/svg>/gim,"");if(e.match(/<\/html>/)||e.match(/<\/head>/)||e.match(/<\/body>/)){let t=r.parseFromString(n,"text/html");t.doctype?.remove();if(e.match(/<\/html>/)){o.add(t);return t}else{let e=t.firstChild;if(e){o.add(e)}return e}}else{let e=r.parseFromString("","text/html");let t=e.body.querySelector("template").content;o.add(t);return t}}return{normalizeElement:e,normalizeParent:r}}();const g=function(){const n=e=>e instanceof Element||e?.nodeType===Node.ELEMENT_NODE;const t=(e,t)=>n(e)&&e.localName===t&&e.namespaceURI==="http://www.w3.org/1999/xhtml";return{element:n,node:e=>e instanceof Node||typeof e?.nodeType==="number",document:e=>e instanceof Document||e?.nodeType===Node.DOCUMENT_NODE,templateElement:e=>t(e,"template"),headElement:e=>t(e,"head"),inputElement:e=>t(e,"input"),optionElement:e=>t(e,"option"),textAreaElement:e=>t(e,"textarea")}}();return{morph:t,defaults:n}}();(function(){function o(e){if(e==="morph"||e==="morph:outerHTML"){return{morphStyle:"outerHTML"}}else if(e==="morph:innerHTML"){return{morphStyle:"innerHTML"}}else if(e.startsWith("morph:")){return Function("return ("+e.slice(6)+")")()}}htmx.defineExtension("morph",{isInlineSwap:function(e){let t=o(e);return t?.morphStyle==="outerHTML"||t?.morphStyle==null},handleSwap:function(e,t,n){let r=o(e);if(r){return Idiomorph.morph(t,n.children,r)}}})})(); \ No newline at end of file diff --git a/dist/idiomorph-htmx.js b/dist/idiomorph-htmx.js deleted file mode 100644 index cabbe5d..0000000 --- a/dist/idiomorph-htmx.js +++ /dev/null @@ -1,24 +0,0 @@ -(function () { - function createMorphConfig(swapStyle) { - if (swapStyle === "morph" || swapStyle === "morph:outerHTML") { - return { morphStyle: "outerHTML" }; - } else if (swapStyle === "morph:innerHTML") { - return { morphStyle: "innerHTML" }; - } else if (swapStyle.startsWith("morph:")) { - return Function("return (" + swapStyle.slice(6) + ")")(); - } - } - - htmx.defineExtension("morph", { - isInlineSwap: function (swapStyle) { - let config = createMorphConfig(swapStyle); - return config?.morphStyle === "outerHTML" || config?.morphStyle == null; - }, - handleSwap: function (swapStyle, target, fragment) { - let config = createMorphConfig(swapStyle); - if (config) { - return Idiomorph.morph(target, fragment.children, config); - } - }, - }); -})(); diff --git a/package-lock.json b/package-lock.json index f098ef7..9d780b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "chai-dom": "^1.11.0", "chromedriver": "latest", "fs-extra": "^9.1.0", - "htmx.org": "1.9.9", "lcov-parse": "^1.0.0", "mocha": "^11.0.1", "playwright": "^1.62.1", @@ -3043,12 +3042,6 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, - "node_modules/htmx.org": { - "version": "1.9.9", - "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.9.9.tgz", - "integrity": "sha512-PDEZU1me7UGLzQk98LyfLvwFgdtn9mrCVMmAxv1/UjshUnxsc+rouu+Ot2QfFZxsY4mBCoOed5nK7m9Nj2Tu7g==", - "dev": true - }, "node_modules/http-assert": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", diff --git a/package.json b/package.json index 79ee4f1..a2f4893 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,6 @@ "types": "./dist/idiomorph.d.ts", "import": "./dist/idiomorph.esm.js" }, - "./htmx": { - "types": "./dist/idiomorph-ext.d.ts", - "import": "./dist/idiomorph-ext.esm.js" - }, "./dist/*": "./dist/*" }, "scripts": { @@ -42,11 +38,10 @@ "test:coverage": "npm run test:chrome && node test/lib/ensure-full-coverage.js", "perf": "playwright install chromium && node perf/runner.js", - "dist": "cp -r src/* dist/ && npm run-script ext && npm run-script esm && npm run-script types && npm run-script uglify && npm run-script gzip && exit", - "ext": "cat src/idiomorph.js src/idiomorph-htmx.js > dist/idiomorph-ext.js", - "esm": "(cat src/idiomorph.js && echo \"\nexport {Idiomorph};\") > dist/idiomorph.esm.js && (echo \"import htmx from \\\"htmx.org\\\";\n\" && cat dist/idiomorph-ext.js && echo \"\nexport {Idiomorph};\") > dist/idiomorph-ext.esm.js", - "types": "tsc dist/idiomorph.esm.js dist/idiomorph-ext.esm.js --declaration --emitDeclarationOnly --allowJs --skipLibCheck --outDir dist && mv dist/idiomorph.esm.d.ts dist/idiomorph.d.ts && mv dist/idiomorph-ext.esm.d.ts dist/idiomorph-ext.d.ts", - "uglify": "uglifyjs -m eval -o dist/idiomorph.min.js dist/idiomorph.js && uglifyjs -m eval -o dist/idiomorph-ext.min.js dist/idiomorph-ext.js", + "dist": "cp -r src/* dist/ && npm run-script esm && npm run-script types && npm run-script uglify && npm run-script gzip && exit", + "esm": "(cat src/idiomorph.js && echo \"\nexport {Idiomorph};\") > dist/idiomorph.esm.js", + "types": "tsc dist/idiomorph.esm.js --declaration --emitDeclarationOnly --allowJs --skipLibCheck --outDir dist && mv dist/idiomorph.esm.d.ts dist/idiomorph.d.ts", + "uglify": "uglifyjs -m eval -o dist/idiomorph.min.js dist/idiomorph.js", "gzip": "gzip -9 -k -f dist/idiomorph.min.js > dist/idiomorph.min.js.gz", "format": "prettier --write .", @@ -64,7 +59,6 @@ "chai-dom": "^1.11.0", "chromedriver": "latest", "fs-extra": "^9.1.0", - "htmx.org": "1.9.9", "lcov-parse": "^1.0.0", "mocha": "^11.0.1", "playwright": "^1.62.1", diff --git a/src/idiomorph-htmx.js b/src/idiomorph-htmx.js deleted file mode 100644 index cabbe5d..0000000 --- a/src/idiomorph-htmx.js +++ /dev/null @@ -1,24 +0,0 @@ -(function () { - function createMorphConfig(swapStyle) { - if (swapStyle === "morph" || swapStyle === "morph:outerHTML") { - return { morphStyle: "outerHTML" }; - } else if (swapStyle === "morph:innerHTML") { - return { morphStyle: "innerHTML" }; - } else if (swapStyle.startsWith("morph:")) { - return Function("return (" + swapStyle.slice(6) + ")")(); - } - } - - htmx.defineExtension("morph", { - isInlineSwap: function (swapStyle) { - let config = createMorphConfig(swapStyle); - return config?.morphStyle === "outerHTML" || config?.morphStyle == null; - }, - handleSwap: function (swapStyle, target, fragment) { - let config = createMorphConfig(swapStyle); - if (config) { - return Idiomorph.morph(target, fragment.children, config); - } - }, - }); -})(); diff --git a/test/demo/scratch.html b/test/demo/scratch.html index 4480a88..e1bc421 100644 --- a/test/demo/scratch.html +++ b/test/demo/scratch.html @@ -5,19 +5,7 @@ - - - - - - - - - - -
-
-

Above...

-
-
- -
-
- - - - \ No newline at end of file diff --git a/test/htmx/htmx-demo2.html b/test/htmx/htmx-demo2.html deleted file mode 100644 index 60b3870..0000000 --- a/test/htmx/htmx-demo2.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - -
-
-

Above...

-

- -

-
-
- -
-
- - - \ No newline at end of file diff --git a/test/index.html b/test/index.html index f835d73..f74a747 100644 --- a/test/index.html +++ b/test/index.html @@ -23,11 +23,9 @@

Mocha Test Suite

- - - @@ -53,7 +50,7 @@

Mocha Test Suite

Work Area
-
+    
       Output Here...
     
diff --git a/web-test-runner.config.mjs b/web-test-runner.config.mjs index 70b3431..bf96b53 100644 --- a/web-test-runner.config.mjs +++ b/web-test-runner.config.mjs @@ -19,8 +19,6 @@ let config = { - - @@ -28,7 +26,7 @@ let config = { Work Area
-
+      
         Output Here...