Source: utils/dom.js

import { getBoundingClientRect } from "./utils.js";

/**
 * Checks if a given node is an Element node.
 *
 * @param {Node} node - The node to check.
 * @returns {boolean} True if the node is an Element (nodeType === 1), else false.
 */
export function isElement(node) {
	return node && node.nodeType === 1;
}

/**
 * Checks if a given node is a Text node.
 *
 * @param {Node} node - The node to check.
 * @returns {boolean} True if the node is a Text node (nodeType === 3), else false.
 */
export function isText(node) {
	return node && node.nodeType === 3;
}

/**
 * Generator function that walks the DOM tree starting from the given node,
 * traversing depth-first and yielding nodes until the limiter node is reached (if provided).
 *
 * @param {Node} start - The starting node for traversal.
 * @param {Node} [limiter] - Optional node at which traversal stops.
 * @yields {Node} Nodes in the DOM tree in depth-first order.
 */
export function* walk(start, limiter) {
	let node = start;

	while (node) {
		yield node;

		if (node.childNodes.length) {
			node = node.firstChild;
		} else if (node.nextSibling) {
			if (limiter && node === limiter) {
				node = undefined;
				break;
			}
			node = node.nextSibling;
		} else {
			while (node) {
				node = node.parentNode;
				if (limiter && node === limiter) {
					node = undefined;
					break;
				}
				if (node && node.nextSibling) {
					node = node.nextSibling;
					break;
				}
			}
		}
	}
}

/**
 * Finds the next significant node after the given node, optionally descending into children.
 * Returns undefined if the limiter node is reached.
 *
 * @param {Node} node - The reference node.
 * @param {Node} [limiter] - Optional node at which traversal stops.
 * @param {boolean} [descend=false] - Whether to descend into child nodes.
 * @returns {Node|undefined} The next significant node or undefined if none found.
 */
export function nodeAfter(node, limiter, descend = false) {
	if (limiter && node === limiter) {
		return;
	}
	if (descend && node.childNodes.length) {
		let child = node.firstChild;
		if (isIgnorable(child)) {
			child = nextSignificantNode(child);
		}
		if (child) {
			return child;
		}
	}
	let significantNode = nextSignificantNode(node);
	if (significantNode) {
		return significantNode;
	}
	if (node.parentNode) {
		while ((node = node.parentNode)) {
			if (limiter && node === limiter) {
				return;
			}
			significantNode = nextSignificantNode(node);
			if (significantNode) {
				return significantNode;
			}
		}
	}
}

/**
 * Finds the last significant descendant of a node.
 * Descends the last child path, skipping ignorable nodes.
 *
 * @param {Node} node - The node to find the last significant descendant for.
 * @returns {Node} The last significant descendant node.
 * @private
 */
function findLastSignificantDescendant(node) {
	let done = false;

	while (!done) {
		let child = node.lastChild;
		if (child && isIgnorable(child)) {
			child = previousSignificantNode(child);
		}
		if (child && isElement(child)) {
			node = child;
		} else {
			done = true;
		}
	}

	return node;
}

/**
 * Finds the previous significant node before the given node, optionally descending into children.
 * Returns undefined if the limiter node is reached.
 *
 * @param {Node} node - The reference node.
 * @param {Node} [limiter] - Optional node at which traversal stops.
 * @param {boolean} [descend=false] - Whether to descend into child nodes.
 * @returns {Node|undefined} The previous significant node or undefined if none found.
 */
export function nodeBefore(node, limiter, descend = false) {
	do {
		if (limiter && node === limiter) {
			return;
		}

		let significantNode = previousSignificantNode(node);
		if (significantNode) {
			if (descend) {
				significantNode = findLastSignificantDescendant(significantNode);
			}
			return significantNode;
		}

		node = node.parentNode;
	} while (node);
}

/**
 * Finds the next Element node after the given node.
 * Skips non-element nodes.
 *
 * @param {Node} node - The reference node.
 * @param {Node} [limiter] - Optional node at which traversal stops.
 * @param {boolean} [descend=false] - Whether to descend into child nodes.
 * @returns {Element|undefined} The next Element node or undefined if none found.
 */
export function elementAfter(node, limiter, descend = false) {
	let after = nodeAfter(node, limiter, descend);

	while (after && after.nodeType !== 1) {
		after = nodeAfter(after, limiter, descend);
	}

	return after;
}

/**
 * Finds the previous Element node before the given node.
 * Skips non-element nodes.
 *
 * @param {Node} node - The reference node.
 * @param {Node} [limiter] - Optional node at which traversal stops.
 * @param {boolean} [descend=false] - Whether to descend into child nodes.
 * @returns {Element|undefined} The previous Element node or undefined if none found.
 */
export function elementBefore(node, limiter, descend = false) {
	let before = nodeBefore(node, limiter, descend);

	while (before && before.nodeType !== 1) {
		before = nodeBefore(before, limiter, descend);
	}

	return before;
}

