Browse Source

feat: initial features

Sven 1 year ago
parent
commit
7aadec8e05
5 changed files with 209 additions and 0 deletions
  1. 16 0
      CHANGELOG.bak.md
  2. 2 0
      lib/index.ts
  3. 21 0
      lib/onSelector.ts
  4. 8 0
      lib/types.ts
  5. 162 0
      lib/utils.ts

+ 16 - 0
CHANGELOG.bak.md

@@ -0,0 +1,16 @@
+## v0.1.0
+Features:
+- `onSelector()` to call a listener once a selector is found in the DOM
+- `autoPlural()` to automatically pluralize a string
+- `clamp()` to clamp a number between a min and max value
+- `pauseFor()` to pause the execution of a function for a given amount of time
+- `debounce()` to call a function only once, after a given amount of time
+- `getUnsafeWindow()` to get the unsafeWindow object or fall back to the regular window object
+- `insertAfter()` to insert an element as a sibling after another element
+- `addParent()` to add a parent element around another element
+- `addGlobalStyle()` to add a global style to the page
+- `preloadImages()` to preload images into the browser cache for faster loading later on
+- `fetchAdvanced()` as a wrapper around the fetch API with a timeout option
+- `openInNewTab()` to open a link in a new tab
+- `interceptEvent()` to conditionally intercept events registered by `addEventListener()` on any given EventTarget object
+- `interceptWindowEvent()` to conditionally intercept events registered by `addEventListener()` on the window object

+ 2 - 0
lib/index.ts

@@ -0,0 +1,2 @@
+export * from "./utils";
+export * from "./onSelector";

+ 21 - 0
lib/onSelector.ts

@@ -0,0 +1,21 @@
+import { SelectorExistsOpts } from "./types";
+
+/**
+ * Calls the `listener` as soon as the `selector` exists in the DOM.  
+ * Listeners are deleted when they are called once, unless `options.continuous` is set.  
+ * Multiple listeners with the same selector may be registered.
+ * @template TElem The type of element that this selector will return - FIXME: listener inferring doesn't work when this generic is given
+ */
+export function onSelector<TElem = HTMLElement, TOpts extends SelectorExistsOpts = SelectorExistsOpts>(
+  options: TOpts,
+  listener: (element: TOpts["all"] extends true ? (TElem extends HTMLElement ? NodeListOf<TElem> : TElem) : TElem) => void,
+) {
+  // TODO:
+  void [options, listener];
+}
+
+/** Removes all listeners registered in `onSelector()` with a matching selector property */
+export function removeOnSelector(selector: string) {
+  // TODO:
+  void [selector];
+}

+ 8 - 0
lib/types.ts

@@ -0,0 +1,8 @@
+export type SelectorExistsOpts = {
+  /** The selector to check for */
+  selector: string;
+  /** Whether to use `querySelectorAll()` instead */
+  all?: boolean;
+  /** Whether to call the listener continuously instead of once */
+  continuous?: boolean;
+};

+ 162 - 0
lib/utils.ts

