Greasy Fork

DOM

Useful library for dealing with the DOM.

目前为 2020-06-26 提交的版本。查看 最新版本

此脚本不应直接安装,它是一个供其他脚本使用的外部库。如果您需要使用该库,请在脚本元属性加入:// @require https://update.greasyfork.icu/scripts/405802/820606/DOM.js

// ==UserScript==
// @name DOM
// @namespace https://rafaelgssa.gitlab.io/monkey-scripts
// @version 4.0.3
// @author rafaelgssa
// @description Useful library for dealing with the DOM.
// @match *://*/*
// @require https://greasyfork.org/scripts/405813-monkey-utils/code/Monkey%20Utils.js
// ==/UserScript==

/* global Utils */

/**
 * @typedef {(element?: Element) => void} ElementCallback
 *
 * @typedef {InsertPosition | 'atouter' | 'atinner'} ExtendedInsertPosition
 *
 * @typedef {ElementArrayConstructor<ElementArrayBase, 8>} ElementArray Any higher than 8 is too deep and does not work.
 *
 * **The definition for ElementArrayConstructor is in DOM.d.ts, as it is too complex for JSDoc:**
 * declare type ElementArrayConstructor<T extends [any, any] | string, N extends number> = T extends [
 *   infer A,
 *   infer B
 * ]
 *   ? {
 *       done: [A, B, string | null];
 *       recurse: [
 *         A,
 *         B,
 *         ElementArrayConstructor<ElementArrayBase, ElementArrayDepth[N]>[] | string | null
 *       ];
 *     }[N extends 0 ? 'done' : 'recurse']
 *   : T extends string
 *   ? T
 *   : never;
 *
 * @typedef {[never, 0, 1, 2, 3, 4, 5, 6, 7]} ElementArrayDepth
 *
 * @typedef {{ [K in ElementTag]: [K, ElementAttributes<K> | null] }[ElementTag] | string} ElementArrayBase
 *
 * @typedef {keyof HTMLElementTagNameMap} ElementTag
 *
 * @typedef {ElementArray[] | string} ElementArrayChildren
 *
 * @typedef {Object} MutationTypes
 * @property {boolean} [attributes]
 * @property {boolean} [childList]
 * @property {boolean} [subtree]
 *
 * @typedef {(node: Node) => void} NodeCallback
 */

/**
 * @template {ElementTag} T
 * @typedef {{ [K in keyof ExtendedElement<T>]?: Partial<ExtendedElement<T>[K]> | null }} ElementAttributes
 */

/**
 * @template {ElementTag} T
 * @typedef {HTMLElementTagNameMap[T] & {
 *   attrs: Record<string, string>;
 *   ref: NodeCallback;
 * }} ExtendedElement
 */