/**
 * Finds the next displayed Element node after the given node.
 * Skips elements marked as undisplayed via `dataset.undisplayed`.
 *
 * @param {Node} node - The reference node.
 * @param {Node} [limiter] - Optional node at which traversal stops.
 * @param {boolean} [descend=false] - Whether to descend into child nodes.
 * @returns {Element|undefined} The next displayed Element node or undefined if none found.
 */
export function displayedElementAfter(node, limiter, descend = false) {
	let after = elementAfter(node, limiter, descend);

	while (after && after.dataset.undisplayed) {
		after = elementAfter(after, limiter, descend);
	}

	return after;
}

/**
 * Finds the previous displayed Element node before the given node.
 * Skips elements marked as undisplayed via `dataset.undisplayed`.
 *
 * @param {Node} node - The reference node.
 * @param {Node} [limiter] - Optional node at which traversal stops.
 * @param {boolean} [descend=false] - Whether to descend into child nodes.
 * @returns {Element|undefined} The previous displayed Element node or undefined if none found.
 */
export function displayedElementBefore(node, limiter, descend = false) {
	let before = elementBefore(node, limiter, descend);

	while (before && before.dataset.undisplayed) {
		before = elementBefore(before, limiter, descend);
	}

	return before;
}

/**
 * Recursively builds a stack (array) of a node and all its descendant elements,
 * in depth-first order, starting with the current node at the front.
 *
 * @param {Element} currentNode - The current node to add and process.
 * @param {Element[]} [stacked] - The accumulator array to hold stacked nodes.
 * @returns {Element[]} An array with the current node and all descendants stacked.
 */
export function stackChildren(currentNode, stacked) {
	let stack = stacked || [];

	stack.unshift(currentNode);

	let children = currentNode.children;
	for (var i = 0, length = children.length; i < length; i++) {
		stackChildren(children[i], stack);
	}

	return stack;
}

/**
 * Copies the width style from an original element to a destination element.
 * The width is computed using getComputedStyle and fallback bounding rect if needed.
 *
 * @param {Element} originalElement - The element to copy width from.
 * @param {Element} destElement - The element to apply the copied width to.
 */
function copyWidth(originalElement, destElement) {
	let originalStyle = getComputedStyle(originalElement);
	let bounds = getBoundingClientRect(originalElement);
	let width = parseInt(originalStyle.width || bounds.width);
	if (width) {
		destElement.style.width = width + "px";
	}
}

/**
 * Rebuilds a table row element by cloning and adjusting its columns, including handling rowspans.
 * Uses an existing rendered DOM tree to maintain styles and structure.
 *
 * @param {HTMLTableRowElement} node - The table row element to rebuild.
 * @param {Element} alreadyRendered - The root element containing the already rendered content for reference.
 * @param {number} [existingChildren] - Number of existing children in the container (optional).
 * @returns {HTMLTableRowElement} A new cloned and rebuilt table row element.
 */
export function rebuildTableRow(node, alreadyRendered, existingChildren) {
	let currentCol = 0,
		maxCols = 0,
		nextInitialColumn = 0;
	let rebuilt = node.cloneNode(false);
	const initialColumns = Array.from(node.children);

	// Find the max number of columns.
	let earlierRow = node.parentElement.children[0];
	while (earlierRow && earlierRow !== node) {
		if (earlierRow.children.length > maxCols) {
			maxCols = earlierRow.children.length;
		}
		earlierRow = earlierRow.nextElementSibling;
	}

	if (!maxCols) {
		let existing = findElement(node, alreadyRendered);
		maxCols = existing?.children.length || 0;
	}

	// The next td to use in each tr.
	// Doesn't take account of rowspans above that might make extra columns.
	let rowOffsets = Array(maxCols).fill(0);

	// Duplicate rowspans and our initial columns.
	while (currentCol < maxCols) {
		let earlierRow = node.parentElement.children[0];
		let earlierRowIndex = 0;
		let rowspan, column;
		// Find the nth column we'll duplicate (rowspan) or use.
		while (earlierRow && earlierRow !== node) {
			if (rowspan == undefined) {
				column =
					earlierRow.children[currentCol - rowOffsets[nextInitialColumn]];
				if (column && column.rowSpan !== undefined && column.rowSpan > 1) {
					rowspan = column.rowSpan;
				}
			}
			// If rowspan === 0 the entire remainder of the table row is used.
			if (rowspan) {
				// Tracking how many rows in the overflow.
				if (rowspan < 2) {
					rowspan = undefined;
				} else {
					rowspan--;
				}
			}
			earlierRow = earlierRow.nextElementSibling;
			earlierRowIndex++;
		}

		let destColumn;
		if (rowspan) {
			if (!existingChildren) {
				destColumn = column.cloneNode(false);
				// Adjust rowspan value.
				destColumn.rowSpan = !column.rowSpan ? 0 : rowspan;
			}
		} else {
			// Fill the gap with the initial columns (if exists).
			destColumn = column =
				initialColumns[nextInitialColumn++]?.cloneNode(false);
		}
		if (column && destColumn) {
			if (alreadyRendered) {
				let existing = findElement(column, alreadyRendered);
				if (existing) {
					column = existing;
				}
			}
			copyWidth(column, destColumn);
			if (destColumn) {
				rebuilt.appendChild(destColumn);
			}
		}
		currentCol++;
	}
	return rebuilt;
}

