mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-06-27 12:55:55 +00:00
Initial commit for converting FrankerFaceZ to TypeScript.
This commit is contained in:
parent
ba72969c51
commit
b9d23accf0
86 changed files with 8673 additions and 5005 deletions
9
.babelrc
9
.babelrc
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"plugins": [
|
||||
"@babel/plugin-syntax-dynamic-import",
|
||||
"@babel/plugin-proposal-optional-chaining",
|
||||
"@babel/plugin-proposal-nullish-coalescing-operator",
|
||||
["@babel/plugin-proposal-object-rest-spread", {"loose": true, "useBuiltIns": true}],
|
||||
["@babel/plugin-proposal-class-properties", {"loose": true}]
|
||||
]
|
||||
}
|
21
.editorconfig
Normal file
21
.editorconfig
Normal file
|
@ -0,0 +1,21 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = tab
|
||||
indent_size = tab
|
||||
tab_width = 4
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.yaml]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.yml]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = false
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,6 +1,7 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
dist
|
||||
typedist
|
||||
Extension Building
|
||||
badges
|
||||
cdn
|
||||
|
|
82
bin/build_types.js
Normal file
82
bin/build_types.js
Normal file
|
@ -0,0 +1,82 @@
|
|||
const fs = require('fs');
|
||||
const glob = require('glob');
|
||||
const nativepath = require('path');
|
||||
const pospath = nativepath.posix;
|
||||
|
||||
const PACKAGE = require('../package.json');
|
||||
|
||||
const manifest = {
|
||||
private: true,
|
||||
name: '@ffz/client-types',
|
||||
description: "TypeScript definitions for FrankerFaceZ",
|
||||
version: PACKAGE.version,
|
||||
types: "main",
|
||||
projects: [
|
||||
'https://www.frankerfacez.com'
|
||||
],
|
||||
repository: PACKAGE.repository,
|
||||
dependencies: {
|
||||
'@types/webpack-env': '^1.18.4'
|
||||
}
|
||||
};
|
||||
|
||||
fs.writeFileSync('typedist/package.json', JSON.stringify(manifest, null, 4));
|
||||
|
||||
// Now, fix all the import paths.
|
||||
|
||||
const MATCHER = /from '([^']+)';$/gm,
|
||||
MATCH_TWO = /\bimport\("([^"]+)"\)/gm;
|
||||
|
||||
function shouldReplace(module) {
|
||||
return module.startsWith('utilities/');
|
||||
}
|
||||
|
||||
for(const filename of glob.sync('typedist/**/*.d.ts')) {
|
||||
const folder = pospath.dirname(filename.split(nativepath.sep).join(pospath.sep));
|
||||
console.log('thing', filename, '-->', folder);
|
||||
|
||||
let content = fs.readFileSync(filename, 'utf8');
|
||||
let changed = false;
|
||||
|
||||
content = content.replace(MATCHER, (match, package, index) => {
|
||||
if ( shouldReplace(package) ) {
|
||||
//const modpath = pospath.dirname(`typedist/${package}`);
|
||||
let relative = pospath.relative(folder, 'typedist');
|
||||
|
||||
if ( relative === '' )
|
||||
relative = '.';
|
||||
|
||||
if ( ! relative.endsWith('/') )
|
||||
relative += '/';
|
||||
|
||||
console.log(' to', package, '->', JSON.stringify(relative));
|
||||
|
||||
changed = true;
|
||||
return `from '${relative}${package}';`;
|
||||
}
|
||||
|
||||
return match;
|
||||
});
|
||||
|
||||
content = content.replace(MATCH_TWO, (match, package, index) => {
|
||||
if ( shouldReplace(package) ) {
|
||||
//const modpath = pospath.dirname(`typedist/${package}`);
|
||||
let relative = pospath.relative(folder, 'typedist');
|
||||
|
||||
if ( relative === '' )
|
||||
relative = '.';
|
||||
|
||||
if ( ! relative.endsWith('/') )
|
||||
relative += '/';
|
||||
|
||||
changed = true;
|
||||
return `import("${relative}${package}")`;
|
||||
}
|
||||
|
||||
return match;
|
||||
});
|
||||
|
||||
if ( changed )
|
||||
fs.writeFileSync(filename, content);
|
||||
|
||||
}
|
|
@ -17,8 +17,16 @@ for(const file of fs.readdirSync(dir)) {
|
|||
const config = JSON.parse(fs.readFileSync('fontello.config.json', 'utf8'));
|
||||
const icons = config.glyphs.map(x => x.css);
|
||||
|
||||
fs.writeFileSync('src/utilities/ffz-icons.js', `'use strict';
|
||||
// This is a generated file. To update it, please run: npm run font:update
|
||||
fs.writeFileSync('src/utilities/ffz-icons.ts', `'use strict';
|
||||
// This is a generated file. To update it, please run: pnpm font:update
|
||||
/* eslint quotes: 0 */
|
||||
|
||||
export default ${JSON.stringify(icons, null, '\t')};`);
|
||||
/**
|
||||
* A list of all valid icon names in the FrankerFaceZ icon font. These
|
||||
* icons can be used by adding a class to a DOM element with the name
|
||||
* \`ffz-i-$\{name}\` where \`$\{name}\` is a name from this list.
|
||||
*
|
||||
* For example, to use the \`threads\` icon, you'd add the class
|
||||
* \`ffz-i-threads\` to your element.
|
||||
*/
|
||||
export default ${JSON.stringify(icons, null, '\t')} as const;`);
|
||||
|
|
190
package.json
190
package.json
|
@ -1,91 +1,103 @@
|
|||
{
|
||||
"name": "frankerfacez",
|
||||
"author": "Dan Salvato LLC",
|
||||
"version": "4.60.0",
|
||||
"description": "FrankerFaceZ is a Twitch enhancement suite.",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"start": "pnpm dev",
|
||||
"eslint": "eslint \"src/**/*.{js,jsx,vue}\"",
|
||||
"clean": "rimraf dist",
|
||||
"dev": "cross-env NODE_ENV=development webpack serve",
|
||||
"dev:prod": "cross-env NODE_ENV=production webpack serve",
|
||||
"build": "pnpm build:prod",
|
||||
"build:stats": "cross-env NODE_ENV=production webpack build --json > stats.json",
|
||||
"build:prod": "cross-env NODE_ENV=production webpack build",
|
||||
"build:dev": "cross-env NODE_ENV=development webpack build",
|
||||
"font": "pnpm font:edit",
|
||||
"font:edit": "fontello-cli --cli-config fontello.client.json edit",
|
||||
"font:save": "fontello-cli --cli-config fontello.client.json save && pnpm font:update",
|
||||
"font:update": "node bin/update_fonts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ffz/fontello-cli": "^1.0.4",
|
||||
"browserslist": "^4.21.10",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"css-loader": "^6.8.1",
|
||||
"esbuild-loader": "^4.0.2",
|
||||
"eslint": "^8.48.0",
|
||||
"eslint-plugin-import": "^2.28.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-vue": "^9.17.0",
|
||||
"extract-loader": "^5.1.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"json-loader": "^0.5.7",
|
||||
"minify-graphql-loader": "^1.0.2",
|
||||
"raw-loader": "^4.0.2",
|
||||
"rimraf": "^5.0.1",
|
||||
"sass": "^1.66.1",
|
||||
"sass-loader": "^13.3.2",
|
||||
"semver": "^7.5.4",
|
||||
"vue-loader": "^15.10.2",
|
||||
"vue-template-compiler": "^2.6.14",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^4.15.1",
|
||||
"webpack-manifest-plugin": "^5.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/FrankerFaceZ/FrankerFaceZ.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ffz/icu-msgparser": "^2.0.0",
|
||||
"@popperjs/core": "^2.10.2",
|
||||
"crypto-js": "^3.3.0",
|
||||
"dayjs": "^1.10.7",
|
||||
"denoflare-mqtt": "^0.0.2",
|
||||
"displacejs": "^1.4.1",
|
||||
"emoji-regex": "^9.2.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"graphql": "^16.0.1",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"js-cookie": "^2.2.1",
|
||||
"jszip": "^3.7.1",
|
||||
"markdown-it": "^12.2.0",
|
||||
"markdown-it-link-attributes": "^3.0.0",
|
||||
"mnemonist": "^0.38.5",
|
||||
"path-to-regexp": "^3.2.0",
|
||||
"raven-js": "^3.27.2",
|
||||
"react": "^17.0.2",
|
||||
"safe-regex": "^2.1.1",
|
||||
"sortablejs": "^1.14.0",
|
||||
"sourcemapped-stacktrace": "^1.1.11",
|
||||
"text-diff": "^1.0.1",
|
||||
"vue": "^2.6.14",
|
||||
"vue-clickaway": "^2.2.2",
|
||||
"vue-color": "^2.8.1",
|
||||
"vue-observe-visibility": "^1.0.0",
|
||||
"vuedraggable": "^2.24.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"ansi-regex@>2.1.1 <5.0.1": ">=5.0.1",
|
||||
"chalk@<4": ">=4 <5",
|
||||
"set-value@<4.0.1": ">=4.0.1",
|
||||
"glob-parent@<5.1.2": ">=5.1.2"
|
||||
}
|
||||
}
|
||||
"name": "frankerfacez",
|
||||
"author": "Dan Salvato LLC",
|
||||
"version": "4.60.0",
|
||||
"description": "FrankerFaceZ is a Twitch enhancement suite.",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"start": "pnpm dev",
|
||||
"eslint": "eslint \"src/**/*.{js,jsx,vue}\"",
|
||||
"clean": "rimraf dist",
|
||||
"dev": "cross-env NODE_ENV=development webpack serve",
|
||||
"dev:prod": "cross-env NODE_ENV=production webpack serve",
|
||||
"build": "pnpm build:prod",
|
||||
"build:stats": "cross-env NODE_ENV=production webpack build --json > stats.json",
|
||||
"build:prod": "cross-env NODE_ENV=production webpack build",
|
||||
"build:dev": "cross-env NODE_ENV=development webpack build",
|
||||
"build:types": "cross-env tsc --declaration --emitDeclarationOnly --outDir typedist && node bin/build_types",
|
||||
"abuild:types": "node bin/build_types",
|
||||
"build:docs": "cross-env typedoc --options typedoc.json",
|
||||
"font": "pnpm font:edit",
|
||||
"font:edit": "fontello-cli --cli-config fontello.client.json edit",
|
||||
"font:save": "fontello-cli --cli-config fontello.client.json save && pnpm font:update",
|
||||
"font:update": "node bin/update_fonts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ffz/fontello-cli": "^1.0.4",
|
||||
"@types/safe-regex": "^1.1.6",
|
||||
"@types/webpack-env": "^1.18.4",
|
||||
"browserslist": "^4.21.10",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"css-loader": "^6.8.1",
|
||||
"esbuild-loader": "^4.0.2",
|
||||
"eslint": "^8.48.0",
|
||||
"eslint-plugin-import": "^2.28.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-vue": "^9.17.0",
|
||||
"extract-loader": "^5.1.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"glob": "^10.3.10",
|
||||
"json-loader": "^0.5.7",
|
||||
"minify-graphql-loader": "^1.0.2",
|
||||
"raw-loader": "^4.0.2",
|
||||
"rimraf": "^5.0.1",
|
||||
"sass": "^1.66.1",
|
||||
"sass-loader": "^13.3.2",
|
||||
"semver": "^7.5.4",
|
||||
"typedoc": "^0.25.3",
|
||||
"typedoc-plugin-markdown": "^3.17.1",
|
||||
"typedoc-plugin-mdn-links": "^3.1.0",
|
||||
"typedoc-plugin-no-inherit": "^1.4.0",
|
||||
"typedoc-plugin-rename-defaults": "^0.7.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vue-loader": "^15.10.2",
|
||||
"vue-template-compiler": "^2.6.14",
|
||||
"webpack": "^5.88.2",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^4.15.1",
|
||||
"webpack-manifest-plugin": "^5.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/FrankerFaceZ/FrankerFaceZ.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ffz/icu-msgparser": "^2.0.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"crypto-js": "^3.3.0",
|
||||
"dayjs": "^1.10.7",
|
||||
"denoflare-mqtt": "^0.0.2",
|
||||
"displacejs": "^1.4.1",
|
||||
"emoji-regex": "^9.2.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"graphql": "^16.0.1",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"js-cookie": "^2.2.1",
|
||||
"jszip": "^3.7.1",
|
||||
"markdown-it": "^12.2.0",
|
||||
"markdown-it-link-attributes": "^3.0.0",
|
||||
"mnemonist": "^0.38.5",
|
||||
"path-to-regexp": "^3.2.0",
|
||||
"raven-js": "^3.27.2",
|
||||
"react": "^17.0.2",
|
||||
"safe-regex": "^2.1.1",
|
||||
"sortablejs": "^1.14.0",
|
||||
"sourcemapped-stacktrace": "^1.1.11",
|
||||
"text-diff": "^1.0.1",
|
||||
"vue": "^2.6.14",
|
||||
"vue-clickaway": "^2.2.2",
|
||||
"vue-color": "^2.8.1",
|
||||
"vue-observe-visibility": "^1.0.0",
|
||||
"vuedraggable": "^2.24.3"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"ansi-regex@>2.1.1 <5.0.1": ">=5.0.1",
|
||||
"chalk@<4": ">=4 <5",
|
||||
"set-value@<4.0.1": ">=4.0.1",
|
||||
"glob-parent@<5.1.2": ">=5.1.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
174
pnpm-lock.yaml
generated
174
pnpm-lock.yaml
generated
|
@ -15,8 +15,8 @@ dependencies:
|
|||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
'@popperjs/core':
|
||||
specifier: ^2.10.2
|
||||
version: 2.10.2
|
||||
specifier: ^2.11.8
|
||||
version: 2.11.8
|
||||
crypto-js:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
|
@ -97,6 +97,12 @@ devDependencies:
|
|||
'@ffz/fontello-cli':
|
||||
specifier: ^1.0.4
|
||||
version: 1.0.4
|
||||
'@types/safe-regex':
|
||||
specifier: ^1.1.6
|
||||
version: 1.1.6
|
||||
'@types/webpack-env':
|
||||
specifier: ^1.18.4
|
||||
version: 1.18.4
|
||||
browserslist:
|
||||
specifier: ^4.21.10
|
||||
version: 4.21.10
|
||||
|
@ -130,6 +136,9 @@ devDependencies:
|
|||
file-loader:
|
||||
specifier: ^6.2.0
|
||||
version: 6.2.0(webpack@5.88.2)
|
||||
glob:
|
||||
specifier: ^10.3.10
|
||||
version: 10.3.10
|
||||
json-loader:
|
||||
specifier: ^0.5.7
|
||||
version: 0.5.7
|
||||
|
@ -151,6 +160,24 @@ devDependencies:
|
|||
semver:
|
||||
specifier: ^7.5.4
|
||||
version: 7.5.4
|
||||
typedoc:
|
||||
specifier: ^0.25.3
|
||||
version: 0.25.3(typescript@5.2.2)
|
||||
typedoc-plugin-markdown:
|
||||
specifier: ^3.17.1
|
||||
version: 3.17.1(typedoc@0.25.3)
|
||||
typedoc-plugin-mdn-links:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0(typedoc@0.25.3)
|
||||
typedoc-plugin-no-inherit:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0(typedoc@0.25.3)
|
||||
typedoc-plugin-rename-defaults:
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.0(typedoc@0.25.3)
|
||||
typescript:
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2
|
||||
vue-loader:
|
||||
specifier: ^15.10.2
|
||||
version: 15.10.2(css-loader@6.8.1)(react@17.0.2)(vue-template-compiler@2.6.14)(webpack@5.88.2)
|
||||
|
@ -535,8 +562,8 @@ packages:
|
|||
dev: true
|
||||
optional: true
|
||||
|
||||
/@popperjs/core@2.10.2:
|
||||
resolution: {integrity: sha512-IXf3XA7+XyN7CP9gGh/XB0UxVMlvARGEgGXLubFICsUMGz6Q+DU+i4gGlpOxTjKvXjkJDJC8YdqdKkDj9qZHEQ==}
|
||||
/@popperjs/core@2.11.8:
|
||||
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
||||
dev: false
|
||||
|
||||
/@types/body-parser@1.19.2:
|
||||
|
@ -643,6 +670,10 @@ packages:
|
|||
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
|
||||
dev: true
|
||||
|
||||
/@types/safe-regex@1.1.6:
|
||||
resolution: {integrity: sha512-CQ/uPB9fLOPKwDsrTeVbNIkwfUthTWOx0l6uIGwVFjZxv7e68pCW5gtTYFzdJi3EBJp8h8zYhJbTasAbX7gEMQ==}
|
||||
dev: true
|
||||
|
||||
/@types/send@0.17.1:
|
||||
resolution: {integrity: sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==}
|
||||
dependencies:
|
||||
|
@ -670,6 +701,10 @@ packages:
|
|||
'@types/node': 20.5.7
|
||||
dev: true
|
||||
|
||||
/@types/webpack-env@1.18.4:
|
||||
resolution: {integrity: sha512-I6e+9+HtWADAWeeJWDFQtdk4EVSAbj6Rtz4q8fJ7mSr1M0jzlFcs8/HZ+Xb5SHzVm1dxH7aUiI+A8kA8Gcrm0A==}
|
||||
dev: true
|
||||
|
||||
/@types/ws@8.5.5:
|
||||
resolution: {integrity: sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==}
|
||||
dependencies:
|
||||
|
@ -989,6 +1024,10 @@ packages:
|
|||
engines: {node: '>=12'}
|
||||
dev: true
|
||||
|
||||
/ansi-sequence-parser@1.1.1:
|
||||
resolution: {integrity: sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==}
|
||||
dev: true
|
||||
|
||||
/ansi-styles@4.3.0:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
@ -1770,6 +1809,11 @@ packages:
|
|||
engines: {node: '>=6'}
|
||||
dev: true
|
||||
|
||||
/camelcase@8.0.0:
|
||||
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
|
||||
engines: {node: '>=16'}
|
||||
dev: true
|
||||
|
||||
/caniuse-lite@1.0.30001524:
|
||||
resolution: {integrity: sha512-Jj917pJtYg9HSJBF95HVX3Cdr89JUyLT4IZ8SvM5aDRni95swKgYi3TgYLH5hnGfPE/U1dg6IfZ50UsIlLkwSA==}
|
||||
dev: true
|
||||
|
@ -3056,13 +3100,13 @@ packages:
|
|||
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
|
||||
dev: true
|
||||
|
||||
/glob@10.3.3:
|
||||
resolution: {integrity: sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw==}
|
||||
/glob@10.3.10:
|
||||
resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
foreground-child: 3.1.1
|
||||
jackspeak: 2.3.0
|
||||
jackspeak: 2.3.6
|
||||
minimatch: 9.0.3
|
||||
minipass: 7.0.3
|
||||
path-scurry: 1.10.1
|
||||
|
@ -3153,6 +3197,19 @@ packages:
|
|||
resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==}
|
||||
dev: true
|
||||
|
||||
/handlebars@4.7.8:
|
||||
resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
|
||||
engines: {node: '>=0.4.7'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
neo-async: 2.6.2
|
||||
source-map: 0.6.1
|
||||
wordwrap: 1.0.0
|
||||
optionalDependencies:
|
||||
uglify-js: 3.17.4
|
||||
dev: true
|
||||
|
||||
/has-bigints@1.0.2:
|
||||
resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==}
|
||||
dev: true
|
||||
|
@ -3626,8 +3683,8 @@ packages:
|
|||
reflect.getprototypeof: 1.0.3
|
||||
dev: true
|
||||
|
||||
/jackspeak@2.3.0:
|
||||
resolution: {integrity: sha512-uKmsITSsF4rUWQHzqaRUuyAir3fZfW3f202Ee34lz/gZCi970CPZwyQXLGNgWJvvZbvFyzeyGq0+4fcG/mBKZg==}
|
||||
/jackspeak@2.3.6:
|
||||
resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==}
|
||||
engines: {node: '>=14'}
|
||||
dependencies:
|
||||
'@isaacs/cliui': 8.0.2
|
||||
|
@ -3710,6 +3767,10 @@ packages:
|
|||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/jsonc-parser@3.2.0:
|
||||
resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==}
|
||||
dev: true
|
||||
|
||||
/jsx-ast-utils@3.2.1:
|
||||
resolution: {integrity: sha512-uP5vu8xfy2F9A6LGC22KO7e2/vGTS1MhP+18f++ZNlf0Ohaxbc9nIEwHAsejlJKyzfZzU5UIhe5ItYkitcZnZA==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
@ -3837,6 +3898,10 @@ packages:
|
|||
yallist: 4.0.0
|
||||
dev: true
|
||||
|
||||
/lunr@2.3.9:
|
||||
resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==}
|
||||
dev: true
|
||||
|
||||
/markdown-it-link-attributes@3.0.0:
|
||||
resolution: {integrity: sha512-B34ySxVeo6MuEGSPCWyIYryuXINOvngNZL87Mp7YYfKIf6DcD837+lXA8mo6EBbauKsnGz22ZH0zsbOiQRWTNg==}
|
||||
dev: false
|
||||
|
@ -3852,6 +3917,12 @@ packages:
|
|||
uc.micro: 1.0.6
|
||||
dev: false
|
||||
|
||||
/marked@4.3.0:
|
||||
resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==}
|
||||
engines: {node: '>= 12'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/material-colors@1.2.6:
|
||||
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
|
||||
dev: false
|
||||
|
@ -4679,7 +4750,7 @@ packages:
|
|||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
glob: 10.3.3
|
||||
glob: 10.3.10
|
||||
dev: true
|
||||
|
||||
/run-parallel@1.2.0:
|
||||
|
@ -4899,6 +4970,15 @@ packages:
|
|||
resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==}
|
||||
dev: true
|
||||
|
||||
/shiki@0.14.5:
|
||||
resolution: {integrity: sha512-1gCAYOcmCFONmErGTrS1fjzJLA7MGZmKzrBNX7apqSwhyITJg2O102uFzXUeBxNnEkDA9vHIKLyeKq0V083vIw==}
|
||||
dependencies:
|
||||
ansi-sequence-parser: 1.1.1
|
||||
jsonc-parser: 3.2.0
|
||||
vscode-oniguruma: 1.7.0
|
||||
vscode-textmate: 8.0.0
|
||||
dev: true
|
||||
|
||||
/side-channel@1.0.4:
|
||||
resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==}
|
||||
dependencies:
|
||||
|
@ -5287,10 +5367,72 @@ packages:
|
|||
is-typed-array: 1.1.12
|
||||
dev: true
|
||||
|
||||
/typedoc-plugin-markdown@3.17.1(typedoc@0.25.3):
|
||||
resolution: {integrity: sha512-QzdU3fj0Kzw2XSdoL15ExLASt2WPqD7FbLeaqwT70+XjKyTshBnUlQA5nNREO1C2P8Uen0CDjsBLMsCQ+zd0lw==}
|
||||
peerDependencies:
|
||||
typedoc: '>=0.24.0'
|
||||
dependencies:
|
||||
handlebars: 4.7.8
|
||||
typedoc: 0.25.3(typescript@5.2.2)
|
||||
dev: true
|
||||
|
||||
/typedoc-plugin-mdn-links@3.1.0(typedoc@0.25.3):
|
||||
resolution: {integrity: sha512-4uwnkvywPFV3UVx7WXpIWTHJdXH1rlE2e4a1WsSwCFYKqJxgTmyapv3ZxJtbSl1dvnb6jmuMNSqKEPz77Gs2OA==}
|
||||
peerDependencies:
|
||||
typedoc: '>= 0.23.14 || 0.24.x || 0.25.x'
|
||||
dependencies:
|
||||
typedoc: 0.25.3(typescript@5.2.2)
|
||||
dev: true
|
||||
|
||||
/typedoc-plugin-no-inherit@1.4.0(typedoc@0.25.3):
|
||||
resolution: {integrity: sha512-cAvqQ8X9xh1xztVoDKtF4nYRSBx9XwttN3OBbNNpA0YaJSRM8XvpVVhugq8FoO1HdWjF3aizS0JzdUOMDt0y9g==}
|
||||
peerDependencies:
|
||||
typedoc: '>=0.23.0'
|
||||
dependencies:
|
||||
typedoc: 0.25.3(typescript@5.2.2)
|
||||
dev: true
|
||||
|
||||
/typedoc-plugin-rename-defaults@0.7.0(typedoc@0.25.3):
|
||||
resolution: {integrity: sha512-NudSQ1o/XLHNF9c4y7LzIZxfE9ltz09yCDklBPJpP5VMRvuBpYGIbQ0ZgmPz+EIV8vPx9Z/OyKwzp4HT2vDtfg==}
|
||||
peerDependencies:
|
||||
typedoc: 0.22.x || 0.23.x || 0.24.x || 0.25.x
|
||||
dependencies:
|
||||
camelcase: 8.0.0
|
||||
typedoc: 0.25.3(typescript@5.2.2)
|
||||
dev: true
|
||||
|
||||
/typedoc@0.25.3(typescript@5.2.2):
|
||||
resolution: {integrity: sha512-Ow8Bo7uY1Lwy7GTmphRIMEo6IOZ+yYUyrc8n5KXIZg1svpqhZSWgni2ZrDhe+wLosFS8yswowUzljTAV/3jmWw==}
|
||||
engines: {node: '>= 16'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x
|
||||
dependencies:
|
||||
lunr: 2.3.9
|
||||
marked: 4.3.0
|
||||
minimatch: 9.0.3
|
||||
shiki: 0.14.5
|
||||
typescript: 5.2.2
|
||||
dev: true
|
||||
|
||||
/typescript@5.2.2:
|
||||
resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
dev: true
|
||||
|
||||
/uc.micro@1.0.6:
|
||||
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
|
||||
dev: false
|
||||
|
||||
/uglify-js@3.17.4:
|
||||
resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==}
|
||||
engines: {node: '>=0.8.0'}
|
||||
hasBin: true
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/unbox-primitive@1.0.2:
|
||||
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
|
||||
dependencies:
|
||||
|
@ -5355,6 +5497,14 @@ packages:
|
|||
engines: {node: '>= 0.8'}
|
||||
dev: true
|
||||
|
||||
/vscode-oniguruma@1.7.0:
|
||||
resolution: {integrity: sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==}
|
||||
dev: true
|
||||
|
||||
/vscode-textmate@8.0.0:
|
||||
resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==}
|
||||
dev: true
|
||||
|
||||
/vue-clickaway@2.2.2(vue@2.6.14):
|
||||
resolution: {integrity: sha512-25SpjXKetL06GLYoLoC8pqAV6Cur9cQ//2g35GRFBV4FgoljbZZjTINR8g2NuVXXDMLSUXaKx5dutgO4PaDE7A==}
|
||||
peerDependencies:
|
||||
|
@ -5789,6 +5939,10 @@ packages:
|
|||
resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==}
|
||||
dev: true
|
||||
|
||||
/wordwrap@1.0.0:
|
||||
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
|
||||
dev: true
|
||||
|
||||
/wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
|
@ -12,7 +12,7 @@ import {timeout} from 'utilities/object';
|
|||
import SettingsManager from './settings/index';
|
||||
import AddonManager from './addons';
|
||||
import ExperimentManager from './experiments';
|
||||
import {TranslationManager} from './i18n';
|
||||
import TranslationManager from './i18n';
|
||||
import PubSubClient from './pubsub';
|
||||
import StagingSelector from './staging';
|
||||
import LoadTracker from './load_tracker';
|
||||
|
|
|
@ -4,8 +4,6 @@
|
|||
// Localization
|
||||
// ============================================================================
|
||||
|
||||
import Parser from '@ffz/icu-msgparser';
|
||||
|
||||
import {DEBUG, SERVER} from 'utilities/constants';
|
||||
import {get, pick_random, shallow_copy, deep_copy} from 'utilities/object';
|
||||
import { getBuster } from 'utilities/time';
|
||||
|
@ -69,13 +67,11 @@ const FACES = ['(・`ω´・)', ';;w;;', 'owo', 'ono', 'oAo', 'oxo', 'ovo;', 'Uw
|
|||
// TranslationManager
|
||||
// ============================================================================
|
||||
|
||||
export class TranslationManager extends Module {
|
||||
export default class TranslationManager extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
this.inject('settings');
|
||||
|
||||
this.parser = new Parser;
|
||||
|
||||
this._seen = new Set;
|
||||
|
||||
this.availableLocales = ['en'];
|
||||
|
|
|
@ -1,79 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Loading Tracker
|
||||
// ============================================================================
|
||||
|
||||
import Module from 'utilities/module';
|
||||
|
||||
export default class LoadTracker extends Module {
|
||||
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.should_enable = true;
|
||||
|
||||
this.inject('settings');
|
||||
|
||||
this.settings.add('chat.update-when-loaded', {
|
||||
default: true,
|
||||
ui: {
|
||||
path: 'Chat > Behavior >> General',
|
||||
title: 'Update existing chat messages when loading new data.',
|
||||
component: 'setting-check-box',
|
||||
description: 'This may cause elements in chat to move, so you may wish to disable this when performing moderation.'
|
||||
}
|
||||
});
|
||||
|
||||
this.pending_loads = new Map;
|
||||
|
||||
this.on(':schedule', this.schedule, this);
|
||||
|
||||
}
|
||||
|
||||
schedule(type, key) {
|
||||
let data = this.pending_loads.get(type);
|
||||
if ( ! data || ! data.pending || ! data.timers ) {
|
||||
data = {
|
||||
pending: new Set,
|
||||
timers: {},
|
||||
success: false
|
||||
};
|
||||
this.pending_loads.set(type, data);
|
||||
}
|
||||
|
||||
if ( data.pending.has(key) )
|
||||
return;
|
||||
|
||||
data.pending.add(key);
|
||||
data.timers[key] = setTimeout(() => this.notify(type, key, false), 15000);
|
||||
}
|
||||
|
||||
notify(type, key, success = true) {
|
||||
const data = this.pending_loads.get(type);
|
||||
if ( ! data || ! data.pending || ! data.timers )
|
||||
return;
|
||||
|
||||
if ( data.timers[key] ) {
|
||||
clearTimeout(data.timers[key]);
|
||||
data.timers[key] = null;
|
||||
}
|
||||
|
||||
if ( ! data.pending.has(key) )
|
||||
return;
|
||||
|
||||
data.pending.delete(key);
|
||||
if ( success )
|
||||
data.success = true;
|
||||
|
||||
if ( ! data.pending.size ) {
|
||||
const keys = Object.keys(data.timers);
|
||||
|
||||
this.log.debug('complete', type, keys);
|
||||
if ( data.success )
|
||||
this.emit(`:complete:${type}`, keys);
|
||||
this.pending_loads.delete(type);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
155
src/load_tracker.ts
Normal file
155
src/load_tracker.ts
Normal file
|
@ -0,0 +1,155 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Loading Tracker
|
||||
// ============================================================================
|
||||
|
||||
import Module, { GenericModule } from 'utilities/module';
|
||||
import type SettingsManager from './settings';
|
||||
|
||||
type PendingLoadData = {
|
||||
pending: Set<string>;
|
||||
timers: Record<string, ReturnType<typeof setTimeout> | null>;
|
||||
success: boolean
|
||||
};
|
||||
|
||||
|
||||
export type LoadEvents = {
|
||||
':schedule': [type: string, key: string],
|
||||
[key: `:complete:${string}`]: [keys: string[]]
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* LoadTracker is a module used for coordinating loading events between
|
||||
* the core of FrankerFaceZ and any present add-ons. This allows for
|
||||
* enhanced performance by, for example, only refreshing chat messages
|
||||
* once emote data has been loaded by all of a user's add-ons.
|
||||
*
|
||||
* @example How to use load tracker if you're loading emotes.
|
||||
* ```typescript
|
||||
* // Inform the load tracker that we're trying to load data.
|
||||
* this.load_tracker.schedule('chat-data', 'my-addon--emotes-global');
|
||||
*
|
||||
* // Load our data.
|
||||
* let emotes;
|
||||
* try {
|
||||
* emotes = await loadEmotesFromSomewhere();
|
||||
* } catch(err) {
|
||||
* // Notify that we failed to load, so it stops waiting.
|
||||
* this.load_tracker.notify('chat-data', 'my-addon--emotes-global', false);
|
||||
* return;
|
||||
* }
|
||||
*
|
||||
* // Load the emote data.
|
||||
* this.emotes.addDefaultSet('my-addon', 'my-addon--global-emotes', emotes);
|
||||
*
|
||||
* // Notify that we succeeded.
|
||||
* this.load_tracker.notify('chat-data', 'my-addon--emotes-global', true);
|
||||
* ```
|
||||
*
|
||||
* @noInheritDoc
|
||||
*/
|
||||
export default class LoadTracker extends Module<'load_tracker', LoadEvents> {
|
||||
|
||||
/** A map for storing information about pending loadables. */
|
||||
private pending_loads: Map<string, PendingLoadData> = new Map();
|
||||
|
||||
// Dependencies.
|
||||
settings: SettingsManager = null as any;
|
||||
|
||||
/** @internal */
|
||||
constructor(name?: string, parent?: GenericModule) {
|
||||
super(name, parent);
|
||||
|
||||
this.should_enable = true;
|
||||
|
||||
this.inject('settings');
|
||||
|
||||
this.settings.add('chat.update-when-loaded', {
|
||||
default: true,
|
||||
ui: {
|
||||
path: 'Chat > Behavior >> General',
|
||||
title: 'Update existing chat messages when loading new data.',
|
||||
component: 'setting-check-box',
|
||||
description: 'This may cause elements in chat to move, so you may wish to disable this when performing moderation.'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
onEnable() {
|
||||
this.emit('load_tracker:schedule', 'test', 'fish');
|
||||
|
||||
this.on(':schedule', this.schedule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register our intent to perform a load. This lets the system know that
|
||||
* a load of {@link type} is pending, and it starts a wait of 15 seconds
|
||||
* for the load to complete.
|
||||
*
|
||||
* You must, after using this, call {@link notify} when your load
|
||||
* completes or fails. That ensures that the system does not wait
|
||||
* needlessly after your load process has finished.
|
||||
*
|
||||
* @param type The load type.
|
||||
* @param key A unique key for your load, on this load type. If you are
|
||||
* loading multiple times (for example, global emotes and channel-specific
|
||||
* emotes), you should use two distinct keys.
|
||||
*/
|
||||
schedule(type: string, key: string) {
|
||||
let data = this.pending_loads.get(type);
|
||||
if ( ! data || ! data.pending || ! data.timers ) {
|
||||
data = {
|
||||
pending: new Set,
|
||||
timers: {},
|
||||
success: false
|
||||
};
|
||||
this.pending_loads.set(type, data);
|
||||
}
|
||||
|
||||
if ( data.pending.has(key) )
|
||||
return;
|
||||
|
||||
data.pending.add(key);
|
||||
data.timers[key] = setTimeout(() => this.notify(type, key, false), 15000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the load tracker that your load has completed. If all loads
|
||||
* for the given type have been completed, and any of the loads were
|
||||
* a success, then a `:complete:${type}` event will be fired.
|
||||
* @param type The load type.
|
||||
* @param key A unique key for your load. The same that you use
|
||||
* with {@link schedule}.
|
||||
* @param success Whether or not your load was a success.
|
||||
*/
|
||||
notify(type: string, key: string, success = true) {
|
||||
const data = this.pending_loads.get(type);
|
||||
if ( ! data || ! data.pending || ! data.timers )
|
||||
return;
|
||||
|
||||
if ( data.timers[key] ) {
|
||||
clearTimeout(data.timers[key] as any);
|
||||
data.timers[key] = null;
|
||||
}
|
||||
|
||||
if ( ! data.pending.has(key) )
|
||||
return;
|
||||
|
||||
data.pending.delete(key);
|
||||
if ( success )
|
||||
data.success = true;
|
||||
|
||||
if ( ! data.pending.size ) {
|
||||
const keys = Object.keys(data.timers);
|
||||
|
||||
this.log.debug('complete', type, keys);
|
||||
if ( data.success )
|
||||
this.emit(`:complete:${type}`, keys);
|
||||
this.pending_loads.delete(type);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -4,7 +4,7 @@ import dayjs from 'dayjs';
|
|||
//import RavenLogger from './raven';
|
||||
|
||||
import Logger from 'utilities/logging';
|
||||
import Module from 'utilities/module';
|
||||
import Module, { State } from 'utilities/module';
|
||||
import { timeout } from 'utilities/object';
|
||||
|
||||
import {DEBUG} from 'utilities/constants';
|
||||
|
@ -12,16 +12,87 @@ import {DEBUG} from 'utilities/constants';
|
|||
import SettingsManager from './settings/index';
|
||||
import AddonManager from './addons';
|
||||
import ExperimentManager from './experiments';
|
||||
import {TranslationManager} from './i18n';
|
||||
import TranslationManager from './i18n';
|
||||
import SocketClient from './socket';
|
||||
import PubSubClient from './pubsub';
|
||||
import Site from 'site';
|
||||
import Vue from 'utilities/vue';
|
||||
import StagingSelector from './staging';
|
||||
import LoadTracker from './load_tracker';
|
||||
//import Timing from 'utilities/timing';
|
||||
|
||||
import type { ClientVersion } from 'utilities/types';
|
||||
|
||||
import * as Utility_Addons from 'utilities/addon';
|
||||
import * as Utility_Blobs from 'utilities/blobs';
|
||||
import * as Utility_Color from 'utilities/color';
|
||||
import * as Utility_Constants from 'utilities/constants';
|
||||
import * as Utility_Dialog from 'utilities/dialog';
|
||||
import * as Utility_DOM from 'utilities/dom';
|
||||
import * as Utility_Events from 'utilities/events';
|
||||
import * as Utility_FontAwesome from 'utilities/font-awesome';
|
||||
import * as Utility_GraphQL from 'utilities/graphql';
|
||||
import * as Utility_Logging from 'utilities/logging';
|
||||
import * as Utility_Module from 'utilities/module';
|
||||
import * as Utility_Object from 'utilities/object';
|
||||
import * as Utility_Time from 'utilities/time';
|
||||
import * as Utility_Tooltip from 'utilities/tooltip';
|
||||
import * as Utility_I18n from 'utilities/translation-core';
|
||||
import * as Utility_Filtering from 'utilities/filtering';
|
||||
|
||||
class FrankerFaceZ extends Module {
|
||||
|
||||
static instance: FrankerFaceZ = null as any;
|
||||
static version_info: ClientVersion = null as any;
|
||||
static Logger = Logger;
|
||||
|
||||
static utilities = {
|
||||
addon: Utility_Addons,
|
||||
blobs: Utility_Blobs,
|
||||
color: Utility_Color,
|
||||
constants: Utility_Constants,
|
||||
dialog: Utility_Dialog,
|
||||
dom: Utility_DOM,
|
||||
events: Utility_Events,
|
||||
fontAwesome: Utility_FontAwesome,
|
||||
graphql: Utility_GraphQL,
|
||||
logging: Utility_Logging,
|
||||
module: Utility_Module,
|
||||
object: Utility_Object,
|
||||
time: Utility_Time,
|
||||
tooltip: Utility_Tooltip,
|
||||
i18n: Utility_I18n,
|
||||
filtering: Utility_Filtering
|
||||
};
|
||||
|
||||
/*
|
||||
static utilities = {
|
||||
addon: require('utilities/addon'),
|
||||
blobs: require('utilities/blobs'),
|
||||
color: require('utilities/color'),
|
||||
constants: require('utilities/constants'),
|
||||
dialog: require('utilities/dialog'),
|
||||
dom: require('utilities/dom'),
|
||||
events: require('utilities/events'),
|
||||
fontAwesome: require('utilities/font-awesome'),
|
||||
graphql: require('utilities/graphql'),
|
||||
logging: require('utilities/logging'),
|
||||
module: require('utilities/module'),
|
||||
object: require('utilities/object'),
|
||||
time: require('utilities/time'),
|
||||
tooltip: require('utilities/tooltip'),
|
||||
i18n: require('utilities/translation-core'),
|
||||
dayjs: require('dayjs'),
|
||||
filtering: require('utilities/filtering'),
|
||||
popper: require('@popperjs/core')
|
||||
};
|
||||
*/
|
||||
|
||||
|
||||
core_log: Logger;
|
||||
|
||||
host: string;
|
||||
flavor: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const start_time = performance.now();
|
||||
|
@ -31,12 +102,14 @@ class FrankerFaceZ extends Module {
|
|||
this.host = 'twitch';
|
||||
this.flavor = 'main';
|
||||
this.name = 'frankerfacez';
|
||||
this.__state = 0;
|
||||
this.__modules.core = this;
|
||||
|
||||
// Evil private member access.
|
||||
(this as any).__state = State.Disabled;
|
||||
(this as any).__modules.core = this;
|
||||
|
||||
// Timing
|
||||
//this.inject('timing', Timing);
|
||||
this.__time('instance');
|
||||
this._time('instance');
|
||||
|
||||
// ========================================================================
|
||||
// Error Reporting and Logging
|
||||
|
@ -48,7 +121,7 @@ class FrankerFaceZ extends Module {
|
|||
this.log.init = true;
|
||||
|
||||
this.core_log = this.log.get('core');
|
||||
this.log.hi(this);
|
||||
this.log.hi(this, FrankerFaceZ.version_info);
|
||||
|
||||
|
||||
// ========================================================================
|
||||
|
@ -96,14 +169,13 @@ class FrankerFaceZ extends Module {
|
|||
|
||||
async generateLog() {
|
||||
const promises = [];
|
||||
for(const key in this.__modules) {
|
||||
const module = this.__modules[key];
|
||||
if ( module instanceof Module && module.generateLog && module != this )
|
||||
for(const [key, module] of Object.entries((this as any).__modules)) {
|
||||
if ( module instanceof Module && module.generateLog && (module as any) != this )
|
||||
promises.push((async () => {
|
||||
try {
|
||||
return [
|
||||
key,
|
||||
await timeout(Promise.resolve(module.generateLog()), 5000)
|
||||
await timeout(Promise.resolve((module as any).generateLog()), 5000)
|
||||
];
|
||||
} catch(err) {
|
||||
return [
|
||||
|
@ -141,11 +213,11 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}`).join('\n\n'
|
|||
const ctx = await require.context(
|
||||
'src/modules',
|
||||
true,
|
||||
/(?:^(?:\.\/)?[^/]+|index)\.jsx?$/
|
||||
/(?:^(?:\.\/)?[^/]+|index)\.[jt]sx?$/
|
||||
/*, 'lazy-once' */
|
||||
);
|
||||
|
||||
const modules = this.populate(ctx, this.core_log);
|
||||
const modules = this.loadFromContext(ctx, this.core_log);
|
||||
|
||||
this.core_log.info(`Loaded descriptions of ${Object.keys(modules).length} modules.`);
|
||||
}
|
||||
|
@ -153,20 +225,17 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}`).join('\n\n'
|
|||
|
||||
async enableInitialModules() {
|
||||
const promises = [];
|
||||
/* eslint guard-for-in: off */
|
||||
for(const key in this.__modules) {
|
||||
const module = this.__modules[key];
|
||||
for(const module of Object.values((this as any).__modules)) {
|
||||
if ( module instanceof Module && module.should_enable )
|
||||
promises.push(module.enable());
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
return Promise.all(promises);
|
||||
}
|
||||
}
|
||||
|
||||
FrankerFaceZ.Logger = Logger;
|
||||
|
||||
const VER = FrankerFaceZ.version_info = Object.freeze({
|
||||
const VER: ClientVersion = FrankerFaceZ.version_info = Object.freeze({
|
||||
major: __version_major__,
|
||||
minor: __version_minor__,
|
||||
revision: __version_patch__,
|
||||
|
@ -179,27 +248,14 @@ const VER = FrankerFaceZ.version_info = Object.freeze({
|
|||
});
|
||||
|
||||
|
||||
FrankerFaceZ.utilities = {
|
||||
addon: require('utilities/addon'),
|
||||
blobs: require('utilities/blobs'),
|
||||
color: require('utilities/color'),
|
||||
constants: require('utilities/constants'),
|
||||
dialog: require('utilities/dialog'),
|
||||
dom: require('utilities/dom'),
|
||||
events: require('utilities/events'),
|
||||
fontAwesome: require('utilities/font-awesome'),
|
||||
graphql: require('utilities/graphql'),
|
||||
logging: require('utilities/logging'),
|
||||
module: require('utilities/module'),
|
||||
object: require('utilities/object'),
|
||||
time: require('utilities/time'),
|
||||
tooltip: require('utilities/tooltip'),
|
||||
i18n: require('utilities/translation-core'),
|
||||
dayjs: require('dayjs'),
|
||||
filtering: require('utilities/filtering'),
|
||||
popper: require('@popperjs/core')
|
||||
}
|
||||
export default FrankerFaceZ;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
FrankerFaceZ: typeof FrankerFaceZ;
|
||||
ffz: FrankerFaceZ;
|
||||
}
|
||||
}
|
||||
|
||||
window.FrankerFaceZ = FrankerFaceZ;
|
||||
window.ffz = new FrankerFaceZ();
|
|
@ -80,7 +80,7 @@ export default {
|
|||
if ( ! ds )
|
||||
return;
|
||||
|
||||
const evt = new FFZEvent({
|
||||
const evt = FFZEvent.makeEvent({
|
||||
url: ds.url ?? target.href,
|
||||
source: event
|
||||
});
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import Module, { buildAddonProxy } from 'utilities/module';
|
||||
import {ManagedStyle} from 'utilities/dom';
|
||||
import { FFZEvent } from 'utilities/events';
|
||||
|
||||
import {get, has, timeout, SourcedSet, make_enum_flags, makeAddonIdChecker} from 'utilities/object';
|
||||
import {NEW_API, IS_OSX, EmoteTypes, TWITCH_GLOBAL_SETS, TWITCH_POINTS_SETS, TWITCH_PRIME_SETS, DEBUG} from 'utilities/constants';
|
||||
|
||||
|
@ -1315,7 +1315,7 @@ export default class Emotes extends Module {
|
|||
/* no-op */
|
||||
}
|
||||
|
||||
const evt = new FFZEvent({
|
||||
const evt = this.makeEvent({
|
||||
provider,
|
||||
id: ds.id,
|
||||
set: ds.set,
|
||||
|
|
|
@ -10,7 +10,6 @@ import { DEBUG, LINK_DATA_HOSTS } from 'utilities/constants';
|
|||
import Module, { buildAddonProxy } from 'utilities/module';
|
||||
import {Color} from 'utilities/color';
|
||||
import {createElement, ManagedStyle} from 'utilities/dom';
|
||||
import {FFZEvent} from 'utilities/events';
|
||||
import {getFontsList} from 'utilities/fonts';
|
||||
import {timeout, has, addWordSeparators, glob_to_regex, escape_regex, split_chars, makeAddonIdChecker} from 'utilities/object';
|
||||
|
||||
|
@ -1800,7 +1799,7 @@ export default class Chat extends Module {
|
|||
if ( ! ds )
|
||||
return;
|
||||
|
||||
const evt = new FFZEvent({
|
||||
const evt = this.makeEvent({
|
||||
url: ds.url ?? target.href,
|
||||
source: event
|
||||
});
|
||||
|
@ -1811,7 +1810,6 @@ export default class Chat extends Module {
|
|||
event.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -55,8 +55,8 @@
|
|||
<script>
|
||||
|
||||
import Sortable from 'sortablejs';
|
||||
import {deep_copy, maybe_call, generateUUID} from 'utilities/object';
|
||||
import {findSharedParent} from 'utilities/dom';
|
||||
import { deep_copy, maybe_call, generateUUID } from 'utilities/object';
|
||||
import { hasSharedParent } from 'utilities/dom';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
|
@ -131,7 +131,7 @@ export default {
|
|||
|
||||
// Check to see if we have a common ancester for the two
|
||||
// draggables.
|
||||
if ( ! findSharedParent(to.el, from.el, '.ffz--rule-list') )
|
||||
if ( ! hasSharedParent(to.el, from.el, '.ffz--rule-list') )
|
||||
return false;
|
||||
|
||||
return true;
|
||||
|
|
|
@ -77,7 +77,7 @@ export default {
|
|||
this.client = this.ffz.resolve('site.apollo')?.client;
|
||||
this.has_client = !! this.client;
|
||||
|
||||
this.printer = this.ffz.resolve('site.web_munch')?.getModule?.('gql-printer');
|
||||
this.printer = this.ffz.resolve('site.web_munch')?.getModule('gql-printer');
|
||||
this.has_printer = !! this.printer;
|
||||
},
|
||||
|
||||
|
|
|
@ -492,7 +492,7 @@ export default class MainMenu extends Module {
|
|||
current = tree.keys[state.ffzcc];
|
||||
if ( ! current ) {
|
||||
const params = new URL(window.location).searchParams,
|
||||
key = params?.get?.('ffz-settings');
|
||||
key = params?.get('ffz-settings');
|
||||
current = key && tree.keys[key];
|
||||
}
|
||||
if ( ! current )
|
||||
|
@ -1161,7 +1161,7 @@ export default class MainMenu extends Module {
|
|||
restored = false;
|
||||
} if ( ! current ) {
|
||||
const params = new URL(window.location).searchParams,
|
||||
key = params?.get?.('ffz-settings');
|
||||
key = params?.get('ffz-settings');
|
||||
current = key && settings.keys[key];
|
||||
if ( ! current )
|
||||
restored = false;
|
||||
|
|
|
@ -1,23 +1,216 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Channel Metadata
|
||||
// ============================================================================
|
||||
|
||||
import { DEBUG } from 'utilities/constants';
|
||||
|
||||
import {createElement, ClickOutside, setChildren} from 'utilities/dom';
|
||||
import {maybe_call} from 'utilities/object';
|
||||
|
||||
import Module, { buildAddonProxy, GenericModule } from 'utilities/module';
|
||||
import {duration_to_string, durationForURL} from 'utilities/time';
|
||||
import Tooltip, { TooltipInstance } from 'utilities/tooltip';
|
||||
import type { AddonInfo, DomFragment, OptionallyThisCallable, OptionalPromise } from 'utilities/types';
|
||||
|
||||
import Tooltip from 'utilities/tooltip';
|
||||
import Module from 'utilities/module';
|
||||
import { DEBUG } from 'src/utilities/constants';
|
||||
import type SettingsManager from '../settings';
|
||||
import type TranslationManager from '../i18n';
|
||||
import type TooltipProvider from './tooltips';
|
||||
import type SocketClient from '../socket';
|
||||
|
||||
const CLIP_URL = /^https:\/\/[^/]+\.(?:twitch\.tv|twitchcdn\.net)\/.+?\.mp4(?:\?.*)?$/;
|
||||
|
||||
declare global {
|
||||
interface Element {
|
||||
_ffz_stat?: HTMLElement | null;
|
||||
_ffz_data?: any;
|
||||
_ffz_order?: number | null;
|
||||
|
||||
_ffz_destroy?: (() => void) | null;
|
||||
_ffz_outside?: ClickOutside<any> | null;
|
||||
_ffz_popup?: Tooltip | null;
|
||||
tip?: TooltipInstance | null;
|
||||
tip_content?: any;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export type MetadataState = {
|
||||
/** Whether or not the metadata is being rendered onto the player directly. */
|
||||
is_player: boolean;
|
||||
|
||||
/** The current channel. */
|
||||
channel: {
|
||||
/** The channel's user ID. */
|
||||
id: string;
|
||||
/** The channel's login name. */
|
||||
login: string;
|
||||
/** The channel's display name. */
|
||||
display_name: string;
|
||||
/** Whether or not the channel is currently displaying a video. */
|
||||
video: boolean;
|
||||
/** Whether or not the channel is currently live. */
|
||||
live: boolean;
|
||||
/** When the channel went live, if it is currently live. */
|
||||
live_since: string | Date;
|
||||
};
|
||||
|
||||
/** Get the current number of viewers watching the current channel. */
|
||||
getViewerCount: () => number;
|
||||
|
||||
/** Get the broadcast ID of the current live broadcast, assuming the current channel is live. */
|
||||
getBroadcastID: () => string | null;
|
||||
|
||||
/** Get the currently logged in user's relationship with the current channel. */
|
||||
// TODO: Types
|
||||
getUserSelf: () => Promise<any>;
|
||||
|
||||
/**
|
||||
* Get the currently logged in user's relationship with the current
|
||||
* channel, immediately. When data loads, if it is not already available
|
||||
* at the time of the call, and a callback method is provided, the
|
||||
* callback method will be called with the data.
|
||||
*/
|
||||
// TODO: Types
|
||||
getUserSelfImmediate: (callback?: (data: any) => void) => any | null;
|
||||
|
||||
/** A method that, when called, will trigger the metadata element to be refreshed. */
|
||||
refresh: () => void;
|
||||
|
||||
}
|
||||
|
||||
|
||||
type OptionallyCallable<TData, TReturn> = OptionallyThisCallable<Metadata, [data: TData], TReturn>;
|
||||
|
||||
|
||||
/**
|
||||
* A metadata definition contains all the information that FrankerFaceZ
|
||||
* needs in order to render a player metadata element. This includes special
|
||||
* data processing, how often to refresh, behavior when interacted with,
|
||||
* and various appearance options.
|
||||
*/
|
||||
export type MetadataDefinition<TData = MetadataState> = {
|
||||
|
||||
// Targets
|
||||
modview?: boolean;
|
||||
player?: boolean;
|
||||
|
||||
// Behavior
|
||||
|
||||
/**
|
||||
* Optional. If present, this setup method will be called whenever
|
||||
* processing this metadata element in order to transform its data
|
||||
* into a prefered format.
|
||||
*/
|
||||
setup?: (this: Metadata, data: MetadataState) => OptionalPromise<TData>;
|
||||
|
||||
/**
|
||||
* Optional. Whether or not this metadata element should refresh itself
|
||||
* periodically. This can be a specific amount of time, in milliseconds,
|
||||
* after which the element should be refreshed or `true` to refresh
|
||||
* after 1 second.
|
||||
*
|
||||
* Note: Your metadata might not refresh after the exact length, as
|
||||
* the metadata manager will attempt to optimize rendering performance
|
||||
* by using animation frames and batching.
|
||||
*/
|
||||
refresh?: OptionallyCallable<TData, boolean | number>;
|
||||
|
||||
/**
|
||||
* Optional. A click handler for the metadata element.
|
||||
* @param data Your state, as returned from {@link setup}
|
||||
* @param event The {@link MouseEvent} being handled.
|
||||
* @param refresh A method that, when called, manually refreshes
|
||||
* your metadata.
|
||||
*/
|
||||
click?: (this: Metadata, data: TData, event: MouseEvent, refresh: () => void) => void;
|
||||
|
||||
/**
|
||||
* Optional. If this returns true, interactions with your metadata
|
||||
* element will be disabled and the element may appear with a visual
|
||||
* disabled state.
|
||||
*/
|
||||
disabled?: OptionallyCallable<TData, boolean>;
|
||||
|
||||
// Appearance
|
||||
|
||||
/**
|
||||
* The label for this metadata element. If no label is returned, the
|
||||
* metadata element will not be displayed. This should be a
|
||||
* human-readable string.
|
||||
*/
|
||||
label: OptionallyCallable<TData, DomFragment>;
|
||||
|
||||
tooltip?: OptionallyCallable<TData, DomFragment>;
|
||||
|
||||
/**
|
||||
* Optional. What order this metadata element should be displayed in.
|
||||
* This uses CSS's flexbox's order property to adjust the visible
|
||||
* position of each metadata element.
|
||||
*/
|
||||
order?: OptionallyCallable<TData, number>;
|
||||
|
||||
/**
|
||||
* Optional. The color that the metadata element's label should be. If
|
||||
* this is not set, the default text color will be used.
|
||||
*/
|
||||
color?: OptionallyCallable<TData, string | null | undefined>;
|
||||
|
||||
/**
|
||||
* Optional. An icon to be displayed
|
||||
*/
|
||||
icon?: OptionallyCallable<TData, DomFragment>;
|
||||
|
||||
// Button Appearance
|
||||
|
||||
/**
|
||||
* Optional. Whether or not this metadata element should be displayed
|
||||
* with a button style. By default, elements are displayed with a button
|
||||
* style if they have a {@link popup} or {@link click} behavior defined.
|
||||
*
|
||||
* You can override the appearance using this value.
|
||||
*/
|
||||
button?: boolean;
|
||||
|
||||
border?: OptionallyCallable<TData, boolean>;
|
||||
|
||||
inherit?: OptionallyCallable<TData, boolean>;
|
||||
|
||||
// Popup Appearance and Behavior
|
||||
|
||||
/**
|
||||
* Optional. When this is true, an arrow element will not be created
|
||||
* when building a popup for this metadata element.
|
||||
*/
|
||||
no_arrow?: boolean;
|
||||
|
||||
popup?: (this: Metadata, data: TData, tip: TooltipInstance, refresh: () => void, addCloseListener: (callback: () => void) => void) => void;
|
||||
|
||||
|
||||
/**
|
||||
* The source that added this metadata definition. This will be unset
|
||||
* if the metadata was added by FrankerFaceZ, or contain the add-on ID
|
||||
* of an add-on.
|
||||
*/
|
||||
__source?: string;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @noInheritDoc
|
||||
*/
|
||||
export default class Metadata extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
definitions: Record<string, MetadataDefinition<any> | null | undefined>;
|
||||
|
||||
// Dependencies
|
||||
settings: SettingsManager = null as any;
|
||||
i18n: TranslationManager = null as any;
|
||||
tooltips: TooltipProvider = null as any;
|
||||
|
||||
/** @internal */
|
||||
constructor(name?: string, parent?: GenericModule) {
|
||||
super(name, parent);
|
||||
|
||||
this.inject('settings');
|
||||
this.inject('i18n');
|
||||
|
@ -105,7 +298,7 @@ export default class Metadata extends Module {
|
|||
});
|
||||
|
||||
|
||||
this.definitions.viewers = {
|
||||
this.define('viewers', {
|
||||
|
||||
refresh() { return this.settings.get('metadata.viewers') },
|
||||
|
||||
|
@ -131,10 +324,11 @@ export default class Metadata extends Module {
|
|||
},
|
||||
|
||||
color: 'var(--color-text-live)'
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
|
||||
this.definitions.uptime = {
|
||||
this.define('uptime', {
|
||||
inherit: true,
|
||||
no_arrow: true,
|
||||
player: true,
|
||||
|
@ -142,20 +336,15 @@ export default class Metadata extends Module {
|
|||
refresh() { return this.settings.get('metadata.uptime') > 0 },
|
||||
|
||||
setup(data) {
|
||||
const socket = this.resolve('socket');
|
||||
let created = data?.channel?.live_since;
|
||||
if ( ! created ) {
|
||||
const created_at = data?.meta?.createdAt;
|
||||
if ( ! created_at )
|
||||
return {};
|
||||
|
||||
created = created_at;
|
||||
}
|
||||
if ( ! created )
|
||||
return null;
|
||||
|
||||
if ( !(created instanceof Date) )
|
||||
created = new Date(created);
|
||||
|
||||
const now = Date.now() - socket._time_drift;
|
||||
const socket = this.resolve('socket');
|
||||
const now = Date.now() - (socket?._time_drift ?? 0);
|
||||
|
||||
return {
|
||||
created,
|
||||
|
@ -169,16 +358,14 @@ export default class Metadata extends Module {
|
|||
|
||||
label(data) {
|
||||
const setting = this.settings.get('metadata.uptime');
|
||||
if ( ! setting || ! data.created )
|
||||
if ( ! setting || ! data?.created )
|
||||
return null;
|
||||
|
||||
return duration_to_string(data.uptime, false, false, false, setting !== 2);
|
||||
},
|
||||
|
||||
subtitle: () => this.i18n.t('metadata.uptime.subtitle', 'Uptime'),
|
||||
|
||||
tooltip(data) {
|
||||
if ( ! data.created )
|
||||
if ( ! data?.created )
|
||||
return null;
|
||||
|
||||
return [
|
||||
|
@ -197,8 +384,13 @@ export default class Metadata extends Module {
|
|||
},
|
||||
|
||||
async popup(data, tip) {
|
||||
if ( ! data )
|
||||
return;
|
||||
|
||||
const [permission, broadcast_id] = await Promise.all([
|
||||
navigator?.permissions?.query?.({name: 'clipboard-write'}).then(perm => perm?.state).catch(() => null),
|
||||
// We need the as any here because TypeScript's devs don't
|
||||
// live with the rest of us in the real world.
|
||||
navigator?.permissions?.query?.({name: 'clipboard-write' as PermissionName}).then(perm => perm?.state).catch(() => null),
|
||||
data.getBroadcastID()
|
||||
]);
|
||||
if ( ! broadcast_id )
|
||||
|
@ -209,13 +401,13 @@ export default class Metadata extends Module {
|
|||
const url = `https://www.twitch.tv/videos/${broadcast_id}${data.uptime > 0 ? `?t=${durationForURL(data.uptime)}` : ''}`,
|
||||
can_copy = permission === 'granted' || permission === 'prompt';
|
||||
|
||||
const copy = can_copy ? e => {
|
||||
const copy = can_copy ? (event: MouseEvent) => {
|
||||
navigator.clipboard.writeText(url);
|
||||
e.preventDefault();
|
||||
event.preventDefault();
|
||||
return false;
|
||||
} : null;
|
||||
|
||||
tip.element.classList.add('ffz-balloon--lg');
|
||||
tip.element?.classList.add('ffz-balloon--lg');
|
||||
|
||||
return (<div>
|
||||
<div class="tw-pd-b-1 tw-mg-b-1 tw-border-b tw-semibold">
|
||||
|
@ -228,7 +420,7 @@ export default class Metadata extends Module {
|
|||
class="tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 ffz-input tw-full-width"
|
||||
type="text"
|
||||
value={url}
|
||||
onFocus={e => e.target.select()}
|
||||
onFocus={(e: FocusEvent) => (e.target as HTMLInputElement)?.select()}
|
||||
/>
|
||||
{can_copy && <div class="tw-relative ffz-il-tooltip__container tw-mg-l-1">
|
||||
<button
|
||||
|
@ -249,9 +441,9 @@ export default class Metadata extends Module {
|
|||
</div>
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.definitions['clip-download'] = {
|
||||
this.define('clip-download', {
|
||||
button: true,
|
||||
inherit: true,
|
||||
|
||||
|
@ -259,7 +451,8 @@ export default class Metadata extends Module {
|
|||
if ( ! this.settings.get('metadata.clip-download') )
|
||||
return;
|
||||
|
||||
const Player = this.resolve('site.player'),
|
||||
// TODO: Types
|
||||
const Player = this.resolve('site.player') as any,
|
||||
player = Player.current;
|
||||
if ( ! player )
|
||||
return;
|
||||
|
@ -271,13 +464,14 @@ export default class Metadata extends Module {
|
|||
return;
|
||||
|
||||
if ( this.settings.get('metadata.clip-download.force') )
|
||||
return src;
|
||||
return src as string;
|
||||
|
||||
const user = this.resolve('site').getUser?.(),
|
||||
// TODO: Types
|
||||
const user = (this.resolve('site') as any).getUser?.(),
|
||||
is_self = user?.id == data.channel.id;
|
||||
|
||||
if ( is_self || data.getUserSelfImmediate(data.refresh)?.isEditor )
|
||||
return src;
|
||||
return src as string;
|
||||
},
|
||||
|
||||
label(src) {
|
||||
|
@ -297,9 +491,9 @@ export default class Metadata extends Module {
|
|||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.definitions['player-stats'] = {
|
||||
this.define('player-stats', {
|
||||
button: true,
|
||||
inherit: true,
|
||||
modview: true,
|
||||
|
@ -309,9 +503,9 @@ export default class Metadata extends Module {
|
|||
return this.settings.get('metadata.player-stats')
|
||||
},
|
||||
|
||||
setup() {
|
||||
const Player = this.resolve('site.player'),
|
||||
socket = this.resolve('socket'),
|
||||
setup(data) {
|
||||
const Player = this.resolve('site.player') as any,
|
||||
socket = this.resolve('socket') as SocketClient,
|
||||
player = Player.current;
|
||||
|
||||
let stats;
|
||||
|
@ -374,13 +568,13 @@ export default class Metadata extends Module {
|
|||
try {
|
||||
const url = player.core.state.path;
|
||||
if ( url.includes('/api/channel/hls/') ) {
|
||||
const data = JSON.parse(new URL(url).searchParams.get('token'));
|
||||
const data = JSON.parse(new URL(url).searchParams.get('token') as string);
|
||||
tampered = data && data.player_type && data.player_type !== 'site' ? data.player_type : false;
|
||||
}
|
||||
} catch(err) { /* no op */ }
|
||||
|
||||
if ( ! stats || stats.hlsLatencyBroadcaster < -100 )
|
||||
return {stats};
|
||||
return null;
|
||||
|
||||
let drift = 0;
|
||||
|
||||
|
@ -388,6 +582,7 @@ export default class Metadata extends Module {
|
|||
drift = socket._time_drift;
|
||||
|
||||
return {
|
||||
is_player: data.is_player,
|
||||
stats,
|
||||
drift,
|
||||
rate: stats.rate == null ? 1 : stats.rate,
|
||||
|
@ -400,16 +595,14 @@ export default class Metadata extends Module {
|
|||
order: 3,
|
||||
|
||||
icon(data) {
|
||||
if ( data.rate > 1 )
|
||||
if ( data?.rate > 1 )
|
||||
return 'ffz-i-fast-fw';
|
||||
|
||||
return 'ffz-i-gauge'
|
||||
},
|
||||
|
||||
subtitle: () => this.i18n.t('metadata.player-stats.subtitle', 'Latency'),
|
||||
|
||||
label(data) {
|
||||
if ( ! this.settings.get('metadata.player-stats') || ! data.delay )
|
||||
if ( ! this.settings.get('metadata.player-stats') || ! data?.delay )
|
||||
return null;
|
||||
|
||||
if ( data.old )
|
||||
|
@ -424,10 +617,10 @@ export default class Metadata extends Module {
|
|||
},
|
||||
|
||||
click() {
|
||||
const Player = this.resolve('site.player'),
|
||||
fine = this.resolve('site.fine'),
|
||||
const Player = this.resolve('site.player') as any,
|
||||
fine = this.resolve('site.fine') as any,
|
||||
player = Player.Player?.first,
|
||||
inst = fine && player && fine.searchTree(player, n => n.props?.setStatsOverlay, 200),
|
||||
inst = fine && player && fine.searchTree(player, (n: any) => n.props?.setStatsOverlay, 200),
|
||||
cont = inst && fine.getChildNode(player),
|
||||
el = cont && cont.querySelector('[data-a-target="player-overlay-video-stats"]');
|
||||
|
||||
|
@ -449,7 +642,7 @@ export default class Metadata extends Module {
|
|||
|
||||
color(data) {
|
||||
const setting = this.settings.get('metadata.stream-delay-warning');
|
||||
if ( setting === 0 || ! data.delay || data.old )
|
||||
if ( setting === 0 || ! data?.delay || data.old )
|
||||
return;
|
||||
|
||||
if ( data.delay > (setting * 2) )
|
||||
|
@ -460,6 +653,9 @@ export default class Metadata extends Module {
|
|||
},
|
||||
|
||||
tooltip(data) {
|
||||
if ( ! data )
|
||||
return null;
|
||||
|
||||
const tampered = data.tampered ? (<div class="tw-border-t tw-mg-t-05 tw-pd-t-05">
|
||||
{this.i18n.t(
|
||||
'metadata.player-stats.tampered',
|
||||
|
@ -470,21 +666,21 @@ export default class Metadata extends Module {
|
|||
)}
|
||||
</div>) : null;
|
||||
|
||||
const delayed = data.drift > 5000 && (<div class="tw-border-b tw-mg-b-05 tw-pd-b-05">
|
||||
const delayed = data.drift > 5000 ? (<div class="tw-border-b tw-mg-b-05 tw-pd-b-05">
|
||||
{this.i18n.t(
|
||||
'metadata.player-stats.delay-warning',
|
||||
'Your local clock seems to be off by roughly {count,number} seconds, which could make this inaccurate.',
|
||||
Math.round(data.drift / 10) / 100
|
||||
)}
|
||||
</div>);
|
||||
</div>) : null;
|
||||
|
||||
const ff = data.rate > 1 && (<div class="tw-border-b tw-mg-b-05 tw-pd-b-05">
|
||||
const ff = data.rate > 1 ? (<div class="tw-border-b tw-mg-b-05 tw-pd-b-05">
|
||||
{this.i18n.t(
|
||||
'metadata.player-stats.rate-warning',
|
||||
'Playing at {rate,number}x speed to reduce delay.',
|
||||
{rate: data.rate.toFixed(2)}
|
||||
)}
|
||||
</div>);
|
||||
</div>) : null;
|
||||
|
||||
if ( ! data.stats || ! data.delay )
|
||||
return [
|
||||
|
@ -555,41 +751,32 @@ export default class Metadata extends Module {
|
|||
tampered
|
||||
];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
getAddonProxy(addon_id, addon, module) {
|
||||
/** @internal */
|
||||
getAddonProxy(addon_id: string, addon: AddonInfo, module: GenericModule): GenericModule {
|
||||
if ( ! addon_id )
|
||||
return this;
|
||||
|
||||
const overrides = {},
|
||||
const overrides: Record<string, any> = {},
|
||||
is_dev = DEBUG || addon?.dev;
|
||||
|
||||
overrides.define = (key, definition) => {
|
||||
overrides.define = <TData,>(key: string, definition: MetadataDefinition<TData>) => {
|
||||
if ( definition )
|
||||
definition.__source = addon_id;
|
||||
|
||||
return this.define(key, definition);
|
||||
};
|
||||
|
||||
return new Proxy(this, {
|
||||
get(obj, prop) {
|
||||
const thing = overrides[prop];
|
||||
if ( thing )
|
||||
return thing;
|
||||
if ( prop === 'definitions' && is_dev )
|
||||
module.log.warn('[DEV-CHECK] Accessed metadata.definitions directly. Please use define()');
|
||||
|
||||
return Reflect.get(...arguments);
|
||||
}
|
||||
});
|
||||
return buildAddonProxy(module, this, 'metadata', overrides);
|
||||
}
|
||||
|
||||
|
||||
/** @internal */
|
||||
onEnable() {
|
||||
const md = this.tooltips.types.metadata = target => {
|
||||
let el = target;
|
||||
const md: any = (this.tooltips.types as any).metadata = (target: HTMLElement) => {
|
||||
let el: HTMLElement | null = target;
|
||||
if ( el._ffz_stat )
|
||||
el = el._ffz_stat;
|
||||
else if ( ! el.classList.contains('ffz-stat') ) {
|
||||
|
@ -601,31 +788,31 @@ export default class Metadata extends Module {
|
|||
return;
|
||||
|
||||
const key = el.dataset.key,
|
||||
def = this.definitions[key];
|
||||
def = key?.length ? this.definitions[key] : null;
|
||||
|
||||
return maybe_call(def.tooltip, this, el._ffz_data)
|
||||
return maybe_call(def?.tooltip, this, el._ffz_data)
|
||||
};
|
||||
|
||||
md.onShow = (target, tip) => {
|
||||
md.onShow = (target: HTMLElement, tip: TooltipInstance) => {
|
||||
const el = target._ffz_stat || target;
|
||||
el.tip = tip;
|
||||
};
|
||||
|
||||
md.onHide = target => {
|
||||
md.onHide = (target: HTMLElement) => {
|
||||
const el = target._ffz_stat || target;
|
||||
el.tip = null;
|
||||
el.tip_content = null;
|
||||
}
|
||||
|
||||
md.popperConfig = (target, tip, opts) => {
|
||||
md.popperConfig = (target: HTMLElement, tip: TooltipInstance, opts: any) => {
|
||||
opts.placement = 'bottom';
|
||||
opts.modifiers.flip = {behavior: ['bottom','top']};
|
||||
return opts;
|
||||
}
|
||||
|
||||
this.on('addon:fully-unload', addon_id => {
|
||||
const removed = new Set;
|
||||
for(const [key,def] of Object.entries(this.definitions)) {
|
||||
const removed = new Set<string>;
|
||||
for(const [key, def] of Object.entries(this.definitions)) {
|
||||
if ( def?.__source === addon_id ) {
|
||||
removed.add(key);
|
||||
this.definitions[key] = undefined;
|
||||
|
@ -640,51 +827,99 @@ export default class Metadata extends Module {
|
|||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return an array of all metadata definition keys.
|
||||
*/
|
||||
get keys() {
|
||||
return Object.keys(this.definitions);
|
||||
}
|
||||
|
||||
define(key, definition) {
|
||||
/**
|
||||
* Add or update a metadata definition. This method updates the entry
|
||||
* in {@link definitions}, and then it updates every live metadata
|
||||
* display to reflect the updated definition.
|
||||
*
|
||||
* @example Adding a simple metadata definition that displays when the channel went live.
|
||||
* ```typescript
|
||||
* metadata.define('when-live', {
|
||||
* setup(data) {
|
||||
* return data.channel?.live && data.channel.live_since;
|
||||
* },
|
||||
*
|
||||
* label(live_since) {
|
||||
* return live_since;
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @param key A unique key for the metadata.
|
||||
* @param definition Your metadata's definition, or `null` to remove it.
|
||||
*/
|
||||
define<TData>(key: string, definition?: MetadataDefinition<TData> | null) {
|
||||
this.definitions[key] = definition;
|
||||
this.updateMetadata(key);
|
||||
}
|
||||
|
||||
updateMetadata(keys) {
|
||||
const channel = this.resolve('site.channel');
|
||||
/**
|
||||
* Update the rendered metadata elements for a key or keys. If keys
|
||||
* is not provided, this will update every metadata element.
|
||||
*
|
||||
* @param keys Optional. The key or keys that should be updated.
|
||||
*/
|
||||
updateMetadata(keys?: string | string[]) {
|
||||
// TODO: Types
|
||||
|
||||
const channel = this.resolve('site.channel') as any;
|
||||
if ( channel )
|
||||
for(const el of channel.InfoBar.instances)
|
||||
channel.updateMetadata(el, keys);
|
||||
|
||||
const player = this.resolve('site.player');
|
||||
const player = this.resolve('site.player') as any;
|
||||
if ( player )
|
||||
for(const inst of player.Player.instances)
|
||||
player.updateMetadata(inst, keys);
|
||||
}
|
||||
|
||||
async renderLegacy(key, data, container, timers, refresh_fn) {
|
||||
/**
|
||||
* Render a metadata definition into a container. This is used
|
||||
* internally to render metadata.
|
||||
*
|
||||
* @param key The metadata's unique key.
|
||||
* @param data The initial state
|
||||
* @param container The container to render into
|
||||
* @param timers An object to store timers for re-rendering
|
||||
* @param refresh_fn A method to call when the metadata should be re-rendered.
|
||||
*/
|
||||
async renderLegacy(
|
||||
key: string,
|
||||
data: MetadataState,
|
||||
container: HTMLElement,
|
||||
timers: Record<string, ReturnType<typeof setTimeout>>,
|
||||
refresh_fn: (key: string) => void
|
||||
) {
|
||||
if ( timers[key] )
|
||||
clearTimeout(timers[key]);
|
||||
|
||||
let el = container.querySelector(`.ffz-stat[data-key="${key}"]`);
|
||||
let el = container.querySelector<HTMLElement>(`.ffz-stat[data-key="${key}"]`);
|
||||
|
||||
const def = this.definitions[key],
|
||||
destroy = () => {
|
||||
if ( el ) {
|
||||
if ( el.tooltip )
|
||||
/*if ( el.tooltip )
|
||||
el.tooltip.destroy();
|
||||
|
||||
if ( el.popper )
|
||||
el.popper.destroy();
|
||||
el.popper.destroy();*/
|
||||
|
||||
if ( el._ffz_destroy )
|
||||
el._ffz_destroy();
|
||||
|
||||
el._ffz_destroy = el.tooltip = el.popper = null;
|
||||
el._ffz_destroy = /*el.tooltip = el.popper =*/ null;
|
||||
el.remove();
|
||||
}
|
||||
};
|
||||
|
||||
if ( ! def || (data._mt || 'channel') !== (def.type || 'channel') )
|
||||
if ( ! def /* || (data._mt || 'channel') !== (def.type || 'channel') */ )
|
||||
return destroy();
|
||||
|
||||
try {
|
||||
|
@ -709,9 +944,10 @@ export default class Metadata extends Module {
|
|||
|
||||
|
||||
// Grab the element again in case it changed, somehow.
|
||||
el = container.querySelector(`.ffz-stat[data-key="${key}"]`);
|
||||
el = container.querySelector<HTMLElement>(`.ffz-stat[data-key="${key}"]`);
|
||||
|
||||
let stat, old_color, old_icon;
|
||||
let stat: HTMLElement | null,
|
||||
old_color, old_icon;
|
||||
|
||||
const label = maybe_call(def.label, this, data);
|
||||
|
||||
|
@ -728,7 +964,9 @@ export default class Metadata extends Module {
|
|||
if ( def.button !== false && (def.popup || def.click) ) {
|
||||
button = true;
|
||||
|
||||
let btn, popup;
|
||||
let btn: HTMLButtonElement | undefined,
|
||||
popup: HTMLButtonElement | undefined;
|
||||
|
||||
const border = maybe_call(def.border, this, data),
|
||||
inherit = maybe_call(def.inherit, this, data);
|
||||
|
||||
|
@ -741,6 +979,8 @@ export default class Metadata extends Module {
|
|||
el = (<div
|
||||
class={`tw-align-items-center tw-inline-flex tw-relative ffz-il-tooltip__container ffz-stat tw-stat ffz-stat--fix-padding ${border ? 'tw-mg-r-1' : 'tw-mg-r-05 ffz-mg-l--05'}`}
|
||||
data-key={key}
|
||||
// createElement will properly assign this to the
|
||||
// created element. Shut up TypeScript.
|
||||
tip_content={null}
|
||||
>
|
||||
{btn = (<button
|
||||
|
@ -748,10 +988,10 @@ export default class Metadata extends Module {
|
|||
data-tooltip-type="metadata"
|
||||
>
|
||||
<div class="tw-align-items-center tw-flex tw-flex-grow-0 tw-justify-center tw-pd-x-1">
|
||||
{icon}
|
||||
{icon as any}
|
||||
{stat = (<span class="ffz-stat-text" />)}
|
||||
</div>
|
||||
</button>)}
|
||||
</button>) as HTMLButtonElement}
|
||||
{popup = (<button
|
||||
class={`tw-align-items-center tw-align-middle tw-border-bottom-right-radius-medium tw-border-top-right-radius-medium ffz-core-button ffz-core-button--text ${inherit ? 'ffz-c-text-inherit' : 'tw-c-text-base'} tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative ${border ? 'tw-border' : 'tw-font-size-5 tw-regular'}${def.tooltip ? ' ffz-tooltip ffz-tooltip--no-mouse' : ''}`}
|
||||
data-tooltip-type="metadata"
|
||||
|
@ -761,7 +1001,7 @@ export default class Metadata extends Module {
|
|||
<figure class="ffz-i-down-dir" />
|
||||
</span>
|
||||
</div>
|
||||
</button>)}
|
||||
</button>) as HTMLButtonElement}
|
||||
</div>);
|
||||
|
||||
} else
|
||||
|
@ -769,64 +1009,74 @@ export default class Metadata extends Module {
|
|||
class={`ffz-stat tw-align-items-center tw-align-middle tw-border-bottom-left-radius-medium tw-border-top-left-radius-medium tw-border-bottom-right-radius-medium tw-border-top-right-radius-medium ffz-core-button ffz-core-button--text ${inherit ? 'ffz-c-text-inherit' : 'tw-c-text-base'} tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative tw-pd-x-05 ffz-stat--fix-padding ${border ? 'tw-border tw-mg-r-1' : 'tw-font-size-5 tw-regular tw-mg-r-05 ffz-mg-l--05'}${def.tooltip ? ' ffz-tooltip ffz-tooltip--no-mouse' : ''}`}
|
||||
data-tooltip-type="metadata"
|
||||
data-key={key}
|
||||
// createElement will properly assign this to the
|
||||
// created element. Shut up TypeScript.
|
||||
tip_content={null}
|
||||
>
|
||||
<div class="tw-align-items-center tw-flex tw-flex-grow-0 tw-justify-center">
|
||||
{icon}
|
||||
{icon as any}
|
||||
{stat = (<span class="ffz-stat-text" />)}
|
||||
{def.popup && ! def.no_arrow && <span class="tw-mg-l-05">
|
||||
<figure class="ffz-i-down-dir" />
|
||||
</span>}
|
||||
</div>
|
||||
</button>);
|
||||
</button>) as any as HTMLButtonElement;
|
||||
|
||||
if ( def.click )
|
||||
btn.addEventListener('click', e => {
|
||||
if ( el._ffz_fading || btn.disabled || btn.classList.contains('disabled') || el.disabled || el.classList.contains('disabled') )
|
||||
btn.addEventListener('click', (event: MouseEvent) => {
|
||||
if ( ! el || ! btn || btn.disabled || btn.classList.contains('disabled') || (el as any).disabled || el.classList.contains('disabled') )
|
||||
return false;
|
||||
|
||||
def.click.call(this, el._ffz_data, e, () => refresh_fn(key));
|
||||
return def.click?.call?.(this, el._ffz_data, event, () => { refresh_fn(key); });
|
||||
});
|
||||
|
||||
if ( def.popup )
|
||||
popup.addEventListener('click', () => {
|
||||
if ( popup.disabled || popup.classList.contains('disabled') || el.disabled || el.classList.contains('disabled') )
|
||||
if ( ! el || ! popup || popup.disabled || popup.classList.contains('disabled') || (el as any).disabled || el.classList.contains('disabled') )
|
||||
return false;
|
||||
|
||||
if ( el._ffz_popup )
|
||||
if ( el._ffz_popup && el._ffz_destroy )
|
||||
return el._ffz_destroy();
|
||||
|
||||
const listeners = [],
|
||||
add_close_listener = cb => listeners.push(cb);
|
||||
const listeners: (() => void)[] = [],
|
||||
add_close_listener = (cb: () => void) => {
|
||||
listeners.push(cb);
|
||||
};
|
||||
|
||||
const destroy = el._ffz_destroy = () => {
|
||||
for(const cb of listeners) {
|
||||
try {
|
||||
cb();
|
||||
} catch(err) {
|
||||
this.log.capture(err, {
|
||||
tags: {
|
||||
metadata: key
|
||||
}
|
||||
});
|
||||
if ( err instanceof Error )
|
||||
this.log.capture(err, {
|
||||
tags: {
|
||||
metadata: key
|
||||
}
|
||||
});
|
||||
this.log.error('Error when running a callback for pop-up destruction for metadata:', key, err);
|
||||
}
|
||||
}
|
||||
|
||||
if ( el._ffz_outside )
|
||||
el._ffz_outside.destroy();
|
||||
// el is not going to be null
|
||||
// TypeScript is on drugs
|
||||
// whatever though
|
||||
if ( el ) {
|
||||
if ( el._ffz_outside )
|
||||
el._ffz_outside.destroy();
|
||||
|
||||
if ( el._ffz_popup ) {
|
||||
const fp = el._ffz_popup;
|
||||
el._ffz_popup = null;
|
||||
fp.destroy();
|
||||
if ( el._ffz_popup ) {
|
||||
const fp = el._ffz_popup;
|
||||
el._ffz_popup = null;
|
||||
fp.destroy();
|
||||
}
|
||||
|
||||
el._ffz_destroy = el._ffz_outside = null;
|
||||
}
|
||||
|
||||
el._ffz_destroy = el._ffz_outside = null;
|
||||
};
|
||||
|
||||
const parent = document.fullscreenElement || document.body.querySelector('#root>div') || document.body,
|
||||
tt = el._ffz_popup = new Tooltip(parent, el, {
|
||||
const parent = document.fullscreenElement || document.body.querySelector<HTMLElement>('#root>div') || document.body,
|
||||
tt = el._ffz_popup = new Tooltip(parent as HTMLElement, el, {
|
||||
logger: this.log,
|
||||
i18n: this.i18n,
|
||||
manual: true,
|
||||
|
@ -850,10 +1100,11 @@ export default class Metadata extends Module {
|
|||
}
|
||||
}
|
||||
},
|
||||
content: (t, tip) => def.popup.call(this, el._ffz_data, tip, () => refresh_fn(key), add_close_listener),
|
||||
content: (t, tip) => def.popup?.call(this, el?._ffz_data, tip, () => refresh_fn(key), add_close_listener),
|
||||
onShow: (t, tip) =>
|
||||
setTimeout(() => {
|
||||
el._ffz_outside = new ClickOutside(tip.outer, destroy);
|
||||
if ( el && tip.outer )
|
||||
el._ffz_outside = new ClickOutside(tip.outer, destroy);
|
||||
}),
|
||||
onHide: destroy
|
||||
});
|
||||
|
@ -871,23 +1122,23 @@ export default class Metadata extends Module {
|
|||
data-key={key}
|
||||
tip_content={null}
|
||||
>
|
||||
{icon}
|
||||
{icon as any}
|
||||
{stat = <span class={`${icon ? 'tw-mg-l-05 ' : ''}ffz-stat-text tw-stat__value`} />}
|
||||
</div>);
|
||||
|
||||
if ( def.click )
|
||||
el.addEventListener('click', e => {
|
||||
if ( el._ffz_fading || el.disabled || el.classList.contains('disabled') )
|
||||
el.addEventListener('click', (event: MouseEvent) => {
|
||||
if ( ! el || (el as any).disabled || el.classList.contains('disabled') )
|
||||
return false;
|
||||
|
||||
def.click.call(this, el._ffz_data, e, () => refresh_fn(key));
|
||||
def.click?.call?.(this, el._ffz_data, event, () => refresh_fn(key));
|
||||
});
|
||||
}
|
||||
|
||||
el._ffz_order = order;
|
||||
|
||||
if ( order != null )
|
||||
el.style.order = order;
|
||||
el.style.order = `${order}`;
|
||||
|
||||
container.appendChild(el);
|
||||
|
||||
|
@ -900,14 +1151,16 @@ export default class Metadata extends Module {
|
|||
old_color = el.dataset.color || '';
|
||||
|
||||
if ( el._ffz_order !== order )
|
||||
el.style.order = el._ffz_order = order;
|
||||
el.style.order = `${el._ffz_order = order}`;
|
||||
|
||||
if ( el.tip ) {
|
||||
const tooltip = maybe_call(def.tooltip, this, data);
|
||||
if ( el.tip_content !== tooltip ) {
|
||||
el.tip_content = tooltip;
|
||||
el.tip.element.innerHTML = '';
|
||||
setChildren(el.tip.element, tooltip);
|
||||
if ( el.tip?.element ) {
|
||||
el.tip.element.innerHTML = '';
|
||||
setChildren(el.tip.element, tooltip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -928,17 +1181,19 @@ export default class Metadata extends Module {
|
|||
}
|
||||
|
||||
el._ffz_data = data;
|
||||
stat.innerHTML = label;
|
||||
stat.innerHTML = '';
|
||||
setChildren(stat, label);
|
||||
|
||||
if ( def.disabled !== undefined )
|
||||
el.disabled = maybe_call(def.disabled, this, data);
|
||||
(el as any).disabled = maybe_call(def.disabled, this, data);
|
||||
|
||||
} catch(err) {
|
||||
this.log.capture(err, {
|
||||
tags: {
|
||||
metadata: key
|
||||
}
|
||||
});
|
||||
if ( err instanceof Error )
|
||||
this.log.capture(err, {
|
||||
tags: {
|
||||
metadata: key
|
||||
}
|
||||
});
|
||||
this.log.error(`Error rendering metadata for ${key}`, err);
|
||||
return destroy();
|
||||
}
|
|
@ -5,16 +5,71 @@
|
|||
// ============================================================================
|
||||
|
||||
import {createElement, sanitize} from 'utilities/dom';
|
||||
import {has, maybe_call, once} from 'utilities/object';
|
||||
import {has, maybe_call} from 'utilities/object';
|
||||
|
||||
import Tooltip from 'utilities/tooltip';
|
||||
import Module from 'utilities/module';
|
||||
import Tooltip, { TooltipInstance } from 'utilities/tooltip';
|
||||
import Module, { GenericModule, buildAddonProxy } from 'utilities/module';
|
||||
import awaitMD, {getMD} from 'utilities/markdown';
|
||||
import { DEBUG } from 'src/utilities/constants';
|
||||
import type { AddonInfo, DomFragment, OptionallyCallable } from '../utilities/types';
|
||||
import type TranslationManager from '../i18n';
|
||||
|
||||
declare global {
|
||||
interface HTMLElement {
|
||||
_ffz_child: Element | null;
|
||||
}
|
||||
}
|
||||
|
||||
export type TooltipEvents = {
|
||||
/**
|
||||
* When this event is emitted, the tooltip provider will attempt to remove
|
||||
* old, invalid tool-tips.
|
||||
*/
|
||||
':cleanup': [],
|
||||
|
||||
':hover': [target: HTMLElement, tip: TooltipInstance, event: MouseEvent];
|
||||
':leave': [target: HTMLElement, tip: TooltipInstance, event: MouseEvent];
|
||||
};
|
||||
|
||||
type TooltipOptional<TReturn> = OptionallyCallable<[target: HTMLElement, tip: TooltipInstance], TReturn>;
|
||||
|
||||
type TooltipExtra = {
|
||||
__source?: string;
|
||||
|
||||
popperConfig(target: HTMLElement, tip: TooltipInstance, options: any): any;
|
||||
|
||||
delayShow: TooltipOptional<number>;
|
||||
delayHide: TooltipOptional<number>;
|
||||
|
||||
interactive: TooltipOptional<boolean>;
|
||||
hover_events: TooltipOptional<boolean>;
|
||||
|
||||
onShow(target: HTMLElement, tip: TooltipInstance): void;
|
||||
onHide(target: HTMLElement, tip: TooltipInstance): void;
|
||||
|
||||
};
|
||||
|
||||
export type TooltipDefinition = Partial<TooltipExtra> &
|
||||
((target: HTMLElement, tip: TooltipInstance) => DomFragment);
|
||||
|
||||
|
||||
export default class TooltipProvider extends Module<'tooltips', TooltipEvents> {
|
||||
|
||||
// Storage
|
||||
types: Record<string, TooltipDefinition | undefined>;
|
||||
|
||||
// Dependencies
|
||||
i18n: TranslationManager = null as any;
|
||||
|
||||
// State
|
||||
container?: HTMLElement | null;
|
||||
tip_element?: HTMLElement | null;
|
||||
tips?: Tooltip | null;
|
||||
|
||||
|
||||
constructor(name?: string, parent?: GenericModule) {
|
||||
super(name, parent);
|
||||
|
||||
export default class TooltipProvider extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
this.types = {};
|
||||
|
||||
this.inject('i18n');
|
||||
|
@ -69,44 +124,46 @@ export default class TooltipProvider extends Module {
|
|||
return md.render(target.dataset.title);
|
||||
};
|
||||
|
||||
this.types.text = target => sanitize(target.dataset.title);
|
||||
this.types.text = target => sanitize(target.dataset.title ?? '');
|
||||
this.types.html = target => target.dataset.title;
|
||||
|
||||
this.onFSChange = this.onFSChange.bind(this);
|
||||
}
|
||||
|
||||
|
||||
getAddonProxy(addon_id, addon, module) {
|
||||
getAddonProxy(addon_id: string, addon: AddonInfo, module: GenericModule) {
|
||||
if ( ! addon_id )
|
||||
return this;
|
||||
|
||||
const overrides = {},
|
||||
const overrides: Record<string, any> = {},
|
||||
is_dev = DEBUG || addon?.dev;
|
||||
let warnings: Record<string, boolean | string> | undefined;
|
||||
|
||||
overrides.define = (key, handler) => {
|
||||
overrides.define = (key: string, handler: TooltipDefinition) => {
|
||||
if ( handler )
|
||||
handler.__source = addon_id;
|
||||
|
||||
return this.define(key, handler);
|
||||
};
|
||||
|
||||
if ( is_dev )
|
||||
if ( is_dev ) {
|
||||
overrides.cleanup = () => {
|
||||
module.log.warn('[DEV-CHECK] Instead of calling tooltips.cleanup(), you can emit the event "tooltips:cleanup"');
|
||||
return this.cleanup();
|
||||
};
|
||||
|
||||
return new Proxy(this, {
|
||||
get(obj, prop) {
|
||||
const thing = overrides[prop];
|
||||
if ( thing )
|
||||
return thing;
|
||||
if ( prop === 'types' && is_dev )
|
||||
module.log.warn('[DEV-CHECK] Accessed tooltips.types directly. Please use tooltips.define()');
|
||||
warnings = {
|
||||
types: 'Please use tooltips.define()'
|
||||
};
|
||||
}
|
||||
|
||||
return Reflect.get(...arguments);
|
||||
}
|
||||
});
|
||||
return buildAddonProxy(
|
||||
module,
|
||||
this,
|
||||
'tooltips',
|
||||
overrides,
|
||||
warnings
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
@ -140,20 +197,22 @@ export default class TooltipProvider extends Module {
|
|||
}
|
||||
|
||||
|
||||
define(key, handler) {
|
||||
define(key: string, handler: TooltipDefinition) {
|
||||
// TODO: Determine if any tooltips are already open.
|
||||
// If so, we need to close them / maybe re-open them?
|
||||
this.types[key] = handler;
|
||||
}
|
||||
|
||||
|
||||
getRoot() { // eslint-disable-line class-methods-use-this
|
||||
return document.querySelector('.sunlight-root') ||
|
||||
return document.querySelector<HTMLElement>('.sunlight-root') ||
|
||||
//document.querySelector('#root>div') ||
|
||||
document.querySelector('#root') ||
|
||||
document.querySelector('.clips-root') ||
|
||||
document.body;
|
||||
}
|
||||
|
||||
_createInstance(container, klass = 'ffz-tooltip', default_type = 'text', tip_container) {
|
||||
_createInstance(container: HTMLElement, klass = 'ffz-tooltip', default_type = 'text', tip_container?: HTMLElement) {
|
||||
return new Tooltip(container, klass, {
|
||||
html: true,
|
||||
i18n: this.i18n,
|
||||
|
@ -190,34 +249,52 @@ export default class TooltipProvider extends Module {
|
|||
|
||||
|
||||
onFSChange() {
|
||||
const tip_element = document.fullscreenElement || this.container;
|
||||
if ( ! this.container )
|
||||
this.container = this.getRoot();
|
||||
|
||||
let tip_element = this.container;
|
||||
if ( document.fullscreenElement instanceof HTMLElement )
|
||||
tip_element = document.fullscreenElement;
|
||||
|
||||
if ( tip_element !== this.tip_element ) {
|
||||
this.tips.destroy();
|
||||
this.tip_element = tip_element;
|
||||
this.tips = this._createInstance(tip_element);
|
||||
if ( this.tips ) {
|
||||
this.tips.destroy();
|
||||
this.tips = this._createInstance(tip_element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
cleanup() {
|
||||
this.tips.cleanup();
|
||||
if ( this.tips )
|
||||
this.tips.cleanup();
|
||||
}
|
||||
|
||||
|
||||
delegatePopperConfig(default_type, target, tip, pop_opts) {
|
||||
delegatePopperConfig(
|
||||
default_type: string,
|
||||
target: HTMLElement,
|
||||
tip: TooltipInstance,
|
||||
options: any
|
||||
) {
|
||||
const type = target.dataset.tooltipType || default_type,
|
||||
handler = this.types[type];
|
||||
|
||||
if ( target.dataset.tooltipSide )
|
||||
pop_opts.placement = target.dataset.tooltipSide;
|
||||
options.placement = target.dataset.tooltipSide;
|
||||
|
||||
if ( handler && handler.popperConfig )
|
||||
return handler.popperConfig(target, tip, pop_opts);
|
||||
return handler.popperConfig(target, tip, options);
|
||||
|
||||
return pop_opts;
|
||||
return options;
|
||||
}
|
||||
|
||||
delegateOnShow(default_type, target, tip) {
|
||||
delegateOnShow(
|
||||
default_type: string,
|
||||
target: HTMLElement,
|
||||
tip: TooltipInstance
|
||||
) {
|
||||
const type = target.dataset.tooltipType || default_type,
|
||||
handler = this.types[type];
|
||||
|
||||
|
@ -225,7 +302,11 @@ export default class TooltipProvider extends Module {
|
|||
handler.onShow(target, tip);
|
||||
}
|
||||
|
||||
delegateOnHide(default_type, target, tip) {
|
||||
delegateOnHide(
|
||||
default_type: string,
|
||||
target: HTMLElement,
|
||||
tip: TooltipInstance
|
||||
) {
|
||||
const type = target.dataset.tooltipType || default_type,
|
||||
handler = this.types[type];
|
||||
|
||||
|
@ -233,47 +314,67 @@ export default class TooltipProvider extends Module {
|
|||
handler.onHide(target, tip);
|
||||
}
|
||||
|
||||
checkDelayShow(default_type, target, tip) {
|
||||
checkDelayShow(
|
||||
default_type: string,
|
||||
target: HTMLElement,
|
||||
tip: TooltipInstance
|
||||
) {
|
||||
const type = target.dataset.tooltipType || default_type,
|
||||
handler = this.types[type];
|
||||
|
||||
if ( has(handler, 'delayShow') )
|
||||
if ( handler?.delayShow != null )
|
||||
return maybe_call(handler.delayShow, null, target, tip);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
checkDelayHide(default_type, target, tip) {
|
||||
checkDelayHide(
|
||||
default_type: string,
|
||||
target: HTMLElement,
|
||||
tip: TooltipInstance
|
||||
) {
|
||||
const type = target.dataset.tooltipType || default_type,
|
||||
handler = this.types[type];
|
||||
|
||||
if ( has(handler, 'delayHide') )
|
||||
if ( handler?.delayHide != null )
|
||||
return maybe_call(handler.delayHide, null, target, tip);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
checkInteractive(default_type, target, tip) {
|
||||
checkInteractive(
|
||||
default_type: string,
|
||||
target: HTMLElement,
|
||||
tip: TooltipInstance
|
||||
) {
|
||||
const type = target.dataset.tooltipType || default_type,
|
||||
handler = this.types[type];
|
||||
|
||||
if ( has(handler, 'interactive') )
|
||||
if ( handler?.interactive != null )
|
||||
return maybe_call(handler.interactive, null, target, tip);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
checkHoverEvents(default_type, target, tip) {
|
||||
checkHoverEvents(
|
||||
default_type: string,
|
||||
target: HTMLElement,
|
||||
tip: TooltipInstance
|
||||
) {
|
||||
const type = target.dataset.tooltipType || default_type,
|
||||
handler = this.types[type];
|
||||
|
||||
if ( has(handler, 'hover_events') )
|
||||
if ( handler?.hover_events != null )
|
||||
return maybe_call(handler.hover_events, null, target, tip);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
process(default_type, target, tip) {
|
||||
process(
|
||||
default_type: string,
|
||||
target: HTMLElement,
|
||||
tip: TooltipInstance
|
||||
) {
|
||||
const type = target.dataset.tooltipType || default_type || 'text',
|
||||
align = target.dataset.tooltipAlign,
|
||||
handler = this.types[type];
|
|
@ -12,7 +12,7 @@ import {timeout} from 'utilities/object';
|
|||
import SettingsManager from './settings/index';
|
||||
import AddonManager from './addons';
|
||||
import ExperimentManager from './experiments';
|
||||
import {TranslationManager} from './i18n';
|
||||
import TranslationManager from './i18n';
|
||||
import StagingSelector from './staging';
|
||||
import PubSubClient from './pubsub';
|
||||
import LoadTracker from './load_tracker';
|
||||
|
|
|
@ -216,7 +216,7 @@ export default class RavenLogger extends Module {
|
|||
return false;
|
||||
|
||||
if ( this.settings && this.settings.get('reports.error.include-user') ) {
|
||||
const user = this.resolve('site')?.getUser();
|
||||
const user = this.resolve('site')?.getUser?.();
|
||||
if ( user )
|
||||
data.user = {id: user.id, username: user.login}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
'use strict';
|
||||
|
||||
import { AdvancedSettingsProvider } from "./providers";
|
||||
import type { SettingsClearable } from "./types";
|
||||
|
||||
// ============================================================================
|
||||
// Clearable Settings
|
||||
// ============================================================================
|
||||
|
||||
export const Experiments = {
|
||||
export const Experiments: SettingsClearable = {
|
||||
label: 'Experiment Overrides',
|
||||
keys: [
|
||||
'exp-lock',
|
||||
|
@ -12,7 +15,7 @@ export const Experiments = {
|
|||
]
|
||||
};
|
||||
|
||||
export const HiddenEmotes = {
|
||||
export const HiddenEmotes: SettingsClearable = {
|
||||
label: 'Hidden Emotes',
|
||||
keys(provider) {
|
||||
const keys = ['emote-menu.hidden-sets'];
|
||||
|
@ -24,7 +27,7 @@ export const HiddenEmotes = {
|
|||
}
|
||||
};
|
||||
|
||||
export const FavoriteEmotes = {
|
||||
export const FavoriteEmotes: SettingsClearable = {
|
||||
label: 'Favorited Emotes',
|
||||
keys(provider) {
|
||||
const keys = [];
|
||||
|
@ -36,7 +39,7 @@ export const FavoriteEmotes = {
|
|||
}
|
||||
};
|
||||
|
||||
export const Overrides = {
|
||||
export const Overrides: SettingsClearable = {
|
||||
label: 'Name and Color Overrides',
|
||||
keys: [
|
||||
'overrides.colors',
|
||||
|
@ -44,7 +47,7 @@ export const Overrides = {
|
|||
]
|
||||
};
|
||||
|
||||
export const Profiles = {
|
||||
export const Profiles: SettingsClearable = {
|
||||
label: 'Profiles',
|
||||
clear(provider, settings) {
|
||||
const keys = ['profiles'];
|
||||
|
@ -59,11 +62,11 @@ export const Profiles = {
|
|||
}
|
||||
};
|
||||
|
||||
export const Everything = {
|
||||
export const Everything: SettingsClearable = {
|
||||
label: 'Absolutely Everything',
|
||||
async clear(provider, settings) {
|
||||
provider.clear();
|
||||
if ( provider.supportsBlobs )
|
||||
if ( provider.supportsBlobs && provider instanceof AdvancedSettingsProvider )
|
||||
await provider.clearBlobs();
|
||||
|
||||
settings.loadProfiles();
|
|
@ -7,7 +7,7 @@
|
|||
import {EventEmitter} from 'utilities/events';
|
||||
import {has, get as getter, array_equals, set_equals, map_equals, deep_equals} from 'utilities/object';
|
||||
|
||||
import * as DEFINITIONS from './types';
|
||||
import * as DEFINITIONS from './typehandlers';
|
||||
|
||||
/**
|
||||
* Perform a basic check of a setting's requirements to see if they changed.
|
||||
|
|
|
@ -4,24 +4,29 @@
|
|||
// Profile Filters for Settings
|
||||
// ============================================================================
|
||||
|
||||
import {glob_to_regex, escape_regex, matchScreen} from 'utilities/object';
|
||||
import {createTester} from 'utilities/filtering';
|
||||
import {glob_to_regex, escape_regex, matchScreen, ScreenOptions} from 'utilities/object';
|
||||
import {FilterData, FilterType, createTester} from 'utilities/filtering';
|
||||
import { DEBUG } from 'utilities/constants';
|
||||
import type { ContextData } from './types';
|
||||
import type { ScreenDetails } from 'root/types/getScreenDetails';
|
||||
import SettingsManager from '.';
|
||||
|
||||
let safety = null;
|
||||
let safety: ((input: string | RegExp) => boolean) | null = null;
|
||||
|
||||
function loadSafety(cb) {
|
||||
function loadSafety(callback?: () => void) {
|
||||
import(/* webpackChunkName: 'regex' */ 'safe-regex').then(thing => {
|
||||
safety = thing.default;
|
||||
if ( cb )
|
||||
cb();
|
||||
if ( callback )
|
||||
callback();
|
||||
})
|
||||
}
|
||||
|
||||
const NeverMatch = () => false;
|
||||
|
||||
|
||||
// Logical Components
|
||||
|
||||
export const Invert = {
|
||||
export const Invert: FilterType<FilterData[], ContextData> = {
|
||||
createTest(config, rule_types, rebuild) {
|
||||
return createTester(config, rule_types, true, false, rebuild)
|
||||
},
|
||||
|
@ -37,7 +42,7 @@ export const Invert = {
|
|||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue')
|
||||
};
|
||||
|
||||
export const And = {
|
||||
export const And: FilterType<FilterData[], ContextData> = {
|
||||
createTest(config, rule_types, rebuild) {
|
||||
return createTester(config, rule_types, false, false, rebuild);
|
||||
},
|
||||
|
@ -52,7 +57,7 @@ export const And = {
|
|||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue')
|
||||
};
|
||||
|
||||
export const Or = {
|
||||
export const Or: FilterType<FilterData[], ContextData> = {
|
||||
createTest(config, rule_types, rebuild) {
|
||||
return createTester(config, rule_types, false, true, rebuild);
|
||||
},
|
||||
|
@ -67,11 +72,17 @@ export const Or = {
|
|||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue')
|
||||
};
|
||||
|
||||
export const If = {
|
||||
type IfData = [
|
||||
condition: FilterData[],
|
||||
if_true: FilterData[],
|
||||
if_else: FilterData[]
|
||||
];
|
||||
|
||||
export const If: FilterType<IfData, ContextData> = {
|
||||
createTest(config, rule_types, rebuild) {
|
||||
const cond = createTester(config[0], rule_types, false, false, rebuild),
|
||||
if_true = createTester(config[1], rule_types, false, false, rebuild),
|
||||
if_false = createTester(config[2], rule_types, false, false, rebuild);
|
||||
const cond = createTester(config[0], rule_types as any, false, false, rebuild),
|
||||
if_true = createTester(config[1], rule_types as any, false, false, rebuild),
|
||||
if_false = createTester(config[2], rule_types as any, false, false, rebuild);
|
||||
|
||||
return ctx => cond(ctx) ? if_true(ctx) : if_false(ctx)
|
||||
},
|
||||
|
@ -85,11 +96,11 @@ export const If = {
|
|||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/if.vue')
|
||||
};
|
||||
|
||||
export const Constant = {
|
||||
export const Constant: FilterType<boolean, ContextData> = {
|
||||
createTest(config) {
|
||||
if ( config )
|
||||
return () => true;
|
||||
return () => false;
|
||||
return NeverMatch;
|
||||
},
|
||||
|
||||
title: 'True or False',
|
||||
|
@ -103,7 +114,7 @@ export const Constant = {
|
|||
|
||||
// Context Stuff
|
||||
|
||||
function parseTime(time) {
|
||||
function parseTime(time: string) {
|
||||
if ( typeof time !== 'string' || ! time.length )
|
||||
return null;
|
||||
|
||||
|
@ -123,7 +134,12 @@ function parseTime(time) {
|
|||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
export const Time = {
|
||||
type TimeFilter = FilterType<[start: string, end: string], ContextData> & {
|
||||
_captured: Set<number>;
|
||||
captured: () => number[];
|
||||
};
|
||||
|
||||
export const Time: TimeFilter = {
|
||||
_captured: new Set,
|
||||
|
||||
createTest(config) {
|
||||
|
@ -131,7 +147,7 @@ export const Time = {
|
|||
end = parseTime(config[1]);
|
||||
|
||||
if ( start == null || end == null )
|
||||
return () => false;
|
||||
return NeverMatch;
|
||||
|
||||
if ( start <= end )
|
||||
return () => {
|
||||
|
@ -170,12 +186,12 @@ export const Time = {
|
|||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/time.vue')
|
||||
}
|
||||
|
||||
export const TheaterMode = {
|
||||
export const TheaterMode: FilterType<boolean, ContextData> = {
|
||||
createTest(config) {
|
||||
return ctx => {
|
||||
if ( ctx.fullscreen )
|
||||
return config === false;
|
||||
return ctx.ui && ctx.ui.theatreModeEnabled === config;
|
||||
return ctx.ui?.theatreModeEnabled === config;
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -187,7 +203,7 @@ export const TheaterMode = {
|
|||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
||||
};
|
||||
|
||||
export const Fullscreen = {
|
||||
export const Fullscreen: FilterType<boolean, ContextData> = {
|
||||
createTest(config) {
|
||||
return ctx => ctx.fullscreen === config;
|
||||
},
|
||||
|
@ -200,7 +216,7 @@ export const Fullscreen = {
|
|||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
||||
};
|
||||
|
||||
export const Moderator = {
|
||||
export const Moderator: FilterType<boolean, ContextData> = {
|
||||
createTest(config) {
|
||||
return ctx => ctx.moderator === config;
|
||||
},
|
||||
|
@ -212,7 +228,7 @@ export const Moderator = {
|
|||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
||||
};
|
||||
|
||||
export const Debug = {
|
||||
export const Debug: FilterType<boolean, ContextData> = {
|
||||
createTest(config) {
|
||||
return () => DEBUG === config;
|
||||
},
|
||||
|
@ -224,7 +240,7 @@ export const Debug = {
|
|||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
||||
};
|
||||
|
||||
export const AddonDebug = {
|
||||
export const AddonDebug: FilterType<boolean, ContextData> = {
|
||||
createTest(config) {
|
||||
return ctx => ctx.addonDev == config
|
||||
},
|
||||
|
@ -236,9 +252,9 @@ export const AddonDebug = {
|
|||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
||||
}
|
||||
|
||||
export const SquadMode = {
|
||||
export const SquadMode: FilterType<boolean, ContextData> = {
|
||||
createTest(config) {
|
||||
return ctx => ctx.ui && ctx.ui.squadModeEnabled === config;
|
||||
return ctx => ctx.ui?.squadModeEnabled === config;
|
||||
},
|
||||
|
||||
title: 'Squad Mode',
|
||||
|
@ -248,10 +264,10 @@ export const SquadMode = {
|
|||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
||||
};
|
||||
|
||||
export const NativeDarkTheme = {
|
||||
export const NativeDarkTheme: FilterType<boolean, ContextData> = {
|
||||
createTest(config) {
|
||||
const val = config ? 1 : 0;
|
||||
return ctx => ctx.ui && ctx.ui.theme === val;
|
||||
return ctx => ctx.ui?.theme === val;
|
||||
},
|
||||
|
||||
title: 'Dark Theme',
|
||||
|
@ -261,33 +277,41 @@ export const NativeDarkTheme = {
|
|||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
||||
};
|
||||
|
||||
export const Page = {
|
||||
createTest(config = {}) {
|
||||
// TODO: Add typing.
|
||||
type PageData = {
|
||||
route: string;
|
||||
values: Record<string, string>;
|
||||
};
|
||||
|
||||
export const Page: FilterType<PageData, ContextData> = {
|
||||
createTest(config) {
|
||||
if ( ! config )
|
||||
return NeverMatch;
|
||||
|
||||
const name = config.route,
|
||||
parts = [];
|
||||
parts: [index: number, value: string][] = [];
|
||||
|
||||
if ( Object.keys(config.values).length ) {
|
||||
const ffz = window.FrankerFaceZ?.get(),
|
||||
router = ffz && ffz.resolve('site.router');
|
||||
router = ffz && ffz.resolve('site.router') as any;
|
||||
|
||||
if ( router ) {
|
||||
const route = router.getRoute(name);
|
||||
if ( ! route || ! route.parts )
|
||||
return () => false;
|
||||
if ( ! router )
|
||||
return NeverMatch;
|
||||
|
||||
let i = 1;
|
||||
for(const part of route.parts) {
|
||||
if ( typeof part === 'object' ) {
|
||||
const val = config.values[part.name];
|
||||
if ( val && val.length )
|
||||
parts.push([i, val.toLowerCase()]);
|
||||
const route = router.getRoute(name);
|
||||
if ( ! route || ! route.parts )
|
||||
return NeverMatch;
|
||||
|
||||
i++;
|
||||
}
|
||||
let i = 1;
|
||||
for(const part of route.parts) {
|
||||
if ( typeof part === 'object' ) {
|
||||
const val = config.values[part.name];
|
||||
if ( val && val.length )
|
||||
parts.push([i, val.toLowerCase()]);
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
} else
|
||||
return () => false;
|
||||
}
|
||||
}
|
||||
|
||||
return ctx => {
|
||||
|
@ -318,12 +342,28 @@ export const Page = {
|
|||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/page.vue')
|
||||
};
|
||||
|
||||
export const Channel = {
|
||||
createTest(config = {}) {
|
||||
const login = config.login,
|
||||
id = config.id;
|
||||
type ChannelData = {
|
||||
login: string | null;
|
||||
id: string | null;
|
||||
};
|
||||
|
||||
return ctx => ctx.channelID === id || (ctx.channelID == null && ctx.channelLogin === login);
|
||||
export const Channel: FilterType<ChannelData, ContextData> = {
|
||||
createTest(config) {
|
||||
const login = config?.login,
|
||||
id = config?.id;
|
||||
|
||||
if ( ! id && ! login )
|
||||
return NeverMatch;
|
||||
|
||||
else if ( ! id )
|
||||
return ctx => ctx.channel === login;
|
||||
|
||||
else if ( ! login )
|
||||
return ctx => ctx.channelID === id;
|
||||
|
||||
return ctx =>
|
||||
ctx.channelID === id ||
|
||||
(ctx.channelID == null && ctx.channel === login);
|
||||
},
|
||||
|
||||
title: 'Current Channel',
|
||||
|
@ -336,15 +376,28 @@ export const Channel = {
|
|||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/channel.vue')
|
||||
};
|
||||
|
||||
export const Category = {
|
||||
createTest(config = {}) {
|
||||
const name = config.name,
|
||||
id = config.id;
|
||||
type CategoryData = {
|
||||
name: string | null;
|
||||
id: string | null;
|
||||
}
|
||||
|
||||
if ( ! id || ! name )
|
||||
return () => false;
|
||||
export const Category: FilterType<CategoryData, ContextData> = {
|
||||
createTest(config) {
|
||||
const name = config?.name,
|
||||
id = config?.id;
|
||||
|
||||
return ctx => ctx.categoryID === id || (ctx.categoryID == null && ctx.category === name);
|
||||
if ( ! id && ! name )
|
||||
return NeverMatch;
|
||||
|
||||
else if ( ! id )
|
||||
return ctx => ctx.category === name;
|
||||
|
||||
else if ( ! name )
|
||||
return ctx => ctx.categoryID === id;
|
||||
|
||||
return ctx =>
|
||||
ctx.categoryID === id ||
|
||||
(ctx.categoryID == null && ctx.category === name);
|
||||
},
|
||||
|
||||
title: 'Current Category',
|
||||
|
@ -358,14 +411,20 @@ export const Category = {
|
|||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/category.vue')
|
||||
}
|
||||
|
||||
export const Title = {
|
||||
createTest(config = {}, _, reload) {
|
||||
const mode = config.mode;
|
||||
let title = config.title,
|
||||
type TitleData = {
|
||||
title: string;
|
||||
mode: 'text' | 'glob' | 'raw' | 'regex';
|
||||
sensitive: boolean;
|
||||
};
|
||||
|
||||
export const Title: FilterType<TitleData, ContextData> = {
|
||||
createTest(config, _, reload) {
|
||||
const mode = config?.mode;
|
||||
let title = config?.title,
|
||||
need_safety = true;
|
||||
|
||||
if ( ! title || ! mode )
|
||||
return () => false;
|
||||
return NeverMatch;
|
||||
|
||||
if ( mode === 'text' ) {
|
||||
title = escape_regex(title);
|
||||
|
@ -373,26 +432,26 @@ export const Title = {
|
|||
} else if ( mode === 'glob' )
|
||||
title = glob_to_regex(title);
|
||||
else if ( mode !== 'raw' )
|
||||
return () => false;
|
||||
return NeverMatch;
|
||||
|
||||
if ( need_safety ) {
|
||||
if ( ! safety )
|
||||
loadSafety(reload);
|
||||
|
||||
if ( ! safety || ! safety(title) )
|
||||
return () => false;
|
||||
return NeverMatch;
|
||||
}
|
||||
|
||||
let regex;
|
||||
let regex: RegExp;
|
||||
try {
|
||||
regex = new RegExp(title, `g${config.sensitive ? '' : 'i'}`);
|
||||
} catch(err) {
|
||||
return () => false;
|
||||
return NeverMatch;
|
||||
}
|
||||
|
||||
return ctx => {
|
||||
regex.lastIndex = 0;
|
||||
return ctx.title && regex.test(ctx.title);
|
||||
return ctx.title ? regex.test(ctx.title): false;
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -410,7 +469,15 @@ export const Title = {
|
|||
|
||||
// Monitor Stuff
|
||||
|
||||
export let Monitor = null;
|
||||
type MonitorType = FilterType<ScreenOptions, ContextData> & {
|
||||
_used: boolean;
|
||||
details?: ScreenDetails | null | false;
|
||||
|
||||
used: () => boolean;
|
||||
|
||||
};
|
||||
|
||||
export let Monitor: MonitorType = null as any;
|
||||
|
||||
if ( window.getScreenDetails ) {
|
||||
|
||||
|
@ -424,31 +491,31 @@ if ( window.getScreenDetails ) {
|
|||
return out;
|
||||
},
|
||||
|
||||
createTest(config = {}, _, reload) {
|
||||
if ( ! config.label )
|
||||
return () => false;
|
||||
createTest(config, _, reload) {
|
||||
if ( ! config?.label )
|
||||
return NeverMatch;
|
||||
|
||||
Monitor._used = true;
|
||||
if ( Monitor.details === undefined ) {
|
||||
const FFZ = window.FrankerFaceZ ?? window.FFZBridge;
|
||||
if ( FFZ )
|
||||
FFZ.get().resolve('settings').createMonitorUpdate().then(() => {
|
||||
if ( reload && Monitor.details === undefined ) {
|
||||
const FFZ = window.FrankerFaceZ ?? ((window as any).FFZBridge as any),
|
||||
ffz = FFZ?.get(),
|
||||
settings = ffz?.resolve('settings');
|
||||
if ( settings )
|
||||
settings.createMonitorUpdate().then(() => {
|
||||
reload();
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
Monitor._used = true;
|
||||
const details = Monitor.details,
|
||||
screen = details?.currentScreen;
|
||||
|
||||
if ( ! screen )
|
||||
const details = Monitor.details;
|
||||
if ( ! details )
|
||||
return false;
|
||||
|
||||
const sorted = details.screens, // sortScreens(Array.from(details.screens)),
|
||||
const sorted = details.screens,
|
||||
matched = matchScreen(sorted, config);
|
||||
|
||||
return matched === screen;
|
||||
return matched === details.currentScreen;
|
||||
};
|
||||
},
|
||||
|
|
@ -4,22 +4,26 @@
|
|||
// Settings System
|
||||
// ============================================================================
|
||||
|
||||
import Module from 'utilities/module';
|
||||
import Module, { GenericModule } from 'utilities/module';
|
||||
import {deep_equals, has, debounce, deep_copy} from 'utilities/object';
|
||||
import {parse as new_parse} from 'utilities/path-parser';
|
||||
import {parse as parse_path} from 'utilities/path-parser';
|
||||
|
||||
import SettingsProfile from './profile';
|
||||
import SettingsContext from './context';
|
||||
import MigrationManager from './migration';
|
||||
//import MigrationManager from './migration';
|
||||
|
||||
import * as PROCESSORS from './processors';
|
||||
import * as VALIDATORS from './validators';
|
||||
import * as PROVIDERS from './providers';
|
||||
import * as FILTERS from './filters';
|
||||
import * as CLEARABLES from './clearables';
|
||||
import type { SettingsProfileMetadata, ContextData, ExportedFullDump, SettingsClearable, SettingsDefinition, SettingsProcessor, SettingsUiDefinition, SettingsValidator } from './types';
|
||||
import type { FilterType } from '../utilities/filtering';
|
||||
import { AdvancedSettingsProvider, IndexedDBProvider, LocalStorageProvider, Providers, type SettingsProvider } from './providers';
|
||||
|
||||
export {parse as parse_path} from 'utilities/path-parser';
|
||||
|
||||
|
||||
function postMessage(target, msg) {
|
||||
function postMessage(target: Window, msg) {
|
||||
try {
|
||||
target.postMessage(msg, '*');
|
||||
return true;
|
||||
|
@ -31,6 +35,28 @@ function postMessage(target, msg) {
|
|||
export const NO_SYNC_KEYS = ['session'];
|
||||
|
||||
|
||||
// TODO: Check settings keys for better typing on events.
|
||||
|
||||
export type SettingsEvents = {
|
||||
[key: `:changed:${string}`]: [value: any, old_value: any];
|
||||
[key: `:uses_changed:${string}`]: [uses: number[], old_uses: number[]];
|
||||
|
||||
':added-definition': [key: string, definition: SettingsDefinition<any>];
|
||||
':removed-definition': [key: string, definition: SettingsDefinition<any>];
|
||||
|
||||
':quota-exceeded': [];
|
||||
':change-provider': [];
|
||||
|
||||
':ls-update': [key: string, value: any];
|
||||
|
||||
':profile-created': [profile: SettingsProfile];
|
||||
':profile-changed': [profile: SettingsProfile];
|
||||
':profile-deleted': [profile: SettingsProfile];
|
||||
':profile-toggled': [profile: SettingsProfile, enabled: boolean];
|
||||
':profiles-reordered': [];
|
||||
};
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// SettingsManager
|
||||
// ============================================================================
|
||||
|
@ -39,22 +65,62 @@ export const NO_SYNC_KEYS = ['session'];
|
|||
* The SettingsManager module creates all the necessary class instances
|
||||
* required for the settings system to operate, facilitates communication
|
||||
* and discovery, and emits events for other modules to react to.
|
||||
* @extends Module
|
||||
*/
|
||||
export default class SettingsManager extends Module {
|
||||
export default class SettingsManager extends Module<'settings', SettingsEvents> {
|
||||
|
||||
_start_time: number;
|
||||
|
||||
// localStorage Hooks
|
||||
private __ls_hooked: boolean;
|
||||
private __ls_scheduled: Set<string>;
|
||||
private __ls_cache: Map<string, unknown>;
|
||||
private __ls_timer?: ReturnType<typeof setTimeout> | null;
|
||||
|
||||
|
||||
// Storage of Things
|
||||
clearables: Record<string, SettingsClearable>;
|
||||
filters: Record<string, FilterType<any, ContextData>>;
|
||||
processors: Record<string, SettingsProcessor<any>>;
|
||||
providers: Record<string, typeof SettingsProvider>;
|
||||
validators: Record<string, SettingsValidator<any>>;
|
||||
|
||||
// Storage of Settings
|
||||
ui_structures: Map<string, SettingsUiDefinition<any>>;
|
||||
definitions: Map<string, SettingsDefinition<any> | string[]>;
|
||||
|
||||
// Storage of State
|
||||
provider: SettingsProvider | null = null;
|
||||
main_context: SettingsContext;
|
||||
|
||||
private _update_timer?: ReturnType<typeof setTimeout> | null;
|
||||
private _time_timer?: ReturnType<typeof setTimeout> | null;
|
||||
|
||||
private _active_provider: string = 'local';
|
||||
private _idb: IndexedDBProvider | null = null;
|
||||
|
||||
private _provider_waiter?: Promise<SettingsProvider> | null;
|
||||
private _provider_resolve?: ((input: SettingsProvider) => void) | null;
|
||||
|
||||
private __contexts: SettingsContext[];
|
||||
private __profiles: SettingsProfile[];
|
||||
private __profile_ids: Record<number, SettingsProfile | null>;
|
||||
|
||||
/**
|
||||
* Create a SettingsManager module.
|
||||
* Whether or not profiles have been disabled for this session
|
||||
*/
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
disable_profiles: boolean = false;
|
||||
|
||||
updateSoon: () => void;
|
||||
|
||||
/** @internal */
|
||||
constructor(name?: string, parent?: GenericModule) {
|
||||
super(name, parent);
|
||||
|
||||
this.providers = {};
|
||||
for(const key in PROVIDERS)
|
||||
if ( has(PROVIDERS, key) ) {
|
||||
const provider = PROVIDERS[key];
|
||||
if ( provider.key && provider.supported(this) )
|
||||
this.providers[provider.key] = provider;
|
||||
}
|
||||
for(const [key, provider] of Object.entries(Providers)) {
|
||||
if ( provider.supported() )
|
||||
this.providers[key] = provider;
|
||||
}
|
||||
|
||||
// This cannot be modified at a future time, as providers NEED
|
||||
// to be ready very early in FFZ intitialization. Seal it.
|
||||
|
@ -64,7 +130,7 @@ export default class SettingsManager extends Module {
|
|||
|
||||
// Do we want to not enable any profiles?
|
||||
try {
|
||||
const params = new URL(window.location).searchParams;
|
||||
const params = new URL(window.location as any).searchParams;
|
||||
if ( params ) {
|
||||
if ( params.has('ffz-no-settings') )
|
||||
this.disable_profiles = true;
|
||||
|
@ -115,24 +181,22 @@ export default class SettingsManager extends Module {
|
|||
|
||||
|
||||
// Create our provider as early as possible.
|
||||
this._provider_waiters = [];
|
||||
|
||||
this._createProvider().then(provider => {
|
||||
this.provider = provider;
|
||||
this.log.info(`Using Provider: ${provider.constructor.name}`);
|
||||
provider.on('changed', this._onProviderChange, this);
|
||||
provider.on('quota-exceeded', err => {
|
||||
this.emit(':quota-exceeded', err);
|
||||
provider.on('quota-exceeded', (err) => {
|
||||
this.emit(':quota-exceeded');
|
||||
});
|
||||
provider.on('change-provider', () => {
|
||||
this.emit(':change-provider');
|
||||
});
|
||||
|
||||
for(const waiter of this._provider_waiters)
|
||||
waiter(provider);
|
||||
if ( this._provider_resolve )
|
||||
this._provider_resolve(provider);
|
||||
});
|
||||
|
||||
this.migrations = new MigrationManager(this);
|
||||
//this.migrations = new MigrationManager(this);
|
||||
|
||||
// Also create the main context as early as possible.
|
||||
this.main_context = new SettingsContext(this);
|
||||
|
@ -184,7 +248,7 @@ export default class SettingsManager extends Module {
|
|||
}
|
||||
|
||||
|
||||
addFilter(key, data) {
|
||||
addFilter<T>(key: string, data: FilterType<T, ContextData>) {
|
||||
if ( this.filters[key] )
|
||||
return this.log.warn('Tried to add already existing filter', key);
|
||||
|
||||
|
@ -209,8 +273,14 @@ export default class SettingsManager extends Module {
|
|||
if ( this.provider )
|
||||
return Promise.resolve(this.provider);
|
||||
|
||||
return new Promise(s => {
|
||||
this._provider_waiters.push(s);
|
||||
if ( this._provider_waiter )
|
||||
return this._provider_waiter;
|
||||
|
||||
return this._provider_waiter = new Promise<SettingsProvider>((resolve, reject) => {
|
||||
this._provider_resolve = resolve;
|
||||
}).finally(() => {
|
||||
this._provider_waiter = null;
|
||||
this._provider_resolve = null;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -221,6 +291,9 @@ export default class SettingsManager extends Module {
|
|||
async onEnable() {
|
||||
// Before we do anything else, make sure the provider is ready.
|
||||
await this.awaitProvider();
|
||||
if ( ! this.provider )
|
||||
throw new Error('did not get provider');
|
||||
|
||||
await this.provider.awaitReady();
|
||||
|
||||
// When the router updates we additional routes, make sure to
|
||||
|
@ -253,11 +326,14 @@ export default class SettingsManager extends Module {
|
|||
|
||||
Monitor.details = null;
|
||||
try {
|
||||
Monitor.details = await window.getScreenDetails();
|
||||
Monitor.details.addEventListener('currentscreenchange', () => {
|
||||
for(const context of this.__contexts)
|
||||
context.selectProfiles();
|
||||
});
|
||||
if ( window.getScreenDetails ) {
|
||||
Monitor.details = await window.getScreenDetails();
|
||||
Monitor.details.addEventListener('currentscreenchange', () => {
|
||||
for(const context of this.__contexts)
|
||||
context.selectProfiles();
|
||||
});
|
||||
} else
|
||||
Monitor.details = false;
|
||||
|
||||
} catch(err) {
|
||||
this.log.error('Unable to get monitor details', err);
|
||||
|
@ -305,7 +381,7 @@ export default class SettingsManager extends Module {
|
|||
// LocalStorage Management
|
||||
// ========================================================================
|
||||
|
||||
_updateLSKey(key) {
|
||||
private _updateLSKey(key: string) {
|
||||
if ( this.__ls_cache.has(key) || this.__ls_cache.has(`raw.${key}`) ) {
|
||||
this.__ls_scheduled.add(key);
|
||||
if ( ! this.__ls_timer )
|
||||
|
@ -313,7 +389,7 @@ export default class SettingsManager extends Module {
|
|||
}
|
||||
}
|
||||
|
||||
_hookLS() {
|
||||
private _hookLS() {
|
||||
if ( this.__ls_hooked )
|
||||
return;
|
||||
|
||||
|
@ -336,13 +412,13 @@ export default class SettingsManager extends Module {
|
|||
window.addEventListener('storage', this._handleLSEvent);
|
||||
}
|
||||
|
||||
_handleLSEvent(event) {
|
||||
if ( event.storageArea === localStorage )
|
||||
private _handleLSEvent(event: StorageEvent) {
|
||||
if ( event.key && event.storageArea === localStorage )
|
||||
this._updateLSKey(event.key);
|
||||
}
|
||||
|
||||
_updateLS() {
|
||||
clearTimeout(this.__ls_timer);
|
||||
private _updateLS() {
|
||||
clearTimeout(this.__ls_timer as ReturnType<typeof setTimeout>);
|
||||
this.__ls_timer = null;
|
||||
const keys = this.__ls_scheduled;
|
||||
this.__ls_scheduled = new Set;
|
||||
|
@ -377,9 +453,9 @@ export default class SettingsManager extends Module {
|
|||
}
|
||||
}
|
||||
|
||||
getLS(key) {
|
||||
getLS<T>(key: string): T | null {
|
||||
if ( this.__ls_cache.has(key) )
|
||||
return this.__ls_cache.get(key);
|
||||
return this.__ls_cache.get(key) as T;
|
||||
|
||||
if ( ! this.__ls_hooked )
|
||||
this._hookLS();
|
||||
|
@ -392,7 +468,7 @@ export default class SettingsManager extends Module {
|
|||
value = raw;
|
||||
else
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
value = raw ? JSON.parse(raw) : null;
|
||||
} catch(err) {
|
||||
this.log.warn(`Unable to parse localStorage value as JSON for "${key}"`, err);
|
||||
}
|
||||
|
@ -420,12 +496,15 @@ export default class SettingsManager extends Module {
|
|||
}
|
||||
|
||||
|
||||
async _needsZipBackup() {
|
||||
private async _needsZipBackup() {
|
||||
// Before we do anything else, make sure the provider is ready.
|
||||
await this.awaitProvider();
|
||||
if ( ! this.provider )
|
||||
return false;
|
||||
|
||||
await this.provider.awaitReady();
|
||||
|
||||
if ( ! this.provider.supportsBlobs )
|
||||
if ( !(this.provider instanceof AdvancedSettingsProvider) || ! this.provider.supportsBlobs )
|
||||
return false;
|
||||
|
||||
const keys = await this.provider.blobKeys();
|
||||
|
@ -433,9 +512,12 @@ export default class SettingsManager extends Module {
|
|||
}
|
||||
|
||||
|
||||
async _getZipBackup() {
|
||||
private async _getZipBackup() {
|
||||
// Before we do anything else, make sure the provider is ready.
|
||||
await this.awaitProvider();
|
||||
if ( ! this.provider )
|
||||
throw new Error('provider not available');
|
||||
|
||||
await this.provider.awaitReady();
|
||||
|
||||
// Create our ZIP file.
|
||||
|
@ -449,7 +531,7 @@ export default class SettingsManager extends Module {
|
|||
// Blob Settings
|
||||
const metadata = {};
|
||||
|
||||
if ( this.provider.supportsBlobs ) {
|
||||
if ( this.provider instanceof AdvancedSettingsProvider && this.provider.supportsBlobs ) {
|
||||
const keys = await this.provider.blobKeys();
|
||||
for(const key of keys) {
|
||||
const safe_key = encodeURIComponent(key),
|
||||
|
@ -489,16 +571,19 @@ export default class SettingsManager extends Module {
|
|||
async getSettingsDump() {
|
||||
// Before we do anything else, make sure the provider is ready.
|
||||
await this.awaitProvider();
|
||||
if ( ! this.provider )
|
||||
return null;
|
||||
|
||||
await this.provider.awaitReady();
|
||||
|
||||
const out = {
|
||||
const out: ExportedFullDump = {
|
||||
version: 2,
|
||||
type: 'full',
|
||||
values: {}
|
||||
};
|
||||
|
||||
for(const [k, v] of this.provider.entries())
|
||||
out.values[k] = v;
|
||||
for(const [key, value] of this.provider.entries())
|
||||
out.values[key] = value;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
@ -514,9 +599,9 @@ export default class SettingsManager extends Module {
|
|||
|
||||
async checkUpdates() {
|
||||
await this.awaitProvider();
|
||||
await this.provider.awaitReady();
|
||||
await this.provider?.awaitReady();
|
||||
|
||||
if ( ! this.provider.shouldUpdate )
|
||||
if ( ! this.provider?.shouldUpdate )
|
||||
return;
|
||||
|
||||
const promises = [];
|
||||
|
@ -575,9 +660,9 @@ export default class SettingsManager extends Module {
|
|||
wanted = localStorage.ffzProviderv2 = await this.sniffProvider();
|
||||
|
||||
if ( this.providers[wanted] ) {
|
||||
const provider = new this.providers[wanted](this);
|
||||
const provider = new (this.providers[wanted] as any)(this) as SettingsProvider;
|
||||
if ( wanted === 'idb' )
|
||||
this._idb = provider;
|
||||
this._idb = provider as IndexedDBProvider;
|
||||
|
||||
this._active_provider = wanted;
|
||||
return provider;
|
||||
|
@ -585,7 +670,7 @@ export default class SettingsManager extends Module {
|
|||
|
||||
// Fallback to localStorage if nothing else was wanted and available.
|
||||
this._active_provider = 'local';
|
||||
return new this.providers.local(this);
|
||||
return new LocalStorageProvider(this);
|
||||
}
|
||||
|
||||
|
||||
|
@ -599,12 +684,15 @@ export default class SettingsManager extends Module {
|
|||
* @returns {String} The key for which provider we should use.
|
||||
*/
|
||||
async sniffProvider() {
|
||||
const providers = Object.values(this.providers);
|
||||
providers.sort((a,b) => b.priority - a.priority);
|
||||
const providers = Array.from(Object.entries(this.providers));
|
||||
providers.sort((a, b) =>
|
||||
((b[1] as any).priority ?? 0) -
|
||||
((a[1] as any).priority ?? 0)
|
||||
);
|
||||
|
||||
for(const provider of providers) {
|
||||
if ( provider.supported(this) && provider.hasContent && await provider.hasContent(this) ) // eslint-disable-line no-await-in-loop
|
||||
return provider.key;
|
||||
for(const [key, provider] of providers) {
|
||||
if ( provider.supported() && await provider.hasContent() ) // eslint-disable-line no-await-in-loop
|
||||
return key;
|
||||
}
|
||||
|
||||
// Fallback to local if no provider indicated present settings.
|
||||
|
@ -620,13 +708,13 @@ export default class SettingsManager extends Module {
|
|||
* @param {Boolean} transfer Whether or not settings should be transferred
|
||||
* from the current provider.
|
||||
*/
|
||||
async changeProvider(key, transfer) {
|
||||
if ( ! this.providers[key] || ! this.providers[key].supported(this) )
|
||||
async changeProvider(key: string, transfer: boolean) {
|
||||
if ( ! this.providers[key] || ! this.providers[key].supported() )
|
||||
throw new Error(`Invalid provider: ${key}`);
|
||||
|
||||
// If we're changing to the current provider... well, that doesn't make
|
||||
// a lot of sense, does it? Abort!
|
||||
if ( key === this._active_provider )
|
||||
if ( key === this._active_provider || ! this.provider )
|
||||
return;
|
||||
|
||||
const old_provider = this.provider;
|
||||
|
@ -637,7 +725,7 @@ export default class SettingsManager extends Module {
|
|||
|
||||
// Are we transfering settings?
|
||||
if ( transfer ) {
|
||||
const new_provider = new this.providers[key](this);
|
||||
const new_provider = new (this.providers[key] as any)(this) as SettingsProvider;
|
||||
await new_provider.awaitReady();
|
||||
|
||||
if ( new_provider.allowTransfer && old_provider.allowTransfer ) {
|
||||
|
@ -645,13 +733,13 @@ export default class SettingsManager extends Module {
|
|||
|
||||
// When transfering, we clear all existing settings.
|
||||
await new_provider.clear();
|
||||
if ( new_provider.supportsBlobs )
|
||||
if ( new_provider instanceof AdvancedSettingsProvider && new_provider.supportsBlobs )
|
||||
await new_provider.clearBlobs();
|
||||
|
||||
for(const [key,val] of old_provider.entries())
|
||||
new_provider.set(key, val);
|
||||
|
||||
if ( old_provider.supportsBlobs && new_provider.supportsBlobs ) {
|
||||
if ( old_provider instanceof AdvancedSettingsProvider && old_provider.supportsBlobs && new_provider instanceof AdvancedSettingsProvider && new_provider.supportsBlobs ) {
|
||||
for(const key of await old_provider.blobKeys() ) {
|
||||
const blob = await old_provider.getBlob(key); // eslint-disable-line no-await-in-loop
|
||||
if ( blob )
|
||||
|
@ -679,7 +767,7 @@ export default class SettingsManager extends Module {
|
|||
* the result of a setting being changed in another tab or, when cloud
|
||||
* settings are enabled, on another computer.
|
||||
*/
|
||||
_onProviderChange(key, new_value, deleted) {
|
||||
_onProviderChange(key: string, new_value: any, deleted: boolean) {
|
||||
// If profiles have changed, reload our profiles.
|
||||
if ( key === 'profiles' )
|
||||
return this.loadProfiles();
|
||||
|
@ -690,17 +778,17 @@ export default class SettingsManager extends Module {
|
|||
// If we're still here, it means an individual setting was changed.
|
||||
// Look up the profile it belongs to and emit a changed event from
|
||||
// that profile, thus notifying any contexts or UI instances.
|
||||
key = key.substr(2);
|
||||
key = key.slice(2);
|
||||
|
||||
// Is it a value?
|
||||
const idx = key.indexOf(':');
|
||||
if ( idx === -1 )
|
||||
return;
|
||||
|
||||
const profile = this.__profile_ids[key.slice(0, idx)],
|
||||
const profile = this.__profile_ids[key.slice(0, idx) as any],
|
||||
s_key = key.slice(idx + 1);
|
||||
|
||||
if ( profile ) {
|
||||
if ( profile && ! profile.ephemeral ) {
|
||||
if ( s_key === ':enabled' )
|
||||
profile.emit('toggled', profile, deleted ? true : new_value);
|
||||
else
|
||||
|
@ -716,7 +804,7 @@ export default class SettingsManager extends Module {
|
|||
updateRoutes() {
|
||||
// Clear the existing matchers.
|
||||
for(const profile of this.__profiles)
|
||||
profile.matcher = null;
|
||||
profile.clearMatcher();
|
||||
|
||||
// And then re-select the active profiles.
|
||||
for(const context of this.__contexts)
|
||||
|
@ -726,12 +814,12 @@ export default class SettingsManager extends Module {
|
|||
}
|
||||
|
||||
|
||||
_onProfileToggled(profile, val) {
|
||||
_onProfileToggled(profile: SettingsProfile, enabled: boolean) {
|
||||
for(const context of this.__contexts)
|
||||
context.selectProfiles();
|
||||
|
||||
this.updateClock();
|
||||
this.emit(':profile-toggled', profile, val);
|
||||
this.emit(':profile-toggled', profile, enabled);
|
||||
}
|
||||
|
||||
|
||||
|
@ -739,8 +827,8 @@ export default class SettingsManager extends Module {
|
|||
* Get an existing {@link SettingsProfile} instance.
|
||||
* @param {number} id - The id of the profile.
|
||||
*/
|
||||
profile(id) {
|
||||
return this.__profile_ids[id] || null;
|
||||
profile(id: number): SettingsProfile | null {
|
||||
return this.__profile_ids[id] ?? null;
|
||||
}
|
||||
|
||||
|
||||
|
@ -748,12 +836,12 @@ export default class SettingsManager extends Module {
|
|||
* Build {@link SettingsProfile} instances for all of the profiles
|
||||
* defined in storage, re-using existing instances when possible.
|
||||
*/
|
||||
loadProfiles(suppress_events) {
|
||||
loadProfiles(suppress_events: boolean = false) {
|
||||
const old_profile_ids = this.__profile_ids,
|
||||
old_profiles = this.__profiles,
|
||||
|
||||
profile_ids = this.__profile_ids = {},
|
||||
profiles = this.__profiles = [],
|
||||
profile_ids: Record<number, SettingsProfile> = this.__profile_ids = {},
|
||||
profiles: SettingsProfile[] = this.__profiles = [],
|
||||
|
||||
// Create a set of actual IDs with a map from the profiles
|
||||
// list rather than just getting the keys from the ID map
|
||||
|
@ -761,17 +849,17 @@ export default class SettingsManager extends Module {
|
|||
// to keys.
|
||||
old_ids = new Set(old_profiles.map(x => x.id)),
|
||||
|
||||
new_ids = new Set,
|
||||
changed_ids = new Set;
|
||||
new_ids = new Set<number>,
|
||||
changed_ids = new Set<number>;
|
||||
|
||||
let raw_profiles = this.provider.get('profiles', [
|
||||
SettingsProfile.Moderation,
|
||||
SettingsProfile.Default
|
||||
]);
|
||||
let raw_profiles = this.provider?.get<SettingsProfileMetadata[]>('profiles') ?? [
|
||||
SettingsProfile.Moderation,
|
||||
SettingsProfile.Default
|
||||
];
|
||||
|
||||
// Sanity check. If we have no profiles, delete the old data.
|
||||
if ( ! raw_profiles?.length ) {
|
||||
this.provider.delete('profiles');
|
||||
this.provider?.delete('profiles');
|
||||
raw_profiles = [
|
||||
SettingsProfile.Moderation,
|
||||
SettingsProfile.Default
|
||||
|
@ -787,7 +875,7 @@ export default class SettingsManager extends Module {
|
|||
}
|
||||
|
||||
for(const profile_data of raw_profiles) {
|
||||
const id = profile_data.id,
|
||||
const id = profile_data.id as number,
|
||||
slot_id = profiles.length,
|
||||
old_profile = old_profile_ids[id],
|
||||
old_slot_id = old_profile ? old_profiles.indexOf(old_profile) : -1;
|
||||
|
@ -798,12 +886,15 @@ export default class SettingsManager extends Module {
|
|||
reordered = true;
|
||||
|
||||
// Monkey patch to the new profile format...
|
||||
// Update: Probably safe to remove this, at this point.
|
||||
/*
|
||||
if ( profile_data.context && ! Array.isArray(profile_data.context) ) {
|
||||
if ( profile_data.context.moderator )
|
||||
profile_data.context = SettingsProfile.Moderation.context;
|
||||
else
|
||||
profile_data.context = null;
|
||||
}
|
||||
*/
|
||||
|
||||
if ( old_profile && deep_equals(old_profile.data, profile_data, true) ) {
|
||||
// Did the order change?
|
||||
|
@ -816,10 +907,7 @@ export default class SettingsManager extends Module {
|
|||
|
||||
const new_profile = profile_ids[id] = new SettingsProfile(this, profile_data);
|
||||
if ( old_profile ) {
|
||||
// Move all the listeners over.
|
||||
new_profile.__listeners = old_profile.__listeners;
|
||||
old_profile.__listeners = {};
|
||||
|
||||
old_profile.transferListeners(new_profile);
|
||||
changed_ids.add(id);
|
||||
|
||||
} else
|
||||
|
@ -856,29 +944,37 @@ export default class SettingsManager extends Module {
|
|||
/**
|
||||
* Create a new profile and return the {@link SettingsProfile} instance
|
||||
* representing it.
|
||||
* @returns {SettingsProfile}
|
||||
*/
|
||||
createProfile(options) {
|
||||
createProfile(options: Partial<SettingsProfileMetadata> = {}) {
|
||||
if ( ! this.enabled )
|
||||
throw new Error('Unable to create profile before settings have initialized. Please await enable()');
|
||||
|
||||
let i = 0;
|
||||
while( this.__profile_ids[i] )
|
||||
i++;
|
||||
if ( options.id !== undefined )
|
||||
throw new Error('You cannot specify an ID when creating a profile.');
|
||||
|
||||
options = options || {};
|
||||
options.id = i;
|
||||
let id = 0;
|
||||
|
||||
// Find the next available profile ID.
|
||||
while ( this.__profile_ids[id] ) {
|
||||
// Ephemeral profiles have negative IDs.
|
||||
options.ephemeral ? id-- : id++;
|
||||
}
|
||||
|
||||
options.id = id;
|
||||
|
||||
if ( ! options.name )
|
||||
options.name = `Unnamed Profile ${i}`;
|
||||
options.name = `Unnamed Profile ${this.__profiles.length + 1}`;
|
||||
|
||||
const profile = this.__profile_ids[i] = new SettingsProfile(this, options);
|
||||
const profile = this.__profile_ids[id] = new SettingsProfile(this, options);
|
||||
this.__profiles.unshift(profile);
|
||||
|
||||
profile.on('toggled', this._onProfileToggled, this);
|
||||
profile.hotkey_enabled = true;
|
||||
|
||||
this._saveProfiles();
|
||||
// Don't bother saving if it's ephemeral.
|
||||
if ( ! profile.ephemeral )
|
||||
this._saveProfiles();
|
||||
|
||||
this.emit(':profile-created', profile);
|
||||
return profile;
|
||||
}
|
||||
|
@ -886,14 +982,17 @@ export default class SettingsManager extends Module {
|
|||
|
||||
/**
|
||||
* Delete a profile.
|
||||
* @param {number|SettingsProfile} id - The profile to delete
|
||||
*
|
||||
* @param id - The ID of the profile to delete, or just the profile itself.
|
||||
*/
|
||||
deleteProfile(id) {
|
||||
deleteProfile(id: number | SettingsProfile) {
|
||||
if ( ! this.enabled )
|
||||
throw new Error('Unable to delete profile before settings have initialized. Please await enable()');
|
||||
|
||||
if ( typeof id === 'object' && id.id != null )
|
||||
if ( typeof id === 'object' && typeof id.id === 'number' )
|
||||
id = id.id;
|
||||
else if ( typeof id !== 'number' )
|
||||
throw new Error('Invalid profile');
|
||||
|
||||
const profile = this.__profile_ids[id];
|
||||
if ( ! profile )
|
||||
|
@ -913,17 +1012,22 @@ export default class SettingsManager extends Module {
|
|||
if ( idx !== -1 )
|
||||
this.__profiles.splice(idx, 1);
|
||||
|
||||
this._saveProfiles();
|
||||
// If it wasn't an ephemeral profile, go ahead and update.
|
||||
if ( ! profile.ephemeral )
|
||||
this._saveProfiles();
|
||||
|
||||
this.emit(':profile-deleted', profile);
|
||||
}
|
||||
|
||||
|
||||
moveProfile(id, index) {
|
||||
moveProfile(id: number | SettingsProfile, index: number) {
|
||||
if ( ! this.enabled )
|
||||
throw new Error('Unable to move profiles before settings have initialized. Please await enable()');
|
||||
|
||||
if ( typeof id === 'object' && id.id )
|
||||
if ( typeof id === 'object' && typeof id.id === 'number' )
|
||||
id = id.id;
|
||||
else if ( typeof id !== 'number' )
|
||||
throw new Error('Invalid profile');
|
||||
|
||||
const profile = this.__profile_ids[id];
|
||||
if ( ! profile )
|
||||
|
@ -936,29 +1040,39 @@ export default class SettingsManager extends Module {
|
|||
|
||||
profiles.splice(index, 0, ...profiles.splice(idx, 1));
|
||||
|
||||
this._saveProfiles();
|
||||
// If it wasn't an ephemeral profile, go ahead and update.
|
||||
if ( ! profile.ephemeral )
|
||||
this._saveProfiles();
|
||||
|
||||
this.emit(':profiles-reordered');
|
||||
}
|
||||
|
||||
|
||||
saveProfile(id) {
|
||||
saveProfile(id: number | SettingsProfile) {
|
||||
if ( ! this.enabled )
|
||||
throw new Error('Unable to save profile before settings have initialized. Please await enable()');
|
||||
|
||||
if ( typeof id === 'object' && id.id )
|
||||
if ( typeof id === 'object' && typeof id.id === 'number' )
|
||||
id = id.id;
|
||||
else if ( typeof id !== 'number' )
|
||||
throw new Error('Invalid profile');
|
||||
|
||||
const profile = this.__profile_ids[id];
|
||||
if ( ! profile )
|
||||
return;
|
||||
|
||||
this._saveProfiles();
|
||||
// If it wasn't an ephemeral profile, go ahead and update.
|
||||
if ( ! profile.ephemeral )
|
||||
this._saveProfiles();
|
||||
|
||||
this.emit(':profile-changed', profile);
|
||||
}
|
||||
|
||||
|
||||
_saveProfiles() {
|
||||
const out = this.__profiles.filter(prof => ! prof.ephemeral).map(prof => prof.data);
|
||||
const out = this.__profiles
|
||||
.filter(prof => ! prof.ephemeral)
|
||||
.map(prof => prof.data);
|
||||
|
||||
// Ensure that we always have a non-ephemeral profile.
|
||||
if ( ! out ) {
|
||||
|
@ -967,10 +1081,12 @@ export default class SettingsManager extends Module {
|
|||
i18n_key: 'setting.profiles.default',
|
||||
description: 'Settings that apply everywhere on Twitch.'
|
||||
});
|
||||
|
||||
// Just return. Creating the profile will call this method again.
|
||||
return;
|
||||
}
|
||||
|
||||
this.provider.set('profiles', out);
|
||||
this.provider?.set('profiles', out);
|
||||
for(const context of this.__contexts)
|
||||
context.selectProfiles();
|
||||
|
||||
|
@ -986,18 +1102,18 @@ export default class SettingsManager extends Module {
|
|||
get(key) { return this.main_context.get(key); }
|
||||
getChanges(key, fn, ctx) { return this.main_context.getChanges(key, fn, ctx); }
|
||||
onChange(key, fn, ctx) { return this.main_context.onChange(key, fn, ctx); }
|
||||
uses(key) { return this.main_context.uses(key) }
|
||||
update(key) { return this.main_context.update(key) }
|
||||
uses(key: string) { return this.main_context.uses(key) }
|
||||
update(key: string) { return this.main_context.update(key) }
|
||||
|
||||
updateContext(context) { return this.main_context.updateContext(context) }
|
||||
setContext(context) { return this.main_context.setContext(context) }
|
||||
updateContext(context: Partial<ContextData>) { return this.main_context.updateContext(context) }
|
||||
setContext(context: Partial<ContextData>) { return this.main_context.setContext(context) }
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Add-On Proxy
|
||||
// ========================================================================
|
||||
|
||||
getAddonProxy(addon_id) {
|
||||
getAddonProxy(addon_id: string) {
|
||||
if ( ! addon_id )
|
||||
return this;
|
||||
|
||||
|
@ -1030,31 +1146,26 @@ export default class SettingsManager extends Module {
|
|||
// Definitions
|
||||
// ========================================================================
|
||||
|
||||
add(key, definition, source) {
|
||||
if ( typeof key === 'object' ) {
|
||||
for(const k in key)
|
||||
if ( has(key, k) )
|
||||
this.add(k, key[k]);
|
||||
return;
|
||||
}
|
||||
add<T>(key: string, definition: SettingsDefinition<T>, source?: string) {
|
||||
|
||||
const old_definition = this.definitions.get(key),
|
||||
required_by = old_definition ?
|
||||
(Array.isArray(old_definition) ? old_definition : old_definition.required_by) : [];
|
||||
required_by = (Array.isArray(old_definition)
|
||||
? old_definition
|
||||
: old_definition?.required_by) ?? [];
|
||||
|
||||
definition.required_by = required_by;
|
||||
definition.requires = definition.requires || [];
|
||||
definition.requires = definition.requires ?? [];
|
||||
|
||||
definition.__source = source;
|
||||
|
||||
for(const req_key of definition.requires) {
|
||||
const req = this.definitions.get(req_key);
|
||||
if ( ! req )
|
||||
this.definitions.set(req_key, [key]);
|
||||
else if ( Array.isArray(req) )
|
||||
if ( Array.isArray(req) )
|
||||
req.push(key);
|
||||
else if ( req )
|
||||
req.required_by?.push(key);
|
||||
else
|
||||
req.required_by.push(key);
|
||||
this.definitions.set(req_key, [key]);
|
||||
}
|
||||
|
||||
|
||||
|
@ -1094,7 +1205,7 @@ export default class SettingsManager extends Module {
|
|||
}
|
||||
|
||||
|
||||
remove(key) {
|
||||
remove(key: string) {
|
||||
const definition = this.definitions.get(key);
|
||||
if ( ! definition )
|
||||
return;
|
||||
|
@ -1162,17 +1273,18 @@ export default class SettingsManager extends Module {
|
|||
}
|
||||
|
||||
|
||||
addClearable(key, definition, source) {
|
||||
addClearable(key: string | Record<string, SettingsClearable>, definition?: SettingsClearable, source?: string) {
|
||||
if ( typeof key === 'object' ) {
|
||||
for(const k in key)
|
||||
if ( has(key, k) )
|
||||
this.addClearable(k, key[k], source);
|
||||
for(const [k, value] of Object.entries(key))
|
||||
this.addClearable(k, value, source);
|
||||
return;
|
||||
} else if ( typeof key !== 'string' )
|
||||
throw new Error('invalid key');
|
||||
|
||||
if ( definition ) {
|
||||
definition.__source = source;
|
||||
this.clearables[key] = definition;
|
||||
}
|
||||
|
||||
definition.__source = source;
|
||||
|
||||
this.clearables[key] = definition;
|
||||
}
|
||||
|
||||
getClearables() {
|
||||
|
@ -1180,39 +1292,40 @@ export default class SettingsManager extends Module {
|
|||
}
|
||||
|
||||
|
||||
addProcessor(key, fn) {
|
||||
addProcessor(key: string | Record<string, SettingsProcessor<any>>, processor?: SettingsProcessor<any>) {
|
||||
if ( typeof key === 'object' ) {
|
||||
for(const k in key)
|
||||
if ( has(key, k) )
|
||||
this.addProcessor(k, key[k]);
|
||||
for(const [k, value] of Object.entries(key))
|
||||
this.addProcessor(k, value);
|
||||
return;
|
||||
}
|
||||
} else if ( typeof key !== 'string' )
|
||||
throw new Error('invalid key');
|
||||
|
||||
this.processors[key] = fn;
|
||||
if ( processor )
|
||||
this.processors[key] = processor;
|
||||
}
|
||||
|
||||
getProcessor(key) {
|
||||
return this.processors[key];
|
||||
getProcessor<T>(key: string): SettingsProcessor<T> | null {
|
||||
return this.processors[key] ?? null;
|
||||
}
|
||||
|
||||
getProcessors() {
|
||||
return deep_copy(this.processors);
|
||||
}
|
||||
|
||||
|
||||
addValidator(key, fn) {
|
||||
addValidator(key: string | Record<string, SettingsValidator<any>>, validator?: SettingsValidator<any>) {
|
||||
if ( typeof key === 'object' ) {
|
||||
for(const k in key)
|
||||
if ( has(key, k) )
|
||||
this.addValidator(k, key[k]);
|
||||
for(const [k, value] of Object.entries(key))
|
||||
this.addValidator(k, value);
|
||||
return;
|
||||
}
|
||||
} else if ( typeof key !== 'string' )
|
||||
throw new Error('invalid key');
|
||||
|
||||
this.validators[key] = fn;
|
||||
if ( validator )
|
||||
this.validators[key] = validator;
|
||||
}
|
||||
|
||||
getValidator(key) {
|
||||
return this.validators[key];
|
||||
getValidator<T>(key: string): SettingsValidator<T> | null {
|
||||
return this.validators[key] ?? null;
|
||||
}
|
||||
|
||||
getValidators() {
|
||||
|
@ -1221,42 +1334,6 @@ export default class SettingsManager extends Module {
|
|||
}
|
||||
|
||||
|
||||
export function parse_path(path) {
|
||||
return new_parse(path);
|
||||
}
|
||||
|
||||
|
||||
/*const PATH_SPLITTER = /(?:^|\s*([~>]+))\s*([^~>@]+)\s*(?:@([^~>]+))?/g;
|
||||
|
||||
export function old_parse_path(path) {
|
||||
const tokens = [];
|
||||
let match;
|
||||
|
||||
while((match = PATH_SPLITTER.exec(path))) {
|
||||
const page = match[1] === '>>',
|
||||
tab = match[1] === '~>',
|
||||
title = match[2].trim(),
|
||||
key = title.toSnakeCase(),
|
||||
options = match[3],
|
||||
|
||||
opts = { key, title, page, tab };
|
||||
|
||||
if ( options ) {
|
||||
try {
|
||||
Object.assign(opts, JSON.parse(options));
|
||||
} catch(err) {
|
||||
console.warn('Matched segment:', options);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
tokens.push(opts);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}*/
|
||||
|
||||
|
||||
export function format_path_tokens(tokens) {
|
||||
for(let i=0, l = tokens.length; i < l; i++) {
|
||||
const token = tokens[i];
|
|
@ -1,16 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Settings Migrations
|
||||
// ============================================================================
|
||||
|
||||
export default class MigrationManager {
|
||||
constructor(manager) {
|
||||
this.manager = manager;
|
||||
this.provider = manager.provider;
|
||||
}
|
||||
|
||||
process() { // eslint-disable-line class-methods-use-this
|
||||
throw new Error('Not Implemented');
|
||||
}
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
const BAD = Symbol('BAD');
|
||||
|
||||
const do_number = (val, default_value, def) => {
|
||||
if ( typeof val !== 'number' || isNaN(val) || ! isFinite(val) )
|
||||
val = BAD;
|
||||
|
||||
if ( val !== BAD ) {
|
||||
const bounds = def.bounds;
|
||||
if ( Array.isArray(bounds) ) {
|
||||
if ( bounds.length >= 3 ) {
|
||||
// [low, inclusive, high, inclusive]
|
||||
if ( (bounds[1] ? (val < bounds[0]) : (val <= bounds[0])) ||
|
||||
(bounds[3] ? (val > bounds[2]) : (val >= bounds[2])) )
|
||||
val = BAD;
|
||||
|
||||
} else if ( bounds.length === 2 ) {
|
||||
// [low, inclusive] or [low, high] ?
|
||||
if ( typeof bounds[1] === 'boolean' ) {
|
||||
if ( bounds[1] ? val < bounds[0] : val <= bounds[0] )
|
||||
val = BAD;
|
||||
} else if ( val < bounds[0] || val > bounds[1] )
|
||||
val = BAD;
|
||||
} else if ( bounds.length === 1 && val < bounds[0] )
|
||||
val = BAD;
|
||||
}
|
||||
}
|
||||
|
||||
return val === BAD ? default_value : val;
|
||||
}
|
||||
|
||||
export const to_int = (val, default_value, def) => {
|
||||
if ( typeof val === 'string' && ! /^-?\d+$/.test(val) )
|
||||
val = BAD;
|
||||
else
|
||||
val = parseInt(val, 10);
|
||||
|
||||
return do_number(val, default_value, def);
|
||||
}
|
||||
|
||||
export const to_float = (val, default_value, def) => {
|
||||
if ( typeof val === 'string' && ! /^-?[\d.]+$/.test(val) )
|
||||
val = BAD;
|
||||
else
|
||||
val = parseFloat(val);
|
||||
|
||||
return do_number(val, default_value, def);
|
||||
}
|
66
src/settings/processors.ts
Normal file
66
src/settings/processors.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
'use strict';
|
||||
|
||||
import type { SettingsDefinition, SettingsProcessor, SettingsUiDefinition } from "./types";
|
||||
|
||||
const BAD = Symbol('BAD');
|
||||
type BadType = typeof BAD;
|
||||
|
||||
function do_number(
|
||||
input: number | BadType,
|
||||
default_value: number,
|
||||
definition: SettingsUiDefinition<number>
|
||||
) {
|
||||
if ( typeof input !== 'number' || isNaN(input) || ! isFinite(input) )
|
||||
input = BAD;
|
||||
|
||||
if ( input !== BAD ) {
|
||||
const bounds = definition.bounds;
|
||||
if ( Array.isArray(bounds) ) {
|
||||
if ( bounds.length >= 3 ) {
|
||||
// [low, inclusive, high, inclusive]
|
||||
if ( (bounds[1] ? (input < bounds[0]) : (input <= bounds[0])) ||
|
||||
// TODO: Figure out why it doesn't like bounds[2] but bounds[3] is okay
|
||||
(bounds[3] ? (input > (bounds as any)[2]) : (input >= (bounds as any)[2])) )
|
||||
input = BAD;
|
||||
|
||||
} else if ( bounds.length === 2 ) {
|
||||
// [low, inclusive] or [low, high] ?
|
||||
if ( typeof bounds[1] === 'boolean' ) {
|
||||
if ( bounds[1] ? input < bounds[0] : input <= bounds[0] )
|
||||
input = BAD;
|
||||
} else if ( input < bounds[0] || input > bounds[1] )
|
||||
input = BAD;
|
||||
} else if ( bounds.length === 1 && input < bounds[0] )
|
||||
input = BAD;
|
||||
}
|
||||
}
|
||||
|
||||
return input === BAD ? default_value : input;
|
||||
}
|
||||
|
||||
export const to_int: SettingsProcessor<number> = (
|
||||
value,
|
||||
default_value,
|
||||
definition
|
||||
) => {
|
||||
if ( typeof value === 'string' && /^-?\d+$/.test(value) )
|
||||
value = parseInt(value, 10);
|
||||
else if ( typeof value !== 'number' )
|
||||
value = BAD;
|
||||
|
||||
return do_number(value as number, default_value, definition);
|
||||
}
|
||||
|
||||
export const to_float: SettingsProcessor<number> = (
|
||||
value: unknown,
|
||||
default_value,
|
||||
definition
|
||||
) => {
|
||||
if ( typeof value === 'string' && /^-?[\d.]+$/.test(value) )
|
||||
value = parseFloat(value);
|
||||
else if ( typeof value !== 'number' )
|
||||
value = BAD;
|
||||
|
||||
return do_number(value as number, default_value, definition);
|
||||
}
|
||||
|
|
@ -5,18 +5,151 @@
|
|||
// ============================================================================
|
||||
|
||||
import {EventEmitter} from 'utilities/events';
|
||||
import {isValidShortcut, has} from 'utilities/object';
|
||||
import {createTester} from 'utilities/filtering';
|
||||
import {isValidShortcut, fetchJSON} from 'utilities/object';
|
||||
import {FilterData, createTester} from 'utilities/filtering';
|
||||
import type SettingsManager from '.';
|
||||
import type { SettingsProvider } from './providers';
|
||||
import type { ContextData, ExportedSettingsProfile, SettingsProfileMetadata } from './types';
|
||||
import type { Mousetrap } from '../utilities/types';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Mousetrap?: Mousetrap;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export type ProfileEvents = {
|
||||
'toggled': [profile: SettingsProfile, enabled: boolean];
|
||||
'changed': [key: string, value: unknown, deleted: boolean];
|
||||
}
|
||||
|
||||
const fetchJSON = (url, options) => fetch(url, options).then(r => r.ok ? r.json() : null).catch(() => null);
|
||||
|
||||
/**
|
||||
* Instances of SettingsProfile are used for getting and setting raw settings
|
||||
* values, enumeration, and emit events when the raw settings are changed.
|
||||
* @extends EventEmitter
|
||||
*/
|
||||
export default class SettingsProfile extends EventEmitter {
|
||||
constructor(manager, data) {
|
||||
export default class SettingsProfile extends EventEmitter<ProfileEvents> {
|
||||
|
||||
static Default: Partial<SettingsProfileMetadata> = {
|
||||
id: 0,
|
||||
name: 'Default Profile',
|
||||
i18n_key: 'setting.profiles.default',
|
||||
|
||||
description: 'Settings that apply everywhere on Twitch.'
|
||||
};
|
||||
|
||||
|
||||
static Moderation: Partial<SettingsProfileMetadata> = {
|
||||
id: 1,
|
||||
name: 'Moderation',
|
||||
i18n_key: 'setting.profiles.moderation',
|
||||
|
||||
description: 'Settings that apply when you are a moderator of the current channel.',
|
||||
|
||||
context: [
|
||||
{
|
||||
type: 'Moderator',
|
||||
data: true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
private manager: SettingsManager;
|
||||
private provider: SettingsProvider;
|
||||
|
||||
private prefix: string;
|
||||
private enabled_key: string;
|
||||
|
||||
private _enabled: boolean = false;
|
||||
private _storage?: Map<string, unknown>;
|
||||
|
||||
/**
|
||||
* If this is true, the profile will not be persisted and the user will
|
||||
* not be able to edit it.
|
||||
*/
|
||||
ephemeral: boolean = false;
|
||||
|
||||
/**
|
||||
* The ID number for this profile. ID numbers may be recycled as profiles
|
||||
* are deleted and created.
|
||||
*/
|
||||
id: number = -1;
|
||||
|
||||
// Metadata
|
||||
|
||||
/**
|
||||
* The name of this profile. A human-readable string that may be edited
|
||||
* by the user.
|
||||
*/
|
||||
name: string = null as any;
|
||||
|
||||
/**
|
||||
* The localization key for the name of this profile. If this is set,
|
||||
* the name will be localized. If this is not set, the name will be
|
||||
* displayed as-is. This value is cleared if the user edits the name.
|
||||
*/
|
||||
i18n_key?: string | null;
|
||||
|
||||
/**
|
||||
* The description of this profile. A human-readable string that may
|
||||
* be edited by the user.
|
||||
*/
|
||||
description?: string | null;
|
||||
|
||||
/**
|
||||
* The localization key for the description of this profile. If this
|
||||
* is set, the description will be localized. If this is not set, the
|
||||
* description will be displayed as-is. This value is cleared if
|
||||
* the user edits the description.
|
||||
*/
|
||||
desc_i18n_key?: string | null;
|
||||
|
||||
/**
|
||||
* A URL for this profile. If this is set, the profile will potentially
|
||||
* be automatically updated from the URL.
|
||||
*/
|
||||
url?: string | null;
|
||||
|
||||
/**
|
||||
* Whether or not automatic updates should be processed. If this is
|
||||
* set to true, the profile will not be automatically updated.
|
||||
*/
|
||||
pause_updates: boolean = false;
|
||||
|
||||
// TODO: Document, check default value
|
||||
show_toggle: boolean = false;
|
||||
|
||||
|
||||
// Profile Rules
|
||||
|
||||
context?: FilterData[];
|
||||
|
||||
|
||||
// Hotkey Stuff
|
||||
|
||||
/**
|
||||
* A user-set hotkey for toggling this profile on or off.
|
||||
* @see {@link hotkey}
|
||||
*/
|
||||
private _hotkey?: string | null;
|
||||
|
||||
/**
|
||||
* Whether or not the hotkey is currently enabled.
|
||||
* @see {@link hotkey_enabled}
|
||||
*/
|
||||
private _hotkey_enabled?: boolean = false;
|
||||
|
||||
private _bound_key?: string | null;
|
||||
private Mousetrap?: Mousetrap;
|
||||
|
||||
|
||||
private matcher?: ((ctx: ContextData) => boolean) | null;
|
||||
|
||||
|
||||
constructor(manager: SettingsManager, data: Partial<SettingsProfileMetadata>) {
|
||||
super();
|
||||
|
||||
this.onShortcut = this.onShortcut.bind(this);
|
||||
|
@ -35,10 +168,10 @@ export default class SettingsProfile extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
get data() {
|
||||
get data(): Partial<SettingsProfileMetadata> {
|
||||
return {
|
||||
id: this.id,
|
||||
parent: this.parent,
|
||||
//parent: this.parent,
|
||||
|
||||
name: this.name,
|
||||
i18n_key: this.i18n_key,
|
||||
|
@ -61,20 +194,32 @@ export default class SettingsProfile extends EventEmitter {
|
|||
if ( typeof val !== 'object' )
|
||||
throw new TypeError('data must be an object');
|
||||
|
||||
this.matcher = null;
|
||||
this.clearMatcher();
|
||||
|
||||
// Make sure ephemeral is set first.
|
||||
if ( val.ephemeral )
|
||||
this.ephemeral = true;
|
||||
|
||||
for(const key in val)
|
||||
if ( has(val, key) )
|
||||
this[key] = val[key];
|
||||
// Copy the values to this profile.
|
||||
for(const [key, value] of Object.entries(val))
|
||||
(this as any)[key] = value;
|
||||
}
|
||||
|
||||
matches(context) {
|
||||
|
||||
clearMatcher() {
|
||||
this.matcher = null;
|
||||
}
|
||||
|
||||
|
||||
matches(context: ContextData) {
|
||||
if ( ! this.matcher )
|
||||
this.matcher = createTester(this.context, this.manager.filters, false, false, () => this.manager.updateSoon());
|
||||
this.matcher = createTester(
|
||||
this.context,
|
||||
this.manager.filters,
|
||||
false,
|
||||
false,
|
||||
() => this.manager.updateSoon()
|
||||
);
|
||||
|
||||
return this.matcher(context);
|
||||
}
|
||||
|
@ -86,8 +231,8 @@ export default class SettingsProfile extends EventEmitter {
|
|||
}
|
||||
|
||||
|
||||
getBackup() {
|
||||
const out = {
|
||||
getBackup(): ExportedSettingsProfile {
|
||||
const out: ExportedSettingsProfile = {
|
||||
version: 2,
|
||||
type: 'profile',
|
||||
profile: this.data,
|
||||
|
@ -97,8 +242,8 @@ export default class SettingsProfile extends EventEmitter {
|
|||
|
||||
delete out.profile.ephemeral;
|
||||
|
||||
for(const [k,v] of this.entries())
|
||||
out.values[k] = v;
|
||||
for(const [key, value] of this.entries())
|
||||
out.values[key] = value;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
@ -108,8 +253,8 @@ export default class SettingsProfile extends EventEmitter {
|
|||
if ( ! this.url || this.pause_updates )
|
||||
return false;
|
||||
|
||||
const data = await fetchJSON(this.url);
|
||||
if ( ! data || ! data.type === 'profile' || ! data.profile || ! data.values )
|
||||
const data = await fetchJSON<ExportedSettingsProfile>(this.url);
|
||||
if ( ! data || data.type !== 'profile' || ! data.profile || ! data.values )
|
||||
return false;
|
||||
|
||||
// We don't want to override general settings.
|
||||
|
@ -186,12 +331,12 @@ export default class SettingsProfile extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
onShortcut(e) {
|
||||
onShortcut(event: KeyboardEvent) {
|
||||
this.toggled = ! this.toggled;
|
||||
|
||||
if ( e ) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if ( event ) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -223,22 +368,24 @@ export default class SettingsProfile extends EventEmitter {
|
|||
// Context
|
||||
// ========================================================================
|
||||
|
||||
// wtf is this method context is an array yo
|
||||
/*
|
||||
updateContext(context) {
|
||||
if ( this.id === 0 )
|
||||
throw new Error('cannot set context of default profile');
|
||||
|
||||
this.context = Object.assign(this.context || {}, context);
|
||||
this.matcher = null;
|
||||
this.manager._saveProfiles();
|
||||
}
|
||||
this.save();
|
||||
}*/
|
||||
|
||||
setContext(context) {
|
||||
setContext(context?: FilterData[]) {
|
||||
if ( this.id === 0 )
|
||||
throw new Error('cannot set context of default profile');
|
||||
|
||||
this.context = context;
|
||||
this.matcher = null;
|
||||
this.manager._saveProfiles();
|
||||
this.clearMatcher();
|
||||
this.save();
|
||||
}
|
||||
|
||||
|
||||
|
@ -246,37 +393,48 @@ export default class SettingsProfile extends EventEmitter {
|
|||
// Setting Access
|
||||
// ========================================================================
|
||||
|
||||
get(key, default_value) {
|
||||
if ( this.ephemeral )
|
||||
return this._storage.get(key, default_value);
|
||||
return this.provider.get(this.prefix + key, default_value);
|
||||
get<T>(key: string, default_value: T): T;
|
||||
get<T>(key: string): T | null;
|
||||
|
||||
get<T>(key: string, default_value?: T): T | null {
|
||||
if ( this.ephemeral ) {
|
||||
if ( this._storage && this._storage.has(key) )
|
||||
return this._storage.get(key) as T;
|
||||
|
||||
return default_value ?? null;
|
||||
}
|
||||
|
||||
return this.provider.get<T>(this.prefix + key, default_value as T);
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
if ( this.ephemeral )
|
||||
this._storage.set(key, value);
|
||||
else
|
||||
set(key: string, value: unknown) {
|
||||
if ( this.ephemeral ) {
|
||||
if ( this._storage )
|
||||
this._storage.set(key, value);
|
||||
} else
|
||||
this.provider.set(this.prefix + key, value);
|
||||
this.emit('changed', key, value);
|
||||
|
||||
this.emit('changed', key, value, false);
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
if ( this.ephemeral )
|
||||
this._storage.delete(key);
|
||||
else
|
||||
delete(key: string) {
|
||||
if ( this.ephemeral ) {
|
||||
if ( this._storage )
|
||||
this._storage.delete(key);
|
||||
} else
|
||||
this.provider.delete(this.prefix + key);
|
||||
this.emit('changed', key, undefined, true);
|
||||
}
|
||||
|
||||
has(key) {
|
||||
has(key: string) {
|
||||
if ( this.ephemeral )
|
||||
return this._storage.has(key);
|
||||
return this._storage ? this._storage.has(key): false;
|
||||
return this.provider.has(this.prefix + key);
|
||||
}
|
||||
|
||||
keys() {
|
||||
if ( this.ephemeral )
|
||||
return Array.from(this._storage.keys());
|
||||
return this._storage ? Array.from(this._storage.keys()) : [];
|
||||
|
||||
const out = [],
|
||||
p = this.prefix,
|
||||
|
@ -291,11 +449,14 @@ export default class SettingsProfile extends EventEmitter {
|
|||
|
||||
clear() {
|
||||
if ( this.ephemeral ) {
|
||||
const keys = this.keys();
|
||||
this._storage.clear();
|
||||
for(const key of keys) {
|
||||
this.emit('changed', key, undefined, true);
|
||||
if ( this._storage ) {
|
||||
const keys = this.keys();
|
||||
this._storage.clear();
|
||||
for(const key of keys) {
|
||||
this.emit('changed', key, undefined, true);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -310,22 +471,26 @@ export default class SettingsProfile extends EventEmitter {
|
|||
|
||||
*entries() {
|
||||
if ( this.ephemeral ) {
|
||||
for(const entry of this._storage.entries())
|
||||
yield entry;
|
||||
if ( this._storage ) {
|
||||
for(const entry of this._storage.entries())
|
||||
yield entry;
|
||||
}
|
||||
|
||||
} else {
|
||||
const p = this.prefix,
|
||||
len = p.length;
|
||||
|
||||
for(const key of this.provider.keys())
|
||||
if ( key.startsWith(p) && key !== this.enabled_key )
|
||||
yield [key.slice(len), this.provider.get(key)];
|
||||
if ( key.startsWith(p) && key !== this.enabled_key ) {
|
||||
const out: [string, unknown] = [key.slice(len), this.provider.get(key)];
|
||||
yield out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get size() {
|
||||
if ( this.ephemeral )
|
||||
return this._storage.size;
|
||||
return this._storage ? this._storage.size : 0;
|
||||
|
||||
const p = this.prefix;
|
||||
let count = 0;
|
||||
|
@ -337,28 +502,3 @@ export default class SettingsProfile extends EventEmitter {
|
|||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
SettingsProfile.Default = {
|
||||
id: 0,
|
||||
name: 'Default Profile',
|
||||
i18n_key: 'setting.profiles.default',
|
||||
|
||||
description: 'Settings that apply everywhere on Twitch.'
|
||||
}
|
||||
|
||||
|
||||
SettingsProfile.Moderation = {
|
||||
id: 1,
|
||||
name: 'Moderation',
|
||||
i18n_key: 'setting.profiles.moderation',
|
||||
|
||||
description: 'Settings that apply when you are a moderator of the current channel.',
|
||||
|
||||
context: [
|
||||
{
|
||||
type: 'Moderator',
|
||||
data: true
|
||||
}
|
||||
]
|
||||
}
|
File diff suppressed because it is too large
Load diff
168
src/settings/types.ts
Normal file
168
src/settings/types.ts
Normal file
|
@ -0,0 +1,168 @@
|
|||
import type SettingsManager from ".";
|
||||
import type { FilterData } from "../utilities/filtering";
|
||||
import type { OptionalPromise, OptionallyCallable, RecursivePartial } from "../utilities/types";
|
||||
import type SettingsContext from "./context";
|
||||
import type { SettingsProvider } from "./providers";
|
||||
|
||||
|
||||
// Clearables
|
||||
|
||||
type SettingsClearableKeys = {
|
||||
keys: OptionallyCallable<[provider: SettingsProvider, manager: SettingsManager], OptionalPromise<string[]>>;
|
||||
}
|
||||
|
||||
type SettingsClearableClear = {
|
||||
clear(provider: SettingsProvider, manager: SettingsManager): OptionalPromise<void>;
|
||||
}
|
||||
|
||||
export type SettingsClearable = {
|
||||
label: string;
|
||||
__source?: string | null;
|
||||
|
||||
} & (SettingsClearableKeys | SettingsClearableClear);
|
||||
|
||||
|
||||
// Context
|
||||
|
||||
export type ContextData = RecursivePartial<{
|
||||
addonDev: boolean;
|
||||
|
||||
category: string;
|
||||
categoryID: string;
|
||||
|
||||
chat: {
|
||||
|
||||
};
|
||||
|
||||
title: string;
|
||||
channel: string;
|
||||
channelColor: string;
|
||||
channelID: string;
|
||||
|
||||
chatHidden: boolean;
|
||||
fullscreen: boolean;
|
||||
isWatchParty: boolean;
|
||||
moderator: boolean;
|
||||
|
||||
route: {
|
||||
domain: string | null;
|
||||
name: string | null;
|
||||
};
|
||||
|
||||
route_data: string[];
|
||||
|
||||
size: {
|
||||
width: number;
|
||||
height: number
|
||||
};
|
||||
|
||||
ui: {
|
||||
theatreModeEnabled: boolean;
|
||||
squadModeEnabled: boolean;
|
||||
theme: number;
|
||||
};
|
||||
|
||||
}>;
|
||||
|
||||
|
||||
// Definitions
|
||||
|
||||
export type SettingsDefinition<T> = {
|
||||
|
||||
default: T,
|
||||
type?: string;
|
||||
|
||||
process?(this: SettingsManager, ctx: SettingsContext, val: T): T;
|
||||
|
||||
// Dependencies
|
||||
required_by?: string[];
|
||||
requires?: string[];
|
||||
|
||||
// Tracking
|
||||
__source?: string | null;
|
||||
|
||||
// UI Stuff
|
||||
ui?: SettingsUiDefinition<T>;
|
||||
|
||||
// Reactivity
|
||||
changed?: () => void;
|
||||
|
||||
};
|
||||
|
||||
export type SettingsUiDefinition<T> = {
|
||||
path: string;
|
||||
component: string;
|
||||
|
||||
process?: string;
|
||||
|
||||
/**
|
||||
* Bounds represents a minimum and maximum numeric value. These values
|
||||
* are used by number processing and validation if the processor is set
|
||||
* to `to_int` or `to_float`.
|
||||
*/
|
||||
bounds?:
|
||||
[low: number, low_inclusive: boolean, high: number, high_inclusive: boolean] |
|
||||
[low: number, low_inclusive: boolean, high: number] |
|
||||
[low: number, low_inclusive: boolean] |
|
||||
[low: number, high: number] |
|
||||
[low: number];
|
||||
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Exports
|
||||
|
||||
export type ExportedSettingsProfile = {
|
||||
version: 2;
|
||||
type: 'profile';
|
||||
profile: Partial<SettingsProfileMetadata>;
|
||||
toggled?: boolean;
|
||||
values: Record<string, any>;
|
||||
};
|
||||
|
||||
export type ExportedFullDump = {
|
||||
version: 2;
|
||||
type: 'full';
|
||||
values: Record<string, any>;
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Profiles
|
||||
|
||||
export type SettingsProfileMetadata = {
|
||||
id: number;
|
||||
|
||||
name: string;
|
||||
i18n_key?: string | null;
|
||||
hotkey?: string | null;
|
||||
pause_updates: boolean;
|
||||
|
||||
ephemeral?: boolean;
|
||||
|
||||
description?: string | null;
|
||||
desc_i18n_key?: string | null;
|
||||
|
||||
url?: string | null;
|
||||
show_toggle: boolean;
|
||||
|
||||
context?: FilterData[] | null;
|
||||
};
|
||||
|
||||
|
||||
// Processors
|
||||
|
||||
export type SettingsProcessor<T> = (
|
||||
input: unknown,
|
||||
default_value: T,
|
||||
definition: SettingsUiDefinition<T>
|
||||
) => T;
|
||||
|
||||
|
||||
// Validators
|
||||
|
||||
export type SettingsValidator<T> = (
|
||||
value: T,
|
||||
definition: SettingsUiDefinition<T>
|
||||
) => boolean;
|
|
@ -1,45 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
const do_number = (val, def) => {
|
||||
if ( typeof val !== 'number' || isNaN(val) || ! isFinite(val) )
|
||||
return false;
|
||||
|
||||
const bounds = def.bounds;
|
||||
if ( Array.isArray(bounds) ) {
|
||||
if ( bounds.length >= 3 ) {
|
||||
// [low, inclusive, high, inclusive]
|
||||
if ( (bounds[1] ? (val < bounds[0]) : (val <= bounds[0])) ||
|
||||
(bounds[3] ? (val > bounds[2]) : (val >= bounds[2])) )
|
||||
return false;
|
||||
|
||||
} else if ( bounds.length === 2 ) {
|
||||
// [low, inclusive] or [low, high] ?
|
||||
if ( typeof bounds[1] === 'boolean' ) {
|
||||
if ( bounds[1] ? val < bounds[0] : val <= bounds[0] )
|
||||
return false;
|
||||
} else if ( val < bounds[0] || val > bounds[1] )
|
||||
return false;
|
||||
} else if ( bounds.length === 1 && val < bounds[0] )
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export const process_to_int = (val, def) => {
|
||||
if ( typeof val === 'string' && ! /^-?\d+$/.test(val) )
|
||||
return false;
|
||||
else
|
||||
val = parseInt(val, 10);
|
||||
|
||||
return do_number(val, def);
|
||||
}
|
||||
|
||||
export const process_to_float = (val, def) => {
|
||||
if ( typeof val === 'string' && ! /^-?[\d.]+$/.test(val) )
|
||||
return false;
|
||||
else
|
||||
val = parseFloat(val);
|
||||
|
||||
return do_number(val, def);
|
||||
}
|
54
src/settings/validators.ts
Normal file
54
src/settings/validators.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
'use strict';
|
||||
|
||||
import type { SettingsUiDefinition, SettingsValidator } from "./types";
|
||||
|
||||
|
||||
function do_number(value: any, definition: SettingsUiDefinition<number>) {
|
||||
if ( typeof value !== 'number' || isNaN(value) || ! isFinite(value) )
|
||||
return false;
|
||||
|
||||
const bounds = definition.bounds;
|
||||
if ( Array.isArray(bounds) ) {
|
||||
if ( bounds.length >= 3 ) {
|
||||
// [low, inclusive, high, inclusive]
|
||||
if ( (bounds[1] ? (value < bounds[0]) : (value <= bounds[0])) ||
|
||||
(bounds[3] ? (value > (bounds as any)[2]) : (value >= (bounds as any)[2])) )
|
||||
return false;
|
||||
|
||||
} else if ( bounds.length === 2 ) {
|
||||
// [low, inclusive] or [low, high] ?
|
||||
if ( typeof bounds[1] === 'boolean' ) {
|
||||
if ( bounds[1] ? value < bounds[0] : value <= bounds[0] )
|
||||
return false;
|
||||
} else if ( value < bounds[0] || value > bounds[1] )
|
||||
return false;
|
||||
} else if ( bounds.length === 1 && value < bounds[0] )
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export const process_to_int: SettingsValidator<number> = (
|
||||
value,
|
||||
definition
|
||||
) => {
|
||||
if ( typeof value === 'string' && /^-?\d+$/.test(value) )
|
||||
value = parseInt(value, 10);
|
||||
else if ( typeof value !== 'number' )
|
||||
return false;
|
||||
|
||||
return do_number(value, definition);
|
||||
}
|
||||
|
||||
export const process_to_float: SettingsValidator<number> = (
|
||||
value,
|
||||
definition
|
||||
) => {
|
||||
if ( typeof value === 'string' && /^-?[\d.]+$/.test(value) )
|
||||
value = parseFloat(value);
|
||||
else if ( typeof value !== 'number' )
|
||||
return false;
|
||||
|
||||
return do_number(value, definition);
|
||||
}
|
|
@ -44,7 +44,7 @@ export default class Twilight extends BaseSite {
|
|||
|
||||
async populateModules() {
|
||||
const ctx = await require.context('site/modules', true, /(?:^(?:\.\/)?[^/]+|index)\.jsx?$/);
|
||||
const modules = await this.populate(ctx, this.log);
|
||||
const modules = await this.loadFromContext(ctx, this.log);
|
||||
this.log.info(`Loaded descriptions of ${Object.keys(modules).length} modules.`);
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
import {Color, ColorAdjuster} from 'utilities/color';
|
||||
import {get, has, make_enum, shallow_object_equals, set_equals, deep_equals, glob_to_regex, escape_regex} from 'utilities/object';
|
||||
import {WEBKIT_CSS as WEBKIT} from 'utilities/constants';
|
||||
import {FFZEvent} from 'utilities/events';
|
||||
|
||||
import {useFont} from 'utilities/fonts';
|
||||
|
||||
import Module from 'utilities/module';
|
||||
|
@ -1910,7 +1910,7 @@ export default class ChatHook extends Module {
|
|||
return;
|
||||
|
||||
if ( t.hasListeners('chat:receive-message') ) {
|
||||
const event = new FFZEvent({
|
||||
const event = t.makeEvent({
|
||||
message: m,
|
||||
inst,
|
||||
channel: room,
|
||||
|
@ -2277,12 +2277,16 @@ export default class ChatHook extends Module {
|
|||
|
||||
if ( want_event ) {
|
||||
if ( ! event ) {
|
||||
event = new FFZEvent();
|
||||
event = t.makeEvent({
|
||||
inst: this,
|
||||
channel: undefined,
|
||||
channelID: undefined,
|
||||
message: undefined
|
||||
});
|
||||
|
||||
const cont = this._ffz_connector ?? this.ffzGetConnector(),
|
||||
room_id = cont && cont.props.channelID;
|
||||
|
||||
event.inst = this;
|
||||
event.channelID = room_id;
|
||||
|
||||
if ( room_id ) {
|
||||
|
@ -2447,7 +2451,7 @@ export default class ChatHook extends Module {
|
|||
msg = msg.slice(idx + 1).trimStart();
|
||||
}
|
||||
|
||||
const event = new FFZEvent({
|
||||
const event = t.makeEvent({
|
||||
command: subcmd,
|
||||
message: msg,
|
||||
extra,
|
||||
|
@ -2472,7 +2476,7 @@ export default class ChatHook extends Module {
|
|||
return false;
|
||||
}
|
||||
|
||||
const event = new FFZEvent({
|
||||
const event = t.makeEvent({
|
||||
message: msg,
|
||||
extra,
|
||||
context: t.chat.context,
|
||||
|
|
|
@ -6,8 +6,8 @@
|
|||
|
||||
import Module from 'utilities/module';
|
||||
import { findReactFragment } from 'utilities/dom';
|
||||
import { FFZEvent } from 'utilities/events';
|
||||
import { getTwitchEmoteSrcSet, has, getTwitchEmoteURL } from 'utilities/object';
|
||||
|
||||
import { getTwitchEmoteSrcSet } from 'utilities/object';
|
||||
import { TWITCH_POINTS_SETS, TWITCH_GLOBAL_SETS, TWITCH_PRIME_SETS, KNOWN_CODES, REPLACEMENTS, REPLACEMENT_BASE, KEYS } from 'utilities/constants';
|
||||
|
||||
import Twilight from 'site';
|
||||
|
@ -523,7 +523,7 @@ export default class Input extends Module {
|
|||
}
|
||||
|
||||
previewClick(id, set, name, evt) {
|
||||
const fe = new FFZEvent({
|
||||
const fe = this.makeEvent({
|
||||
provider: 'ffz',
|
||||
id,
|
||||
set,
|
||||
|
@ -779,7 +779,7 @@ export default class Input extends Module {
|
|||
isEditor: inst.props.isCurrentUserEditor
|
||||
});
|
||||
|
||||
const event = new FFZEvent({
|
||||
const event = t.makeEvent({
|
||||
input,
|
||||
permissionLevel: inst.props.permissionLevel,
|
||||
isEditor: inst.props.isCurrentUserEditor,
|
||||
|
|
|
@ -8,11 +8,11 @@ import Twilight from 'site';
|
|||
import Module from 'utilities/module';
|
||||
|
||||
import RichContent from './rich_content';
|
||||
import { has, maybe_call } from 'utilities/object';
|
||||
import { has } from 'utilities/object';
|
||||
import { KEYS, RERENDER_SETTINGS, UPDATE_BADGE_SETTINGS, UPDATE_TOKEN_SETTINGS } from 'utilities/constants';
|
||||
import { print_duration } from 'utilities/time';
|
||||
import { FFZEvent } from 'utilities/events';
|
||||
import { getRewardTitle, getRewardCost, isHighlightedReward } from './points';
|
||||
|
||||
import { getRewardTitle, getRewardCost } from './points';
|
||||
|
||||
const SUB_TIERS = {
|
||||
1000: 1,
|
||||
|
@ -431,21 +431,6 @@ export default class ChatLine extends Module {
|
|||
this.updateLines();
|
||||
});
|
||||
|
||||
/*this.on('experiments:changed:line_renderer', () => {
|
||||
const value = this.experiments.get('line_renderer'),
|
||||
cls = this.ChatLine._class;
|
||||
|
||||
this.log.debug('Changing line renderer:', value ? 'new' : 'old');
|
||||
|
||||
if (cls) {
|
||||
cls.prototype.render = this.experiments.get('line_renderer')
|
||||
? cls.prototype.ffzNewRender
|
||||
: cls.prototype.ffzOldRender;
|
||||
|
||||
this.rerenderLines();
|
||||
}
|
||||
});*/
|
||||
|
||||
for(const setting of RERENDER_SETTINGS)
|
||||
this.chat.context.on(`changed:${setting}`, this.rerenderLines, this);
|
||||
|
||||
|
@ -839,7 +824,7 @@ other {# messages were deleted by a moderator.}
|
|||
} catch(err) { /* nothing~! */ }
|
||||
}
|
||||
|
||||
const fe = new FFZEvent({
|
||||
const fe = t.makeEvent({
|
||||
inst: this,
|
||||
event,
|
||||
message: msg,
|
||||
|
|
|
@ -597,7 +597,7 @@ export default class CSSTweaks extends Module {
|
|||
return;
|
||||
|
||||
if ( ! this.chunks_loaded )
|
||||
return this.populate().then(() => this._apply(key));
|
||||
return this.loadFromContext().then(() => this._apply(key));
|
||||
|
||||
if ( ! has(this.chunks, key) ) {
|
||||
this.log.warn(`Unknown chunk name "${key}" for toggle()`);
|
||||
|
@ -618,7 +618,7 @@ export default class CSSTweaks extends Module {
|
|||
deleteVariable(key) { this.style.delete(`var--${key}`) }
|
||||
|
||||
|
||||
populate() {
|
||||
loadFromContext() {
|
||||
if ( this.chunks_loaded )
|
||||
return;
|
||||
|
||||
|
|
|
@ -4,15 +4,16 @@
|
|||
// Directory (Following, for now)
|
||||
// ============================================================================
|
||||
|
||||
import {SiteModule} from 'utilities/module';
|
||||
import Module from 'utilities/module';
|
||||
import {createElement} from 'utilities/dom';
|
||||
import { get } from 'utilities/object';
|
||||
|
||||
|
||||
export default class Game extends SiteModule {
|
||||
export default class Game extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.inject('site');
|
||||
this.inject('site.fine');
|
||||
this.inject('site.apollo');
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
// Directory
|
||||
// ============================================================================
|
||||
|
||||
import {SiteModule} from 'utilities/module';
|
||||
import Module from 'utilities/module';
|
||||
import {duration_to_string} from 'utilities/time';
|
||||
import {createElement} from 'utilities/dom';
|
||||
import {get, glob_to_regex, escape_regex, addWordSeparators} from 'utilities/object';
|
||||
|
@ -33,12 +33,13 @@ function formatTerms(data, flags) {
|
|||
const DIR_ROUTES = ['front-page', 'dir', 'dir-community', 'dir-community-index', 'dir-creative', 'dir-following', 'dir-game-index', 'dir-game-clips', 'dir-game-videos', 'dir-all', 'dir-category', 'user-videos', 'user-clips'];
|
||||
|
||||
|
||||
export default class Directory extends SiteModule {
|
||||
export default class Directory extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.should_enable = true;
|
||||
|
||||
this.inject('site');
|
||||
this.inject('site.elemental');
|
||||
this.inject('site.fine');
|
||||
this.inject('site.router');
|
||||
|
|
|
@ -4,19 +4,20 @@
|
|||
// Following Button Modification
|
||||
// ============================================================================
|
||||
|
||||
import {SiteModule} from 'utilities/module';
|
||||
import Module from 'utilities/module';
|
||||
import {createElement as e} from 'utilities/dom';
|
||||
import {duration_to_string} from 'utilities/time';
|
||||
|
||||
import Tooltip from 'utilities/tooltip';
|
||||
|
||||
export default class FollowingText extends SiteModule {
|
||||
export default class FollowingText extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.should_enable = true;
|
||||
|
||||
this.inject('settings');
|
||||
this.inject('site');
|
||||
this.inject('site.router');
|
||||
this.inject('site.apollo');
|
||||
this.inject('i18n');
|
||||
|
|
|
@ -5,19 +5,20 @@
|
|||
// ============================================================================
|
||||
|
||||
import {DEBUG} from 'utilities/constants';
|
||||
import {SiteModule} from 'utilities/module';
|
||||
import Module from 'utilities/module';
|
||||
import {createElement, ClickOutside, setChildren} from 'utilities/dom';
|
||||
|
||||
import Twilight from 'site';
|
||||
import awaitMD, {getMD} from 'utilities/markdown';
|
||||
|
||||
|
||||
export default class MenuButton extends SiteModule {
|
||||
export default class MenuButton extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.inject('i18n');
|
||||
this.inject('settings');
|
||||
this.inject('site');
|
||||
this.inject('site.fine');
|
||||
this.inject('site.elemental');
|
||||
//this.inject('addons');
|
||||
|
|
|
@ -1,56 +0,0 @@
|
|||
import Module from 'utilities/module';
|
||||
|
||||
const EXTRACTOR = /^addon\.([^.]+)(?:\.|$)/i;
|
||||
|
||||
function extractAddonId(path) {
|
||||
const match = EXTRACTOR.exec(path);
|
||||
if ( match )
|
||||
return match[1];
|
||||
}
|
||||
|
||||
export class Addon extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.addon_id = extractAddonId(this.__path);
|
||||
this.addon_root = this;
|
||||
|
||||
this.inject('i18n');
|
||||
this.inject('settings');
|
||||
}
|
||||
|
||||
static register(id, info) {
|
||||
if ( typeof id === 'object' ) {
|
||||
info = id;
|
||||
id = info.id || undefined;
|
||||
}
|
||||
|
||||
if ( ! id ) {
|
||||
if ( this.name )
|
||||
id = this.name.toSnakeCase();
|
||||
else
|
||||
throw new Error(`Unable to register module without ID.`);
|
||||
}
|
||||
|
||||
if ( ! info && this.info )
|
||||
info = this.info;
|
||||
|
||||
const ffz = FrankerFaceZ.get();
|
||||
if ( info ) {
|
||||
info.id = id;
|
||||
ffz.addons.addAddon(info);
|
||||
}
|
||||
|
||||
try {
|
||||
ffz.register(`addon.${id}`, this);
|
||||
} catch(err) {
|
||||
if ( err.message && err.message.includes('Name Collision for Module') ) {
|
||||
const module = ffz.resolve(`addon.${id}`);
|
||||
if ( module )
|
||||
module.external = true;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
82
src/utilities/addon.ts
Normal file
82
src/utilities/addon.ts
Normal file
|
@ -0,0 +1,82 @@
|
|||
import Module, { GenericModule, ModuleEvents } from 'utilities/module';
|
||||
import type { AddonInfo } from './types';
|
||||
import type Logger from './logging';
|
||||
import type TranslationManager from '../i18n';
|
||||
import type SettingsManager from '../settings';
|
||||
|
||||
/**
|
||||
* A special sub-class of {@link Module} used for the root module of an add-on.
|
||||
*
|
||||
* This sub-class has a static {@link register} method that add-ons should call
|
||||
* to properly inject themselves into FrankerFaceZ once their scripts have
|
||||
* loaded. {@link register} is called automatically by add-ons build from the
|
||||
* official add-ons repository.
|
||||
*/
|
||||
export class Addon<TPath extends string = '', TEventMap extends ModuleEvents = ModuleEvents> extends Module<TPath, TEventMap> {
|
||||
|
||||
static info?: AddonInfo;
|
||||
|
||||
// Dependencies
|
||||
i18n: TranslationManager = null as any;
|
||||
settings: SettingsManager = null as any;
|
||||
|
||||
constructor(name?: string, parent?: GenericModule) {
|
||||
super(name, parent, true);
|
||||
|
||||
this.inject('i18n');
|
||||
this.inject('settings');
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @see {@link loadFromContext}
|
||||
*/
|
||||
populate(ctx: __WebpackModuleApi.RequireContext, log?: Logger) {
|
||||
this.log.warn('[DEV-CHECK] populate() has been renamed to loadFromContext(). The populate() name is deprecated.');
|
||||
return this.loadFromContext(ctx, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this add-on with the FrankerFaceZ module system. This
|
||||
* should be called as soon as your add-on class is available and
|
||||
* ready to be enabled. The {@link AddonManager} class will then
|
||||
* call {@link enable} on this module (assuming the user wants
|
||||
* the add-on to be enabled.)
|
||||
* @param id This add-on's ID, or an {@link AddonInfo} object.
|
||||
* @param info An optional AddonInfo object if {@link id} was not set to an AddonInfo object.
|
||||
*/
|
||||
static register(id?: string | AddonInfo, info?: AddonInfo) {
|
||||
if ( typeof id === 'object' ) {
|
||||
info = id;
|
||||
id = info.id || undefined;
|
||||
}
|
||||
|
||||
if ( ! id ) {
|
||||
if ( this.name )
|
||||
id = this.name.toSnakeCase();
|
||||
else
|
||||
throw new Error(`Unable to register module without ID.`);
|
||||
}
|
||||
|
||||
if ( ! info && this.info )
|
||||
info = this.info;
|
||||
|
||||
const ffz = window.FrankerFaceZ.get();
|
||||
if ( info ) {
|
||||
info.id = id;
|
||||
(ffz as any).addons.addAddon(info);
|
||||
}
|
||||
|
||||
try {
|
||||
ffz.register(`addon.${id}`, this);
|
||||
} catch(err) {
|
||||
if ( (err instanceof Error) && err.message && err.message.includes('Name Collision for Module') ) {
|
||||
const module = ffz.resolve(`addon.${id}`);
|
||||
if ( module )
|
||||
(module as any).external = true;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,138 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
export function isValidBlob(blob) {
|
||||
return blob instanceof Blob || blob instanceof File || blob instanceof ArrayBuffer || blob instanceof Uint8Array;
|
||||
}
|
||||
|
||||
export async function serializeBlob(blob) {
|
||||
if ( ! blob )
|
||||
return null;
|
||||
|
||||
if ( blob instanceof Blob )
|
||||
return {
|
||||
type: 'blob',
|
||||
mime: blob.type,
|
||||
buffer: await blob.arrayBuffer(),
|
||||
}
|
||||
|
||||
if ( blob instanceof File )
|
||||
return {
|
||||
type: 'file',
|
||||
mime: blob.type,
|
||||
name: blob.name,
|
||||
modified: blob.lastModified,
|
||||
buffer: await blob.arrayBuffer()
|
||||
}
|
||||
|
||||
if ( blob instanceof ArrayBuffer )
|
||||
return {
|
||||
type: 'ab',
|
||||
buffer: blob
|
||||
}
|
||||
|
||||
if ( blob instanceof Uint8Array )
|
||||
return {
|
||||
type: 'u8',
|
||||
buffer: blob.buffer
|
||||
}
|
||||
|
||||
throw new TypeError('Invalid type');
|
||||
}
|
||||
|
||||
export function deserializeBlob(data) {
|
||||
if ( ! data || ! data.type )
|
||||
return null;
|
||||
|
||||
if ( data.type === 'blob' )
|
||||
return new Blob([data.buffer], {type: data.mime});
|
||||
|
||||
if ( data.type === 'file' )
|
||||
return new File([data.buffer], data.name, {type: data.mime, lastModified: data.modified});
|
||||
|
||||
if ( data.type === 'ab' )
|
||||
return data.buffer;
|
||||
|
||||
if ( data.type === 'u8' )
|
||||
return new Uint8Array(data.buffer);
|
||||
|
||||
throw new TypeError('Invalid type');
|
||||
}
|
||||
|
||||
export function serializeBlobUrl(blob) {
|
||||
return new Promise((s,f) => {
|
||||
const reader = new FileReader();
|
||||
reader.onabort = f;
|
||||
reader.onerror = f;
|
||||
reader.onload = e => {
|
||||
s(e.target.result);
|
||||
}
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export function deserializeBlobUrl(url) {
|
||||
return fetch(blob).then(res => res.blob())
|
||||
}
|
||||
|
||||
export function deserializeABUrl(url) {
|
||||
return fetch(blob).then(res => res.arrayBuffer())
|
||||
}
|
||||
|
||||
export async function serializeBlobForExt(blob) {
|
||||
if ( ! blob )
|
||||
return null;
|
||||
|
||||
if ( blob instanceof Blob )
|
||||
return {
|
||||
type: 'blob',
|
||||
mime: blob.type,
|
||||
url: await serializeBlobUrl(blob)
|
||||
}
|
||||
|
||||
if ( blob instanceof File )
|
||||
return {
|
||||
type: 'file',
|
||||
mime: blob.type,
|
||||
name: blob.name,
|
||||
modified: blob.lastModified,
|
||||
url: await serializeBlobUrl(blob)
|
||||
}
|
||||
|
||||
if ( blob instanceof ArrayBuffer )
|
||||
return {
|
||||
type: 'ab',
|
||||
url: await serializeBlobUrl(new Blob([blob]))
|
||||
}
|
||||
|
||||
if ( blob instanceof Uint8Array )
|
||||
return {
|
||||
type: 'u8',
|
||||
url: await serializeBlobUrl(new Blob([blob]))
|
||||
}
|
||||
|
||||
throw new TypeError('Invalid type');
|
||||
|
||||
}
|
||||
|
||||
export async function deserializeBlobForExt(data) {
|
||||
if ( ! data || ! data.type )
|
||||
return null;
|
||||
|
||||
if ( data.type === 'blob' )
|
||||
return await deserializeBlobUrl(data.url);
|
||||
|
||||
if ( data.type === 'file' )
|
||||
return new File(
|
||||
[await deserializeBlobUrl(data.url)],
|
||||
data.name,
|
||||
{type: data.mime, lastModified: data.modified}
|
||||
);
|
||||
|
||||
if ( data.type === 'ab' )
|
||||
return await deserializeABUrl(data.url);
|
||||
|
||||
if ( data.type === 'u8' )
|
||||
return new Uint8Array(await deserializeABUrl(data.url));
|
||||
|
||||
throw new TypeError('Invalid type');
|
||||
}
|
104
src/utilities/blobs.ts
Normal file
104
src/utilities/blobs.ts
Normal file
|
@ -0,0 +1,104 @@
|
|||
|
||||
/** A union of the various Blob types that are supported. */
|
||||
export type BlobLike = Blob | File | ArrayBuffer | Uint8Array;
|
||||
|
||||
/** A union of the various serialized blob types. */
|
||||
export type SerializedBlobLike = SerializedBlob | SerializedFile | SerializedArrayBuffer | SerializedUint8Array;
|
||||
|
||||
/** A serialized {@link Blob} representation. */
|
||||
export type SerializedBlob = {
|
||||
type: 'blob';
|
||||
mime: string;
|
||||
buffer: ArrayBuffer
|
||||
};
|
||||
|
||||
/** A serialized {@link File} representation. */
|
||||
export type SerializedFile = {
|
||||
type: 'file';
|
||||
mime: string;
|
||||
name: string;
|
||||
modified: number;
|
||||
buffer: ArrayBuffer
|
||||
};
|
||||
|
||||
/** A serialized {@link ArrayBuffer} representation. */
|
||||
export type SerializedArrayBuffer = {
|
||||
type: 'ab';
|
||||
buffer: ArrayBuffer;
|
||||
};
|
||||
|
||||
/** A serialized {@link Uint8Array} representation. */
|
||||
export type SerializedUint8Array = {
|
||||
type: 'u8',
|
||||
buffer: ArrayBuffer
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine if the provided object is a valid Blob that can be serialized
|
||||
* for transmission via a messaging API.
|
||||
*/
|
||||
export function isValidBlob(blob: any): blob is BlobLike {
|
||||
return blob instanceof Blob || blob instanceof File || blob instanceof ArrayBuffer || blob instanceof Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the provided {@link BlobLike} object into a format that can be
|
||||
* transmitted via a messaging API.
|
||||
*/
|
||||
export async function serializeBlob(blob?: BlobLike): Promise<SerializedBlobLike | null> {
|
||||
if ( ! blob )
|
||||
return null;
|
||||
|
||||
if ( blob instanceof Blob )
|
||||
return {
|
||||
type: 'blob',
|
||||
mime: blob.type,
|
||||
buffer: await blob.arrayBuffer(),
|
||||
}
|
||||
|
||||
if ( blob instanceof File )
|
||||
return {
|
||||
type: 'file',
|
||||
mime: blob.type,
|
||||
name: blob.name,
|
||||
modified: blob.lastModified,
|
||||
buffer: await blob.arrayBuffer()
|
||||
}
|
||||
|
||||
if ( blob instanceof ArrayBuffer )
|
||||
return {
|
||||
type: 'ab',
|
||||
buffer: blob
|
||||
}
|
||||
|
||||
if ( blob instanceof Uint8Array )
|
||||
return {
|
||||
type: 'u8',
|
||||
buffer: blob.buffer
|
||||
}
|
||||
|
||||
throw new TypeError('Invalid type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize the provided {@link SerializedBlobLike} object into a copy of
|
||||
* the original {@link BlobLike}.
|
||||
*/
|
||||
export function deserializeBlob(data: SerializedBlobLike): BlobLike | null {
|
||||
if ( ! data || ! data.type )
|
||||
return null;
|
||||
|
||||
if ( data.type === 'blob' )
|
||||
return new Blob([data.buffer], {type: data.mime});
|
||||
|
||||
if ( data.type === 'file' )
|
||||
return new File([data.buffer], data.name, {type: data.mime, lastModified: data.modified});
|
||||
|
||||
if ( data.type === 'ab' )
|
||||
return data.buffer;
|
||||
|
||||
if ( data.type === 'u8' )
|
||||
return new Uint8Array(data.buffer);
|
||||
|
||||
throw new TypeError('Invalid type');
|
||||
}
|
|
@ -547,6 +547,19 @@ LUVAColor.prototype._a = function(a) { return new LUVAColor(this.l, this.u, this
|
|||
|
||||
|
||||
export class ColorAdjuster {
|
||||
|
||||
/*
|
||||
private _base: string;
|
||||
private _contrast: number;
|
||||
private _mode: number;
|
||||
|
||||
private _dark: boolean = false;
|
||||
private _cache: Map<string, string> = new Map;
|
||||
|
||||
private _luv: number = 0;
|
||||
private _luma: number = 0;
|
||||
*/
|
||||
|
||||
constructor(base = '#232323', mode = 0, contrast = 4.5) {
|
||||
this._contrast = contrast;
|
||||
this._base = base;
|
||||
|
|
|
@ -781,31 +781,16 @@ export class FineWrapper extends EventEmitter {
|
|||
return super.on(event, fn, ctx);
|
||||
}
|
||||
|
||||
prependOn(event, fn, ctx) {
|
||||
this._maybeWrap(event);
|
||||
return super.prependOn(event, fn, ctx);
|
||||
}
|
||||
|
||||
once(event, fn, ctx) {
|
||||
this._maybeWrap(event);
|
||||
return super.once(event, fn, ctx);
|
||||
}
|
||||
|
||||
prependOnce(event, fn, ctx) {
|
||||
this._maybeWrap(event);
|
||||
return super.prependOnce(event, fn, ctx);
|
||||
}
|
||||
|
||||
many(event, ttl, fn, ctx) {
|
||||
this._maybeWrap(event);
|
||||
return super.many(event, ttl, fn, ctx);
|
||||
}
|
||||
|
||||
prependMany(event, ttl, fn, ctx) {
|
||||
this._maybeWrap(event);
|
||||
return super.prependMany(event, ttl, fn, ctx);
|
||||
}
|
||||
|
||||
waitFor(event) {
|
||||
this._maybeWrap(event);
|
||||
return super.waitFor(event);
|
||||
|
|
|
@ -5,11 +5,78 @@
|
|||
// It controls Twitch PubSub.
|
||||
// ============================================================================
|
||||
|
||||
import Module from 'utilities/module';
|
||||
import Module, { GenericModule, ModuleEvents } from 'utilities/module';
|
||||
import { FFZEvent } from 'utilities/events';
|
||||
|
||||
export class PubSubEvent extends FFZEvent {
|
||||
constructor(data) {
|
||||
declare global {
|
||||
interface Window {
|
||||
__twitch_pubsub_client: TwitchPubSubClient | null | undefined;
|
||||
//__Twitch__pubsubInstances: any;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This is a rough map of the parts of Twitch's PubSub client that we
|
||||
* care about for our purposes.
|
||||
*/
|
||||
type TwitchPubSubClient = {
|
||||
|
||||
connection: {
|
||||
removeAllListeners(topic: string): void;
|
||||
addListener(topic: string, listener: (event: any) => void): void;
|
||||
}
|
||||
|
||||
topicListeners?: {
|
||||
_events?: Record<string, any>;
|
||||
}
|
||||
|
||||
onMessage(event: TwitchPubSubMessageEvent): any;
|
||||
|
||||
listen(opts: { topic: string }, listener: (event: any) => void, ...args: any[]): void;
|
||||
unlisten(topic: string, listener: (event: any) => void, ...args: any[]): void;
|
||||
|
||||
ffz_original_listen?: (opts: { topic: string }, listener: (event: any) => void, ...args: any[]) => void;
|
||||
ffz_original_unlisten?: (topic: string, listener: (event: any) => void, ...args: any[]) => void;
|
||||
|
||||
simulateMessage(topic: string, message: string): void;
|
||||
|
||||
}
|
||||
|
||||
type TwitchPubSubMessageEvent = {
|
||||
type: string;
|
||||
data?: {
|
||||
topic: string;
|
||||
message: string;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export type RawPubSubEventData = {
|
||||
prefix: string;
|
||||
trail: string;
|
||||
event: {
|
||||
topic: string;
|
||||
message: string;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class PubSubEvent<TMessage = any> extends FFZEvent<RawPubSubEventData> {
|
||||
|
||||
_obj?: TMessage;
|
||||
_changed: boolean;
|
||||
|
||||
// This is assigned in super()
|
||||
prefix: string = null as any;
|
||||
trail: string = null as any;
|
||||
event: {
|
||||
topic: string;
|
||||
message: string;
|
||||
} = null as any;
|
||||
|
||||
constructor(data: RawPubSubEventData) {
|
||||
super(data);
|
||||
|
||||
this._obj = undefined;
|
||||
|
@ -24,31 +91,45 @@ export class PubSubEvent extends FFZEvent {
|
|||
return this.event.topic;
|
||||
}
|
||||
|
||||
get message() {
|
||||
get message(): TMessage {
|
||||
if ( this._obj === undefined )
|
||||
this._obj = JSON.parse(this.event.message);
|
||||
this._obj = JSON.parse(this.event.message) ?? null;
|
||||
|
||||
return this._obj;
|
||||
return this._obj as TMessage;
|
||||
}
|
||||
|
||||
set message(val) {
|
||||
this._obj = val;
|
||||
this._changed = true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default class Subpump extends Module {
|
||||
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
export type SubpumpEvents = {
|
||||
/** A message was received via Twitch's PubSub connection. */
|
||||
':pubsub-message': [event: PubSubEvent];
|
||||
/** Twitch subscribed to a new topic. */
|
||||
':add-topic': [topic: string];
|
||||
/** Twitch unsubscribed from a topic. */
|
||||
':remove-topic': [topic: string];
|
||||
}
|
||||
|
||||
|
||||
export default class Subpump extends Module<'site.subpump', SubpumpEvents> {
|
||||
|
||||
instance?: TwitchPubSubClient | null;
|
||||
|
||||
constructor(name?: string, parent?: GenericModule) {
|
||||
super(name, parent);
|
||||
this.instance = null;
|
||||
}
|
||||
|
||||
onEnable(tries = 0) {
|
||||
const instance = window.__twitch_pubsub_client,
|
||||
instances = window.__Twitch__pubsubInstances;
|
||||
const instance = window.__twitch_pubsub_client;
|
||||
//instances = window.__Twitch__pubsubInstances;
|
||||
|
||||
if ( ! instance && ! instances ) {
|
||||
if ( ! instance ) { //} && ! instances ) {
|
||||
if ( tries > 10 )
|
||||
this.log.warn('Unable to find PubSub.');
|
||||
else
|
||||
|
@ -62,6 +143,7 @@ export default class Subpump extends Module {
|
|||
this.hookClient(instance);
|
||||
}
|
||||
|
||||
/*
|
||||
else if ( instances ) {
|
||||
for(const val of Object.values(instances))
|
||||
if ( val?._client ) {
|
||||
|
@ -74,12 +156,13 @@ export default class Subpump extends Module {
|
|||
this.hookOldClient(val._client);
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
if ( ! this.instance )
|
||||
this.log.warn('Unable to find a PubSub instance.');
|
||||
}
|
||||
|
||||
handleMessage(msg) {
|
||||
handleMessage(msg: TwitchPubSubMessageEvent) {
|
||||
try {
|
||||
if ( msg.type === 'MESSAGE' && msg.data?.topic ) {
|
||||
const raw_topic = msg.data.topic,
|
||||
|
@ -108,11 +191,11 @@ export default class Subpump extends Module {
|
|||
return false;
|
||||
}
|
||||
|
||||
hookClient(client) {
|
||||
hookClient(client: TwitchPubSubClient) {
|
||||
const t = this,
|
||||
orig_message = client.onMessage;
|
||||
|
||||
this.is_old = false;
|
||||
//this.is_old = false;
|
||||
|
||||
client.connection.removeAllListeners('message');
|
||||
|
||||
|
@ -153,6 +236,7 @@ export default class Subpump extends Module {
|
|||
}
|
||||
}
|
||||
|
||||
/*
|
||||
hookOldClient(client) {
|
||||
const t = this,
|
||||
orig_message = client._onMessage;
|
||||
|
@ -194,25 +278,26 @@ export default class Subpump extends Module {
|
|||
return out;
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
inject(topic, message) {
|
||||
inject(topic: string, message: any) {
|
||||
if ( ! this.instance )
|
||||
throw new Error('No PubSub instance available');
|
||||
|
||||
if ( this.is_old ) {
|
||||
/*if ( this.is_old ) {
|
||||
const listens = this.instance._client?._listens;
|
||||
listens._trigger(topic, JSON.stringify(message));
|
||||
} else {
|
||||
this.instance.simulateMessage(topic, JSON.stringify(message));
|
||||
}
|
||||
} else {*/
|
||||
this.instance.simulateMessage(topic, JSON.stringify(message));
|
||||
//}
|
||||
}
|
||||
|
||||
get topics() {
|
||||
let events;
|
||||
if ( this.is_old )
|
||||
const events = this.instance?.topicListeners?._events;
|
||||
/*if ( this.is_old )
|
||||
events = this.instance?._client?._listens._events;
|
||||
else
|
||||
events = this.instance?.topicListeners?._events;
|
||||
events = this.instance?.topicListeners?._events;*/
|
||||
|
||||
if ( ! events )
|
||||
return [];
|
|
@ -1,10 +1,14 @@
|
|||
'use strict';
|
||||
|
||||
import {make_enum} from 'utilities/object';
|
||||
|
||||
declare global {
|
||||
let __extension__: string | undefined;
|
||||
}
|
||||
|
||||
/** Whether or not FrankerFaceZ was loaded from a development server. */
|
||||
export const DEBUG = localStorage.ffzDebugMode === 'true' && document.body.classList.contains('ffz-dev');
|
||||
|
||||
/** Whether or not FrankerFaceZ was loaded as a packed web extension. */
|
||||
export const EXTENSION = !!__extension__;
|
||||
|
||||
/** The base URL of the FrankerFaceZ CDN. */
|
||||
export const SERVER = DEBUG ? 'https://localhost:8000' : 'https://cdn.frankerfacez.com';
|
||||
|
||||
let path = `${SERVER}/script`;
|
||||
|
@ -15,21 +19,31 @@ if ( EXTENSION ) {
|
|||
path = path.slice(0, path.length - 1);
|
||||
}
|
||||
|
||||
/** Either the base URL of the FrankerFaceZ CDN or, if FFZ was loaded as a packed web extension, the base URL of the web extension's web accessible files. */
|
||||
export const SERVER_OR_EXT = path;
|
||||
|
||||
export const CLIENT_ID = 'a3bc9znoz6vi8ozsoca0inlcr4fcvkl';
|
||||
/** The base URL of the FrankerFaceZ API. */
|
||||
export const API_SERVER = 'https://api.frankerfacez.com';
|
||||
|
||||
/** The base URL of the FrankerFaceZ staging API. */
|
||||
export const STAGING_API = 'https://api-staging.frankerfacez.com';
|
||||
|
||||
/** The base URL of the FrankerFaceZ staging CDN. */
|
||||
export const STAGING_CDN = 'https://cdn-staging.frankerfacez.com';
|
||||
|
||||
/** The base URL of the FrankerFaceZ testing API used for load testing. */
|
||||
export const NEW_API = 'https://api2.frankerfacez.com';
|
||||
|
||||
//export const SENTRY_ID = 'https://1c3b56f127254d3ba1bd1d6ad8805eee@sentry.io/1186960';
|
||||
//export const SENTRY_ID = 'https://07ded545d3224ca59825daee02dc7745@catbag.frankerfacez.com:444/2';
|
||||
/** The base URL provided to Sentry integrations for automatic error reporting. */
|
||||
export const SENTRY_ID = 'https://74b46b3894114f399d51949c6d237489@sentry.frankerfacez.com/2';
|
||||
|
||||
export const WORD_SEPARATORS = '[\\s`~<>!-#%-\\x2A,-/:;\\x3F@\\x5B-\\x5D_\\x7B}\\u00A1\\u00A7\\u00AB\\u00B6\\u00B7\\u00BB\\u00BF\\u037E\\u0387\\u055A-\\u055F\\u0589\\u058A\\u05BE\\u05C0\\u05C3\\u05C6\\u05F3\\u05F4\\u0609\\u060A\\u060C\\u060D\\u061B\\u061E\\u061F\\u066A-\\u066D\\u06D4\\u0700-\\u070D\\u07F7-\\u07F9\\u0830-\\u083E\\u085E\\u0964\\u0965\\u0970\\u0AF0\\u0DF4\\u0E4F\\u0E5A\\u0E5B\\u0F04-\\u0F12\\u0F14\\u0F3A-\\u0F3D\\u0F85\\u0FD0-\\u0FD4\\u0FD9\\u0FDA\\u104A-\\u104F\\u10FB\\u1360-\\u1368\\u1400\\u166D\\u166E\\u169B\\u169C\\u16EB-\\u16ED\\u1735\\u1736\\u17D4-\\u17D6\\u17D8-\\u17DA\\u1800-\\u180A\\u1944\\u1945\\u1A1E\\u1A1F\\u1AA0-\\u1AA6\\u1AA8-\\u1AAD\\u1B5A-\\u1B60\\u1BFC-\\u1BFF\\u1C3B-\\u1C3F\\u1C7E\\u1C7F\\u1CC0-\\u1CC7\\u1CD3\\u2010-\\u2027\\u2030-\\u2043\\u2045-\\u2051\\u2053-\\u205E\\u207D\\u207E\\u208D\\u208E\\u2329\\u232A\\u2768-\\u2775\\u27C5\\u27C6\\u27E6-\\u27EF\\u2983-\\u2998\\u29D8-\\u29DB\\u29FC\\u29FD\\u2CF9-\\u2CFC\\u2CFE\\u2CFF\\u2D70\\u2E00-\\u2E2E\\u2E30-\\u2E3B\\u3001-\\u3003\\u3008-\\u3011\\u3014-\\u301F\\u3030\\u303D\\u30A0\\u30FB\\uA4FE\\uA4FF\\uA60D-\\uA60F\\uA673\\uA67E\\uA6F2-\\uA6F7\\uA874-\\uA877\\uA8CE\\uA8CF\\uA8F8-\\uA8FA\\uA92E\\uA92F\\uA95F\\uA9C1-\\uA9CD\\uA9DE\\uA9DF\\uAA5C-\\uAA5F\\uAADE\\uAADF\\uAAF0\\uAAF1\\uABEB\\uFD3E\\uFD3F\\uFE10-\\uFE19\\uFE30-\\uFE52\\uFE54-\\uFE61\\uFE63\\uFE68\\uFE6A\\uFE6B\\uFF01-\\uFF03\\uFF05-\\uFF0A\\uFF0C-\\uFF0F\\uFF1A\\uFF1B\\uFF1F\\uFF20\\uFF3B-\\uFF3D\\uFF3F\\uFF5B\\uFF5D\\uFF5F-\\uFF65]';
|
||||
|
||||
export const WEIRD_EMOTE_SIZES = {
|
||||
/**
|
||||
* A map of default Twitch emotes with non-standard sizes, so they can be displayed
|
||||
* more accurately in certain situations.
|
||||
*/
|
||||
export const WEIRD_EMOTE_SIZES: Record<string, [width: number, height: number]> = {
|
||||
15: [21,27],
|
||||
16: [22,27],
|
||||
17: [20,27],
|
||||
|
@ -90,6 +104,7 @@ export const WEIRD_EMOTE_SIZES = {
|
|||
1906: [24,30]
|
||||
};
|
||||
|
||||
/** A list of hotkey combinations that are not valid for one reason or another. */
|
||||
export const BAD_HOTKEYS = [
|
||||
'f',
|
||||
'space',
|
||||
|
@ -103,7 +118,7 @@ export const BAD_HOTKEYS = [
|
|||
'alt+x'
|
||||
];
|
||||
|
||||
|
||||
/** A list of setting keys that, when changed, cause chat messages to re-render. */
|
||||
export const RERENDER_SETTINGS = [
|
||||
'chat.name-format',
|
||||
'chat.me-style',
|
||||
|
@ -120,15 +135,23 @@ export const RERENDER_SETTINGS = [
|
|||
'chat.bits.cheer-notice',
|
||||
'chat.filtering.hidden-tokens',
|
||||
'chat.hype.message-style'
|
||||
];
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* A list of setting keys that, when changed, cause chat messages to first clear
|
||||
* their badge caches and then re-render.
|
||||
*/
|
||||
export const UPDATE_BADGE_SETTINGS = [
|
||||
'chat.badges.style',
|
||||
'chat.badges.hidden',
|
||||
'chat.badges.custom-mod',
|
||||
'chat.badges.custom-vip',
|
||||
];
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* A list of setting keys that, when changed, cause chat messages to first clear
|
||||
* their cached token lists and then re-render.
|
||||
*/
|
||||
export const UPDATE_TOKEN_SETTINGS = [
|
||||
'chat.emotes.enabled',
|
||||
'chat.emotes.2x',
|
||||
|
@ -151,9 +174,12 @@ export const UPDATE_TOKEN_SETTINGS = [
|
|||
'__filter:block-terms',
|
||||
'__filter:block-users',
|
||||
'__filter:block-badges'
|
||||
];
|
||||
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* A list of keycodes for specific keys, for use with
|
||||
* {@link KeyboardEvent} events.
|
||||
*/
|
||||
export const KEYS = {
|
||||
Tab: 9,
|
||||
Enter: 13,
|
||||
|
@ -172,13 +198,16 @@ export const KEYS = {
|
|||
ArrowDown: 40,
|
||||
Meta: 91,
|
||||
Context: 93
|
||||
};
|
||||
} as const;
|
||||
|
||||
|
||||
export const TWITCH_EMOTE_BASE = '//static-cdn.jtvnw.net/emoticons/v1/';
|
||||
/** The base URL for Twitch emote images. */
|
||||
export const TWITCH_EMOTE_V2 = '//static-cdn.jtvnw.net/emoticons/v2';
|
||||
|
||||
export const KNOWN_CODES = {
|
||||
/**
|
||||
* A map of regex-style Twitch emote codes into normal,
|
||||
* human-readable strings for display in UI.
|
||||
*/
|
||||
export const KNOWN_CODES: Record<string, string> = {
|
||||
'#-?[\\\\/]': '#-/',
|
||||
':-?(?:7|L)': ':-7',
|
||||
'\\<\\;\\]': '<]',
|
||||
|
@ -203,9 +232,11 @@ export const KNOWN_CODES = {
|
|||
'Gr(a|e)yFace': 'GrayFace'
|
||||
};
|
||||
|
||||
/** The base URL for replacement images used for specific Twitch emotes. */
|
||||
export const REPLACEMENT_BASE = `${SERVER}/static/replacements/`;
|
||||
|
||||
export const REPLACEMENTS = {
|
||||
/** A map of specific Twitch emotes that should use replacement images. */
|
||||
export const REPLACEMENTS: Record<string, string> = {
|
||||
15: '15-JKanStyle.png',
|
||||
16: '16-OptimizePrime.png',
|
||||
17: '17-StoneLightning.png',
|
||||
|
@ -221,7 +252,10 @@ export const REPLACEMENTS = {
|
|||
36: '36-PJSalt.png'
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* A map of WebSocket servers for the original FrankerFaceZ socket
|
||||
* system. @deprecated
|
||||
*/
|
||||
export const WS_CLUSTERS = {
|
||||
Production: [
|
||||
['wss://catbag.frankerfacez.com/', 0.25],
|
||||
|
@ -275,29 +309,56 @@ export const PUBSUB_CLUSTERS = {
|
|||
Development: `https://stendec.dev/ps/`
|
||||
}
|
||||
|
||||
|
||||
/** Whether or not we're running on macOS */
|
||||
export const IS_OSX = navigator.platform ? navigator.platform.indexOf('Mac') !== -1 : /OS X/.test(navigator.userAgent);
|
||||
export const IS_WIN = navigator.platform ? navigator.platform.indexOf('Win') !== -1 : /Windows/.test(navigator.userAgent);
|
||||
export const IS_WEBKIT = navigator.userAgent.indexOf('AppleWebKit/') !== -1 && navigator.userAgent.indexOf('Edge/') === -1;
|
||||
export const IS_FIREFOX = (navigator.userAgent.indexOf('Firefox/') !== -1) || (window.InstallTrigger !== undefined);
|
||||
|
||||
/** Whether or not we're running on Windows */
|
||||
export const IS_WIN = navigator.platform ? navigator.platform.indexOf('Win') !== -1 : /Windows/.test(navigator.userAgent);
|
||||
|
||||
/** Whether or not we're running on a Webkit-based browser. */
|
||||
export const IS_WEBKIT = navigator.userAgent.indexOf('AppleWebKit/') !== -1 && navigator.userAgent.indexOf('Edge/') === -1;
|
||||
|
||||
/** Whether or not we're running on a Firefox-based browser. */
|
||||
export const IS_FIREFOX = (navigator.userAgent.indexOf('Firefox/') !== -1);
|
||||
|
||||
/**
|
||||
* A -webkit- CSS prefix, if we're running on a Webkit-based browser.
|
||||
* Hopefully we don't need this anymore.
|
||||
* @deprecated
|
||||
*/
|
||||
export const WEBKIT_CSS = IS_WEBKIT ? '-webkit-' : '';
|
||||
|
||||
/** A list of Twitch emote sets that are globally available. */
|
||||
export const TWITCH_GLOBAL_SETS = [0, 33, 42] as const;
|
||||
|
||||
export const TWITCH_GLOBAL_SETS = [0, 33, 42];
|
||||
export const TWITCH_POINTS_SETS = [300238151];
|
||||
export const TWITCH_PRIME_SETS = [457, 793, 19151, 19194];
|
||||
/** A list of Twitch emote sets that are for emotes unlocked with channel points. */
|
||||
export const TWITCH_POINTS_SETS = [300238151] as const;
|
||||
|
||||
export const EmoteTypes = make_enum(
|
||||
'Unknown',
|
||||
'Prime',
|
||||
'Turbo',
|
||||
'LimitedTime',
|
||||
'ChannelPoints',
|
||||
'Unavailable',
|
||||
'Subscription',
|
||||
'BitsTier',
|
||||
'Global',
|
||||
'TwoFactor',
|
||||
'Follower'
|
||||
);
|
||||
/** A list of Twitch emote sets that are for Twitch Prime subscribers. */
|
||||
export const TWITCH_PRIME_SETS = [457, 793, 19151, 19194] as const;
|
||||
|
||||
/** An enum of all possible Twitch emote types. */
|
||||
export enum EmoteTypes {
|
||||
/** What kind of weird emote are you dragging in here */
|
||||
Unknown,
|
||||
/** Emotes unlocked via Twitch Prime */
|
||||
Prime,
|
||||
/** Emotes unlocked via Twitch Turbo */
|
||||
Turbo,
|
||||
/** Emotes unlocked via arbitrary condition, permanently available. */
|
||||
LimitedTime,
|
||||
/** Emotes unlocked via channel points. */
|
||||
ChannelPoints,
|
||||
/** Emote no longer available. */
|
||||
Unavailable,
|
||||
/** Emote unlocked via subscription to channel. */
|
||||
Subscription,
|
||||
/** Emote permanently unlocked via cheering in channel. */
|
||||
BitsTier,
|
||||
/** Globally available emote. */
|
||||
Global,
|
||||
/** Emote unlocked via enabling two-factor authentication. */
|
||||
TwoFactor,
|
||||
/** Emote unlocked via following a channel. */
|
||||
Follower
|
||||
};
|
|
@ -1,104 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// CSS Tweaks
|
||||
// Tweak some CSS
|
||||
// ============================================================================
|
||||
|
||||
import Module from 'utilities/module';
|
||||
import {ManagedStyle} from 'utilities/dom';
|
||||
import {has, once} from 'utilities/object';
|
||||
|
||||
export default class CSSTweaks extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.rules = {};
|
||||
|
||||
this.loader = null;
|
||||
this.chunks = {};
|
||||
this.chunks_loaded = false;
|
||||
|
||||
this._state = {};
|
||||
|
||||
this.populate = once(this.populate);
|
||||
}
|
||||
|
||||
get style() {
|
||||
if ( ! this._style )
|
||||
this._style = new ManagedStyle;
|
||||
|
||||
return this._style;
|
||||
}
|
||||
|
||||
toggleHide(key, val) {
|
||||
const k = `hide--${key}`;
|
||||
if ( ! val ) {
|
||||
if ( this._style )
|
||||
this._style.delete(k);
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! has(this.rules, key) )
|
||||
throw new Error(`unknown rule "${key}" for toggleHide`);
|
||||
|
||||
this.style.set(k, `${this.rules[key]}{display:none !important}`);
|
||||
}
|
||||
|
||||
toggle(key, val) {
|
||||
if ( this._state[key] == val )
|
||||
return;
|
||||
|
||||
this._state[key] = val;
|
||||
this._apply(key);
|
||||
}
|
||||
|
||||
_apply(key) {
|
||||
const val = this._state[key];
|
||||
if ( ! val ) {
|
||||
if ( this._style )
|
||||
this._style.delete(key);
|
||||
return;
|
||||
}
|
||||
|
||||
if ( this.style.has(key) )
|
||||
return;
|
||||
|
||||
if ( ! this.chunks_loaded )
|
||||
return this.populate().then(() => this._apply(key));
|
||||
|
||||
if ( ! has(this.chunks, key) ) {
|
||||
this.log.warn(`Unknown chunk name "${key}" for toggle()`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.style.set(key, this.chunks[key]);
|
||||
}
|
||||
|
||||
set(key, val) { return this.style.set(key, val); }
|
||||
delete(key) { this._style && this._style.delete(key) }
|
||||
|
||||
setVariable(key, val, scope = 'body') {
|
||||
this.style.set(`var--${key}`, `${scope}{--ffz-${key}:${val};}`);
|
||||
}
|
||||
|
||||
deleteVariable(key) {
|
||||
if ( this._style )
|
||||
this._style.delete(`var--${key}`);
|
||||
}
|
||||
|
||||
async populate() {
|
||||
if ( this.chunks_loaded || ! this.loader )
|
||||
return;
|
||||
|
||||
const promises = [];
|
||||
for(const key of this.loader.keys()) {
|
||||
const k = key.slice(2, key.length - (key.endsWith('.scss') ? 5 : 4));
|
||||
promises.push(this.loader(key).then(data => this.chunks[k] = data.default));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
this.chunks_loaded = true;
|
||||
}
|
||||
|
||||
}
|
218
src/utilities/css-tweaks.ts
Normal file
218
src/utilities/css-tweaks.ts
Normal file
|
@ -0,0 +1,218 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// CSS Tweaks
|
||||
// Tweak some CSS
|
||||
// ============================================================================
|
||||
|
||||
import Module, { GenericModule } from 'utilities/module';
|
||||
import {ManagedStyle} from 'utilities/dom';
|
||||
import {has, once} from 'utilities/object';
|
||||
|
||||
/**
|
||||
* CSS Tweaks is a somewhat generic module for handling FrankerFaceZ's CSS
|
||||
* injection. It can load and unload specific blocks of CSS, as well as
|
||||
* automatically generate rules to hide specific elements based on their
|
||||
* selectors.
|
||||
*
|
||||
* Generally, this module is loaded by the current site module and is
|
||||
* available as `site.css_tweaks`.
|
||||
*
|
||||
* @noInheritDoc
|
||||
*/
|
||||
export default class CSSTweaks<TPath extends string = 'site.css_tweaks'> extends Module<TPath> {
|
||||
|
||||
/** Stores CSS rules used with the {@link toggleHide} method. */
|
||||
rules: Record<string, string> = {};
|
||||
|
||||
/** Stores CSS chunks loaded by the provided loader, and used with the {@link toggle} method. */
|
||||
chunks: Record<string, string> = {};
|
||||
|
||||
private _toggle_state: Record<string, boolean> = {};
|
||||
private _chunk_loader?: __WebpackModuleApi.RequireContext | null;
|
||||
private _chunks_loaded: boolean = false;
|
||||
private _style?: ManagedStyle;
|
||||
|
||||
/** @internal */
|
||||
constructor(name?: string, parent?: GenericModule) {
|
||||
super(name, parent);
|
||||
|
||||
this._loadChunks = once(this._loadChunks);
|
||||
}
|
||||
|
||||
/** Whether or not chunks have been loaded using the {@link loader}. */
|
||||
get chunks_loaded() {
|
||||
return this._chunks_loaded;
|
||||
}
|
||||
|
||||
/** An optional require context that can be used for loading arbitrary, named CSS chunks. */
|
||||
get loader() {
|
||||
return this._chunk_loader;
|
||||
}
|
||||
|
||||
set loader(value: __WebpackModuleApi.RequireContext | null | undefined) {
|
||||
if ( value === this._chunk_loader )
|
||||
return;
|
||||
|
||||
this._chunks_loaded = false;
|
||||
this._chunk_loader = value;
|
||||
}
|
||||
|
||||
/** The {@link ManagedStyle} instance used internally by this {@link CSSTweaks} instance. */
|
||||
get style() {
|
||||
if ( ! this._style )
|
||||
this._style = new ManagedStyle;
|
||||
|
||||
return this._style;
|
||||
}
|
||||
|
||||
/**
|
||||
* If {@link force} is not set, this toggles a specific element hiding rule,
|
||||
* enabling it if it was not previously enabled and vice versa. If force is
|
||||
* provided, it will either enable or disable the specific element hiding
|
||||
* rule based on the boolean value of {@link force}.
|
||||
*
|
||||
* @param key The key for the element hiding rule in {@link rules}.
|
||||
* @param force Optional. The desired state.
|
||||
* @throws If the provided {@link key} is not within {@link rules}.
|
||||
*/
|
||||
toggleHide(key: string, force?: boolean) {
|
||||
const k = `hide--${key}`;
|
||||
force = force != null ? !! force : ! this._toggle_state[k];
|
||||
if ( this._toggle_state[k] === force )
|
||||
return;
|
||||
|
||||
this._toggle_state[k] = force;
|
||||
|
||||
if ( ! force ) {
|
||||
if ( this._style )
|
||||
this._style.delete(k);
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! has(this.rules, key) )
|
||||
throw new Error(`unknown rule "${key}" for toggleHide`);
|
||||
|
||||
this.style.set(k, `${this.rules[key]}{display:none !important}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* If {@link force} is not set, this toggles a specific CSS chunk,
|
||||
* enabling it if it was not previously enabled and vice versa. If force is
|
||||
* provide, it will either enable or disable the specific CSS chunk based
|
||||
* on the boolean value of {@link force}.
|
||||
*
|
||||
* @param key The key for the CSS block in {@link chunks}.
|
||||
* @param force Optional. The desired state.
|
||||
*/
|
||||
toggle(key: string, force?: boolean) {
|
||||
force = force != null ? !! force : ! this._toggle_state[key];
|
||||
|
||||
if ( this._toggle_state[key] == force )
|
||||
return;
|
||||
|
||||
this._toggle_state[key] = force;
|
||||
this._apply(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actually perform the update for {@link toggle}. This method may
|
||||
* have to wait and then call itself again if the chunks have not yet
|
||||
* been loaded.
|
||||
*
|
||||
* @param key The key for the CSS block to toggle.
|
||||
*/
|
||||
private _apply(key: string): void {
|
||||
const val = this._toggle_state[key];
|
||||
if ( ! val ) {
|
||||
if ( this._style )
|
||||
this._style.delete(key);
|
||||
return;
|
||||
}
|
||||
|
||||
if ( this.style.has(key) )
|
||||
return;
|
||||
|
||||
else if ( ! this._chunks_loaded ) {
|
||||
this._loadChunks().then(() => this._apply(key));
|
||||
|
||||
} else if ( ! has(this.chunks, key) ) {
|
||||
this.log.warn(`Unknown chunk name "${key}" for toggle()`);
|
||||
|
||||
} else
|
||||
this.style.set(key, this.chunks[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Include an arbitrary string of CSS using this CSSTweak instance's
|
||||
* {@link ManagedStyle} instance. This will override any existing
|
||||
* CSS block using the same key.
|
||||
*
|
||||
* @see {@link ManagedStyle.set}
|
||||
* @param key The key for the CSS block.
|
||||
* @param value The text content of the CSS block.
|
||||
*/
|
||||
set(key: string, value: string) { return this.style.set(key, value); }
|
||||
|
||||
/**
|
||||
* Delete a CSS block from this CSSTweak instance's {@link ManagedStyle}
|
||||
* instance. This can be used to delete managed blocks including
|
||||
* those set by {@link toggle}, {@link toggleHide}, and
|
||||
* {@link setVariable} to please use caution.
|
||||
*
|
||||
* @see {@link ManagedStyle.delete}
|
||||
* @param key The key to be deleted.
|
||||
*/
|
||||
delete(key: string) { this._style && this._style.delete(key) }
|
||||
|
||||
/**
|
||||
* Set a CSS variable. The variable's name will be prefixed with `ffz-`
|
||||
* so, for example, if {@link key} is `"link-color"` then the resulting
|
||||
* CSS variable will be `--ffz-link-color` and can be used with
|
||||
* `var(--ffz-link-color)`.
|
||||
*
|
||||
* @param key The key for the variable.
|
||||
* @param value The value of the variable.
|
||||
* @param scope The scope this variable should be set on. Defaults
|
||||
* to `"body"`.
|
||||
*/
|
||||
setVariable(key: string, value: string, scope: string = 'body') {
|
||||
this.style.set(`var--${key}`, `${scope}{--ffz-${key}:${value};}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a CSS variable.
|
||||
* @param key The key for the variable
|
||||
*/
|
||||
deleteVariable(key: string) {
|
||||
if ( this._style )
|
||||
this._style.delete(`var--${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is used internally to load CSS chunks from the
|
||||
* provided {@link loader} instance.
|
||||
*/
|
||||
private async _loadChunks() {
|
||||
if ( this._chunks_loaded )
|
||||
return;
|
||||
|
||||
if ( ! this._chunk_loader ) {
|
||||
this._chunks_loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
for(const key of this._chunk_loader.keys()) {
|
||||
const k = key.slice(2, key.length - (key.endsWith('.scss') ? 5 : 4));
|
||||
promises.push(this._chunk_loader(key).then((data: any) => {
|
||||
if ( typeof data?.default === 'string' )
|
||||
this.chunks[k] = data.default;
|
||||
}));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
this._chunks_loaded = true;
|
||||
}
|
||||
|
||||
}
|
|
@ -16,8 +16,47 @@ export function getDialogNextZ() {
|
|||
}
|
||||
|
||||
|
||||
export class Dialog extends EventEmitter {
|
||||
constructor(element, options = {}) {
|
||||
export type DialogSelectors = {
|
||||
exclusive: string;
|
||||
maximized: string;
|
||||
normal: string;
|
||||
}
|
||||
|
||||
|
||||
export type DialogOptions = {
|
||||
selectors: DialogSelectors;
|
||||
maximized?: boolean;
|
||||
exclusive?: boolean;
|
||||
prepend?: boolean;
|
||||
}
|
||||
|
||||
|
||||
export type DialogEvents = {
|
||||
hide: [],
|
||||
show: [],
|
||||
error: [error: any],
|
||||
resize: []
|
||||
};
|
||||
|
||||
|
||||
export class Dialog extends EventEmitter<DialogEvents> {
|
||||
|
||||
static EXCLUSIVE = Site.DIALOG_EXCLUSIVE;
|
||||
static MAXIMIZED = Site.DIALOG_MAXIMIZED;
|
||||
static SELECTOR = Site.DIALOG_SELECTOR;
|
||||
|
||||
selectors: DialogSelectors;
|
||||
|
||||
factory?: () => Element;
|
||||
_element?: Element | null;
|
||||
|
||||
_visible: boolean;
|
||||
_maximized: boolean;
|
||||
_exclusive: boolean;
|
||||
prepend: boolean;
|
||||
|
||||
|
||||
constructor(element: Element | (() => Element), options: Partial<DialogOptions> = {}) {
|
||||
super();
|
||||
|
||||
this.selectors = {
|
||||
|
@ -128,7 +167,7 @@ export class Dialog extends EventEmitter {
|
|||
);
|
||||
}
|
||||
|
||||
toggleVisible(event) {
|
||||
toggleVisible(event?: MouseEvent) {
|
||||
if ( event && event.button !== 0 )
|
||||
return;
|
||||
|
||||
|
@ -174,14 +213,18 @@ export class Dialog extends EventEmitter {
|
|||
this._element = el;
|
||||
}
|
||||
|
||||
if ( ! this._element )
|
||||
return;
|
||||
|
||||
if ( this.prepend )
|
||||
container.insertBefore(this._element, container.firstChild);
|
||||
else
|
||||
container.appendChild(this._element);
|
||||
|
||||
this.emit('show');
|
||||
}
|
||||
|
||||
toggleSize(event) {
|
||||
toggleSize(event?: MouseEvent) {
|
||||
if ( ! this._visible || event && event.button !== 0 || ! this._element )
|
||||
return;
|
||||
|
||||
|
@ -212,10 +255,5 @@ export class Dialog extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
Dialog.EXCLUSIVE = Site.DIALOG_EXCLUSIVE;
|
||||
Dialog.MAXIMIZED = Site.DIALOG_MAXIMIZED;
|
||||
Dialog.SELECTOR = Site.DIALOG_SELECTOR;
|
||||
|
||||
|
||||
// This is necessary for add-ons for now.
|
||||
export default Dialog;
|
|
@ -1,377 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
import {has} from 'utilities/object';
|
||||
|
||||
const ATTRS = [
|
||||
'accept', 'accept-charset', 'accesskey', 'action', 'align', 'alt', 'async',
|
||||
'autocomplete', 'autofocus', 'autoplay', 'bgcolor', 'border', 'buffered',
|
||||
'challenge', 'charset', 'checked', 'cite', 'class', 'code', 'codebase',
|
||||
'color', 'cols', 'colspan', 'content', 'contenteditable', 'contextmenu',
|
||||
'controls', 'coords', 'crossorigin', 'data', 'data-*', 'datetime',
|
||||
'default', 'defer', 'dir', 'dirname', 'download', 'draggable',
|
||||
'dropzone', 'enctype', 'for', 'form', 'formaction', 'headers', 'height',
|
||||
'hidden', 'high', 'href', 'hreflang', 'http-equiv', 'icon', 'id',
|
||||
'integrity', 'ismap', 'itemprop', 'keytype', 'kind', 'label', 'lang',
|
||||
'language', 'list', 'loop', 'low', 'manifest', 'max', 'maxlength',
|
||||
'minlength', 'media', 'method', 'min', 'multiple', 'name',
|
||||
'novalidate', 'open', 'optimum', 'pattern', 'ping', 'placeholder', 'poster',
|
||||
'preload', 'radiogroup', 'readonly', 'rel', 'required', 'reversed', 'rows',
|
||||
'rowspan', 'sandbox', 'scope', 'scoped', 'seamless', 'selected', 'shape',
|
||||
'size', 'sizes', 'slot', 'span', 'spellcheck', 'src', 'srcdoc', 'srclang',
|
||||
'srcset', 'start', 'step', 'style', 'summary', 'tabindex', 'target',
|
||||
'title', 'type', 'usemap', 'value', 'width', 'wrap'
|
||||
];
|
||||
|
||||
const BOOLEAN_ATTRS = [
|
||||
'controls', 'autoplay', 'loop'
|
||||
];
|
||||
|
||||
|
||||
const range = document.createRange();
|
||||
|
||||
function camelCase(name) {
|
||||
return name.replace(/[-_]\w/g, m => m[1].toUpperCase());
|
||||
}
|
||||
|
||||
|
||||
export function on(obj, ...args) {
|
||||
return obj.addEventListener(...args);
|
||||
}
|
||||
|
||||
|
||||
export function off(obj, ...args) {
|
||||
return obj.removeEventListener(...args);
|
||||
}
|
||||
|
||||
|
||||
export function findReactFragment(frag, criteria, depth = 25, current = 0, visited = null) {
|
||||
if ( ! visited )
|
||||
visited = new Set;
|
||||
else if ( visited.has(frag) )
|
||||
return null;
|
||||
|
||||
if ( criteria(frag) )
|
||||
return frag;
|
||||
|
||||
if ( current >= depth )
|
||||
return null;
|
||||
|
||||
visited.add(frag);
|
||||
|
||||
if ( frag && frag.props && frag.props.children ) {
|
||||
if ( Array.isArray(frag.props.children) ) {
|
||||
for(const child of frag.props.children) {
|
||||
if ( ! child )
|
||||
continue;
|
||||
|
||||
if ( Array.isArray(child) ) {
|
||||
for(const f of child) {
|
||||
const out = findReactFragment(f, criteria, depth, current + 1, visited);
|
||||
if ( out )
|
||||
return out;
|
||||
}
|
||||
} else {
|
||||
const out = findReactFragment(child, criteria, depth, current + 1, visited);
|
||||
if ( out )
|
||||
return out;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const out = findReactFragment(frag.props.children, criteria, depth, current + 1, visited);
|
||||
if ( out )
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
export function createElement(tag, props, ...children) {
|
||||
const el = document.createElement(tag);
|
||||
|
||||
if ( children.length === 0)
|
||||
children = null;
|
||||
else if ( children.length === 1)
|
||||
children = children[0];
|
||||
|
||||
if ( typeof props === 'string' )
|
||||
el.className = props;
|
||||
else if ( props )
|
||||
for(const key in props)
|
||||
if ( has(props, key) ) {
|
||||
const lk = key.toLowerCase(),
|
||||
prop = props[key];
|
||||
|
||||
if ( lk === 'style' ) {
|
||||
if ( typeof prop === 'string' )
|
||||
el.style.cssText = prop;
|
||||
else
|
||||
for(const k in prop)
|
||||
if ( has(prop, k) ) {
|
||||
if ( has(el.style, k) || has(Object.getPrototypeOf(el.style), k) )
|
||||
el.style[k] = prop[k];
|
||||
else
|
||||
el.style.setProperty(k, prop[k]);
|
||||
}
|
||||
|
||||
} else if ( lk === 'dataset' ) {
|
||||
for(const k in prop)
|
||||
if ( has(prop, k) )
|
||||
el.dataset[camelCase(k)] = prop[k];
|
||||
|
||||
} else if ( key === 'dangerouslySetInnerHTML' ) {
|
||||
// React compatibility is cool. SeemsGood
|
||||
if ( prop && prop.__html )
|
||||
el.innerHTML = prop.__html;
|
||||
|
||||
} else if ( lk.startsWith('on') )
|
||||
el.addEventListener(lk.slice(2), prop);
|
||||
|
||||
else if ( lk.startsWith('data-') )
|
||||
el.dataset[camelCase(lk.slice(5))] = prop;
|
||||
|
||||
else if ( BOOLEAN_ATTRS.includes(lk) ) {
|
||||
if ( prop && prop !== 'false' )
|
||||
el.setAttribute(key, prop);
|
||||
|
||||
} else if ( lk.startsWith('aria-') || ATTRS.includes(lk) )
|
||||
el.setAttribute(key, prop);
|
||||
|
||||
else
|
||||
el[key] = prop;
|
||||
}
|
||||
|
||||
if ( children )
|
||||
setChildren(el, children);
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
export function setChildren(el, children, no_sanitize, no_empty) {
|
||||
if (children instanceof Node ) {
|
||||
if (! no_empty )
|
||||
el.innerHTML = '';
|
||||
|
||||
el.appendChild(children);
|
||||
|
||||
} else if ( Array.isArray(children) ) {
|
||||
if (! no_empty)
|
||||
el.innerHTML = '';
|
||||
|
||||
for(const child of children)
|
||||
if (child instanceof Node)
|
||||
el.appendChild(child);
|
||||
else if (Array.isArray(child))
|
||||
setChildren(el, child, no_sanitize, true);
|
||||
else if (child) {
|
||||
const val = typeof child === 'string' ? child : String(child);
|
||||
|
||||
el.appendChild(no_sanitize ?
|
||||
range.createContextualFragment(val) : document.createTextNode(val));
|
||||
}
|
||||
|
||||
} else if (children) {
|
||||
const val = typeof children === 'string' ? children : String(children);
|
||||
|
||||
el.appendChild(no_sanitize ?
|
||||
range.createContextualFragment(val) : document.createTextNode(val));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function findSharedParent(element, other, selector) {
|
||||
while(element) {
|
||||
if ( element.contains(other) )
|
||||
return true;
|
||||
|
||||
element = element.parentElement;
|
||||
if ( selector )
|
||||
element = element && element.closest(selector);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
export function openFile(contentType, multiple) {
|
||||
return new Promise(resolve => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = contentType;
|
||||
input.multiple = multiple;
|
||||
|
||||
let resolved = false;
|
||||
|
||||
// TODO: Investigate this causing issues
|
||||
// for some users.
|
||||
/*const focuser = () => {
|
||||
off(window, 'focus', focuser);
|
||||
setTimeout(() => {
|
||||
if ( ! resolved ) {
|
||||
resolved = true;
|
||||
resolve(multiple ? [] : null);
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
on(window, 'focus', focuser);*/
|
||||
|
||||
input.onchange = () => {
|
||||
//off(window, 'focus', focuser);
|
||||
if ( ! resolved ) {
|
||||
resolved = true;
|
||||
const files = Array.from(input.files);
|
||||
resolve(multiple ? files : files[0])
|
||||
}
|
||||
}
|
||||
|
||||
input.click();
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export function readFile(file, encoding = 'utf-8') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsText(file, encoding);
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = e => reject(e);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const el = createElement('span');
|
||||
|
||||
export function sanitize(text) {
|
||||
el.textContent = text;
|
||||
return el.innerHTML;
|
||||
}
|
||||
|
||||
|
||||
let last_id = 0;
|
||||
|
||||
export class ManagedStyle {
|
||||
constructor(id) {
|
||||
this.id = id || last_id++;
|
||||
|
||||
this._blocks = {};
|
||||
|
||||
this._style = createElement('style', {
|
||||
type: 'text/css',
|
||||
id: `ffz--managed-style--${this.id}`
|
||||
});
|
||||
|
||||
document.head.appendChild(this._style);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._style.remove();
|
||||
this._blocks = null;
|
||||
this._style = null;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this._blocks = {};
|
||||
this._style.innerHTML = '';
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const block = this._blocks[key];
|
||||
if ( block )
|
||||
return block.textContent;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
has(key) {
|
||||
return !! this._blocks[key];
|
||||
}
|
||||
|
||||
set(key, value, force) {
|
||||
const block = this._blocks[key];
|
||||
if ( block ) {
|
||||
if ( ! force && block.textContent === value )
|
||||
return;
|
||||
|
||||
block.textContent = value;
|
||||
} else
|
||||
this._style.appendChild(this._blocks[key] = document.createTextNode(value));
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
const block = this._blocks[key];
|
||||
if ( block ) {
|
||||
if ( this._style.contains(block) )
|
||||
this._style.removeChild(block);
|
||||
|
||||
this._blocks[key] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class ClickOutside {
|
||||
constructor(element, callback) {
|
||||
this.el = element;
|
||||
this.cb = callback;
|
||||
this._fn = this.handleClick.bind(this);
|
||||
document.documentElement.addEventListener('click', this._fn);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if ( this._fn )
|
||||
document.documentElement.removeEventListener('click', this._fn);
|
||||
|
||||
this.cb = this.el = this._fn = null;
|
||||
}
|
||||
|
||||
handleClick(e) {
|
||||
if ( this.el && ! this.el.contains(e.target) )
|
||||
this.cb(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TODO: Rewrite this method to not use raw HTML.
|
||||
|
||||
export function highlightJson(object, pretty = false, depth = 1, max_depth = 30) {
|
||||
let indent = '', indent_inner = '';
|
||||
if ( pretty ) {
|
||||
indent = ' '.repeat(depth - 1);
|
||||
indent_inner = ' '.repeat(depth);
|
||||
}
|
||||
|
||||
if ( depth > max_depth )
|
||||
return `<span class="ffz-ct--obj-literal"><nested></span>`;
|
||||
|
||||
if (object == null)
|
||||
return `<span class="ffz-ct--literal" depth="${depth}">null</span>`;
|
||||
|
||||
if ( typeof object === 'number' || typeof object === 'boolean' )
|
||||
return `<span class="ffz-ct--literal" depth="${depth}">${object}</span>`;
|
||||
|
||||
if ( typeof object === 'string' )
|
||||
return `<span class=ffz-ct--string depth="${depth}">"${sanitize(object)}"</span>`;
|
||||
|
||||
if ( Array.isArray(object) )
|
||||
return `<span class="ffz-ct--obj-open" depth="${depth}">[</span>`
|
||||
+ (object.length > 0 ? (
|
||||
object.map(x => (pretty ? `\n${indent_inner}` : '') + highlightJson(x, pretty, depth + 1, max_depth)).join(`<span class="ffz-ct--obj-sep" depth="${depth}">, </span>`)
|
||||
+ (pretty ? `\n${indent}` : '')
|
||||
) : '')
|
||||
+ `<span class="ffz-ct--obj-close" depth="${depth}">]</span>`;
|
||||
|
||||
const out = [];
|
||||
|
||||
for(const [key, val] of Object.entries(object)) {
|
||||
if ( out.length > 0 )
|
||||
out.push(`<span class="ffz-ct--obj-sep" depth="${depth}">, </span>`);
|
||||
|
||||
if ( pretty )
|
||||
out.push(`\n${indent_inner}`);
|
||||
out.push(`<span class="ffz-ct--obj-key" depth="${depth}">"${sanitize(key)}"</span><span class="ffz-ct--obj-key-sep" depth="${depth}">: </span>`);
|
||||
out.push(highlightJson(val, pretty, depth + 1, max_depth));
|
||||
}
|
||||
|
||||
return `<span class="ffz-ct--obj-open" depth="${depth}">{</span>${out.join('')}${out.length && pretty ? `\n${indent}` : ''}<span class="ffz-ct--obj-close" depth="${depth}">}</span>`;
|
||||
}
|
529
src/utilities/dom.ts
Normal file
529
src/utilities/dom.ts
Normal file
|
@ -0,0 +1,529 @@
|
|||
|
||||
import {has} from 'utilities/object';
|
||||
import type { DomFragment, OptionalArray } from './types';
|
||||
|
||||
const ATTRS = [
|
||||
'accept', 'accept-charset', 'accesskey', 'action', 'align', 'alt', 'async',
|
||||
'autocomplete', 'autofocus', 'autoplay', 'bgcolor', 'border', 'buffered',
|
||||
'challenge', 'charset', 'checked', 'cite', 'class', 'code', 'codebase',
|
||||
'color', 'cols', 'colspan', 'content', 'contenteditable', 'contextmenu',
|
||||
'controls', 'coords', 'crossorigin', 'data', 'data-*', 'datetime',
|
||||
'default', 'defer', 'dir', 'dirname', 'download', 'draggable',
|
||||
'dropzone', 'enctype', 'for', 'form', 'formaction', 'headers', 'height',
|
||||
'hidden', 'high', 'href', 'hreflang', 'http-equiv', 'icon', 'id',
|
||||
'integrity', 'ismap', 'itemprop', 'keytype', 'kind', 'label', 'lang',
|
||||
'language', 'list', 'loop', 'low', 'manifest', 'max', 'maxlength',
|
||||
'minlength', 'media', 'method', 'min', 'multiple', 'name',
|
||||
'novalidate', 'open', 'optimum', 'pattern', 'ping', 'placeholder', 'poster',
|
||||
'preload', 'radiogroup', 'readonly', 'rel', 'required', 'reversed', 'rows',
|
||||
'rowspan', 'sandbox', 'scope', 'scoped', 'seamless', 'selected', 'shape',
|
||||
'size', 'sizes', 'slot', 'span', 'spellcheck', 'src', 'srcdoc', 'srclang',
|
||||
'srcset', 'start', 'step', 'style', 'summary', 'tabindex', 'target',
|
||||
'title', 'type', 'usemap', 'value', 'width', 'wrap'
|
||||
];
|
||||
|
||||
const BOOLEAN_ATTRS = [
|
||||
'controls', 'autoplay', 'loop'
|
||||
];
|
||||
|
||||
|
||||
const range = document.createRange();
|
||||
|
||||
function camelCase(name: string) {
|
||||
return name.replace(/[-_]\w/g, m => m[1].toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple helper method for calling {@link EventTarget.addEventListener}
|
||||
* @internal
|
||||
*/
|
||||
export function on(obj: EventTarget, type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions) {
|
||||
return obj.addEventListener(type, listener, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple helper method for calling {@link EventTarget.removeEventListener}
|
||||
* @internal
|
||||
*/
|
||||
export function off(obj: EventTarget, type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions) {
|
||||
return obj.removeEventListener(type, listener, options);
|
||||
}
|
||||
|
||||
|
||||
// TODO: Better fake React types.
|
||||
|
||||
type SimpleNodeLike = {
|
||||
props?: {
|
||||
children?: SimpleNodeLike | SimpleNodeLike[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a React render tree, attempting to find a matching fragment.
|
||||
*
|
||||
* @param frag The initial point to start scanning the tree.
|
||||
* @param criteria A function that returns true if a fragment matches what
|
||||
* we want.
|
||||
* @param depth The maximum scanning depth, defaults to 25.
|
||||
* @param current For Internal Use. The current scanning depth.
|
||||
* @param visited For Internal Use. A Set of all visited fragments, to prevent
|
||||
* redundant checks.
|
||||
* @returns The matching fragment, or null if one was not found.
|
||||
*/
|
||||
export function findReactFragment<TNode extends SimpleNodeLike>(
|
||||
frag: TNode,
|
||||
criteria: (node: TNode) => boolean,
|
||||
depth: number = 25,
|
||||
current: number = 0,
|
||||
visited?: Set<any>
|
||||
): TNode | null {
|
||||
if ( ! visited )
|
||||
visited = new Set;
|
||||
else if ( visited.has(frag) )
|
||||
return null;
|
||||
|
||||
if ( criteria(frag) )
|
||||
return frag;
|
||||
|
||||
if ( current >= depth )
|
||||
return null;
|
||||
|
||||
visited.add(frag);
|
||||
|
||||
if ( frag?.props?.children ) {
|
||||
if ( Array.isArray(frag.props.children) ) {
|
||||
for(const child of frag.props.children) {
|
||||
if ( ! child )
|
||||
continue;
|
||||
|
||||
if ( Array.isArray(child) ) {
|
||||
for(const f of child) {
|
||||
const out = findReactFragment(f, criteria, depth, current + 1, visited);
|
||||
if ( out )
|
||||
return out;
|
||||
}
|
||||
} else {
|
||||
const out = findReactFragment(child as TNode, criteria, depth, current + 1, visited);
|
||||
if ( out )
|
||||
return out;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const out = findReactFragment(frag.props.children as TNode, criteria, depth, current + 1, visited);
|
||||
if ( out )
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// TODO: Stronger types.
|
||||
|
||||
/**
|
||||
* This method allows you to create native DOM fragments using the same calling
|
||||
* syntax as React's `React.createElement` method. Because of this, we can use
|
||||
* JSX for creating native DOM fragments, as well as rendering functions that are
|
||||
* interchangable inside of and outside of a React context.
|
||||
*
|
||||
* @example Create a span containing a figure to render an icon.
|
||||
* ```typescript
|
||||
* return createElement('span', {
|
||||
* className: 'ffz--icon-holder tw-mg-r-05'
|
||||
* }, createElement('figure', {
|
||||
* className: 'ffz-i-zreknarf'
|
||||
* }));
|
||||
* ```
|
||||
*
|
||||
* @example Doing the same, but with JSX
|
||||
* ```typescript
|
||||
* // When using JSX, we still need to make sure createElement is available in
|
||||
* // the current context. It can be provided as an argument to a function, or
|
||||
* // imported at the top level of the module.
|
||||
*
|
||||
* import { createElement } from 'utilities/dom';
|
||||
* // or... if you're working with add-ons...
|
||||
* const { createElement } = FrankerFaceZ.utilities.dom;
|
||||
*
|
||||
* return (<span class="ffz--icon-holder tw-mg-r-05">
|
||||
* <figure class="ffz-i-zreknarf" />
|
||||
* </span>);
|
||||
* ```
|
||||
*
|
||||
* @param tag The name of the tag to be created. Functions are not supported.
|
||||
* @param props The properties object.
|
||||
* @param children A child or list of children. These should be strings, `null`s,
|
||||
* or {@link Node}s that can be assigned as children of a {@link HTMLElement}.
|
||||
*/
|
||||
export function createElement<K extends keyof HTMLElementTagNameMap>(tag: K, props?: any, ...children: DomFragment[]): HTMLElementTagNameMap[K];
|
||||
export function createElement<K extends keyof HTMLElementDeprecatedTagNameMap>(tag: K, props?: any, ...children: DomFragment[]): HTMLElementDeprecatedTagNameMap[K];
|
||||
export function createElement(tag: string, props?: any, ...children: DomFragment[]): HTMLElement {
|
||||
const el = document.createElement(tag);
|
||||
|
||||
if ( children.length === 0)
|
||||
children = null as any;
|
||||
else if ( children.length === 1 )
|
||||
children = children[0] as any;
|
||||
|
||||
if ( typeof props === 'string' )
|
||||
el.className = props;
|
||||
else if ( props )
|
||||
for(const key in props)
|
||||
if ( has(props, key) ) {
|
||||
const lk = key.toLowerCase(),
|
||||
prop = props[key];
|
||||
|
||||
if ( lk === 'style' ) {
|
||||
if ( typeof prop === 'string' )
|
||||
el.style.cssText = prop;
|
||||
else
|
||||
for(const [key, val] of Object.entries(prop)) {
|
||||
if ( has(el.style, key) || has(Object.getPrototypeOf(el.style), key) )
|
||||
(el.style as any)[key] = val;
|
||||
else
|
||||
el.style.setProperty(key, prop[key]);
|
||||
}
|
||||
|
||||
} else if ( lk === 'dataset' ) {
|
||||
for(const k in prop)
|
||||
if ( has(prop, k) )
|
||||
el.dataset[camelCase(k)] = prop[k];
|
||||
|
||||
} else if ( key === 'dangerouslySetInnerHTML' ) {
|
||||
// React compatibility is cool. SeemsGood
|
||||
if ( prop && prop.__html )
|
||||
el.innerHTML = prop.__html;
|
||||
|
||||
} else if ( lk.startsWith('on') )
|
||||
el.addEventListener(lk.slice(2), prop);
|
||||
|
||||
else if ( lk.startsWith('data-') )
|
||||
el.dataset[camelCase(lk.slice(5))] = prop;
|
||||
|
||||
else if ( BOOLEAN_ATTRS.includes(lk) ) {
|
||||
if ( prop && prop !== 'false' )
|
||||
el.setAttribute(key, prop);
|
||||
|
||||
} else if ( lk.startsWith('aria-') || ATTRS.includes(lk) )
|
||||
el.setAttribute(key, prop);
|
||||
|
||||
else
|
||||
(el as any)[key] = prop;
|
||||
}
|
||||
|
||||
if ( children )
|
||||
setChildren(el, children);
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the children of a {@link HTMLElement}. This is also used internally by
|
||||
* the {@link createElement} method.
|
||||
*
|
||||
* @param element The element to set the children of.
|
||||
* @param children The children to add to the element.
|
||||
* @param no_sanitize If this is set to true, any provided string values will
|
||||
* be treated as HTML rather than text and will not be sanitized. This is
|
||||
* NOT recommended.
|
||||
* @param no_empty If this is set to true, the element's previous contents
|
||||
* will not be discarded before setting the new children.
|
||||
*/
|
||||
export function setChildren(
|
||||
element: HTMLElement,
|
||||
children: DomFragment,
|
||||
no_sanitize: boolean = false,
|
||||
no_empty: boolean = false
|
||||
) {
|
||||
if (children instanceof Node ) {
|
||||
if (! no_empty )
|
||||
element.innerHTML = '';
|
||||
|
||||
element.appendChild(children);
|
||||
|
||||
} else if ( Array.isArray(children) ) {
|
||||
if (! no_empty)
|
||||
element.innerHTML = '';
|
||||
|
||||
for(const child of children)
|
||||
if (child instanceof Node)
|
||||
element.appendChild(child);
|
||||
else if (Array.isArray(child))
|
||||
setChildren(element, child, no_sanitize, true);
|
||||
else if (child) {
|
||||
const val = typeof child === 'string' ? child : String(child);
|
||||
|
||||
element.appendChild(no_sanitize ?
|
||||
range.createContextualFragment(val) : document.createTextNode(val));
|
||||
}
|
||||
|
||||
} else if (children) {
|
||||
const val = typeof children === 'string' ? children : String(children);
|
||||
|
||||
element.appendChild(no_sanitize ?
|
||||
range.createContextualFragment(val) : document.createTextNode(val));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the two provided Nodes share a parent.
|
||||
*
|
||||
* @param element The first node.
|
||||
* @param other The second node.
|
||||
* @param selector A CSS selector to use. If this is set, only consider parents
|
||||
* that match the selector.
|
||||
*/
|
||||
export function hasSharedParent(element: Node | null, other: Node, selector?: string) {
|
||||
while(element) {
|
||||
if ( element.contains(other) )
|
||||
return true;
|
||||
|
||||
element = element.parentElement;
|
||||
if ( selector )
|
||||
element = element instanceof Element
|
||||
? element.closest(selector)
|
||||
: null;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Display an Open File dialog to the user and return the selected
|
||||
* value. This may never return depending on the user agent's
|
||||
* behavior and should be used sparingly and never in a heavy
|
||||
* context to avoid excess memory usage.
|
||||
*
|
||||
* @param contentType The content type to filter by when selecting files.
|
||||
* @param multiple Whether or not multiple files should be returned.
|
||||
* @returns A file or list of files.
|
||||
*/
|
||||
export function openFile(contentType: string, multiple: boolean) {
|
||||
return new Promise<File | File[] | null>(resolve => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = contentType;
|
||||
input.multiple = multiple;
|
||||
|
||||
let resolved = false;
|
||||
|
||||
// TODO: Investigate this causing issues
|
||||
// for some users.
|
||||
/*const focuser = () => {
|
||||
off(window, 'focus', focuser);
|
||||
setTimeout(() => {
|
||||
if ( ! resolved ) {
|
||||
resolved = true;
|
||||
resolve(multiple ? [] : null);
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
on(window, 'focus', focuser);*/
|
||||
|
||||
input.onchange = () => {
|
||||
//off(window, 'focus', focuser);
|
||||
if ( ! resolved ) {
|
||||
resolved = true;
|
||||
const files = Array.from(input.files ?? []);
|
||||
resolve(multiple ? files : files[0])
|
||||
}
|
||||
}
|
||||
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the contents of a {@link File} asynchronously.
|
||||
*
|
||||
* @param file The file to read
|
||||
* @param encoding The character encoding to use. Defaults to UTF-8.
|
||||
*/
|
||||
export function readFile(file: Blob, encoding = 'utf-8') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsText(file, encoding);
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = e => reject(e);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const el = document.createElement('span');
|
||||
|
||||
/**
|
||||
* Sanitize a string, replacing all special HTML characters
|
||||
* with entities.
|
||||
*
|
||||
* Internally, this uses the browser's native DOM library
|
||||
* by setting `textContent` on an Element and returning its
|
||||
* `innerHTML`.
|
||||
*
|
||||
* @param text The text to sanitize.
|
||||
*/
|
||||
export function sanitize(text: string) {
|
||||
el.textContent = text;
|
||||
const out = el.innerHTML;
|
||||
// Ensure we're not keeping large strings in memory.
|
||||
el.textContent = '';
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
let last_id = 0;
|
||||
|
||||
export class ManagedStyle {
|
||||
id: number;
|
||||
|
||||
private _blocks: Record<string, Text | null>;
|
||||
private _style: HTMLStyleElement;
|
||||
|
||||
constructor(id?: number) {
|
||||
this.id = id || last_id++;
|
||||
|
||||
this._blocks = {};
|
||||
|
||||
this._style = createElement('style', {
|
||||
type: 'text/css',
|
||||
id: `ffz--managed-style--${this.id}`
|
||||
});
|
||||
|
||||
document.head.appendChild(this._style);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if ( this._style )
|
||||
this._style.remove();
|
||||
|
||||
// This is lazy typing, but I don't really care.
|
||||
// Rather do this than put checks in every other bit of code.
|
||||
this._blocks = null as any;
|
||||
this._style = null as any;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this._blocks = {};
|
||||
this._style.innerHTML = '';
|
||||
}
|
||||
|
||||
get(key: string) {
|
||||
const block = this._blocks[key];
|
||||
if ( block )
|
||||
return block.textContent;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
has(key: string) {
|
||||
return !! this._blocks[key];
|
||||
}
|
||||
|
||||
set(key: string, value: string, force: boolean = false) {
|
||||
const block = this._blocks[key];
|
||||
if ( block ) {
|
||||
if ( ! force && block.textContent === value )
|
||||
return;
|
||||
|
||||
block.textContent = value;
|
||||
} else
|
||||
this._style.appendChild(this._blocks[key] = document.createTextNode(value));
|
||||
}
|
||||
|
||||
delete(key: string) {
|
||||
const block = this._blocks[key];
|
||||
if ( block ) {
|
||||
if ( this._style.contains(block) )
|
||||
this._style.removeChild(block);
|
||||
|
||||
this._blocks[key] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class ClickOutside<TFunc extends (event: MouseEvent) => void> {
|
||||
|
||||
el: HTMLElement | null;
|
||||
cb: TFunc | null;
|
||||
_fn: ((event: MouseEvent) => void) | null;
|
||||
|
||||
constructor(element: HTMLElement, callback: TFunc) {
|
||||
this.el = element;
|
||||
this.cb = callback;
|
||||
|
||||
this._fn = this.handleClick.bind(this);
|
||||
document.documentElement.addEventListener('click', this.handleClick);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if ( this._fn )
|
||||
document.documentElement.removeEventListener('click', this._fn);
|
||||
|
||||
this.cb = this.el = this._fn = null;
|
||||
}
|
||||
|
||||
handleClick(event: MouseEvent) {
|
||||
if ( this.cb && this.el && ! this.el.contains(event.target as Node) )
|
||||
this.cb(event);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Take an object that can be expressed as JSON and return a string of HTML
|
||||
* that can be used to display the object with highlighting and formatting.
|
||||
*
|
||||
* TODO: Rewrite this method to not use raw HTML.
|
||||
*
|
||||
* @deprecated You should not depend on this method, as its signature is expected to change.
|
||||
*
|
||||
* @param object The object to be formatted
|
||||
* @param pretty Whether or not to use indentation when rendering the object
|
||||
* @param depth The current rendering depth
|
||||
* @param max_depth The maximum depth to render, defaults to 30.
|
||||
* @returns A string of HTML.
|
||||
*/
|
||||
export function highlightJson(object: any, pretty = false, depth = 1, max_depth = 30): string {
|
||||
let indent = '', indent_inner = '';
|
||||
if ( pretty ) {
|
||||
indent = ' '.repeat(depth - 1);
|
||||
indent_inner = ' '.repeat(depth);
|
||||
}
|
||||
|
||||
if ( depth > max_depth )
|
||||
return `<span class="ffz-ct--obj-literal"><nested></span>`;
|
||||
|
||||
if (object == null)
|
||||
return `<span class="ffz-ct--literal" depth="${depth}">null</span>`;
|
||||
|
||||
if ( typeof object === 'number' || typeof object === 'boolean' )
|
||||
return `<span class="ffz-ct--literal" depth="${depth}">${object}</span>`;
|
||||
|
||||
if ( typeof object === 'string' )
|
||||
return `<span class=ffz-ct--string depth="${depth}">"${sanitize(object)}"</span>`;
|
||||
|
||||
if ( Array.isArray(object) )
|
||||
return `<span class="ffz-ct--obj-open" depth="${depth}">[</span>`
|
||||
+ (object.length > 0 ? (
|
||||
object.map(x => (pretty ? `\n${indent_inner}` : '') + highlightJson(x, pretty, depth + 1, max_depth)).join(`<span class="ffz-ct--obj-sep" depth="${depth}">, </span>`)
|
||||
+ (pretty ? `\n${indent}` : '')
|
||||
) : '')
|
||||
+ `<span class="ffz-ct--obj-close" depth="${depth}">]</span>`;
|
||||
|
||||
const out = [];
|
||||
|
||||
for(const [key, val] of Object.entries(object)) {
|
||||
if ( out.length > 0 )
|
||||
out.push(`<span class="ffz-ct--obj-sep" depth="${depth}">, </span>`);
|
||||
|
||||
if ( pretty )
|
||||
out.push(`\n${indent_inner}`);
|
||||
out.push(`<span class="ffz-ct--obj-key" depth="${depth}">"${sanitize(key)}"</span><span class="ffz-ct--obj-key-sep" depth="${depth}">: </span>`);
|
||||
out.push(highlightJson(val, pretty, depth + 1, max_depth));
|
||||
}
|
||||
|
||||
return `<span class="ffz-ct--obj-open" depth="${depth}">{</span>${out.join('')}${out.length && pretty ? `\n${indent}` : ''}<span class="ffz-ct--obj-close" depth="${depth}">}</span>`;
|
||||
}
|
|
@ -1,484 +0,0 @@
|
|||
// ============================================================================
|
||||
// EventEmitter
|
||||
// Homegrown for that lean feeling.
|
||||
// ============================================================================
|
||||
|
||||
import {has} from 'utilities/object';
|
||||
|
||||
const Detach = Symbol('Detach');
|
||||
const StopPropagation = Symbol('StopPropagation');
|
||||
|
||||
const SNAKE_CAPS = /([a-z])([A-Z])/g,
|
||||
SNAKE_SPACE = /[ \t\W]/g,
|
||||
SNAKE_TRIM = /^_+|_+$/g;
|
||||
|
||||
String.prototype.toSlug = function(separator = '-') {
|
||||
let result = this;
|
||||
if (result.normalize)
|
||||
result = result.normalize('NFD');
|
||||
|
||||
return result
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9 ]/g, '')
|
||||
.replace(/\s+/g, separator);
|
||||
}
|
||||
|
||||
String.prototype.toSnakeCase = function() {
|
||||
let result = this;
|
||||
if (result.normalize)
|
||||
result = result.normalize('NFD');
|
||||
|
||||
return result
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.replace(SNAKE_CAPS, '$1_$2')
|
||||
.replace(SNAKE_SPACE, '_')
|
||||
.replace(SNAKE_TRIM, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
|
||||
export class EventEmitter {
|
||||
constructor() {
|
||||
this.__listeners = {};
|
||||
this.__running = new Set;
|
||||
this.__dead_events = 0;
|
||||
}
|
||||
|
||||
__cleanListeners() {
|
||||
if ( ! this.__dead_events )
|
||||
return;
|
||||
|
||||
const nl = {}, ol = this.__listeners;
|
||||
for(const key in ol)
|
||||
if ( has(ol, key) ) {
|
||||
const val = ol[key];
|
||||
if ( val )
|
||||
nl[key] = val;
|
||||
}
|
||||
|
||||
this.__listeners = nl;
|
||||
this.__dead_events = 0;
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Public Methods
|
||||
// ========================================================================
|
||||
|
||||
on(event, fn, ctx) {
|
||||
if ( typeof fn !== 'function' )
|
||||
throw new TypeError('fn must be a function');
|
||||
|
||||
(this.__listeners[event] = this.__listeners[event] || []).push([fn, ctx, false])
|
||||
}
|
||||
|
||||
prependOn(event, fn, ctx) {
|
||||
if ( typeof fn !== 'function' )
|
||||
throw new TypeError('fn must be a function');
|
||||
|
||||
(this.__listeners[event] = this.__listeners[event] || []).unshift([fn, ctx, false])
|
||||
}
|
||||
|
||||
once(event, fn, ctx) { return this.many(event, 1, fn, ctx) }
|
||||
prependOnce(event, fn, ctx) { return this.prependMany(event, 1, fn, ctx) }
|
||||
|
||||
many(event, ttl, fn, ctx) {
|
||||
if ( typeof fn !== 'function' )
|
||||
throw new TypeError('fn must be a function');
|
||||
|
||||
if ( typeof ttl !== 'number' || isNaN(ttl) || ! isFinite(ttl) || ttl < 1 )
|
||||
throw new TypeError('ttl must be a positive, finite number');
|
||||
|
||||
(this.__listeners[event] = this.__listeners[event] || []).push([fn, ctx, ttl]);
|
||||
}
|
||||
|
||||
prependMany(event, ttl, fn, ctx) {
|
||||
if ( typeof fn !== 'function' )
|
||||
throw new TypeError('fn must be a function');
|
||||
|
||||
if ( typeof ttl !== 'number' || isNaN(ttl) || ! isFinite(ttl) || ttl < 1 )
|
||||
throw new TypeError('ttl must be a positive, finite number');
|
||||
|
||||
(this.__listeners[event] = this.__listeners[event] || []).unshift([fn, ctx, ttl]);
|
||||
}
|
||||
|
||||
waitFor(event) {
|
||||
return new Promise(resolve => {
|
||||
(this.__listeners[event] = this.__listeners[event] || []).push([resolve, null, 1]);
|
||||
})
|
||||
}
|
||||
|
||||
off(event, fn, ctx) {
|
||||
if ( this.__running.has(event) )
|
||||
throw new Error(`concurrent modification: tried removing event listener while event is running`);
|
||||
|
||||
let list = this.__listeners[event];
|
||||
if ( ! list )
|
||||
return;
|
||||
|
||||
if ( ! fn )
|
||||
list = null;
|
||||
else {
|
||||
list = list.filter(([f, c]) => !(f === fn && (!ctx || ctx === c)));
|
||||
if ( ! list.length )
|
||||
list = null;
|
||||
}
|
||||
|
||||
this.__listeners[event] = list;
|
||||
if ( ! list )
|
||||
this.__dead_events++;
|
||||
}
|
||||
|
||||
offContext(event, ctx) {
|
||||
if ( event == null ) {
|
||||
for(const evt in Object.keys(this.__listeners)) {
|
||||
if ( ! this.__running.has(evt) )
|
||||
this.offContext(evt, ctx);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ( this.__running.has(event) )
|
||||
throw new Error(`concurrent modification: tried removing event listener while event is running`);
|
||||
|
||||
let list = this.__listeners[event];
|
||||
if ( ! list )
|
||||
return;
|
||||
|
||||
if ( ! fn )
|
||||
list = null;
|
||||
else {
|
||||
list = list.filter(x => x && x[1] !== ctx);
|
||||
if ( ! list.length )
|
||||
list = null;
|
||||
}
|
||||
|
||||
this.__listeners[event] = list;
|
||||
if ( ! list )
|
||||
this.__dead_events++;
|
||||
}
|
||||
|
||||
events() {
|
||||
this.__cleanListeners();
|
||||
return Object.keys(this.__listeners);
|
||||
}
|
||||
|
||||
listeners(event) {
|
||||
const list = this.__listeners[event];
|
||||
return list ? Array.from(list) : [];
|
||||
}
|
||||
|
||||
hasListeners(event) {
|
||||
return !! this.__listeners[event]
|
||||
}
|
||||
|
||||
emitUnsafe(event, ...args) {
|
||||
let list = this.__listeners[event];
|
||||
if ( ! list )
|
||||
return;
|
||||
|
||||
if ( this.__running.has(event) )
|
||||
throw new Error(`concurrent access: tried to emit event while event is running`);
|
||||
|
||||
// Track removals separately to make iteration over the event list
|
||||
// much, much simpler.
|
||||
const removed = new Set;
|
||||
|
||||
// Set the current list of listeners to null because we don't want
|
||||
// to enter some kind of loop if a new listener is added as the result
|
||||
// of an existing listener.
|
||||
this.__listeners[event] = null;
|
||||
this.__running.add(event);
|
||||
|
||||
for(const item of list) {
|
||||
const [fn, ctx, ttl] = item,
|
||||
ret = fn.apply(ctx, args);
|
||||
|
||||
if ( ret === Detach )
|
||||
removed.add(item);
|
||||
else if ( ttl !== false ) {
|
||||
if ( ttl <= 1 )
|
||||
removed.add(item);
|
||||
else
|
||||
item[2] = ttl - 1;
|
||||
}
|
||||
|
||||
if ( (args[0] instanceof FFZEvent && args[0].propagationStopped) || ret === StopPropagation )
|
||||
break;
|
||||
}
|
||||
|
||||
// Remove any dead listeners from the list.
|
||||
if ( removed.size ) {
|
||||
for(const item of removed) {
|
||||
const idx = list.indexOf(item);
|
||||
if ( idx !== -1 )
|
||||
list.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Were more listeners added while we were running? Just combine
|
||||
// the two lists if so.
|
||||
if ( this.__listeners[event] )
|
||||
list = list.concat(this.__listeners[event]);
|
||||
|
||||
// If we have items, store the list back. Otherwise, mark that we
|
||||
// have a dead listener.
|
||||
if ( list.length )
|
||||
this.__listeners[event] = list;
|
||||
else {
|
||||
this.__listeners[event] = null;
|
||||
this.__dead_events++;
|
||||
}
|
||||
|
||||
this.__running.delete(event);
|
||||
}
|
||||
|
||||
emit(event, ...args) {
|
||||
let list = this.__listeners[event];
|
||||
if ( ! list )
|
||||
return;
|
||||
|
||||
if ( this.__running.has(event) )
|
||||
throw new Error(`concurrent access: tried to emit event while event is running`);
|
||||
|
||||
// Track removals separately to make iteration over the event list
|
||||
// much, much simpler.
|
||||
const removed = new Set;
|
||||
|
||||
// Set the current list of listeners to null because we don't want
|
||||
// to enter some kind of loop if a new listener is added as the result
|
||||
// of an existing listener.
|
||||
this.__listeners[event] = null;
|
||||
this.__running.add(event);
|
||||
|
||||
for(const item of list) {
|
||||
const [fn, ctx, ttl] = item;
|
||||
let ret;
|
||||
try {
|
||||
ret = fn.apply(ctx, args);
|
||||
} catch(err) {
|
||||
if ( this.log ) {
|
||||
this.log.capture(err, {tags: {event}, extra:{args}});
|
||||
this.log.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
if ( ret === Detach )
|
||||
removed.add(item);
|
||||
else if ( ttl !== false ) {
|
||||
if ( ttl <= 1 )
|
||||
removed.add(item);
|
||||
else
|
||||
item[2] = ttl - 1;
|
||||
}
|
||||
|
||||
// Automatically wait for a promise, if the return value is a promise
|
||||
// and we're dealing with a waitable event.
|
||||
if ( ret instanceof Promise ) {
|
||||
if ( (args[0] instanceof FFZWaitableEvent) )
|
||||
args[0].waitFor(ret);
|
||||
/*else if ( this.log )
|
||||
this.log.debug(`handler for event "${event}" returned a Promise but the event is not an FFZWaitableEvent`);*/
|
||||
}
|
||||
|
||||
if ( (args[0] instanceof FFZEvent && args[0].propagationStopped) || ret === StopPropagation )
|
||||
break;
|
||||
}
|
||||
|
||||
// Remove any dead listeners from the list.
|
||||
if ( removed.size ) {
|
||||
for(const item of removed) {
|
||||
const idx = list.indexOf(item);
|
||||
if ( idx !== -1 )
|
||||
list.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Were more listeners added while we were running? Just combine
|
||||
// the two lists if so.
|
||||
if ( this.__listeners[event] )
|
||||
list = list.concat(this.__listeners[event]);
|
||||
|
||||
// If we have items, store the list back. Otherwise, mark that we
|
||||
// have a dead listener.
|
||||
if ( list.length )
|
||||
this.__listeners[event] = list;
|
||||
else {
|
||||
this.__listeners[event] = null;
|
||||
this.__dead_events++;
|
||||
}
|
||||
|
||||
this.__running.delete(event);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
EventEmitter.Detach = Detach;
|
||||
EventEmitter.StopPropagation = StopPropagation;
|
||||
|
||||
|
||||
export class FFZEvent {
|
||||
constructor(data) {
|
||||
this.defaultPrevented = false;
|
||||
this.propagationStopped = false;
|
||||
|
||||
Object.assign(this, data);
|
||||
}
|
||||
|
||||
_reset() {
|
||||
this.defaultPrevented = false;
|
||||
this.propagationStopped = false;
|
||||
}
|
||||
|
||||
stopPropagation() {
|
||||
this.propagationStopped = true;
|
||||
}
|
||||
|
||||
preventDefault() {
|
||||
this.defaultPrevented = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class FFZWaitableEvent extends FFZEvent {
|
||||
|
||||
_wait() {
|
||||
if ( this.__waiter )
|
||||
return this.__waiter;
|
||||
|
||||
if ( ! this.__promises )
|
||||
return;
|
||||
|
||||
const promises = this.__promises;
|
||||
this.__promises = null;
|
||||
|
||||
return this.__waiter = Promise.all(promises).finally(() => {
|
||||
this.__waiter = null;
|
||||
return this._wait();
|
||||
});
|
||||
}
|
||||
|
||||
_reset() {
|
||||
super._reset();
|
||||
this.__waiter = null;
|
||||
this.__promises = null;
|
||||
}
|
||||
|
||||
waitFor(promise) {
|
||||
if ( ! this.__promises )
|
||||
this.__promises = [promise];
|
||||
else
|
||||
this.__promises.push(promise);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
export class HierarchicalEventEmitter extends EventEmitter {
|
||||
constructor(name, parent) {
|
||||
super();
|
||||
|
||||
this.name = name || (this.constructor.name || '').toSnakeCase();
|
||||
this.parent = parent;
|
||||
|
||||
if ( parent ) {
|
||||
this.root = parent.root;
|
||||
this.__listeners = parent.__listeners;
|
||||
this.__path = name && parent.__path ? `${parent.__path}.${name}` : name;
|
||||
|
||||
} else {
|
||||
this.root = this;
|
||||
this.__path = undefined;
|
||||
}
|
||||
|
||||
this.__path_parts = this.__path ? this.__path.split('.') : [];
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Public Properties
|
||||
// ========================================================================
|
||||
|
||||
get path() {
|
||||
return this.__path;
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Public Methods
|
||||
// ========================================================================
|
||||
|
||||
abs_path(path) {
|
||||
if ( typeof path !== 'string' || ! path.length )
|
||||
throw new TypeError('path must be a non-empty string');
|
||||
|
||||
let i = 0, chr;
|
||||
const parts = this.__path_parts,
|
||||
depth = parts.length;
|
||||
|
||||
do {
|
||||
chr = path.charAt(i);
|
||||
if ( path.charAt(i) === '.' ) {
|
||||
if ( i > depth )
|
||||
throw new Error('invalid path: reached top of stack');
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
} while ( ++i < path.length );
|
||||
|
||||
const event = chr === ':';
|
||||
if ( i === 0 )
|
||||
return event && this.__path ? `${this.__path}${path}` : path;
|
||||
|
||||
const prefix = parts.slice(0, depth - (i-1)).join('.'),
|
||||
remain = path.slice(i);
|
||||
|
||||
if ( ! prefix.length )
|
||||
return remain;
|
||||
|
||||
else if ( ! remain.length )
|
||||
return prefix;
|
||||
|
||||
else if ( event )
|
||||
return prefix + remain;
|
||||
|
||||
return `${prefix}.${remain}`;
|
||||
}
|
||||
|
||||
|
||||
on(event, fn, ctx) { return super.on(this.abs_path(event), fn, ctx) }
|
||||
prependOn(event, fn, ctx) { return super.prependOn(this.abs_path(event), fn, ctx) }
|
||||
|
||||
once(event, fn, ctx) { return super.once(this.abs_path(event), fn, ctx) }
|
||||
prependOnce(event, fn, ctx) { return super.prependOnce(this.abs_path(event), fn, ctx) }
|
||||
|
||||
many(event, ttl, fn, ctx) { return super.many(this.abs_path(event), ttl, fn, ctx) }
|
||||
prependMany(event, ttl, fn, ctx) { return super.prependMany(this.abs_path(event), ttl, fn, ctx) }
|
||||
|
||||
waitFor(event) { return super.waitFor(this.abs_path(event)) }
|
||||
off(event, fn, ctx) { return super.off(this.abs_path(event), fn, ctx) }
|
||||
listeners(event) { return super.listeners(this.abs_path(event)) }
|
||||
hasListeners(event) { return super.hasListeners(this.abs_path(event)) }
|
||||
|
||||
emit(event, ...args) { return super.emit(this.abs_path(event), ...args) }
|
||||
emitUnsafe(event, ...args) { return super.emitUnsafe(this.abs_path(event), ...args) }
|
||||
|
||||
events(include_children) {
|
||||
this.__cleanListeners();
|
||||
const keys = Object.keys(this.__listeners),
|
||||
path = this.__path || '',
|
||||
len = path.length;
|
||||
|
||||
return keys.filter(x => {
|
||||
const y = x.charAt(len);
|
||||
return x.startsWith(path) && (y === '' || (include_children && y === '.') || y === ':');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default HierarchicalEventEmitter;
|
1009
src/utilities/events.ts
Normal file
1009
src/utilities/events.ts
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,7 +1,15 @@
|
|||
'use strict';
|
||||
// This is a generated file. To update it, please run: npm run font:update
|
||||
// This is a generated file. To update it, please run: pnpm font:update
|
||||
/* eslint quotes: 0 */
|
||||
|
||||
/**
|
||||
* A list of all valid icon names in the FrankerFaceZ icon font. These
|
||||
* icons can be used by adding a class to a DOM element with the name
|
||||
* `ffz-i-${name}` where `${name}` is a name from this list.
|
||||
*
|
||||
* For example, to use the `threads` icon, you'd add the class
|
||||
* `ffz-i-threads` to your element.
|
||||
*/
|
||||
export default [
|
||||
"window-minimize",
|
||||
"window-maximize",
|
||||
|
@ -114,4 +122,4 @@ export default [
|
|||
"doc-text",
|
||||
"fx",
|
||||
"artist"
|
||||
];
|
||||
] as const;
|
|
@ -1,14 +1,55 @@
|
|||
'use strict';
|
||||
|
||||
import type { OptionalPromise, OptionallyCallable } from "./types";
|
||||
|
||||
// ============================================================================
|
||||
// Advanced Filter System
|
||||
// ============================================================================
|
||||
|
||||
export function createTester(rules, filter_types, inverted = false, or = false, rebuild) {
|
||||
type FilterMethod<TContext = unknown> = (ctx: TContext) => boolean;
|
||||
|
||||
export type FilterType<TConfig, TContext> = {
|
||||
createTest: (config: TConfig, filter_types: FilterTypeMap<TContext>, rebuild?: () => void) => FilterMethod<TContext>
|
||||
|
||||
default: OptionallyCallable<[], TConfig>;
|
||||
|
||||
// Editor Configuration
|
||||
editor?: OptionallyCallable<[], OptionalPromise<any>>;
|
||||
|
||||
title: string;
|
||||
i18n?: string | null;
|
||||
tall?: boolean;
|
||||
|
||||
maxRules?: number;
|
||||
childRules?: boolean;
|
||||
|
||||
|
||||
}
|
||||
|
||||
type FilterTypeMap<TContext> = {
|
||||
[key: string]: FilterType<any, TContext>;
|
||||
};
|
||||
|
||||
|
||||
export type FilterData = {
|
||||
id?: string;
|
||||
type: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
|
||||
export function createTester<TContext, Types extends FilterTypeMap<TContext>>(
|
||||
rules: FilterData[] | null | undefined,
|
||||
filter_types: Types,
|
||||
inverted: boolean = false,
|
||||
or: boolean = false,
|
||||
rebuild?: () => void
|
||||
): (ctx: TContext) => boolean {
|
||||
|
||||
if ( ! Array.isArray(rules) || ! filter_types )
|
||||
return inverted ? () => false : () => true;
|
||||
|
||||
const tests = [],
|
||||
const tests: FilterMethod<TContext>[] = [],
|
||||
names = [];
|
||||
|
||||
let i = 0;
|
||||
|
@ -42,7 +83,7 @@ export function createTester(rules, filter_types, inverted = false, or = false,
|
|||
return inverted ? () => false : () => true;
|
||||
|
||||
if ( tests.length === 1 )
|
||||
return inverted ? ctx => ! tests[0](ctx) : tests[0];
|
||||
return inverted ? (ctx: TContext) => ! tests[0](ctx) : tests[0];
|
||||
|
||||
return new Function(...names, 'ctx',
|
||||
`return ${inverted ? `!(` : ''}${names.map(name => `${name}(ctx)`).join(or ? ' || ' : ' && ')}${inverted ? ')' : ''};`
|
|
@ -6,6 +6,11 @@ import {createElement} from 'utilities/dom';
|
|||
// Font Awesome Data
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* A map of aliases for FontAwesome icons. This is used to allow for slightly
|
||||
* less annoying search behavior in UIs. These names are all raw icon names
|
||||
* and not suitable for direct use.
|
||||
*/
|
||||
export const ALIASES = {
|
||||
'ban': ['ban', 'block'],
|
||||
'ok': ['ok', 'unban', 'untimeout'],
|
||||
|
@ -102,8 +107,12 @@ export const ALIASES = {
|
|||
'bath': ['bathtub','s15','bath'],
|
||||
'window-close': ['times-rectangle','window-close'],
|
||||
'window-close-o': ['times-rectangle-o','window-close-o']
|
||||
};
|
||||
} as Record<string, string[]>; // const;
|
||||
|
||||
/**
|
||||
* A list of all available FontAwesome icon names, for use in populating UIs.
|
||||
* These are raw names, and not suitable for direct use.
|
||||
*/
|
||||
export const ICONS = [
|
||||
'glass','music','search','envelope-o','heart','star','star-o','user',
|
||||
'film','th-large','th','th-list','check','times','search-plus',
|
||||
|
@ -217,12 +226,17 @@ export const ICONS = [
|
|||
'thermometer-quarter','thermometer-empty','shower','bath','podcast',
|
||||
'window-maximize','window-minimize','window-restore','window-close',
|
||||
'window-close-o','bandcamp','grav','etsy','imdb','ravelry','eercast',
|
||||
'microchip','snowflake-o','superpowers','wpexplorer','meetup'];
|
||||
'microchip','snowflake-o','superpowers','wpexplorer','meetup'
|
||||
] as string[]; // const;
|
||||
|
||||
let loaded = false;
|
||||
|
||||
import FA_URL from 'styles/font-awesome.scss';
|
||||
|
||||
/**
|
||||
* Load the FontAwesome stylesheet and font files if they have not already
|
||||
* been loaded.
|
||||
*/
|
||||
export const load = () => {
|
||||
if ( loaded )
|
||||
return;
|
||||
|
@ -237,7 +251,12 @@ export const load = () => {
|
|||
}));
|
||||
}
|
||||
|
||||
export const maybeLoad = icon => {
|
||||
/**
|
||||
* Potentially load the FontAwesome stylesheet and font files, if the
|
||||
* provided icon name requires them. If it does not, do nothing.
|
||||
* @param icon An icon's name.
|
||||
*/
|
||||
export const maybeLoad = (icon: string) => {
|
||||
if ( loaded || ! String(icon).startsWith('fa-') && ! String(icon).startsWith('ffz-fa') )
|
||||
return;
|
||||
|
|
@ -66,10 +66,10 @@ const GOOGLE_FONTS = [
|
|||
'Karla'
|
||||
];
|
||||
|
||||
const LOADED_GOOGLE = new Map();
|
||||
const LOADED_GOOGLE_LINKS = new Map();
|
||||
const LOADED_GOOGLE = new Map<string, number>();
|
||||
const LOADED_GOOGLE_LINKS = new Map<string, HTMLLinkElement>();
|
||||
|
||||
function loadGoogleFont(font) {
|
||||
function loadGoogleFont(font: string) {
|
||||
if ( LOADED_GOOGLE_LINKS.has(font) )
|
||||
return;
|
||||
|
||||
|
@ -85,7 +85,7 @@ function loadGoogleFont(font) {
|
|||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
function unloadGoogleFont(font) {
|
||||
function unloadGoogleFont(font: string) {
|
||||
const link = LOADED_GOOGLE_LINKS.get(font);
|
||||
if ( ! link )
|
||||
return;
|
||||
|
@ -106,7 +106,7 @@ const OD_FONTS = [
|
|||
import OD_URL from 'styles/opendyslexic.scss';
|
||||
|
||||
let od_count = 0;
|
||||
let od_link = null;
|
||||
let od_link: HTMLLinkElement | null = null;
|
||||
|
||||
function loadOpenDyslexic() {
|
||||
if ( od_link )
|
||||
|
@ -134,8 +134,28 @@ function unloadOpenDyslexic() {
|
|||
|
||||
/* Using and Listing Fonts */
|
||||
|
||||
export function getFontsList() {
|
||||
const out = [
|
||||
// TODO: Move this type somewhere more generic.
|
||||
type SettingSelectOption<TValue> = {
|
||||
value: TValue;
|
||||
|
||||
title: string;
|
||||
i18n_key?: string;
|
||||
|
||||
separator?: boolean;
|
||||
}
|
||||
|
||||
type SettingSelectSeparator = {
|
||||
separator: true;
|
||||
|
||||
title: string;
|
||||
i18n_key?: string;
|
||||
}
|
||||
|
||||
type SettingSelectEntry<TValue> = SettingSelectOption<TValue> | SettingSelectSeparator;
|
||||
|
||||
|
||||
export function getFontsList(): SettingSelectEntry<string>[] {
|
||||
const out: SettingSelectEntry<string>[] = [
|
||||
{value: '', i18n_key: 'setting.font.default', title: 'Default'},
|
||||
{separator: true, i18n_key: 'setting.font.builtin', title: 'Built-in Fonts'},
|
||||
];
|
||||
|
@ -161,7 +181,7 @@ export function getFontsList() {
|
|||
}
|
||||
|
||||
|
||||
export function useFont(font) {
|
||||
export function useFont(font: string): [string, (() => void) | null] {
|
||||
if ( ! font )
|
||||
return [font, null];
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
const RAVEN_LEVELS = {
|
||||
1: 'debug',
|
||||
2: 'info',
|
||||
4: 'warn',
|
||||
8: 'error'
|
||||
};
|
||||
|
||||
|
||||
function readLSLevel() {
|
||||
const level = localStorage.ffzLogLevel;
|
||||
if ( ! level )
|
||||
return null;
|
||||
|
||||
const upper = level.toUpperCase();
|
||||
if ( Logger.hasOwnProperty(upper) )
|
||||
return Logger[upper];
|
||||
|
||||
if ( /^\d+$/.test(level) )
|
||||
return parseInt(level, 10);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
export class Logger {
|
||||
constructor(parent, name, level, raven) {
|
||||
this.root = parent ? parent.root : this;
|
||||
this.parent = parent;
|
||||
this.name = name;
|
||||
|
||||
if ( this.root == this ) {
|
||||
this.captured_init = [];
|
||||
this.label = 'FFZ';
|
||||
}
|
||||
|
||||
this.init = false;
|
||||
this.enabled = true;
|
||||
this.level = level ?? (parent && parent.level) ?? readLSLevel() ?? Logger.DEFAULT_LEVEL;
|
||||
this.raven = raven || (parent && parent.raven);
|
||||
|
||||
this.children = {};
|
||||
}
|
||||
|
||||
hi(core) {
|
||||
const VER = core.constructor.version_info;
|
||||
this.info(`FrankerFaceZ v${VER} (s:${core.host} f:${core.flavor} b:${VER.build} c:${VER.commit || 'null'})`);
|
||||
|
||||
try {
|
||||
const loc = new URL(location);
|
||||
loc.search = '';
|
||||
this.info(`Initial URL: ${loc}`);
|
||||
} catch(err) {
|
||||
this.warn(`Unable to read location.`, err);
|
||||
}
|
||||
}
|
||||
|
||||
get(name, level) {
|
||||
if ( ! this.children[name] )
|
||||
this.children[name] = new Logger(this, (this.name ? `${this.name}.${name}` : name), level);
|
||||
|
||||
return this.children[name];
|
||||
}
|
||||
|
||||
verbose(...args) {
|
||||
return this.invoke(Logger.VERBOSE, args);
|
||||
}
|
||||
|
||||
verboseColor(msg, colors, ...args) {
|
||||
return this.invokeColor(Logger.VERBOSE, msg, colors, args);
|
||||
}
|
||||
|
||||
debug(...args) {
|
||||
return this.invoke(Logger.DEBUG, args);
|
||||
}
|
||||
|
||||
debugColor(msg, colors, ...args) {
|
||||
return this.invokeColor(Logger.DEBUG, msg, colors, args);
|
||||
}
|
||||
|
||||
info(...args) {
|
||||
return this.invoke(Logger.INFO, args);
|
||||
}
|
||||
|
||||
infoColor(msg, colors, ...args) {
|
||||
return this.invokeColor(Logger.INFO, msg, colors, args);
|
||||
}
|
||||
|
||||
warn(...args) {
|
||||
return this.invoke(Logger.WARN, args);
|
||||
}
|
||||
|
||||
warnColor(msg, colors, ...args) {
|
||||
return this.invokeColor(Logger.WARN, msg, colors, args);
|
||||
}
|
||||
|
||||
warning(...args) {
|
||||
return this.invoke(Logger.WARN, args);
|
||||
}
|
||||
|
||||
warningColor(msg, colors, ...args) {
|
||||
return this.invokeColor(Logger.WARN, msg, colors, args);
|
||||
}
|
||||
|
||||
error(...args) {
|
||||
return this.invoke(Logger.ERROR, args);
|
||||
}
|
||||
|
||||
errorColor(msg, colors, ...args) {
|
||||
return this.invokeColor(Logger.ERROR, msg, colors, args);
|
||||
}
|
||||
|
||||
crumb(...args) {
|
||||
if ( this.raven )
|
||||
return this.raven.captureBreadcrumb(...args);
|
||||
}
|
||||
|
||||
capture(exc, opts, ...args) {
|
||||
if ( this.raven ) {
|
||||
opts = opts || {};
|
||||
if ( ! opts.logger )
|
||||
opts.logger = this.name;
|
||||
|
||||
this.raven.captureException(exc, opts);
|
||||
}
|
||||
|
||||
if ( args.length )
|
||||
return this.error(...args);
|
||||
}
|
||||
|
||||
invokeColor(level, msg, colors, args) {
|
||||
if ( ! this.enabled || level < this.level )
|
||||
return;
|
||||
|
||||
if ( ! Array.isArray(colors) )
|
||||
colors = [colors];
|
||||
|
||||
const message = args ? Array.prototype.slice.call(args) : [];
|
||||
|
||||
if ( level !== Logger.VERBOSE ) {
|
||||
const out = msg.replace(/%c/g, '') + ' ' + message.join(' ');
|
||||
|
||||
if ( this.root.init )
|
||||
this.root.captured_init.push({
|
||||
time: Date.now(),
|
||||
category: this.name,
|
||||
message: out,
|
||||
level: RAVEN_LEVELS[level] || level
|
||||
});
|
||||
|
||||
this.crumb({
|
||||
message: out,
|
||||
category: this.name,
|
||||
level: RAVEN_LEVELS[level] || level
|
||||
});
|
||||
}
|
||||
|
||||
message.unshift(msg);
|
||||
|
||||
if ( this.name ) {
|
||||
message[0] = `%c${this.root.label} [%c${this.name}%c]:%c ${message[0]}`;
|
||||
colors.unshift('color:#755000; font-weight:bold', '', 'color:#755000; font-weight:bold', '');
|
||||
|
||||
} else {
|
||||
message[0] = `%c${this.root.label}:%c ${message[0]}`;
|
||||
colors.unshift('color:#755000; font-weight:bold', '');
|
||||
}
|
||||
|
||||
message.splice(1, 0, ...colors);
|
||||
|
||||
if ( level === Logger.DEBUG || level === Logger.VERBOSE )
|
||||
console.debug(...message);
|
||||
|
||||
else if ( level === Logger.INFO )
|
||||
console.info(...message);
|
||||
|
||||
else if ( level === Logger.WARN )
|
||||
console.warn(...message);
|
||||
|
||||
else if ( level === Logger.ERROR )
|
||||
console.error(...message);
|
||||
|
||||
else
|
||||
console.log(...message);
|
||||
}
|
||||
|
||||
/* eslint no-console: "off" */
|
||||
invoke(level, args) {
|
||||
if ( ! this.enabled || level < this.level )
|
||||
return;
|
||||
|
||||
const message = Array.prototype.slice.call(args);
|
||||
|
||||
if ( level !== Logger.VERBOSE ) {
|
||||
if ( this.root.init )
|
||||
this.root.captured_init.push({
|
||||
time: Date.now(),
|
||||
category: this.name,
|
||||
message: message.join(' '),
|
||||
level: RAVEN_LEVELS[level] || level
|
||||
});
|
||||
|
||||
this.crumb({
|
||||
message: message.join(' '),
|
||||
category: this.name,
|
||||
level: RAVEN_LEVELS[level] || level
|
||||
});
|
||||
}
|
||||
|
||||
if ( this.name )
|
||||
message.unshift(`%c${this.root.label} [%c${this.name}%c]:%c`, 'color:#755000; font-weight:bold', '', 'color:#755000; font-weight:bold', '');
|
||||
else
|
||||
message.unshift(`%c${this.root.label}:%c`, 'color:#755000; font-weight:bold', '');
|
||||
|
||||
if ( level === Logger.DEBUG || level === Logger.VERBOSE )
|
||||
console.debug(...message);
|
||||
|
||||
else if ( level === Logger.INFO )
|
||||
console.info(...message);
|
||||
|
||||
else if ( level === Logger.WARN )
|
||||
console.warn(...message);
|
||||
|
||||
else if ( level === Logger.ERROR )
|
||||
console.error(...message);
|
||||
|
||||
else
|
||||
console.log(...message);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.VERBOSE = 0;
|
||||
Logger.DEBUG = 1;
|
||||
Logger.INFO = 2;
|
||||
Logger.WARN = 4;
|
||||
Logger.WARNING = 4;
|
||||
Logger.ERROR = 8;
|
||||
Logger.OFF = 99;
|
||||
|
||||
Logger.DEFAULT_LEVEL = Logger.INFO;
|
||||
|
||||
export default Logger;
|
320
src/utilities/logging.ts
Normal file
320
src/utilities/logging.ts
Normal file
|
@ -0,0 +1,320 @@
|
|||
import type { ClientVersion } from "./types";
|
||||
|
||||
const RAVEN_LEVELS: Record<number, string> = {
|
||||
1: 'debug',
|
||||
2: 'info',
|
||||
4: 'warn',
|
||||
8: 'error'
|
||||
};
|
||||
|
||||
|
||||
export enum LogLevel {
|
||||
Verbose = 0,
|
||||
Debug = 1,
|
||||
Info = 2,
|
||||
Warning = 4,
|
||||
Error = 8,
|
||||
Off = 99
|
||||
}
|
||||
|
||||
function readLSLevel(): number | null {
|
||||
const level = localStorage.ffzLogLevel;
|
||||
if ( ! level )
|
||||
return null;
|
||||
|
||||
const upper = level.toUpperCase(),
|
||||
value = (Logger as any)[upper];
|
||||
|
||||
if ( typeof value === 'number' )
|
||||
return value;
|
||||
|
||||
if ( /^\d+$/.test(level) )
|
||||
return parseInt(level, 10);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
interface Core {
|
||||
|
||||
host: string;
|
||||
flavor: string;
|
||||
|
||||
};
|
||||
|
||||
|
||||
type InitItem = {
|
||||
time: number;
|
||||
category: string | null;
|
||||
message: string;
|
||||
level: string | number;
|
||||
}
|
||||
|
||||
|
||||
export class Logger {
|
||||
public static readonly Levels = LogLevel;
|
||||
public static readonly VERBOSE = LogLevel.Verbose;
|
||||
public static readonly DEBUG = LogLevel.Debug;
|
||||
public static readonly INFO = LogLevel.Info;
|
||||
public static readonly WARN = LogLevel.Warning;
|
||||
public static readonly WARNING = LogLevel.Warning;
|
||||
public static readonly ERROR = LogLevel.Error;
|
||||
public static readonly OFF = LogLevel.Off;
|
||||
|
||||
public static readonly DEFAULT_LEVEL = LogLevel.Info;
|
||||
|
||||
name: string | null;
|
||||
enabled: boolean;
|
||||
level: LogLevel;
|
||||
|
||||
label?: string;
|
||||
|
||||
init?: boolean;
|
||||
captured_init?: InitItem[];
|
||||
|
||||
root: Logger;
|
||||
parent: Logger | null;
|
||||
children: Record<string, Logger>;
|
||||
|
||||
raven: any;
|
||||
|
||||
constructor(parent: Logger | null, name: string | null, level?: LogLevel | null, raven?: any) {
|
||||
this.root = parent ? parent.root : this;
|
||||
this.parent = parent;
|
||||
this.name = name;
|
||||
|
||||
if ( this.root == this ) {
|
||||
this.init = false;
|
||||
this.captured_init = [];
|
||||
this.label = 'FFZ';
|
||||
}
|
||||
|
||||
this.enabled = true;
|
||||
this.level = level ?? (parent && parent.level) ?? readLSLevel() ?? Logger.DEFAULT_LEVEL;
|
||||
this.raven = raven || (parent && parent.raven);
|
||||
|
||||
this.children = {};
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
hi(core: Core, version?: ClientVersion) {
|
||||
const VER = version ?? (core.constructor as any)?.version_info;
|
||||
this.info(`FrankerFaceZ v${VER} (s:${core.host} f:${core.flavor} b:${VER?.build} c:${VER?.commit || 'null'})`);
|
||||
|
||||
try {
|
||||
const loc = new URL(location.toString());
|
||||
loc.search = '';
|
||||
this.info(`Initial URL: ${loc}`);
|
||||
} catch(err) {
|
||||
this.warn(`Unable to read location.`, err);
|
||||
}
|
||||
}
|
||||
|
||||
get(name: string, level?: LogLevel) {
|
||||
if ( ! this.children[name] )
|
||||
this.children[name] = new Logger(this, (this.name ? `${this.name}.${name}` : name), level);
|
||||
|
||||
return this.children[name];
|
||||
}
|
||||
|
||||
verbose(message: any, ...optionalParams: any[]) {
|
||||
return this.invoke(LogLevel.Verbose, message, optionalParams);
|
||||
}
|
||||
|
||||
verboseColor(message: any, colors: string[], ...optionalParams: any[]) {
|
||||
return this.invokeColor(Logger.VERBOSE, message, colors, optionalParams);
|
||||
}
|
||||
|
||||
debug(message: any, ...optionalParams: any[]) {
|
||||
return this.invoke(Logger.DEBUG, message, optionalParams);
|
||||
}
|
||||
|
||||
debugColor(message: any, colors: string[], ...optionalParams: any[]) {
|
||||
return this.invokeColor(Logger.DEBUG, message, colors, optionalParams);
|
||||
}
|
||||
|
||||
info(message: any, ...optionalParams: any[]) {
|
||||
return this.invoke(Logger.INFO, message, optionalParams);
|
||||
}
|
||||
|
||||
infoColor(message: any, colors: string[], ...optionalParams: any[]) {
|
||||
return this.invokeColor(Logger.INFO, message, colors, optionalParams);
|
||||
}
|
||||
|
||||
warn(message: any, ...optionalParams: any[]) {
|
||||
return this.invoke(Logger.WARN, message, optionalParams);
|
||||
}
|
||||
|
||||
warnColor(message: any, colors: string[], ...optionalParams: any[]) {
|
||||
return this.invokeColor(Logger.WARN, message, colors, optionalParams);
|
||||
}
|
||||
|
||||
warning(message: any, ...optionalParams: any[]) {
|
||||
return this.invoke(Logger.WARN, message, optionalParams);
|
||||
}
|
||||
|
||||
warningColor(message: any, colors: string[], ...optionalParams: any[]) {
|
||||
return this.invokeColor(Logger.WARN, message, colors, optionalParams);
|
||||
}
|
||||
|
||||
error(message: any, ...optionalParams: any[]) {
|
||||
return this.invoke(Logger.ERROR, message, optionalParams);
|
||||
}
|
||||
|
||||
errorColor(message: any, colors: string[], ...optionalParams: any[]) {
|
||||
return this.invokeColor(Logger.ERROR, message, colors, optionalParams);
|
||||
}
|
||||
|
||||
crumb(...args: any[]) {
|
||||
if ( this.raven )
|
||||
return this.raven.captureBreadcrumb(...args);
|
||||
}
|
||||
|
||||
capture(exc: Error, opts?: any, ...args: any[]) {
|
||||
if ( this.raven ) {
|
||||
opts = opts || {};
|
||||
if ( ! opts.logger )
|
||||
opts.logger = this.name;
|
||||
|
||||
this.raven.captureException(exc, opts);
|
||||
}
|
||||
|
||||
if ( args.length ) {
|
||||
const msg = args.shift();
|
||||
return this.error(msg, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
invokeColor(level: number, message: any, colors: string | string[], ...optionalParams: any[]) {
|
||||
if ( ! this.enabled || level < this.level )
|
||||
return;
|
||||
|
||||
if ( ! Array.isArray(colors) )
|
||||
colors = [colors];
|
||||
|
||||
//const message = args ? Array.prototype.slice.call(args) : [];
|
||||
|
||||
if ( level > LogLevel.Verbose ) {
|
||||
let out = message;
|
||||
if ( typeof out === 'string' )
|
||||
out = out.replace(/%c/g, '');
|
||||
|
||||
if ( optionalParams.length )
|
||||
out = `${out} ${optionalParams.join(' ')}`;
|
||||
|
||||
if ( this.root.init && this.root.captured_init )
|
||||
this.root.captured_init.push({
|
||||
time: Date.now(),
|
||||
category: this.name,
|
||||
message: out,
|
||||
level: RAVEN_LEVELS[level] || level
|
||||
});
|
||||
|
||||
this.crumb({
|
||||
message: out,
|
||||
category: this.name,
|
||||
level: RAVEN_LEVELS[level] || level
|
||||
});
|
||||
}
|
||||
|
||||
const default_style = level < LogLevel.Info
|
||||
? 'color:#999999'
|
||||
: '';
|
||||
|
||||
if ( this.name ) {
|
||||
if ( typeof message === 'string' )
|
||||
message = `%c${this.root.label} [%c${this.name}%c]:%c ${message}`;
|
||||
else {
|
||||
optionalParams.unshift(message);
|
||||
message = `%c${this.root.label} [%c${this.name}%c]:%c`;
|
||||
}
|
||||
|
||||
colors.unshift('color:#755000; font-weight:bold', default_style, 'color:#755000; font-weight:bold', default_style);
|
||||
|
||||
} else {
|
||||
if ( typeof message === 'string' )
|
||||
message = `%c${this.root.label}:%c ${message}`;
|
||||
else {
|
||||
optionalParams.unshift(message);
|
||||
message = `%c${this.root.label}:%c`;
|
||||
}
|
||||
|
||||
colors.unshift('color:#755000; font-weight:bold', default_style);
|
||||
}
|
||||
|
||||
if ( level < LogLevel.Info )
|
||||
console.debug(message, ...colors, ...optionalParams);
|
||||
|
||||
else if ( level < LogLevel.Warning )
|
||||
console.info(message, ...colors, ...optionalParams);
|
||||
|
||||
else if ( level < LogLevel.Error )
|
||||
console.warn(message, ...colors, ...optionalParams);
|
||||
|
||||
else if ( level < LogLevel.Off )
|
||||
console.error(message, ...colors, ...optionalParams);
|
||||
}
|
||||
|
||||
/* eslint no-console: "off" */
|
||||
invoke(level: number, message: string, optionalParams?: any[]) {
|
||||
if ( ! this.enabled || level < this.level || level >= LogLevel.Off )
|
||||
return;
|
||||
|
||||
const result = optionalParams ? [
|
||||
message,
|
||||
...optionalParams
|
||||
] : [message];
|
||||
|
||||
if ( level > LogLevel.Verbose ) {
|
||||
const out = result.join(' ');
|
||||
|
||||
if ( this.root.init && this.root.captured_init )
|
||||
this.root.captured_init.push({
|
||||
time: Date.now(),
|
||||
category: this.name,
|
||||
message: out,
|
||||
level: RAVEN_LEVELS[level] || level
|
||||
});
|
||||
|
||||
this.crumb({
|
||||
message: out,
|
||||
category: this.name,
|
||||
level: RAVEN_LEVELS[level] || level
|
||||
});
|
||||
}
|
||||
|
||||
// Chrome removed any sort of special styling from debug
|
||||
// logging, so let's add our own to make them visually distinct.
|
||||
const default_style = level < LogLevel.Info
|
||||
? 'color:#999999'
|
||||
: '';
|
||||
|
||||
// If we're adding our own style, we need to grab as many of
|
||||
// the strings as we can.
|
||||
let strings = '';
|
||||
if ( default_style !== '' ) {
|
||||
while(result.length > 0 && typeof result[0] === 'string') {
|
||||
strings += ' ' + result.shift();
|
||||
}
|
||||
}
|
||||
|
||||
if ( this.name ) {
|
||||
result.unshift(`%c${this.root.label} [%c${this.name}%c]:%c${strings}`, 'color:#755000; font-weight:bold', default_style, 'color:#755000; font-weight:bold', default_style);
|
||||
} else
|
||||
result.unshift(`%c${this.root.label}:%c${strings}`, 'color:#755000; font-weight:bold', default_style);
|
||||
|
||||
if ( level < LogLevel.Info )
|
||||
console.debug(...result);
|
||||
|
||||
else if ( level < LogLevel.Warning )
|
||||
console.info(...result);
|
||||
|
||||
else if ( level < LogLevel.Error )
|
||||
console.warn(...result);
|
||||
|
||||
else if ( level < LogLevel.Off )
|
||||
console.error(...result);
|
||||
}
|
||||
}
|
||||
|
||||
export default Logger;
|
|
@ -1,873 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Module System
|
||||
// Modules are cool.
|
||||
// ============================================================================
|
||||
|
||||
import EventEmitter from 'utilities/events';
|
||||
import {has} from 'utilities/object';
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Module
|
||||
// ============================================================================
|
||||
|
||||
export const State = {
|
||||
UNLOADED: 0,
|
||||
LOADING: 1,
|
||||
LOADED: 2,
|
||||
UNLOADING: 3,
|
||||
|
||||
DISABLED: 0,
|
||||
ENABLING: 1,
|
||||
ENABLED: 2,
|
||||
DISABLING: 3
|
||||
}
|
||||
|
||||
|
||||
export class Module extends EventEmitter {
|
||||
constructor(name, parent) {
|
||||
if ( ! parent && name instanceof Module ) {
|
||||
parent = name;
|
||||
name = null;
|
||||
}
|
||||
|
||||
super(name, parent);
|
||||
this.__modules = parent ? parent.__modules : {};
|
||||
this.children = {};
|
||||
|
||||
if ( parent?.addon_id ) {
|
||||
this.addon_id = parent.addon_id;
|
||||
this.addon_root = parent.addon_root;
|
||||
}
|
||||
|
||||
if ( parent && ! parent.children[this.name] )
|
||||
parent.children[this.name] = this;
|
||||
|
||||
if ( this.root === this )
|
||||
this.__modules[this.__path || ''] = this;
|
||||
|
||||
this.__load_state = this.onLoad ? State.UNLOADED : State.LOADED;
|
||||
this.__state = this.onLoad || this.onEnable ?
|
||||
State.DISABLED : State.ENABLED;
|
||||
|
||||
this.__time('instance');
|
||||
this.emit(':registered');
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Public Properties
|
||||
// ========================================================================
|
||||
|
||||
get state() { return this.__state }
|
||||
get load_state() { return this.__load_state }
|
||||
|
||||
get loaded() { return this.__load_state === State.LOADED }
|
||||
get loading() { return this.__load_state === State.LOADING }
|
||||
|
||||
get enabled() { return this.__state === State.ENABLED }
|
||||
get enabling() { return this.__state === State.ENABLING }
|
||||
|
||||
|
||||
get log() {
|
||||
if ( ! this.__log )
|
||||
this.__log = this.parent && this.parent.log.get(this.name);
|
||||
return this.__log
|
||||
}
|
||||
|
||||
set log(log) {
|
||||
this.__log = log;
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Timing
|
||||
// ========================================================================
|
||||
|
||||
__time(event) {
|
||||
if ( this.root.timing ) {
|
||||
if ( typeof event !== 'object' )
|
||||
event = {event};
|
||||
event.module = this.__path || 'core';
|
||||
this.root.timing.addEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// State! Glorious State
|
||||
// ========================================================================
|
||||
|
||||
load(...args) {
|
||||
return this.__load(args, this.__path, []);
|
||||
}
|
||||
|
||||
unload(...args) {
|
||||
return this.__unload(args, this.__path, []);
|
||||
}
|
||||
|
||||
enable(...args) {
|
||||
return this.__enable(args, this.__path, []);
|
||||
}
|
||||
|
||||
disable(...args) {
|
||||
return this.__disable(args, this.__path, []);
|
||||
}
|
||||
|
||||
canUnload() {
|
||||
return this.__canUnload(this.__path, []);
|
||||
}
|
||||
|
||||
canDisable() {
|
||||
return this.__canDisable(this.__path, []);
|
||||
}
|
||||
|
||||
__load(args, initial, chain) {
|
||||
const path = this.__path || this.name,
|
||||
state = this.__load_state;
|
||||
|
||||
if ( chain.includes(this) )
|
||||
return Promise.reject(new CyclicDependencyError(`cyclic load requirements when loading ${initial}`, [...chain, this]));
|
||||
else if ( this.load_requires )
|
||||
for(const name of this.load_requires) {
|
||||
const module = this.__resolve(name);
|
||||
if ( module && chain.includes(module) )
|
||||
return Promise.reject(new CyclicDependencyError(`cyclic load requirements when loading ${initial}`, [...chain, this, module]));
|
||||
}
|
||||
|
||||
chain.push(this);
|
||||
|
||||
if ( state === State.LOADING )
|
||||
return this.__load_promise;
|
||||
|
||||
else if ( state === State.LOADED )
|
||||
return Promise.resolve();
|
||||
|
||||
else if ( state === State.UNLOADING )
|
||||
return Promise.reject(new ModuleError(`attempted to load module ${path} while module is being unloaded`));
|
||||
|
||||
this.__time('load-start');
|
||||
this.__load_state = State.LOADING;
|
||||
return this.__load_promise = (async () => {
|
||||
if ( this.load_requires ) {
|
||||
const promises = [];
|
||||
for(const name of this.load_requires) {
|
||||
const module = this.__resolve(name);
|
||||
if ( ! module || !(module instanceof Module) )
|
||||
throw new ModuleError(`cannot find required module ${name} when loading ${path}`);
|
||||
|
||||
promises.push(module.__enable([], initial, Array.from(chain)));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
if ( this.onLoad ) {
|
||||
this.__time('load-self');
|
||||
return this.onLoad(...args);
|
||||
}
|
||||
|
||||
})().then(ret => {
|
||||
this.__load_state = State.LOADED;
|
||||
this.__load_promise = null;
|
||||
this.__time('load-end');
|
||||
this.emit(':loaded', this);
|
||||
return ret;
|
||||
}).catch(err => {
|
||||
this.__load_state = State.UNLOADED;
|
||||
this.__load_promise = null;
|
||||
this.__time('load-end');
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
__canUnload(initial, chain) {
|
||||
const path = this.__path || this.name,
|
||||
state = this.__load_state;
|
||||
|
||||
if ( chain.includes(this) )
|
||||
throw new CyclicDependencyError(`cyclic load requirements when checking if can unload ${initial}`, [...chain, this]);
|
||||
else if ( this.load_dependents ) {
|
||||
chain.push(this);
|
||||
|
||||
for(const dep of this.load_dependents) {
|
||||
const module = this.__resolve(dep);
|
||||
if ( module ) {
|
||||
if ( chain.includes(module) )
|
||||
throw new CyclicDependencyError(`cyclic load requirements when checking if can unload ${initial}`, [...chain, this, module]);
|
||||
|
||||
if ( ! module.__canUnload(initial, Array.from(chain)) )
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( state === State.UNLOADING )
|
||||
return true;
|
||||
|
||||
else if ( state === State.UNLOADED )
|
||||
return true;
|
||||
|
||||
else if ( this.onLoad && ! this.onUnload )
|
||||
return false;
|
||||
|
||||
else if ( state === State.LOADING )
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
__unload(args, initial, chain) {
|
||||
const path = this.__path || this.name,
|
||||
state = this.__load_state;
|
||||
|
||||
if ( chain.includes(this) )
|
||||
return Promise.reject(new CyclicDependencyError(`cyclic load requirements when unloading ${initial}`, [...chain, this]));
|
||||
else if ( this.load_dependents )
|
||||
for(const dep of this.load_dependents) {
|
||||
const module = this.__resolve(dep);
|
||||
if ( module && chain.includes(module) )
|
||||
return Promise.reject(new CyclicDependencyError(`cyclic load requirements when unloading ${initial}`, [...chain, this, module]));
|
||||
}
|
||||
|
||||
chain.push(this);
|
||||
|
||||
if ( state === State.UNLOADING )
|
||||
return this.__load_promise;
|
||||
|
||||
else if ( state === State.UNLOADED )
|
||||
return Promise.resolve();
|
||||
|
||||
else if ( this.onLoad && ! this.onUnload )
|
||||
return Promise.reject(new ModuleError(`attempted to unload module ${path} but module cannot be unloaded`));
|
||||
|
||||
else if ( state === State.LOADING )
|
||||
return Promise.reject(new ModuleError(`attempted to unload module ${path} while module is being loaded`));
|
||||
|
||||
this.__time('unload-start');
|
||||
this.__load_state = State.UNLOADING;
|
||||
return this.__load_promise = (async () => {
|
||||
if ( this.__state !== State.DISABLED )
|
||||
await this.disable();
|
||||
|
||||
if ( this.load_dependents ) {
|
||||
const promises = [];
|
||||
for(const name of this.load_dependents) {
|
||||
const module = this.__resolve(name);
|
||||
if ( ! module || !(module instanceof Module) )
|
||||
//throw new ModuleError(`cannot find depending module ${name} when unloading ${path}`);
|
||||
continue;
|
||||
|
||||
promises.push(module.__unload([], initial, Array.from(chain)));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
this.__time('unload-self');
|
||||
if ( this.onUnload )
|
||||
return this.onUnload(...args);
|
||||
return null;
|
||||
|
||||
})().then(ret => {
|
||||
this.__load_state = State.UNLOADED;
|
||||
this.__load_promise = null;
|
||||
this.__time('unload-end');
|
||||
this.emit(':unloaded', this);
|
||||
return ret;
|
||||
}).catch(err => {
|
||||
this.__load_state = State.LOADED;
|
||||
this.__load_promise = null;
|
||||
this.__time('unload-end');
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
__enable(args, initial, chain) {
|
||||
const path = this.__path || this.name,
|
||||
state = this.__state;
|
||||
|
||||
if ( chain.includes(this) )
|
||||
return Promise.reject(new CyclicDependencyError(`cyclic requirements when enabling ${initial}`, [...chain, this]));
|
||||
else if ( this.requires )
|
||||
for(const name of this.requires) {
|
||||
const module = this.__resolve(name);
|
||||
if ( module && chain.includes(module) )
|
||||
return Promise.reject(new CyclicDependencyError(`cyclic requirements when enabling ${initial}`, [...chain, this, module]));
|
||||
}
|
||||
|
||||
chain.push(this);
|
||||
|
||||
if ( state === State.ENABLING )
|
||||
return this.__state_promise;
|
||||
|
||||
else if ( state === State.ENABLED )
|
||||
return Promise.resolve();
|
||||
|
||||
else if ( state === State.DISABLING )
|
||||
return Promise.reject(new ModuleError(`attempted to enable module ${path} while module is being disabled`));
|
||||
|
||||
this.__time('enable-start');
|
||||
this.__state = State.ENABLING;
|
||||
return this.__state_promise = (async () => {
|
||||
const promises = [],
|
||||
requires = this.requires,
|
||||
load_state = this.__load_state;
|
||||
|
||||
if ( load_state === State.UNLOADING )
|
||||
// We'd abort for this later to, but kill it now before we start
|
||||
// any unnecessary work.
|
||||
throw new ModuleError(`attempted to load module ${path} while module is being unloaded`);
|
||||
|
||||
else if ( load_state === State.LOADING || load_state === State.UNLOADED )
|
||||
promises.push(this.load());
|
||||
|
||||
if ( requires )
|
||||
for(const name of requires) {
|
||||
const module = this.__resolve(name);
|
||||
if ( ! module || !(module instanceof Module) )
|
||||
throw new ModuleError(`cannot find required module ${name} when enabling ${path}`);
|
||||
|
||||
promises.push(module.__enable([], initial, Array.from(chain)));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
if ( this.onEnable ) {
|
||||
this.__time('enable-self');
|
||||
return this.onEnable(...args);
|
||||
}
|
||||
|
||||
})().then(ret => {
|
||||
this.__state = State.ENABLED;
|
||||
this.__state_promise = null;
|
||||
this.__time('enable-end');
|
||||
this.emit(':enabled', this);
|
||||
return ret;
|
||||
|
||||
}).catch(err => {
|
||||
this.__state = State.DISABLED;
|
||||
this.__state_promise = null;
|
||||
this.__time('enable-end');
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
__canDisable(initial, chain) {
|
||||
const path = this.__path || this.name,
|
||||
state = this.__state;
|
||||
|
||||
if ( chain.includes(this) )
|
||||
throw new CyclicDependencyError(`cyclic load requirements when checking if can disable ${initial}`, [...chain, this]);
|
||||
else if ( this.dependents ) {
|
||||
chain.push(this);
|
||||
|
||||
for(const dep of this.dependents) {
|
||||
const module = this.__resolve(dep);
|
||||
if ( module && (module instanceof Module) ) {
|
||||
if ( chain.includes(module) )
|
||||
throw new CyclicDependencyError(`cyclic load requirements when checking if can disable ${initial}`, [...chain, this, module]);
|
||||
|
||||
if ( ! module.__canDisable(initial, Array.from(chain)) )
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( state === State.DISABLING || state === State.DISABLED )
|
||||
return true;
|
||||
|
||||
else if ( ! this.onDisable )
|
||||
return false;
|
||||
|
||||
else if ( state === State.ENABLING )
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
__disable(args, initial, chain) {
|
||||
const path = this.__path || this.name,
|
||||
state = this.__state;
|
||||
|
||||
if ( chain.includes(this) )
|
||||
return Promise.reject(new CyclicDependencyError(`cyclic requirements when disabling ${initial}`, [...chain, this]));
|
||||
else if ( this.dependents )
|
||||
for(const dep of this.dependents) {
|
||||
const module = this.__resolve(dep);
|
||||
if ( module && chain.includes(module) )
|
||||
return Promise.reject(new CyclicDependencyError(`cyclic requirements when disabling ${initial}`, [...chain, this, dep]));
|
||||
}
|
||||
|
||||
chain.push(this);
|
||||
|
||||
if ( state === State.DISABLING )
|
||||
return this.__state_promise;
|
||||
|
||||
else if ( state === State.DISABLED )
|
||||
return Promise.resolve();
|
||||
|
||||
else if ( ! this.onDisable )
|
||||
return Promise.reject(new ModuleError(`attempted to disable module ${path} but module cannot be disabled`));
|
||||
|
||||
else if ( state === State.ENABLING )
|
||||
return Promise.reject(new ModuleError(`attempted to disable module ${path} but module is being enabled`));
|
||||
|
||||
this.__time('disable-start');
|
||||
this.__state = State.DISABLING;
|
||||
return this.__state_promise = (async () => {
|
||||
if ( this.__load_state !== State.LOADED )
|
||||
// We'd abort for this later to, but kill it now before we start
|
||||
// any unnecessary work.
|
||||
throw new ModuleError(`attempted to disable module ${path} but module is unloaded -- weird state`);
|
||||
|
||||
if ( this.dependents ) {
|
||||
const promises = [];
|
||||
for(const name of this.dependents) {
|
||||
const module = this.__resolve(name);
|
||||
if ( ! module || !(module instanceof Module) )
|
||||
// Assume a non-existent module isn't enabled.
|
||||
//throw new ModuleError(`cannot find depending module ${name} when disabling ${path}`);
|
||||
continue;
|
||||
|
||||
promises.push(module.__disable([], initial, Array.from(chain)));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
this.__time('disable-self');
|
||||
return this.onDisable(...args);
|
||||
|
||||
})().then(ret => {
|
||||
this.__state = State.DISABLED;
|
||||
this.__state_promise = null;
|
||||
this.__time('disable-end');
|
||||
this.emit(':disabled', this);
|
||||
return ret;
|
||||
|
||||
}).catch(err => {
|
||||
this.__state = State.ENABLED;
|
||||
this.__state_promise = null;
|
||||
this.__time('disable-end');
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Slightly Easier Events
|
||||
// ========================================================================
|
||||
|
||||
on(event, fn, ctx) {
|
||||
return super.on(event, fn, ctx === undefined ? this : ctx)
|
||||
}
|
||||
|
||||
prependOn(event, fn, ctx) {
|
||||
return super.prependOn(event, fn, ctx === undefined ? this : ctx)
|
||||
}
|
||||
|
||||
many(event, ttl, fn, ctx) {
|
||||
return super.many(event, ttl, fn, ctx === undefined ? this : ctx)
|
||||
}
|
||||
|
||||
prependMany(event, ttl, fn, ctx) {
|
||||
return super.prependMany(event, ttl, fn, ctx === undefined ? this : ctx)
|
||||
}
|
||||
|
||||
once(event, fn, ctx) {
|
||||
return super.once(event, fn, ctx === undefined ? this : ctx)
|
||||
}
|
||||
|
||||
prependOnce(event, fn, ctx) {
|
||||
return super.prependOnce(event, fn, ctx === undefined ? this : ctx)
|
||||
}
|
||||
|
||||
off(event, fn, ctx) {
|
||||
return super.off(event, fn, ctx === undefined ? this : ctx)
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Child Control
|
||||
// ========================================================================
|
||||
|
||||
loadModules(...names) {
|
||||
return Promise.all(names.map(n => this.__resolve(n).load()))
|
||||
}
|
||||
|
||||
unloadModules(...names) {
|
||||
return Promise.all(names.map(n => this.__resolve(n).unload()))
|
||||
}
|
||||
|
||||
enableModules(...names) {
|
||||
return Promise.all(names.map(n => this.__resolve(n).enable()))
|
||||
}
|
||||
|
||||
disableModules(...names) {
|
||||
return Promise.all(names.map(n => this.__resolve(n).disable()))
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Module Management
|
||||
// ========================================================================
|
||||
|
||||
__resolve(name) {
|
||||
if ( name instanceof Module )
|
||||
return name;
|
||||
|
||||
return this.__modules[this.abs_path(name)];
|
||||
}
|
||||
|
||||
resolve(name) {
|
||||
let module = this.__resolve(name);
|
||||
if ( !(module instanceof Module) )
|
||||
return null;
|
||||
|
||||
if ( this.__processModule )
|
||||
module = this.__processModule(module);
|
||||
|
||||
return module;
|
||||
}
|
||||
|
||||
|
||||
hasModule(name) {
|
||||
const module = this.__modules[this.abs_path(name)];
|
||||
return module instanceof Module;
|
||||
}
|
||||
|
||||
|
||||
__get_requires() {
|
||||
if ( has(this, 'requires') )
|
||||
return this.requires;
|
||||
if ( has(this.constructor, 'requires') )
|
||||
return this.constructor.requires;
|
||||
}
|
||||
|
||||
|
||||
__get_load_requires() {
|
||||
if ( has(this, 'load_requires') )
|
||||
return this.load_requires;
|
||||
if ( has(this.constructor, 'load_requires') )
|
||||
return this.constructor.load_requires;
|
||||
}
|
||||
|
||||
|
||||
__processModule(module) {
|
||||
if ( this.addon_root && module.getAddonProxy ) {
|
||||
const addon_id = this.addon_id;
|
||||
if ( ! module.__proxies )
|
||||
module.__proxies = {};
|
||||
|
||||
if ( module.__proxies[addon_id] )
|
||||
return module.__proxies[addon_id];
|
||||
|
||||
const addon = this.__resolve('addons')?.getAddon?.(addon_id),
|
||||
out = module.getAddonProxy(addon_id, addon, this.addon_root, this);
|
||||
|
||||
if ( out !== module )
|
||||
module.__proxies[addon_id] = out;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
return module;
|
||||
}
|
||||
|
||||
|
||||
inject(name, module, require = true) {
|
||||
if ( name instanceof Module || name.prototype instanceof Module ) {
|
||||
require = module != null ? module : true;
|
||||
module = name;
|
||||
name = null;
|
||||
}
|
||||
|
||||
const requires = this.requires = this.__get_requires() || [];
|
||||
|
||||
if ( module instanceof Module ) {
|
||||
// Existing Instance
|
||||
if ( ! name )
|
||||
name = module.constructor.name.toSnakeCase();
|
||||
|
||||
} else if ( module && module.prototype instanceof Module ) {
|
||||
// New Instance
|
||||
if ( ! name )
|
||||
name = module.name.toSnakeCase();
|
||||
|
||||
module = this.register(name, module);
|
||||
|
||||
} else if ( name ) {
|
||||
// Just a Name
|
||||
const full_name = name;
|
||||
name = name.replace(/^(?:[^.]*\.)+/, '');
|
||||
module = this.__resolve(full_name);
|
||||
|
||||
// Allow injecting a module that doesn't exist yet?
|
||||
|
||||
if ( ! module || !(module instanceof Module) ) {
|
||||
if ( module )
|
||||
module[2].push([this.__path, name]);
|
||||
else
|
||||
this.__modules[this.abs_path(full_name)] = [[], [], [[this.__path, name]]]
|
||||
|
||||
requires.push(this.abs_path(full_name));
|
||||
|
||||
return this[name] = null;
|
||||
}
|
||||
|
||||
} else
|
||||
throw new TypeError(`must provide a valid module name or class`);
|
||||
|
||||
if ( ! module )
|
||||
throw new Error(`cannot find module ${name} or no module provided`);
|
||||
|
||||
if ( require )
|
||||
requires.push(module.abs_path('.'));
|
||||
|
||||
if ( this.enabled && ! module.enabled )
|
||||
module.enable();
|
||||
|
||||
module.references.push([this.__path, name]);
|
||||
|
||||
if ( (module instanceof Module) && this.__processModule )
|
||||
module = this.__processModule(module);
|
||||
|
||||
return this[name] = module;
|
||||
}
|
||||
|
||||
|
||||
injectAs(variable, name, module, require = true) {
|
||||
if ( name instanceof Module || name.prototype instanceof Module ) {
|
||||
require = module != null ? module : true;
|
||||
module = name;
|
||||
name = null;
|
||||
}
|
||||
|
||||
const requires = this.requires = this.__get_requires() || [];
|
||||
|
||||
if ( module instanceof Module ) {
|
||||
// Existing Instance
|
||||
if ( ! name )
|
||||
name = module.constructor.name.toSnakeCase();
|
||||
|
||||
} else if ( module && module.prototype instanceof Module ) {
|
||||
// New Instance
|
||||
if ( ! name )
|
||||
name = module.name.toSnakeCase();
|
||||
|
||||
module = this.register(name, module);
|
||||
|
||||
} else if ( name ) {
|
||||
// Just a Name
|
||||
const full_name = name;
|
||||
name = name.replace(/^(?:[^.]*\.)+/, '');
|
||||
module = this.__resolve(full_name);
|
||||
|
||||
// Allow injecting a module that doesn't exist yet?
|
||||
|
||||
if ( ! module || !(module instanceof Module) ) {
|
||||
if ( module )
|
||||
module[2].push([this.__path, variable]);
|
||||
else
|
||||
this.__modules[this.abs_path(full_name)] = [[], [], [[this.__path, variable]]]
|
||||
|
||||
requires.push(this.abs_path(full_name));
|
||||
|
||||
return this[variable] = null;
|
||||
}
|
||||
|
||||
} else
|
||||
throw new TypeError(`must provide a valid module name or class`);
|
||||
|
||||
if ( ! module )
|
||||
throw new Error(`cannot find module ${name} or no module provided`);
|
||||
|
||||
if ( require )
|
||||
requires.push(module.abs_path('.'));
|
||||
|
||||
|
||||
if ( this.enabled && ! module.enabled )
|
||||
module.enable();
|
||||
|
||||
module.references.push([this.__path, variable]);
|
||||
|
||||
if ( (module instanceof Module) && this.__processModule )
|
||||
module = this.__processModule(module, name);
|
||||
|
||||
return this[variable] = module;
|
||||
}
|
||||
|
||||
|
||||
register(name, module, inject_reference) {
|
||||
if ( name.prototype instanceof Module ) {
|
||||
inject_reference = module;
|
||||
module = name;
|
||||
name = module.name.toSnakeCase();
|
||||
}
|
||||
|
||||
const path = this.abs_path(`.${name}`),
|
||||
proto = module.prototype,
|
||||
old_val = this.__modules[path];
|
||||
|
||||
if ( !(proto instanceof Module) )
|
||||
throw new TypeError(`Module ${name} is not subclass of Module.`);
|
||||
|
||||
if ( old_val instanceof Module )
|
||||
throw new ModuleError(`Name Collision for Module ${path}`);
|
||||
|
||||
const dependents = old_val || [[], [], []];
|
||||
let inst = this.__modules[path] = new module(name, this);
|
||||
const requires = inst.requires = inst.__get_requires() || [],
|
||||
load_requires = inst.load_requires = inst.__get_load_requires() || [];
|
||||
|
||||
inst.dependents = dependents[0];
|
||||
inst.load_dependents = dependents[1];
|
||||
inst.references = dependents[2];
|
||||
|
||||
if ( inst instanceof SiteModule && ! requires.includes('site') )
|
||||
requires.push('site');
|
||||
|
||||
for(const req_name of requires) {
|
||||
const req_path = inst.abs_path(req_name),
|
||||
req_mod = this.__modules[req_path];
|
||||
|
||||
if ( ! req_mod )
|
||||
this.__modules[req_path] = [[path],[],[]];
|
||||
else if ( Array.isArray(req_mod) )
|
||||
req_mod[0].push(path);
|
||||
else
|
||||
req_mod.dependents.push(path);
|
||||
}
|
||||
|
||||
for(const req_name of load_requires) {
|
||||
const req_path = inst.abs_path(req_name),
|
||||
req_mod = this.__modules[req_path];
|
||||
|
||||
if ( ! req_mod )
|
||||
this.__modules[req_path] = [[], [path], []];
|
||||
else if ( Array.isArray(req_mod) )
|
||||
req_mod[1].push(path);
|
||||
else
|
||||
req_mod.load_dependents.push(path);
|
||||
}
|
||||
|
||||
for(const [in_path, in_name] of dependents[2]) {
|
||||
const in_mod = this.__resolve(in_path);
|
||||
if ( in_mod )
|
||||
in_mod[in_name] = inst;
|
||||
else
|
||||
this.log.warn(`Unable to find module "${in_path}" that wanted "${in_name}".`);
|
||||
}
|
||||
|
||||
if ( (inst instanceof Module) && this.__processModule )
|
||||
inst = this.__processModule(inst, name);
|
||||
|
||||
if ( inject_reference )
|
||||
this[name] = inst;
|
||||
|
||||
return inst;
|
||||
}
|
||||
|
||||
|
||||
async populate(ctx, log) {
|
||||
log = log || this.log;
|
||||
const added = {};
|
||||
for(const raw_path of ctx.keys()) {
|
||||
const raw_module = await ctx(raw_path), // eslint-disable-line no-await-in-loop
|
||||
module = raw_module.module || raw_module.default,
|
||||
lix = raw_path.lastIndexOf('.'),
|
||||
trimmed = lix > 2 ? raw_path.slice(2, lix) : raw_path,
|
||||
name = trimmed.endsWith('/index') ? trimmed.slice(0, -6) : trimmed;
|
||||
|
||||
try {
|
||||
added[name] = this.register(name, module);
|
||||
} catch(err) {
|
||||
log && log.capture(err, {
|
||||
extra: {
|
||||
module: name,
|
||||
path: raw_path
|
||||
}
|
||||
});
|
||||
|
||||
log && log.warn(err, `Skipping ${raw_path}`);
|
||||
}
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
Module.State = State;
|
||||
Module.prototype.State = State;
|
||||
|
||||
|
||||
export class SiteModule extends Module {
|
||||
constructor(name, parent) {
|
||||
super(name, parent);
|
||||
this.site = this.resolve('site');
|
||||
}
|
||||
}
|
||||
|
||||
export default Module;
|
||||
|
||||
|
||||
export function buildAddonProxy(accessor, thing, name, overrides, access_warnings, no_proxy = false) {
|
||||
|
||||
const handler = {
|
||||
get(obj, prop) {
|
||||
// First, handle basic overrides behavior.
|
||||
let value = overrides[prop];
|
||||
if ( value !== undefined ) {
|
||||
// Check for functions, and bind their this.
|
||||
if ( typeof value === 'function' )
|
||||
return value.bind(obj);
|
||||
return value;
|
||||
}
|
||||
|
||||
// Next, handle access warnings.
|
||||
const warning = access_warnings && access_warnings[prop];
|
||||
if ( accessor?.log && warning )
|
||||
accessor.log.warn(`[DEV-CHECK] Accessed ${name}.${prop} directly. ${typeof warning === 'string' ? warning : ''}`)
|
||||
|
||||
// Check for functions, and bind their this.
|
||||
value = obj[prop];
|
||||
if ( typeof value === 'function' )
|
||||
return value.bind(obj);
|
||||
|
||||
// Make sure all module access is proxied.
|
||||
if ( accessor && (value instanceof Module) )
|
||||
return accessor.resolve(value);
|
||||
|
||||
// Return whatever it would be normally.
|
||||
return Reflect.get(...arguments);
|
||||
}
|
||||
};
|
||||
|
||||
return no_proxy ? handler : new Proxy(thing, handler);
|
||||
|
||||
}
|
||||
|
||||
Module.buildAddonProxy = buildAddonProxy;
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Errors
|
||||
// ============================================================================
|
||||
|
||||
export class ModuleError extends Error { }
|
||||
|
||||
export class CyclicDependencyError extends ModuleError {
|
||||
constructor(message, modules) {
|
||||
super(`${message} (${modules.map(x => x.path).join(' => ')})`);
|
||||
this.modules = modules;
|
||||
}
|
||||
}
|
1188
src/utilities/module.ts
Normal file
1188
src/utilities/module.ts
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,933 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
import {BAD_HOTKEYS, TWITCH_EMOTE_V2, WORD_SEPARATORS} from 'utilities/constants';
|
||||
|
||||
const HOP = Object.prototype.hasOwnProperty;
|
||||
|
||||
export function getTwitchEmoteURL(id, scale, animated = false, dark = true) {
|
||||
return `${TWITCH_EMOTE_V2}/${id}/${animated ? 'default' : 'static'}/${dark ? 'dark' : 'light'}/${scale == 4 ? 3 : scale}.0`
|
||||
}
|
||||
|
||||
export function getTwitchEmoteSrcSet(id, animated = false, dark = true, big = false) {
|
||||
if ( big )
|
||||
return `${getTwitchEmoteURL(id, 2, animated, dark)} 1x, ${getTwitchEmoteURL(id, 4, animated, dark)} 2x`;
|
||||
|
||||
return `${getTwitchEmoteURL(id, 1, animated, dark)} 1x, ${getTwitchEmoteURL(id, 2, animated, dark)} 2x, ${getTwitchEmoteURL(id, 4, animated, dark)} 4x`;
|
||||
}
|
||||
|
||||
export function isValidShortcut(key) {
|
||||
if ( ! key )
|
||||
return false;
|
||||
|
||||
key = key.toLowerCase().trim();
|
||||
return ! BAD_HOTKEYS.includes(key);
|
||||
}
|
||||
|
||||
// Source: https://gist.github.com/jed/982883 (WTFPL)
|
||||
export function generateUUID(input) {
|
||||
return input // if the placeholder was passed, return
|
||||
? ( // a random number from 0 to 15
|
||||
input ^ // unless b is 8,
|
||||
Math.random() // in which case
|
||||
* 16 // a random number from
|
||||
>> input/4 // 8 to 11
|
||||
).toString(16) // in hexadecimal
|
||||
: ( // or otherwise a concatenated string:
|
||||
[1e7] + // 10000000 +
|
||||
-1e3 + // -1000 +
|
||||
-4e3 + // -4000 +
|
||||
-8e3 + // -80000000 +
|
||||
-1e11 // -100000000000,
|
||||
).replace( // replacing
|
||||
/[018]/g, // zeroes, ones, and eights with
|
||||
generateUUID // random hex digits
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export async function sha256(message) {
|
||||
// encode as UTF-8
|
||||
const msgBuffer = new TextEncoder().encode(message);
|
||||
|
||||
// hash the message
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
|
||||
|
||||
// convert ArrayBuffer to Array
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
|
||||
// convert bytes to hex string
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
return hashHex;
|
||||
}
|
||||
|
||||
|
||||
/*export function sortScreens(screens) {
|
||||
screens.sort((a,b) => {
|
||||
if ( a.left < b.left ) return -1;
|
||||
if ( a.left > b.left ) return 1;
|
||||
if ( a.top < b.top ) return -1;
|
||||
if ( a.top > b.top ) return 1;
|
||||
return 0;
|
||||
});
|
||||
return screens;
|
||||
}*/
|
||||
|
||||
|
||||
export function matchScreen(screens, options) {
|
||||
let match = undefined;
|
||||
let mscore = 0;
|
||||
|
||||
for(let i = 0; i < screens.length; i++) {
|
||||
const mon = screens[i];
|
||||
if ( mon.label !== options.label )
|
||||
continue;
|
||||
|
||||
let score = 1;
|
||||
if ( options.left && options.left === mon.left )
|
||||
score += 15;
|
||||
if ( options.top && options.top === mon.top )
|
||||
score += 15;
|
||||
|
||||
if ( options.width && options.width === mon.width )
|
||||
score += 10;
|
||||
|
||||
if ( options.height && options.height === mon.height )
|
||||
score += 10;
|
||||
|
||||
if ( options.index )
|
||||
score -= Math.abs(options.index - i);
|
||||
|
||||
if ( score > mscore ) {
|
||||
match = mon;
|
||||
mscore = score;
|
||||
}
|
||||
}
|
||||
|
||||
return match;
|
||||
}
|
||||
|
||||
|
||||
export function has(object, key) {
|
||||
return object ? HOP.call(object, key) : false;
|
||||
}
|
||||
|
||||
|
||||
export function sleep(delay) {
|
||||
return new Promise(s => setTimeout(s, delay));
|
||||
}
|
||||
|
||||
export function make_enum(...array) {
|
||||
const out = {};
|
||||
|
||||
for(let i=0; i < array.length; i++) {
|
||||
const word = array[i];
|
||||
out[word] = i;
|
||||
out[i] = word;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function make_enum_flags(...array) {
|
||||
const out = {};
|
||||
|
||||
out.None = 0;
|
||||
out[0] = 'None';
|
||||
|
||||
for(let i = 0; i < array.length; i++) {
|
||||
const word = array[i],
|
||||
value = Math.pow(2, i);
|
||||
out[word] = value;
|
||||
out[value] = word;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
export function timeout(promise, delay) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolved = false;
|
||||
const timer = setTimeout(() => {
|
||||
if ( ! resolved ) {
|
||||
resolved = true;
|
||||
reject(new Error('timeout'));
|
||||
}
|
||||
}, delay);
|
||||
|
||||
promise.then(result => {
|
||||
if ( ! resolved ) {
|
||||
resolved = true;
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
}
|
||||
}).catch(err => {
|
||||
if ( ! resolved ) {
|
||||
resolved = true;
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export class Mutex {
|
||||
constructor(limit = 1) {
|
||||
this.limit = limit;
|
||||
this._active = 0;
|
||||
this._waiting = [];
|
||||
|
||||
this._done = this._done.bind(this);
|
||||
}
|
||||
|
||||
get available() { return this._active < this.limit }
|
||||
|
||||
_done() {
|
||||
this._active--;
|
||||
|
||||
while(this._active < this.limit && this._waiting.length > 0) {
|
||||
this._active++;
|
||||
const waiter = this._waiting.shift();
|
||||
waiter(this._done);
|
||||
}
|
||||
}
|
||||
|
||||
wait() {
|
||||
if ( this._active < this.limit) {
|
||||
this._active++;
|
||||
return Promise.resolve(this._done);
|
||||
}
|
||||
|
||||
return new Promise(s => this._waiting.push(s));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Return a wrapper for a function that will only execute the function
|
||||
* a period of time after it has stopped being called.
|
||||
* @param {Function} fn The function to wrap.
|
||||
* @param {Integer} delay The time to wait, in milliseconds
|
||||
* @param {Boolean} immediate If immediate is true, trigger the function immediately rather than eventually.
|
||||
* @returns {Function} wrapped function
|
||||
*/
|
||||
export function debounce(fn, delay, immediate) {
|
||||
let timer;
|
||||
if ( immediate ) {
|
||||
const later = () => timer = null;
|
||||
if ( immediate === 2 )
|
||||
// Special Mode! Run immediately OR later.
|
||||
return function(...args) {
|
||||
if ( timer ) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
fn.apply(this, args); // eslint-disable-line no-invalid-this
|
||||
}, delay);
|
||||
} else {
|
||||
fn.apply(this, args); // eslint-disable-line no-invalid-this
|
||||
timer = setTimeout(later, delay);
|
||||
}
|
||||
}
|
||||
|
||||
return function(...args) {
|
||||
if ( ! timer )
|
||||
fn.apply(this, args); // eslint-disable-line no-invalid-this
|
||||
else
|
||||
clearTimeout(timer);
|
||||
|
||||
timer = setTimeout(later, delay);
|
||||
}
|
||||
}
|
||||
|
||||
return function(...args) {
|
||||
if ( timer )
|
||||
clearTimeout(timer);
|
||||
|
||||
timer = setTimeout(fn.bind(this, ...args), delay); // eslint-disable-line no-invalid-this
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Make sure that a given asynchronous function is only called once
|
||||
* at a time.
|
||||
*/
|
||||
|
||||
export function once(fn) {
|
||||
let waiters;
|
||||
|
||||
return function(...args) {
|
||||
return new Promise(async (s,f) => {
|
||||
if ( waiters )
|
||||
return waiters.push([s,f]);
|
||||
|
||||
waiters = [[s,f]];
|
||||
let result;
|
||||
try {
|
||||
result = await fn.call(this, ...args); // eslint-disable-line no-invalid-this
|
||||
} catch(err) {
|
||||
for(const w of waiters)
|
||||
w[1](err);
|
||||
waiters = null;
|
||||
return;
|
||||
}
|
||||
|
||||
for(const w of waiters)
|
||||
w[0](result);
|
||||
|
||||
waiters = null;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check that two arrays are the same length and that each array has the same
|
||||
* items in the same indices.
|
||||
* @param {Array} a The first array
|
||||
* @param {Array} b The second array
|
||||
* @returns {boolean} Whether or not they match
|
||||
*/
|
||||
export function array_equals(a, b) {
|
||||
if ( ! Array.isArray(a) || ! Array.isArray(b) || a.length !== b.length )
|
||||
return false;
|
||||
|
||||
let i = a.length;
|
||||
while(i--)
|
||||
if ( a[i] !== b[i] )
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
export function deep_equals(object, other, ignore_undefined = false, seen, other_seen) {
|
||||
if ( object === other )
|
||||
return true;
|
||||
if ( typeof object !== typeof other )
|
||||
return false;
|
||||
if ( typeof object !== 'object' )
|
||||
return false;
|
||||
if ( (object === null) !== (other === null) )
|
||||
return false;
|
||||
|
||||
if ( ! seen )
|
||||
seen = new Set;
|
||||
|
||||
if ( ! other_seen )
|
||||
other_seen = new Set;
|
||||
|
||||
if ( seen.has(object) || other_seen.has(other) )
|
||||
throw new Error('recursive structure detected');
|
||||
|
||||
seen.add(object);
|
||||
other_seen.add(other);
|
||||
|
||||
const source_keys = Object.keys(object),
|
||||
dest_keys = Object.keys(other);
|
||||
|
||||
if ( ! ignore_undefined && ! set_equals(new Set(source_keys), new Set(dest_keys)) )
|
||||
return false;
|
||||
|
||||
for(const key of source_keys)
|
||||
if ( ! deep_equals(object[key], other[key], ignore_undefined, new Set(seen), new Set(other_seen)) )
|
||||
return false;
|
||||
|
||||
if ( ignore_undefined )
|
||||
for(const key of dest_keys)
|
||||
if ( ! source_keys.includes(key) ) {
|
||||
if ( ! deep_equals(object[key], other[key], ignore_undefined, new Set(seen), new Set(other_seen)) )
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
export function shallow_object_equals(a, b) {
|
||||
if ( typeof a !== 'object' || typeof b !== 'object' )
|
||||
return false;
|
||||
|
||||
const keys = Object.keys(a);
|
||||
if ( ! set_equals(new Set(keys), new Set(Object.keys(b))) )
|
||||
return false;
|
||||
|
||||
for(const key of keys)
|
||||
if ( a[key] !== b[key] )
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
export function map_equals(a, b) {
|
||||
if ( !(a instanceof Map) || !(b instanceof Map) || a.size !== b.size )
|
||||
return false;
|
||||
|
||||
for(const [key, val] of a)
|
||||
if ( ! b.has(key) || b.get(key) !== val )
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
export function set_equals(a,b) {
|
||||
if ( !(a instanceof Set) || !(b instanceof Set) || a.size !== b.size )
|
||||
return false;
|
||||
|
||||
for(const v of a)
|
||||
if ( ! b.has(v) )
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Special logic to ensure that a target object is matched by a filter.
|
||||
* @param {object} filter The filter object
|
||||
* @param {object} target The object to check it against
|
||||
* @returns {boolean} Whether or not it matches
|
||||
*/
|
||||
export function filter_match(filter, target) {
|
||||
for(const key in filter) {
|
||||
if ( HOP.call(filter, key) ) {
|
||||
const filter_value = filter[key],
|
||||
target_value = target[key],
|
||||
type = typeof filter_value;
|
||||
|
||||
if ( type === 'function' ) {
|
||||
if ( ! filter_value(target_value) )
|
||||
return false;
|
||||
|
||||
} else if ( Array.isArray(filter_value) ) {
|
||||
if ( Array.isArray(target_value) ) {
|
||||
for(const val of filter_value)
|
||||
if ( ! target_value.includes(val) )
|
||||
return false;
|
||||
|
||||
} else if ( ! filter_value.include(target_value) )
|
||||
return false;
|
||||
|
||||
} else if ( typeof target_value !== type )
|
||||
return false;
|
||||
|
||||
else if ( type === 'object' ) {
|
||||
if ( ! filter_match(filter_value, target_value) )
|
||||
return false;
|
||||
|
||||
} else if ( filter_value !== target_value )
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
export function substr_count(str, needle) {
|
||||
let i = 0, idx = 0;
|
||||
while( idx < str.length ) {
|
||||
const x = str.indexOf(needle, idx);
|
||||
if ( x === -1 )
|
||||
break;
|
||||
|
||||
i++;
|
||||
idx = x + 1;
|
||||
}
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get a value from an object at a path.
|
||||
* @param {string|Array} path The path to follow, using periods to go down a level.
|
||||
* @param {object|Array} object The starting object.
|
||||
* @returns {*} The value at that point in the path, or undefined if part of the path doesn't exist.
|
||||
*/
|
||||
export function get(path, object) {
|
||||
if ( HOP.call(object, path) )
|
||||
return object[path];
|
||||
|
||||
if ( typeof path === 'string' )
|
||||
path = path.split('.');
|
||||
|
||||
for(let i=0, l = path.length; i < l; i++) {
|
||||
const part = path[i];
|
||||
if ( part === '@each' ) {
|
||||
const p = path.slice(i + 1);
|
||||
if ( p.length ) {
|
||||
if ( Array.isArray )
|
||||
object = object.map(x => get(p, x));
|
||||
else {
|
||||
const new_object = {};
|
||||
for(const key in object)
|
||||
if ( HOP.call(object, key) )
|
||||
new_object[key] = get(p, object[key]);
|
||||
object = new_object;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
} else if ( part === '@last' )
|
||||
object = object[object.length - 1];
|
||||
else
|
||||
object = object[path[i]];
|
||||
|
||||
if ( ! object )
|
||||
break;
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Copy an object so that it can be safely serialized. If an object
|
||||
* is not serializable, such as a promise, returns null.
|
||||
*
|
||||
* @export
|
||||
* @param {*} object The thing to copy.
|
||||
* @param {Number} [depth=2] The maximum depth to explore the object.
|
||||
* @param {Set} [seen=null] A Set of seen objects. Internal use only.
|
||||
* @returns {Object} The copy to safely store or use.
|
||||
*/
|
||||
export function shallow_copy(object, depth = 2, seen = null) {
|
||||
if ( object == null )
|
||||
return object;
|
||||
|
||||
if ( object instanceof Promise || typeof object === 'function' )
|
||||
return null;
|
||||
|
||||
if ( typeof object !== 'object' )
|
||||
return object;
|
||||
|
||||
if ( depth === 0 )
|
||||
return null;
|
||||
|
||||
if ( ! seen )
|
||||
seen = new Set;
|
||||
|
||||
seen.add(object);
|
||||
|
||||
if ( Array.isArray(object) ) {
|
||||
const out = [];
|
||||
for(const val of object) {
|
||||
if ( seen.has(val) )
|
||||
continue;
|
||||
|
||||
out.push(shallow_copy(val, depth - 1, new Set(seen)));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
const out = {};
|
||||
for(const [key, val] of Object.entries(object) ) {
|
||||
if ( seen.has(val) )
|
||||
continue;
|
||||
|
||||
out[key] = shallow_copy(val, depth - 1, new Set(seen));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
export function deep_copy(object, seen) {
|
||||
if ( object === null )
|
||||
return null;
|
||||
else if ( object === undefined )
|
||||
return undefined;
|
||||
|
||||
if ( object instanceof Promise )
|
||||
return new Promise((s,f) => object.then(s).catch(f));
|
||||
|
||||
if ( typeof object === 'function' )
|
||||
return function(...args) { return object.apply(this, args); } // eslint-disable-line no-invalid-this
|
||||
|
||||
if ( typeof object !== 'object' )
|
||||
return object;
|
||||
|
||||
if ( ! seen )
|
||||
seen = new Set;
|
||||
|
||||
if ( seen.has(object) )
|
||||
throw new Error('recursive structure detected');
|
||||
|
||||
seen.add(object);
|
||||
|
||||
if ( Array.isArray(object) )
|
||||
return object.map(x => deep_copy(x, new Set(seen)));
|
||||
|
||||
const out = {};
|
||||
for(const key in object)
|
||||
if ( HOP.call(object, key) ) {
|
||||
const val = object[key];
|
||||
if ( typeof val === 'object' )
|
||||
out[key] = deep_copy(val, new Set(seen));
|
||||
else
|
||||
out[key] = val;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
export function normalizeAddonIdForComparison(input) {
|
||||
return input.toLowerCase().replace(/[\.\_\-]+/, '-');
|
||||
}
|
||||
|
||||
export function makeAddonIdChecker(input) {
|
||||
input = escape_regex(normalizeAddonIdForComparison(input));
|
||||
input = input.replace(/-+/g, '[\.\_\-]+');
|
||||
|
||||
// Special: ffzap-bttv
|
||||
input = input.replace(/\bbttv\b/g, '(?:bttv|betterttv)');
|
||||
|
||||
// Special: which seven tho
|
||||
input = input.replace(/\b7tv\b/g, '(?:7tv|seventv)');
|
||||
|
||||
// Special: pronouns (badges)
|
||||
input = input.replace(/\bpronouns\b/g, '(?:pronouns|addon-pn)');
|
||||
|
||||
return new RegExp('\\b' + input + '\\b', 'i');
|
||||
}
|
||||
|
||||
|
||||
export function maybe_call(fn, ctx, ...args) {
|
||||
if ( typeof fn === 'function' ) {
|
||||
if ( ctx )
|
||||
return fn.call(ctx, ...args);
|
||||
return fn(...args);
|
||||
}
|
||||
|
||||
return fn;
|
||||
}
|
||||
|
||||
|
||||
const SPLIT_REGEX = /[^\uD800-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDFFF]/g;
|
||||
|
||||
export function split_chars(str) {
|
||||
if ( str === '' )
|
||||
return [];
|
||||
|
||||
return str.match(SPLIT_REGEX);
|
||||
}
|
||||
|
||||
|
||||
export function pick_random(obj) {
|
||||
if ( ! obj )
|
||||
return null;
|
||||
|
||||
if ( ! Array.isArray(obj) )
|
||||
return obj[pick_random(Object.keys(obj))]
|
||||
|
||||
return obj[Math.floor(Math.random() * obj.length)];
|
||||
}
|
||||
|
||||
|
||||
export const escape_regex = RegExp.escape || function escape_regex(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
|
||||
export function addWordSeparators(str) {
|
||||
return `(^|.*?${WORD_SEPARATORS})(?:${str})(?=$|${WORD_SEPARATORS})`
|
||||
}
|
||||
|
||||
|
||||
const CONTROL_CHARS = '/$^+.()=!|';
|
||||
|
||||
export function glob_to_regex(input) {
|
||||
if ( typeof input !== 'string' )
|
||||
throw new TypeError('input must be a string');
|
||||
|
||||
let output = '',
|
||||
groups = 0;
|
||||
|
||||
for(let i=0, l=input.length; i<l; i++) {
|
||||
const char = input[i];
|
||||
|
||||
if ( CONTROL_CHARS.includes(char) )
|
||||
output += `\\${char}`;
|
||||
|
||||
else if ( char === '\\' ) {
|
||||
i++;
|
||||
const next = input[i];
|
||||
if ( next ) {
|
||||
if ( CONTROL_CHARS.includes(next) )
|
||||
output += `\\${next}`;
|
||||
else
|
||||
output += next;
|
||||
}
|
||||
|
||||
} else if ( char === '?' )
|
||||
output += '.';
|
||||
|
||||
else if ( char === '[' ) {
|
||||
output += char;
|
||||
const next = input[i + 1];
|
||||
if ( next === '!' ) {
|
||||
i++;
|
||||
output += '^';
|
||||
}
|
||||
|
||||
} else if ( char === ']' )
|
||||
output += char;
|
||||
|
||||
else if ( char === '{' ) {
|
||||
output += '(?:';
|
||||
groups++;
|
||||
|
||||
} else if ( char === '}' ) {
|
||||
if ( groups > 0 ) {
|
||||
output += ')';
|
||||
groups--;
|
||||
}
|
||||
|
||||
} else if ( char === ',' && groups > 0 )
|
||||
output += '|';
|
||||
|
||||
else if ( char === '*' ) {
|
||||
let count = 1;
|
||||
while(input[i+1] === '*') {
|
||||
count++;
|
||||
i++;
|
||||
}
|
||||
|
||||
if ( count > 1 )
|
||||
output += '.*?';
|
||||
else
|
||||
output += '[^\\s]*?';
|
||||
|
||||
} else
|
||||
output += char;
|
||||
}
|
||||
|
||||
/*while(groups > 0) {
|
||||
output += ')';
|
||||
groups--;
|
||||
}*/
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Truncate a string. Tries to intelligently break the string in white-space
|
||||
* if possible, without back-tracking. The returned string can be up to
|
||||
* `ellipsis.length + target + overage` characters long.
|
||||
* @param {String} str The string to truncate.
|
||||
* @param {Number} target The target length for the result
|
||||
* @param {Number} overage Accept up to this many additional characters for a better result
|
||||
* @param {String} [ellipsis='…'] The string to append when truncating
|
||||
* @param {Boolean} [break_line=true] If true, attempt to break at the first LF
|
||||
* @param {Boolean} [trim=true] If true, runs trim() on the string before truncating
|
||||
* @returns {String} The truncated string
|
||||
*/
|
||||
export function truncate(str, target = 100, overage = 15, ellipsis = '…', break_line = true, trim = true) {
|
||||
if ( ! str || ! str.length )
|
||||
return str;
|
||||
|
||||
if ( trim )
|
||||
str = str.trim();
|
||||
|
||||
let idx = break_line ? str.indexOf('\n') : -1;
|
||||
if ( idx === -1 || idx > target )
|
||||
idx = target;
|
||||
|
||||
if ( str.length <= idx )
|
||||
return str;
|
||||
|
||||
let out = str.slice(0, idx).trimRight();
|
||||
if ( overage > 0 && out.length >= idx ) {
|
||||
let next_space = str.slice(idx).search(/\s+/);
|
||||
if ( next_space === -1 && overage + idx > str.length )
|
||||
next_space = str.length - idx;
|
||||
|
||||
if ( next_space !== -1 && next_space <= overage ) {
|
||||
if ( str.length <= (idx + next_space) )
|
||||
return str;
|
||||
|
||||
out = str.slice(0, idx + next_space);
|
||||
}
|
||||
}
|
||||
|
||||
return out + ellipsis;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function decimalToHex(number) {
|
||||
return number.toString(16).padStart(2, '0')
|
||||
}
|
||||
|
||||
|
||||
export function generateHex(length = 40) {
|
||||
const arr = new Uint8Array(length / 2);
|
||||
window.crypto.getRandomValues(arr);
|
||||
return Array.from(arr, decimalToHex).join('')
|
||||
}
|
||||
|
||||
|
||||
export class SourcedSet {
|
||||
constructor(use_set = false) {
|
||||
this._use_set = use_set;
|
||||
this._cache = use_set ? new Set : [];
|
||||
}
|
||||
|
||||
_rebuild() {
|
||||
if ( ! this._sources )
|
||||
return;
|
||||
|
||||
const use_set = this._use_set,
|
||||
cache = this._cache = use_set ? new Set : [];
|
||||
for(const items of this._sources.values())
|
||||
for(const i of items)
|
||||
if ( use_set )
|
||||
cache.add(i);
|
||||
else if ( ! cache.includes(i) )
|
||||
this._cache.push(i);
|
||||
}
|
||||
|
||||
get(key) { return this._sources && this._sources.get(key) }
|
||||
has(key) { return this._sources ? this._sources.has(key) : false }
|
||||
|
||||
sourceIncludes(key, val) {
|
||||
const src = this._sources && this._sources.get(key);
|
||||
return src && src.includes(val);
|
||||
}
|
||||
|
||||
includes(val) {
|
||||
return this._use_set ? this._cache.has(val) : this._cache.includes(val);
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
if ( this._sources && this._sources.has(key) ) {
|
||||
this._sources.delete(key);
|
||||
this._rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
extend(key, ...items) {
|
||||
if ( ! this._sources )
|
||||
this._sources = new Map;
|
||||
|
||||
const had = this.has(key);
|
||||
if ( had )
|
||||
items = [...this._sources.get(key), ...items];
|
||||
|
||||
this._sources.set(key, items);
|
||||
if ( had )
|
||||
this._rebuild();
|
||||
else
|
||||
for(const i of items)
|
||||
if ( this._use_set )
|
||||
this._cache.add(i);
|
||||
else if ( ! this._cache.includes(i) )
|
||||
this._cache.push(i);
|
||||
}
|
||||
|
||||
set(key, val) {
|
||||
if ( ! this._sources )
|
||||
this._sources = new Map;
|
||||
|
||||
const had = this.has(key);
|
||||
if ( ! Array.isArray(val) )
|
||||
val = [val];
|
||||
|
||||
this._sources.set(key, val);
|
||||
if ( had )
|
||||
this._rebuild();
|
||||
else
|
||||
for(const i of val)
|
||||
if ( this._use_set )
|
||||
this._cache.add(i);
|
||||
else if ( ! this._cache.includes(i) )
|
||||
this._cache.push(i);
|
||||
}
|
||||
|
||||
push(key, val) {
|
||||
if ( ! this._sources )
|
||||
return this.set(key, val);
|
||||
|
||||
const old_val = this._sources.get(key);
|
||||
if ( old_val === undefined )
|
||||
return this.set(key, val);
|
||||
|
||||
else if ( old_val.includes(val) )
|
||||
return;
|
||||
|
||||
old_val.push(val);
|
||||
if ( this._use_set )
|
||||
this._cache.add(val);
|
||||
else if ( ! this._cache.includes(val) )
|
||||
this._cache.push(val);
|
||||
}
|
||||
|
||||
remove(key, val) {
|
||||
if ( ! this.has(key) )
|
||||
return;
|
||||
|
||||
const old_val = this._sources.get(key),
|
||||
idx = old_val.indexOf(val);
|
||||
|
||||
if ( idx === -1 )
|
||||
return;
|
||||
|
||||
old_val.splice(idx, 1);
|
||||
this._rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function b64ToArrayBuffer(input) {
|
||||
const bin = atob(input),
|
||||
len = bin.length,
|
||||
buffer = new ArrayBuffer(len),
|
||||
view = new Uint8Array(buffer);
|
||||
|
||||
for(let i = 0, len = bin.length; i < len; i++)
|
||||
view[i] = bin.charCodeAt(i);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
|
||||
const PEM_HEADER = /-----BEGIN (.+?) KEY-----/,
|
||||
PEM_FOOTER = /-----END (.+?) KEY-----/;
|
||||
|
||||
export function importRsaKey(pem, uses = ['verify']) {
|
||||
const start_match = PEM_HEADER.exec(pem),
|
||||
end_match = PEM_FOOTER.exec(pem);
|
||||
|
||||
if ( ! start_match || ! end_match || start_match[1] !== end_match[1] )
|
||||
throw new Error('invalid key');
|
||||
|
||||
const is_private = /\bPRIVATE\b/i.test(start_match[1]),
|
||||
start = start_match.index + start_match[0].length,
|
||||
end = end_match.index;
|
||||
|
||||
const content = pem.slice(start, end).replace(/\n/g, '').trim();
|
||||
//console.debug('content', JSON.stringify(content));
|
||||
|
||||
const buffer = b64ToArrayBuffer(content);
|
||||
|
||||
return crypto.subtle.importKey(
|
||||
is_private ? 'pkcs8' : 'spki',
|
||||
buffer,
|
||||
{
|
||||
name: "RSA-PSS",
|
||||
hash: "SHA-256"
|
||||
},
|
||||
true,
|
||||
uses
|
||||
);
|
||||
}
|
1339
src/utilities/object.ts
Normal file
1339
src/utilities/object.ts
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,18 +1,32 @@
|
|||
'use strict';
|
||||
|
||||
export function parse(path) {
|
||||
export function parse(path: string) {
|
||||
return parseAST({
|
||||
path,
|
||||
i: 0
|
||||
});
|
||||
}
|
||||
|
||||
function parseAST(ctx) {
|
||||
type ParseContext = {
|
||||
path: string;
|
||||
i: number;
|
||||
}
|
||||
|
||||
export type PathNode = {
|
||||
title: string;
|
||||
key: string;
|
||||
page: boolean;
|
||||
tab: boolean;
|
||||
}
|
||||
|
||||
function parseAST(ctx: ParseContext) {
|
||||
const path = ctx.path,
|
||||
length = path.length,
|
||||
out = [];
|
||||
|
||||
let token, raw;
|
||||
let token: PathNode | null = null,
|
||||
raw: string | null = null;
|
||||
|
||||
let old_tab = false,
|
||||
old_page = false;
|
||||
|
||||
|
@ -21,10 +35,11 @@ function parseAST(ctx) {
|
|||
char = path[start],
|
||||
next = path[start + 1];
|
||||
|
||||
if ( ! token ) {
|
||||
raw = [];
|
||||
token = {};
|
||||
}
|
||||
if ( ! token )
|
||||
token = {} as PathNode;
|
||||
|
||||
if ( ! raw )
|
||||
raw = '';
|
||||
|
||||
// JSON
|
||||
if ( char === '@' && next === '{') {
|
||||
|
@ -42,7 +57,7 @@ function parseAST(ctx) {
|
|||
segment = ! page && char === '>';
|
||||
|
||||
if ( ! segment && ! page && ! tab ) {
|
||||
raw.push(char);
|
||||
raw += char;
|
||||
ctx.i++;
|
||||
continue;
|
||||
}
|
||||
|
@ -52,7 +67,7 @@ function parseAST(ctx) {
|
|||
if ( tab || page )
|
||||
ctx.i++;
|
||||
|
||||
token.title = raw.join('').trim();
|
||||
token.title = raw.trim();
|
||||
token.key = token.title.toSnakeCase();
|
||||
|
||||
token.page = old_page;
|
||||
|
@ -66,8 +81,8 @@ function parseAST(ctx) {
|
|||
ctx.i++;
|
||||
}
|
||||
|
||||
if ( token ) {
|
||||
token.title = raw.join('').trim();
|
||||
if ( token && raw ) {
|
||||
token.title = raw.trim();
|
||||
token.key = token.title.toSnakeCase();
|
||||
token.page = old_page;
|
||||
token.tab = old_tab;
|
||||
|
@ -77,7 +92,7 @@ function parseAST(ctx) {
|
|||
return out;
|
||||
}
|
||||
|
||||
function parseJSON(ctx) {
|
||||
function parseJSON(ctx: ParseContext) {
|
||||
const path = ctx.path,
|
||||
length = path.length,
|
||||
|
||||
|
@ -86,7 +101,7 @@ function parseJSON(ctx) {
|
|||
ctx.i++;
|
||||
|
||||
const stack = ['{'];
|
||||
let string = false;
|
||||
let string: string | boolean = false;
|
||||
|
||||
while ( ctx.i < length && stack.length ) {
|
||||
const start = ctx.i,
|
|
@ -1,11 +1,16 @@
|
|||
'use strict';
|
||||
|
||||
export function getBuster(resolution = 5) {
|
||||
export function getBuster(resolution: number = 5) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return now - (now % resolution);
|
||||
}
|
||||
|
||||
export function duration_to_string(elapsed, separate_days, days_only, no_hours, no_seconds) {
|
||||
export function duration_to_string(
|
||||
elapsed: number,
|
||||
separate_days?: boolean,
|
||||
days_only?: boolean,
|
||||
no_hours?: boolean,
|
||||
no_seconds?: boolean
|
||||
) {
|
||||
const seconds = elapsed % 60;
|
||||
let minutes = Math.floor(elapsed / 60),
|
||||
hours = Math.floor(minutes / 60),
|
||||
|
@ -14,12 +19,12 @@ export function duration_to_string(elapsed, separate_days, days_only, no_hours,
|
|||
minutes = minutes % 60;
|
||||
|
||||
if ( separate_days ) {
|
||||
days = Math.floor(hours / 24);
|
||||
const day_count = Math.floor(hours / 24);
|
||||
hours = hours % 24;
|
||||
if ( days_only && days > 0 )
|
||||
if ( days_only && day_count > 0 )
|
||||
return `${days} days`;
|
||||
|
||||
days = days > 0 ? `${days} days, ` : '';
|
||||
days = day_count > 0 ? `${day_count} days, ` : '';
|
||||
}
|
||||
|
||||
const show_hours = (!no_hours || days || hours);
|
||||
|
@ -31,7 +36,7 @@ export function duration_to_string(elapsed, separate_days, days_only, no_hours,
|
|||
}
|
||||
|
||||
|
||||
export function print_duration(seconds) {
|
||||
export function print_duration(seconds: number) {
|
||||
let minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
|
@ -42,7 +47,7 @@ export function print_duration(seconds) {
|
|||
}
|
||||
|
||||
|
||||
export function durationForChat(elapsed) {
|
||||
export function durationForChat(elapsed: number) {
|
||||
const seconds = elapsed % 60;
|
||||
let minutes = Math.floor(elapsed / 60);
|
||||
let hours = Math.floor(minutes / 60);
|
||||
|
@ -55,7 +60,7 @@ export function durationForChat(elapsed) {
|
|||
}
|
||||
|
||||
|
||||
export function durationForURL(elapsed) {
|
||||
export function durationForURL(elapsed: number) {
|
||||
const seconds = elapsed % 60;
|
||||
let minutes = Math.floor(elapsed / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
|
@ -29,7 +29,7 @@ export default class Timing extends Module {
|
|||
});
|
||||
}
|
||||
|
||||
__time() { /* no-op */ } // eslint-disable-line class-methods-use-this
|
||||
_time() { /* no-op */ } // eslint-disable-line class-methods-use-this
|
||||
|
||||
addEvent(event) {
|
||||
event.ts = performance.now();
|
||||
|
|
|
@ -11,7 +11,20 @@
|
|||
import {createElement, setChildren} from 'utilities/dom';
|
||||
import {maybe_call, debounce, has} from 'utilities/object';
|
||||
|
||||
import {createPopper} from '@popperjs/core';
|
||||
import {Instance, createPopper} from '@popperjs/core';
|
||||
import type Logger from './logging';
|
||||
import type { OptionallyCallable } from './types';
|
||||
|
||||
// Extensions to things.
|
||||
declare global {
|
||||
interface HTMLElement {
|
||||
_ffz_tooltip?: TooltipInstance | null;
|
||||
|
||||
_ffz_move_handler?: MouseEventHandler | null;
|
||||
_ffz_over_handler?: MouseEventHandler | null;
|
||||
_ffz_out_handler?: MouseEventHandler | null;
|
||||
}
|
||||
}
|
||||
|
||||
let last_id = 0;
|
||||
|
||||
|
@ -30,40 +43,146 @@ export const DefaultOptions = {
|
|||
}
|
||||
|
||||
|
||||
type TooltipOptional<TReturn> = OptionallyCallable<[target: HTMLElement, tip: TooltipInstance], TReturn>;
|
||||
|
||||
|
||||
export type TooltipOptions = {
|
||||
html: boolean;
|
||||
delayShow: TooltipOptional<number>;
|
||||
delayHide: TooltipOptional<number>;
|
||||
live: boolean;
|
||||
manual: boolean;
|
||||
check_modifiers: boolean;
|
||||
|
||||
logger?: Logger;
|
||||
|
||||
// TODO: Replace this with proper i18n.
|
||||
i18n?: {
|
||||
t: (key: string, phrase: string, data: any) => string;
|
||||
};
|
||||
|
||||
container?: HTMLElement;
|
||||
|
||||
tooltipClass: string;
|
||||
innerClass: string;
|
||||
arrowClass: string;
|
||||
arrowInner?: string;
|
||||
|
||||
sanitizeChildren: boolean;
|
||||
|
||||
popper?: any;
|
||||
popperConfig?: TipFunction<any, [any]>;
|
||||
|
||||
no_update?: boolean;
|
||||
no_auto_remove?: boolean;
|
||||
|
||||
content: ContentFunction;
|
||||
interactive: TooltipOptional<boolean>;
|
||||
hover_events: TooltipOptional<boolean>;
|
||||
|
||||
onShow: TipFunction<void>;
|
||||
onHide: TipFunction<void>;
|
||||
|
||||
onMove: TipFunction<void, [MouseEvent]>;
|
||||
onHover: TipFunction<void, [MouseEvent]>;
|
||||
onLeave: TipFunction<void, [MouseEvent]>;
|
||||
|
||||
}
|
||||
|
||||
|
||||
export interface TooltipInstance {
|
||||
|
||||
target: HTMLElement;
|
||||
visible: boolean;
|
||||
|
||||
element: HTMLElement | null;
|
||||
outer: HTMLElement | null;
|
||||
|
||||
align?: string;
|
||||
add_class?: string | string[];
|
||||
|
||||
state: boolean;
|
||||
_show_timer?: ReturnType<typeof setTimeout> | null;
|
||||
|
||||
popper?: Instance | null;
|
||||
|
||||
_update: () => void;
|
||||
_waiter: (() => void) | null;
|
||||
_wait_promise: Promise<void> | null;
|
||||
|
||||
waitForDom: () => Promise<void>;
|
||||
update: () => void;
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
rerender: () => void;
|
||||
|
||||
}
|
||||
|
||||
// TODO: Narrow the return type. Need better types for createElement / setChildren
|
||||
type ContentFunction = TipFunction<any>;
|
||||
|
||||
type TipFunction<TReturn = unknown, TArgs extends any[] = []> = (target: HTMLElement, tip: TooltipInstance, ...extra: TArgs) => TReturn;
|
||||
|
||||
|
||||
type MouseEventHandler = (event: MouseEvent) => void;
|
||||
type KeyboardEventHandler = (event: KeyboardEvent) => void;
|
||||
|
||||
// ============================================================================
|
||||
// Tooltip Class
|
||||
// ============================================================================
|
||||
|
||||
export class Tooltip {
|
||||
constructor(parent, cls, options) {
|
||||
if ( typeof parent === 'string' )
|
||||
parent = document.querySelector(parent);
|
||||
|
||||
if (!( parent instanceof Node ))
|
||||
cls: Element | Element[] | string;
|
||||
|
||||
options: TooltipOptions;
|
||||
live: boolean;
|
||||
|
||||
parent: HTMLElement;
|
||||
container: HTMLElement;
|
||||
elements: Set<HTMLElement>;
|
||||
|
||||
private _accessor: string;
|
||||
|
||||
private _onMouseOut: MouseEventHandler;
|
||||
private _onMouseOver?: MouseEventHandler;
|
||||
private _keyUpdate?: KeyboardEventHandler | null;
|
||||
|
||||
shift_state: boolean = false;
|
||||
ctrl_state: boolean = false;
|
||||
|
||||
private _shift_af?: ReturnType<typeof requestAnimationFrame> | null;
|
||||
|
||||
|
||||
constructor(parent: HTMLElement | string | null, cls: HTMLElement | HTMLElement[] | string, options: Partial<TooltipOptions>) {
|
||||
if ( typeof parent === 'string' )
|
||||
parent = document.querySelector(parent) as HTMLElement;
|
||||
|
||||
if (!( parent instanceof HTMLElement ))
|
||||
throw new TypeError('invalid parent');
|
||||
|
||||
this.options = Object.assign({}, DefaultOptions, options);
|
||||
this.options = Object.assign({}, DefaultOptions, options) as TooltipOptions;
|
||||
this.live = this.options.live;
|
||||
this.check_modifiers = this.options.check_modifiers;
|
||||
|
||||
this.parent = parent;
|
||||
this.container = this.options.container || this.parent;
|
||||
this.cls = cls;
|
||||
|
||||
if ( this.check_modifiers )
|
||||
if ( this.options.check_modifiers )
|
||||
this.installModifiers();
|
||||
|
||||
if ( ! this.live ) {
|
||||
let elements;
|
||||
if ( typeof cls === 'string' )
|
||||
this.elements = parent.querySelectorAll(cls);
|
||||
elements = Array.from(parent.querySelectorAll(cls)) as HTMLElement[];
|
||||
else if ( Array.isArray(cls) )
|
||||
this.elements = cls;
|
||||
else if ( cls instanceof Node )
|
||||
this.elements = [cls];
|
||||
elements = cls;
|
||||
else if ( cls instanceof HTMLElement )
|
||||
elements = [cls];
|
||||
else
|
||||
throw new TypeError('invalid elements');
|
||||
|
||||
this.elements = new Set(this.elements);
|
||||
this.elements = new Set(elements);
|
||||
|
||||
} else {
|
||||
this.cls = cls;
|
||||
|
@ -72,7 +191,11 @@ export class Tooltip {
|
|||
|
||||
this._accessor = `_ffz_tooltip$${last_id++}`;
|
||||
|
||||
this._onMouseOut = e => e.target && e.target?.dataset?.forceOpen !== 'true' && this._exit(e.target);
|
||||
this._onMouseOut = e => {
|
||||
const target = e.target as HTMLElement;
|
||||
if ( target && target.dataset?.forceOpen !== 'true' )
|
||||
return this._exit(target);
|
||||
};
|
||||
|
||||
if ( this.options.manual ) {
|
||||
// Do nothing~!
|
||||
|
@ -80,8 +203,8 @@ export class Tooltip {
|
|||
} else if ( this.live ) {
|
||||
this._onMouseOver = e => {
|
||||
this.updateShift(e.shiftKey, e.ctrlKey);
|
||||
const target = e.target;
|
||||
if ( target && target.classList && target.classList.contains(this.cls) && target.dataset.forceOpen !== 'true' ) {
|
||||
const target = e.target as HTMLElement;
|
||||
if ( target && target.classList && target.classList.contains(this.cls as string) && target.dataset?.forceOpen !== 'true' ) {
|
||||
this._enter(target);
|
||||
}
|
||||
};
|
||||
|
@ -92,10 +215,9 @@ export class Tooltip {
|
|||
} else {
|
||||
this._onMouseOver = e => {
|
||||
this.updateShift(e.shiftKey, e.ctrlKey);
|
||||
const target = e.target;
|
||||
if ( this.elements.has(target) && target.dataset.forceOpen !== 'true' ) {
|
||||
this._enter(e.target);
|
||||
}
|
||||
const target = e.target as HTMLElement;
|
||||
if ( this.elements.has(target) && target.dataset.forceOpen !== 'true' )
|
||||
this._enter(target);
|
||||
}
|
||||
|
||||
if ( this.elements.size <= 5 )
|
||||
|
@ -118,27 +240,30 @@ export class Tooltip {
|
|||
if ( this.options.manual ) {
|
||||
// Do nothing~!
|
||||
} else if ( this.live || this.elements.size > 5 ) {
|
||||
this.parent.removeEventListener('mouseover', this._onMouseOver);
|
||||
if ( this._onMouseOver )
|
||||
this.parent.removeEventListener('mouseover', this._onMouseOver);
|
||||
this.parent.removeEventListener('mouseout', this._onMouseOut);
|
||||
} else
|
||||
for(const el of this.elements) {
|
||||
el.removeEventListener('mouseenter', this._onMouseOver);
|
||||
if ( this._onMouseOver )
|
||||
el.removeEventListener('mouseenter', this._onMouseOver);
|
||||
el.removeEventListener('mouseleave', this._onMouseOut);
|
||||
}
|
||||
|
||||
for(const el of this.elements) {
|
||||
const tip = el[this._accessor];
|
||||
const tip = (el as any)[this._accessor] as TooltipInstance;
|
||||
if ( tip && tip.visible )
|
||||
this.hide(tip);
|
||||
|
||||
el[this._accessor] = null;
|
||||
(el as any)[this._accessor] = null;
|
||||
el._ffz_tooltip = null;
|
||||
}
|
||||
|
||||
this.elements = null;
|
||||
this._onMouseOut = this._onMouseOver = null;
|
||||
this.container = null;
|
||||
this.parent = null;
|
||||
// Lazy types. We don't care.
|
||||
this.elements = null as any;
|
||||
this._onMouseOut = this._onMouseOver = null as any;
|
||||
this.container = null as any;
|
||||
this.parent = null as any;
|
||||
}
|
||||
|
||||
|
||||
|
@ -160,7 +285,7 @@ export class Tooltip {
|
|||
this._keyUpdate = null;
|
||||
}
|
||||
|
||||
updateShift(state, ctrl_state) {
|
||||
updateShift(state: boolean, ctrl_state: boolean) {
|
||||
if ( state === this.shift_state && ctrl_state === this.ctrl_state )
|
||||
return;
|
||||
|
||||
|
@ -172,12 +297,11 @@ export class Tooltip {
|
|||
this._shift_af = null;
|
||||
if ( this.elements )
|
||||
for(const el of this.elements) {
|
||||
const tip = el[this._accessor];
|
||||
const tip = (el as any)[this._accessor] as TooltipInstance;
|
||||
if ( tip && tip.outer ) {
|
||||
tip.outer.dataset.shift = this.shift_state;
|
||||
tip.outer.dataset.ctrl = this.ctrl_state;
|
||||
tip.outer.dataset.shift = `${this.shift_state}`;
|
||||
tip.outer.dataset.ctrl = `${this.ctrl_state}`;
|
||||
tip.update();
|
||||
//tip.updateVideo();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -189,7 +313,7 @@ export class Tooltip {
|
|||
return;
|
||||
|
||||
for(const el of this.elements) {
|
||||
const tip = el[this._accessor];
|
||||
const tip = (el as any)[this._accessor] as TooltipInstance;
|
||||
if ( document.body.contains(el) )
|
||||
continue;
|
||||
|
||||
|
@ -199,10 +323,10 @@ export class Tooltip {
|
|||
}
|
||||
|
||||
|
||||
_enter(target) {
|
||||
let tip = target[this._accessor];
|
||||
_enter(target: HTMLElement) {
|
||||
let tip = (target as any)[this._accessor] as TooltipInstance;
|
||||
if ( ! tip )
|
||||
tip = target[this._accessor] = {target};
|
||||
tip = (target as any)[this._accessor] = {target} as TooltipInstance;
|
||||
|
||||
tip.state = true;
|
||||
|
||||
|
@ -227,8 +351,8 @@ export class Tooltip {
|
|||
}, delay);
|
||||
}
|
||||
|
||||
_exit(target) {
|
||||
const tip = target[this._accessor];
|
||||
_exit(target: HTMLElement) {
|
||||
const tip = (target as any)[this._accessor] as TooltipInstance;
|
||||
if ( ! tip )
|
||||
return;
|
||||
|
||||
|
@ -257,7 +381,7 @@ export class Tooltip {
|
|||
}
|
||||
|
||||
|
||||
show(tip) {
|
||||
show(tip: TooltipInstance) {
|
||||
const opts = this.options,
|
||||
target = tip.target;
|
||||
|
||||
|
@ -265,26 +389,21 @@ export class Tooltip {
|
|||
target._ffz_tooltip = tip;
|
||||
|
||||
// Set this early in case content uses it early.
|
||||
tip._promises = [];
|
||||
tip.waitForDom = () => tip.element ? Promise.resolve() : new Promise(s => {tip._promises.push(s)});
|
||||
tip._wait_promise = null;
|
||||
tip.waitForDom = () => {
|
||||
if ( tip.element )
|
||||
return Promise.resolve();
|
||||
if ( ! tip._wait_promise )
|
||||
tip._wait_promise = new Promise(resolve => tip._waiter = resolve);
|
||||
return tip._wait_promise;
|
||||
};
|
||||
tip.update = () => tip._update(); // tip.popper && tip.popper.scheduleUpdate();
|
||||
tip.show = () => {
|
||||
let tip = target[this._accessor];
|
||||
let tip = (target as any)[this._accessor] as TooltipInstance;
|
||||
if ( ! tip )
|
||||
tip = target[this._accessor] = {target};
|
||||
tip = (target as any)[this._accessor] = {target} as TooltipInstance;
|
||||
this.show(tip);
|
||||
};
|
||||
tip.updateVideo = () => {
|
||||
if ( ! tip.element )
|
||||
return;
|
||||
const videos = tip.element.querySelectorAll('video');
|
||||
for(const video of videos) {
|
||||
if ( this.ctrl_state )
|
||||
video.play();
|
||||
else
|
||||
video.pause();
|
||||
}
|
||||
};
|
||||
tip.hide = () => this.hide(tip);
|
||||
tip.rerender = () => {
|
||||
if ( tip.visible ) {
|
||||
|
@ -301,7 +420,10 @@ export class Tooltip {
|
|||
return;
|
||||
|
||||
// Build the DOM.
|
||||
const arrow = createElement('div', opts.arrowClass),
|
||||
const arrow = createElement('div', {
|
||||
className: opts.arrowClass,
|
||||
'x-arrow': true
|
||||
}),
|
||||
inner = tip.element = createElement('div', opts.innerClass),
|
||||
|
||||
el = tip.outer = createElement('div', {
|
||||
|
@ -310,8 +432,6 @@ export class Tooltip {
|
|||
'data-ctrl': this.ctrl_state
|
||||
}, [inner, arrow]);
|
||||
|
||||
arrow.setAttribute('x-arrow', true);
|
||||
|
||||
if ( opts.arrowInner )
|
||||
arrow.appendChild(createElement('div', opts.arrowInner));
|
||||
|
||||
|
@ -386,12 +506,12 @@ export class Tooltip {
|
|||
if ( opts.popperConfig )
|
||||
pop_opts = opts.popperConfig(target, tip, pop_opts) ?? pop_opts;
|
||||
|
||||
pop_opts.onUpdate = tip._on_update = debounce(() => {
|
||||
pop_opts.onUpdate = debounce(() => {
|
||||
if ( ! opts.no_auto_remove && ! document.contains(tip.target) )
|
||||
this.hide(tip);
|
||||
}, 250);
|
||||
|
||||
let popper_target = target;
|
||||
let popper_target: any = target;
|
||||
if ( opts.no_update )
|
||||
popper_target = makeReference(target);
|
||||
|
||||
|
@ -401,12 +521,13 @@ export class Tooltip {
|
|||
}
|
||||
}
|
||||
|
||||
for(const fn of tip._promises)
|
||||
fn();
|
||||
if ( tip._waiter )
|
||||
tip._waiter();
|
||||
|
||||
tip._promises = null;
|
||||
tip._waiter = null;
|
||||
tip._wait_promise = null;
|
||||
|
||||
if ( content instanceof Promise || (content?.then && content.toString() === '[object Promise]') ) {
|
||||
if ( content instanceof Promise ) { //} || (content?.then && content.toString() === '[object Promise]') ) {
|
||||
inner.innerHTML = '<div class="ffz-i-zreknarf loader"></div>';
|
||||
content.then(content => {
|
||||
if ( ! content )
|
||||
|
@ -451,7 +572,7 @@ export class Tooltip {
|
|||
}
|
||||
|
||||
|
||||
hide(tip) { // eslint-disable-line class-methods-use-this
|
||||
hide(tip: TooltipInstance) {
|
||||
const opts = this.options;
|
||||
if ( opts.onHide )
|
||||
opts.onHide(tip.target, tip);
|
||||
|
@ -482,7 +603,7 @@ export class Tooltip {
|
|||
if ( tip.target._ffz_tooltip === tip )
|
||||
tip.target._ffz_tooltip = null;
|
||||
|
||||
tip.target[this._accessor] = null;
|
||||
(tip.target as any)[this._accessor] = null;
|
||||
tip._update = tip.rerender = tip.update = noop;
|
||||
tip.element = null;
|
||||
tip.visible = false;
|
||||
|
@ -492,17 +613,20 @@ export class Tooltip {
|
|||
export default Tooltip;
|
||||
|
||||
|
||||
export function normalizeModifiers(input) {
|
||||
const output = [];
|
||||
// Is this gross? Yes.
|
||||
// You know what else is gross?
|
||||
// Popper's type definitions.
|
||||
export function normalizeModifiers(input: any) {
|
||||
const output: any[] = [];
|
||||
|
||||
for(const [key, val] of Object.entries(input)) {
|
||||
const thing = {
|
||||
const thing: any = {
|
||||
name: key
|
||||
};
|
||||
|
||||
if (val && typeof val === 'object' && ! Array.isArray(val)) {
|
||||
if (has(val, 'enabled'))
|
||||
thing.enabled = val.enabled;
|
||||
thing.enabled = (val as any).enabled;
|
||||
|
||||
const keys = Object.keys(val);
|
||||
if (keys.length > 1 || (keys.length === 1 && keys[0] !== 'enabled'))
|
||||
|
@ -516,22 +640,25 @@ export function normalizeModifiers(input) {
|
|||
}
|
||||
|
||||
|
||||
export function makeReference(x, y, height=0, width=0) {
|
||||
if ( x instanceof Node ) {
|
||||
export function makeReference(x: HTMLElement | number, y?: number, height: number = 0, width: number = 0) {
|
||||
let _x: number;
|
||||
|
||||
if ( x instanceof HTMLElement ) {
|
||||
const rect = x.getBoundingClientRect();
|
||||
x = rect.x;
|
||||
_x = rect.x;
|
||||
y = rect.y;
|
||||
height = rect.height;
|
||||
width = rect.width;
|
||||
}
|
||||
} else
|
||||
_x = x;
|
||||
|
||||
const out = {
|
||||
getBoundingClientRect: () => ({
|
||||
top: y,
|
||||
bottom: y + height,
|
||||
bottom: (y as number) + height,
|
||||
y,
|
||||
left: x,
|
||||
right: x + width,
|
||||
left: _x,
|
||||
right: _x + width,
|
||||
x,
|
||||
height,
|
||||
width
|
|
@ -7,25 +7,26 @@
|
|||
import dayjs from 'dayjs';
|
||||
import RelativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
import {get} from 'utilities/object';
|
||||
import {duration_to_string} from 'utilities/time';
|
||||
|
||||
import Parser, { MessageAST, MessageNode, MessageVariable, ParserOptions } from '@ffz/icu-msgparser';
|
||||
|
||||
dayjs.extend(RelativeTime);
|
||||
|
||||
|
||||
import Parser from '@ffz/icu-msgparser';
|
||||
|
||||
const DEFAULT_PARSER_OPTIONS = {
|
||||
allowTags: false,
|
||||
requireOther: false
|
||||
};
|
||||
} as Partial<ParserOptions>;
|
||||
|
||||
import {get} from 'utilities/object';
|
||||
import {duration_to_string} from 'utilities/time';
|
||||
|
||||
export type TypeFormatter = (this: TranslationCore, val: any, node: MessageVariable, locale: string, out: any[], ast: MessageAST, data: any) => any;
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export const DEFAULT_TYPES = {
|
||||
export const DEFAULT_TYPES: Record<string, TypeFormatter> = {
|
||||
tostring(val) {
|
||||
return `${val}`
|
||||
},
|
||||
|
@ -65,7 +66,7 @@ export const DEFAULT_TYPES = {
|
|||
val = new_val;
|
||||
}
|
||||
|
||||
return this.formatNumber(val, node.f);
|
||||
return this.formatNumber(val, node.f as string);
|
||||
},
|
||||
|
||||
currency(val, node) {
|
||||
|
@ -79,19 +80,19 @@ export const DEFAULT_TYPES = {
|
|||
val = new_val;
|
||||
}
|
||||
|
||||
return this.formatCurrency(val, node.f);
|
||||
return this.formatCurrency(val, node.f as string);
|
||||
},
|
||||
|
||||
date(val, node) {
|
||||
return this.formatDate(val, node.f);
|
||||
return this.formatDate(val, node.f as string);
|
||||
},
|
||||
|
||||
time(val, node) {
|
||||
return this.formatTime(val, node.f);
|
||||
return this.formatTime(val, node.f as string);
|
||||
},
|
||||
|
||||
datetime(val, node) {
|
||||
return this.formatDateTime(val, node.f);
|
||||
return this.formatDateTime(val, node.f as string);
|
||||
},
|
||||
|
||||
duration(val) {
|
||||
|
@ -103,17 +104,19 @@ export const DEFAULT_TYPES = {
|
|||
},
|
||||
|
||||
relativetime(val, node) {
|
||||
return this.formatRelativeTime(val, node.f);
|
||||
return this.formatRelativeTime(val, node.f as string);
|
||||
},
|
||||
|
||||
humantime(val, node) {
|
||||
return this.formatRelativeTime(val, node.f);
|
||||
return this.formatRelativeTime(val, node.f as string);
|
||||
},
|
||||
|
||||
en_plural: v => v !== 1 ? 's' : ''
|
||||
en_plural: (v: number) => v !== 1 ? 's' : ''
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export const DEFAULT_FORMATS = {
|
||||
number: {
|
||||
currency: {
|
||||
|
@ -209,15 +212,83 @@ export const DEFAULT_FORMATS = {
|
|||
timeZoneName: 'short'
|
||||
}
|
||||
}
|
||||
} as FormattingOptions;
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Options
|
||||
// ============================================================================
|
||||
|
||||
type WarningMethod = typeof console.warn;
|
||||
|
||||
type RecursivePhraseMap = {
|
||||
[key: string]: RecursivePhraseMap | string
|
||||
};
|
||||
|
||||
|
||||
export type FormattingOptions = {
|
||||
number: Record<string, Intl.NumberFormatOptions>,
|
||||
date: Record<string, Intl.DateTimeFormatOptions>,
|
||||
time: Record<string, Intl.DateTimeFormatOptions>,
|
||||
datetime: Record<string, Intl.DateTimeFormatOptions>
|
||||
};
|
||||
|
||||
|
||||
export type TranslationOptions = {
|
||||
warn?: WarningMethod;
|
||||
|
||||
locale?: string;
|
||||
dayjsLocale?: string;
|
||||
defaultLocale?: string;
|
||||
|
||||
types?: Record<string, TypeFormatter>;
|
||||
formats?: Partial<FormattingOptions>;
|
||||
phrases?: RecursivePhraseMap;
|
||||
|
||||
parserOptions?: Partial<ParserOptions>,
|
||||
|
||||
defaultDateFormat: string;
|
||||
defaultTimeFormat: string;
|
||||
defaultDateTimeFormat: string;
|
||||
}
|
||||
|
||||
export type ParseTranslationSettings = {
|
||||
noCache?: boolean;
|
||||
throwParse?: boolean;
|
||||
noWarn?: boolean;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// TranslationCore
|
||||
// ============================================================================
|
||||
|
||||
export default class TranslationCore {
|
||||
constructor(options) {
|
||||
export class TranslationCore {
|
||||
|
||||
warn?: WarningMethod;
|
||||
|
||||
parser: Parser;
|
||||
phrases: Map<string, string>;
|
||||
cache: Map<string, MessageAST>;
|
||||
|
||||
transformation: ((key: string, ast: MessageAST) => MessageAST) | null;
|
||||
|
||||
types: Record<string, TypeFormatter>;
|
||||
formats: FormattingOptions;
|
||||
|
||||
private _locale: string;
|
||||
private _dayjs_locale: string;
|
||||
|
||||
defaultLocale: string;
|
||||
defaultDateFormat: string;
|
||||
defaultTimeFormat: string;
|
||||
defaultDateTimeFormat: string;
|
||||
|
||||
numberFormats: Map<string, Intl.NumberFormat>;
|
||||
currencyFormats: Map<string, Intl.NumberFormat>;
|
||||
|
||||
constructor(options?: Partial<TranslationOptions>) {
|
||||
options = options || {};
|
||||
|
||||
this.warn = options.warn;
|
||||
|
@ -226,9 +297,9 @@ export default class TranslationCore {
|
|||
this.defaultLocale = options.defaultLocale || this._locale;
|
||||
this.transformation = null;
|
||||
|
||||
this.defaultDateFormat = options.defaultDateFormat;
|
||||
this.defaultTimeFormat = options.defaultTimeFormat;
|
||||
this.defaultDateTimeFormat = options.defaultDateTimeFormat;
|
||||
this.defaultDateFormat = options.defaultDateFormat ?? 'default';
|
||||
this.defaultTimeFormat = options.defaultTimeFormat ?? 'short';
|
||||
this.defaultDateTimeFormat = options.defaultDateTimeFormat ?? 'medium';
|
||||
|
||||
this.phrases = new Map;
|
||||
this.cache = new Map;
|
||||
|
@ -237,9 +308,12 @@ export default class TranslationCore {
|
|||
this.currencyFormats = new Map;
|
||||
|
||||
this.formats = Object.assign({}, DEFAULT_FORMATS);
|
||||
if ( options.formats )
|
||||
for(const key of Object.keys(options.formats))
|
||||
this.formats[key] = Object.assign({}, this.formats[key], options.formats[key]);
|
||||
if ( options.formats ) {
|
||||
// I have no idea why the types are so picky here.
|
||||
const keys = Object.keys(options.formats) as (keyof FormattingOptions)[];
|
||||
for(const key of keys)
|
||||
(this.formats as any)[key] = Object.assign({}, this.formats[key], options.formats[key]);
|
||||
}
|
||||
|
||||
this.types = Object.assign({}, DEFAULT_TYPES, options.types || {});
|
||||
this.parser = new Parser(Object.assign({}, DEFAULT_PARSER_OPTIONS, options.parserOptions));
|
||||
|
@ -260,15 +334,15 @@ export default class TranslationCore {
|
|||
}
|
||||
}
|
||||
|
||||
toLocaleString(thing) {
|
||||
toLocaleString(thing: any) {
|
||||
if ( thing && thing.toLocaleString )
|
||||
return thing.toLocaleString(this._locale);
|
||||
return thing;
|
||||
}
|
||||
|
||||
formatRelativeTime(value, f) { // eslint-disable-line class-methods-use-this
|
||||
formatRelativeTime(value: string | number | Date, format?: string) { // eslint-disable-line class-methods-use-this
|
||||
const d = dayjs(value),
|
||||
without_suffix = f === 'plain';
|
||||
without_suffix = format === 'plain';
|
||||
|
||||
try {
|
||||
return d.locale(this._dayjs_locale).fromNow(without_suffix);
|
||||
|
@ -277,10 +351,10 @@ export default class TranslationCore {
|
|||
}
|
||||
}
|
||||
|
||||
formatCurrency(value, currency) {
|
||||
formatCurrency(value: number | bigint, currency: string) {
|
||||
let formatter = this.currencyFormats.get(currency);
|
||||
if ( ! formatter ) {
|
||||
formatter = new Intl.NumberFormat(navigator.languages, {
|
||||
formatter = new Intl.NumberFormat(navigator.languages as string[], {
|
||||
style: 'currency',
|
||||
currency
|
||||
});
|
||||
|
@ -291,7 +365,7 @@ export default class TranslationCore {
|
|||
return formatter.format(value);
|
||||
}
|
||||
|
||||
formatNumber(value, format) {
|
||||
formatNumber(value: number | bigint, format: string) {
|
||||
let formatter = this.numberFormats.get(format);
|
||||
if ( ! formatter ) {
|
||||
if ( this.formats.number[format] )
|
||||
|
@ -310,11 +384,11 @@ export default class TranslationCore {
|
|||
return formatter.format(value);
|
||||
}
|
||||
|
||||
formatDuration(value) { // eslint-disable-line class-methods-use-this
|
||||
formatDuration(value: number) { // eslint-disable-line class-methods-use-this
|
||||
return duration_to_string(value);
|
||||
}
|
||||
|
||||
formatDate(value, format) {
|
||||
formatDate(value: string | number | Date, format?: string) {
|
||||
if ( ! format )
|
||||
format = this.defaultDateFormat;
|
||||
|
||||
|
@ -333,7 +407,7 @@ export default class TranslationCore {
|
|||
return value.toLocaleDateString(this._locale, this.formats.date[format] || {});
|
||||
}
|
||||
|
||||
formatTime(value, format) {
|
||||
formatTime(value: string | number | Date, format?: string) {
|
||||
if ( ! format )
|
||||
format = this.defaultTimeFormat;
|
||||
|
||||
|
@ -352,7 +426,7 @@ export default class TranslationCore {
|
|||
return value.toLocaleTimeString(this._locale, this.formats.time[format] || {});
|
||||
}
|
||||
|
||||
formatDateTime(value, format) {
|
||||
formatDateTime(value: string | number | Date, format?: string) {
|
||||
if ( ! format )
|
||||
format = this.defaultDateTimeFormat;
|
||||
|
||||
|
@ -371,8 +445,8 @@ export default class TranslationCore {
|
|||
return value.toLocaleString(this._locale, this.formats.datetime[format] || {});
|
||||
}
|
||||
|
||||
extend(phrases, prefix) {
|
||||
const added = [];
|
||||
extend(phrases: RecursivePhraseMap, prefix?: string) {
|
||||
const added: string[] = [];
|
||||
if ( ! phrases || typeof phrases !== 'object' )
|
||||
return added;
|
||||
|
||||
|
@ -402,14 +476,14 @@ export default class TranslationCore {
|
|||
return added;
|
||||
}
|
||||
|
||||
unset(phrases, prefix) {
|
||||
unset(phrases: string | string[], prefix: string) {
|
||||
if ( typeof phrases === 'string' )
|
||||
phrases = [phrases];
|
||||
|
||||
const keys = Array.isArray(phrases) ? phrases : Object.keys(phrases);
|
||||
for(const key of keys) {
|
||||
const full_key = prefix ? key === '_' ? prefix : `${prefix}.${key}` : key,
|
||||
phrase = phrases[key];
|
||||
phrase = (phrases as any)[key];
|
||||
|
||||
if ( typeof phrase === 'object' )
|
||||
this.unset(phrases, full_key);
|
||||
|
@ -420,11 +494,11 @@ export default class TranslationCore {
|
|||
}
|
||||
}
|
||||
|
||||
has(key) {
|
||||
has(key: string) {
|
||||
return this.phrases.has(key);
|
||||
}
|
||||
|
||||
set(key, phrase) {
|
||||
set(key: string, phrase: string) {
|
||||
const parsed = this.parser.parse(phrase);
|
||||
this.phrases.set(key, phrase);
|
||||
this.cache.set(key, parsed);
|
||||
|
@ -435,26 +509,34 @@ export default class TranslationCore {
|
|||
this.cache.clear();
|
||||
}
|
||||
|
||||
replace(phrases) {
|
||||
replace(phrases: RecursivePhraseMap) {
|
||||
this.clear();
|
||||
this.extend(phrases);
|
||||
}
|
||||
|
||||
_preTransform(key, phrase, options, settings = {}) {
|
||||
let ast, locale, data = options == null ? {} : options;
|
||||
_preTransform(
|
||||
key: string,
|
||||
phrase: string,
|
||||
options: any,
|
||||
settings: ParseTranslationSettings = {}
|
||||
): [MessageAST, any, string] {
|
||||
let ast: MessageAST,
|
||||
locale: string,
|
||||
data = options == null ? {} : options;
|
||||
if ( typeof data === 'number' )
|
||||
data = {count: data};
|
||||
|
||||
if ( ! settings.noCache && this.phrases.has(key) ) {
|
||||
ast = this.cache.get(key);
|
||||
// TODO: Remind myself why this exists.
|
||||
ast = this.cache.get(key) ?? [];
|
||||
locale = this.locale;
|
||||
|
||||
} else if ( ! settings.noCache && this.cache.has(key) ) {
|
||||
ast = this.cache.get(key);
|
||||
ast = this.cache.get(key) ?? [];
|
||||
locale = this.defaultLocale;
|
||||
|
||||
} else {
|
||||
let parsed = null;
|
||||
let parsed: MessageAST | null = null;
|
||||
try {
|
||||
parsed = this.parser.parse(phrase);
|
||||
} catch(err) {
|
||||
|
@ -478,6 +560,10 @@ export default class TranslationCore {
|
|||
|
||||
this.cache.set(key, parsed);
|
||||
}
|
||||
} else {
|
||||
// This should never happen unless bad data is supplied.
|
||||
ast = [];
|
||||
locale = this.defaultLocale;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -487,15 +573,21 @@ export default class TranslationCore {
|
|||
return [ast, data, locale];
|
||||
}
|
||||
|
||||
t(key, phrase, options, settings) {
|
||||
return listToString(this.tList(key, phrase, options, settings));
|
||||
t(key: string, phrase: string, data: any, settings?: ParseTranslationSettings) {
|
||||
return listToString(this.tList(key, phrase, data, settings));
|
||||
}
|
||||
|
||||
tList(key, phrase, options, settings) {
|
||||
return this._processAST(...this._preTransform(key, phrase, options, settings));
|
||||
tList(key: string, phrase: string, data: any, settings?: ParseTranslationSettings) {
|
||||
return this._processAST(...this._preTransform(key, phrase, data, settings));
|
||||
}
|
||||
|
||||
formatNode(node, data, locale = null, out = null, ast = null) {
|
||||
formatNode(
|
||||
node: MessageNode,
|
||||
data: any,
|
||||
locale: string | null,
|
||||
out: any[],
|
||||
ast: MessageAST
|
||||
) {
|
||||
if ( ! node || typeof node !== 'object' )
|
||||
return node;
|
||||
|
||||
|
@ -507,16 +599,17 @@ export default class TranslationCore {
|
|||
return null;
|
||||
|
||||
if ( node.t ) {
|
||||
if ( this.types[node.t] )
|
||||
return this.types[node.t].call(this, val, node, locale, out, ast, data);
|
||||
const handler = this.types[node.t];
|
||||
if ( handler )
|
||||
return handler.call(this, val, node as MessageVariable, locale, out, ast, data);
|
||||
else if ( this.warn )
|
||||
this.warn(`Encountered unknown type "${node.t}" when formatting node.`);
|
||||
this.warn(`Encountered unknown type "${(node as MessageVariable).t}" when formatting node.`);
|
||||
}
|
||||
|
||||
return val;
|
||||
}
|
||||
|
||||
_processAST(ast, data, locale) {
|
||||
_processAST(ast: MessageAST, data: any, locale: string) {
|
||||
const out = [];
|
||||
|
||||
for(const node of ast) {
|
||||
|
@ -530,10 +623,12 @@ export default class TranslationCore {
|
|||
|
||||
}
|
||||
|
||||
export default TranslationCore;
|
||||
|
||||
function listToString(list) {
|
||||
|
||||
function listToString(list: any[]): string {
|
||||
if ( ! Array.isArray(list) )
|
||||
return String(list);
|
||||
return `${list}`;
|
||||
|
||||
return list.map(listToString).join('');
|
||||
}
|
||||
|
@ -543,6 +638,7 @@ function listToString(list) {
|
|||
// Plural Handling
|
||||
// ============================================================================
|
||||
|
||||
/*
|
||||
const CARDINAL_TO_LANG = {
|
||||
arabic: ['ar'],
|
||||
czech: ['cs'],
|
||||
|
@ -705,9 +801,39 @@ function executePlural(fn, input) {
|
|||
t
|
||||
)]
|
||||
}
|
||||
*/
|
||||
|
||||
let cardinal_i18n: Intl.PluralRules | null = null,
|
||||
cardinal_locale: string | null = null;
|
||||
|
||||
export function getCardinalName(locale: string, input: number) {
|
||||
if ( ! cardinal_i18n || locale !== cardinal_locale ) {
|
||||
cardinal_i18n = new Intl.PluralRules(locale, {
|
||||
type: 'cardinal'
|
||||
});
|
||||
cardinal_locale = locale;
|
||||
}
|
||||
|
||||
return cardinal_i18n.select(input);
|
||||
}
|
||||
|
||||
let ordinal_i18n: Intl.PluralRules | null = null,
|
||||
ordinal_locale: string | null = null;
|
||||
|
||||
export function getOrdinalName(locale: string, input: number) {
|
||||
if ( ! ordinal_i18n || locale !== ordinal_locale ) {
|
||||
ordinal_i18n = new Intl.PluralRules(locale, {
|
||||
type: 'ordinal'
|
||||
});
|
||||
ordinal_locale = locale;
|
||||
}
|
||||
|
||||
return ordinal_i18n.select(input);
|
||||
}
|
||||
|
||||
|
||||
export function getCardinalName(locale, input) {
|
||||
/*
|
||||
export function getCardinalName(locale: string, input: number) {
|
||||
let type = CARDINAL_LANG_TO_TYPE[locale];
|
||||
if ( ! type ) {
|
||||
const idx = locale.indexOf('-');
|
||||
|
@ -727,4 +853,4 @@ export function getOrdinalName(locale, input) {
|
|||
}
|
||||
|
||||
return executePlural(ORDINAL_TYPES[type], input);
|
||||
}
|
||||
}*/
|
181
src/utilities/types.ts
Normal file
181
src/utilities/types.ts
Normal file
|
@ -0,0 +1,181 @@
|
|||
import type ExperimentManager from "../experiments";
|
||||
import type TranslationManager from "../i18n";
|
||||
import type LoadTracker from "../load_tracker";
|
||||
import type { LoadEvents } from "../load_tracker";
|
||||
import type Chat from "../modules/chat";
|
||||
import type Actions from "../modules/chat/actions/actions";
|
||||
import type Badges from "../modules/chat/badges";
|
||||
import type Emoji from "../modules/chat/emoji";
|
||||
import type Emotes from "../modules/chat/emotes";
|
||||
import type Overrides from "../modules/chat/overrides";
|
||||
import type EmoteCard from "../modules/emote_card";
|
||||
import type LinkCard from "../modules/link_card";
|
||||
import type MainMenu from "../modules/main_menu";
|
||||
import type Metadata from "../modules/metadata";
|
||||
import type TooltipProvider from "../modules/tooltips";
|
||||
import type { TooltipEvents } from "../modules/tooltips";
|
||||
import type TranslationUI from "../modules/translation_ui";
|
||||
import type PubSub from "../pubsub";
|
||||
import type { SettingsEvents } from "../settings";
|
||||
import type SettingsManager from "../settings";
|
||||
import type SocketClient from "../socket";
|
||||
import type StagingSelector from "../staging";
|
||||
import type Apollo from "./compat/apollo";
|
||||
import type Elemental from "./compat/elemental";
|
||||
import type Fine from "./compat/fine";
|
||||
import type Subpump from "./compat/subpump";
|
||||
import type { SubpumpEvents } from "./compat/subpump";
|
||||
import type WebMunch from "./compat/webmunch";
|
||||
import type CSSTweaks from "./css-tweaks";
|
||||
import type { NamespacedEvents } from "./events";
|
||||
import type TwitchData from "./twitch-data";
|
||||
import type Vue from "./vue";
|
||||
|
||||
/**
|
||||
* AddonInfo represents the data contained in an add-on's manifest.
|
||||
*/
|
||||
export type AddonInfo = {
|
||||
|
||||
// ========================================================================
|
||||
// System Data
|
||||
// ========================================================================
|
||||
|
||||
/** The add-on's ID. This is used to identify content, including settings, modules, emotes, etc. that are associated with the add-on. */
|
||||
id: string;
|
||||
|
||||
/** The add-on's version number. This should be a semantic version, but this is not enforced. */
|
||||
version: string;
|
||||
|
||||
// ========================================================================
|
||||
// Metadata
|
||||
// ========================================================================
|
||||
|
||||
/** The human-readable name of the add-on, in English. */
|
||||
name: string;
|
||||
|
||||
/** Optional. A human-readable shortened name for the add-on, in English. */
|
||||
short_name?: string;
|
||||
|
||||
/** The name of the add-on's author. */
|
||||
author: string;
|
||||
|
||||
/** The name of the person or persons maintaining the add-on, if different than the author. */
|
||||
maintainer?: string;
|
||||
|
||||
/** A description of the add-on. This can be multiple lines and supports Markdown. */
|
||||
description: string;
|
||||
|
||||
/** Optional. A settings UI key. If set, a Settings button will be displayed for this add-on that takes the user to this add-on's settings. */
|
||||
settings?: string;
|
||||
|
||||
/** Optional. This add-on's website. If set, a Website button will be displayed that functions as a link. */
|
||||
website?: string;
|
||||
|
||||
/** The date when the add-on was first created. */
|
||||
created: Date;
|
||||
|
||||
/** The date when the add-on was last updated. */
|
||||
updated?: Date;
|
||||
|
||||
// ========================================================================
|
||||
// Runtime Requirements / State
|
||||
// ========================================================================
|
||||
|
||||
/** Whether or not the add-on has been loaded from a development center. */
|
||||
dev: boolean;
|
||||
|
||||
/** Whether or not the add-on has been loaded externally (outside of FFZ's control). */
|
||||
external: boolean;
|
||||
|
||||
/** A list of add-ons, by ID, that require this add-on to be enabled to function. */
|
||||
required_by: string[];
|
||||
|
||||
/** A list of add-ons, by ID, that this add-on requires to be enabled to function. */
|
||||
requires: string[];
|
||||
|
||||
/** List of FrankerFaceZ flavors ("main", "clips", "player") that this add-on supports. */
|
||||
targets: string[];
|
||||
|
||||
};
|
||||
|
||||
export type OptionallyThisCallable<TThis, TArgs extends any[], TReturn> = TReturn | ((this: TThis, ...args: TArgs) => TReturn);
|
||||
export type OptionallyCallable<TArgs extends any[], TReturn> = TReturn | ((...args: TArgs) => TReturn);
|
||||
|
||||
export type OptionalPromise<T> = T | Promise<T>;
|
||||
|
||||
export type OptionalArray<T> = T | T[];
|
||||
|
||||
export type RecursivePartial<T> = {
|
||||
[K in keyof T]?: T[K] extends object
|
||||
? RecursivePartial<T[K]>
|
||||
: T[K];
|
||||
};
|
||||
|
||||
|
||||
export type ClientVersion = {
|
||||
major: number;
|
||||
minor: number;
|
||||
revision: number;
|
||||
extra: number;
|
||||
commit: string | null;
|
||||
build: string;
|
||||
hash: string;
|
||||
};
|
||||
|
||||
|
||||
export type Mousetrap = {
|
||||
bind(
|
||||
keys: string | string[],
|
||||
callback: (event: KeyboardEvent, combo: string) => boolean | void
|
||||
): void;
|
||||
unbind(keys: string | string[], action?: string): void;
|
||||
};
|
||||
|
||||
|
||||
export type DomFragment = Node | string | null | undefined | DomFragment[];
|
||||
|
||||
|
||||
|
||||
// TODO: Move this event into addons.
|
||||
type AddonEvent = {
|
||||
'addon:fully-unload': [addon_id: string]
|
||||
};
|
||||
|
||||
|
||||
export type KnownEvents =
|
||||
AddonEvent &
|
||||
NamespacedEvents<'load_tracker', LoadEvents> &
|
||||
NamespacedEvents<'settings', SettingsEvents> &
|
||||
NamespacedEvents<'site.subpump', SubpumpEvents> &
|
||||
NamespacedEvents<'tooltips', TooltipEvents>;
|
||||
|
||||
|
||||
export type ModuleMap = {
|
||||
'chat': Chat;
|
||||
'chat.actions': Actions;
|
||||
'chat.badges': Badges;
|
||||
'chat.emoji': Emoji;
|
||||
'chat.emotes': Emotes;
|
||||
'chat.overrides': Overrides;
|
||||
'emote_card': EmoteCard;
|
||||
'experiments': ExperimentManager;
|
||||
'i18n': TranslationManager;
|
||||
'link_card': LinkCard;
|
||||
'load_tracker': LoadTracker;
|
||||
'main_menu': MainMenu;
|
||||
'metadata': Metadata;
|
||||
'pubsub': PubSub;
|
||||
'settings': SettingsManager;
|
||||
'site.apollo': Apollo;
|
||||
'site.css_tweaks': CSSTweaks;
|
||||
'site.elemental': Elemental;
|
||||
'site.fine': Fine;
|
||||
'site.subpump': Subpump;
|
||||
'site.twitch_data': TwitchData;
|
||||
'site.web_munch': WebMunch;
|
||||
'socket': SocketClient;
|
||||
'staging': StagingSelector;
|
||||
'tooltips': TooltipProvider;
|
||||
'translation_ui': TranslationUI;
|
||||
'vue': Vue;
|
||||
};
|
37
tsconfig.json
Normal file
37
tsconfig.json
Normal file
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM"
|
||||
],
|
||||
"allowJs": true,
|
||||
"jsx": "preserve",
|
||||
"alwaysStrict": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"strictBindCallApply": true,
|
||||
"strictPropertyInitialization": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitReturns": false,
|
||||
"moduleResolution": "Bundler",
|
||||
"importsNotUsedAsValues": "error",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noImplicitAny": true,
|
||||
"paths": {
|
||||
"res/*": ["./res/*"],
|
||||
"styles/*": ["./styles/*"],
|
||||
"root/*": ["./*"],
|
||||
"src/*": ["./src/*"],
|
||||
"utilities/*": ["./src/utilities/*"],
|
||||
"site": ["./src/sites/twitch-twilight/index.js"],
|
||||
"site/*": ["./src/sites/twitch-twilight/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"types/*",
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
25
typedoc.json
Normal file
25
typedoc.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"$schema": "https://typedoc.org/schema.json",
|
||||
"plugin": [
|
||||
"typedoc-plugin-no-inherit",
|
||||
"typedoc-plugin-mdn-links",
|
||||
"typedoc-plugin-rename-defaults",
|
||||
"typedoc-plugin-markdown"
|
||||
],
|
||||
//"out": "./distdocs",
|
||||
"out": "../ffz-docs/docs/dev/client",
|
||||
"hideBreadcrumbs": true,
|
||||
"hideInPageTOC": true,
|
||||
// Vite has a bug surrounding "." characters in names.
|
||||
"filenameSeparator": "_",
|
||||
"excludePrivate": true,
|
||||
"excludeInternal": true,
|
||||
"excludeNotDocumented": true,
|
||||
"excludeNotDocumentedKinds": ["Property", "Interface", "TypeAlias"],
|
||||
"disableGit": true,
|
||||
"sourceLinkTemplate": "https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/{path}#L{line}",
|
||||
"entryPoints": [
|
||||
"./src/**/*.ts",
|
||||
"./src/**/*.tsx"
|
||||
],
|
||||
}
|
55
types/ffz_icu-msgparser.d.ts
vendored
Normal file
55
types/ffz_icu-msgparser.d.ts
vendored
Normal file
|
@ -0,0 +1,55 @@
|
|||
declare module '@ffz/icu-msgparser' {
|
||||
|
||||
export type MessageAST = MessageNode[];
|
||||
|
||||
export type MessageNode = string | MessagePlaceholder;
|
||||
|
||||
export type MessagePlaceholder = MessageTag | MessageVariable;
|
||||
|
||||
export type MessageTag = {
|
||||
n: string;
|
||||
v: never;
|
||||
t: never;
|
||||
c?: MessageAST;
|
||||
};
|
||||
|
||||
export type MessageVariable = {
|
||||
n: never;
|
||||
v: string;
|
||||
t?: string;
|
||||
f?: string | number;
|
||||
o?: MessageSubmessages;
|
||||
};
|
||||
|
||||
export type MessageSubmessages = {
|
||||
[rule: string]: MessageAST;
|
||||
};
|
||||
|
||||
export type ParserOptions = {
|
||||
OPEN: string;
|
||||
CLOSE: string;
|
||||
SEP: string;
|
||||
ESCAPE: string;
|
||||
SUB_VAR: string;
|
||||
TAG_OPEN: string;
|
||||
TAG_CLOSE: string;
|
||||
TAG_CLOSING: string;
|
||||
|
||||
OFFSET: string;
|
||||
|
||||
subnumeric_types: string[];
|
||||
submessage_types: string[];
|
||||
|
||||
allowTags: boolean;
|
||||
requireOther: boolean | string[];
|
||||
}
|
||||
|
||||
export default class Parser {
|
||||
|
||||
constructor(options?: Partial<ParserOptions>);
|
||||
|
||||
parse(input: string): MessageAST;
|
||||
|
||||
}
|
||||
|
||||
}
|
31
types/getScreenDetails.d.ts
vendored
Normal file
31
types/getScreenDetails.d.ts
vendored
Normal file
|
@ -0,0 +1,31 @@
|
|||
|
||||
export interface ScreenDetailed extends Screen {
|
||||
|
||||
readonly availLeft: number;
|
||||
readonly availTop: number;
|
||||
readonly devicePixelRatio: number;
|
||||
readonly isInternal: boolean;
|
||||
readonly isPrimary: boolean;
|
||||
readonly label: string;
|
||||
readonly left: number;
|
||||
readonly top: number;
|
||||
|
||||
}
|
||||
|
||||
export interface ScreenDetails extends EventTarget {
|
||||
|
||||
readonly currentScreen: ScreenDetailed;
|
||||
|
||||
readonly screens: ScreenDetailed[];
|
||||
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
interface Window {
|
||||
getScreenDetails: (() => Promise<ScreenDetails>) | undefined;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export {}
|
11
types/global.d.ts
vendored
Normal file
11
types/global.d.ts
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
|
||||
declare global {
|
||||
const __version_major__: number;
|
||||
const __version_minor__: number;
|
||||
const __version_patch__: number;
|
||||
const __version_prerelease__: number[];
|
||||
const __git_commit__: string | null;
|
||||
const __version_build__: string;
|
||||
}
|
||||
|
||||
export {}
|
4
types/import-types.d.ts
vendored
Normal file
4
types/import-types.d.ts
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
declare module "*.scss" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
9
types/jsx-global.d.ts
vendored
Normal file
9
types/jsx-global.d.ts
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
|
||||
declare namespace JSX {
|
||||
interface Element extends HTMLElement {}
|
||||
|
||||
interface IntrinsicElements {
|
||||
[elemName: string]: any;
|
||||
}
|
||||
|
||||
}
|
|
@ -48,7 +48,7 @@ const ENTRY_POINTS = {
|
|||
bridge: './src/bridge.js',
|
||||
esbridge: './src/esbridge.js',
|
||||
player: './src/player.js',
|
||||
avalon: './src/main.js',
|
||||
avalon: './src/main.ts',
|
||||
clips: './src/clips.js'
|
||||
};
|
||||
|
||||
|
@ -66,7 +66,7 @@ const config = {
|
|||
target: ['web', TARGET],
|
||||
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx'],
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
alias: {
|
||||
res: path.resolve(__dirname, 'res/'),
|
||||
styles: path.resolve(__dirname, 'styles/'),
|
||||
|
@ -163,6 +163,16 @@ const config = {
|
|||
target: TARGET
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
exclude: /node_modules/,
|
||||
loader: 'esbuild-loader',
|
||||
options: {
|
||||
loader: 'tsx',
|
||||
jsxFactory: 'createElement',
|
||||
target: TARGET
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.(graphql|gql)$/,
|
||||
exclude: /node_modules/,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue