impress/vendor/javascript/idiomorph.js

1386 lines
45 KiB
JavaScript
Raw Normal View History

// https://raw.githubusercontent.com/bigskysoftware/idiomorph/v0.7.4/dist/idiomorph.js
/**
* @typedef {object} ConfigHead
*
* @property {'merge' | 'append' | 'morph' | 'none'} [style]
* @property {boolean} [block]
* @property {boolean} [ignore]
* @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]
*/
/**
* @typedef {function} NoOp
*
* @returns {void}
*/
/**
* @typedef {object} ConfigHeadInternal
*
* @property {'merge' | 'append' | 'morph' | 'none'} style
* @property {boolean} [block]
* @property {boolean} [ignore]
* @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} IdSets
* @property {Set<string>} persistentIds
* @property {Map<Node, Set<string>>} idMap
*/
/**
* @typedef {Function} Morph
*
* @param {Element | Document} oldNode
* @param {Element | Node | HTMLCollection | Node[] | string | null} newContent
* @param {Config} [config]
* @returns {undefined | 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 {ConfigInternal} config
* @property {ConfigInternal['morphStyle']} morphStyle
* @property {ConfigInternal['ignoreActive']} ignoreActive
* @property {ConfigInternal['ignoreActiveValue']} ignoreActiveValue
* @property {ConfigInternal['restoreFocus']} restoreFocus
* @property {Map<Node, Set<string>>} idMap
* @property {Set<string>} 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 {Element | Document} oldNode
* @param {Element | Node | HTMLCollection | Node[] | string | null} newContent
* @param {Config} [config]
* @returns {Promise<Node[]> | Node[]}
*/
function morph(oldNode, newContent, config = {}) {
oldNode = normalizeElement(oldNode);
const newNode = normalizeParent(newContent);
const ctx = createMorphContext(oldNode, newNode, config);
const morphedNodes = saveAndRestoreFocus(ctx, () => {
return withHeadBlocking(
ctx,
oldNode,
newNode,
/** @param {MorphContext} ctx */ (ctx) => {
if (ctx.morphStyle === "innerHTML") {
morphChildren(ctx, oldNode, newNode);
return Array.from(oldNode.childNodes);
} else {
return morphOuterHTML(ctx, oldNode, 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 {Promise<Node[]> | Node[]}
*/
function saveAndRestoreFocus(ctx, fn) {
if (!ctx.config.restoreFocus) return fn();
let activeElement =
/** @type {HTMLInputElement|HTMLTextAreaElement|null} */ (
document.activeElement
);
// don't bother if the active element is not an input or textarea
if (
!(
activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement
)
) {
return fn();
}
const { id: activeElementId, selectionStart, selectionEnd } = activeElement;
const results = fn();
if (
activeElementId &&
activeElementId !== document.activeElement?.getAttribute("id")
) {
activeElement = ctx.target.querySelector(`[id="${activeElementId}"]`);
activeElement?.focus();
}
if (activeElement && !activeElement.selectionEnd && selectionEnd) {
activeElement.setSelectionRange(selectionStart, selectionEnd);
}
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 (
oldParent instanceof HTMLTemplateElement &&
newParent instanceof HTMLTemplateElement
) {
// @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 (newChild instanceof Element) {
// 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
const newEmptyChild = document.createElement(
/** @type {Element} */ (newChild).tagName,
);
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 = document.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) {
// 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 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(`[id="${id}"]`) ||
ctx.pantry.querySelector(`[id="${id}"]`)
);
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 proposed `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) {
// fall back to insertBefore as some browsers may fail on moveBefore when trying to move Dom disconnected nodes to pantry
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 === document.activeElement) {
// don't morph focused element
return null;
}
if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) {
return oldNode;
}
if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) {
// ignore the head element
} else if (
oldNode instanceof HTMLHeadElement &&
ctx.head.style !== "morph"
) {
// ok to cast: if newContent wasn't also a <head>, 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 (ignoreAttribute(newAttribute.name, oldElt, "update", ctx)) {
continue;
}
if (oldElt.getAttribute(newAttribute.name) !== newAttribute.value) {
oldElt.setAttribute(newAttribute.name, newAttribute.value);
}
}
// 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 (
oldElement instanceof HTMLInputElement &&
newElement instanceof HTMLInputElement &&
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 (
oldElement instanceof HTMLOptionElement &&
newElement instanceof HTMLOptionElement
) {
syncBooleanAttribute(oldElement, newElement, "selected", ctx);
} else if (
oldElement instanceof HTMLTextAreaElement &&
newElement instanceof HTMLTextAreaElement
) {
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 === document.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 === document.activeElement &&
possibleActiveElement !== document.body
);
}
return morphNode;
})();
//=============================================================================
// Head Management Functions
//=============================================================================
/**
* @param {MorphContext} ctx
* @param {Element} oldNode
* @param {Element} newNode
* @param {function} callback
* @returns {Node[] | Promise<Node[]>}
*/
function withHeadBlocking(ctx, oldNode, newNode, callback) {
if (ctx.head.block) {
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,
ignore: true,
},
});
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<void>[]}
*/
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} */ (
document.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}`;
}
return {
target: oldNode,
newContent: newContent,
config: mergedConfig,
morphStyle: morphStyle,
ignoreActive: mergedConfig.ignoreActive,
ignoreActiveValue: mergedConfig.ignoreActiveValue,
restoreFocus: mergedConfig.restoreFocus,
idMap: idMap,
persistentIds: persistentIds,
pantry: createPantry(),
activeElementAndParents: createActiveElementAndParents(oldNode),
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;
}
/**
* @returns {HTMLDivElement}
*/
function createPantry() {
const pantry = document.createElement("div");
pantry.hidden = true;
document.body.insertAdjacentElement("afterend", pantry);
return pantry;
}
/**
* @param {Element} oldNode
* @returns {Element[]}
*/
function createActiveElementAndParents(oldNode) {
/** @type {Element[]} */
let activeElementAndParents = [];
let elt = document.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 an ID contained within the root element and its descendants
*
* @param {Element} root
* @returns {Element[]}
*/
function findIdElements(root) {
let elements = Array.from(root.querySelectorAll("[id]"));
// root could be a document fragment which doesn't have `getAttribute`
if (root.getAttribute?.("id")) {
elements.push(root);
}
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<Node, Set<string>>} idMap
* @param {Set<string>} persistentIds
* @param {Element} root
* @param {Element[]} elements
*/
function populateIdMapWithTree(idMap, persistentIds, root, elements) {
for (const elt of elements) {
// we can pretend id is non-null String, because the .has line will reject it immediately if not
const id = /** @type {String} */ (elt.getAttribute("id"));
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<Node, Set<string>>} */
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 {Element[]} oldIdElements
* @param {Element[]} newIdElements
* @returns {Set<string>}
*/
function createPersistentIds(oldIdElements, newIdElements) {
let duplicateIds = new Set();
/** @type {Map<string, string>} */
let oldIdTagNameMap = new Map();
for (const { id, tagName } of oldIdElements) {
if (oldIdTagNameMap.has(id)) {
duplicateIds.add(id);
} else {
oldIdTagNameMap.set(id, tagName);
}
}
let persistentIds = new Set();
for (const { id, tagName } of newIdElements) {
if (persistentIds.has(id)) {
duplicateIds.add(id);
} else if (oldIdTagNameMap.get(id) === 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);
}
return persistentIds;
}
return createMorphContext;
})();
//=============================================================================
// HTML Normalization Functions
//=============================================================================
const { normalizeElement, normalizeParent } = (function () {
/** @type {WeakSet<Node>} */
const generatedByIdiomorph = new WeakSet();
/**
*
* @param {Element | Document} content
* @returns {Element}
*/
function normalizeElement(content) {
if (content instanceof Document) {
return content.documentElement;
} else {
return content;
}
}
/**
*
* @param {null | string | Node | HTMLCollection | Node[] | Document & {generatedByIdiomorph:boolean}} 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 (newContent instanceof Node) {
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 (18491916)
*/
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 (node instanceof Element) {
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(
/<svg(\s[^>]*>|>)([\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");
// 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(
"<body><template>" + newContent + "</template></body>",
"text/html",
);
let content = /** @type {HTMLTemplateElement} */ (
responseDoc.body.querySelector("template")
).content;
generatedByIdiomorph.add(content);
return content;
}
}
return { normalizeElement, normalizeParent };
})();
//=============================================================================
// This is what ends up becoming the Idiomorph global object
//=============================================================================
return {
morph,
defaults,
};
})();