/**
 * Rebuilds the ancestor tree for a given node, appending clones or existing elements to a document fragment.
 * Handles table rows, siblings duplication, and other special cases.
 *
 * @param {Node} node - The starting node for rebuilding.
 * @param {DocumentFragment} [fragment] - Optional document fragment to append rebuilt nodes to. Created if omitted.
 * @param {Element} [alreadyRendered] - Root element with already rendered DOM for reference and style copying.
 * @returns {DocumentFragment} The fragment containing the rebuilt ancestor tree.
 */
export function rebuildTree(node, fragment, alreadyRendered) {
	let parent, subject;
	let ancestors = [];
	let added = [];
	let dupSiblings = false;
	let freshPage = !fragment;
	let numListItems = 0;

	if (!fragment) {
		fragment = document.createDocumentFragment();
	}

	// Gather all ancestors
	let element = node;

	if (!isText(node)) {
		ancestors.unshift(node);
		if (node.tagName == "LI") {
			numListItems++;
		}
	}
	while (element.parentNode && element.parentNode.nodeType === 1) {
		ancestors.unshift(element.parentNode);
		if (element.parentNode.tagName == "LI") {
			numListItems++;
		}
		element = element.parentNode;
	}

	for (var i = 0; i < ancestors.length; i++) {
		subject = ancestors[i];

		let container, split;
		if (added.length) {
			container = added[added.length - 1];
		} else {
			container = fragment;
		}

		if (subject.nodeName == "TR") {
			parent = findElement(subject, container);
			if (!parent) {
				parent = rebuildTableRow(
					subject,
					alreadyRendered,
					container.childElementCount,
				);
				container.appendChild(parent);
			}
		} else if (dupSiblings) {
			let sibling = subject.parentElement
				? subject.parentElement.children[0]
				: subject;

			while (sibling) {
				let existing = findElement(sibling, container),
					siblingClone;
				if (!existing) {
					let split = inIndexOfRefs(subject, alreadyRendered);
					siblingClone = cloneNodeAncestor(sibling);
					if (alreadyRendered) {
						let originalElement = findElement(sibling, alreadyRendered);
						if (originalElement) {
							copyWidth(originalElement, siblingClone);
						}
					}
					container.appendChild(siblingClone);
				}

				if (sibling == subject) {
					parent = siblingClone || existing;
				}
				sibling = sibling.nextElementSibling;
			}
		} else {
			parent = findElement(subject, container);
			if (!parent) {
				parent = cloneNodeAncestor(subject);
				if (alreadyRendered) {
					let originalElement = findElement(subject, alreadyRendered);
					if (originalElement) {
						copyWidth(originalElement, parent);

						// Colgroup to clone?
						Array.from(originalElement.children).forEach((child) => {
							if (child.tagName == "COLGROUP") {
								parent.append(child.cloneNode(true));
							}
						});
					}
				}
				container.appendChild(parent);
			}
		}

		if (subject.previousElementSibling?.nodeName == "THEAD") {
			// Clone the THEAD too.
			let sibling = subject.previousElementSibling;

			let existing = findElement(sibling, container),
				siblingClone;
			if (!existing) {
				siblingClone = cloneNodeAncestor(sibling, true);
				if (alreadyRendered) {
					let originalElement = findElement(sibling, alreadyRendered);
					if (originalElement) {
						let walker = walk(siblingClone, siblingClone);
						let next, pos, done;
						while (!done) {
							next = walker.next();
							pos = next.value;
							done = next.done;

							if (isElement(pos)) {
								originalElement = findElement(pos, alreadyRendered);
								copyWidth(originalElement, pos);

								// I've tried to make the THEAD invisible; this is the best
								// I could achieve. It gets a zero height but still somehow
								// affects the container height by a couple of pixels in my
								// testing. :(
								// Next step is to change the "true" below to use a custom
								// attribute that lets you control whether the header is shown.
								if (true) {
									pos.style.visibility = "collapse";
									pos.style.marginTop = "0px";
									pos.style.marginBottom = "0px";
									pos.style.paddingTop = "0px";
									pos.style.paddingBottom = "0px";
									pos.style.borderTop = "0px";
									pos.style.borderBottom = "0px";
									pos.style.lineHeight = "0px";
									pos.style.opacity = 0;
								}
							}
						}
					}
				}
				container.insertBefore(siblingClone, container.firstChild);
			}

			if (sibling == subject) {
				parent = siblingClone || existing;
			}
			sibling = sibling.nextElementSibling;
		}

		split = inIndexOfRefs(subject, alreadyRendered);
		if (split) {
			setSplit(split, parent);
		}

		dupSiblings =
			subject.dataset.clonesiblings == true ||
			["grid", "flex", "table-row"].indexOf(subject.style.display) > -1;
		added.push(parent);

		if (subject.tagName == "LI") {
			numListItems--;
		}

		if (freshPage && (isText(node) || numListItems)) {
			// Flag the first node on the page so we can suppress list styles on
			// a continued item and list item numbers except the list one
			// if an item number should be printed.
			parent.dataset.suppressListStyle = true;
		}
	}

	added = undefined;
	return fragment;
}
/**
 * Sets split attributes between original and clone elements for table splitting.
 *
 * @param {HTMLElement} orig The original element to be split.
 * @param {HTMLElement} clone The cloned element that receives attributes.
 */
