NH_web

Common patterns for working with the WEB API.

Ovu skriptu ne treba izravno instalirati. To je biblioteka za druge skripte koje se uključuju u meta direktivu // @require https://update.greasyfork.org/scripts/478440/1885317/NH_web.js

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// ==UserLibrary==
// @name        NH_web
// @description Common patterns for working with the WEB API.
// @version     16
// @license     GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0-standalone.html
// @homepageURL https://github.com/nexushoratio/userscripts
// @supportURL  https://github.com/nexushoratio/userscripts/issues
// @match       https://www.example.com/*
// ==/UserLibrary==
// ==/UserScript==

window.NexusHoratio ??= {};

/**
 * The WEB API `Element` object.
 * @external Element
 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Element Element}
 */

/**
 * The WEB API `MutationObserver` object.
 * @external MutationObserver
 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver MutationObserver}
 */

/**
 * The WEB API `MutationRecord` object.
 * @external MutationRecord
 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MutationRecord MutationRecord}
 */

/**
 * The WEB API `ResizeObserverEntry` object.
 * @external ResizeObserverEntry
 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry ResizeObserverEntry}
 */

/**
 * Common patterns for working with the [WEB
 * API](https://developer.mozilla.org/en-US/docs/Web/API).
 *
 * Depends on:
 * - {@link NexusHoratio.base}
 * @version 16
 * @license [GPL-3.0-or-later]{@link https://www.gnu.org/licenses/gpl-3.0-standalone.html}
 * @namespace NexusHoratio.web
 */
