NH_web

Common patterns for working with the WEB API.

Dette scriptet burde ikke installeres direkte. Det er et bibliotek for andre script å inkludere med det nye metadirektivet // @require https://update.greasyfork.org/scripts/478440/1304193/NH_web.js

// ==UserScript==
// ==UserLibrary==
// @name        NH_web
// @description Common patterns for working with the WEB API.
// @version     6
// @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 ??= {};

window.NexusHoratio.web = (function web() {
  'use strict';

  /** @type {number} - Bumped per release. */
  const version = 6;

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

  /**
   * Run querySelector to get an element, then click it.
   * @param {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;
  }

  /**
   * Bring the Browser's focus onto element.
   * @param {Element} element - HTML Element to focus on.
   */
  function focusOnElement(element) {
    if (element) {
      const magicTabIndex = -1;
      const tabIndex = element.getAttribute('tabindex');
      element.setAttribute('tabindex', magicTabIndex);
      element.focus();
      if (tabIndex) {
        element.setAttribute('tabindex', tabIndex);
      } else {
        element.removeAttribute('tabindex');
      }
    }
  }

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

  /**
   * Determines if the element accepts keyboard input.
   * @param {Element} element - HTML 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));
  }

  /**
   * @typedef {object} Continuation
   * @property {boolean} done - Indicate whether the monitor is done
   * processing.
   * @property {object} [results] - Optional results object.
   */

  /**
   * @callback Monitor
   * @param {MutationRecord[]} records - Standard mutation records.
   * @returns {Continuation} - Indicate whether done monitoring.
   */

  /**
   * Simple function that takes no parameters and returns nothing.
   * @callback SimpleFunction
   */

  /**
   * @typedef {object} OtmotWhat
   * @property {string} name - The name for this observer.
   * @property {Element} base - Element to observe.
   */

  /**
   * @typedef {object} OtmotHow
   * @property {object} observeOptions - MutationObserver().observe() options.
   * @property {SimpleFunction} [trigger] - Function to call that triggers
   * observable results.
   * @property {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.
   *
   * @param {MutationRecord[]} records - Standard mutation records.
   * @param {MutationObserver} observer - The invoking observer, enhanced with
   * extra properties by *otmot()*.
   * @returns {boolean} - The *done* value of the monitor function.
   */
  const 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.
   * @param {OtmotWhat} what - What to observe.
   * @param {OtmotHow} how - How to observe.
   * @returns {Promise<Continuation.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,
        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 ${name}`);
      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 ${name} 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
   * @property {string} name - The name for this observer.
   * @property {Element} base - Element to observe.
   */

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

  /**
   * ResizeObserver callback for otrot.
   *
   * @param {ResizeObserverEntry[]} entries - Standard resize records.
   * @param {ResizeObserver} observer - The invoking observer, enhanced with
   * extra properties by *otrot()*.
   * @returns {boolean} - Whether a resize was observed.
   */
  const 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.
   * @param {OtrotWhat} what - What to observe.
   * @param {OtrotHow} how - How to observe.
   * @returns {Promise<OtrotWhat>} - Will resolve with the what parameter.
   */
  function otrot(what, how) {
    const prom = new Promise((resolve, reject) => {
      const observer = new ResizeObserver(otrotRoCallback);

      const {
        name,
        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 ${name}`);
      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 ${name} 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
   * @param {ResizeObserverEntry[]} entries - Standard resize entries.
   */

  /**
   * @typedef {object} Otrot2How
   * @property {SimpleFunction} [trigger] - Function to call that triggers
   * observable events.
   * @property {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.
   *
   * @param {ResizeObserverEntry[]} entries - Standard resize records.
   * @param {ResizeObserver} observer - The invoking observer, enhanced with
   * extra properties by *otrot()*.
   */
  const 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 {OtrotWhat} what - What to observe.
   * @param {Otrow2How} 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,
        base,
      } = what;
      const {
        trigger = () => {},  // eslint-disable-line no-empty-function
        duration,
      } = how;

      observer.logger = new NH.base.Logger(`otrot2 ${name}`);
      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 ${name} 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.
   * @param {string} selector - CSS selector.
   * @param {number} timeout - Time to wait in milliseconds, 0 disables.
   * @returns {Promise<Element>} - Matched element.
   */
  function waitForSelector(selector, timeout) {
    const me = 'waitForSelector';
    const logger = new NH.base.Logger(me);
    logger.entered(me, selector, timeout);

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

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

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

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

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

}());