function setSplit(orig, clone) {
	if (orig.dataset.splitTo) {
		clone.setAttribute("data-split-from", clone.getAttribute("data-ref"));
	}

	// This will let us split a table with multiple columns correctly.
	orig.setAttribute("data-split-to", clone.getAttribute("data-ref"));
}

/**
 * Clones a node and removes certain attributes like 'id' and break-related attributes.
 *
 * @param {Node} node The node to clone.
 * @param {boolean} [deep=false] Whether to perform a deep clone.
 * @returns {Node} The cloned node with adjusted attributes.
 */
function cloneNodeAncestor(node, deep = false) {
	let result = node.cloneNode(deep);

	if (result.hasAttribute("id")) {
		let dataID = result.getAttribute("id");
		result.setAttribute("data-id", dataID);
		result.removeAttribute("id");
	}

	// This is handled by css :not, but also tidied up here
	if (result.hasAttribute("data-break-before")) {
		result.removeAttribute("data-break-before");
	}

	if (result.hasAttribute("data-previous-break-after")) {
		result.removeAttribute("data-previous-break-after");
	}

	return result;
}

/**
 * Rebuilds the ancestor tree of a given node as a document fragment.
 *
 * @param {Node} node The node for which ancestors are rebuilt.
 * @returns {DocumentFragment} A fragment containing cloned ancestors of the node.
 */
export function rebuildAncestors(node) {
	let parent, ancestor;
	let ancestors = [];
	let added = [];

	let fragment = document.createDocumentFragment();

	// Gather all ancestors
	let element = node;
	while (element.parentNode && element.parentNode.nodeType === 1) {
		ancestors.unshift(element.parentNode);
		element = element.parentNode;
	}

	for (var i = 0; i < ancestors.length; i++) {
		ancestor = ancestors[i];
		parent = ancestor.cloneNode(false);

		parent.setAttribute("data-split-from", parent.getAttribute("data-ref"));

		if (parent.hasAttribute("id")) {
			let dataID = parent.getAttribute("id");
			parent.setAttribute("data-id", dataID);
			parent.removeAttribute("id");
		}

		// This is handled by css :not, but also tidied up here
		if (parent.hasAttribute("data-break-before")) {
			parent.removeAttribute("data-break-before");
		}

		if (parent.hasAttribute("data-previous-break-after")) {
			parent.removeAttribute("data-previous-break-after");
		}

		if (added.length) {
			let container = added[added.length - 1];
			container.appendChild(parent);
		} else {
			fragment.appendChild(parent);
		}
		added.push(parent);

		// rebuild table rows
		if (parent.nodeName === "TD" && ancestor.parentElement.contains(ancestor)) {
			let td = ancestor;
			let prev = parent;
			while ((td = td.previousElementSibling)) {
				let sib = td.cloneNode(false);
				parent.parentElement.insertBefore(sib, prev);
				prev = sib;
			}
		}
	}

	added = undefined;
	return fragment;
}
/*
export function split(bound, cutElement, breakAfter) {
		let needsRemoval = [];
		let index = indexOf(cutElement);

		if (!breakAfter && index === 0) {
			return;
		}

		if (breakAfter && index === (cutElement.parentNode.children.length - 1)) {
			return;
		}

		// Create a fragment with rebuilt ancestors
		let fragment = rebuildTree(cutElement);

		// Clone cut
		if (!breakAfter) {
			let clone = cutElement.cloneNode(true);
			let ref = cutElement.parentNode.getAttribute('data-ref');
			let parent = fragment.querySelector("[data-ref='" + ref + "']");
			parent.appendChild(clone);
			needsRemoval.push(cutElement);
		}

		// Remove all after cut
		let next = nodeAfter(cutElement, bound);
		while (next) {
			let clone = next.cloneNode(true);
			let ref = next.parentNode.getAttribute('data-ref');
			let parent = fragment.querySelector("[data-ref='" + ref + "']");
			parent.appendChild(clone);
			needsRemoval.push(next);
			next = nodeAfter(next, bound);
		}

		// Remove originals
		needsRemoval.forEach((node) => {
			if (node) {
				node.remove();
			}
		});

		// Insert after bounds
		bound.parentNode.insertBefore(fragment, bound.nextSibling);
		return [bound, bound.nextSibling];
}
*/

