dom.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import type { NonEmptyArray } from "./array";
  2. /**
  3. * Returns `unsafeWindow` if the `@grant unsafeWindow` is given, otherwise falls back to the regular `window`
  4. */
  5. export function getUnsafeWindow() {
  6. try {
  7. // throws ReferenceError if the "@grant unsafeWindow" isn't present
  8. return unsafeWindow;
  9. }
  10. catch(e) {
  11. return window;
  12. }
  13. }
  14. /**
  15. * Inserts {@linkcode afterElement} as a sibling just after the provided {@linkcode beforeElement}
  16. * @returns Returns the {@linkcode afterElement}
  17. */
  18. export function insertAfter(beforeElement: Element, afterElement: Element) {
  19. beforeElement.parentNode?.insertBefore(afterElement, beforeElement.nextSibling);
  20. return afterElement;
  21. }
  22. /**
  23. * Adds a parent container around the provided element
  24. * @returns Returns the new parent element
  25. */
  26. export function addParent(element: Element, newParent: Element) {
  27. const oldParent = element.parentNode;
  28. if(!oldParent)
  29. throw new Error("Element doesn't have a parent node");
  30. oldParent.replaceChild(newParent, element);
  31. newParent.appendChild(element);
  32. return newParent;
  33. }
  34. /**
  35. * Adds global CSS style in the form of a `<style>` element in the document's `<head>`
  36. * This needs to be run after the `DOMContentLoaded` event has fired on the document object (or instantly if `@run-at document-end` is used).
  37. * @param style CSS string
  38. */
  39. export function addGlobalStyle(style: string) {
  40. const styleElem = document.createElement("style");
  41. styleElem.innerHTML = style;
  42. document.head.appendChild(styleElem);
  43. }
  44. /**
  45. * Preloads an array of image URLs so they can be loaded instantly from the browser cache later on
  46. * @param rejects If set to `true`, the returned PromiseSettledResults will contain rejections for any of the images that failed to load
  47. * @returns Returns an array of `PromiseSettledResult` - each resolved result will contain the loaded image element, while each rejected result will contain an `ErrorEvent`
  48. */
  49. export function preloadImages(srcUrls: string[], rejects = false) {
  50. const promises = srcUrls.map(src => new Promise((res, rej) => {
  51. const image = new Image();
  52. image.src = src;
  53. image.addEventListener("load", () => res(image));
  54. image.addEventListener("error", (evt) => rejects && rej(evt));
  55. }));
  56. return Promise.allSettled(promises);
  57. }
  58. /**
  59. * Creates an invisible anchor with a `_blank` target and clicks it.
  60. * 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.
  61. *
  62. * This function has to be run in response to a user interaction event, else the browser might reject it.
  63. */
  64. export function openInNewTab(href: string) {
  65. const openElem = document.createElement("a");
  66. Object.assign(openElem, {
  67. className: "userutils-open-in-new-tab",
  68. target: "_blank",
  69. rel: "noopener noreferrer",
  70. href,
  71. });
  72. openElem.style.display = "none";
  73. document.body.appendChild(openElem);
  74. openElem.click();
  75. // timeout just to be safe
  76. setTimeout(openElem.remove, 50);
  77. }
  78. /**
  79. * Intercepts the specified event on the passed object and prevents it from being called if the called {@linkcode predicate} function returns a truthy value.
  80. * 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.
  81. * Calling this function will set the `Error.stackTraceLimit` to 1000 to ensure the stack trace is preserved.
  82. */
  83. export function interceptEvent<TEvtObj extends EventTarget, TPredicateEvt extends Event>(
  84. eventObject: TEvtObj,
  85. eventName: Parameters<TEvtObj["addEventListener"]>[0],
  86. predicate: (event: TPredicateEvt) => boolean = () => true,
  87. ) {
  88. // default is between 10 and 100 on conventional browsers so this should hopefully be more than enough
  89. // @ts-ignore
  90. if(typeof Error.stackTraceLimit === "number" && Error.stackTraceLimit < 1000) {
  91. // @ts-ignore
  92. Error.stackTraceLimit = 1000;
  93. }
  94. (function(original: typeof eventObject.addEventListener) {
  95. // @ts-ignore
  96. eventObject.__proto__.addEventListener = function(...args: Parameters<typeof eventObject.addEventListener>) {
  97. const origListener = typeof args[1] === "function" ? args[1] : args[1]?.handleEvent ?? (() => void 0);
  98. args[1] = function(...a) {
  99. if(args[0] === eventName && predicate((Array.isArray(a) ? a[0] : a) as TPredicateEvt))
  100. return;
  101. else
  102. return origListener.apply(this, a);
  103. };
  104. original.apply(this, args);
  105. };
  106. // @ts-ignore
  107. })(eventObject.__proto__.addEventListener);
  108. }
  109. /**
  110. * Intercepts the specified event on the window object and prevents it from being called if the called {@linkcode predicate} function returns a truthy value.
  111. * 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.
  112. * Calling this function will set the `Error.stackTraceLimit` to 1000 to ensure the stack trace is preserved.
  113. */
  114. export function interceptWindowEvent<TEvtKey extends keyof WindowEventMap>(
  115. eventName: TEvtKey,
  116. predicate: (event: WindowEventMap[TEvtKey]) => boolean = () => true,
  117. ) {
  118. return interceptEvent(getUnsafeWindow(), eventName, predicate);
  119. }
  120. /** An object which contains the results of {@linkcode amplifyMedia()} */
  121. export type AmplifyMediaResult = ReturnType<typeof amplifyMedia>;
  122. const amplifyBands = [60, 170, 310, 600, 1000, 3000, 6000, 12000, 14000, 16000];
  123. /**
  124. * Amplifies the gain of the passed media element's audio by the specified values.
  125. * Also applies biquad filters to prevent clipping and distortion.
  126. * This function supports any MediaElement instance like `<audio>` or `<video>`
  127. *
  128. * This is the audio processing workflow:
  129. * `MediaElement (source)` => `GainNode (pre-amplifier)` => 10x `BiquadFilterNode` => `GainNode (post-amplifier)` => `destination`
  130. *
  131. * ⚠️ This function has to be run in response to a user interaction event, else the browser will reject it because of the strict autoplay policy.
  132. * ⚠️ Make sure to call the returned function `enable()` after calling this function to actually enable the amplification.
  133. *
  134. * @param mediaElement The media element to amplify (e.g. `<audio>` or `<video>`)
  135. * @param initialPreampGain The initial gain to apply to the pre-amplifier GainNode (floating point number, default is `0.02`)
  136. * @param initialPostampGain The initial gain to apply to the post-amplifier GainNode (floating point number, default is `1.0`)
  137. * @returns Returns an object with the following properties:
  138. * **Important properties:**
  139. * | Property | Description |
  140. * | :-- | :-- |
  141. * | `setPreampGain()` | Used to change the pre-amplifier gain value from the default set by {@linkcode initialPreampGain} (0.02) |
  142. * | `getPreampGain()` | Returns the current pre-amplifier gain value |
  143. * | `setPostampGain()` | Used to change the post-amplifier gain value from the default set by {@linkcode initialPostampGain} (1.0) |
  144. * | `getPostampGain()` | Returns the current post-amplifier gain value |
  145. * | `enable()` | Call to enable the amplification for the first time or re-enable it if it was disabled before |
  146. * | `disable()` | Call to disable amplification |
  147. * | `enabled` | Whether the amplification is currently enabled |
  148. *
  149. * **Other properties:**
  150. * | Property | Description |
  151. * | :-- | :-- |
  152. * | `context` | The AudioContext instance |
  153. * | `sourceNode` | A MediaElementSourceNode instance created from the passed {@linkcode mediaElement} |
  154. * | `preampNode` | The pre-amplifier GainNode instance |
  155. * | `postampNode` | The post-amplifier GainNode instance |
  156. * | `filterNodes` | An array of BiquadFilterNode instances used for normalizing the audio volume |
  157. */
  158. export function amplifyMedia<TElem extends HTMLMediaElement>(mediaElement: TElem, initialPreampGain = 0.02, initialPostampGain = 1.0) {
  159. // @ts-ignore
  160. const context = new (window.AudioContext || window.webkitAudioContext)();
  161. const props = {
  162. context,
  163. sourceNode: context.createMediaElementSource(mediaElement),
  164. preampNode: context.createGain(),
  165. postampNode: context.createGain(),
  166. filterNodes: amplifyBands.map((band, i) => {
  167. const node = context.createBiquadFilter();
  168. node.type = (i === 0 ? "lowshelf" : "highshelf");
  169. node.frequency.setValueAtTime(band, context.currentTime);
  170. return node;
  171. }) as NonEmptyArray<BiquadFilterNode>,
  172. /** Sets the gain of the pre-amplifier GainNode */
  173. setPreampGain(gain: number) {
  174. props.preampNode.gain.setValueAtTime(gain, context.currentTime);
  175. },
  176. /** Returns the current gain of the pre-amplifier GainNode */
  177. getPreampGain() {
  178. return props.preampNode.gain.value;
  179. },
  180. /** Sets the gain of the post-amplifier GainNode */
  181. setPostampGain(multiplier: number) {
  182. props.postampNode.gain.setValueAtTime(multiplier, context.currentTime);
  183. },
  184. /** Returns the current gain of the post-amplifier GainNode */
  185. getPostampGain() {
  186. return props.postampNode.gain.value;
  187. },
  188. /** Whether the amplification is currently enabled */
  189. enabled: false,
  190. /** Enable the amplification for the first time or if it was disabled before */
  191. enable() {
  192. if(props.enabled)
  193. return;
  194. props.enabled = true;
  195. props.sourceNode.connect(props.preampNode);
  196. props.filterNodes.slice(1).forEach(filterNode => {
  197. props.preampNode.connect(filterNode);
  198. filterNode.connect(props.filterNodes[0]);
  199. });
  200. props.filterNodes[0].connect(props.postampNode);
  201. props.postampNode.connect(props.context.destination);
  202. },
  203. /** Disable the amplification */
  204. disable() {
  205. if(!props.enabled)
  206. return;
  207. props.enabled = false;
  208. props.sourceNode.disconnect(props.preampNode);
  209. props.filterNodes.slice(1).forEach(filterNode => {
  210. props.preampNode.disconnect(filterNode);
  211. filterNode.disconnect(props.filterNodes[0]);
  212. });
  213. props.filterNodes[0].disconnect(props.postampNode);
  214. props.postampNode.disconnect(props.context.destination);
  215. props.sourceNode.connect(props.context.destination);
  216. },
  217. };
  218. props.setPreampGain(initialPreampGain);
  219. props.setPostampGain(initialPostampGain);
  220. return props;
  221. }
  222. /** Checks if an element is scrollable in the horizontal and vertical directions */
  223. export function isScrollable(element: Element) {
  224. const { overflowX, overflowY } = getComputedStyle(element);
  225. return {
  226. vertical: (overflowY === "scroll" || overflowY === "auto") && element.scrollHeight > element.clientHeight,
  227. horizontal: (overflowX === "scroll" || overflowX === "auto") && element.scrollWidth > element.clientWidth,
  228. };
  229. }