@@ -0,0 +1,162 @@
+/**
+ * Automatically appends an `s` to the passed `word`, if `num` is not equal to 1
+ * @param word A word in singular form, to auto-convert to plural
+ * @param num If this is an array, the amount of items is used
+ */
+export function autoPlural(word: string, num: number | unknown[] | NodeList) {
+  if(Array.isArray(num) || num instanceof NodeList)
+    num = num.length;
+  return `${word}${num === 1 ? "" : "s"}`;
+}
+
+/** Ensures the passed `value` always stays between `min` and `max` */
+export function clamp(value: number, min: number, max: number) {
+  return Math.max(Math.min(value, max), min);
+}
+
+/** Pauses async execution for the specified time in ms */
+export function pauseFor(time: number) {
+  return new Promise((res) => {
+    setTimeout(res, time);
+  });
+}
+
+/**
+ * Calls the passed `func` after the specified `timeout` in ms.  
+ * Any subsequent calls to this function will reset the timer and discard previous calls.
+ */
+export function debounce<TFunc extends (...args: TArgs[]) => void, TArgs = any>(func: TFunc, timeout = 300) { // eslint-disable-line @typescript-eslint/no-explicit-any
+  let timer: number | undefined;
+  return function(...args: TArgs[]) {
+    clearTimeout(timer);
+    timer = setTimeout(() => func.apply(this, args), timeout) as unknown as number;
+  };
+}
+
+/**
+ * Returns `unsafeWindow` if it is available (if `@grant unsafeWindow` is set), otherwise falls back to the regular `window`
+ */
+export function getUnsafeWindow() {
+  try {
+    // throws ReferenceError if the "@grant unsafeWindow" isn't present
+    return unsafeWindow;
+  }
+  catch(e) {
+    return window;
+  }
+}
+
+/**
+ * Inserts `afterNode` as a sibling just after the provided `beforeNode`
+ * @returns Returns the `afterNode`
+ */
+export function insertAfter(beforeNode: HTMLElement, afterNode: HTMLElement) {
+  beforeNode.parentNode?.insertBefore(afterNode, beforeNode.nextSibling);
+  return afterNode;
+}
+
+/** Adds a parent container around the provided element - returns the new parent node */
+export function addParent(element: HTMLElement, newParent: HTMLElement) {
+  const oldParent = element.parentNode;
+
+  if(!oldParent)
+    throw new Error("Element doesn't have a parent node");
+
+  oldParent.replaceChild(newParent, element);
+  newParent.appendChild(element);
+
+  return newParent;
+}
+
+/**
+ * Adds global CSS style through a `<style>` element in the document's `<head>`  
+ * This needs to be run after the `DOMContentLoaded` event has fired on the document object.
+ * @param style CSS string
+ */
+export function addGlobalStyle(style: string) {
+  const styleElem = document.createElement("style");
+  styleElem.innerHTML = style;
+  document.head.appendChild(styleElem);
+}
+
+/** Preloads an array of image URLs so they can be loaded instantly from the browser cache later on */
+export function preloadImages(srcUrls: string[], rejects = false) {
+  const promises = srcUrls.map(src => new Promise((res, rej) => {
+    const image = new Image();
+    image.src = src;
+    image.addEventListener("load", () => res(image));
+    image.addEventListener("error", () => rejects && rej(`Failed to preload image with URL '${src}'`));
+  }));
+
+  return Promise.allSettled(promises);
+}
+
+type FetchOpts = RequestInit & Partial<{
+  timeout: number;
+}>;
+
+/** Calls the fetch API with special options */
+export async function fetchAdvanced(url: string, options: FetchOpts = {}) {
+  const { timeout = 10000 } = options;
+
+  const controller = new AbortController();
+  const id = setTimeout(() => controller.abort(), timeout);
+
+  const res = await fetch(url, {
+    ...options,
+    signal: controller.signal,
+  });
+
+  clearTimeout(id);
+  return res;
+}
+
+/**
+ * Creates an invisible anchor with _blank target and clicks it.  
+ * This has to be run in relatively quick succession to a user interaction event, else the browser rejects it.
+ */
+export function openInNewTab(href: string) {
+  const openElem = document.createElement("a");
+  Object.assign(openElem, {
+    className: "userutils-open-in-new-tab",
+    target: "_blank",
+    rel: "noopener noreferrer",
+    href,
+  });
+  openElem.style.display = "none";
+
+  document.body.appendChild(openElem);
+  openElem.click();
+  // timeout just to be safe
+  setTimeout(openElem.remove, 100);
+}
+
+/**
+ * Intercepts the specified event on the passed object and prevents it from being called if the called `predicate` function returns `true`
+ */
+export function interceptEvent<TEvtObj extends EventTarget>(eventObject: TEvtObj, eventName: Parameters<TEvtObj["addEventListener"]>[0], predicate: () => boolean) {  
+  // default is between 10 and 100 on conventional browsers so this should hopefully be more than enough
+  // @ts-ignore
+  if(Error.stackTraceLimit < 1000) {
+    // @ts-ignore
+    Error.stackTraceLimit = 1000;
+  }
+
+  (function(original: typeof eventObject.addEventListener) {
+    // @ts-ignore
+    element.__proto__.addEventListener = function(...args: Parameters<typeof eventObject.addEventListener>) {
+      if(args[0] === eventName && predicate())
+        return;
+      else
+        return original.apply(this, args);
+    };
+    // @ts-ignore
+  })(eventObject.__proto__.addEventListener);
+}
+
+/**
+ * Intercepts the specified event on the window object and prevents it from being called if the called `predicate` function returns `true`
+ */
+export function interceptWindowEvent(eventName: keyof WindowEventMap, predicate: () => boolean) {  
+  interceptEvent(getUnsafeWindow(), eventName, predicate);
+}