/**
 * Checks if a node requires a break before it according to dataset.breakBefore attribute.
 *
 * @param {Node} node The node to check.
 * @returns {boolean} True if a break before is needed, false otherwise.
 */
export function needsBreakBefore(node) {
	if (
		typeof node !== "undefined" &&
		typeof node.dataset !== "undefined" &&
		typeof node.dataset.breakBefore !== "undefined" &&
		(node.dataset.breakBefore === "always" ||
			node.dataset.breakBefore === "page" ||
			node.dataset.breakBefore === "left" ||
			node.dataset.breakBefore === "right" ||
			node.dataset.breakBefore === "recto" ||
			node.dataset.breakBefore === "verso")
	) {
		return true;
	}

	return false;
}

/**
 * Checks if a node requires a break before it according to dataset.breakBefore attribute.
 *
 * @param {Node} node The node to check.
 * @returns {boolean} True if a break before is needed, false otherwise.
 */
export function needsBreakAfter(node) {
	if (
		typeof node !== "undefined" &&
		typeof node.dataset !== "undefined" &&
		typeof node.dataset.breakAfter !== "undefined" &&
		(node.dataset.breakAfter === "always" ||
			node.dataset.breakAfter === "page" ||
			node.dataset.breakAfter === "left" ||
			node.dataset.breakAfter === "right" ||
			node.dataset.breakAfter === "recto" ||
			node.dataset.breakAfter === "verso")
	) {
		return true;
	}

	return false;
}

/**
 * Checks if a node's previous sibling requires a break after it according to dataset.previousBreakAfter attribute.
 *
 * @param {Node} node The node to check.
 * @returns {boolean} True if the previous break after is needed, false otherwise.
 */
export function needsPreviousBreakAfter(node) {
	if (
		typeof node !== "undefined" &&
		typeof node.dataset !== "undefined" &&
		typeof node.dataset.previousBreakAfter !== "undefined" &&
		(node.dataset.previousBreakAfter === "always" ||
			node.dataset.previousBreakAfter === "page" ||
			node.dataset.previousBreakAfter === "left" ||
			node.dataset.previousBreakAfter === "right" ||
			node.dataset.previousBreakAfter === "recto" ||
			node.dataset.previousBreakAfter === "verso")
	) {
		return true;
	}

	return false;
}

/**
 * Determines if a page break is needed between the given node and the previous significant node.
 *
 * @param {Node} node The current node.
 * @param {Node} previousSignificantNode The previous significant node.
 * @returns {boolean} True if a page break is needed, false otherwise.
 */
export function needsPageBreak(node, previousSignificantNode) {
	if (
		typeof node === "undefined" ||
		!previousSignificantNode ||
		isIgnorable(node)
	) {
		return false;
	}
	if (node.dataset && node.dataset.undisplayed) {
		return false;
	}
	let previousSignificantNodePage = previousSignificantNode.dataset
		? previousSignificantNode.dataset.page
		: undefined;
	if (typeof previousSignificantNodePage === "undefined") {
		const nodeWithNamedPage = getNodeWithNamedPage(previousSignificantNode);
		if (nodeWithNamedPage) {
			previousSignificantNodePage = nodeWithNamedPage.dataset.page;
		}
	}
	let currentNodePage = node.dataset ? node.dataset.page : undefined;
	if (typeof currentNodePage === "undefined") {
		const nodeWithNamedPage = getNodeWithNamedPage(
			node,
			previousSignificantNode,
		);
		if (nodeWithNamedPage) {
			currentNodePage = nodeWithNamedPage.dataset.page;
		}
	}
	return currentNodePage !== previousSignificantNodePage;
}

/**
 * Generator function to yield word ranges from a text node.
 *
 * @param {Text} node The text node to extract words from.
 * @yields {Range} A Range object for each word found.
 */
export function* words(node) {
	let currentText = node.nodeValue;
	let max = currentText.length;
	let currentOffset = 0;
	let currentLetter;

	let range;
	const significantWhitespaces =
		node.parentElement && node.parentElement.nodeName === "PRE";

	while (currentOffset < max) {
		currentLetter = currentText[currentOffset];
		if (/^[\S\u202F\u00A0]$/.test(currentLetter) || significantWhitespaces) {
			if (!range) {
				range = document.createRange();
				range.setStart(node, currentOffset);
			}
		} else {
			if (range) {
				range.setEnd(node, currentOffset);
				yield range;
				range = undefined;
			}
		}

		currentOffset += 1;
	}

	if (range) {
		range.setEnd(node, currentOffset);
		yield range;
	}
}

