2 |
3 |
4 | ## UserUtils
5 | General purpose DOM/GreaseMonkey library that allows you to register listeners for when CSS selectors exist, intercept events, create persistent & synchronous data stores, modify the DOM more easily and much more.
6 | Contains builtin TypeScript declarations. Supports ESM and CJS imports via a bundler and global declaration via `@require` or `
167 |
168 | ```
169 |
170 | > [!NOTE]
171 | > In order for your script not to break on a major library update, use one the versioned URLs above after replacing `INSERT_VERSION` with the desired version (e.g. `8.3.2`) or the versioned URL that's shown [at the top of the GreasyFork page.](https://greasyfork.org/scripts/472956-userutils)
172 |
173 |
174 |
175 | - Then, access the functions on the global variable `UserUtils`:
176 |
177 | ```ts
178 | UserUtils.addGlobalStyle("body { background-color: red; }");
179 |
180 | // or using object destructuring:
181 |
182 | const { clamp } = UserUtils;
183 | console.log(clamp(1, 5, 10)); // 5
184 | ```
185 |
186 |
187 |
188 | - If you're using TypeScript and it complains about the missing global variable `UserUtils`, install the library using the package manager of your choice and add the following inside any `.ts` file that is included in the final build:
189 |
190 | ```ts
191 | declare const UserUtils: typeof import("@sv443-network/userutils");
192 |
193 | declare global {
194 | interface Window {
195 | UserUtils: typeof UserUtils;
196 | }
197 | }
198 | ```
199 |
200 |
201 |
202 | - If you're using a linter like ESLint, it might complain about the global variable `UserUtils` not being defined. To fix this, add the following to your ESLint configuration file:
203 | ```json
204 | "globals": {
205 | "UserUtils": "readonly"
206 | }
207 | ```
208 |
209 |
210 |
211 |
212 | ## License:
213 | This library is licensed under the MIT License.
214 | See the [license file](./LICENSE.txt) for details.
215 |
216 |
217 |
218 |
219 |
220 |
221 | Made with ❤️ by [Sv443](https://github.com/Sv443)
222 | If you like this library, please consider [supporting the development](https://github.com/sponsors/Sv443)
223 |
224 |
225 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import path from "node:path";
2 | import { fileURLToPath } from "node:url";
3 | import typescriptEslint from "@typescript-eslint/eslint-plugin";
4 | import globals from "globals";
5 | import tsParser from "@typescript-eslint/parser";
6 | import js from "@eslint/js";
7 | import { FlatCompat } from "@eslint/eslintrc";
8 |
9 | const __filename = fileURLToPath(import.meta.url);
10 | const __dirname = path.dirname(__filename);
11 |
12 | const compat = new FlatCompat({
13 | baseDirectory: __dirname,
14 | recommendedConfig: js.configs.recommended,
15 | allConfig: js.configs.all,
16 | });
17 |
18 | /** @type {import("eslint").Linter.Config} */
19 | const config = [
20 | {
21 | ignores: [
22 | "**/*.min.*",
23 | "**/*.user.js",
24 | "**/*.map",
25 | "dist/**/*",
26 | "**/dev/**/*",
27 | "**/test.ts",
28 | "test/**/*",
29 | "**/*.spec.ts",
30 | ],
31 | }, ...compat.extends(
32 | "eslint:recommended",
33 | "plugin:@typescript-eslint/recommended",
34 | ), {
35 | plugins: {
36 | "@typescript-eslint": typescriptEslint,
37 | },
38 | languageOptions: {
39 | globals: {
40 | ...globals.browser,
41 | ...globals.node,
42 | Atomics: "readonly",
43 | SharedArrayBuffer: "readonly",
44 | GM: "readonly",
45 | unsafeWindow: "writable",
46 | },
47 | parser: tsParser,
48 | ecmaVersion: "latest",
49 | sourceType: "module",
50 | },
51 | rules: {
52 | "no-unreachable": "off",
53 | quotes: ["error", "double"],
54 | semi: ["error", "always"],
55 | "eol-last": ["error", "always"],
56 | "no-async-promise-executor": "off",
57 | "no-cond-assign": "off",
58 | indent: ["error", 2, {
59 | ignoredNodes: ["VariableDeclaration[declarations.length=0]"],
60 | }],
61 | "@typescript-eslint/no-non-null-assertion": "off",
62 | "@typescript-eslint/no-unused-vars": ["warn", {
63 | vars: "local",
64 | ignoreRestSiblings: true,
65 | args: "after-used",
66 | argsIgnorePattern: "^_",
67 | varsIgnorePattern: "^_",
68 | }],
69 | "no-unused-vars": "off",
70 | "@typescript-eslint/ban-ts-comment": "off",
71 | "@typescript-eslint/no-empty-object-type": "off",
72 | "@typescript-eslint/no-explicit-any": "error",
73 | "@typescript-eslint/no-unused-expressions": ["error", {
74 | allowShortCircuit: true,
75 | allowTernary: true,
76 | allowTaggedTemplates: true,
77 | }],
78 | "@typescript-eslint/no-unsafe-declaration-merging": "off",
79 | "@typescript-eslint/explicit-function-return-type": ["error", {
80 | allowExpressions: true,
81 | allowIIFEs: true,
82 | }],
83 | "comma-dangle": ["error", "only-multiline"],
84 | "no-misleading-character-class": "off",
85 | },
86 | }, {
87 | files: ["**/*.js", "**/*.mjs", "**/*.cjs"],
88 | rules: {
89 | "@typescript-eslint/no-var-requires": "off",
90 | "@typescript-eslint/explicit-function-return-type": "off",
91 | quotes: ["error", "double"],
92 | semi: ["error", "always"],
93 | "eol-last": ["error", "always"],
94 | "no-async-promise-executor": "off",
95 | indent: ["error", 2, {
96 | ignoredNodes: ["VariableDeclaration[declarations.length=0]"],
97 | }],
98 | "no-unused-vars": ["warn", {
99 | vars: "local",
100 | ignoreRestSiblings: true,
101 | args: "after-used",
102 | argsIgnorePattern: "^_",
103 | }],
104 | "comma-dangle": ["error", "only-multiline"],
105 | },
106 | },
107 | ];
108 |
109 | export default config;
110 |
--------------------------------------------------------------------------------
/jsr.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://jsr.io/schema/config-file.v1.json",
3 | "name": "@sv443-network/userutils",
4 | "version": "0.0.1-invalid",
5 | "exports": "./lib/index.ts",
6 | "publish": {
7 | "include": [
8 | "lib/**/*.ts",
9 | "dist/index.js",
10 | "dist/index.mjs",
11 | "dist/index.cjs",
12 | "dist/index.global.js",
13 | "dist/index.umd.js",
14 | "dist/lib/*.d.ts",
15 | "package.json",
16 | "README.md",
17 | "CHANGELOG.md",
18 | "LICENSE.txt"
19 | ],
20 | "exclude": [
21 | "**/*.spec.ts"
22 | ]
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/lib/DataStore.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { DataStore } from "./DataStore.js";
3 | import { compress, decompress } from "./crypto.js";
4 |
5 | class TestDataStore
extends DataStore {
6 | public async test_getValue(name: string, defaultValue: TValue): Promise {
7 | return await this.getValue(name, defaultValue);
8 | }
9 |
10 | public async test_setValue(name: string, value: GM.Value): Promise {
11 | return await this.setValue(name, value);
12 | }
13 | }
14 |
15 | describe("DataStore", () => {
16 | //#region base
17 | it("Basic usage", async () => {
18 | const store = new DataStore({
19 | id: "test-1",
20 | defaultData: { a: 1, b: 2 },
21 | formatVersion: 1,
22 | storageMethod: "localStorage",
23 | encodeData: (d) => d,
24 | decodeData: (d) => d,
25 | });
26 |
27 | // should equal defaultData:
28 | expect(store.getData().a).toBe(1);
29 |
30 | // deepCopy should return a new object:
31 | expect(store.getData(true) === store.getData(true)).toBe(false);
32 |
33 | await store.loadData();
34 |
35 | // synchronous in-memory change:
36 | const prom = store.setData({ ...store.getData(), a: 2 });
37 |
38 | expect(store.getData().a).toBe(2);
39 |
40 | await prom;
41 |
42 | // only clears persistent data, not the stuff in memory:
43 | await store.deleteData();
44 | expect(store.getData().a).toBe(2);
45 |
46 | // refreshes memory data:
47 | await store.loadData();
48 | expect(store.getData().a).toBe(1);
49 |
50 | expect(store.encodingEnabled()).toBe(true);
51 |
52 | // restore initial state:
53 | await store.deleteData();
54 | });
55 |
56 | //#region encoding
57 | it("Works with encoding", async () => {
58 | const store = new DataStore({
59 | id: "test-2",
60 | defaultData: { a: 1, b: 2 },
61 | formatVersion: 1,
62 | storageMethod: "sessionStorage",
63 | encodeData: async (data) => await compress(data, "deflate-raw", "string"),
64 | decodeData: async (data) => await decompress(data, "deflate-raw", "string"),
65 | });
66 |
67 | await store.loadData();
68 |
69 | await store.setData({ ...store.getData(), a: 2 });
70 |
71 | await store.loadData();
72 |
73 | expect(store.getData()).toEqual({ a: 2, b: 2 });
74 |
75 | expect(store.encodingEnabled()).toBe(true);
76 |
77 | // restore initial state:
78 | await store.deleteData();
79 | });
80 |
81 | //#region data & ID migrations
82 | it("Data and ID migrations work", async () => {
83 | const firstStore = new DataStore({
84 | id: "test-3",
85 | defaultData: { a: 1, b: 2 },
86 | formatVersion: 1,
87 | storageMethod: "sessionStorage",
88 | });
89 |
90 | await firstStore.loadData();
91 |
92 | await firstStore.setData({ ...firstStore.getData(), a: 2 });
93 |
94 | // new store with increased format version & new ID:
95 | const secondStore = new DataStore({
96 | id: "test-4",
97 | migrateIds: [firstStore.id],
98 | defaultData: { a: -1337, b: -1337, c: 69 },
99 | formatVersion: 2,
100 | storageMethod: "sessionStorage",
101 | migrations: {
102 | 2: (oldData: Record) => ({ ...oldData, c: 1 }),
103 | },
104 | });
105 |
106 | const data1 = await secondStore.loadData();
107 |
108 | expect(data1.a).toBe(2);
109 | expect(data1.b).toBe(2);
110 | expect(data1.c).toBe(1);
111 |
112 | await secondStore.saveDefaultData();
113 | const data2 = secondStore.getData();
114 |
115 | expect(data2.a).toBe(-1337);
116 | expect(data2.b).toBe(-1337);
117 | expect(data2.c).toBe(69);
118 |
119 | // migrate with migrateId method:
120 | const thirdStore = new TestDataStore({
121 | id: "test-5",
122 | defaultData: secondStore.defaultData,
123 | formatVersion: 3,
124 | storageMethod: "sessionStorage",
125 | });
126 |
127 | await thirdStore.migrateId(secondStore.id);
128 | const thirdData = await thirdStore.loadData();
129 |
130 | expect(thirdData.a).toBe(-1337);
131 | expect(thirdData.b).toBe(-1337);
132 | expect(thirdData.c).toBe(69);
133 |
134 | expect(await thirdStore.test_getValue("_uucfgver-test-5", "")).toBe("2");
135 | await thirdStore.setData(thirdStore.getData());
136 | expect(await thirdStore.test_getValue("_uucfgver-test-5", "")).toBe("3");
137 |
138 | expect(await thirdStore.test_getValue("_uucfgver-test-3", "")).toBe("");
139 | expect(await thirdStore.test_getValue("_uucfgver-test-4", "")).toBe("");
140 |
141 | // restore initial state:
142 | await firstStore.deleteData();
143 | await secondStore.deleteData();
144 | await thirdStore.deleteData();
145 | });
146 |
147 | //#region migration error
148 | it("Migration error", async () => {
149 | const store1 = new DataStore({
150 | id: "test-migration-error",
151 | defaultData: { a: 1, b: 2 },
152 | formatVersion: 1,
153 | storageMethod: "localStorage",
154 | });
155 |
156 | await store1.loadData();
157 |
158 | const store2 = new DataStore({
159 | id: "test-migration-error",
160 | defaultData: { a: 5, b: 5, c: 5 },
161 | formatVersion: 2,
162 | storageMethod: "localStorage",
163 | migrations: {
164 | 2: (_oldData: typeof store1["defaultData"]) => {
165 | throw new Error("Some error in the migration function");
166 | },
167 | },
168 | });
169 |
170 | // should reset to defaultData, because of the migration error:
171 | await store2.loadData();
172 |
173 | expect(store2.getData().a).toBe(5);
174 | expect(store2.getData().b).toBe(5);
175 | expect(store2.getData().c).toBe(5);
176 | });
177 |
178 | //#region invalid persistent data
179 | it("Invalid persistent data", async () => {
180 | const store1 = new TestDataStore({
181 | id: "test-6",
182 | defaultData: { a: 1, b: 2 },
183 | formatVersion: 1,
184 | storageMethod: "sessionStorage",
185 | });
186 |
187 | await store1.loadData();
188 | await store1.setData({ ...store1.getData(), a: 2 });
189 |
190 | await store1.test_setValue(`_uucfg-${store1.id}`, "invalid");
191 |
192 | // should reset to defaultData:
193 | await store1.loadData();
194 |
195 | expect(store1.getData().a).toBe(1);
196 | expect(store1.getData().b).toBe(2);
197 |
198 | // @ts-expect-error
199 | window.GM = {
200 | getValue: async () => 1337,
201 | setValue: async () => undefined,
202 | }
203 |
204 | const store2 = new TestDataStore({
205 | id: "test-7",
206 | defaultData: { a: 1, b: 2 },
207 | formatVersion: 1,
208 | storageMethod: "GM",
209 | });
210 |
211 | await store1.setData({ ...store1.getData(), a: 2 });
212 |
213 | // invalid type number should reset to defaultData:
214 | await store2.loadData();
215 |
216 | expect(store2.getData().a).toBe(1);
217 | expect(store2.getData().b).toBe(2);
218 |
219 | // @ts-expect-error
220 | delete window.GM;
221 | });
222 | });
223 |
--------------------------------------------------------------------------------
/lib/DataStoreSerializer.spec.ts:
--------------------------------------------------------------------------------
1 | import { afterAll, beforeAll, describe, expect, it } from "vitest";
2 | import { DataStoreSerializer } from "./DataStoreSerializer.js";
3 | import { DataStore } from "./DataStore.js";
4 | import { beforeEach } from "node:test";
5 | import { compress, decompress } from "./crypto.js";
6 |
7 | const store1 = new DataStore({
8 | id: "dss-test-1",
9 | defaultData: { a: 1, b: 2 },
10 | formatVersion: 1,
11 | storageMethod: "sessionStorage",
12 | });
13 |
14 | const store2 = new DataStore({
15 | id: "dss-test-2",
16 | defaultData: { c: 1, d: 2 },
17 | formatVersion: 1,
18 | storageMethod: "sessionStorage",
19 | encodeData: async (data) => await compress(data, "deflate-raw", "string"),
20 | decodeData: async (data) => await decompress(data, "deflate-raw", "string"),
21 | });
22 |
23 | const getStores = () => [
24 | store1,
25 | store2,
26 | ];
27 |
28 | describe("DataStoreSerializer", () => {
29 | beforeEach(async () => {
30 | const ser = new DataStoreSerializer(getStores());
31 | await ser.deleteStoresData();
32 | await ser.resetStoresData();
33 | await ser.loadStoresData();
34 | });
35 |
36 | afterAll(async () => {
37 | await new DataStoreSerializer(getStores()).deleteStoresData();
38 | });
39 |
40 | it("Serialization", async () => {
41 | const ser = new DataStoreSerializer(getStores());
42 | await ser.loadStoresData();
43 |
44 | const full = await ser.serialize();
45 | expect(full).toEqual(`[{"id":"dss-test-1","data":"{\\"a\\":1,\\"b\\":2}","formatVersion":1,"encoded":false,"checksum":"43258cff783fe7036d8a43033f830adfc60ec037382473548ac742b888292777"},{"id":"dss-test-2","data":"q1ZKVrIy1FFKUbIyqgUA","formatVersion":1,"encoded":true,"checksum":"b1020c3faac493009494fa622f701b831657c11ea53f8c8236f0689089c7e2d3"}]`);
46 |
47 | const partial = await ser.serializePartial(["dss-test-1"]);
48 | expect(partial).toEqual(`[{"id":"dss-test-1","data":"{\\"a\\":1,\\"b\\":2}","formatVersion":1,"encoded":false,"checksum":"43258cff783fe7036d8a43033f830adfc60ec037382473548ac742b888292777"}]`);
49 |
50 | const unencoded = await ser.serializePartial(["dss-test-2"], false);
51 | expect(unencoded).toEqual(`[{"id":"dss-test-2","data":"{\\"c\\":1,\\"d\\":2}","formatVersion":1,"encoded":false,"checksum":"86cada6157f4b726bf413e0371a2f461a82d2809e6eb3c095ec796fcfd8d72ee"}]`);
52 |
53 | const notStringified = await ser.serializePartial(["dss-test-2"], false, false);
54 | expect(DataStoreSerializer.isSerializedDataStoreObjArray(notStringified)).toBe(true);
55 | expect(DataStoreSerializer.isSerializedDataStoreObj(notStringified?.[0])).toBe(true);
56 | expect(notStringified).toEqual([
57 | {
58 | id: "dss-test-2",
59 | data: "{\"c\":1,\"d\":2}",
60 | encoded: false,
61 | formatVersion: 1,
62 | checksum: "86cada6157f4b726bf413e0371a2f461a82d2809e6eb3c095ec796fcfd8d72ee",
63 | },
64 | ]);
65 | });
66 |
67 | it("Deserialization", async () => {
68 | const stores = getStores();
69 | const ser = new DataStoreSerializer(stores);
70 |
71 | await ser.deserialize(`[{"id":"dss-test-2","data":"{\\"c\\":420,\\"d\\":420}","formatVersion":1,"encoded":false}]`);
72 | expect(store2.getData().c).toBe(420);
73 |
74 | await ser.resetStoresData();
75 | expect(store1.getData().a).toBe(1);
76 | expect(store2.getData().c).toBe(1);
77 |
78 | await ser.resetStoresData();
79 | await ser.deserializePartial(["dss-test-1"], `[{"id":"dss-test-1","data":"{\\"a\\":421,\\"b\\":421}","checksum":"ad33b8f6a1d18c781a80390496b1b7dfaf56d73cf25a9497cb156ba83214357d","formatVersion":1,"encoded":false}, {"id":"dss-test-2","data":"{\\"c\\":421,\\"d\\":421}","formatVersion":1,"encoded":false}]`);
80 | expect(store1.getData().a).toBe(421);
81 | expect(store2.getData().c).toBe(1);
82 |
83 | await ser.resetStoresData();
84 | await ser.deserializePartial(["dss-test-2"], `[{"id":"dss-test-1","data":"{\\"a\\":422,\\"b\\":422}","formatVersion":1,"encoded":false}, {"id":"dss-test-2","data":"{\\"c\\":422,\\"d\\":422}","checksum":"ab1d18cf13554369cea6bb517a9034e3d6548f19a40d176b16ac95c8e02d65bb","formatVersion":1,"encoded":false}]`);
85 | expect(store1.getData().a).toBe(1);
86 | expect(store2.getData().c).toBe(422);
87 |
88 | await ser.resetStoresData(() => false);
89 | expect(store1.getData().a).toBe(1);
90 | expect(store2.getData().c).toBe(422);
91 | });
92 | });
93 |
--------------------------------------------------------------------------------
/lib/DataStoreSerializer.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module lib/DataStoreSerializer
3 | * This module contains the DataStoreSerializer class, which allows you to import and export serialized DataStore data - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#datastoreserializer)
4 | */
5 |
6 | import { computeHash } from "./crypto.js";
7 | import { getUnsafeWindow } from "./dom.js";
8 | import { ChecksumMismatchError } from "./errors.js";
9 | import type { DataStore } from "./DataStore.js";
10 |
11 | export type DataStoreSerializerOptions = {
12 | /** Whether to add a checksum to the exported data. Defaults to `true` */
13 | addChecksum?: boolean;
14 | /** Whether to ensure the integrity of the data when importing it by throwing an error (doesn't throw when the checksum property doesn't exist). Defaults to `true` */
15 | ensureIntegrity?: boolean;
16 | };
17 |
18 | /** Serialized data of a DataStore instance */
19 | export type SerializedDataStore = {
20 | /** The ID of the DataStore instance */
21 | id: string;
22 | /** The serialized data */
23 | data: string;
24 | /** The format version of the data */
25 | formatVersion: number;
26 | /** Whether the data is encoded */
27 | encoded: boolean;
28 | /** The checksum of the data - key is not present when `addChecksum` is `false` */
29 | checksum?: string;
30 | };
31 |
32 | /** Result of {@linkcode DataStoreSerializer.loadStoresData()} */
33 | export type LoadStoresDataResult = {
34 | /** The ID of the DataStore instance */
35 | id: string;
36 | /** The in-memory data object */
37 | data: object;
38 | }
39 |
40 | /** A filter for selecting data stores */
41 | export type StoreFilter = string[] | ((id: string) => boolean);
42 |
43 | /**
44 | * Allows for easy serialization and deserialization of multiple DataStore instances.
45 | *
46 | * All methods are at least `protected`, so you can easily extend this class and overwrite them to use a different storage method or to add additional functionality.
47 | * Remember that you can call `super.methodName()` in the subclass to access the original method.
48 | *
49 | * - ⚠️ Needs to run in a secure context (HTTPS) due to the use of the SubtleCrypto API if checksumming is enabled.
50 | */
51 | export class DataStoreSerializer {
52 | protected stores: DataStore[];
53 | protected options: Required;
54 |
55 | constructor(stores: DataStore[], options: DataStoreSerializerOptions = {}) {
56 | if(!getUnsafeWindow().crypto || !getUnsafeWindow().crypto.subtle)
57 | throw new Error("DataStoreSerializer has to run in a secure context (HTTPS)!");
58 |
59 | this.stores = stores;
60 | this.options = {
61 | addChecksum: true,
62 | ensureIntegrity: true,
63 | ...options,
64 | };
65 | }
66 |
67 | /** Calculates the checksum of a string */
68 | protected async calcChecksum(input: string): Promise {
69 | return computeHash(input, "SHA-256");
70 | }
71 |
72 | /**
73 | * Serializes only a subset of the data stores into a string.
74 | * @param stores An array of store IDs or functions that take a store ID and return a boolean
75 | * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
76 | * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
77 | */
78 | public async serializePartial(stores: StoreFilter, useEncoding?: boolean, stringified?: true): Promise;
79 | /**
80 | * Serializes only a subset of the data stores into a string.
81 | * @param stores An array of store IDs or functions that take a store ID and return a boolean
82 | * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
83 | * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
84 | */
85 | public async serializePartial(stores: StoreFilter, useEncoding?: boolean, stringified?: false): Promise;
86 | /**
87 | * Serializes only a subset of the data stores into a string.
88 | * @param stores An array of store IDs or functions that take a store ID and return a boolean
89 | * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
90 | * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
91 | */
92 | public async serializePartial(stores: StoreFilter, useEncoding?: boolean, stringified?: boolean): Promise;
93 | /**
94 | * Serializes only a subset of the data stores into a string.
95 | * @param stores An array of store IDs or functions that take a store ID and return a boolean
96 | * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
97 | * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
98 | */
99 | public async serializePartial(stores: StoreFilter, useEncoding = true, stringified = true): Promise {
100 | const serData: SerializedDataStore[] = [];
101 |
102 | for(const storeInst of this.stores.filter(s => typeof stores === "function" ? stores(s.id) : stores.includes(s.id))) {
103 | const data = useEncoding && storeInst.encodingEnabled()
104 | ? await storeInst.encodeData(JSON.stringify(storeInst.getData()))
105 | : JSON.stringify(storeInst.getData());
106 |
107 | serData.push({
108 | id: storeInst.id,
109 | data,
110 | formatVersion: storeInst.formatVersion,
111 | encoded: useEncoding && storeInst.encodingEnabled(),
112 | checksum: this.options.addChecksum
113 | ? await this.calcChecksum(data)
114 | : undefined,
115 | });
116 | }
117 |
118 | return stringified ? JSON.stringify(serData) : serData;
119 | }
120 |
121 | /**
122 | * Serializes the data stores into a string.
123 | * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
124 | * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
125 | */
126 | public async serialize(useEncoding?: boolean, stringified?: true): Promise;
127 | /**
128 | * Serializes the data stores into a string.
129 | * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
130 | * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
131 | */
132 | public async serialize(useEncoding?: boolean, stringified?: false): Promise;
133 | /**
134 | * Serializes the data stores into a string.
135 | * @param useEncoding Whether to encode the data using each DataStore's `encodeData()` method
136 | * @param stringified Whether to return the result as a string or as an array of `SerializedDataStore` objects
137 | */
138 | public async serialize(useEncoding = true, stringified = true): Promise {
139 | return this.serializePartial(this.stores.map(s => s.id), useEncoding, stringified);
140 | }
141 |
142 | /**
143 | * Deserializes the data exported via {@linkcode serialize()} and imports only a subset into the DataStore instances.
144 | * Also triggers the migration process if the data format has changed.
145 | */
146 | public async deserializePartial(stores: StoreFilter, data: string | SerializedDataStore[]): Promise {
147 | const deserStores: SerializedDataStore[] = typeof data === "string" ? JSON.parse(data) : data;
148 |
149 | if(!Array.isArray(deserStores) || !deserStores.every(DataStoreSerializer.isSerializedDataStoreObj))
150 | throw new TypeError("Invalid serialized data format! Expected an array of SerializedDataStore objects.");
151 |
152 | for(const storeData of deserStores.filter(s => typeof stores === "function" ? stores(s.id) : stores.includes(s.id))) {
153 | const storeInst = this.stores.find(s => s.id === storeData.id);
154 | if(!storeInst)
155 | throw new Error(`DataStore instance with ID "${storeData.id}" not found! Make sure to provide it in the DataStoreSerializer constructor.`);
156 |
157 | if(this.options.ensureIntegrity && typeof storeData.checksum === "string") {
158 | const checksum = await this.calcChecksum(storeData.data);
159 | if(checksum !== storeData.checksum)
160 | throw new ChecksumMismatchError(`Checksum mismatch for DataStore with ID "${storeData.id}"!\nExpected: ${storeData.checksum}\nHas: ${checksum}`);
161 | }
162 |
163 | const decodedData = storeData.encoded && storeInst.encodingEnabled()
164 | ? await storeInst.decodeData(storeData.data)
165 | : storeData.data;
166 |
167 | if(storeData.formatVersion && !isNaN(Number(storeData.formatVersion)) && Number(storeData.formatVersion) < storeInst.formatVersion)
168 | await storeInst.runMigrations(JSON.parse(decodedData), Number(storeData.formatVersion), false);
169 | else
170 | await storeInst.setData(JSON.parse(decodedData));
171 | }
172 | }
173 |
174 | /**
175 | * Deserializes the data exported via {@linkcode serialize()} and imports the data into all matching DataStore instances.
176 | * Also triggers the migration process if the data format has changed.
177 | */
178 | public async deserialize(data: string | SerializedDataStore[]): Promise {
179 | return this.deserializePartial(this.stores.map(s => s.id), data);
180 | }
181 |
182 | /**
183 | * Loads the persistent data of the DataStore instances into the in-memory cache.
184 | * Also triggers the migration process if the data format has changed.
185 | * @param stores An array of store IDs or a function that takes the store IDs and returns a boolean - if omitted, all stores will be loaded
186 | * @returns Returns a PromiseSettledResult array with the results of each DataStore instance in the format `{ id: string, data: object }`
187 | */
188 | public async loadStoresData(stores?: StoreFilter): Promise[]> {
189 | return Promise.allSettled(
190 | this.getStoresFiltered(stores)
191 | .map(async (store) => ({
192 | id: store.id,
193 | data: await store.loadData(),
194 | })),
195 | );
196 | }
197 |
198 | /**
199 | * Resets the persistent and in-memory data of the DataStore instances to their default values.
200 | * @param stores An array of store IDs or a function that takes the store IDs and returns a boolean - if omitted, all stores will be affected
201 | */
202 | public async resetStoresData(stores?: StoreFilter): Promise[]> {
203 | return Promise.allSettled(
204 | this.getStoresFiltered(stores).map(store => store.saveDefaultData()),
205 | );
206 | }
207 |
208 | /**
209 | * Deletes the persistent data of the DataStore instances.
210 | * Leaves the in-memory data untouched.
211 | * @param stores An array of store IDs or a function that takes the store IDs and returns a boolean - if omitted, all stores will be affected
212 | */
213 | public async deleteStoresData(stores?: StoreFilter): Promise[]> {
214 | return Promise.allSettled(
215 | this.getStoresFiltered(stores).map(store => store.deleteData()),
216 | );
217 | }
218 |
219 | /** Checks if a given value is an array of SerializedDataStore objects */
220 | public static isSerializedDataStoreObjArray(obj: unknown): obj is SerializedDataStore[] {
221 | return Array.isArray(obj) && obj.every((o) => typeof o === "object" && o !== null && "id" in o && "data" in o && "formatVersion" in o && "encoded" in o);
222 | }
223 |
224 | /** Checks if a given value is a SerializedDataStore object */
225 | public static isSerializedDataStoreObj(obj: unknown): obj is SerializedDataStore {
226 | return typeof obj === "object" && obj !== null && "id" in obj && "data" in obj && "formatVersion" in obj && "encoded" in obj;
227 | }
228 |
229 | /** Returns the DataStore instances whose IDs match the provided array or function */
230 | protected getStoresFiltered(stores?: StoreFilter): DataStore[] {
231 | return this.stores.filter(s => typeof stores === "undefined" ? true : Array.isArray(stores) ? stores.includes(s.id) : stores(s.id));
232 | }
233 | }
234 |
--------------------------------------------------------------------------------
/lib/Debouncer.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { debounce, Debouncer } from "./Debouncer.js";
3 | import { pauseFor } from "./misc.js";
4 |
5 | describe("Debouncer", () => {
6 | //#region deltaT
7 | it("deltaT test with type \"immediate\"", async () => {
8 | const deb = new Debouncer(200, "immediate");
9 |
10 | deb.addListener(debCalled);
11 |
12 | const deltaTs: number[] = [];
13 | let lastCall: number | undefined;
14 | function debCalled() {
15 | const n = Date.now(),
16 | deltaT = lastCall ? n - lastCall : undefined;
17 | typeof deltaT === "number" && deltaT > 0 && deltaTs.push(deltaT);
18 | lastCall = n;
19 | }
20 |
21 | for(let i = 0; i < 2; i++) {
22 | for(let j = 0; j < 6; j++) {
23 | deb.call(i, j);
24 | expect(deb.isTimeoutActive()).toBe(true);
25 | await pauseFor(50);
26 | }
27 | await pauseFor(300);
28 | }
29 |
30 | const avg = deltaTs
31 | .reduce((a, b) => a + b, 0) / deltaTs.length;
32 |
33 | expect(avg + 10).toBeLessThanOrEqual(deb.getTimeout() + 50);
34 | });
35 |
36 | //#region idle
37 | it("deltaT test with type \"idle\"", async () => {
38 | const deb = new Debouncer(200, "idle");
39 |
40 | deb.addListener(debCalled);
41 |
42 | const deltaTs: number[] = [];
43 | let callCount = 0;
44 | let lastCall: number | undefined;
45 | function debCalled() {
46 | callCount++;
47 | const n = Date.now(),
48 | deltaT = lastCall ? n - lastCall : undefined;
49 | typeof deltaT === "number" && deltaT > 0 && deltaTs.push(deltaT);
50 | lastCall = n;
51 | }
52 |
53 | const jAmt = 6,
54 | iTime = 400,
55 | jTime = 30;
56 | for(let i = 0; i < 2; i++) {
57 | for(let j = 0; j < jAmt; j++) {
58 | deb.call(i, j);
59 | await pauseFor(jTime);
60 | }
61 | await pauseFor(iTime);
62 | }
63 |
64 | expect(callCount).toBeLessThanOrEqual(5); // expected 2~3 calls
65 |
66 | /** Minimum possible deltaT between calls */
67 | const minDeltaT = jAmt * jTime + iTime;
68 | const avg = deltaTs
69 | .reduce((a, b) => a + b, 0) / deltaTs.length;
70 |
71 | expect(avg + 10).toBeGreaterThanOrEqual(minDeltaT);
72 | });
73 |
74 | //#region modify props & listeners
75 | it("Modify props and listeners", async () => {
76 | const deb = new Debouncer(200);
77 |
78 | expect(deb.getTimeout()).toBe(200);
79 | deb.setTimeout(10);
80 | expect(deb.getTimeout()).toBe(10);
81 |
82 | expect(deb.getType()).toBe("immediate");
83 | deb.setType("idle");
84 | expect(deb.getType()).toBe("idle");
85 |
86 | const l = () => {};
87 | deb.addListener(l);
88 | deb.addListener(() => {});
89 | expect(deb.getListeners()).toHaveLength(2);
90 |
91 | deb.removeListener(l);
92 | expect(deb.getListeners()).toHaveLength(1);
93 |
94 | deb.removeAllListeners();
95 | expect(deb.getListeners()).toHaveLength(0);
96 | });
97 |
98 | //#region all methods
99 | // TODO:FIXME:
100 | it.skip("All methods", async () => {
101 | const deb = new Debouncer<(v?: number) => void>(200);
102 |
103 | let callAmt = 0, evtCallAmt = 0;
104 | const debCalled = (): number => ++callAmt;
105 | const debCalledEvt = (): number => ++evtCallAmt;
106 |
107 | // hook debCalled first, then call, then hook debCalledEvt:
108 | deb.addListener(debCalled);
109 |
110 | deb.call();
111 |
112 | deb.on("call", debCalledEvt);
113 |
114 | expect(callAmt).toBe(1);
115 | expect(evtCallAmt).toBe(0);
116 |
117 | deb.setTimeout(10);
118 | expect(deb.getTimeout()).toBe(10);
119 |
120 | const callPaused = (v?: number): Promise => {
121 | deb.call(v);
122 | return pauseFor(50);
123 | };
124 |
125 | let onceAmt = 0;
126 | deb.once("call", () => ++onceAmt);
127 | await callPaused();
128 | await callPaused();
129 | await callPaused();
130 | expect(onceAmt).toBe(1);
131 |
132 | let args = 0;
133 | const setArgs = (v?: number) => args = v ?? args;
134 | deb.addListener(setArgs);
135 | await callPaused(1);
136 | expect(args).toBe(1);
137 |
138 | deb.removeListener(setArgs);
139 | await callPaused(2);
140 | expect(args).toBe(1);
141 |
142 | deb.removeAllListeners();
143 | await callPaused();
144 | expect(callAmt).toEqual(evtCallAmt + 1); // evtCallAmt is always behind by 1
145 | });
146 |
147 | //#region errors
148 | it("Errors", () => {
149 | try {
150 | // @ts-expect-error
151 | const deb = new Debouncer(200, "invalid");
152 | deb.call();
153 | }
154 | catch(e) {
155 | expect(e).toBeInstanceOf(TypeError);
156 | }
157 | });
158 |
159 | //#region debounce function
160 | it("Debounce function", async () => {
161 | let callAmt = 0;
162 | const callFn = debounce(() => ++callAmt, 200);
163 |
164 | for(let i = 0; i < 4; i++) {
165 | callFn();
166 | await pauseFor(25);
167 | }
168 |
169 | expect(callAmt).toBe(1);
170 | });
171 | });
172 |
--------------------------------------------------------------------------------
/lib/Debouncer.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 |
3 | /**
4 | * @module lib/Debouncer
5 | * This module contains the Debouncer class and debounce function that allow you to reduce the amount of calls in rapidly firing event listeners and such - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#debouncer)
6 | */
7 |
8 | import { NanoEmitter } from "./NanoEmitter.js";
9 |
10 | //#region types
11 |
12 | /**
13 | * The type of edge to use for the debouncer - [see the docs for a diagram and explanation.](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#debouncer)
14 | * - `immediate` - (default & recommended) - calls the listeners at the very first call ("rising" edge) and queues the latest call until the timeout expires
15 | * - Pros:
16 | * - First call is let through immediately
17 | * - Cons:
18 | * - After all calls stop, the JS engine's event loop will continue to run until the last timeout expires (doesn't really matter on the web, but could cause a process exit delay in Node.js)
19 | * - `idle` - queues all calls until there are no more calls in the given timeout duration ("falling" edge), and only then executes the very last call
20 | * - Pros:
21 | * - Makes sure there are zero calls in the given `timeoutDuration` before executing the last call
22 | * - Cons:
23 | * - Calls are always delayed by at least `1 * timeoutDuration`
24 | * - Calls could get stuck in the queue indefinitely if there is no downtime between calls that is greater than the `timeoutDuration`
25 | */
26 | export type DebouncerType = "immediate" | "idle";
27 |
28 | type AnyFunc = (...args: any) => any;
29 |
30 | /** The debounced function type that is returned by the {@linkcode debounce} function */
31 | export type DebouncedFunction = ((...args: Parameters) => ReturnType) & { debouncer: Debouncer };
32 |
33 | /** Event map for the {@linkcode Debouncer} */
34 | export type DebouncerEventMap = {
35 | /** Emitted when the debouncer calls all registered listeners, as a pub-sub alternative */
36 | call: TFunc;
37 | /** Emitted when the timeout or edge type is changed after the instance was created */
38 | change: (timeout: number, type: DebouncerType) => void;
39 | };
40 |
41 | //#region debounce class
42 |
43 | /**
44 | * A debouncer that calls all listeners after a specified timeout, discarding all calls in-between.
45 | * It is very useful for event listeners that fire quickly, like `input` or `mousemove`, to prevent the listeners from being called too often and hogging resources.
46 | * The exact behavior can be customized with the `type` parameter.
47 | *
48 | * The instance inherits from {@linkcode NanoEmitter} and emits the following events:
49 | * - `call` - emitted when the debouncer calls all listeners - use this as a pub-sub alternative to the default callback-style listeners
50 | * - `change` - emitted when the timeout or edge type is changed after the instance was created
51 | */
52 | export class Debouncer extends NanoEmitter> {
53 | /** All registered listener functions and the time they were attached */
54 | protected listeners: TFunc[] = [];
55 |
56 | /** The currently active timeout */
57 | protected activeTimeout: ReturnType | undefined;
58 |
59 | /** The latest queued call */
60 | protected queuedCall: (() => void) | undefined;
61 |
62 | /**
63 | * Creates a new debouncer with the specified timeout and edge type.
64 | * @param timeout Timeout in milliseconds between letting through calls - defaults to 200
65 | * @param type The edge type to use for the debouncer - see {@linkcode DebouncerType} for details or [the documentation for an explanation and diagram](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#debouncer) - defaults to "immediate"
66 | */
67 | constructor(protected timeout = 200, protected type: DebouncerType = "immediate") {
68 | super();
69 | }
70 |
71 | //#region listeners
72 |
73 | /** Adds a listener function that will be called on timeout */
74 | public addListener(fn: TFunc): void {
75 | this.listeners.push(fn);
76 | }
77 |
78 | /** Removes the listener with the specified function reference */
79 | public removeListener(fn: TFunc): void {
80 | const idx = this.listeners.findIndex((l) => l === fn);
81 | idx !== -1 && this.listeners.splice(idx, 1);
82 | }
83 |
84 | /** Removes all listeners */
85 | public removeAllListeners(): void {
86 | this.listeners = [];
87 | }
88 |
89 | /** Returns all registered listeners */
90 | public getListeners(): TFunc[] {
91 | return this.listeners;
92 | }
93 |
94 | //#region timeout
95 |
96 | /** Sets the timeout for the debouncer */
97 | public setTimeout(timeout: number): void {
98 | this.emit("change", this.timeout = timeout, this.type);
99 | }
100 |
101 | /** Returns the current timeout */
102 | public getTimeout(): number {
103 | return this.timeout;
104 | }
105 |
106 | /** Whether the timeout is currently active, meaning any latest call to the {@linkcode call()} method will be queued */
107 | public isTimeoutActive(): boolean {
108 | return typeof this.activeTimeout !== "undefined";
109 | }
110 |
111 | //#region type
112 |
113 | /** Sets the edge type for the debouncer */
114 | public setType(type: DebouncerType): void {
115 | this.emit("change", this.timeout, this.type = type);
116 | }
117 |
118 | /** Returns the current edge type */
119 | public getType(): DebouncerType {
120 | return this.type;
121 | }
122 |
123 | //#region call
124 |
125 | /** Use this to call the debouncer with the specified arguments that will be passed to all listener functions registered with {@linkcode addListener()} */
126 | public call(...args: Parameters): void {
127 | /** When called, calls all registered listeners */
128 | const cl = (...a: Parameters): void => {
129 | this.queuedCall = undefined;
130 | this.emit("call", ...a);
131 | this.listeners.forEach((l) => l.call(this, ...a));
132 | };
133 |
134 | /** Sets a timeout that will call the latest queued call and then set another timeout if there was a queued call */
135 | const setRepeatTimeout = (): void => {
136 | this.activeTimeout = setTimeout(() => {
137 | if(this.queuedCall) {
138 | this.queuedCall();
139 | setRepeatTimeout();
140 | }
141 | else
142 | this.activeTimeout = undefined;
143 | }, this.timeout);
144 | };
145 |
146 | switch(this.type) {
147 | case "immediate":
148 | if(typeof this.activeTimeout === "undefined") {
149 | cl(...args);
150 | setRepeatTimeout();
151 | }
152 | else
153 | this.queuedCall = () => cl(...args);
154 |
155 | break;
156 | case "idle":
157 | if(this.activeTimeout)
158 | clearTimeout(this.activeTimeout);
159 |
160 | this.activeTimeout = setTimeout(() => {
161 | cl(...args);
162 | this.activeTimeout = undefined;
163 | }, this.timeout);
164 |
165 | break;
166 | default:
167 | throw new TypeError(`Invalid debouncer type: ${this.type}`);
168 | }
169 | }
170 | }
171 |
172 | //#region debounce fn
173 |
174 | /**
175 | * Creates a {@linkcode Debouncer} instance with the specified timeout and edge type and attaches the passed function as a listener.
176 | * The returned function can be called with any arguments and will execute the `call()` method of the debouncer.
177 | * The debouncer instance is accessible via the `debouncer` property of the returned function.
178 | *
179 | * Refer to the {@linkcode Debouncer} class definition or the [Debouncer documentation](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#debouncer) for more information.
180 | */
181 | export function debounce any>(
182 | fn: TFunc,
183 | timeout = 200,
184 | type: DebouncerType = "immediate"
185 | ): DebouncedFunction {
186 | const debouncer = new Debouncer(timeout, type);
187 | debouncer.addListener(fn);
188 |
189 | const func = (((...args: Parameters) => debouncer.call(...args))) as DebouncedFunction;
190 | func.debouncer = debouncer;
191 |
192 | return func;
193 | }
194 |
--------------------------------------------------------------------------------
/lib/Dialog.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { Dialog } from "./Dialog.js";
3 |
4 | //TODO:FIXME: doesn't work because of random "DOMException {}"
5 | describe.skip("Dialog", () => {
6 | it("Gets created, opened, closed and deleted properly", async () => {
7 | const dialog = new Dialog({
8 | id: "test-1",
9 | height: 100,
10 | width: 200,
11 | renderBody: () => document.createElement("div"),
12 | });
13 |
14 | expect(document.querySelector(".uu-dialog-bg")).toBeNull();
15 |
16 | await dialog.mount();
17 |
18 | expect(document.querySelector(".uu-dialog-bg")).not.toBeNull();
19 |
20 | expect(document.body.classList.contains("uu-no-select")).toBe(false);
21 | await dialog.open();
22 | expect(document.body.classList.contains("uu-no-select")).toBe(true);
23 |
24 | dialog.close();
25 | expect(document.body.classList.contains("uu-no-select")).toBe(false);
26 |
27 | dialog.unmount();
28 | expect(document.querySelector(".uu-dialog-bg")).toBeNull();
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/lib/Mixins.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from "vitest";
2 | import { Mixins } from "./Mixins.js";
3 |
4 | describe("Mixins", () => {
5 | //#region base
6 | it("Base resolution", () => {
7 | const mixins = new Mixins<{
8 | foo: (v: number, ctx: { a: number }) => number;
9 | }>({ autoIncrementPriority: true });
10 |
11 | mixins.add("foo", (v) => v ^ 0b0001); // 1 (prio 0)
12 | mixins.add("foo", (v) => v ^ 0b1000); // 2 (prio 1)
13 | mixins.add("foo", (v, c) => v ^ c.a); // 3 (prio 2)
14 |
15 | // input: 0b1100
16 | // 1: 0b1100 ^ 0b0001 = 0b1101
17 | // 2: 0b1101 ^ 0b1000 = 0b0101
18 | // 3: 0b0101 ^ 0b0100 = 0b0001
19 | // result: 0b0001 = 1
20 |
21 | expect(mixins.resolve("foo", 0b1100, { a: 0b0100 })).toBe(1);
22 |
23 | expect(mixins.list()).toHaveLength(3);
24 | expect(mixins.list().every(m => m.key === "foo")).toBe(true);
25 | });
26 |
27 | //#region priority
28 | it("Priority resolution", () => {
29 | const mixins = new Mixins<{
30 | foo: (v: number) => number;
31 | }>();
32 |
33 | mixins.add("foo", (v) => v / 2, 1); // 2 (prio 1)
34 | mixins.add("foo", (v) => Math.round(Math.log(v) * 10), -1); // 4 (prio -1)
35 | mixins.add("foo", (v) => Math.pow(v, 2)); // 3 (prio 0)
36 | mixins.add("foo", (v) => Math.sqrt(v), Number.MAX_SAFE_INTEGER); // 1 (prio max)
37 |
38 | // input: 100
39 | // 1: sqrt(100) = 10
40 | // 2: 10 / 2 = 5
41 | // 3: 5 ^ 2 = 25
42 | // 4: round(log(25) * 10) = round(32.188758248682006) = 32
43 | // result: 3
44 |
45 | expect(mixins.resolve("foo", 100)).toBe(32);
46 | });
47 |
48 | //#region sync/async & cleanup
49 | it("Sync/async resolution & cleanup", async () => {
50 | const acAll = new AbortController();
51 |
52 | const mixins = new Mixins<{
53 | foo: (v: number) => Promise;
54 | }>({
55 | defaultSignal: acAll.signal,
56 | });
57 |
58 | const ac1 = new AbortController();
59 |
60 | mixins.add("foo", (v) => Math.sqrt(v), { signal: ac1.signal }); // 1 (prio 0, index 0)
61 | mixins.add("foo", (v) => Math.pow(v, 4)); // 2 (prio 0, index 1)
62 | const rem3 = mixins.add("foo", async (v) => { // 3 (prio 0, index 2)
63 | await new Promise((r) => setTimeout(r, 50));
64 | return v + 2;
65 | });
66 | const rem4 = mixins.add("foo", async (v) => v); // 4 (prio 0, index 3)
67 |
68 | const res1 = mixins.resolve("foo", 100);
69 | expect(res1).toBeInstanceOf(Promise);
70 | expect(await res1).toBe(10002);
71 |
72 | rem3();
73 | rem4();
74 |
75 | const res2 = mixins.resolve("foo", 100);
76 | expect(res2).not.toBeInstanceOf(Promise);
77 | expect(res2).toBe(10000);
78 |
79 | ac1.abort();
80 |
81 | const res3 = mixins.resolve("foo", 100);
82 | expect(res3).not.toBeInstanceOf(Promise);
83 | expect(res3).toBe(100000000);
84 |
85 | acAll.abort();
86 |
87 | const res4 = mixins.resolve("foo", 100);
88 | expect(res4).not.toBeInstanceOf(Promise);
89 | expect(res4).toBe(100);
90 | });
91 | });
92 |
--------------------------------------------------------------------------------
/lib/Mixins.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module lib/Mixins
3 | * Allows for defining and applying mixin functions to allow multiple sources to modify a value in a controlled way.
4 | */
5 |
6 | /* eslint-disable @typescript-eslint/no-explicit-any */
7 |
8 | import { purifyObj } from "./misc.js";
9 | import type { Prettify } from "./types.js";
10 |
11 | /** Full mixin object (either sync or async), as it is stored in the instance's mixin array. */
12 | export type MixinObj = Prettify<
13 | | MixinObjSync
14 | | MixinObjAsync
15 | >;
16 |
17 | /** Asynchronous mixin object, as it is stored in the instance's mixin array. */
18 | export type MixinObjSync = Prettify<{
19 | /** The mixin function */
20 | fn: (arg: TArg, ctx?: TCtx) => TArg;
21 | } & MixinObjBase>;
22 |
23 | /** Synchronous mixin object, as it is stored in the instance's mixin array. */
24 | export type MixinObjAsync = Prettify<{
25 | /** The mixin function */
26 | fn: (arg: TArg, ctx?: TCtx) => TArg | Promise;
27 | } & MixinObjBase>;
28 |
29 | /** Base type for mixin objects */
30 | type MixinObjBase = Prettify<{
31 | /** The public identifier key (purpose) of the mixin */
32 | key: string;
33 | } & MixinConfig>;
34 |
35 | /** Configuration object for a mixin function */
36 | export type MixinConfig = {
37 | /** The higher, the earlier the mixin will be applied. Supports floating-point and negative numbers too. 0 by default. */
38 | priority: number;
39 | /** If true, no further mixins will be applied after this one. */
40 | stopPropagation: boolean;
41 | /** If set, the mixin will only be applied if the given signal is not aborted. */
42 | signal?: AbortSignal;
43 | }
44 |
45 | /** Configuration object for the Mixins class */
46 | export type MixinsConstructorConfig = {
47 | /**
48 | * If true, when no priority is specified, an auto-incrementing integer priority will be used, starting at `defaultPriority` or 0 (unique per mixin key). Defaults to false.
49 | * If a priority value is already used, it will be incremented until a unique value is found.
50 | * This is useful to ensure that mixins are applied in the order they were added, even if they don't specify a priority.
51 | * It also allows for a finer level of interjection when the priority is a floating point number.
52 | */
53 | autoIncrementPriority: boolean;
54 | /** The default priority for mixins that do not specify one. Defaults to 0. */
55 | defaultPriority: number;
56 | /** The default stopPropagation value for mixins that do not specify one. Defaults to false. */
57 | defaultStopPropagation: boolean;
58 | /** The default AbortSignal for mixins that do not specify one. Defaults to undefined. */
59 | defaultSignal?: AbortSignal;
60 | }
61 |
62 | //#region class
63 |
64 | /**
65 | * The mixin class allows for defining and applying mixin functions to allow multiple sources to modify values in a controlled way.
66 | * Mixins are identified via their string key and can be added with {@linkcode add()}
67 | * When calling {@linkcode resolve()}, all registered mixin functions with the same key will be applied to the input value in the order of their priority.
68 | * If a mixin has the stopPropagation flag set to true, no further mixins will be applied after it.
69 | * @template TMixinMap A map of mixin keys to their respective function signatures. The first argument of the function is the input value, the second argument is an optional context object. If it is defined here, it must be passed as the third argument in {@linkcode resolve()}.
70 | * @example ```ts
71 | * const ac = new AbortController();
72 | * const { abort: removeAllMixins } = ac;
73 | *
74 | * const mathMixins = new Mixins<{
75 | * // supports sync and async functions:
76 | * foo: (val: number, ctx: { baz: string }) => Promise;
77 | * // first argument and return value have to be of the same type:
78 | * bar: (val: bigint) => bigint;
79 | * // ...
80 | * }>({
81 | * autoIncrementPriority: true,
82 | * defaultPriority: 0,
83 | * defaultSignal: ac.signal,
84 | * });
85 | *
86 | * // will be applied last due to base priority of 0:
87 | * mathMixins.add("foo", (val, ctx) => Promise.resolve(val * 2 + ctx.baz.length));
88 | * // will be applied second due to manually set priority of 1:
89 | * mathMixins.add("foo", (val) => val + 1, { priority: 1 });
90 | * // will be applied first, even though the above ones were called first, because of the auto-incrementing priority of 2:
91 | * mathMixins.add("foo", (val) => val / 2);
92 | *
93 | * const result = await mathMixins.resolve("foo", 10, { baz: "this has a length of 23" });
94 | * // order of application:
95 | * // input value: 10
96 | * // 10 / 2 = 5
97 | * // 5 + 1 = 6
98 | * // 6 * 2 + 23 = 35
99 | * // result = 35
100 | *
101 | * // removes all mixins added without a `signal` property:
102 | * removeAllMixins();
103 | * ```
104 | */
105 | export class Mixins<
106 | TMixinMap extends Record any>,
107 | TMixinKey extends Extract = Extract,
108 | > {
109 | /** List of all registered mixins */
110 | protected mixins: MixinObj[] = [];
111 |
112 | /** Default configuration object for mixins */
113 | protected readonly defaultMixinCfg: MixinConfig;
114 |
115 | /** Whether the priorities should auto-increment if not specified */
116 | protected readonly autoIncPrioEnabled: boolean;
117 | /** The current auto-increment priority counter */
118 | protected autoIncPrioCounter: Map = new Map();
119 |
120 | /**
121 | * Creates a new Mixins instance.
122 | * @param config Configuration object to customize the behavior.
123 | */
124 | constructor(config: Partial = {}) {
125 | this.defaultMixinCfg = purifyObj({
126 | priority: config.defaultPriority ?? 0,
127 | stopPropagation: config.defaultStopPropagation ?? false,
128 | signal: config.defaultSignal,
129 | });
130 | this.autoIncPrioEnabled = config.autoIncrementPriority ?? false;
131 | }
132 |
133 | //#region public
134 |
135 | /**
136 | * Adds a mixin function to the given {@linkcode mixinKey}.
137 | * If no priority is specified, it will be calculated via the protected method {@linkcode calcPriority()} based on the constructor configuration, or fall back to the default priority.
138 | * @param mixinKey The key to identify the mixin function.
139 | * @param mixinFn The function to be called to apply the mixin. The first argument is the input value, the second argument is the context object (if any).
140 | * @param config Configuration object to customize the mixin behavior, or just the priority if a number is passed.
141 | * @returns Returns a cleanup function, to be called when this mixin is no longer needed.
142 | */
143 | public add<
144 | TKey extends TMixinKey,
145 | TArg extends Parameters[0],
146 | TCtx extends Parameters[1],
147 | >(
148 | mixinKey: TKey,
149 | mixinFn: (arg: TArg, ...ctx: TCtx extends undefined ? [void] : [TCtx]) => ReturnType extends Promise ? ReturnType | Awaited> : ReturnType,
150 | config: Partial | number = purifyObj({}),
151 | ): () => void {
152 | const calcPrio = typeof config === "number" ? config : this.calcPriority(mixinKey, config);
153 | const mixin = purifyObj({
154 | ...this.defaultMixinCfg,
155 | key: mixinKey as string,
156 | fn: mixinFn,
157 | ...(typeof config === "object" ? config : {}),
158 | ...(typeof calcPrio === "number" && !isNaN(calcPrio) ? { priority: calcPrio } : {}),
159 | }) as MixinObj;
160 | this.mixins.push(mixin);
161 |
162 | const rem = (): void => {
163 | this.mixins = this.mixins.filter((m) => m !== mixin);
164 | };
165 | if(mixin.signal)
166 | mixin.signal.addEventListener("abort", rem, { once: true });
167 |
168 | return rem;
169 | }
170 |
171 | /** Returns a list of all added mixins with their keys and configuration objects, but not their functions */
172 | public list(): ({ key: string; } & MixinConfig)[] {
173 | return this.mixins.map(({ fn: _f, ...rest }) => rest);
174 | }
175 |
176 | /**
177 | * Applies all mixins with the given key to the input value, respecting the priority and stopPropagation settings.
178 | * If additional context is set in the MixinMap, it will need to be passed as the third argument.
179 | * @returns The modified value after all mixins have been applied.
180 | */
181 | public resolve<
182 | TKey extends TMixinKey,
183 | TArg extends Parameters[0],
184 | TCtx extends Parameters[1],
185 | >(
186 | mixinKey: TKey,
187 | inputValue: TArg,
188 | ...inputCtx: TCtx extends undefined ? [void] : [TCtx]
189 | ): ReturnType extends Promise ? ReturnType : ReturnType {
190 | const mixins = this.mixins.filter((m) => m.key === mixinKey);
191 | const sortedMixins = [...mixins].sort((a, b) => b.priority - a.priority);
192 | let result = inputValue;
193 |
194 | // start resolving synchronously:
195 | for(let i = 0; i < sortedMixins.length; i++) {
196 | const mixin = sortedMixins[i]!;
197 | result = mixin.fn(result, ...inputCtx);
198 | if(result as unknown instanceof Promise) {
199 | // if one of the mixins is async, switch to async resolution:
200 | return (async () => {
201 | result = await result;
202 | if(mixin.stopPropagation)
203 | return result;
204 | for(let j = i + 1; j < sortedMixins.length; j++) {
205 | const mixin = sortedMixins[j]!;
206 | result = await mixin.fn(result, ...inputCtx);
207 | if(mixin.stopPropagation)
208 | break;
209 | }
210 | return result;
211 | })() as ReturnType extends Promise ? ReturnType : never;
212 | }
213 | else if(mixin.stopPropagation)
214 | break;
215 | }
216 |
217 | return result;
218 | }
219 |
220 | //#region protected
221 |
222 | /** Calculates the priority for a mixin based on the given configuration and the current auto-increment state of the instance */
223 | protected calcPriority(mixinKey: TMixinKey, config: Partial): number | undefined {
224 | // if prio specified, skip calculation
225 | if(config.priority !== undefined)
226 | return undefined;
227 |
228 | // if a-i disabled, use default prio
229 | if(!this.autoIncPrioEnabled)
230 | return config.priority ?? this.defaultMixinCfg.priority;
231 |
232 | // initialize a-i map to default prio
233 | if(!this.autoIncPrioCounter.has(mixinKey))
234 | this.autoIncPrioCounter.set(mixinKey, this.defaultMixinCfg.priority);
235 |
236 | // increment a-i prio until unique
237 | let prio = this.autoIncPrioCounter.get(mixinKey)!;
238 | while(this.mixins.some((m) => m.key === mixinKey && m.priority === prio))
239 | prio++;
240 | this.autoIncPrioCounter.set(mixinKey, prio + 1);
241 |
242 | return prio;
243 | }
244 |
245 | /** Removes all mixins with the given key */
246 | protected removeAll(mixinKey: TMixinKey): void {
247 | this.mixins.filter((m) => m.key === mixinKey);
248 | this.mixins = this.mixins.filter((m) => m.key !== mixinKey);
249 | }
250 | }
251 |
--------------------------------------------------------------------------------
/lib/NanoEmitter.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { NanoEmitter } from "./NanoEmitter.js";
3 |
4 | describe("NanoEmitter", () => {
5 | it("Functional", async () => {
6 | const evts = new NanoEmitter<{
7 | val: (v1: number, v2: number) => void;
8 | }>({
9 | publicEmit: true,
10 | });
11 |
12 | setTimeout(() => evts.emit("val", 5, 5), 1);
13 | const [v1, v2] = await evts.once("val");
14 | expect(v1 + v2).toBe(10);
15 |
16 | let v3 = 0, v4 = 0;
17 | const unsub = evts.on("val", (v1, v2) => {
18 | v3 = v1;
19 | v4 = v2;
20 | });
21 | evts.emit("val", 10, 10);
22 | expect(v3 + v4).toBe(20);
23 |
24 | unsub();
25 | evts.emit("val", 20, 20);
26 | expect(v3 + v4).toBe(20);
27 |
28 | evts.on("val", (v1, v2) => {
29 | v3 = v1;
30 | v4 = v2;
31 | });
32 | evts.emit("val", 30, 30);
33 | expect(v3 + v4).toBe(60);
34 | evts.unsubscribeAll();
35 | evts.emit("val", 40, 40);
36 | expect(v3 + v4).toBe(60);
37 | });
38 |
39 | it("Object oriented", async () => {
40 | class MyEmitter extends NanoEmitter<{
41 | val: (v1: number, v2: number) => void;
42 | }> {
43 | constructor() {
44 | super({ publicEmit: false });
45 | }
46 |
47 | run() {
48 | this.events.emit("val", 5, 5);
49 | }
50 | }
51 |
52 | const evts = new MyEmitter();
53 |
54 | setTimeout(() => evts.run(), 1);
55 | const [v1, v2] = await evts.once("val");
56 | expect(v1 + v2).toBe(10);
57 |
58 | expect(evts.emit("val", 0, 0)).toBe(false);
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/lib/NanoEmitter.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module lib/NanoEmitter
3 | * This module contains the NanoEmitter class, which is a tiny event emitter powered by [nanoevents](https://www.npmjs.com/package/nanoevents) - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#nanoemitter)
4 | */
5 |
6 | import { createNanoEvents, type DefaultEvents, type Emitter, type EventsMap, type Unsubscribe } from "nanoevents";
7 |
8 | export interface NanoEmitterOptions {
9 | /** If set to true, allows emitting events through the public method emit() */
10 | publicEmit: boolean;
11 | }
12 |
13 | /**
14 | * Class that can be extended or instantiated by itself to create a lightweight event emitter with helper methods and a strongly typed event map.
15 | * If extended from, you can use `this.events.emit()` to emit events, even if the `emit()` method doesn't work because `publicEmit` is not set to true in the constructor.
16 | */
17 | export class NanoEmitter {
18 | protected readonly events: Emitter = createNanoEvents();
19 | protected eventUnsubscribes: Unsubscribe[] = [];
20 | protected emitterOptions: NanoEmitterOptions;
21 |
22 | /** Creates a new instance of NanoEmitter - a lightweight event emitter with helper methods and a strongly typed event map */
23 | constructor(options: Partial = {}) {
24 | this.emitterOptions = {
25 | publicEmit: false,
26 | ...options,
27 | };
28 | }
29 |
30 | /**
31 | * Subscribes to an event and calls the callback when it's emitted.
32 | * @param event The event to subscribe to. Use `as "_"` in case your event names aren't thoroughly typed (like when using a template literal, e.g. \`event-${val}\` as "_")
33 | * @returns Returns a function that can be called to unsubscribe the event listener
34 | * @example ```ts
35 | * const emitter = new NanoEmitter<{
36 | * foo: (bar: string) => void;
37 | * }>({
38 | * publicEmit: true,
39 | * });
40 | *
41 | * let i = 0;
42 | * const unsub = emitter.on("foo", (bar) => {
43 | * // unsubscribe after 10 events:
44 | * if(++i === 10) unsub();
45 | * console.log(bar);
46 | * });
47 | *
48 | * emitter.emit("foo", "bar");
49 | * ```
50 | */
51 | public on(event: TKey | "_", cb: TEvtMap[TKey]): () => void {
52 | // eslint-disable-next-line prefer-const
53 | let unsub: Unsubscribe | undefined;
54 |
55 | const unsubProxy = (): void => {
56 | if(!unsub)
57 | return;
58 | unsub();
59 | this.eventUnsubscribes = this.eventUnsubscribes.filter(u => u !== unsub);
60 | };
61 |
62 | unsub = this.events.on(event, cb);
63 |
64 | this.eventUnsubscribes.push(unsub);
65 | return unsubProxy;
66 | }
67 |
68 | /**
69 | * Subscribes to an event and calls the callback or resolves the Promise only once when it's emitted.
70 | * @param event The event to subscribe to. Use `as "_"` in case your event names aren't thoroughly typed (like when using a template literal, e.g. \`event-${val}\` as "_")
71 | * @param cb The callback to call when the event is emitted - if provided or not, the returned Promise will resolve with the event arguments
72 | * @returns Returns a Promise that resolves with the event arguments when the event is emitted
73 | * @example ```ts
74 | * const emitter = new NanoEmitter<{
75 | * foo: (bar: string) => void;
76 | * }>();
77 | *
78 | * // Promise syntax:
79 | * const [bar] = await emitter.once("foo");
80 | * console.log(bar);
81 | *
82 | * // Callback syntax:
83 | * emitter.once("foo", (bar) => console.log(bar));
84 | * ```
85 | */
86 | public once(event: TKey | "_", cb?: TEvtMap[TKey]): Promise> {
87 | return new Promise((resolve) => {
88 | // eslint-disable-next-line prefer-const
89 | let unsub: Unsubscribe | undefined;
90 |
91 | const onceProxy = ((...args: Parameters) => {
92 | cb?.(...args);
93 | unsub?.();
94 | resolve(args);
95 | }) as TEvtMap[TKey];
96 |
97 | unsub = this.events.on(event, onceProxy);
98 | this.eventUnsubscribes.push(unsub);
99 | });
100 | }
101 |
102 | /**
103 | * Emits an event on this instance.
104 | * ⚠️ Needs `publicEmit` to be set to true in the NanoEmitter constructor or super() call!
105 | * @param event The event to emit
106 | * @param args The arguments to pass to the event listeners
107 | * @returns Returns true if `publicEmit` is true and the event was emitted successfully
108 | */
109 | public emit(event: TKey, ...args: Parameters): boolean {
110 | if(this.emitterOptions.publicEmit) {
111 | this.events.emit(event, ...args);
112 | return true;
113 | }
114 | return false;
115 | }
116 |
117 | /** Unsubscribes all event listeners from this instance */
118 | public unsubscribeAll(): void {
119 | for(const unsub of this.eventUnsubscribes)
120 | unsub();
121 | this.eventUnsubscribes = [];
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/lib/SelectorObserver.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module lib/SelectorObserver
3 | * This module contains the SelectorObserver class, allowing you to register listeners that get called whenever the element(s) behind a selector exist in the DOM - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#selectorobserver)
4 | */
5 |
6 | import { Debouncer, debounce, type DebouncerType } from "./Debouncer.js";
7 | import { isDomLoaded } from "./dom.js";
8 | import type { Prettify } from "./types.js";
9 |
10 | void ["type only", Debouncer];
11 |
12 | /** Options for the `onSelector()` method of {@linkcode SelectorObserver} */
13 | export type SelectorListenerOptions = Prettify | SelectorOptionsAll>;
14 |
15 | export type SelectorOptionsOne = SelectorOptionsCommon & {
16 | /** Whether to use `querySelectorAll()` instead - default is false */
17 | all?: false;
18 | /** Gets called whenever the selector was found in the DOM */
19 | listener: (element: TElem) => void;
20 | };
21 |
22 | export type SelectorOptionsAll = SelectorOptionsCommon & {
23 | /** Whether to use `querySelectorAll()` instead - default is false */
24 | all: true;
25 | /** Gets called whenever the selector was found in the DOM */
26 | listener: (elements: NodeListOf) => void;
27 | };
28 |
29 | export type SelectorOptionsCommon = {
30 | /** Whether to call the listener continuously instead of once - default is false */
31 | continuous?: boolean;
32 | /** Whether to debounce the listener to reduce calls to `querySelector` or `querySelectorAll` - set undefined or <=0 to disable (default) */
33 | debounce?: number;
34 | /** The edge type of the debouncer - default is "immediate" - refer to {@linkcode Debouncer} for more info */
35 | debounceType?: DebouncerType;
36 | };
37 |
38 | export type UnsubscribeFunction = () => void;
39 |
40 | export type SelectorObserverOptions = {
41 | /** If set, applies this debounce in milliseconds to all listeners that don't have their own debounce set */
42 | defaultDebounce?: number;
43 | /** If set, applies this debounce edge type to all listeners that don't have their own set - refer to {@linkcode Debouncer} for more info */
44 | defaultDebounceType?: DebouncerType;
45 | /** Whether to disable the observer when no listeners are present - default is true */
46 | disableOnNoListeners?: boolean;
47 | /** Whether to ensure the observer is enabled when a new listener is added - default is true */
48 | enableOnAddListener?: boolean;
49 | /** If set to a number, the checks will be run on interval instead of on mutation events - in that case all MutationObserverInit props will be ignored */
50 | checkInterval?: number;
51 | };
52 |
53 | export type SelectorObserverConstructorOptions = Prettify;
54 |
55 | /** Observes the children of the given element for changes */
56 | export class SelectorObserver {
57 | private enabled = false;
58 | private baseElement: Element | string;
59 | private observer?: MutationObserver;
60 | private observerOptions: MutationObserverInit;
61 | private customOptions: SelectorObserverOptions;
62 | private listenerMap: Map;
63 |
64 | /**
65 | * Creates a new SelectorObserver that will observe the children of the given base element selector for changes (only creation and deletion of elements by default)
66 | * @param baseElementSelector The selector of the element to observe
67 | * @param options Fine-tune what triggers the MutationObserver's checking function - `subtree` and `childList` are set to true by default
68 | */
69 | constructor(baseElementSelector: string, options?: SelectorObserverConstructorOptions)
70 | /**
71 | * Creates a new SelectorObserver that will observe the children of the given base element for changes (only creation and deletion of elements by default)
72 | * @param baseElement The element to observe
73 | * @param options Fine-tune what triggers the MutationObserver's checking function - `subtree` and `childList` are set to true by default
74 | */
75 | constructor(baseElement: Element, options?: SelectorObserverConstructorOptions)
76 | constructor(baseElement: Element | string, options: SelectorObserverConstructorOptions = {}) {
77 | this.baseElement = baseElement;
78 |
79 | this.listenerMap = new Map();
80 |
81 | const {
82 | defaultDebounce,
83 | defaultDebounceType,
84 | disableOnNoListeners,
85 | enableOnAddListener,
86 | ...observerOptions
87 | } = options;
88 |
89 | this.observerOptions = {
90 | childList: true,
91 | subtree: true,
92 | ...observerOptions,
93 | };
94 |
95 | this.customOptions = {
96 | defaultDebounce: defaultDebounce ?? 0,
97 | defaultDebounceType: defaultDebounceType ?? "immediate",
98 | disableOnNoListeners: disableOnNoListeners ?? false,
99 | enableOnAddListener: enableOnAddListener ?? true,
100 | };
101 |
102 | if(typeof this.customOptions.checkInterval !== "number") {
103 | // if the arrow func isn't there, `this` will be undefined in the callback
104 | this.observer = new MutationObserver(() => this.checkAllSelectors());
105 | }
106 | else {
107 | this.checkAllSelectors();
108 | setInterval(() => this.checkAllSelectors(), this.customOptions.checkInterval);
109 | }
110 | }
111 |
112 | /** Call to check all selectors in the {@linkcode listenerMap} using {@linkcode checkSelector()} */
113 | protected checkAllSelectors(): void {
114 | if(!this.enabled || !isDomLoaded())
115 | return;
116 |
117 | for(const [selector, listeners] of this.listenerMap.entries())
118 | this.checkSelector(selector, listeners);
119 | }
120 |
121 | /** Checks if the element(s) with the given {@linkcode selector} exist in the DOM and calls the respective {@linkcode listeners} accordingly */
122 | protected checkSelector(selector: string, listeners: SelectorListenerOptions[]): void {
123 | if(!this.enabled)
124 | return;
125 |
126 | const baseElement = typeof this.baseElement === "string" ? document.querySelector(this.baseElement) : this.baseElement;
127 |
128 | if(!baseElement)
129 | return;
130 |
131 | const all = listeners.some(listener => listener.all);
132 | const one = listeners.some(listener => !listener.all);
133 |
134 | const allElements = all ? baseElement.querySelectorAll(selector) : null;
135 | const oneElement = one ? baseElement.querySelector(selector) : null;
136 |
137 | for(const options of listeners) {
138 | if(options.all) {
139 | if(allElements && allElements.length > 0) {
140 | options.listener(allElements);
141 | if(!options.continuous)
142 | this.removeListener(selector, options);
143 | }
144 | }
145 | else {
146 | if(oneElement) {
147 | options.listener(oneElement);
148 | if(!options.continuous)
149 | this.removeListener(selector, options);
150 | }
151 | }
152 | if(this.listenerMap.get(selector)?.length === 0)
153 | this.listenerMap.delete(selector);
154 | if(this.listenerMap.size === 0 && this.customOptions.disableOnNoListeners)
155 | this.disable();
156 | }
157 | }
158 |
159 | /**
160 | * Starts observing the children of the base element for changes to the given {@linkcode selector} according to the set {@linkcode options}
161 | * @param selector The selector to observe
162 | * @param options Options for the selector observation
163 | * @param options.listener Gets called whenever the selector was found in the DOM
164 | * @param [options.all] Whether to use `querySelectorAll()` instead - default is false
165 | * @param [options.continuous] Whether to call the listener continuously instead of just once - default is false
166 | * @param [options.debounce] Whether to debounce the listener to reduce calls to `querySelector` or `querySelectorAll` - set undefined or <=0 to disable (default)
167 | * @returns Returns a function that can be called to remove this listener more easily
168 | */
169 | public addListener(selector: string, options: SelectorListenerOptions): UnsubscribeFunction {
170 | options = {
171 | all: false,
172 | continuous: false,
173 | debounce: 0,
174 | ...options,
175 | };
176 |
177 | if((options.debounce && options.debounce > 0) || (this.customOptions.defaultDebounce && this.customOptions.defaultDebounce > 0)) {
178 | options.listener = debounce(
179 | options.listener as ((arg: NodeListOf | Element) => void),
180 | (options.debounce || this.customOptions.defaultDebounce)!,
181 | (options.debounceType || this.customOptions.defaultDebounceType),
182 | ) as (arg: NodeListOf | Element) => void;
183 | }
184 |
185 | if(this.listenerMap.has(selector))
186 | this.listenerMap.get(selector)!.push(options as SelectorListenerOptions);
187 | else
188 | this.listenerMap.set(selector, [options as SelectorListenerOptions]);
189 |
190 | if(this.enabled === false && this.customOptions.enableOnAddListener)
191 | this.enable();
192 |
193 | this.checkSelector(selector, [options as SelectorListenerOptions]);
194 |
195 | return () => this.removeListener(selector, options as SelectorListenerOptions);
196 | }
197 |
198 | /** Disables the observation of the child elements */
199 | public disable(): void {
200 | if(!this.enabled)
201 | return;
202 | this.enabled = false;
203 | this.observer?.disconnect();
204 | }
205 |
206 | /**
207 | * Enables or reenables the observation of the child elements.
208 | * @param immediatelyCheckSelectors Whether to immediately check if all previously registered selectors exist (default is true)
209 | * @returns Returns true when the observation was enabled, false otherwise (e.g. when the base element wasn't found)
210 | */
211 | public enable(immediatelyCheckSelectors = true): boolean {
212 | const baseElement = typeof this.baseElement === "string" ? document.querySelector(this.baseElement) : this.baseElement;
213 | if(this.enabled || !baseElement)
214 | return false;
215 | this.enabled = true;
216 | this.observer?.observe(baseElement, this.observerOptions);
217 | if(immediatelyCheckSelectors)
218 | this.checkAllSelectors();
219 | return true;
220 | }
221 |
222 | /** Returns whether the observation of the child elements is currently enabled */
223 | public isEnabled(): boolean {
224 | return this.enabled;
225 | }
226 |
227 | /** Removes all listeners that have been registered with {@linkcode addListener()} */
228 | public clearListeners(): void {
229 | this.listenerMap.clear();
230 | }
231 |
232 | /**
233 | * Removes all listeners for the given {@linkcode selector} that have been registered with {@linkcode addListener()}
234 | * @returns Returns true when all listeners for the associated selector were found and removed, false otherwise
235 | */
236 | public removeAllListeners(selector: string): boolean {
237 | return this.listenerMap.delete(selector);
238 | }
239 |
240 | /**
241 | * Removes a single listener for the given {@linkcode selector} and {@linkcode options} that has been registered with {@linkcode addListener()}
242 | * @returns Returns true when the listener was found and removed, false otherwise
243 | */
244 | public removeListener(selector: string, options: SelectorListenerOptions): boolean {
245 | const listeners = this.listenerMap.get(selector);
246 | if(!listeners)
247 | return false;
248 | const index = listeners.indexOf(options);
249 | if(index > -1) {
250 | listeners.splice(index, 1);
251 | return true;
252 | }
253 | return false;
254 | }
255 |
256 | /** Returns all listeners that have been registered with {@linkcode addListener()} */
257 | public getAllListeners(): Map[]> {
258 | return this.listenerMap;
259 | }
260 |
261 | /** Returns all listeners for the given {@linkcode selector} that have been registered with {@linkcode addListener()} */
262 | public getListeners(selector: string): SelectorListenerOptions[] | undefined {
263 | return this.listenerMap.get(selector);
264 | }
265 | }
266 |
--------------------------------------------------------------------------------
/lib/array.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { randomItem, randomItemIndex, randomizeArray, takeRandomItem } from "./array.js";
3 |
4 | //#region randomItem
5 | describe("array/randomItem", () => {
6 | it("Returns a random item", () => {
7 | const arr = [1, 2, 3, 4];
8 | const items = [] as number[];
9 |
10 | for(let i = 0; i < 500; i++)
11 | items.push(randomItem(arr)!);
12 |
13 | const missing = arr.filter(item => !items.some(i => i === item));
14 | expect(missing).toHaveLength(0);
15 | });
16 |
17 | it("Returns undefined for an empty array", () => {
18 | expect(randomItem([])).toBeUndefined();
19 | });
20 | });
21 |
22 | //#region randomItemIndex
23 | describe("array/randomItemIndex", () => {
24 | it("Returns a random item with the correct index", () => {
25 | const arr = [1, 2, 3, 4];
26 | const items = [] as [number, number][];
27 |
28 | for(let i = 0; i < 500; i++)
29 | items.push(randomItemIndex(arr) as [number, number]);
30 |
31 | const missing = arr.filter((item, index) => !items.some(([it, idx]) => it === item && idx === index));
32 | expect(missing).toHaveLength(0);
33 | });
34 |
35 | it("Returns undefined for an empty array", () => {
36 | expect(randomItemIndex([])).toEqual([undefined, undefined]);
37 | });
38 | });
39 |
40 | //#region takeRandomItem
41 | describe("array/takeRandomItem", () => {
42 | it("Returns a random item and removes it from the array", () => {
43 | const arr = [1, 2];
44 |
45 | const itm = takeRandomItem(arr);
46 | expect(arr).not.toContain(itm);
47 |
48 | takeRandomItem(arr);
49 |
50 | const itm2 = takeRandomItem(arr);
51 | expect(itm2).toBeUndefined();
52 | expect(arr).toHaveLength(0);
53 | });
54 |
55 | it("Returns undefined for an empty array", () => {
56 | expect(takeRandomItem([])).toBeUndefined();
57 | });
58 | });
59 |
60 | //#region randomizeArray
61 | describe("array/randomizeArray", () => {
62 | it("Returns a copy of the array with a random item order", () => {
63 | const arr = Array.from({ length: 100 }, (_, i) => i);
64 | const randomized = randomizeArray(arr);
65 |
66 | expect(randomized === arr).toBe(false);
67 | expect(randomized).toHaveLength(arr.length);
68 |
69 | const sameItems = arr.filter((item, i) => randomized[i] === item);
70 | expect(sameItems.length).toBeLessThan(arr.length);
71 | });
72 |
73 | it("Returns an empty array as-is", () => {
74 | const arr = [] as number[];
75 | const randomized = randomizeArray(arr);
76 |
77 | expect(randomized === arr).toBe(false);
78 | expect(randomized).toHaveLength(0);
79 | });
80 | });
81 |
--------------------------------------------------------------------------------
/lib/array.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module lib/array
3 | * This module contains various functions for working with arrays - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#arrays)
4 | */
5 |
6 | import { randRange } from "./math.js";
7 |
8 | /** Describes an array with at least one item */
9 | export type NonEmptyArray = [TArray, ...TArray[]];
10 |
11 | /** Returns a random item from the passed array */
12 | export function randomItem(array: TItem[]): TItem | undefined {
13 | return randomItemIndex(array)[0];
14 | }
15 |
16 | /**
17 | * Returns a tuple of a random item and its index from the passed array
18 | * Returns `[undefined, undefined]` if the passed array is empty
19 | */
20 | export function randomItemIndex(array: TItem[]): [item?: TItem, index?: number] {
21 | if(array.length === 0)
22 | return [undefined, undefined];
23 |
24 | const idx = randRange(array.length - 1);
25 |
26 | return [array[idx]!, idx];
27 | }
28 |
29 | /** Returns a random item from the passed array and mutates the array to remove the item */
30 | export function takeRandomItem(arr: TItem[]): TItem | undefined {
31 | const [itm, idx] = randomItemIndex(arr);
32 |
33 | if(idx === undefined)
34 | return undefined;
35 |
36 | arr.splice(idx, 1);
37 | return itm as TItem;
38 | }
39 |
40 | /** Returns a copy of the array with its items in a random order */
41 | export function randomizeArray(array: TItem[]): TItem[] {
42 | const retArray = [...array]; // so array and retArray don't point to the same memory address
43 |
44 | if(array.length === 0)
45 | return retArray;
46 |
47 | // shamelessly stolen from https://javascript.info/task/shuffle
48 | for(let i = retArray.length - 1; i > 0; i--) {
49 | const j = Math.floor((Math.random() * (i + 1)));
50 | [retArray[i], retArray[j]] = [retArray[j], retArray[i]] as [TItem, TItem];
51 | }
52 |
53 | return retArray;
54 | }
55 |
--------------------------------------------------------------------------------
/lib/colors.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { darkenColor, hexToRgb, lightenColor, rgbToHex } from "./colors.js";
3 |
4 | //#region hexToRgb
5 | describe("colors/hexToRgb", () => {
6 | it("Converts a hex color string to an RGB tuple", () => {
7 | const hex = "#FF0000";
8 | const [r, g, b, a] = hexToRgb(hex);
9 |
10 | expect(r).toBe(255);
11 | expect(g).toBe(0);
12 | expect(b).toBe(0);
13 | expect(a).toBeUndefined();
14 | });
15 |
16 | it("Converts a hex color string with an alpha channel to an RGBA tuple", () => {
17 | const hex = "#FF0000FF";
18 | const [r, g, b, a] = hexToRgb(hex);
19 |
20 | expect(r).toBe(255);
21 | expect(g).toBe(0);
22 | expect(b).toBe(0);
23 | expect(a).toBe(1);
24 | });
25 |
26 | it("Works as expected with invalid input", () => {
27 | expect(hexToRgb("")).toEqual([0, 0, 0, undefined]);
28 | });
29 | });
30 |
31 | //#region rgbToHex
32 | describe("colors/rgbToHex", () => {
33 | it("Converts an RGB tuple to a hex color string", () => {
34 | expect(rgbToHex(255, 0, 0, undefined, true, true)).toBe("#FF0000");
35 | expect(rgbToHex(255, 0, 0, undefined, true, false)).toBe("#ff0000");
36 | expect(rgbToHex(255, 0, 0, undefined, false, false)).toBe("ff0000");
37 | expect(rgbToHex(255, 0, 127, 0.5, false, false)).toBe("ff007f80");
38 | expect(rgbToHex(0, 0, 0, 1)).toBe("#000000ff");
39 | });
40 |
41 | it("Handles special values as expected", () => {
42 | expect(rgbToHex(NaN, Infinity, -1, 255)).toBe("#nanff00ff");
43 | expect(rgbToHex(256, -1, 256, -1, false, true)).toBe("FF00FF00");
44 | });
45 |
46 | it("Works as expected with invalid input", () => {
47 | expect(rgbToHex(0, 0, 0, 0)).toBe("#000000");
48 | //@ts-ignore
49 | expect(rgbToHex(NaN, "ello", 0, -1)).toBe("#nannan0000");
50 | });
51 | });
52 |
53 | //#region lightenColor
54 | describe("colors/lightenColor", () => {
55 | it("Lightens a color by a given percentage", () => {
56 | expect(lightenColor("#ab35de", 50)).toBe("#ff50ff");
57 | expect(lightenColor("ab35de", Infinity, true)).toBe("FFFFFF");
58 | expect(lightenColor("rgba(255, 50, 127, 0.5)", 50)).toBe("rgba(255, 75, 190.5, 0.5)");
59 | expect(lightenColor("rgb(255, 50, 127)", 50)).toBe("rgb(255, 75, 190.5)");
60 | });
61 | });
62 |
63 | //#region darkenColor
64 | describe("colors/darkenColor", () => {
65 | it("Darkens a color by a given percentage", () => {
66 | // since both functions are the exact same but with a different sign, only one test is needed:
67 | expect(darkenColor("#1affe3", 50)).toBe(lightenColor("#1affe3", -50));
68 | });
69 | });
70 |
--------------------------------------------------------------------------------
/lib/colors.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module lib/colors
3 | * This module contains various functions for working with colors - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#colors)
4 | */
5 |
6 | import { clamp } from "./math.js";
7 |
8 | /**
9 | * Converts a hex color string in the format `#RRGGBB`, `#RRGGBBAA` (or even `RRGGBB` and `RGB`) to a tuple.
10 | * @returns Returns a tuple array where R, G and B are an integer from 0-255 and alpha is a float from 0 to 1, or undefined if no alpha channel exists.
11 | */
12 | export function hexToRgb(hex: string): [red: number, green: number, blue: number, alpha?: number] {
13 | hex = (hex.startsWith("#") ? hex.slice(1) : hex).trim();
14 | const a = hex.length === 8 || hex.length === 4 ? parseInt(hex.slice(-(hex.length / 4)), 16) / (hex.length === 8 ? 255 : 15) : undefined;
15 |
16 | if(!isNaN(Number(a)))
17 | hex = hex.slice(0, -(hex.length / 4));
18 |
19 | if(hex.length === 3 || hex.length === 4)
20 | hex = hex.split("").map(c => c + c).join("");
21 |
22 | const bigint = parseInt(hex, 16);
23 |
24 | const r = (bigint >> 16) & 255;
25 | const g = (bigint >> 8) & 255;
26 | const b = bigint & 255;
27 |
28 | return [clamp(r, 0, 255), clamp(g, 0, 255), clamp(b, 0, 255), typeof a === "number" ? clamp(a, 0, 1) : undefined];
29 | }
30 |
31 | /** Converts RGB or RGBA number values to a hex color string in the format `#RRGGBB` or `#RRGGBBAA` */
32 | export function rgbToHex(red: number, green: number, blue: number, alpha?: number, withHash = true, upperCase = false): string {
33 | const toHexVal = (n: number): string =>
34 | clamp(Math.round(n), 0, 255).toString(16).padStart(2, "0")[(upperCase ? "toUpperCase" : "toLowerCase")]();
35 | return `${withHash ? "#" : ""}${toHexVal(red)}${toHexVal(green)}${toHexVal(blue)}${alpha ? toHexVal(alpha * 255) : ""}`;
36 | }
37 |
38 | /**
39 | * Lightens a CSS color value (in #HEX, rgb() or rgba() format) by a given percentage.
40 | * Will not exceed the maximum range (00-FF or 0-255).
41 | * @returns Returns the new color value in the same format as the input
42 | * @throws Throws if the color format is invalid or not supported
43 | */
44 | export function lightenColor(color: string, percent: number, upperCase = false): string {
45 | return darkenColor(color, percent * -1, upperCase);
46 | }
47 |
48 | /**
49 | * Darkens a CSS color value (in #HEX, rgb() or rgba() format) by a given percentage.
50 | * Will not exceed the maximum range (00-FF or 0-255).
51 | * @returns Returns the new color value in the same format as the input
52 | * @throws Throws if the color format is invalid or not supported
53 | */
54 | export function darkenColor(color: string, percent: number, upperCase = false): string {
55 | color = color.trim();
56 |
57 | const darkenRgb = (r: number, g: number, b: number, percent: number): [number, number, number] => {
58 | r = Math.max(0, Math.min(255, r - (r * percent / 100)));
59 | g = Math.max(0, Math.min(255, g - (g * percent / 100)));
60 | b = Math.max(0, Math.min(255, b - (b * percent / 100)));
61 | return [r, g, b];
62 | };
63 |
64 | let r: number, g: number, b: number, a: number | undefined;
65 |
66 | const isHexCol = color.match(/^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/);
67 |
68 | if(isHexCol)
69 | [r, g, b, a] = hexToRgb(color);
70 | else if(color.startsWith("rgb")) {
71 | const rgbValues = color.match(/\d+(\.\d+)?/g)?.map(Number);
72 | if(!rgbValues)
73 | throw new TypeError("Invalid RGB/RGBA color format");
74 | [r, g, b, a] = rgbValues as [number, number, number, number?];
75 | }
76 | else
77 | throw new TypeError("Unsupported color format");
78 |
79 | [r, g, b] = darkenRgb(r, g, b, percent);
80 |
81 | if(isHexCol)
82 | return rgbToHex(r, g, b, a, color.startsWith("#"), upperCase);
83 | else if(color.startsWith("rgba"))
84 | return `rgba(${r}, ${g}, ${b}, ${a ?? NaN})`;
85 | else if(color.startsWith("rgb"))
86 | return `rgb(${r}, ${g}, ${b})`;
87 | else
88 | throw new TypeError("Unsupported color format");
89 | }
90 |
--------------------------------------------------------------------------------
/lib/crypto.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { compress, computeHash, decompress, randomId } from "./crypto.js";
3 |
4 | //#region compress
5 | describe("crypto/compress", () => {
6 | it("Compresses strings and buffers as expected", async () => {
7 | const input = "Hello, world!".repeat(100);
8 |
9 | expect((await compress(input, "gzip", "string")).startsWith("H4sI")).toBe(true);
10 | expect((await compress(input, "deflate", "string")).startsWith("eJzz")).toBe(true);
11 | expect((await compress(input, "deflate-raw", "string")).startsWith("80jN")).toBe(true);
12 | expect(await compress(input, "gzip", "arrayBuffer")).toBeInstanceOf(ArrayBuffer);
13 | });
14 | });
15 |
16 | //#region decompress
17 | describe("crypto/decompress", () => {
18 | it("Decompresses strings and buffers as expected", async () => {
19 | const inputGz = "H4sIAAAAAAAACvNIzcnJ11Eozy/KSVH0GOWMckY5o5yRzQEAatVNcBQFAAA=";
20 | const inputDf = "eJzzSM3JyddRKM8vyklR9BjljHJGOaOckc0BAOWGxZQ=";
21 | const inputDfRaw = "80jNycnXUSjPL8pJUfQY5YxyRjmjnJHNAQA=";
22 |
23 | const expectedDecomp = "Hello, world!".repeat(100);
24 |
25 | expect(await decompress(inputGz, "gzip", "string")).toBe(expectedDecomp);
26 | expect(await decompress(inputDf, "deflate", "string")).toBe(expectedDecomp);
27 | expect(await decompress(inputDfRaw, "deflate-raw", "string")).toBe(expectedDecomp);
28 | });
29 | });
30 |
31 | //#region computeHash
32 | describe("crypto/computeHash", () => {
33 | it("Computes hashes as expected", async () => {
34 | const input1 = "Hello, world!";
35 | const input2 = input1.repeat(10);
36 |
37 | expect(await computeHash(input1, "SHA-1")).toBe("943a702d06f34599aee1f8da8ef9f7296031d699");
38 | expect(await computeHash(input1, "SHA-256")).toBe("315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3");
39 | expect(await computeHash(input1, "SHA-512")).toBe("c1527cd893c124773d811911970c8fe6e857d6df5dc9226bd8a160614c0cd963a4ddea2b94bb7d36021ef9d865d5cea294a82dd49a0bb269f51f6e7a57f79421");
40 | expect(await computeHash(input2, "SHA-256")).toBe(await computeHash(input2, "SHA-256"));
41 | });
42 | });
43 |
44 | //#region randomId
45 | describe("crypto/randomId", () => {
46 | it("Generates random IDs as expected", () => {
47 | const id1 = randomId(32, 36, false, true);
48 |
49 | expect(id1).toHaveLength(32);
50 | expect(id1).toMatch(/^[0-9a-zA-Z]+$/);
51 |
52 | const id2 = randomId(32, 36, true, true);
53 |
54 | expect(id2).toHaveLength(32);
55 | expect(id2).toMatch(/^[0-9a-zA-Z]+$/);
56 |
57 | expect(randomId(32, 2, false, false)).toMatch(/^[01]+$/);
58 | });
59 |
60 | it("Handles all edge cases", () => {
61 | expect(() => randomId(16, 1)).toThrow(RangeError);
62 | expect(() => randomId(16, 37)).toThrow(RangeError);
63 | expect(() => randomId(-1)).toThrow(RangeError);
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/lib/crypto.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module lib/crypto
3 | * This module contains various cryptographic functions using the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#table-of-contents)
4 | */
5 |
6 | import { getUnsafeWindow } from "./dom.js";
7 | import { mapRange, randRange } from "./math.js";
8 |
9 | /** Compresses a string or an ArrayBuffer using the provided {@linkcode compressionFormat} and returns it as a base64 string */
10 | export async function compress(input: string | ArrayBuffer, compressionFormat: CompressionFormat, outputType?: "string"): Promise
11 | /** Compresses a string or an ArrayBuffer using the provided {@linkcode compressionFormat} and returns it as an ArrayBuffer */
12 | export async function compress(input: string | ArrayBuffer, compressionFormat: CompressionFormat, outputType: "arrayBuffer"): Promise
13 | /** Compresses a string or an ArrayBuffer using the provided {@linkcode compressionFormat} and returns it as a base64 string or ArrayBuffer, depending on what {@linkcode outputType} is set to */
14 | export async function compress(input: string | ArrayBuffer, compressionFormat: CompressionFormat, outputType: "string" | "arrayBuffer" = "string"): Promise {
15 | const byteArray = typeof input === "string" ? new TextEncoder().encode(input) : input;
16 | const comp = new CompressionStream(compressionFormat);
17 | const writer = comp.writable.getWriter();
18 | writer.write(byteArray);
19 | writer.close();
20 | const buf = await (new Response(comp.readable).arrayBuffer());
21 | return outputType === "arrayBuffer" ? buf : ab2str(buf);
22 | }
23 |
24 | /** Decompresses a previously compressed base64 string or ArrayBuffer, with the format passed by {@linkcode compressionFormat}, converted to a string */
25 | export async function decompress(input: string | ArrayBuffer, compressionFormat: CompressionFormat, outputType?: "string"): Promise
26 | /** Decompresses a previously compressed base64 string or ArrayBuffer, with the format passed by {@linkcode compressionFormat}, converted to an ArrayBuffer */
27 | export async function decompress(input: string | ArrayBuffer, compressionFormat: CompressionFormat, outputType: "arrayBuffer"): Promise
28 | /** Decompresses a previously compressed base64 string or ArrayBuffer, with the format passed by {@linkcode compressionFormat}, converted to a string or ArrayBuffer, depending on what {@linkcode outputType} is set to */
29 | export async function decompress(input: string | ArrayBuffer, compressionFormat: CompressionFormat, outputType: "string" | "arrayBuffer" = "string"): Promise {
30 | const byteArray = typeof input === "string" ? str2ab(input) : input;
31 | const decomp = new DecompressionStream(compressionFormat);
32 | const writer = decomp.writable.getWriter();
33 | writer.write(byteArray);
34 | writer.close();
35 | const buf = await (new Response(decomp.readable).arrayBuffer());
36 | return outputType === "arrayBuffer" ? buf : new TextDecoder().decode(buf);
37 | }
38 |
39 | /** Converts an ArrayBuffer to a base64-encoded string */
40 | function ab2str(buf: ArrayBuffer): string {
41 | return getUnsafeWindow().btoa(
42 | new Uint8Array(buf)
43 | .reduce((data, byte) => data + String.fromCharCode(byte), "")
44 | );
45 | }
46 |
47 | /** Converts a base64-encoded string to an ArrayBuffer representation of its bytes */
48 | function str2ab(str: string): ArrayBuffer {
49 | return Uint8Array.from(getUnsafeWindow().atob(str), c => c.charCodeAt(0));
50 | }
51 |
52 | /**
53 | * Creates a hash / checksum of the given {@linkcode input} string or ArrayBuffer using the specified {@linkcode algorithm} ("SHA-256" by default).
54 | *
55 | * - ⚠️ Uses the SubtleCrypto API so it needs to run in a secure context (HTTPS).
56 | * - ⚠️ If you use this for cryptography, make sure to use a secure algorithm (under no circumstances use SHA-1) and to [salt](https://en.wikipedia.org/wiki/Salt_(cryptography)) your input data.
57 | */
58 | export async function computeHash(input: string | ArrayBuffer, algorithm = "SHA-256"): Promise {
59 | let data: ArrayBuffer;
60 | if(typeof input === "string") {
61 | const encoder = new TextEncoder();
62 | data = encoder.encode(input);
63 | }
64 | else
65 | data = input;
66 |
67 | const hashBuffer = await crypto.subtle.digest(algorithm, data);
68 | const hashArray = Array.from(new Uint8Array(hashBuffer));
69 | const hashHex = hashArray.map(byte => byte.toString(16).padStart(2, "0")).join("");
70 |
71 | return hashHex;
72 | }
73 |
74 | /**
75 | * Generates a random ID with the specified length and radix (16 characters and hexadecimal by default)
76 | *
77 | * - ⚠️ Not suitable for generating anything related to cryptography! Use [SubtleCrypto's `generateKey()`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/generateKey) for that instead.
78 | * @param length The length of the ID to generate (defaults to 16)
79 | * @param radix The [radix](https://en.wikipedia.org/wiki/Radix) of each digit (defaults to 16 which is hexadecimal. Use 2 for binary, 10 for decimal, 36 for alphanumeric, etc.)
80 | * @param enhancedEntropy If set to true, uses [`crypto.getRandomValues()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) for better cryptographic randomness (this also makes it take longer to generate)
81 | * @param randomCase If set to false, the generated ID will be lowercase only - also makes use of the `enhancedEntropy` parameter unless the output doesn't contain letters
82 | */
83 | export function randomId(length = 16, radix = 16, enhancedEntropy = false, randomCase = true): string {
84 | if(length < 1)
85 | throw new RangeError("The length argument must be at least 1");
86 |
87 | if(radix < 2 || radix > 36)
88 | throw new RangeError("The radix argument must be between 2 and 36");
89 |
90 | let arr: string[] = [];
91 | const caseArr = randomCase ? [0, 1] : [0];
92 |
93 | if(enhancedEntropy) {
94 | const uintArr = new Uint8Array(length);
95 | crypto.getRandomValues(uintArr);
96 | arr = Array.from(
97 | uintArr,
98 | (v) => mapRange(v, 0, 255, 0, radix).toString(radix).substring(0, 1),
99 | );
100 | }
101 | else {
102 | arr = Array.from(
103 | { length },
104 | () => Math.floor(Math.random() * radix).toString(radix),
105 | );
106 | }
107 |
108 | if(!arr.some((v) => /[a-zA-Z]/.test(v)))
109 | return arr.join("");
110 |
111 | return arr.map((v) => caseArr[randRange(0, caseArr.length - 1, enhancedEntropy)] === 1 ? v.toUpperCase() : v).join("");
112 | }
113 |
--------------------------------------------------------------------------------
/lib/dom.spec.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "vitest";
2 | import { addGlobalStyle, addParent, getSiblingsFrame, getUnsafeWindow, interceptWindowEvent, isDomLoaded, observeElementProp, onDomLoad, openInNewTab, preloadImages, probeElementStyle, setInnerHtmlUnsafe } from "./dom.js";
3 | import { PlatformError } from "./errors.js";
4 |
5 | //#region getUnsafeWindow
6 | describe("dom/getUnsafeWindow", () => {
7 | it("Returns the correct window objects", () => {
8 | expect(getUnsafeWindow()).toBe(window);
9 | var unsafeWindow = window;
10 | expect(getUnsafeWindow()).toBe(unsafeWindow);
11 | });
12 | });
13 |
14 | //#region addParent
15 | describe("dom/addParent", () => {
16 | it("Adds a parent to an element", () => {
17 | const container = document.createElement("div");
18 | container.id = "container";
19 |
20 | const child = document.createElement("div");
21 | child.id = "child";
22 |
23 | document.body.appendChild(child);
24 |
25 | addParent(child, container);
26 |
27 | expect(child.parentNode).toBe(container);
28 |
29 | container.remove();
30 | });
31 | });
32 |
33 | //#region addGlobalStyle
34 | describe("dom/addGlobalStyle", () => {
35 | it("Adds a global style to the document", () => {
36 | const el = addGlobalStyle(`body { background-color: red; }`);
37 | el.id = "test-style";
38 |
39 | expect(document.querySelector("head #test-style")).toBe(el);
40 | });
41 | });
42 |
43 | //#region preloadImages
44 | //TODO:FIXME: no workis
45 | describe.skip("dom/preloadImages", () => {
46 | it("Preloads images", async () => {
47 | const res = await preloadImages(["https://picsum.photos/50/50"]);
48 |
49 | expect(Array.isArray(res)).toBe(true);
50 | expect(res.every(r => r.status === "fulfilled")).toBe(true);
51 | });
52 | });
53 |
54 | //#region openInNewTab
55 | describe("dom/openInNewTab", () => {
56 | it("Via GM.openInTab", () => {
57 | let link = "", bg;
58 | // @ts-expect-error
59 | window.GM = {
60 | openInTab(href: string, background?: boolean) {
61 | link = href;
62 | bg = background;
63 | }
64 | };
65 |
66 | openInNewTab("https://example.org", true);
67 |
68 | expect(link).toBe("https://example.org");
69 | expect(bg).toBe(true);
70 |
71 | // @ts-expect-error
72 | window.GM = {
73 | openInTab(_href: string, _background?: boolean) {
74 | throw new Error("Error");
75 | }
76 | }
77 |
78 | openInNewTab("https://example.org", true);
79 | expect(document.querySelector(".userutils-open-in-new-tab")).not.toBeNull();
80 |
81 | // @ts-expect-error
82 | delete window.GM;
83 | });
84 | });
85 |
86 | //#region interceptWindowEvent
87 | describe("dom/interceptWindowEvent", () => {
88 | it("Intercepts a window event", () => {
89 | let amount = 0;
90 | const inc = () => amount++;
91 |
92 | window.addEventListener("foo", inc);
93 | Error.stackTraceLimit = NaN;
94 | // @ts-expect-error
95 | interceptWindowEvent("foo", () => true);
96 | window.addEventListener("foo", inc);
97 |
98 | window.dispatchEvent(new Event("foo"));
99 |
100 | expect(amount).toBe(1);
101 |
102 | window.removeEventListener("foo", inc);
103 | });
104 |
105 | it("Throws when GM platform is FireMonkey", () => {
106 | // @ts-expect-error
107 | window.GM = { info: { scriptHandler: "FireMonkey" } };
108 |
109 | // @ts-expect-error
110 | expect(() => interceptWindowEvent("foo", () => true)).toThrow(PlatformError);
111 |
112 | // @ts-expect-error
113 | delete window.GM;
114 | });
115 | });
116 |
117 | //#region observeElementProp
118 | //TODO:FIXME: no workio
119 | describe.skip("dom/observeElementProp", () => {
120 | it("Observes an element property", () => {
121 | const el = document.createElement("input");
122 | el.type = "text";
123 | document.body.appendChild(el);
124 |
125 | let newVal = "";
126 | observeElementProp(el, "value", (_oldVal, newVal) => {
127 | newVal = newVal;
128 | });
129 |
130 | el.value = "foo";
131 |
132 | expect(newVal).toBe("foo");
133 | });
134 | });
135 |
136 | //#region getSiblingsFrame
137 | describe("dom/getSiblingsFrame", () => {
138 | it("Returns the correct frame", () => {
139 | const container = document.createElement("div");
140 | for(let i = 0; i < 10; i++) {
141 | const el = document.createElement("div");
142 | el.id = `e${i}`;
143 | container.appendChild(el);
144 | }
145 |
146 | const cntrEl = container.querySelector("#e5")!;
147 |
148 | expect(getSiblingsFrame(cntrEl, 2).map(e => e.id)).toEqual(["e5", "e6"]);
149 | expect(getSiblingsFrame(cntrEl, 2, "top", false).map(e => e.id)).toEqual(["e6", "e7"]);
150 | expect(getSiblingsFrame(cntrEl, 2, "bottom", false).map(e => e.id)).toEqual(["e3", "e4"]);
151 | expect(getSiblingsFrame(cntrEl, 2, "center-top", false).map(e => e.id)).toEqual(["e4", "e6"]);
152 | expect(getSiblingsFrame(cntrEl, 3, "center-top", true).map(e => e.id)).toEqual(["e4", "e5", "e6"]);
153 | expect(getSiblingsFrame(cntrEl, 4, "center-top", true).map(e => e.id)).toEqual(["e4", "e5", "e6", "e7"]);
154 | expect(getSiblingsFrame(cntrEl, 4, "center-bottom", true).map(e => e.id)).toEqual(["e3", "e4", "e5", "e6"]);
155 | // @ts-expect-error
156 | expect(getSiblingsFrame(cntrEl, 2, "invalid")).toHaveLength(0);
157 | });
158 | });
159 |
160 | //#region setInnerHtmlUnsafe
161 | describe("dom/setInnerHtmlUnsafe", () => {
162 | it("Sets inner HTML", () => {
163 | // @ts-expect-error
164 | window.trustedTypes = {
165 | createPolicy: (_name: string, opts: { createHTML: (html: string) => string }) => ({
166 | createHTML: opts.createHTML,
167 | }),
168 | };
169 |
170 | const el = document.createElement("div");
171 | setInnerHtmlUnsafe(el, "foo
");
172 |
173 | expect(el.querySelector("div")?.textContent).toBe("foo");
174 | });
175 | });
176 |
177 | //#region probeElementStyle
178 | //TODO:FIXME: no workiong
179 | describe.skip("dom/probeElementStyle", () => {
180 | it("Resolves a CSS variable", async () => {
181 | addGlobalStyle(`:root { --foo: #f00; --bar: var(--foo, #00f); }`);
182 |
183 | const tryResolveCol = (i = 0) => new Promise((res, rej) => {
184 | if(i > 100)
185 | return rej(new Error("Could not resolve color after 100 tries"));
186 |
187 | const probedCol = probeElementStyle(
188 | (style) => style.backgroundColor,
189 | () => {
190 | const elem = document.createElement("span");
191 | elem.style.backgroundColor = "var(--foo, #000)";
192 | return elem;
193 | },
194 | true,
195 | );
196 |
197 | if(probedCol.length === 0 || probedCol.match(/^rgba?\((?:(?:255,\s?255,\s?255)|(?:0,\s?0,\s?0))/) || probedCol.match(/^#(?:fff(?:fff)?|000(?:000)?)/))
198 | return setTimeout(async () => res(await tryResolveCol(++i)), 100);
199 |
200 | return res(probedCol);
201 | });
202 |
203 | const val = await tryResolveCol();
204 |
205 | expect(val).toBe("rgb(255, 0, 0)");
206 | });
207 | });
208 |
209 | //#region onDomLoad & isDomLoaded
210 | describe("dom/onDomLoad", () => {
211 | it("Resolves when the DOM is loaded", async () => {
212 | let cb = false;
213 | const res = onDomLoad(() => cb = true);
214 | document.dispatchEvent(new Event("DOMContentLoaded"));
215 | await res;
216 |
217 | expect(cb).toBe(true);
218 | expect(isDomLoaded()).toBe(true);
219 |
220 | cb = false;
221 | onDomLoad(() => cb = true);
222 | document.dispatchEvent(new Event("DOMContentLoaded"));
223 | expect(cb).toBe(true);
224 | });
225 | });
226 |
--------------------------------------------------------------------------------
/lib/dom.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module lib/dom
3 | * This module contains various functions for working with the DOM - [see the documentation for more info](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#dom)
4 | */
5 |
6 | import { PlatformError } from "./errors.js";
7 |
8 | /** Whether the DOM has finished loading */
9 | let domReady = false;
10 | document.addEventListener("DOMContentLoaded", () => domReady = true);
11 |
12 | /**
13 | * Returns `unsafeWindow` if the `@grant unsafeWindow` is given, otherwise falls back to the regular `window`
14 | */
15 | export function getUnsafeWindow(): Window {
16 | try {
17 | // throws ReferenceError if the "@grant unsafeWindow" isn't present
18 | return unsafeWindow;
19 | }
20 | catch {
21 | return window;
22 | }
23 | }
24 |
25 | /**
26 | * Adds a parent container around the provided element
27 | * @returns Returns the new parent element
28 | */
29 | export function addParent(element: TElem, newParent: TParentElem): TParentElem {
30 | const oldParent = element.parentNode;
31 |
32 | if(!oldParent)
33 | throw new Error("Element doesn't have a parent node");
34 |
35 | oldParent.replaceChild(newParent, element);
36 | newParent.appendChild(element);
37 |
38 | return newParent;
39 | }
40 |
41 | /**
42 | * Adds global CSS style in the form of a `