window.NexusHoratio.web = (function web() {
  'use strict';

  /**
   * @const {number} - Bumped per release.
   * @memberof NexusHoratio.web
   * @default
   */
  const version = 16;

  const NH = window.NexusHoratio.base.ensure(
    [{name: 'base', minVersion: 36}]
  );

  const logger = new NH.base.Logger('NHWeb');

  const MAGIC_TAB_INDEX_VALUE = -1;

  const FOCUS_SELECTOR = ':enabled, a, [tabindex]';

  /**
   * Run querySelector to get an element, then click it.
   *
   * @memberof NexusHoratio.web
   * @param {external:Element} base - Where to start looking.
   * @param {string[]} selectorArray - CSS selectors to use to find an
   * element.
   * @param {boolean} [matchSelf=false] - If a CSS selector would match base,
   * then use it.
   * @returns {boolean} Whether an element could be found.
   */
  function clickElement(base, selectorArray, matchSelf = false) {
    if (base) {
      for (const selector of selectorArray) {
        let el = null;
        if (matchSelf && base.matches(selector)) {
          el = base;
        } else {
          el = base.querySelector(selector);
        }
        if (el) {
          el.click();
          return true;
        }
      }
    }
    return false;
  }

  /**
   * Move the browser's focus onto element.
   *
   * This is accomplished by ensuring the the element has a *tabindex*
   * attribute.  By default, if the element had no *tabindex* attribute, the
   * temporary one will be removed.  Some browsers will update the
   * *activeElement* after removing the *tabindex*.
   *
   * @memberof NexusHoratio.web
   * @param {external:Element} element - Element to focus on.
   * @param {boolean} [removeNonExistent=true] - Whether to remove the
   * tabindex if it was originally not present.
   */
  function focusOnElement(element, removeNonExistent = true) {
    const me = focusOnElement.name;
    logger.entered(me, element, removeNonExistent);

    if (element) {
      element.focus();
      if (!document.activeElement.isSameNode(element)) {
        element.setAttribute('tabindex', MAGIC_TAB_INDEX_VALUE);
        element.focus();
        if (removeNonExistent) {
          element.removeAttribute('tabindex');
        }
      }
    }

    logger.leaving(me, document.activeElement);
  }

  /**
   * Move the browser's focus somewhere into the requested tree.
   *
   * This uses a heuristic to find the first focusable element in a tree.  If
   * it is unable to find one, it falls back to forcing the requested element.
   *
   * @memberof NexusHoratio.web
   * @param {external:Element} root - The root of the tree
   */
  function focusOnTree(root) {
    const me = focusOnTree.name;
    logger.entered(me, root);

    let element = root;

    if (element) {
      if (!element.matches(FOCUS_SELECTOR)) {
        for (element of element.querySelectorAll(FOCUS_SELECTOR)) {
          if (element.checkVisibility()) {
            break;
          }
        }
      }
      focusOnElement(element, false);
    }

    logger.leaving(me, element, document.activeElement);
  }

  /**
   * Post a bunch of information about an Element to {@link
   * NexusHoratio.base.issues issues}.
   *
   * @memberof NexusHoratio.web
   * @param {external:Element} element - Element to get information about.
   * @param {string} area - What area this information came from.
   */
  function postInfoAboutElement(element, area) {
    const msg = `An unsupported element from  "${area}" discovered:`;
    NH.base.issues.post(msg, element.outerHTML);
  }

  /**
   * Determines if the element accepts keyboard input.
   *
   * @memberof NexusHoratio.web
   * @param {external:Element} element - Element to examine.
   * @returns {boolean} Indicating whether the element accepts keyboard
   * input.
   */
  function isInput(element) {
    let tagName = '';
    if ('tagName' in element) {
      tagName = element.tagName.toLowerCase();
    }
    // eslint-disable-next-line no-extra-parens
    return (element.isContentEditable ||
            ['input', 'textarea'].includes(tagName));
  }

  /**
   * Generic object representing results.
   *
   * @typedef {object.<string, object>} Results
   * @memberof NexusHoratio.web~
   */

  /**
   * @typedef {object} Continuation
   * @memberof NexusHoratio.web~
   * @property {boolean} done - Indicate whether the monitor is done
   * processing.
   * @property {NexusHoratio.web~Results} [results] - Results of an
   * observation.
   */

  /**
   * @callback Monitor
   * @memberof NexusHoratio.web~
   * @param {external:MutationRecord[]} records - Standard mutation records.
   * @returns {NexusHoratio.web~Continuation} Indicate whether done
   * monitoring.
   */

  /**
   * Simple function that takes no parameters and returns nothing.
   * @callback SimpleFunction
   * @memberof NexusHoratio.web~
   */

  /**
   * @typedef {object} OtmotWhat
   * @memberof NexusHoratio.web~
   * @property {string} name - The name for this observer.
   * @property {external:Element} base - Element to observe.
   */

  /**
   * @typedef {object} OtmotHow
   * @memberof NexusHoratio.web~
   * @property {object} observeOptions - {@link external:MutationObserver}
   * `observe()` {@link
   * https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe#options
   * options}.
   * @property {NexusHoratio.web~SimpleFunction} [trigger] - Function to call
   * that triggers observable results.
   * @property {NexusHoratio.web~Monitor} monitor - Callback used to process
   * MutationObserver records.
   * @property {number} [timeout] - Time to wait for completion in
   * milliseconds, default of 0 disables.
   */

  /**
   * MutationObserver callback for otmot.
   *
   * @memberof NexusHoratio.web~
   * @param {external:MutationRecord[]} records - Standard mutation records.
   * @param {external:MutationObserver} observer - The invoking observer,
   * enhanced with extra properties by *otmot()*.
   * @returns {boolean} The *done* value of the monitor function.
   */
  function otmotMoCallback(records, observer) {
    const {done, results} = observer.monitor(records);
    observer.logger.log('monitor:', done, results);
    if (done) {
      observer.disconnect();
      clearTimeout(observer.timeoutID);
      observer.logger.log('resolving');
      observer.resolve(results);
    }
    return done;
  }

  /**
   * One time mutation observer with timeout.
   *
   * @memberof NexusHoratio.web
   * @param {NexusHoratio.web~OtmotWhat} what - What to observe.
   * @param {NexusHoratio.web~OtmotHow} how - How to observe.
   * @returns {Promise<NexusHoratio.web~Results>} Will resolve with the
   * results from monitor when done is true.
   */
  function otmot(what, how) {
    const prom = new Promise((resolve, reject) => {
      const observer = new MutationObserver(otmotMoCallback);

      const {
        name: otmotName,
        base,
      } = what;
      const {
        observeOptions,
        trigger = () => {},  // eslint-disable-line no-empty-function
        timeout = 0,
      } = how;

      observer.monitor = how.monitor;
      observer.resolve = resolve;
      observer.logger = new NH.base.Logger(`otmot ${otmotName}`);
      observer.timeoutID = null;

      /** Standard setTimeout callback. */
      const toCallback = () => {
        observer.disconnect();
        observer.logger.log('one last try');
        if (!otmotMoCallback([], observer)) {
          observer.logger.log('rejecting after timeout');
          reject(new Error(`otmot ${otmotName} timed out`));
        }
      };

      if (timeout) {
        observer.timeoutID = setTimeout(toCallback, timeout);
      }

      observer.observe(base, observeOptions);
      trigger();
      observer.logger.log('running');
      // Call once at start in case we missed the change.
      otmotMoCallback([], observer);
    });

    return prom;
  }

  /**
   * @typedef {object} OtrotWhat
   * @memberof NexusHoratio.web~
   * @property {string} name - The name for this observer.
   * @property {external:Element} base - Element to observe.
   */

  /**
   * @typedef {object} OtrotHow
   * @memberof NexusHoratio.web~
   * @property {NexusHoratio.web~SimpleFunction} [trigger] - Function to call
   * that triggers observable events.
   * @property {number} timeout - Time to wait for completion in milliseconds.
   */

  /**
   * ResizeObserver callback for otrot.
   *
   * @memberof NexusHoratio.web~
   * @param {external:ResizeObserverEntry[]} entries - Standard resize
   * records.
   * @param {NexusHoratio.web~ResizeObserver} observer - The invoking
   * observer, enhanced with extra properties by *otrot()*.
   * @returns {boolean} Whether a resize was observed.
   */
  function otrotRoCallback(entries, observer) {
    const {initialHeight, initialWidth} = observer;
    const {clientHeight, clientWidth} = observer.base;
    observer.logger.log('observed dimensions:', clientWidth, clientHeight);
    const resized = clientHeight !== initialHeight ||
          clientWidth !== initialWidth;
    if (resized) {
      observer.disconnect();
      clearTimeout(observer.timeoutID);
      observer.logger.log('resolving');
      observer.resolve(observer.what);
    }
    return resized;
  }

  /**
   * One time resize observer with timeout.
   *
   * Will resolve automatically upon first resize change.
   *
   * @memberof NexusHoratio.web
   * @param {NexusHoratio.web~OtrotWhat} what - What to observe.
   * @param {OtrotHow} how - How to observe.
   * @returns {Promise<NexusHoratio.web~OtrotWhat>} Will resolve with the what
   * parameter.
   */
  function otrot(what, how) {
    const prom = new Promise((resolve, reject) => {
      const observer = new ResizeObserver(otrotRoCallback);

      const {
        name: otrotName,
        base,
      } = what;
      const {
        trigger = () => {},  // eslint-disable-line no-empty-function
        timeout,
      } = how;

      observer.base = base;
      observer.initialHeight = base.clientHeight;
      observer.initialWidth = base.clientWidth;

      observer.what = what;
      observer.resolve = resolve;
      observer.logger = new NH.base.Logger(`otrot ${otrotName}`);
      observer.logger.log(
        'initial dimensions:',
        observer.initialWidth,
        observer.initialHeight
      );

      /** Standard setTimeout callback. */
      const toCallback = () => {
        observer.disconnect();
        observer.logger.log('one last try');
        if (!otrotRoCallback([], observer)) {
          observer.logger.log('rejecting after timeout');
          reject(new Error(`otrot ${otrotName} timed out`));
        }
      };

      observer.timeoutID = setTimeout(toCallback, timeout);

      observer.observe(base);
      trigger();
      observer.logger.log('running');
      // Call once at start in case we missed the change.
      otrotRoCallback([], observer);
    });

    return prom;
  }

  /**
   * @callback ResizeAction
   * @memberof NexusHoratio.web~
   * @param {external:ResizeObserverEntry[]} entries - Standard resize
   * entries.
   */

  /**
   * @typedef {object} Otrot2How
   * @memberof NexusHoratio.web~
   * @property {NexusHoratio.web~SimpleFunction} [trigger] - Function to call
   * that triggers observable events.
   * @property {NexusHoratio.web~ResizeAction} action - Function to call upon
   * each event observed and also at the end of duration.
   * @property {number} duration - Time to run in milliseconds.
   */

  /**
   * ResizeObserver callback for otrot2.
   *
   * @memberof NexusHoratio.web~
   * @param {external:ResizeObserverEntry[]} entries - Standard resize
   * records.
   * @param {NexusHoratio.web~ResizeObserver} observer - The invoking
   * observer, enhanced with extra properties by *otrot()*.
   */
  function otrot2RoCallback(entries, observer) {
    observer.logger.log('calling action');
    observer.action(entries);
  }

  /**
   * One time resize observer with action callback and duration.
   *
   * Will resolve upon duration expiration.  Uses the same what parameter as
   * {@link otrot}.
   * @param {NexusHoratio.web.OtrotWhat} what - What to observe.
   * @param {NexusHoratio.web~Otrot2How} how - How to observe.
   * @returns {Promise<string>} Will resolve after duration expires.
   */
  function otrot2(what, how) {
    const prom = new Promise((resolve) => {
      const observer = new ResizeObserver(otrot2RoCallback);

      const {
        name: otrotName,
        base,
      } = what;
      const {
        trigger = () => {},  // eslint-disable-line no-empty-function
        duration,
      } = how;

      observer.logger = new NH.base.Logger(`otrot2 ${otrotName}`);
      observer.action = how.action;

      /** Standard setTimeout callback. */
      const toCallback = () => {
        observer.disconnect();
        observer.logger.log('one last call');
        otrot2RoCallback([], observer);
        observer.logger.log('resolving');
        resolve(`otrot2 ${otrotName} finished`);
      };

      setTimeout(toCallback, duration);

      observer.observe(base);
      trigger();
      observer.logger.log('running');
      // Call once at start in case we missed the change.
      otrot2RoCallback([], observer);
    });

    return prom;
  }

  /**
   * Wait for selector to match using querySelector.
   *
   * @memberof NexusHoratio.web
   * @param {string} selector - CSS selector.
   * @param {number} [timeout=0] - Time to wait in milliseconds, 0 disables.
   * @param {external:Element} [base=document] - Where to start looking.
   * @returns {Promise<external:Element>} Matched element.
   */
  function waitForSelector(selector, timeout = 0, base = document) {
    const me = waitForSelector.name;
    logger.entered(me, selector, timeout, base);

    /**
     * @implements {NexusHoratio.web~Monitor}
     * @returns {NexusHoratio.web~Continuation} Indicate whether done
     * monitoring.
     */
    const monitorImpl = () => {
      const element = base.querySelector(selector);
      if (element) {
        logger.log(`match for ${selector}`, element);
        return {done: true, results: element};
      }
      logger.log('Still waiting for', selector);
      return {done: false};
    };

    /**
     * @implements {NexusHoratio.web~Monitor}
     * @returns {NexusHoratio.web~Continuation} Indicate whether done
     * monitoring.
     */
    const monitor = () => {
      const monMe = `${me}.${monitor.name}`;
      logger.starting(monMe);

      const ret = monitorImpl();

      logger.finished(monMe, ret);
      return ret;
    };

    const what = {
      name: me,
      base: base,
    };

    const how = {
      observeOptions: {childList: true, subtree: true},
      monitor: monitor,
      timeout: timeout,
    };

    logger.leaving(me);
    return otmot(what, how);
  }

  /**
   * Update a style element every time monitored elements change.
   *
   * @memberof NexusHoratio.web
   * @extends NexusHoratio.base.Service
   */
  class StyleService extends NH.base.Service {

    /**
     * @typedef {Map<string, external:Element>} ElementMap
     * @memberof NexusHoratio.web.StyleService~
     */

    /**
     * CSS style properties.
     *
     * @typedef {Map<string, string>} StyleProperties
     * @memberof NexusHoratio.web.StyleService~
     */

    /**
     * Function that finds DOM elements.
     *
     * @callback Finder
     * @memberof NexusHoratio.web.StyleService~
     * @returns {NexusHoratio.web.StyleService~ElementMap} Desired elements.
     */

    /**
     * Function that examines multiple elements to compute a style.
     *
     * @callback ElementsProcessor
     * @memberof NexusHoratio.web.StyleService~
     * @param {NexusHoratio.web.StyleService~ElementMap} elements - Elements
     * to examine.
     * @returns {NexusHoratio.web.StyleService~StyleProperties} Style
     * properties for to contribute.
     */

    /**
     * @typedef {object} Config
     * @memberof NexusHoratio.web.StyleService~
     * @property {string} className - Name for the class to control.
     * @property {NexusHoratio.web.StyleService~Finder} finder - Function to
     * find the elements to monitor.
     * @property {NexusHoratio.web.StyleService~ElementsProcessor}
     * elementsProcessor - Function that examines multiple elements to compute
     * a style.
     * @property {string[]} [events=[]] - List of Element events to listen to
     * on the elements.
     */

    /**
     * @param {string} instanceName - Custom portion of this instance.
     * @param {NexusHoratio.web.StyleService~Config} config - Instance
     * configuration.
     */
    constructor(instanceName, config) {
      super(instanceName);

      ({
        className: this.#className,
        finder: this.#finder,
        elementsProcessor: this.#elementsProcessor,
        events: this.#events = [],
      } = config);

      this.#resizeObserver = new ResizeObserver(this.#handler);

      this.on('activate', this.#onActivate)
        .on('deactivate', this.#onDeactivate)
        .allowReactivation(false);
    }

    #className
    #elements = new Map()
    #elementsProcessor
    #events
    #finder
    #resizeObserver
    #style

    #onActivate = () => {
      const me = this.#onActivate.name;
      this.logger.entered(me);

      if (!this.#style) {
        this.#style = document.createElement('style');
        document.head.prepend(this.#style);
      }

      const connected = this.#elements.values()
        .every(x => x?.isConnected);
      if (this.#elements.size === 0 || !connected) {
        this.#elements = this.#finder();
      }

      for (const element of this.#elements.values()) {
        if (element) {
          this.#resizeObserver.observe(element);
          for (const evt of this.#events) {
            this.logger.log('evt', evt);
            element.addEventListener(evt, this.#handler);
          }
        }
      }

      this.logger.leaving(me);
    }

    #onDeactivate = () => {
      this.#resizeObserver.disconnect();
      for (const element of this.#elements.values()) {
        for (const evt of this.#events) {
          this.logger.log('evt', evt);
          element.removeEventListener(evt, this.#handler);
        }
      }
    }

    #handler = () => {
      this.#setStyle();
    }

    #setStyle = () => {
      const properties = this.#elementsProcessor(this.#elements);
      const style = [
        '',
        `.${this.#className} {`,
      ];

      for (const [key, value] of properties) {
        style.push(`  ${key}: ${value};`);
      }

      style.push(
        '}',
        '',
      );
      this.#style.textContent = style.join('\n');
    }

  }
  return {
    version: version,
    clickElement: clickElement,
    focusOnElement: focusOnElement,
    focusOnTree: focusOnTree,
    postInfoAboutElement: postInfoAboutElement,
    isInput: isInput,
    otmot: otmot,
    otrot: otrot,
    otrot2: otrot2,
    waitForSelector: waitForSelector,
    StyleService: StyleService,
  };

}());