/**
 * Generator function to yield letter ranges from a word range.
 *
 * @param {Range} wordRange The Range object representing a word.
 * @yields {Range} A Range object for each letter in the word.
 */
export function* letters(wordRange) {
	let currentText = wordRange.startContainer;
	let max = currentText.length;
	let currentOffset = wordRange.startOffset;
	// let currentLetter;

	let range;

	while (currentOffset < max) {
		// currentLetter = currentText[currentOffset];
		range = document.createRange();
		range.setStart(currentText, currentOffset);
		range.setEnd(currentText, currentOffset + 1);

		yield range;

		currentOffset += 1;
	}
}
/**
 * Determines if a node is considered a container (block) element.
 *
 * @param {Node} node The node to check.
 * @returns {boolean} True if the node is a container, false if it is inline or hidden.
 */
export function isContainer(node) {
	let container;

	if (typeof node.tagName === "undefined") {
		return true;
	}

	if (node.style && node.style.display === "none") {
		return false;
	}

	switch (node.tagName) {
		// Inline
		case "A":
		case "ABBR":
		case "ACRONYM":
		case "B":
		case "BDO":
		case "BIG":
		case "BR":
		case "BUTTON":
		case "CITE":
		case "CODE":
		case "DFN":
		case "EM":
		case "I":
		case "IMG":
		case "INPUT":
		case "KBD":
		case "LABEL":
		case "MAP":
		case "OBJECT":
		case "Q":
		case "SAMP":
		case "SCRIPT":
		case "SELECT":
		case "SMALL":
		case "SPAN":
		case "STRONG":
		case "SUB":
		case "SUP":
		case "TEXTAREA":
		case "TIME":
		case "TT":
		case "VAR":
		case "P":
		case "H1":
		case "H2":
		case "H3":
		case "H4":
		case "H5":
		case "H6":
		case "FIGCAPTION":
		case "BLOCKQUOTE":
		case "PRE":
		case "LI":
		case "TD":
		case "DT":
		case "DD":
		case "VIDEO":
		case "CANVAS":
			container = false;
			break;
		default:
			container = true;
	}

	return container;
}

/**
 * Clones a node.
 *
 * @param {Node} n The node to clone.
 * @param {boolean} [deep=false] Whether to clone deeply.
 * @returns {Node} The cloned node.
 */
export function cloneNode(n, deep = false) {
	return n.cloneNode(deep);
}

/**
 * Retrieves the index of a node's reference in the document's indexOfRefs.
 *
 * @param {Node} node The node with a data-ref attribute.
 * @param {Document} doc The document containing indexOfRefs.
 * @returns {number|undefined} The index of the reference, or undefined if not found.
 */
export function inIndexOfRefs(node, doc) {
	if (!doc || !doc.indexOfRefs) return;
	const ref = node.getAttribute("data-ref");
	return doc.indexOfRefs[ref];
}

/**
 * Replaces a child element in the parent node if a child with the same data-ref exists,
 * otherwise appends the child.
 *
 * @param {HTMLElement} parentNode The parent element.
 * @param {Node} child The child element to replace or append.
 */
export function replaceOrAppendElement(parentNode, child) {
	if (!isText(child)) {
		let childRef = child.getAttribute("data-ref");
		for (let index = 0; index < parentNode.children.length; index++) {
			if (parentNode.children[index].getAttribute("data-ref") == childRef) {
				parentNode.replaceChild(child, parentNode.childNodes[index]);
				return;
			}
		}
	}

	parentNode.appendChild(child);
}

/**
 * Finds an element in the document by the node's data-ref attribute.
 *
 * @param {Node} node The node with a data-ref attribute.
 * @param {Document} doc The document to search in.
 * @param {boolean} [forceQuery=false] Whether to force a querySelector search.
 * @returns {Element|undefined} The found element or undefined.
 */
export function findElement(node, doc, forceQuery) {
	if (!doc) return;
	const ref = node.getAttribute("data-ref");
	return findRef(ref, doc, forceQuery);
}

/**
 * Finds an element in the document by data-ref value.
 *
 * @param {string} ref The data-ref string to find.
 * @param {Document} doc The document to search in.
 * @param {boolean} [forceQuery=false] Whether to force querySelector search.
 * @returns {Element|null} The found element or null.
 */
export function findRef(ref, doc, forceQuery) {
	if (!forceQuery && doc.indexOfRefs && doc.indexOfRefs[ref]) {
		return doc.indexOfRefs[ref];
	} else {
		return doc.querySelector(`[data-ref='${ref}']`);
	}
}

/**
 * Validates if a node is either a text node or an element with a data-ref attribute.
 *
 * @param {Node} node The node to validate.
 * @returns {boolean} True if valid, false otherwise.
 */
export function validNode(node) {
	if (isText(node)) {
		return true;
	}

	if (isElement(node) && node.dataset.ref) {
		return true;
	}

	return false;
}

/**
 * Finds the previous valid node in the sibling/parent chain.
 *
 * @param {Node} node The starting node.
 * @returns {Node|null} The previous valid node or null.
 */
