1
0

dom.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  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. */
  38. export function addGlobalStyle(style: string) {
  39. const styleElem = document.createElement("style");
  40. styleElem.innerHTML = style;
  41. document.head.appendChild(styleElem);
  42. }
  43. /**
  44. * Preloads an array of image URLs so they can be loaded instantly from the browser cache later on
  45. * @param rejects If set to `true`, the returned PromiseSettledResults will contain rejections for any of the images that failed to load
  46. * @returns Returns an array of `PromiseSettledResult` - each resolved result will contain the loaded image element, while each rejected result will contain an `ErrorEvent`
  47. */
  48. export function preloadImages(srcUrls: string[], rejects = false) {
  49. const promises = srcUrls.map(src => new Promise((res, rej) => {
  50. const image = new Image();
  51. image.src = src;
  52. image.addEventListener("load", () => res(image));
  53. image.addEventListener("error", (evt) => rejects && rej(evt));
  54. }));
  55. return Promise.allSettled(promises);
  56. }
  57. /**
  58. * Creates an invisible anchor with a `_blank` target and clicks it.
  59. * 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.
  60. *
  61. * This function has to be run in response to a user interaction event, else the browser might reject it.
  62. */
  63. export function openInNewTab(href: string) {
  64. const openElem = document.createElement("a");
  65. Object.assign(openElem, {
  66. className: "userutils-open-in-new-tab",
  67. target: "_blank",
  68. rel: "noopener noreferrer",
  69. href,
  70. });
  71. openElem.style.display = "none";
  72. document.body.appendChild(openElem);
  73. openElem.click();
  74. // timeout just to be safe
  75. setTimeout(openElem.remove, 50);
  76. }
  77. /**
  78. * Intercepts the specified event on the passed object and prevents it from being called if the called {@linkcode predicate} function returns a truthy value.
  79. * 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.
  80. * Calling this function will set the `Error.stackTraceLimit` to 1000 to ensure the stack trace is preserved.
  81. */
  82. export function interceptEvent<TEvtObj extends EventTarget, TPredicateEvt extends Event>(
  83. eventObject: TEvtObj,
  84. eventName: Parameters<TEvtObj["addEventListener"]>[0],
  85. predicate: (event: TPredicateEvt) => boolean = () => true,
  86. ) {
  87. // default is between 10 and 100 on conventional browsers so this should hopefully be more than enough
  88. // @ts-ignore
  89. if(typeof Error.stackTraceLimit === "number" && Error.stackTraceLimit < 1000) {
  90. // @ts-ignore
  91. Error.stackTraceLimit = 1000;
  92. }
  93. (function(original: typeof eventObject.addEventListener) {
  94. // @ts-ignore
  95. eventObject.__proto__.addEventListener = function(...args: Parameters<typeof eventObject.addEventListener>) {
  96. const origListener = typeof args[1] === "function" ? args[1] : args[1]?.handleEvent ?? (() => void 0);
  97. args[1] = function(...a) {
  98. if(args[0] === eventName && predicate((Array.isArray(a) ? a[0] : a) as TPredicateEvt))
  99. return;
  100. else
  101. return origListener.apply(this, a);
  102. };
  103. original.apply(this, args);
  104. };
  105. // @ts-ignore
  106. })(eventObject.__proto__.addEventListener);
  107. }
  108. /**
  109. * Intercepts the specified event on the window object and prevents it from being called if the called {@linkcode predicate} function returns a truthy value.
  110. * 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.
  111. * Calling this function will set the `Error.stackTraceLimit` to 1000 to ensure the stack trace is preserved.
  112. */
  113. export function interceptWindowEvent<TEvtKey extends keyof WindowEventMap>(
  114. eventName: TEvtKey,
  115. predicate: (event: WindowEventMap[TEvtKey]) => boolean = () => true,
  116. ) {
  117. return interceptEvent(getUnsafeWindow(), eventName, predicate);
  118. }
  119. /**
  120. * Amplifies the gain of the passed media element's audio by the specified multiplier.
  121. * Also applies a limiter to prevent clipping and distortion.
  122. * This function supports any MediaElement instance like `<audio>` or `<video>`
  123. *
  124. * This is the audio processing workflow:
  125. * `MediaElement (source)` => `DynamicsCompressorNode (limiter)` => `GainNode` => `AudioDestinationNode (output)`
  126. *
  127. * ⚠️ 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.
  128. * ⚠️ Make sure to call the returned function `enable()` after calling this function to actually enable the amplification.
  129. *
  130. * @param mediaElement The media element to amplify (e.g. `<audio>` or `<video>`)
  131. * @param initialMultiplier The initial gain multiplier to apply (floating point number, default is `1.0`)
  132. * @returns Returns an object with the following properties:
  133. * | Property | Description |
  134. * | :-- | :-- |
  135. * | `setGain()` | Used to change the gain multiplier from the default set by {@linkcode initialMultiplier} |
  136. * | `getGain()` | Returns the current gain multiplier |
  137. * | `enable()` | Call to enable the amplification for the first time or if it was disabled before |
  138. * | `disable()` | Call to disable amplification |
  139. * | `setLimiterOptions()` | Used for changing the [options of the DynamicsCompressorNode](https://developer.mozilla.org/en-US/docs/Web/API/DynamicsCompressorNode/DynamicsCompressorNode#options) - the default is `{ threshold: -2, knee: 40, ratio: 12, attack: 0.003, release: 0.25 }` |
  140. * | `context` | The AudioContext instance |
  141. * | `source` | The MediaElementSourceNode instance |
  142. * | `gainNode` | The GainNode instance |
  143. * | `limiterNode` | The DynamicsCompressorNode instance used for limiting clipping and distortion |
  144. */
  145. export function amplifyMedia<TElem extends HTMLMediaElement>(mediaElement: TElem, initialMultiplier = 1.0) {
  146. /*
  147. // Globals:
  148. bands = [60, 170, 310, 600, 1000, 3000, 6000, 12000, 14000, 16000]
  149. // Audio Processing Nodes:
  150. <HTMLMediaElement (source)>
  151. // connect to:
  152. <GainNode (preamp)>
  153. attr gain = ?
  154. // connect to:
  155. // only for panning L/R I think
  156. // <StereoPannerNode (balance)>
  157. // attr pan
  158. // connect to:
  159. [foreach band of bands] <BiquadFilterNode (filter)>
  160. attr frequency = band
  161. attr gain = ?
  162. attr type = (index === 0 ? "lowshelf" : "highshelf) || "peaking" // "peaking" is unreachable??
  163. // connect all but the first filter to the first filter, then first filter to destination:
  164. <AudioContext.destination>
  165. */
  166. // @ts-ignore
  167. const context = new (window.AudioContext || window.webkitAudioContext)();
  168. const props = {
  169. context,
  170. source: context.createMediaElementSource(mediaElement),
  171. gainNode: context.createGain(),
  172. limiterNode: context.createDynamicsCompressor(),
  173. /** Sets the gain multiplier */
  174. setGain(multiplier: number) {
  175. props.gainNode.gain.setValueAtTime(multiplier, props.context.currentTime);
  176. },
  177. /** Returns the current gain multiplier */
  178. getGain() {
  179. return props.gainNode.gain.value;
  180. },
  181. /** Enable the amplification for the first time or if it was disabled before */
  182. enable() {
  183. props.source.connect(props.limiterNode);
  184. props.limiterNode.connect(props.gainNode);
  185. props.gainNode.connect(props.context.destination);
  186. },
  187. /** Disable the amplification */
  188. disable() {
  189. props.source.disconnect(props.limiterNode);
  190. props.limiterNode.disconnect(props.gainNode);
  191. props.gainNode.disconnect(props.context.destination);
  192. props.source.connect(props.context.destination);
  193. },
  194. /**
  195. * Set the options of the [limiter / DynamicsCompressorNode](https://developer.mozilla.org/en-US/docs/Web/API/DynamicsCompressorNode/DynamicsCompressorNode#options)
  196. * The default is `{ threshold: -12, knee: 30, ratio: 12, attack: 0.003, release: 0.25 }`
  197. */
  198. setLimiterOptions(options: Partial<Record<"threshold" | "knee" | "ratio" | "attack" | "release", number>>) {
  199. for(const [key, val] of Object.entries(options))
  200. props.limiterNode[key as keyof typeof options]
  201. .setValueAtTime(val, props.context.currentTime);
  202. },
  203. };
  204. // TODO: better limiter options
  205. // - https://www.reddit.com/r/edmproduction/comments/ssi4sx/explain_limitingclippingcompression_like_im_8/hx5kukj/
  206. // - https://blog.landr.com/how-to-use-a-compressor/
  207. // - https://youtu.be/72rtkuk9Gb0?t=120
  208. props.setLimiterOptions(limiterPresets.default);
  209. props.setGain(initialMultiplier);
  210. return props;
  211. }
  212. /** Presets for the `setLimiterOptions()` function returned by {@linkcode amplifyMedia()} */
  213. export const limiterPresets = {
  214. /** The default limiter options */
  215. default: {
  216. threshold: -16,
  217. knee: 15,
  218. ratio: 5,
  219. attack: 0.004,
  220. release: 0.01,
  221. },
  222. /** A limiter preset for a more aggressive compression */
  223. aggressive: {
  224. threshold: -12,
  225. knee: 30,
  226. ratio: 12,
  227. attack: 0.003,
  228. release: 0.25,
  229. },
  230. /** A limiter preset for a more subtle compression */
  231. subtle: {
  232. threshold: -24,
  233. knee: 5,
  234. ratio: 2,
  235. attack: 0.005,
  236. release: 0.05,
  237. },
  238. };
  239. /** An object which contains the results of {@linkcode amplifyMedia()} */
  240. export type AmplifyMediaResult = ReturnType<typeof amplifyMedia>;
  241. /** Checks if an element is scrollable in the horizontal and vertical directions */
  242. export function isScrollable(element: Element) {
  243. const { overflowX, overflowY } = getComputedStyle(element);
  244. return {
  245. vertical: (overflowY === "scroll" || overflowY === "auto") && element.scrollHeight > element.clientHeight,
  246. horizontal: (overflowX === "scroll" || overflowX === "auto") && element.scrollWidth > element.clientWidth,
  247. };
  248. }