Common patterns for working with the WEB API.
Ten skrypt nie powinien być instalowany bezpośrednio. Jest to biblioteka dla innych skyptów do włączenia dyrektywą meta // @require https://update.greasyfork.org/scripts/478440/1885317/NH_web.js
// ==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,
};
}());