1385 lines
45 KiB
JavaScript
1385 lines
45 KiB
JavaScript
// 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 (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 (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,
|
||
};
|
||
})();
|