1
0

dom.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. /**
  2. * Returns `unsafeWindow` if the `@grant unsafeWindow` is given, otherwise falls back to the regular `window`
  3. */
  4. export function getUnsafeWindow() {
  5. try {
  6. // throws ReferenceError if the "@grant unsafeWindow" isn't present
  7. return unsafeWindow;
  8. }
  9. catch(e) {
  10. return window;
  11. }
  12. }
  13. /**
  14. * Inserts {@linkcode afterElement} as a sibling just after the provided {@linkcode beforeElement}
  15. * @returns Returns the {@linkcode afterElement}
  16. */
  17. export function insertAfter(beforeElement: Element, afterElement: Element) {
  18. beforeElement.parentNode?.insertBefore(afterElement, beforeElement.nextSibling);
  19. return afterElement;
  20. }
  21. /**
  22. * Adds a parent container around the provided element
  23. * @returns Returns the new parent element
  24. */
  25. export function addParent(element: Element, newParent: Element) {
  26. const oldParent = element.parentNode;
  27. if(!oldParent)
  28. throw new Error("Element doesn't have a parent node");
  29. oldParent.replaceChild(newParent, element);
  30. newParent.appendChild(element);
  31. return newParent;
  32. }
  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) {
  40. const styleElem = document.createElement("style");
  41. styleElem.innerHTML = style;
  42. document.head.appendChild(styleElem);
  43. return styleElem;
  44. }
  45. /**
  46. * Preloads an array of image URLs so they can be loaded instantly from the browser cache later on
  47. * @param rejects If set to `true`, the returned PromiseSettledResults will contain rejections for any of the images that failed to load
  48. * @returns Returns an array of `PromiseSettledResult` - each resolved result will contain the loaded image element, while each rejected result will contain an `ErrorEvent`
  49. */
  50. export function preloadImages(srcUrls: string[], rejects = false) {
  51. const promises = srcUrls.map(src => new Promise((res, rej) => {
  52. const image = new Image();
  53. image.src = src;
  54. image.addEventListener("load", () => res(image));
  55. image.addEventListener("error", (evt) => rejects && rej(evt));
  56. }));
  57. return Promise.allSettled(promises);
  58. }
  59. /**
  60. * Creates an invisible anchor with a `_blank` target and clicks it.
  61. * Contrary to `window.open()`, this has a lesser chance to get blocked by the browser's popup blocker and doesn't open the URL as a new window.
  62. *
  63. * This function has to be run in response to a user interaction event, else the browser might reject it.
  64. */
  65. export function openInNewTab(href: string) {
  66. const openElem = document.createElement("a");
  67. Object.assign(openElem, {
  68. className: "userutils-open-in-new-tab",
  69. target: "_blank",
  70. rel: "noopener noreferrer",
  71. href,
  72. });
  73. openElem.style.display = "none";
  74. document.body.appendChild(openElem);
  75. openElem.click();
  76. // timeout just to be safe
  77. setTimeout(openElem.remove, 50);
  78. }
  79. /**
  80. * Intercepts the specified event on the passed object and prevents it from being called if the called {@linkcode predicate} function returns a truthy value.
  81. * If no predicate is specified, all events will be discarded.
  82. * 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.
  83. * Calling this function will set the `Error.stackTraceLimit` to 1000 to ensure the stack trace is preserved.
  84. */
  85. export function interceptEvent<TEvtObj extends EventTarget, TPredicateEvt extends Event>(
  86. eventObject: TEvtObj,
  87. eventName: Parameters<TEvtObj["addEventListener"]>[0],
  88. predicate: (event: TPredicateEvt) => boolean = () => true,
  89. ) {
  90. // default is between 10 and 100 on conventional browsers so this should hopefully be more than enough
  91. // @ts-ignore
  92. if(typeof Error.stackTraceLimit === "number" && Error.stackTraceLimit < 1000) {
  93. // @ts-ignore
  94. Error.stackTraceLimit = 1000;
  95. }
  96. (function(original: typeof eventObject.addEventListener) {
  97. // @ts-ignore
  98. eventObject.__proto__.addEventListener = function(...args: Parameters<typeof eventObject.addEventListener>) {
  99. const origListener = typeof args[1] === "function" ? args[1] : args[1]?.handleEvent ?? (() => void 0);
  100. args[1] = function(...a) {
  101. if(args[0] === eventName && predicate((Array.isArray(a) ? a[0] : a) as TPredicateEvt))
  102. return;
  103. else
  104. return origListener.apply(this, a);
  105. };
  106. original.apply(this, args);
  107. };
  108. // @ts-ignore
  109. })(eventObject.__proto__.addEventListener);
  110. }
  111. /**
  112. * Intercepts the specified event on the window object and prevents it from being called if the called {@linkcode predicate} function returns a truthy value.
  113. * If no predicate is specified, all events will be discarded.
  114. * 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.
  115. * Calling this function will set the `Error.stackTraceLimit` to 1000 to ensure the stack trace is preserved.
  116. */
  117. export function interceptWindowEvent<TEvtKey extends keyof WindowEventMap>(
  118. eventName: TEvtKey,
  119. predicate: (event: WindowEventMap[TEvtKey]) => boolean = () => true,
  120. ) {
  121. return interceptEvent(getUnsafeWindow(), eventName, predicate);
  122. }
  123. /** Checks if an element is scrollable in the horizontal and vertical directions */
  124. export function isScrollable(element: Element) {
  125. const { overflowX, overflowY } = getComputedStyle(element);
  126. return {
  127. vertical: (overflowY === "scroll" || overflowY === "auto") && element.scrollHeight > element.clientHeight,
  128. horizontal: (overflowX === "scroll" || overflowX === "auto") && element.scrollWidth > element.clientWidth,
  129. };
  130. }
  131. /**
  132. * Executes the callback when the passed element's property changes.
  133. * Contrary to an element's attributes, properties can usually not be observed with a MutationObserver.
  134. * This function shims the getter and setter of the property to invoke the callback.
  135. *
  136. * [Source](https://stackoverflow.com/a/61975440)
  137. * @param property The name of the property to observe
  138. * @param callback Callback to execute when the value is changed
  139. */
  140. export function observeElementProp<
  141. TElem extends Element = HTMLElement,
  142. TPropKey extends keyof TElem = keyof TElem,
  143. >(
  144. element: TElem,
  145. property: TPropKey,
  146. callback: (oldVal: TElem[TPropKey], newVal: TElem[TPropKey]) => void
  147. ) {
  148. const elementPrototype = Object.getPrototypeOf(element);
  149. // eslint-disable-next-line no-prototype-builtins
  150. if(elementPrototype.hasOwnProperty(property)) {
  151. const descriptor = Object.getOwnPropertyDescriptor(elementPrototype, property);
  152. Object.defineProperty(element, property, {
  153. get: function() {
  154. // @ts-ignore
  155. // eslint-disable-next-line prefer-rest-params
  156. return descriptor?.get?.apply(this, arguments);
  157. },
  158. set: function() {
  159. const oldValue = this[property];
  160. // @ts-ignore
  161. // eslint-disable-next-line prefer-rest-params
  162. descriptor?.set?.apply(this, arguments);
  163. const newValue = this[property];
  164. if(typeof callback === "function") {
  165. // @ts-ignore
  166. callback.bind(this, oldValue, newValue);
  167. }
  168. return newValue;
  169. }
  170. });
  171. }
  172. }