export function prevValidNode(node) {
	while (!validNode(node)) {
		if (node.previousSibling) {
			node = node.previousSibling;
		} else {
			node = node.parentNode;
		}

		if (!node) {
			break;
		}
	}

	return node;
}
/**
 * Finds the next valid node in the sibling/parent chain.
 *
 * @param {Node} node The starting node.
 * @returns {Node|null} The next valid node or null.
 */
export function nextValidNode(node) {
	while (!validNode(node)) {
		if (node.nextSibling) {
			node = node.nextSibling;
		} else {
			node = node.parentNode.nextSibling;
		}

		if (!node) {
			break;
		}
	}

	return node;
}
/**
 * Gets the index of a node among its siblings.
 *
 * @param {Node} node The node to find the index for.
 * @returns {number} The index of the node.
 */
export function indexOf(node) {
	let parent = node.parentNode;
	if (!parent) {
		return 0;
	}
	return Array.prototype.indexOf.call(parent.childNodes, node);
}
/**
 * Returns the child node at a specific index.
 *
 * @param {Node} node The parent node.
 * @param {number} index The index of the child.
 * @returns {Node} The child node.
 */
export function child(node, index) {
	return node.childNodes[index];
}

/**
 * Checks if a node is visible (not display:none).
 *
 * @param {Node} node The node to check.
 * @returns {boolean} True if visible, false otherwise.
 */
export function isVisible(node) {
	if (isElement(node) && window.getComputedStyle(node).display !== "none") {
		return true;
	} else if (
		isText(node) &&
		hasTextContent(node) &&
		window.getComputedStyle(node.parentNode).display !== "none"
	) {
		return true;
	}
	return false;
}

/**
 * Checks if a node has any content.
 * Returns true for element nodes or non-empty text nodes.
 *
 * @param {Node} node The node to check.
 * @returns {boolean} True if the node has content, false otherwise.
 */
export function hasContent(node) {
	if (isElement(node)) {
		return true;
	} else if (isText(node) && node.textContent.trim().length) {
		return true;
	}
	return false;
}

/**
 * Checks if a node or any of its immediate child text nodes have non-empty text content.
 *
 * @param {Node} node The node to check.
 * @returns {boolean} True if the node or any child text node has non-empty text content, false otherwise.
 */
export function hasTextContent(node) {
	if (isElement(node)) {
		let child;
		for (var i = 0; i < node.childNodes.length; i++) {
			child = node.childNodes[i];
			if (child && isText(child) && child.textContent.trim().length) {
				return true;
			}
		}
	} else if (isText(node) && node.textContent.trim().length) {
		return true;
	}
	return false;
}

/**
 * Finds the index of a text node within its parent's child nodes.
 * If the text node has a previous sibling, tries to find the matching element by data-ref attribute
 * and returns its index + 1. Otherwise, matches by text content.
 * Optionally considers hyphenation removal in the text.
 *
 * @param {Node} node The text node to find the index for.
 * @param {Node} parent The parent node containing the child nodes.
 * @param {string} hyphen The hyphenation string to remove if present at the end of the text.
 * @returns {number} The index of the text node within the parent's child nodes, or -1 if not found.
 */
export function indexOfTextNode(node, parent, hyphen) {
	if (!isText(node)) {
		return -1;
	}

	// Use previous element's dataref to match if possible. Matching the text
	// will potentially return the wrong node.
	if (node.previousSibling) {
		let matchingNode = parent.querySelector(
			`[data-ref='${node.previousSibling.dataset.ref}']`,
		);
		return Array.prototype.indexOf.call(parent.childNodes, matchingNode) + 1;
	}

	let nodeTextContent = node.textContent;
	// Remove hyphenation if necessary.
	if (
		nodeTextContent.substring(nodeTextContent.length - hyphen.length) == hyphen
	) {
		nodeTextContent = nodeTextContent.substring(
			0,
			nodeTextContent.length - hyphen.length,
		);
	}
	let child;
	let index = -1;
	for (var i = 0; i < parent.childNodes.length; i++) {
		child = parent.childNodes[i];
		if (child.nodeType === 3) {
			let text = parent.childNodes[i].textContent;
			if (text.includes(nodeTextContent)) {
				index = i;
				break;
			}
		}
	}

	return index;
}

/**
 * Throughout, whitespace is defined as one of the characters
 *  "\t" TAB \u0009
 *  "\n" LF  \u000A
 *  "\r" CR  \u000D
 *  " "  SPC \u0020
 *
 * This does not use Javascript's "\s" because that includes non-breaking
 * spaces (and also some other characters).
 */

/**
 * Determine if a node should be ignored by the iterator functions.
 * taken from https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Whitespace#Whitespace_helper_functions
 *
 * @param {Node} node An object implementing the DOM1 |Node| interface.
 * @return {boolean} true if the node is:
 *  1) A |Text| node that is all whitespace
 *  2) A |Comment| node
 *  and otherwise false.
 */
