├── .changeset ├── README.md └── config.json ├── .github ├── CONTRIBUTING.md ├── FUNDING.yml ├── assets │ ├── debounce.pdn │ └── debounce.png └── workflows │ ├── analyze-with-codeql.yml │ ├── build-and-publish-jsr.yml │ ├── build-and-publish-npm.yml │ └── lint-and-test-code.yml ├── .gitignore ├── .gitmodules ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── LICENSE.txt ├── README-summary.md ├── README.md ├── docs.md ├── eslint.config.mjs ├── jsr.json ├── lib ├── DataStore.spec.ts ├── DataStore.ts ├── DataStoreSerializer.spec.ts ├── DataStoreSerializer.ts ├── Debouncer.spec.ts ├── Debouncer.ts ├── Dialog.spec.ts ├── Dialog.ts ├── Mixins.spec.ts ├── Mixins.ts ├── NanoEmitter.spec.ts ├── NanoEmitter.ts ├── SelectorObserver.ts ├── array.spec.ts ├── array.ts ├── colors.spec.ts ├── colors.ts ├── crypto.spec.ts ├── crypto.ts ├── dom.spec.ts ├── dom.ts ├── errors.spec.ts ├── errors.ts ├── index.ts ├── math.spec.ts ├── math.ts ├── misc.spec.ts ├── misc.ts ├── translation.spec.ts ├── translation.ts └── types.ts ├── package.json ├── pnpm-lock.yaml ├── test ├── README.md └── TestPage │ ├── index.css │ ├── index.html │ ├── index.js │ └── server.mts ├── tools ├── fix-dts.mts ├── post-build-global.mts └── update-jsr-version.mts ├── tsconfig.json ├── tsconfig.spec.json └── vitest.config.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | [Changesets documentation](https://github.com/changesets/changesets#readme) • [Changesets common questions](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 3 | 4 |
5 | 6 | ## Commands 7 | 8 | | Command | Description | 9 | | --- | --- | 10 | | `npx changeset` | Create a changeset. On commit & push, the Actions workflow will create a PR that versions the package and publishes it on merge. | 11 | | `npx changeset status` | Shows info on the current changesets. | 12 | | `npx changeset version` | Versions the package and updates the changelog using the previously created changesets. | 13 | | `npx changeset publish --otp=TOKEN` | Publishes to npm. Should be run after the changes made by `version` are pushed to main. Don't create any commits in between! | 14 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Development & Contributing Guide 2 | Thanks to Matt Pocock for his video on how to set up a modern TypeScript library: https://youtu.be/eh89VE3Mk5g 3 | 4 |
5 | 6 | ### Initial setup: 7 | 1. Have Node.js and npm installed 8 | 2. Clone or download and extract the repository 9 | 3. Run `npm i` in the project root to install dependencies 10 | 11 |
12 | 13 | ### Commands: 14 | | Command | Description | 15 | | :-- | :-- | 16 | | `npm run lint` | Run TSC and ESLint to lint the code | 17 | | `npm run build` | Build the project with tsup, outputting CJS and ESM bundles as well as TypeScript declarations to `dist/` | 18 | | `npm run build-all` | Build the project with tsup, outputting a bundle that exports as CJS, ESM and global declaration bundles (for publishing to GreasyFork and OpenUserJS) to `dist/` | 19 | | `npm run dev` | Watch for changes and build the project with sourcemaps | 20 | 21 |
22 | 23 | ### Testing locally: 24 | 1. Use `npm link` in the root of UserUtils to create a global symlink to the package 25 | 2. Use `npm link @sv443-network/userutils` in the root of the project you want to test the package in to bind to the symlink 26 | 3. Run `npm run dev` in the root of UserUtils to watch for changes and rebuild the package automatically 27 | 4. Start your project and test the changes :) 28 | 29 |
30 | 31 | ### Publishing a new version: 32 | 1. Create a changeset with `npx changeset` (modify the description in `.changeset/random-name.md` if needed) 33 | 2. Commit the changeset and push the changes 34 | 3. Merge the commit that contains the changeset into `main` 35 | After the Actions workflow completes, a pull request will be opened 36 | 4. Merge the pull request to automatically publish the new version to npm 37 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: Sv443 2 | ko_fi: Sv443 3 | custom: ['paypal.me/Sv443'] 4 | -------------------------------------------------------------------------------- /.github/assets/debounce.pdn: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sv443-Network/UserUtils/b29f0400f84765046dadfd94756c9562e6ab3e65/.github/assets/debounce.pdn -------------------------------------------------------------------------------- /.github/assets/debounce.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sv443-Network/UserUtils/b29f0400f84765046dadfd94756c9562e6ab3e65/.github/assets/debounce.png -------------------------------------------------------------------------------- /.github/workflows/analyze-with-codeql.yml: -------------------------------------------------------------------------------- 1 | name: "Analyze Code with CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main, develop] 8 | 9 | jobs: 10 | analyze: 11 | name: Analyze Code 12 | 13 | runs-on: ubuntu-latest 14 | 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | matrix: 22 | language: ["javascript-typescript"] 23 | # CodeQL supports "c-cpp", "csharp", "go", "java-kotlin", "javascript-typescript", "python", "ruby", "swift" 24 | # Learn more: 25 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | with: 30 | submodules: recursive 31 | 32 | - name: Initialize CodeQL 33 | uses: github/codeql-action/init@v3 34 | with: 35 | languages: ${{ matrix.language }} 36 | 37 | - name: Perform CodeQL Analysis 38 | uses: github/codeql-action/analyze@v3 39 | -------------------------------------------------------------------------------- /.github/workflows/build-and-publish-jsr.yml: -------------------------------------------------------------------------------- 1 | name: "Build and Publish on JSR" 2 | 3 | on: 4 | # manual only for now 5 | workflow_dispatch: 6 | 7 | concurrency: ${{ github.workflow }}-${{ github.ref }} 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [22.x] 16 | 17 | permissions: 18 | contents: read 19 | id-token: write 20 | 21 | env: 22 | CI: "true" 23 | STORE_PATH: "" 24 | PNPM_VERSION: 9 25 | RETENTION_DAYS: 2 26 | 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@v4 30 | 31 | - name: Setup Node.js v${{ matrix.node-version }} 32 | uses: actions/setup-node@v4 33 | with: 34 | node-version: ${{ matrix.node-version }} 35 | 36 | - name: Setup pnpm 37 | uses: pnpm/action-setup@v4 38 | with: 39 | version: ${{ env.PNPM_VERSION }} 40 | run_install: false 41 | 42 | - name: Get pnpm store directory 43 | shell: bash 44 | run: | 45 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 46 | 47 | - name: Setup pnpm cache 48 | uses: actions/cache@v4 49 | with: 50 | path: ${{ env.STORE_PATH }} 51 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 52 | restore-keys: | 53 | ${{ runner.os }}-pnpm-store- 54 | 55 | - name: Install dependencies 56 | run: pnpm i 57 | 58 | - name: Build package 59 | run: pnpm build-all 60 | 61 | - name: Publish on JSR 62 | run: pnpm publish-package-jsr 63 | -------------------------------------------------------------------------------- /.github/workflows/build-and-publish-npm.yml: -------------------------------------------------------------------------------- 1 | name: "Build and Publish on NPM" 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | env: 9 | PR_TITLE: "Create Release" 10 | COMMIT_MSG: "chore: create new release" 11 | 12 | concurrency: ${{ github.workflow }}-${{ github.ref }} 13 | 14 | jobs: 15 | publish: 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | matrix: 20 | node-version: [22.x] 21 | 22 | permissions: 23 | contents: write # For pushing Git tags and creating releases 24 | pull-requests: write # For creating the changesets relese PR 25 | id-token: write # The OIDC ID token is used for authentication with JSR 26 | 27 | env: 28 | CI: "true" 29 | STORE_PATH: "" 30 | PNPM_VERSION: 9 31 | RETENTION_DAYS: 2 32 | 33 | steps: 34 | - name: Checkout code 35 | uses: actions/checkout@v4 36 | 37 | - name: Setup Node.js v${{ matrix.node-version }} 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version: ${{ matrix.node-version }} 41 | 42 | - name: Setup pnpm 43 | uses: pnpm/action-setup@v4 44 | with: 45 | version: ${{ env.PNPM_VERSION }} 46 | run_install: false 47 | 48 | - name: Get pnpm store directory 49 | shell: bash 50 | run: | 51 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 52 | 53 | - name: Setup pnpm cache 54 | uses: actions/cache@v4 55 | with: 56 | path: ${{ env.STORE_PATH }} 57 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 58 | restore-keys: | 59 | ${{ runner.os }}-pnpm-store- 60 | 61 | - name: Install dependencies 62 | run: pnpm i 63 | 64 | - name: Build package 65 | run: pnpm build-all 66 | 67 | - name: Create artifact 68 | uses: actions/upload-artifact@v4 69 | with: 70 | name: dist 71 | path: dist/ 72 | retention-days: ${{ env.RETENTION_DAYS }} 73 | 74 | - name: Create release or publish package 75 | uses: changesets/action@v1 76 | id: changesets 77 | with: 78 | publish: npm run publish-package 79 | commit: ${{ env.COMMIT_MSG }} 80 | title: ${{ env.PR_TITLE }} 81 | env: 82 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 83 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 84 | -------------------------------------------------------------------------------- /.github/workflows/lint-and-test-code.yml: -------------------------------------------------------------------------------- 1 | name: "Lint and test code" 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main, develop] 8 | 9 | jobs: 10 | lint-test: 11 | name: Lint and test 12 | 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [22.x] 18 | 19 | env: 20 | CI: "true" 21 | STORE_PATH: "" 22 | PNPM_VERSION: 9 23 | RETENTION_DAYS: 2 24 | 25 | steps: 26 | - name: Checkout code 27 | uses: actions/checkout@v4 28 | 29 | - name: Setup Node.js v${{ matrix.node-version }} 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: ${{ matrix.node-version }} 33 | 34 | - name: Setup pnpm 35 | uses: pnpm/action-setup@v4 36 | with: 37 | version: ${{ env.PNPM_VERSION }} 38 | run_install: false 39 | 40 | - name: Get pnpm store directory 41 | shell: bash 42 | run: | 43 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 44 | 45 | - name: Setup pnpm cache 46 | uses: actions/cache@v4 47 | with: 48 | path: ${{ env.STORE_PATH }} 49 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 50 | restore-keys: | 51 | ${{ runner.os }}-pnpm-store- 52 | 53 | - name: Install dependencies 54 | run: pnpm i 55 | 56 | - name: Lint 57 | run: pnpm lint 58 | 59 | - name: Test 60 | run: pnpm test-coverage 61 | 62 | - name: Upload coverage report 63 | uses: actions/upload-artifact@v4 64 | with: 65 | name: coverage 66 | path: coverage/lcov-report/ 67 | retention-days: ${{ env.RETENTION_DAYS }} 68 | 69 | - name: Upload coverage to Coveralls 70 | uses: coverallsapp/github-action@v2 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | test.ts 4 | coverage/ 5 | .env 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "test/TestScript"] 2 | path = test/TestScript 3 | url = https://github.com/Sv443-Network/UserUtils-Test.git 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "fix-dts", 6 | "type": "node", 7 | "request": "launch", 8 | "program": "${workspaceFolder}/tools/fix-dts.mts", 9 | "runtimeExecutable": "tsx", 10 | "console": "integratedTerminal", 11 | "internalConsoleOptions": "neverOpen", 12 | "skipFiles": [ 13 | "/**", 14 | "${workspaceFolder}/node_modules/**" 15 | ] 16 | }, 17 | { 18 | "name": "test.ts", 19 | "type": "node", 20 | "request": "launch", 21 | "program": "${workspaceFolder}/test.ts", 22 | "runtimeExecutable": "tsx", 23 | "console": "integratedTerminal", 24 | "internalConsoleOptions": "neverOpen", 25 | "skipFiles": [ 26 | "/**", 27 | "${workspaceFolder}/node_modules/**" 28 | ] 29 | }, 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "npm.packageManager": "pnpm", 3 | "javascript.preferences.importModuleSpecifier": "relative", 4 | "typescript.tsdk": "node_modules/typescript/lib", 5 | "editor.tabSize": 2, 6 | 7 | // requires extension: fabiospampinato.vscode-highlight 8 | "highlight.regexes": { 9 | "(TODO(\\((\\s|\\d|\\w|[,.-_+*&])+\\))?:?)": { // TODO: or TODO or TODO(xy): but not todo or todo: 10 | "backgroundColor": "#ed0", 11 | "color": "black", 12 | "overviewRulerColor": "#ed0", 13 | }, 14 | "((//\\s*|/\\*\\s*)?#region ([^\\S\\r\\n]*[\\(\\)\\w,.\\-_&+#*'\"/:]+)*)": { //#region test: (abc): 15 | "backgroundColor": "#5df", 16 | "color": "#000", 17 | "overviewRulerColor": "#5df", 18 | }, 19 | "(()?)": { // and or <{{BAR}}> and 20 | "backgroundColor": "#9af", 21 | "overviewRulerColor": "#9af", 22 | "color": "#000", 23 | }, 24 | "(#?(DEBUG|DBG)#?)": { // #DEBUG or DEBUG or #DBG or #DBG# 25 | "backgroundColor": "#ff0", 26 | "color": "blue", 27 | "overviewRulerColor": "#ff0", 28 | }, 29 | "(IMPORTANT:)": { // IMPORTANT: 30 | "backgroundColor": "#a22", 31 | "color": "#fff", 32 | }, 33 | "(FIXME:)": { // FIXME: 34 | "backgroundColor": "#a22", 35 | "color": "#fff", 36 | "overviewRulerColor": "#752020", 37 | }, 38 | }, 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sven Fehler (Sv443) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
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 `