wait-for-element

Provides utility functions to get and wait for elements asyncronously that are not yet loaded or available on the page.

目前為 2024-02-24 提交的版本,檢視 最新版本

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.cn-greasyfork.org/scripts/488161/1332704/wait-for-element.js

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         wait-for-element
// @description  Provides utility functions to get and wait for elements asyncronously that are not yet loaded or available on the page.
// @version      0.0.1
// @namespace    owowed.moe
// @author       owowed <[email protected]>
// @require      https://update.greasyfork.org/scripts/488160/1332703/make-mutation-observer.js
// @license      LGPL-3.0
// ==/UserScript==

/**
 * @typedef WaitForElementOptions
 * @prop {string} [id] - when used, it will select element by ID, and it will use the `document` as the selector parent.
 * @prop {string | string[]} selector - the selector for the element. If `selector` is `string[]`, then `multiple` option is automatically enabled. Setting `multiple` option to `false` will not have effect at all.
 * @prop {ParentNode} [parent] - when used, it will instead use `parent` parent element as the selector parent. This option will specify/limit the scope of query selector from `parent` parent element. This may be useful for optimizing selecting element.
 * @prop {AbortSignal} [abortSignal] - when used, user may able to abort waiting for element by using `AbortSignal#abort()`.
 * @prop {boolean} [multiple] - when used, `waitForElement*` will act as `ParentNode#querySelectorAll()`.
 * @prop {number} [timeout] - will set wait for element timeout. Default timeout is 5 seconds.
 * @prop {boolean} [enableTimeout] - if timeout set by `timeout` reached, `waitForElement*` will throw `WaitForElementTimeoutError`.
 * @prop {number} [maxTries] - will set how many attempt `waitForElement*` will query select, and if it reached, it will throw `WaitForElementMaximumTriesError`.
 * @prop {boolean} [ensureDomContentLoaded] - ensure DOM content loaded by listening to `DOMContentLoad` event, and then execute by that, or checking by using `document.readyState`.
 * @prop {MutationObserverInit} [observerOptions] - set options for `MutationObserver` used in `waitForElement*`.
 * @prop {(elem: HTMLElement) => boolean} [filter] - filter multiple or single element before being returned.
 * @prop {(elem: HTMLElement) => HTMLElement} [transform] - transform or modify multiple or single element before being returned.
 */

/**
 * @typedef {Element[] | Element | null} WaitForElementReturnValue
 */

class WaitForElementError extends Error {
    name = this.constructor.name;
}
class WaitForElementTimeoutError extends WaitForElementError {}
class WaitForElementMaximumTriesError extends WaitForElementError {}

/**
 * Wait for element asyncronously until the element is available on the page. This function immediately accept `parent` as its first parameter. `parent` parameter will specify/limit the scope of query selector. This may be useful for optimizing selecting element.
 * @param {NonNullable<WaitForElementOptions["parent"]>} parent 
 * @param {WaitForElementOptions["selector"]} selector 
 * @param {WaitForElementOptions} options 
 * @returns {WaitForElementReturnValue}
 */
function waitForElementByParent(parent, selector, options) {
    return waitForElementOptions({ selector, parent, ...options });
}

/**
 * Wait for element asyncronously until the element is available on the page. This function immediately accept `selector` as its first parameter.
 * @param {WaitForElementOptions["selector"]} selector 
 * @param {WaitForElementOptions} options 
 * @returns {WaitForElementReturnValue}
 */
function waitForElement(selector, options) {
    return waitForElementOptions({ selector, ...options });
}

/**
 * Wait for element asyncronously until the element is available on the page.
 * @param {WaitForElementOptions} options 
 * @returns {WaitForElementReturnValue}
 */
function waitForElementOptions(
    { id,
        selector,
        parent = document.documentElement,
        abortSignal, // abort controller signal
        multiple = false,
        timeout = 5000,
        enableTimeout = true,
        maxTries = Number.MAX_SAFE_INTEGER,
        ensureDomContentLoaded = true,
        observerOptions = {},
        filter,
        transform,
        throwError } = {}) {
    return new Promise((resolve, reject) => {
        let result, tries = 0;
        
        function* applyFilterTransform(result) {
            if (filter != undefined) {
                for (let elem of result) {
                    if (filter(elem)) {
                        if (transform) elem = transform(elem);
                        yield elem;
                    }
                }
            }
            else if (transform != undefined) {
                for (const elem of result) {
                    yield transform(elem);
                }
            }
        }

        function tryQueryElement(observer) {
            abortSignal?.throwIfAborted();

            if (multiple && id == undefined) {
                if (Array.isArray(selector)) {
                    result = [];
                    for (const sel of selector) {
                        result = result.concat(Array.from(parent.querySelectorAll(sel)));
                    }
                }
                else {
                    result = Array.from(parent.querySelectorAll(selector));
                }
                result = Array.from(applyFilterTransform(result));
            }
            else {
                if (id) {
                    result = document.getElementById(id);
                }
                else if (Array.isArray(selector)) {
                    result = [];

                    function* querySelectorIterator() {
                        for (const sel of selector) {
                            yield parent.querySelector(sel);
                        }
                    }

                    result = Array.from(applyFilterTransform(querySelectorIterator()));
                }
                else {
                    result = parent.querySelector(selector);
                }
                if (transform) result = transform(result);
                if (filter != undefined && !filter(result)) {
                    return false;
                }
            }

            if (multiple ? result?.length > 0 : result) {
                observer?.disconnect();
                resolve(result);
                return result;
            }

            tries++;

            if (tries >= maxTries) {
                observer?.disconnect();
                if (throwError) {
                    reject(new WaitForElementMaximumTriesError(`maximum number of tries (${maxTries}) reached waiting for element "${selector}"`));
                }
                else {
                    resolve(null);
                }
                return false;
            }
        }

        function startWaitForElement() {
            const firstResult = tryQueryElement();

            if (firstResult) return;
            
            let observer = makeMutationObserver(
                { target: parent,
                    childList: true,
                    subtree: true,
                    abortSignal,
                    ...observerOptions },
                () => tryQueryElement(observer));
    
            let timeoutId = null;
    
            if (enableTimeout) {
                timeoutId = setTimeout(() => {
                    observer.disconnect();
                    if (throwError) {
                        reject(new WaitForElementTimeoutError(`timeout waiting for element "${selector}"`));
                    }
                    else {
                        resolve(null);
                    }
                }, timeout);
            }
    
            abortSignal?.addEventListener("abort", () => {
                clearTimeout(timeoutId);
                observer.disconnect();
                if (throwError) {
                    reject(new DOMException(abortSignal.reason, "AbortError"));
                }
                else {
                    resolve(null);
                }
            });
    
            tryQueryElement();
        }

        if (ensureDomContentLoaded && document.readyState == "loading") {
            document.addEventListener("DOMContentLoaded", () => {
                startWaitForElement();
            });
        }
        else {
            startWaitForElement();
        }
    });
}