export function isIgnorable(node) {
	return (
		node.nodeType === 8 || // A comment node
		(node.nodeType === 3 && isAllWhitespace(node))
	); // a text node, all whitespace
}

/**
 * Determine whether a node's text content is entirely whitespace.
 *
 * @param {Node} node  A node implementing the |CharacterData| interface (i.e., a |Text|, |Comment|, or |CDATASection| node
 * @return {boolean} true if all of the text content of |nod| is whitespace, otherwise false.
 */
export function isAllWhitespace(node) {
	return !/[^\t\n\r ]/.test(node.textContent);
}

/**
 * Version of |previousSibling| that skips nodes that are entirely
 * whitespace or comments.  (Normally |previousSibling| is a property
 * of all DOM nodes that gives the sibling node, the node that is
 * a child of the same parent, that occurs immediately before the
 * reference node.)
 *
 * @param {ChildNode} sib  The reference node.
 * @return {Node|null} Either:
 *  1) The closest previous sibling to |sib| that is not ignorable according to |is_ignorable|, or
 *  2) null if no such node exists.
 */
export function previousSignificantNode(sib) {
	while ((sib = sib.previousSibling)) {
		if (!isIgnorable(sib)) return sib;
	}
	return null;
}

/**
 * Finds the closest ancestor (including the node itself) that has a dataset.page attribute.
 * Traverses up the DOM tree until the optional limiter node is reached.
 *
 * @param {Node} node - The starting node to search from.
 * @param {Node} [limiter] - Optional ancestor node to stop the search at (exclusive).
 * @returns {Node|null|undefined} The closest node with dataset.page, null if none found,
 *                               or undefined if limiter is reached without finding.
 */
function getNodeWithNamedPage(node, limiter) {
	if (node && node.dataset && node.dataset.page) {
		return node;
	}
	if (node.parentNode) {
		while ((node = node.parentNode)) {
			if (limiter && node === limiter) {
				return;
			}
			if (node.dataset && node.dataset.page) {
				return node;
			}
		}
	}
	return null;
}

/**
 * Finds the closest ancestor of a node with a dataset.breakInside attribute equal to "avoid".
 * Traverses up the DOM tree until such a node is found or root is reached.
 *
 * @param {Node} node - The starting node to search from.
 * @returns {Node|null} The closest ancestor node with dataset.breakInside === "avoid",
 *                      or null if none found.
 */
export function breakInsideAvoidParentNode(node) {
	while ((node = node.parentNode)) {
		if (node && node.dataset && node.dataset.breakInside === "avoid") {
			return node;
		}
	}
	return null;
}

/**
 * Find a parent with a given node name.
 * @param {Node} node - initial Node
 * @param {string} nodeName - node name (eg. "TD", "TABLE", "STRONG"...)
 * @param {Node} limiter - go up to the parent until there's no more parent or the current node is equals to the limiter
 * @returns {Node|undefined} - Either:
 *  1) The closest parent for a the given node name, or
 *  2) undefined if no such node exists.
 */
export function parentOf(node, nodeName, limiter) {
	if (limiter && node === limiter) {
		return;
	}
	if (node.parentNode) {
		while ((node = node.parentNode)) {
			if (limiter && node === limiter) {
				return;
			}
			if (node.nodeName === nodeName) {
				return node;
			}
		}
	}
}

/**
 * Version of |nextSibling| that skips nodes that are entirely
 * whitespace or comments.
 *
 * @param {ChildNode} sib  The reference node.
 * @return {Node|null} Either:
 *  1) The closest next sibling to |sib| that is not ignorable according to |is_ignorable|, or
 *  2) null if no such node exists.
 */
export function nextSignificantNode(sib) {
	while ((sib = sib.nextSibling)) {
		if (!isIgnorable(sib)) return sib;
	}
	return null;
}

/**
 * Traverses a DOM subtree and removes nodes that match a filter function.
 *
 * @param {Node} content - The root node to start traversal from. If falsy, defaults to `this.dom`.
 * @param {function(Node): number} [func] - Optional filter function used by the TreeWalker.
 *        Should return one of the constants from NodeFilter:
 *        - NodeFilter.FILTER_ACCEPT to keep the node,
 *        - NodeFilter.FILTER_REJECT or FILTER_SKIP to exclude it.
 * @param {number} [what=NodeFilter.SHOW_ALL] - Optional mask specifying which node types to show.
 *        Defaults to all nodes.
 */
export function filterTree(content, func, what) {
	const treeWalker = document.createTreeWalker(
		content || this.dom,
		what || NodeFilter.SHOW_ALL,
		func ? { acceptNode: func } : null,
		false,
	);

	let node;
	let current;
	node = treeWalker.nextNode();
	while (node) {
		current = node;
		node = treeWalker.nextNode();
		current.parentNode.removeChild(current);
	}
}