1
0

dom.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. /**
  2. * Returns `unsafeWindow` if the `@grant unsafeWindow` is given, otherwise falls back to the regular `window`
  3. */
  4. export function getUnsafeWindow(): Window {
  5. try {
  6. // throws ReferenceError if the "@grant unsafeWindow" isn't present
  7. return unsafeWindow;
  8. }
  9. catch {
  10. return window;
  11. }
  12. }
  13. /**
  14. * Adds a parent container around the provided element
  15. * @returns Returns the new parent element
  16. */
  17. export function addParent<TElem extends Element, TParentElem extends Element>(element: TElem, newParent: TParentElem): TParentElem {
  18. const oldParent = element.parentNode;
  19. if(!oldParent)
  20. throw new Error("Element doesn't have a parent node");
  21. oldParent.replaceChild(newParent, element);
  22. newParent.appendChild(element);
  23. return newParent;
  24. }
  25. /**
  26. * Adds global CSS style in the form of a `<style>` element in the document's `<head>`
  27. * This needs to be run after the `DOMContentLoaded` event has fired on the document object (or instantly if `@run-at document-end` is used).
  28. * @param style CSS string
  29. * @returns Returns the created style element
  30. */
  31. export function addGlobalStyle(style: string): HTMLStyleElement {
  32. const styleElem = document.createElement("style");
  33. setInnerHtmlUnsafe(styleElem, style);
  34. document.head.appendChild(styleElem);
  35. return styleElem;
  36. }
  37. /**
  38. * Preloads an array of image URLs so they can be loaded instantly from the browser cache later on
  39. * @param rejects If set to `true`, the returned PromiseSettledResults will contain rejections for any of the images that failed to load
  40. * @returns Returns an array of `PromiseSettledResult` - each resolved result will contain the loaded image element, while each rejected result will contain an `ErrorEvent`
  41. */
  42. export function preloadImages(srcUrls: string[], rejects = false): Promise<PromiseSettledResult<HTMLImageElement>[]> {
  43. const promises = srcUrls.map(src => new Promise<HTMLImageElement>((res, rej) => {
  44. const image = new Image();
  45. image.src = src;
  46. image.addEventListener("load", () => res(image));
  47. image.addEventListener("error", (evt) => rejects && rej(evt));
  48. }));
  49. return Promise.allSettled(promises);
  50. }
  51. /**
  52. * 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.
  53. * For the fallback to work, this function needs to be run in response to a user interaction event, else the browser might reject it.
  54. * @param href The URL to open in a new tab
  55. * @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
  56. * @param additionalProps Additional properties to set on the anchor element (only applies when `GM.openInTab` is not available)
  57. */
  58. export function openInNewTab(href: string, background?: boolean, additionalProps?: Partial<HTMLAnchorElement>): void {
  59. try {
  60. GM.openInTab?.(href, background);
  61. }
  62. catch {
  63. const openElem = document.createElement("a");
  64. Object.assign(openElem, {
  65. className: "userutils-open-in-new-tab",
  66. target: "_blank",
  67. rel: "noopener noreferrer",
  68. tabIndex: -1,
  69. ariaHidden: "true",
  70. href,
  71. ...additionalProps,
  72. });
  73. Object.assign(openElem.style, {
  74. display: "none",
  75. pointerEvents: "none",
  76. });
  77. document.body.appendChild(openElem);
  78. openElem.click();
  79. // schedule removal after the click event has been processed
  80. setTimeout(openElem.remove, 0);
  81. }
  82. }
  83. /**
  84. * Intercepts the specified event on the passed object and prevents it from being called if the called {@linkcode predicate} function returns a truthy value.
  85. * If no predicate is specified, all events will be discarded.
  86. * 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.
  87. * Calling this function will set `Error.stackTraceLimit = 100` (if not already higher) to ensure the stack trace is preserved.
  88. */
  89. export function interceptEvent<
  90. TEvtObj extends EventTarget,
  91. TPredicateEvt extends Event
  92. > (
  93. eventObject: TEvtObj,
  94. eventName: Parameters<TEvtObj["addEventListener"]>[0],
  95. predicate: (event: TPredicateEvt) => boolean = () => true,
  96. ): void {
  97. // @ts-ignore
  98. if((eventObject === window || eventObject === getUnsafeWindow()) && GM?.info?.scriptHandler && GM.info.scriptHandler === "FireMonkey")
  99. throw new Error("Intercepting window events is not supported on FireMonkey due to the isolated context the userscript runs in.");
  100. // default is 25 on FF so this should hopefully be more than enough
  101. // @ts-ignore
  102. Error.stackTraceLimit = Math.max(Error.stackTraceLimit, 100);
  103. if(isNaN(Error.stackTraceLimit))
  104. Error.stackTraceLimit = 100;
  105. (function(original: typeof eventObject.addEventListener) {
  106. // @ts-ignore
  107. eventObject.__proto__.addEventListener = function(...args: Parameters<typeof eventObject.addEventListener>) {
  108. const origListener = typeof args[1] === "function" ? args[1] : args[1]?.handleEvent ?? (() => void 0);
  109. args[1] = function(...a) {
  110. if(args[0] === eventName && predicate((Array.isArray(a) ? a[0] : a) as TPredicateEvt))
  111. return;
  112. else
  113. return origListener.apply(this, a);
  114. };
  115. original.apply(this, args);
  116. };
  117. // @ts-ignore
  118. })(eventObject.__proto__.addEventListener);
  119. }
  120. /**
  121. * Intercepts the specified event on the window object and prevents it from being called if the called {@linkcode predicate} function returns a truthy value.
  122. * If no predicate is specified, all events will be discarded.
  123. * 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.
  124. * Calling this function will set `Error.stackTraceLimit = 100` (if not already higher) to ensure the stack trace is preserved.
  125. */
  126. export function interceptWindowEvent<TEvtKey extends keyof WindowEventMap>(
  127. eventName: TEvtKey,
  128. predicate: (event: WindowEventMap[TEvtKey]) => boolean = () => true,
  129. ): void {
  130. return interceptEvent(getUnsafeWindow(), eventName, predicate);
  131. }
  132. /** Checks if an element is scrollable in the horizontal and vertical directions */
  133. export function isScrollable(element: Element): Record<"vertical" | "horizontal", boolean> {
  134. const { overflowX, overflowY } = getComputedStyle(element);
  135. return {
  136. vertical: (overflowY === "scroll" || overflowY === "auto") && element.scrollHeight > element.clientHeight,
  137. horizontal: (overflowX === "scroll" || overflowX === "auto") && element.scrollWidth > element.clientWidth,
  138. };
  139. }
  140. /**
  141. * Executes the callback when the passed element's property changes.
  142. * Contrary to an element's attributes, properties can usually not be observed with a MutationObserver.
  143. * This function shims the getter and setter of the property to invoke the callback.
  144. *
  145. * [Source](https://stackoverflow.com/a/61975440)
  146. * @param property The name of the property to observe
  147. * @param callback Callback to execute when the value is changed
  148. */
  149. export function observeElementProp<
  150. TElem extends Element = HTMLElement,
  151. TPropKey extends keyof TElem = keyof TElem,
  152. > (
  153. element: TElem,
  154. property: TPropKey,
  155. callback: (oldVal: TElem[TPropKey], newVal: TElem[TPropKey]) => void
  156. ): void {
  157. const elementPrototype = Object.getPrototypeOf(element);
  158. // eslint-disable-next-line no-prototype-builtins
  159. if(elementPrototype.hasOwnProperty(property)) {
  160. const descriptor = Object.getOwnPropertyDescriptor(elementPrototype, property);
  161. Object.defineProperty(element, property, {
  162. get: function() {
  163. // @ts-ignore
  164. // eslint-disable-next-line prefer-rest-params
  165. return descriptor?.get?.apply(this, arguments);
  166. },
  167. set: function() {
  168. const oldValue = this[property];
  169. // @ts-ignore
  170. // eslint-disable-next-line prefer-rest-params
  171. descriptor?.set?.apply(this, arguments);
  172. const newValue = this[property];
  173. if(typeof callback === "function") {
  174. // @ts-ignore
  175. callback.bind(this, oldValue, newValue);
  176. }
  177. return newValue;
  178. }
  179. });
  180. }
  181. }
  182. /**
  183. * Returns a "frame" of the closest siblings of the {@linkcode refElement}, based on the passed amount of siblings and {@linkcode refElementAlignment}
  184. * @param refElement The reference element to return the relative closest siblings from
  185. * @param siblingAmount The amount of siblings to return
  186. * @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
  187. * @param includeRef If set to `true` (default), the provided {@linkcode refElement} will be included in the returned array at the corresponding position
  188. * @template TSibling The type of the sibling elements that are returned
  189. * @returns An array of sibling elements
  190. */
  191. export function getSiblingsFrame<
  192. TSibling extends Element = HTMLElement,
  193. > (
  194. refElement: Element,
  195. siblingAmount: number,
  196. refElementAlignment: "center-top" | "center-bottom" | "top" | "bottom" = "center-top",
  197. includeRef = true,
  198. ): TSibling[] {
  199. const siblings = [...refElement.parentNode?.childNodes ?? []] as TSibling[];
  200. const elemSiblIdx = siblings.indexOf(refElement as TSibling);
  201. if(elemSiblIdx === -1)
  202. throw new Error("Element doesn't have a parent node");
  203. if(refElementAlignment === "top")
  204. return [...siblings.slice(elemSiblIdx + Number(!includeRef), elemSiblIdx + siblingAmount + Number(!includeRef))];
  205. else if(refElementAlignment.startsWith("center-")) {
  206. // if the amount of siblings is even, one of the two center ones will be decided by the value of `refElementAlignment`
  207. const halfAmount = (refElementAlignment === "center-bottom" ? Math.ceil : Math.floor)(siblingAmount / 2);
  208. const startIdx = Math.max(0, elemSiblIdx - halfAmount);
  209. // if the amount of siblings is even, the top offset of 1 will be applied whenever `includeRef` is set to true
  210. const topOffset = Number(refElementAlignment === "center-top" && siblingAmount % 2 === 0 && includeRef);
  211. // if the amount of siblings is odd, the bottom offset of 1 will be applied whenever `includeRef` is set to true
  212. const btmOffset = Number(refElementAlignment === "center-bottom" && siblingAmount % 2 !== 0 && includeRef);
  213. const startIdxWithOffset = startIdx + topOffset + btmOffset;
  214. // filter out the reference element if `includeRef` is set to false,
  215. // then slice the array to the desired framing including the offsets
  216. return [
  217. ...siblings
  218. .filter((_, idx) => includeRef || idx !== elemSiblIdx)
  219. .slice(startIdxWithOffset, startIdxWithOffset + siblingAmount)
  220. ];
  221. }
  222. else if(refElementAlignment === "bottom")
  223. return [...siblings.slice(elemSiblIdx - siblingAmount + Number(includeRef), elemSiblIdx + Number(includeRef))];
  224. return [] as TSibling[];
  225. }
  226. let ttPolicy: { createHTML: (html: string) => string } | undefined;
  227. /**
  228. * Sets the innerHTML property of the provided element without any sanitation or validation.
  229. * 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.
  230. * 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.
  231. *
  232. * - ⚠️ This function does not perform any sanitization and should thus be used with utmost caution, as it can easily lead to XSS vulnerabilities!
  233. */
  234. export function setInnerHtmlUnsafe<TElement extends Element = HTMLElement>(element: TElement, html: string): TElement {
  235. // @ts-ignore
  236. if(!ttPolicy && typeof window?.trustedTypes?.createPolicy === "function") {
  237. // @ts-ignore
  238. ttPolicy = window.trustedTypes.createPolicy("_uu_set_innerhtml_unsafe", {
  239. createHTML: (unsafeHtml: string) => unsafeHtml,
  240. });
  241. }
  242. element.innerHTML = ttPolicy?.createHTML?.(html) ?? html;
  243. return element;
  244. }