dom.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. /**
  2. * @module lib/dom
  3. * This module contains various functions for working with the DOM - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#dom)
  4. */
  5. import { PlatformError } from "./Errors.js";
  6. //#region unsafeWindow
  7. /**
  8. * Returns `unsafeWindow` if the `@grant unsafeWindow` is given, otherwise falls back to the regular `window`
  9. */
  10. export function getUnsafeWindow(): Window {
  11. try {
  12. // throws ReferenceError if the "@grant unsafeWindow" isn't present
  13. return unsafeWindow;
  14. }
  15. catch {
  16. return window;
  17. }
  18. }
  19. //#region addParent
  20. /**
  21. * Adds a parent container around the provided element
  22. * @returns Returns the new parent element
  23. */
  24. export function addParent<TElem extends Element, TParentElem extends Element>(element: TElem, newParent: TParentElem): TParentElem {
  25. const oldParent = element.parentNode;
  26. if(!oldParent)
  27. throw new Error("Element doesn't have a parent node");
  28. oldParent.replaceChild(newParent, element);
  29. newParent.appendChild(element);
  30. return newParent;
  31. }
  32. //#region addGlobalStyle
  33. /**
  34. * Adds global CSS style in the form of a `<style>` element in the document's `<head>`
  35. * This needs to be run after the `DOMContentLoaded` event has fired on the document object (or instantly if `@run-at document-end` is used).
  36. * @param style CSS string
  37. * @returns Returns the created style element
  38. */
  39. export function addGlobalStyle(style: string): HTMLStyleElement {
  40. const styleElem = document.createElement("style");
  41. setInnerHtmlUnsafe(styleElem, style);
  42. document.head.appendChild(styleElem);
  43. return styleElem;
  44. }
  45. //#region preloadImages
  46. /**
  47. * Preloads an array of image URLs so they can be loaded instantly from the browser cache later on
  48. * @param rejects If set to `true`, the returned PromiseSettledResults will contain rejections for any of the images that failed to load. Is set to `false` by default.
  49. * @returns Returns an array of `PromiseSettledResult` - each resolved result will contain the loaded image element, while each rejected result will contain an `ErrorEvent`
  50. */
  51. export function preloadImages(srcUrls: string[], rejects = false): Promise<PromiseSettledResult<HTMLImageElement>[]> {
  52. const promises = srcUrls.map(src => new Promise<HTMLImageElement>((res, rej) => {
  53. const image = new Image();
  54. image.addEventListener("load", () => res(image), { once: true });
  55. image.addEventListener("error", (evt) => rejects && rej(evt), { once: true });
  56. image.src = src;
  57. }));
  58. return Promise.allSettled(promises);
  59. }
  60. //#region openInNewTab
  61. /**
  62. * Tries to use `GM.openInTab` to open the given URL in a new tab, otherwise if the grant is not given, creates an invisible anchor element and clicks it.
  63. * For the fallback to work, this function needs to be run in response to a user interaction event, else the browser might reject it.
  64. * @param href The URL to open in a new tab
  65. * @param background If set to `true`, the tab will be opened in the background - set to `undefined` (default) to use the browser's default behavior
  66. * @param additionalProps Additional properties to set on the anchor element (only applies when `GM.openInTab` is not available)
  67. */
  68. export function openInNewTab(href: string, background?: boolean, additionalProps?: Partial<HTMLAnchorElement>): void {
  69. try {
  70. if(typeof window.GM === "object")
  71. GM.openInTab(href, background);
  72. }
  73. catch {
  74. const openElem = document.createElement("a");
  75. Object.assign(openElem, {
  76. className: "userutils-open-in-new-tab",
  77. target: "_blank",
  78. rel: "noopener noreferrer",
  79. tabIndex: -1,
  80. ariaHidden: "true",
  81. href,
  82. ...additionalProps,
  83. });
  84. Object.assign(openElem.style, {
  85. display: "none",
  86. pointerEvents: "none",
  87. });
  88. document.body.appendChild(openElem);
  89. openElem.click();
  90. // schedule removal after the click event has been processed
  91. setTimeout(() => {
  92. try {
  93. openElem.remove();
  94. }
  95. catch {
  96. void 0;
  97. }
  98. }, 0);
  99. }
  100. }
  101. //#region interceptEvent
  102. /**
  103. * Intercepts the specified event on the passed object and prevents it from being called if the called {@linkcode predicate} function returns a truthy value.
  104. * If no predicate is specified, all events will be discarded.
  105. * This function should be called as soon as possible (I recommend using `@run-at document-start`), as it will only intercept events that are added after this function is called.
  106. * Calling this function will set `Error.stackTraceLimit = 100` (if not already higher) to ensure the stack trace is preserved.
  107. */
  108. export function interceptEvent<
  109. TEvtObj extends EventTarget,
  110. TPredicateEvt extends Event
  111. > (
  112. eventObject: TEvtObj,
  113. eventName: Parameters<TEvtObj["addEventListener"]>[0],
  114. predicate: (event: TPredicateEvt) => boolean = () => true,
  115. ): void {
  116. // @ts-ignore
  117. if(typeof window.GM === "object" && GM?.info?.scriptHandler && GM.info.scriptHandler === "FireMonkey" && (eventObject === window || eventObject === getUnsafeWindow()))
  118. throw new PlatformError("Intercepting window events is not supported on FireMonkey due to the isolated context the userscript is forced to run in.");
  119. // default is 25 on FF so this should hopefully be more than enough
  120. // @ts-ignore
  121. Error.stackTraceLimit = Math.max(Error.stackTraceLimit, 100);
  122. if(isNaN(Error.stackTraceLimit))
  123. Error.stackTraceLimit = 100;
  124. (function(original: typeof eventObject.addEventListener) {
  125. // @ts-ignore
  126. eventObject.__proto__.addEventListener = function(...args: Parameters<typeof eventObject.addEventListener>) {
  127. const origListener = typeof args[1] === "function" ? args[1] : args[1]?.handleEvent ?? (() => void 0);
  128. args[1] = function(...a) {
  129. if(args[0] === eventName && predicate((Array.isArray(a) ? a[0] : a) as TPredicateEvt))
  130. return;
  131. else
  132. return origListener.apply(this, a);
  133. };
  134. original.apply(this, args);
  135. };
  136. // @ts-ignore
  137. })(eventObject.__proto__.addEventListener);
  138. }
  139. //#region interceptWindowEvent
  140. /**
  141. * Intercepts the specified event on the window object and prevents it from being called if the called {@linkcode predicate} function returns a truthy value.
  142. * If no predicate is specified, all events will be discarded.
  143. * This function should be called as soon as possible (I recommend using `@run-at document-start`), as it will only intercept events that are added after this function is called.
  144. * Calling this function will set `Error.stackTraceLimit = 100` (if not already higher) to ensure the stack trace is preserved.
  145. */
  146. export function interceptWindowEvent<TEvtKey extends keyof WindowEventMap>(
  147. eventName: TEvtKey,
  148. predicate: (event: WindowEventMap[TEvtKey]) => boolean = () => true,
  149. ): void {
  150. return interceptEvent(getUnsafeWindow(), eventName, predicate);
  151. }
  152. //#region isScrollable
  153. /** Checks if an element is scrollable in the horizontal and vertical directions */
  154. export function isScrollable(element: Element): Record<"vertical" | "horizontal", boolean> {
  155. const { overflowX, overflowY } = getComputedStyle(element);
  156. return {
  157. vertical: (overflowY === "scroll" || overflowY === "auto") && element.scrollHeight > element.clientHeight,
  158. horizontal: (overflowX === "scroll" || overflowX === "auto") && element.scrollWidth > element.clientWidth,
  159. };
  160. }
  161. //#region observeElementProp
  162. /**
  163. * Executes the callback when the passed element's property changes.
  164. * Contrary to an element's attributes, properties can usually not be observed with a MutationObserver.
  165. * This function shims the getter and setter of the property to invoke the callback.
  166. *
  167. * [Source](https://stackoverflow.com/a/61975440)
  168. * @param property The name of the property to observe
  169. * @param callback Callback to execute when the value is changed
  170. */
  171. export function observeElementProp<
  172. TElem extends Element = HTMLElement,
  173. TPropKey extends keyof TElem = keyof TElem,
  174. > (
  175. element: TElem,
  176. property: TPropKey,
  177. callback: (oldVal: TElem[TPropKey], newVal: TElem[TPropKey]) => void
  178. ): void {
  179. const elementPrototype = Object.getPrototypeOf(element);
  180. // eslint-disable-next-line no-prototype-builtins
  181. if(elementPrototype.hasOwnProperty(property)) {
  182. const descriptor = Object.getOwnPropertyDescriptor(elementPrototype, property);
  183. Object.defineProperty(element, property, {
  184. get: function() {
  185. // @ts-ignore
  186. // eslint-disable-next-line prefer-rest-params
  187. return descriptor?.get?.apply(this, arguments);
  188. },
  189. set: function() {
  190. const oldValue = this[property];
  191. // @ts-ignore
  192. // eslint-disable-next-line prefer-rest-params
  193. descriptor?.set?.apply(this, arguments);
  194. const newValue = this[property];
  195. if(typeof callback === "function") {
  196. // @ts-ignore
  197. callback.bind(this, oldValue, newValue);
  198. }
  199. return newValue;
  200. }
  201. });
  202. }
  203. }
  204. //#region getSiblingsFrame
  205. /**
  206. * Returns a "frame" of the closest siblings of the {@linkcode refElement}, based on the passed amount of siblings and {@linkcode refElementAlignment}
  207. * @param refElement The reference element to return the relative closest siblings from
  208. * @param siblingAmount The amount of siblings to return
  209. * @param refElementAlignment Can be set to `center-top` (default), `center-bottom`, `top`, or `bottom`, which will determine where the relative location of the provided {@linkcode refElement} is in the returned array
  210. * @param includeRef If set to `true` (default), the provided {@linkcode refElement} will be included in the returned array at the corresponding position
  211. * @template TSibling The type of the sibling elements that are returned
  212. * @returns An array of sibling elements
  213. */
  214. export function getSiblingsFrame<
  215. TSibling extends Element = HTMLElement,
  216. > (
  217. refElement: Element,
  218. siblingAmount: number,
  219. refElementAlignment: "center-top" | "center-bottom" | "top" | "bottom" = "center-top",
  220. includeRef = true,
  221. ): TSibling[] {
  222. const siblings = [...refElement.parentNode?.childNodes ?? []] as TSibling[];
  223. const elemSiblIdx = siblings.indexOf(refElement as TSibling);
  224. if(elemSiblIdx === -1)
  225. throw new Error("Element doesn't have a parent node");
  226. if(refElementAlignment === "top")
  227. return [...siblings.slice(elemSiblIdx + Number(!includeRef), elemSiblIdx + siblingAmount + Number(!includeRef))];
  228. else if(refElementAlignment.startsWith("center-")) {
  229. // if the amount of siblings is even, one of the two center ones will be decided by the value of `refElementAlignment`
  230. const halfAmount = (refElementAlignment === "center-bottom" ? Math.ceil : Math.floor)(siblingAmount / 2);
  231. const startIdx = Math.max(0, elemSiblIdx - halfAmount);
  232. // if the amount of siblings is even, the top offset of 1 will be applied whenever `includeRef` is set to true
  233. const topOffset = Number(refElementAlignment === "center-top" && siblingAmount % 2 === 0 && includeRef);
  234. // if the amount of siblings is odd, the bottom offset of 1 will be applied whenever `includeRef` is set to true
  235. const btmOffset = Number(refElementAlignment === "center-bottom" && siblingAmount % 2 !== 0 && includeRef);
  236. const startIdxWithOffset = startIdx + topOffset + btmOffset;
  237. // filter out the reference element if `includeRef` is set to false,
  238. // then slice the array to the desired framing including the offsets
  239. return [
  240. ...siblings
  241. .filter((_, idx) => includeRef || idx !== elemSiblIdx)
  242. .slice(startIdxWithOffset, startIdxWithOffset + siblingAmount)
  243. ];
  244. }
  245. else if(refElementAlignment === "bottom")
  246. return [...siblings.slice(elemSiblIdx - siblingAmount + Number(includeRef), elemSiblIdx + Number(includeRef))];
  247. return [] as TSibling[];
  248. }
  249. //#region setInnerHtmlUnsafe
  250. let ttPolicy: { createHTML: (html: string) => string } | undefined;
  251. /**
  252. * Sets the innerHTML property of the provided element without any sanitation or validation.
  253. * Uses a [Trusted Types policy](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) on Chromium-based browsers to trick the browser into thinking the HTML is safe.
  254. * Use this if the page makes use of the CSP directive `require-trusted-types-for 'script'` and throws a "This document requires 'TrustedHTML' assignment" error on Chromium-based browsers.
  255. *
  256. * - ⚠️ This function does not perform any sanitization and should thus be used with utmost caution, as it can easily lead to XSS vulnerabilities!
  257. */
  258. export function setInnerHtmlUnsafe<TElement extends Element = HTMLElement>(element: TElement, html: string): TElement {
  259. // @ts-ignore
  260. if(!ttPolicy && typeof window?.trustedTypes?.createPolicy === "function") {
  261. // @ts-ignore
  262. ttPolicy = window.trustedTypes.createPolicy("_uu_set_innerhtml_unsafe", {
  263. createHTML: (unsafeHtml: string) => unsafeHtml,
  264. });
  265. }
  266. element.innerHTML = ttPolicy?.createHTML?.(html) ?? html;
  267. return element;
  268. }
  269. //#region probeElementStyle
  270. /**
  271. * Creates an invisible temporary element to probe its rendered style.
  272. * Has to be run after the `DOMContentLoaded` event has fired on the document object.
  273. * @param probeStyle Function to probe the element's style. First argument is the element's style object from [`window.getComputedStyle()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/getComputedStyle), second argument is the element itself
  274. * @param element The element to probe, or a function that creates and returns the element - should not be added to the DOM prior to calling this function! - all probe elements will have the class `_uu_probe_element` added to them
  275. * @param hideOffscreen Whether to hide the element offscreen, enabled by default - disable if you want to probe the position style properties of the element
  276. * @param parentElement The parent element to append the probe element to, defaults to `document.body`
  277. * @returns The value returned by the `probeElement` function
  278. */
  279. export function probeElementStyle<
  280. TValue,
  281. TElem extends HTMLElement = HTMLSpanElement,
  282. > (
  283. probeStyle: (style: CSSStyleDeclaration, element: TElem) => TValue,
  284. element?: TElem | (() => TElem),
  285. hideOffscreen = true,
  286. parentElement = document.body,
  287. ): TValue {
  288. const el = element
  289. ? typeof element === "function" ? element() : element
  290. : document.createElement("span") as TElem;
  291. if(hideOffscreen) {
  292. el.style.position = "absolute";
  293. el.style.left = "-9999px";
  294. el.style.top = "-9999px";
  295. el.style.zIndex = "-9999";
  296. }
  297. el.classList.add("_uu_probe_element");
  298. parentElement.appendChild(el);
  299. const style = window.getComputedStyle(el);
  300. const result = probeStyle(style, el);
  301. setTimeout(() => el.remove(), 1);
  302. return result;
  303. }
  304. //#region isDomLoaded
  305. let domReady = false;
  306. document.addEventListener("DOMContentLoaded", () => domReady = true, { once: true });
  307. /** Returns whether or not the DOM has finished loading */
  308. export function isDomLoaded(): boolean {
  309. return domReady;
  310. }
  311. //#region onDomLoad
  312. /**
  313. * Executes a callback and/or resolves the returned Promise when the DOM has finished loading.
  314. * Immediately executes/resolves if the DOM is already loaded.
  315. * @param cb Callback to execute when the DOM has finished loading
  316. * @returns Returns a Promise that resolves when the DOM has finished loading
  317. */
  318. export function onDomLoad(cb?: () => void): Promise<void> {
  319. return new Promise((res) => {
  320. if(domReady) {
  321. cb?.();
  322. res();
  323. }
  324. else
  325. document.addEventListener("DOMContentLoaded", () => {
  326. cb?.();
  327. res();
  328. }, { once: true });
  329. });
  330. }