// eslint-disable-next-line
const DOM = (() => {
	const _parser = new DOMParser();

	/**
	 * Waits for an element.
	 * @param {string} selectors The selectors to query for the element.
	 * @param {number} timeout How long to wait for the element in seconds. Defaults to 60 (1 minute).
	 * @returns {Promise<Element | undefined>} The element, if found.
	 */
	const dynamicQuerySelector = (selectors, timeout = 60) => {
		return new Promise((resolve) => _checkElementExists(selectors, resolve, timeout));
	};

	/**
	 * @param {string} selectors
	 * @param {ElementCallback} callback
	 * @param {number} timeout
	 */
	const _checkElementExists = (selectors, callback, timeout = 60) => {
		const element = document.querySelector(selectors);
		if (element) {
			callback(element);
		} else if (timeout > 0) {
			window.setTimeout(_checkElementExists, 1000, selectors, callback, timeout - 1);
		} else {
			callback();
		}
	};

	/**
	 * Inserts elements in reference to another element based on element arrays.
	 * @param {Element} reference The element to use as reference.
	 * @param {ExtendedInsertPosition} position Where to insert the elements.
	 * @param {ElementArray[]} arrays The arrays to use.
	 * @returns {(HTMLElement | undefined)[]} The inserted elements from the root level, if successful.
	 *
	 * @example
	 * // 'pElement' will contain the P element.
	 * // 'elements' will be an array containing the DIV and the SPAN elements, in this order, if successful.
	 * let pElement;
	 * const elements = DOM.insertElement(document.body, 'beforeend', [
	 *   ['div', { className: 'hello', onclick: () => {} }, [
	 *     'Hello, ', // This is added as a text node.
	 *     ['p', { ref: (ref) => pElement = ref }, 'John'],
	 *     '!' // This is added as a text node.
	 *   ]],
	 *   ['span', null, 'How are you?']
	 * ]);
	 *
	 * @example
	 * // Using array destructuring.
	 * // 'divElement' will contain the DIV element and 'spanElement' will contain the SPAN element, if successful.
	 * let pElement;
	 * const [divElement, spanElement] = DOM.insertElement(document.body, 'beforeend', [
	 *   ['div', { className: 'hello', onclick: () => {} }, [
	 *     'Hello, ', // This is added as a text node.
	 *     ['p', { ref: (ref) => pElement = ref }, 'John'],
	 *     '!' // This is added as a text node.
	 *   ]],
	 *   ['span', null, 'How are you?']
	 * ]);
	 */
	const insertElements = (reference, position, arrays) => {
		const fragment = _buildFragment(arrays);
		if (!fragment) {
			return [];
		}
		const elements = /** @type {HTMLElement[]} */ (Array.from(fragment.children));
		const referenceParent = reference.parentElement;
		switch (position) {
			case 'beforebegin':
				if (referenceParent) {
					referenceParent.insertBefore(fragment, reference);
				}
				break;
			case 'afterbegin':
				reference.insertBefore(fragment, reference.firstElementChild);
				break;
			case 'beforeend':
				reference.appendChild(fragment);
				break;
			case 'afterend':
				if (referenceParent) {
					referenceParent.insertBefore(fragment, reference.nextElementSibling);
				}
				break;
			case 'atouter':
				if (referenceParent) {
					referenceParent.insertBefore(fragment, reference.nextElementSibling);
					reference.remove();
				}
				break;
			case 'atinner':
				reference.innerHTML = '';
				reference.appendChild(fragment);
				break;
			// no default
		}
		if (fragment.children.length > 0) {
			return [];
		}
		return elements;
	};

	/**
	 * Builds a fragment from element arrays.
	 * @param {ElementArray[]} arrays
	 * @returns {DocumentFragment | null} The built fragment, if successful.
	 */
	const _buildFragment = (arrays) => {
		if (!Array.isArray(arrays)) {
			return null;
		}
		// @ts-ignore
		const filteredArrays = arrays.filter(Utils.isSet);
		const fragment = document.createDocumentFragment();
		for (const array of filteredArrays) {
			const element = _buildElement(array);
			fragment.appendChild(element);
		}
		return fragment;
	};

	/**
	 * Builds an element from an element array.
	 * @param {ElementArray} array
	 * @returns {HTMLElement} The built element.
	 */
	const _buildElement = (array) => {
		if (typeof array === 'string') {
			const textNode = document.createTextNode(array);
			return /** @type {HTMLElement} */ (/** @type {unknown} */ (textNode));
		}
		const [tag, attributes, children] = array;
		const element = document.createElement(tag);
		if (attributes) {
			_setElementAttributes(element, attributes);
		}
		if (children) {
			_appendElementChildren(element, children);
		}
		return element;
	};

	/**
	 * Sets attributes for an element.
	 * @template {ElementTag} T
	 * @param {HTMLElement} element
	 * @param {ElementAttributes<T>} attributes
	 */
	const _setElementAttributes = (element, attributes) => {
		const filteredAttributes = Object.entries(attributes).filter(([, value]) => Utils.isSet(value));
		for (const [key, value] of filteredAttributes) {
			if (key === 'attrs' && typeof value === 'object') {
				_setCustomElementAttributes(element, value);
			} else if (key === 'ref' && typeof value === 'function') {
				value(element);
			} else if (key.startsWith('on') && typeof value === 'function') {
				const eventType = key.slice(2);
				element.addEventListener(eventType, value);
			} else if (typeof value === 'object') {
				_setElementProperties(element, key, value);
			} else {
				// @ts-ignore
				element[key] = value;
			}
		}
	};

	/**
	 * Sets custom attributes for an element.
	 * @template {ElementTag} T
	 * @param {HTMLElement} element
	 * @param {ElementAttributes<T>} attributes
	 */
	const _setCustomElementAttributes = (element, attributes) => {
		const filteredAttributes = Object.entries(attributes).filter(([, value]) => Utils.isSet(value));
		for (const [key, value] of filteredAttributes) {
			element.setAttribute(key, value);
		}
	};

	/**
	 * Sets properties for the attribute of an element.
	 * @param {HTMLElement} element
	 * @param {string} attribute
	 * @param {Object} properties
	 */
	const _setElementProperties = (element, attribute, properties) => {
		const filteredProperties = Object.entries(properties).filter(([, value]) => Utils.isSet(value));
		for (const [key, value] of filteredProperties) {
			// @ts-ignore
			element[attribute][key] = value;
		}
	};

	/**
	 * Appends children to an element from an element array.
	 * @param {HTMLElement} element
	 * @param {ElementArrayChildren} children
	 */
	const _appendElementChildren = (element, children) => {
		if (Array.isArray(children)) {
			const fragment = _buildFragment(children);
			if (fragment) {
				element.appendChild(fragment);
			}
		} else if (typeof children === 'string') {
			const textNode = document.createTextNode(children);
			element.appendChild(textNode);
		}
	};

	/**
	 * Observes a node for mutations.
	 * @param {Node} node The node to observe.
	 * @param {MutationTypes | null} types The types of mutations to observe. Defaults to child list of the node and all its descendants.
	 * @param {NodeCallback} callback The callback to call with each updated / added node.
	 * @returns {MutationObserver} The observer.
	 */
	const observeNode = (node, types, callback) => {
		const observer = new MutationObserver((mutations) =>
			_processNodeMutations(mutations, callback)
		);
		observer.observe(
			node,
			types || {
				childList: true,
				subtree: true,
			}
		);
		return observer;
	};

	/**
	 * @param {MutationRecord[]} mutations
	 * @param {NodeCallback} callback
	 */
	const _processNodeMutations = (mutations, callback) => {
		for (const mutation of mutations) {
			if (mutation.type === 'attributes') {
				callback(mutation.target);
			} else {
				mutation.addedNodes.forEach(callback);
			}
		}
	};

	/**
	 * Parses an HTML string into a DOM.
	 * @param {string} html The HTML string to parse.
	 * @returns {Document} The parsed DOM.
	 */
	const parse = (html) => {
		return _parser.parseFromString(html, 'text/html');
	};

	return {
		dynamicQuerySelector,
		insertElements,
		observeNode,
		parse,
	};
})();