mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-06-27 21:05:53 +00:00
Merge pull request #1430 from FrankerFaceZ/types
The Grand TypeScript Update
This commit is contained in:
commit
40865adba7
118 changed files with 13451 additions and 7821 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;`);
|
||||
|
|
23
package.json
23
package.json
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "frankerfacez",
|
||||
"author": "Dan Salvato LLC",
|
||||
"version": "4.60.1",
|
||||
"version": "4.61.0",
|
||||
"description": "FrankerFaceZ is a Twitch enhancement suite.",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
|
@ -15,6 +15,9 @@
|
|||
"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",
|
||||
|
@ -22,6 +25,11 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@ffz/fontello-cli": "^1.0.4",
|
||||
"@types/crypto-js": "^4.2.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/safe-regex": "^1.1.6",
|
||||
"@types/vue-clickaway": "^2.2.4",
|
||||
"@types/webpack-env": "^1.18.4",
|
||||
"browserslist": "^4.21.10",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
|
@ -33,6 +41,7 @@
|
|||
"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",
|
||||
|
@ -40,6 +49,12 @@
|
|||
"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",
|
||||
|
@ -53,8 +68,8 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@ffz/icu-msgparser": "^2.0.0",
|
||||
"@popperjs/core": "^2.10.2",
|
||||
"crypto-js": "^3.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.10.7",
|
||||
"denoflare-mqtt": "^0.0.2",
|
||||
"displacejs": "^1.4.1",
|
||||
|
@ -62,7 +77,7 @@
|
|||
"file-saver": "^2.0.5",
|
||||
"graphql": "^16.0.1",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"js-cookie": "^2.2.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jszip": "^3.7.1",
|
||||
"markdown-it": "^12.2.0",
|
||||
"markdown-it-link-attributes": "^3.0.0",
|
||||
|
|
215
pnpm-lock.yaml
generated
215
pnpm-lock.yaml
generated
|
@ -15,11 +15,11 @@ 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
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
dayjs:
|
||||
specifier: ^1.10.7
|
||||
version: 1.10.7
|
||||
|
@ -42,8 +42,8 @@ dependencies:
|
|||
specifier: ^2.12.6
|
||||
version: 2.12.6(graphql@16.0.1)
|
||||
js-cookie:
|
||||
specifier: ^2.2.1
|
||||
version: 2.2.1
|
||||
specifier: ^3.0.5
|
||||
version: 3.0.5
|
||||
jszip:
|
||||
specifier: ^3.7.1
|
||||
version: 3.7.1
|
||||
|
@ -97,6 +97,21 @@ devDependencies:
|
|||
'@ffz/fontello-cli':
|
||||
specifier: ^1.0.4
|
||||
version: 1.0.4
|
||||
'@types/crypto-js':
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
'@types/js-cookie':
|
||||
specifier: ^3.0.6
|
||||
version: 3.0.6
|
||||
'@types/safe-regex':
|
||||
specifier: ^1.1.6
|
||||
version: 1.1.6
|
||||
'@types/vue-clickaway':
|
||||
specifier: ^2.2.4
|
||||
version: 2.2.4
|
||||
'@types/webpack-env':
|
||||
specifier: ^1.18.4
|
||||
version: 1.18.4
|
||||
browserslist:
|
||||
specifier: ^4.21.10
|
||||
version: 4.21.10
|
||||
|
@ -130,6 +145,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 +169,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 +571,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:
|
||||
|
@ -565,6 +601,10 @@ packages:
|
|||
'@types/node': 20.5.7
|
||||
dev: true
|
||||
|
||||
/@types/crypto-js@4.2.1:
|
||||
resolution: {integrity: sha512-FSPGd9+OcSok3RsM0UZ/9fcvMOXJ1ENE/ZbLfOPlBWj7BgXtEAM8VYfTtT760GiLbQIMoVozwVuisjvsVwqYWw==}
|
||||
dev: true
|
||||
|
||||
/@types/eslint-scope@3.7.4:
|
||||
resolution: {integrity: sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==}
|
||||
dependencies:
|
||||
|
@ -611,6 +651,10 @@ packages:
|
|||
'@types/node': 20.5.7
|
||||
dev: true
|
||||
|
||||
/@types/js-cookie@3.0.6:
|
||||
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
|
||||
dev: true
|
||||
|
||||
/@types/json-schema@7.0.9:
|
||||
resolution: {integrity: sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==}
|
||||
dev: true
|
||||
|
@ -643,6 +687,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 +718,16 @@ packages:
|
|||
'@types/node': 20.5.7
|
||||
dev: true
|
||||
|
||||
/@types/vue-clickaway@2.2.4:
|
||||
resolution: {integrity: sha512-Jy0dGNUrm/Fya1hY8bHM5lXJvZvlyU/rvgLEFVcjQkwNp2Z2IGNnRKS6ZH9orMDkUI7Qj0oyWp0b89VTErAS9Q==}
|
||||
dependencies:
|
||||
vue: 2.6.14
|
||||
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 +1047,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 +1832,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
|
||||
|
@ -2129,8 +2196,8 @@ packages:
|
|||
which: 2.0.2
|
||||
dev: true
|
||||
|
||||
/crypto-js@3.3.0:
|
||||
resolution: {integrity: sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q==}
|
||||
/crypto-js@4.2.0:
|
||||
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
||||
dev: false
|
||||
|
||||
/css-loader@6.8.1(webpack@5.88.2):
|
||||
|
@ -3056,13 +3123,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 +3220,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 +3706,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
|
||||
|
@ -3644,8 +3724,9 @@ packages:
|
|||
supports-color: 8.1.1
|
||||
dev: true
|
||||
|
||||
/js-cookie@2.2.1:
|
||||
resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==}
|
||||
/js-cookie@3.0.5:
|
||||
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
|
||||
engines: {node: '>=14'}
|
||||
dev: false
|
||||
|
||||
/js-tokens@3.0.2:
|
||||
|
@ -3710,6 +3791,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 +3922,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 +3941,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 +4774,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 +4994,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 +5391,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 +5521,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:
|
||||
|
@ -5502,7 +5676,6 @@ packages:
|
|||
|
||||
/vue@2.6.14:
|
||||
resolution: {integrity: sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ==}
|
||||
dev: false
|
||||
|
||||
/vuedraggable@2.24.3:
|
||||
resolution: {integrity: sha512-6/HDXi92GzB+Hcs9fC6PAAozK1RLt1ewPTLjK0anTYguXLAeySDmcnqE8IC0xa7shvSzRjQXq3/+dsZ7ETGF3g==}
|
||||
|
@ -5789,6 +5962,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'}
|
||||
|
|
|
@ -4,21 +4,77 @@
|
|||
// Add-On System
|
||||
// ============================================================================
|
||||
|
||||
import Module from 'utilities/module';
|
||||
import Module, { GenericModule } from 'utilities/module';
|
||||
import { EXTENSION, SERVER_OR_EXT } from 'utilities/constants';
|
||||
import { createElement } from 'utilities/dom';
|
||||
import { timeout, has, deep_copy } from 'utilities/object';
|
||||
import { timeout, has, deep_copy, fetchJSON } from 'utilities/object';
|
||||
import { getBuster } from 'utilities/time';
|
||||
import type SettingsManager from './settings';
|
||||
import type TranslationManager from './i18n';
|
||||
import type LoadTracker from './load_tracker';
|
||||
import type FrankerFaceZ from './main';
|
||||
import type { AddonInfo } from 'utilities/types';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ffzAddonsWebpackJsonp: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'utilities/types' {
|
||||
interface ModuleMap {
|
||||
addons: AddonManager;
|
||||
}
|
||||
interface ModuleEventMap {
|
||||
addons: AddonManagerEvents;
|
||||
}
|
||||
interface SettingsTypeMap {
|
||||
'addons.dev.server': boolean;
|
||||
}
|
||||
};
|
||||
|
||||
type AddonManagerEvents = {
|
||||
':ready': [];
|
||||
':data-loaded': [];
|
||||
':reload-required': [];
|
||||
|
||||
':added': [id: string, info: AddonInfo];
|
||||
':addon-loaded': [id: string];
|
||||
':addon-enabled': [id: string];
|
||||
':addon-disabled': [id: string];
|
||||
':fully-unload': [id: string];
|
||||
};
|
||||
|
||||
|
||||
type FullAddonInfo = AddonInfo & {
|
||||
_search?: string | null;
|
||||
src: string;
|
||||
};
|
||||
|
||||
const fetchJSON = (url, options) => fetch(url, options).then(r => r.ok ? r.json() : null).catch(() => null);
|
||||
|
||||
// ============================================================================
|
||||
// AddonManager
|
||||
// ============================================================================
|
||||
|
||||
export default class AddonManager extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
export default class AddonManager extends Module<'addons'> {
|
||||
|
||||
// Dependencies
|
||||
i18n: TranslationManager = null as any;
|
||||
load_tracker: LoadTracker = null as any;
|
||||
settings: SettingsManager = null as any;
|
||||
|
||||
// State
|
||||
has_dev: boolean;
|
||||
reload_required: boolean;
|
||||
target: string;
|
||||
|
||||
addons: Record<string, FullAddonInfo | string[]>;
|
||||
enabled_addons: string[];
|
||||
|
||||
private _loader?: Promise<void>;
|
||||
|
||||
constructor(name?: string, parent?: GenericModule) {
|
||||
super(name, parent);
|
||||
|
||||
this.should_enable = true;
|
||||
|
||||
|
@ -28,7 +84,7 @@ export default class AddonManager extends Module {
|
|||
|
||||
this.load_requires = ['settings'];
|
||||
|
||||
this.target = this.parent.flavor || 'unknown';
|
||||
this.target = (this.parent as unknown as FrankerFaceZ).flavor || 'unknown';
|
||||
|
||||
this.has_dev = false;
|
||||
this.reload_required = false;
|
||||
|
@ -39,6 +95,7 @@ export default class AddonManager extends Module {
|
|||
}
|
||||
|
||||
onLoad() {
|
||||
// We don't actually *wait* for this, we just start it.
|
||||
this._loader = this.loadAddonData();
|
||||
}
|
||||
|
||||
|
@ -54,20 +111,20 @@ export default class AddonManager extends Module {
|
|||
getFFZ: () => this,
|
||||
isReady: () => this.enabled,
|
||||
getAddons: () => Object.values(this.addons),
|
||||
hasAddon: id => this.hasAddon(id),
|
||||
getVersion: id => this.getVersion(id),
|
||||
doesAddonTarget: id => this.doesAddonTarget(id),
|
||||
isAddonEnabled: id => this.isAddonEnabled(id),
|
||||
isAddonExternal: id => this.isAddonExternal(id),
|
||||
enableAddon: id => this.enableAddon(id),
|
||||
disableAddon: id => this.disableAddon(id),
|
||||
reloadAddon: id => this.reloadAddon(id),
|
||||
canReloadAddon: id => this.canReloadAddon(id),
|
||||
hasAddon: (id: string) => this.hasAddon(id),
|
||||
getVersion: (id: string) => this.getVersion(id),
|
||||
doesAddonTarget: (id: string) => this.doesAddonTarget(id),
|
||||
isAddonEnabled: (id: string) => this.isAddonEnabled(id),
|
||||
isAddonExternal: (id: string) => this.isAddonExternal(id),
|
||||
enableAddon: (id: string) => this.enableAddon(id),
|
||||
disableAddon: (id: string) => this.disableAddon(id),
|
||||
reloadAddon: (id: string) => this.reloadAddon(id),
|
||||
canReloadAddon: (id: string) => this.canReloadAddon(id),
|
||||
isReloadRequired: () => this.reload_required,
|
||||
refresh: () => window.location.reload(),
|
||||
|
||||
on: (...args) => this.on(...args),
|
||||
off: (...args) => this.off(...args)
|
||||
on: (...args: Parameters<typeof this.on>) => this.on(...args),
|
||||
off: (...args: Parameters<typeof this.off>) => this.off(...args)
|
||||
});
|
||||
|
||||
if ( ! EXTENSION )
|
||||
|
@ -85,7 +142,7 @@ export default class AddonManager extends Module {
|
|||
|
||||
this.settings.provider.on('changed', this.onProviderChange, this);
|
||||
|
||||
this._loader.then(() => {
|
||||
this._loader?.then(() => {
|
||||
this.enabled_addons = this.settings.provider.get('addons.enabled', []);
|
||||
|
||||
// We do not await enabling add-ons because that would delay the
|
||||
|
@ -103,8 +160,8 @@ export default class AddonManager extends Module {
|
|||
}
|
||||
|
||||
|
||||
doesAddonTarget(id) {
|
||||
const data = this.addons[id];
|
||||
doesAddonTarget(id: string) {
|
||||
const data = this.getAddon(id);
|
||||
if ( ! data )
|
||||
return false;
|
||||
|
||||
|
@ -118,12 +175,15 @@ export default class AddonManager extends Module {
|
|||
|
||||
generateLog() {
|
||||
const out = ['Known'];
|
||||
for(const [id, addon] of Object.entries(this.addons))
|
||||
for(const [id, addon] of Object.entries(this.addons)) {
|
||||
if ( Array.isArray(addon) )
|
||||
continue;
|
||||
out.push(`${id} | ${this.isAddonEnabled(id) ? 'enabled' : 'disabled'} | ${addon.dev ? 'dev | ' : ''}${this.isAddonExternal(id) ? 'external | ' : ''}${addon.short_name} v${addon.version}`);
|
||||
}
|
||||
|
||||
out.push('');
|
||||
out.push('Modules');
|
||||
for(const [key, module] of Object.entries(this.__modules)) {
|
||||
for(const [key, module] of Object.entries((this as any).__modules as Record<string, GenericModule>)) {
|
||||
if ( module )
|
||||
out.push(`${module.loaded ? 'loaded ' : module.loading ? 'loading ' : 'unloaded'} | ${module.enabled ? 'enabled ' : module.enabling ? 'enabling' : 'disabled'} | ${key}`)
|
||||
}
|
||||
|
@ -131,22 +191,20 @@ export default class AddonManager extends Module {
|
|||
return out.join('\n');
|
||||
}
|
||||
|
||||
onProviderChange(key, value) {
|
||||
onProviderChange(key: string, value: unknown) {
|
||||
if ( key != 'addons.enabled' )
|
||||
return;
|
||||
|
||||
if ( ! value )
|
||||
value = [];
|
||||
|
||||
const old_enabled = [...this.enabled_addons];
|
||||
const val: string[] = Array.isArray(value) ? value : [],
|
||||
old_enabled = [...this.enabled_addons];
|
||||
|
||||
// Add-ons to disable
|
||||
for(const id of old_enabled)
|
||||
if ( ! value.includes(id) )
|
||||
if ( ! val.includes(id) )
|
||||
this.disableAddon(id, false);
|
||||
|
||||
// Add-ons to enable
|
||||
for(const id of value)
|
||||
for(const id of val)
|
||||
if ( ! old_enabled.includes(id) )
|
||||
this.enableAddon(id, false);
|
||||
}
|
||||
|
@ -187,7 +245,9 @@ export default class AddonManager extends Module {
|
|||
this.emit(':data-loaded');
|
||||
}
|
||||
|
||||
addAddon(addon, is_dev = false) {
|
||||
addAddon(input: AddonInfo, is_dev: boolean = false) {
|
||||
let addon = input as FullAddonInfo;
|
||||
|
||||
const old = this.addons[addon.id];
|
||||
this.addons[addon.id] = addon;
|
||||
|
||||
|
@ -217,7 +277,7 @@ export default class AddonManager extends Module {
|
|||
this.addons[id] = [addon.id];
|
||||
}
|
||||
|
||||
if ( ! old )
|
||||
if ( ! old || Array.isArray(old) )
|
||||
this.settings.addUI(`addon-changelog.${addon.id}`, {
|
||||
path: `Add-Ons > Changelog > ${addon.name}`,
|
||||
component: 'changelog',
|
||||
|
@ -227,11 +287,14 @@ export default class AddonManager extends Module {
|
|||
getFFZ: () => this
|
||||
});
|
||||
|
||||
this.emit(':added');
|
||||
this.emit(':added', addon.id, addon);
|
||||
}
|
||||
|
||||
rebuildAddonSearch() {
|
||||
for(const addon of Object.values(this.addons)) {
|
||||
if ( Array.isArray(addon) )
|
||||
continue;
|
||||
|
||||
const terms = new Set([
|
||||
addon._search,
|
||||
addon.name,
|
||||
|
@ -250,47 +313,51 @@ export default class AddonManager extends Module {
|
|||
if ( addon.author_i18n )
|
||||
terms.add(this.i18n.t(addon.author_i18n, addon.author));
|
||||
|
||||
if ( addon.maintainer_i18n )
|
||||
terms.add(this.i18n.t(addon.maintainer_i18n, addon.maintainer));
|
||||
|
||||
if ( addon.description_i18n )
|
||||
terms.add(this.i18n.t(addon.description_i18n, addon.description));
|
||||
}
|
||||
|
||||
addon.search_terms = [...terms].map(term => term ? term.toLocaleLowerCase() : '').join('\n');
|
||||
addon.search_terms = [...terms]
|
||||
.map(term => term ? term.toLocaleLowerCase() : '').join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
isAddonEnabled(id) {
|
||||
isAddonEnabled(id: string) {
|
||||
if ( this.isAddonExternal(id) )
|
||||
return true;
|
||||
|
||||
return this.enabled_addons.includes(id);
|
||||
}
|
||||
|
||||
getAddon(id) {
|
||||
getAddon(id: string) {
|
||||
const addon = this.addons[id];
|
||||
return Array.isArray(addon) ? null : addon;
|
||||
}
|
||||
|
||||
hasAddon(id) {
|
||||
hasAddon(id: string) {
|
||||
return this.getAddon(id) != null;
|
||||
}
|
||||
|
||||
getVersion(id) {
|
||||
getVersion(id: string) {
|
||||
const addon = this.getAddon(id);
|
||||
if ( ! addon )
|
||||
throw new Error(`Unknown add-on id: ${id}`);
|
||||
|
||||
const module = this.resolve(`addon.${id}`);
|
||||
if ( module ) {
|
||||
if ( has(module, 'version') )
|
||||
if ( 'version' in module ) // has(module, 'version') )
|
||||
return module.version;
|
||||
else if ( module.constructor && has(module.constructor, 'version') )
|
||||
else if ( module.constructor && 'version' in module.constructor ) // has(module.constructor, 'version') )
|
||||
return module.constructor.version;
|
||||
}
|
||||
|
||||
return addon.version;
|
||||
}
|
||||
|
||||
isAddonExternal(id) {
|
||||
isAddonExternal(id: string) {
|
||||
if ( ! this.hasAddon(id) )
|
||||
throw new Error(`Unknown add-on id: ${id}`);
|
||||
|
||||
|
@ -306,10 +373,10 @@ export default class AddonManager extends Module {
|
|||
return true;
|
||||
|
||||
// Finally, let the module flag itself as external.
|
||||
return module.external || (module.constructor && module.constructor.external);
|
||||
return (module as any).external || (module.constructor as any)?.external;
|
||||
}
|
||||
|
||||
canReloadAddon(id) {
|
||||
canReloadAddon(id: string) {
|
||||
// Obviously we can't reload it if we don't have it.
|
||||
if ( ! this.hasAddon(id) )
|
||||
throw new Error(`Unknown add-on id: ${id}`);
|
||||
|
@ -334,8 +401,8 @@ export default class AddonManager extends Module {
|
|||
return true;
|
||||
}
|
||||
|
||||
async fullyUnloadModule(module) {
|
||||
if ( ! module )
|
||||
async fullyUnloadModule(module: GenericModule) {
|
||||
if ( ! module || ! module.addon_id )
|
||||
return;
|
||||
|
||||
if ( module.children )
|
||||
|
@ -346,47 +413,47 @@ export default class AddonManager extends Module {
|
|||
await module.unload();
|
||||
|
||||
// Clean up parent references.
|
||||
if ( module.parent && module.parent.children[module.name] === module )
|
||||
if ( module.parent instanceof Module && module.parent.children[module.name] === module )
|
||||
delete module.parent.children[module.name];
|
||||
|
||||
// Clean up all individual references.
|
||||
for(const entry of module.references) {
|
||||
const other = this.resolve(entry[0]),
|
||||
name = entry[1];
|
||||
if ( other && other[name] === module )
|
||||
other[name] = null;
|
||||
if ( (other as any)[name] === module )
|
||||
(other as any)[name] = null;
|
||||
}
|
||||
|
||||
// Send off a signal for other modules to unload related data.
|
||||
this.emit('addon:fully-unload', module.addon_id);
|
||||
this.emit(':fully-unload', module.addon_id);
|
||||
|
||||
// Clean up the global reference.
|
||||
if ( this.__modules[module.__path] === module )
|
||||
delete this.__modules[module.__path]; /* = [
|
||||
if ( (this as any).__modules[(module as any).__path] === module )
|
||||
delete (this as any).__modules[(module as any).__path]; /* = [
|
||||
module.dependents,
|
||||
module.load_dependents,
|
||||
module.references
|
||||
];*/
|
||||
|
||||
// Remove any events we didn't unregister.
|
||||
this.offContext(null, module);
|
||||
this.off(undefined, undefined, module);
|
||||
|
||||
// Do the same for settings.
|
||||
for(const ctx of this.settings.__contexts)
|
||||
ctx.offContext(null, module);
|
||||
ctx.off(undefined, undefined, module);
|
||||
|
||||
// Clean up all settings.
|
||||
for(const [key, def] of Array.from(this.settings.definitions.entries())) {
|
||||
if ( def && def.__source === module.addon_id ) {
|
||||
if ( ! Array.isArray(def) && def?.__source === module.addon_id ) {
|
||||
this.settings.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up the logger too.
|
||||
module.__log = null;
|
||||
(module as any).__log = null;
|
||||
}
|
||||
|
||||
async reloadAddon(id) {
|
||||
async reloadAddon(id: string) {
|
||||
const addon = this.getAddon(id),
|
||||
button = this.resolve('site.menu_button');
|
||||
if ( ! addon )
|
||||
|
@ -456,7 +523,7 @@ export default class AddonManager extends Module {
|
|||
});
|
||||
}
|
||||
|
||||
async _enableAddon(id) {
|
||||
private async _enableAddon(id: string) {
|
||||
const addon = this.getAddon(id);
|
||||
if ( ! addon )
|
||||
throw new Error(`Unknown add-on id: ${id}`);
|
||||
|
@ -476,7 +543,7 @@ export default class AddonManager extends Module {
|
|||
this.load_tracker.notify(event, `addon.${id}`, false);
|
||||
}
|
||||
|
||||
async loadAddon(id) {
|
||||
async loadAddon(id: string) {
|
||||
const addon = this.getAddon(id);
|
||||
if ( ! addon )
|
||||
throw new Error(`Unknown add-on id: ${id}`);
|
||||
|
@ -500,7 +567,7 @@ export default class AddonManager extends Module {
|
|||
}));
|
||||
|
||||
// Error if this takes more than 5 seconds.
|
||||
await timeout(this.waitFor(`addon.${id}:registered`), 60000);
|
||||
await timeout(this.waitFor(`addon.${id}:registered` as any), 60000);
|
||||
|
||||
module = this.resolve(`addon.${id}`);
|
||||
if ( module && ! module.loaded )
|
||||
|
@ -509,13 +576,13 @@ export default class AddonManager extends Module {
|
|||
this.emit(':addon-loaded', id);
|
||||
}
|
||||
|
||||
unloadAddon(id) {
|
||||
unloadAddon(id: string) {
|
||||
const module = this.resolve(`addon.${id}`);
|
||||
if ( module )
|
||||
return module.unload();
|
||||
}
|
||||
|
||||
enableAddon(id, save = true) {
|
||||
enableAddon(id: string, save: boolean = true) {
|
||||
const addon = this.getAddon(id);
|
||||
if( ! addon )
|
||||
throw new Error(`Unknown add-on id: ${id}`);
|
||||
|
@ -546,7 +613,7 @@ export default class AddonManager extends Module {
|
|||
});
|
||||
}
|
||||
|
||||
async disableAddon(id, save = true) {
|
||||
async disableAddon(id: string, save: boolean = true) {
|
||||
const addon = this.getAddon(id);
|
||||
if ( ! addon )
|
||||
throw new Error(`Unknown add-on id: ${id}`);
|
|
@ -12,13 +12,13 @@ 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';
|
||||
|
||||
import Site from './sites/clips';
|
||||
import Vue from 'utilities/vue';
|
||||
import VueModule from 'utilities/vue';
|
||||
|
||||
import Tooltips from 'src/modules/tooltips';
|
||||
import Chat from 'src/modules/chat';
|
||||
|
@ -64,7 +64,7 @@ class FrankerFaceZ extends Module {
|
|||
this.inject('site', Site);
|
||||
this.inject('addons', AddonManager);
|
||||
|
||||
this.register('vue', Vue);
|
||||
this.register('vue', VueModule);
|
||||
|
||||
// ========================================================================
|
||||
// Startup
|
||||
|
|
|
@ -1,479 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Experiments
|
||||
// ============================================================================
|
||||
|
||||
import {DEBUG, SERVER} from 'utilities/constants';
|
||||
import Module from 'utilities/module';
|
||||
import {has, deep_copy} from 'utilities/object';
|
||||
import { getBuster } from 'utilities/time';
|
||||
|
||||
import Cookie from 'js-cookie';
|
||||
import SHA1 from 'crypto-js/sha1';
|
||||
|
||||
const OVERRIDE_COOKIE = 'experiment_overrides',
|
||||
COOKIE_OPTIONS = {
|
||||
expires: 7,
|
||||
domain: '.twitch.tv'
|
||||
};
|
||||
|
||||
|
||||
// We want to import this so that the file is included in the output.
|
||||
// We don't load using this because we might want a newer file from the
|
||||
// server.
|
||||
import EXPERIMENTS from './experiments.json'; // eslint-disable-line no-unused-vars
|
||||
|
||||
|
||||
function sortExperimentLog(a,b) {
|
||||
if ( a.rarity < b.rarity )
|
||||
return -1;
|
||||
else if ( a.rarity > b.rarity )
|
||||
return 1;
|
||||
|
||||
if ( a.name < b.name )
|
||||
return -1;
|
||||
else if ( a.name > b.name )
|
||||
return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Experiment Manager
|
||||
// ============================================================================
|
||||
|
||||
export default class ExperimentManager extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.get = this.getAssignment;
|
||||
|
||||
this.inject('settings');
|
||||
|
||||
this.settings.addUI('experiments', {
|
||||
path: 'Debugging > Experiments',
|
||||
component: 'experiments',
|
||||
no_filter: true,
|
||||
|
||||
getExtraTerms: () => {
|
||||
const values = [];
|
||||
|
||||
for(const exps of [this.experiments, this.getTwitchExperiments()]) {
|
||||
if ( ! exps )
|
||||
continue;
|
||||
|
||||
for(const [key, val] of Object.entries(exps)) {
|
||||
values.push(key);
|
||||
if ( val.name )
|
||||
values.push(val.name);
|
||||
if ( val.description )
|
||||
values.push(val.description);
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
},
|
||||
|
||||
is_locked: () => this.getControlsLocked(),
|
||||
unlock: () => this.unlockControls(),
|
||||
|
||||
unique_id: () => this.unique_id,
|
||||
|
||||
ffz_data: () => deep_copy(this.experiments),
|
||||
twitch_data: () => deep_copy(this.getTwitchExperiments()),
|
||||
|
||||
usingTwitchExperiment: key => this.usingTwitchExperiment(key),
|
||||
getTwitchAssignment: key => this.getTwitchAssignment(key),
|
||||
getTwitchType: type => this.getTwitchType(type),
|
||||
hasTwitchOverride: key => this.hasTwitchOverride(key),
|
||||
setTwitchOverride: (key, val) => this.setTwitchOverride(key, val),
|
||||
deleteTwitchOverride: key => this.deleteTwitchOverride(key),
|
||||
|
||||
getAssignment: key => this.getAssignment(key),
|
||||
hasOverride: key => this.hasOverride(key),
|
||||
setOverride: (key, val) => this.setOverride(key, val),
|
||||
deleteOverride: key => this.deleteOverride(key),
|
||||
|
||||
on: (...args) => this.on(...args),
|
||||
off: (...args) => this.off(...args)
|
||||
});
|
||||
|
||||
this.unique_id = Cookie.get('unique_id');
|
||||
|
||||
this.Cookie = Cookie;
|
||||
|
||||
this.experiments = {};
|
||||
this.cache = new Map;
|
||||
}
|
||||
|
||||
getControlsLocked() {
|
||||
if ( DEBUG )
|
||||
return false;
|
||||
|
||||
const ts = this.settings.provider.get('exp-lock', 0);
|
||||
if ( isNaN(ts) || ! isFinite(ts) )
|
||||
return true;
|
||||
|
||||
return Date.now() - ts >= 86400000;
|
||||
}
|
||||
|
||||
unlockControls() {
|
||||
this.settings.provider.set('exp-lock', Date.now());
|
||||
}
|
||||
|
||||
async onLoad() {
|
||||
await this.loadExperiments();
|
||||
}
|
||||
|
||||
|
||||
async loadExperiments() {
|
||||
let data;
|
||||
|
||||
try {
|
||||
data = await fetch(DEBUG ? EXPERIMENTS : `${SERVER}/script/experiments.json?_=${getBuster()}`).then(r =>
|
||||
r.ok ? r.json() : null);
|
||||
|
||||
} catch(err) {
|
||||
this.log.warn('Unable to load experiment data.', err);
|
||||
}
|
||||
|
||||
if ( ! data )
|
||||
return;
|
||||
|
||||
this.experiments = data;
|
||||
|
||||
const old_cache = this.cache;
|
||||
this.cache = new Map;
|
||||
|
||||
let changed = 0;
|
||||
|
||||
for(const [key, old_val] of old_cache.entries()) {
|
||||
const new_val = this.getAssignment(key);
|
||||
if ( old_val !== new_val ) {
|
||||
changed++;
|
||||
this.emit(':changed', key, new_val);
|
||||
this.emit(`:changed:${key}`, new_val);
|
||||
}
|
||||
}
|
||||
|
||||
this.log.info(`Loaded information on ${Object.keys(data).length} experiments.${changed > 0 ? ` ${changed} values updated.` : ''}`);
|
||||
//this.emit(':loaded');
|
||||
}
|
||||
|
||||
|
||||
onEnable() {
|
||||
this.on('pubsub:command:reload_experiments', this.loadExperiments, this);
|
||||
this.on('pubsub:command:update_experiment', this.updateExperiment, this);
|
||||
}
|
||||
|
||||
|
||||
updateExperiment(key, data) {
|
||||
this.log.info(`Received updated data for experiment "${key}" via WebSocket.`, data);
|
||||
|
||||
if ( data.groups )
|
||||
this.experiments[key] = data;
|
||||
else
|
||||
this.experiments[key].groups = data;
|
||||
|
||||
this._rebuildKey(key);
|
||||
}
|
||||
|
||||
|
||||
generateLog() {
|
||||
const out = [
|
||||
`Unique ID: ${this.unique_id}`,
|
||||
''
|
||||
];
|
||||
|
||||
const ffz_assignments = [];
|
||||
for(const [key, value] of Object.entries(this.experiments)) {
|
||||
const assignment = this.getAssignment(key),
|
||||
override = this.hasOverride(key);
|
||||
|
||||
let weight = 0, total = 0;
|
||||
for(const group of value.groups) {
|
||||
if ( group.value === assignment )
|
||||
weight = group.weight;
|
||||
total += group.weight;
|
||||
}
|
||||
|
||||
if ( ! override && weight === total )
|
||||
continue;
|
||||
|
||||
ffz_assignments.push({
|
||||
key,
|
||||
name: value.name,
|
||||
value: assignment,
|
||||
override,
|
||||
rarity: weight / total
|
||||
});
|
||||
|
||||
//out.push(`FFZ | ${value.name}: ${this.getAssignment(key)}${this.hasOverride(key) ? ' (Overriden)' : ''}`);
|
||||
}
|
||||
|
||||
ffz_assignments.sort(sortExperimentLog);
|
||||
|
||||
for(const entry of ffz_assignments)
|
||||
out.push(`FFZ | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity})`);
|
||||
|
||||
const twitch_assignments = [],
|
||||
channel = this.settings.get('context.channel');
|
||||
|
||||
for(const [key, value] of Object.entries(this.getTwitchExperiments())) {
|
||||
if ( ! this.usingTwitchExperiment(key) )
|
||||
continue;
|
||||
|
||||
const assignment = this.getTwitchAssignment(key),
|
||||
override = this.hasTwitchOverride(key);
|
||||
|
||||
let weight = 0, total = 0;
|
||||
for(const group of value.groups) {
|
||||
if ( group.value === assignment )
|
||||
weight = group.weight;
|
||||
total += group.weight;
|
||||
}
|
||||
|
||||
if ( ! override && weight === total )
|
||||
continue;
|
||||
|
||||
twitch_assignments.push({
|
||||
key,
|
||||
name: value.name,
|
||||
value: assignment,
|
||||
override,
|
||||
type: this.getTwitchTypeByKey(key),
|
||||
rarity: weight / total
|
||||
});
|
||||
|
||||
//out.push(`TWITCH | ${value.name}: ${this.getTwitchAssignment(key)}${this.hasTwitchOverride(key) ? ' (Overriden)' : ''}`)
|
||||
}
|
||||
|
||||
twitch_assignments.sort(sortExperimentLog);
|
||||
|
||||
for(const entry of twitch_assignments)
|
||||
out.push(`Twitch | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity}, t:${entry.type}${entry.type === 'channel_id' ? `, c:${channel}`: ''})`);
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
|
||||
// Twitch Experiments
|
||||
|
||||
getTwitchType(type) {
|
||||
const core = this.resolve('site')?.getCore?.();
|
||||
if ( core?.experiments?.getExperimentType )
|
||||
return core.experiments.getExperimentType(type);
|
||||
|
||||
if ( type === 1 )
|
||||
return 'device_id';
|
||||
else if ( type === 2 )
|
||||
return 'user_id';
|
||||
else if ( type === 3 )
|
||||
return 'channel_id';
|
||||
return type;
|
||||
}
|
||||
|
||||
getTwitchTypeByKey(key) {
|
||||
const core = this.resolve('site')?.getCore?.(),
|
||||
exps = core && core.experiments,
|
||||
exp = exps?.experiments?.[key];
|
||||
|
||||
if ( exp?.t )
|
||||
return this.getTwitchType(exp.t);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getTwitchExperiments() {
|
||||
if ( window.__twilightSettings )
|
||||
return window.__twilightSettings.experiments;
|
||||
|
||||
const core = this.resolve('site')?.getCore?.();
|
||||
return core && core.experiments.experiments;
|
||||
}
|
||||
|
||||
|
||||
usingTwitchExperiment(key) {
|
||||
const core = this.resolve('site')?.getCore?.();
|
||||
return core && has(core.experiments.assignments, key)
|
||||
}
|
||||
|
||||
|
||||
setTwitchOverride(key, value = null) {
|
||||
const overrides = Cookie.getJSON(OVERRIDE_COOKIE) || {};
|
||||
const experiments = overrides.experiments = overrides.experiments || {};
|
||||
const disabled = overrides.disabled = overrides.disabled || [];
|
||||
experiments[key] = value;
|
||||
const idx = disabled.indexOf(key);
|
||||
if (idx != -1)
|
||||
disabled.remove(idx);
|
||||
Cookie.set(OVERRIDE_COOKIE, overrides, COOKIE_OPTIONS);
|
||||
|
||||
const core = this.resolve('site')?.getCore?.();
|
||||
if ( core )
|
||||
core.experiments.overrides[key] = value;
|
||||
|
||||
this._rebuildTwitchKey(key, true, value);
|
||||
}
|
||||
|
||||
deleteTwitchOverride(key) {
|
||||
const overrides = Cookie.getJSON(OVERRIDE_COOKIE),
|
||||
experiments = overrides?.experiments;
|
||||
if ( ! experiments || ! has(experiments, key) )
|
||||
return;
|
||||
|
||||
const old_val = experiments[key];
|
||||
delete experiments[key];
|
||||
Cookie.set(OVERRIDE_COOKIE, overrides, COOKIE_OPTIONS);
|
||||
|
||||
const core = this.resolve('site')?.getCore?.();
|
||||
if ( core )
|
||||
delete core.experiments.overrides[key];
|
||||
|
||||
this._rebuildTwitchKey(key, false, old_val);
|
||||
}
|
||||
|
||||
hasTwitchOverride(key) { // eslint-disable-line class-methods-use-this
|
||||
const overrides = Cookie.getJSON(OVERRIDE_COOKIE),
|
||||
experiments = overrides?.experiments;
|
||||
return experiments && has(experiments, key);
|
||||
}
|
||||
|
||||
getTwitchAssignment(key, channel = null) {
|
||||
const core = this.resolve('site')?.getCore?.(),
|
||||
exps = core && core.experiments;
|
||||
|
||||
if ( ! exps )
|
||||
return null;
|
||||
|
||||
if ( ! exps.hasInitialized && exps.initialize )
|
||||
try {
|
||||
exps.initialize();
|
||||
} catch(err) {
|
||||
this.log.warn('Error attempting to initialize Twitch experiments tracker.', err);
|
||||
}
|
||||
|
||||
if ( channel || this.getTwitchType(exps.experiments[key]?.t) === 'channel_id' )
|
||||
return exps.getAssignmentById(key, {channel: channel ?? this.settings.get('context.channel')});
|
||||
|
||||
if ( exps.overrides && exps.overrides[key] )
|
||||
return exps.overrides[key];
|
||||
|
||||
else if ( exps.assignments && exps.assignments[key] )
|
||||
return exps.assignments[key];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getTwitchKeyFromName(name) {
|
||||
const experiments = this.getTwitchExperiments();
|
||||
if ( ! experiments )
|
||||
return undefined;
|
||||
|
||||
name = name.toLowerCase();
|
||||
for(const key in experiments)
|
||||
if ( has(experiments, key) ) {
|
||||
const data = experiments[key];
|
||||
if ( data && data.name && data.name.toLowerCase() === name )
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
getTwitchAssignmentByName(name, channel = null) {
|
||||
return this.getTwitchAssignment(this.getTwitchKeyFromName(name), channel);
|
||||
}
|
||||
|
||||
_rebuildTwitchKey(key, is_set, new_val) {
|
||||
const core = this.resolve('site')?.getCore?.(),
|
||||
exps = core.experiments,
|
||||
|
||||
old_val = has(exps.assignments, key) ?
|
||||
exps.assignments[key] :
|
||||
undefined;
|
||||
|
||||
if ( old_val !== new_val ) {
|
||||
const value = is_set ? new_val : old_val;
|
||||
this.emit(':twitch-changed', key, value);
|
||||
this.emit(`:twitch-changed:${key}`, value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// FFZ Experiments
|
||||
|
||||
setOverride(key, value = null) {
|
||||
const overrides = this.settings.provider.get('experiment-overrides') || {};
|
||||
overrides[key] = value;
|
||||
|
||||
this.settings.provider.set('experiment-overrides', overrides);
|
||||
|
||||
this._rebuildKey(key);
|
||||
}
|
||||
|
||||
deleteOverride(key) {
|
||||
const overrides = this.settings.provider.get('experiment-overrides');
|
||||
if ( ! overrides || ! has(overrides, key) )
|
||||
return;
|
||||
|
||||
delete overrides[key];
|
||||
this.settings.provider.set('experiment-overrides', overrides);
|
||||
|
||||
this._rebuildKey(key);
|
||||
}
|
||||
|
||||
hasOverride(key) {
|
||||
const overrides = this.settings.provider.get('experiment-overrides');
|
||||
return overrides && has(overrides, key);
|
||||
}
|
||||
|
||||
getAssignment(key) {
|
||||
if ( this.cache.has(key) )
|
||||
return this.cache.get(key);
|
||||
|
||||
const experiment = this.experiments[key];
|
||||
if ( ! experiment ) {
|
||||
this.log.warn(`Tried to get assignment for experiment "${key}" which is not known.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const overrides = this.settings.provider.get('experiment-overrides'),
|
||||
out = overrides && has(overrides, key) ?
|
||||
overrides[key] :
|
||||
ExperimentManager.selectGroup(key, experiment, this.unique_id);
|
||||
|
||||
this.cache.set(key, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
_rebuildKey(key) {
|
||||
if ( ! this.cache.has(key) )
|
||||
return;
|
||||
|
||||
const old_val = this.cache.get(key);
|
||||
this.cache.delete(key);
|
||||
const new_val = this.getAssignment(key);
|
||||
|
||||
if ( new_val !== old_val ) {
|
||||
this.emit(':changed', key, new_val);
|
||||
this.emit(`:changed:${key}`, new_val);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static selectGroup(key, experiment, unique_id) {
|
||||
const seed = key + unique_id + (experiment.seed || ''),
|
||||
total = experiment.groups.reduce((a,b) => a + b.weight, 0);
|
||||
|
||||
let value = (SHA1(seed).words[0] >>> 0) / Math.pow(2, 32);
|
||||
|
||||
for(const group of experiment.groups) {
|
||||
value -= group.weight / total;
|
||||
if ( value <= 0 )
|
||||
return group.value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
696
src/experiments.ts
Normal file
696
src/experiments.ts
Normal file
|
@ -0,0 +1,696 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Experiments
|
||||
// ============================================================================
|
||||
|
||||
import {DEBUG, SERVER} from 'utilities/constants';
|
||||
import Module, { GenericModule } from 'utilities/module';
|
||||
import {has, deep_copy, fetchJSON} from 'utilities/object';
|
||||
import { getBuster } from 'utilities/time';
|
||||
|
||||
import Cookie from 'js-cookie';
|
||||
import SHA1 from 'crypto-js/sha1';
|
||||
|
||||
import type SettingsManager from './settings';
|
||||
import type { ExperimentTypeMap } from 'utilities/types';
|
||||
|
||||
declare module 'utilities/types' {
|
||||
interface ModuleMap {
|
||||
experiments: ExperimentManager;
|
||||
}
|
||||
interface ModuleEventMap {
|
||||
experiments: ExperimentEvents;
|
||||
}
|
||||
interface ProviderTypeMap {
|
||||
'experiment-overrides': {
|
||||
[K in keyof ExperimentTypeMap]?: ExperimentTypeMap[K];
|
||||
}
|
||||
}
|
||||
interface PubSubCommands {
|
||||
reload_experiments: [];
|
||||
update_experiment: {
|
||||
key: keyof ExperimentTypeMap,
|
||||
data: FFZExperimentData | ExperimentGroup[]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__twilightSettings?: {
|
||||
experiments?: Record<string, TwitchExperimentData>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const OVERRIDE_COOKIE = 'experiment_overrides',
|
||||
COOKIE_OPTIONS = {
|
||||
expires: 7,
|
||||
domain: '.twitch.tv'
|
||||
};
|
||||
|
||||
|
||||
// We want to import this so that the file is included in the output.
|
||||
// We don't load using this because we might want a newer file from the
|
||||
// server. Because of our webpack settings, this is imported as a URL
|
||||
// and not an object.
|
||||
const EXPERIMENTS: string = require('./experiments.json');
|
||||
|
||||
// ============================================================================
|
||||
// Data Types
|
||||
// ============================================================================
|
||||
|
||||
export enum TwitchExperimentType {
|
||||
Unknown = 0,
|
||||
Device = 1,
|
||||
User = 2,
|
||||
Channel = 3
|
||||
};
|
||||
|
||||
export type ExperimentGroup = {
|
||||
value: unknown;
|
||||
weight: number;
|
||||
};
|
||||
|
||||
export type FFZExperimentData = {
|
||||
name: string;
|
||||
seed?: number;
|
||||
description: string;
|
||||
groups: ExperimentGroup[];
|
||||
}
|
||||
|
||||
export type TwitchExperimentData = {
|
||||
name: string;
|
||||
t: TwitchExperimentType;
|
||||
v: number;
|
||||
groups: ExperimentGroup[];
|
||||
};
|
||||
|
||||
export type ExperimentData = FFZExperimentData | TwitchExperimentData;
|
||||
|
||||
|
||||
export type OverrideCookie = {
|
||||
experiments: Record<string, string>;
|
||||
disabled: string[];
|
||||
};
|
||||
|
||||
|
||||
type ExperimentEvents = {
|
||||
':changed': [key: string, new_value: any, old_value: any];
|
||||
':twitch-changed': [key: string, new_value: string | null, old_value: string | null];
|
||||
[key: `:twitch-changed:${string}`]: [new_value: string | null, old_value: string | null];
|
||||
} & {
|
||||
[K in keyof ExperimentTypeMap as `:changed:${K}`]: [new_value: ExperimentTypeMap[K], old_value: ExperimentTypeMap[K] | null];
|
||||
};
|
||||
|
||||
|
||||
type ExperimentLogEntry = {
|
||||
key: string;
|
||||
name: string;
|
||||
value: any;
|
||||
override: boolean;
|
||||
rarity: number;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Helper Methods
|
||||
// ============================================================================
|
||||
|
||||
export function isTwitchExperiment(exp: ExperimentData): exp is TwitchExperimentData {
|
||||
return 't' in exp;
|
||||
}
|
||||
|
||||
export function isFFZExperiment(exp: ExperimentData): exp is FFZExperimentData {
|
||||
return 'description' in exp;
|
||||
}
|
||||
|
||||
function sortExperimentLog(a: ExperimentLogEntry, b: ExperimentLogEntry) {
|
||||
if ( a.rarity < b.rarity )
|
||||
return -1;
|
||||
else if ( a.rarity > b.rarity )
|
||||
return 1;
|
||||
|
||||
if ( a.name < b.name )
|
||||
return -1;
|
||||
else if ( a.name > b.name )
|
||||
return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Experiment Manager
|
||||
// ============================================================================
|
||||
|
||||
export default class ExperimentManager extends Module<'experiments', ExperimentEvents> {
|
||||
|
||||
// Dependencies
|
||||
settings: SettingsManager = null as any;
|
||||
|
||||
// State
|
||||
unique_id?: string;
|
||||
experiments: Partial<{
|
||||
[K in keyof ExperimentTypeMap]: FFZExperimentData;
|
||||
}>;
|
||||
|
||||
private cache: Map<keyof ExperimentTypeMap, unknown>;
|
||||
|
||||
|
||||
// Helpers
|
||||
Cookie: typeof Cookie;
|
||||
|
||||
|
||||
constructor(name?: string, parent?: GenericModule) {
|
||||
super(name, parent);
|
||||
|
||||
this.get = this.getAssignment;
|
||||
|
||||
this.inject('settings');
|
||||
|
||||
this.settings.addUI('experiments', {
|
||||
path: 'Debugging > Experiments',
|
||||
component: 'experiments',
|
||||
no_filter: true,
|
||||
|
||||
getExtraTerms: () => {
|
||||
const values: string[] = [];
|
||||
|
||||
for(const [key, val] of Object.entries(this.experiments)) {
|
||||
values.push(key);
|
||||
if ( val.name )
|
||||
values.push(val.name);
|
||||
if ( val.description )
|
||||
values.push(val.description);
|
||||
}
|
||||
|
||||
for(const [key, val] of Object.entries(this.getTwitchExperiments())) {
|
||||
values.push(key);
|
||||
if ( val.name )
|
||||
values.push(val.name);
|
||||
}
|
||||
|
||||
return values;
|
||||
},
|
||||
|
||||
is_locked: () => this.getControlsLocked(),
|
||||
unlock: () => this.unlockControls(),
|
||||
|
||||
unique_id: () => this.unique_id,
|
||||
|
||||
ffz_data: () => deep_copy(this.experiments),
|
||||
twitch_data: () => deep_copy(this.getTwitchExperiments()),
|
||||
|
||||
usingTwitchExperiment: (key: string) => this.usingTwitchExperiment(key),
|
||||
getTwitchAssignment: (key: string) => this.getTwitchAssignment(key),
|
||||
getTwitchType: (type: TwitchExperimentType) => this.getTwitchType(type),
|
||||
hasTwitchOverride: (key: string) => this.hasTwitchOverride(key),
|
||||
setTwitchOverride: (key: string, val: string) => this.setTwitchOverride(key, val),
|
||||
deleteTwitchOverride: (key: string) => this.deleteTwitchOverride(key),
|
||||
|
||||
getAssignment: <K extends keyof ExperimentTypeMap>(key: K) => this.getAssignment(key),
|
||||
hasOverride: (key: keyof ExperimentTypeMap) => this.hasOverride(key),
|
||||
setOverride: <K extends keyof ExperimentTypeMap>(key: K, val: ExperimentTypeMap[K]) => this.setOverride(key, val),
|
||||
deleteOverride: (key: keyof ExperimentTypeMap) => this.deleteOverride(key),
|
||||
|
||||
on: (...args: Parameters<typeof this.on>) => this.on(...args),
|
||||
off: (...args: Parameters<typeof this.off>) => this.off(...args)
|
||||
});
|
||||
|
||||
this.unique_id = Cookie.get('unique_id');
|
||||
|
||||
this.Cookie = Cookie;
|
||||
|
||||
this.experiments = {};
|
||||
this.cache = new Map;
|
||||
}
|
||||
|
||||
getControlsLocked() {
|
||||
if ( DEBUG )
|
||||
return false;
|
||||
|
||||
const ts = this.settings.provider.get<number>('exp-lock', 0);
|
||||
if ( isNaN(ts) || ! isFinite(ts) )
|
||||
return true;
|
||||
|
||||
return Date.now() - ts >= 86400000;
|
||||
}
|
||||
|
||||
unlockControls() {
|
||||
this.settings.provider.set('exp-lock', Date.now());
|
||||
}
|
||||
|
||||
async onLoad() {
|
||||
await this.loadExperiments();
|
||||
}
|
||||
|
||||
|
||||
async loadExperiments() {
|
||||
let data: Record<keyof ExperimentTypeMap, FFZExperimentData> | null;
|
||||
|
||||
try {
|
||||
data = await fetchJSON(DEBUG
|
||||
? EXPERIMENTS
|
||||
: `${SERVER}/script/experiments.json?_=${getBuster()}`
|
||||
);
|
||||
|
||||
} catch(err) {
|
||||
this.log.warn('Unable to load experiment data.', err);
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! data )
|
||||
return;
|
||||
|
||||
this.experiments = data;
|
||||
|
||||
const old_cache = this.cache;
|
||||
this.cache = new Map;
|
||||
|
||||
let changed = 0;
|
||||
|
||||
for(const [key, old_val] of old_cache.entries()) {
|
||||
const new_val = this.getAssignment(key);
|
||||
if ( old_val !== new_val ) {
|
||||
changed++;
|
||||
this.emit(':changed', key, new_val, old_val);
|
||||
this.emit(`:changed:${key as keyof ExperimentTypeMap}`, new_val as any, old_val as any);
|
||||
}
|
||||
}
|
||||
|
||||
this.log.info(`Loaded information on ${Object.keys(data).length} experiments.${changed > 0 ? ` ${changed} values updated.` : ''}`);
|
||||
//this.emit(':loaded');
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
onEnable() {
|
||||
this.on('pubsub:command:reload_experiments', this.loadExperiments, this);
|
||||
this.on('pubsub:command:update_experiment', data => {
|
||||
this.updateExperiment(data.key, data.data);
|
||||
}, this);
|
||||
}
|
||||
|
||||
|
||||
updateExperiment(key: keyof ExperimentTypeMap, data: FFZExperimentData | ExperimentGroup[]) {
|
||||
this.log.info(`Received updated data for experiment "${key}" via PubSub.`, data);
|
||||
|
||||
if ( Array.isArray(data) ) {
|
||||
const existing = this.experiments[key];
|
||||
if ( ! existing )
|
||||
return;
|
||||
|
||||
existing.groups = data;
|
||||
|
||||
} else if ( data?.groups )
|
||||
this.experiments[key] = data;
|
||||
|
||||
this._rebuildKey(key);
|
||||
}
|
||||
|
||||
|
||||
generateLog() {
|
||||
const out = [
|
||||
`Unique ID: ${this.unique_id}`,
|
||||
''
|
||||
];
|
||||
|
||||
const ffz_assignments: ExperimentLogEntry[] = [];
|
||||
for(const [key, value] of Object.entries(this.experiments) as [keyof ExperimentTypeMap, FFZExperimentData][]) {
|
||||
const assignment = this.getAssignment(key),
|
||||
override = this.hasOverride(key);
|
||||
|
||||
let weight = 0, total = 0;
|
||||
for(const group of value.groups) {
|
||||
if ( group.value === assignment )
|
||||
weight = group.weight;
|
||||
total += group.weight;
|
||||
}
|
||||
|
||||
if ( ! override && weight === total )
|
||||
continue;
|
||||
|
||||
ffz_assignments.push({
|
||||
key,
|
||||
name: value.name,
|
||||
value: assignment,
|
||||
override,
|
||||
rarity: weight / total
|
||||
});
|
||||
|
||||
//out.push(`FFZ | ${value.name}: ${this.getAssignment(key)}${this.hasOverride(key) ? ' (Overriden)' : ''}`);
|
||||
}
|
||||
|
||||
ffz_assignments.sort(sortExperimentLog);
|
||||
|
||||
for(const entry of ffz_assignments)
|
||||
out.push(`FFZ | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity})`);
|
||||
|
||||
const twitch_assignments: ExperimentLogEntry[] = [],
|
||||
channel = this.settings.get('context.channel');
|
||||
|
||||
for(const [key, value] of Object.entries(this.getTwitchExperiments())) {
|
||||
if ( ! this.usingTwitchExperiment(key) )
|
||||
continue;
|
||||
|
||||
const assignment = this.getTwitchAssignment(key),
|
||||
override = this.hasTwitchOverride(key);
|
||||
|
||||
let weight = 0, total = 0;
|
||||
for(const group of value.groups) {
|
||||
if ( group.value === assignment )
|
||||
weight = group.weight;
|
||||
total += group.weight;
|
||||
}
|
||||
|
||||
if ( ! override && weight === total )
|
||||
continue;
|
||||
|
||||
twitch_assignments.push({
|
||||
key,
|
||||
name: value.name,
|
||||
value: assignment,
|
||||
override,
|
||||
type: this.getTwitchTypeByKey(key),
|
||||
rarity: weight / total
|
||||
});
|
||||
|
||||
//out.push(`TWITCH | ${value.name}: ${this.getTwitchAssignment(key)}${this.hasTwitchOverride(key) ? ' (Overriden)' : ''}`)
|
||||
}
|
||||
|
||||
twitch_assignments.sort(sortExperimentLog);
|
||||
|
||||
for(const entry of twitch_assignments)
|
||||
out.push(`Twitch | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity}, t:${entry.type}${entry.type === 'channel_id' ? `, c:${channel}`: ''})`);
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
|
||||
// Twitch Experiments
|
||||
|
||||
getTwitchType(type: number) {
|
||||
const core = this.resolve('site')?.getCore?.();
|
||||
if ( core?.experiments?.getExperimentType )
|
||||
return core.experiments.getExperimentType(type);
|
||||
|
||||
if ( type === 1 )
|
||||
return 'device_id';
|
||||
else if ( type === 2 )
|
||||
return 'user_id';
|
||||
else if ( type === 3 )
|
||||
return 'channel_id';
|
||||
return type;
|
||||
}
|
||||
|
||||
getTwitchTypeByKey(key: string) {
|
||||
const exps = this.getTwitchExperiments(),
|
||||
exp = exps?.[key];
|
||||
|
||||
if ( exp?.t )
|
||||
return this.getTwitchType(exp.t);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getTwitchExperiments(): Record<string, TwitchExperimentData> {
|
||||
if ( window.__twilightSettings )
|
||||
return window.__twilightSettings.experiments ?? {};
|
||||
|
||||
const core = this.resolve('site')?.getCore?.();
|
||||
return core && core.experiments.experiments || {};
|
||||
}
|
||||
|
||||
|
||||
usingTwitchExperiment(key: string) {
|
||||
const core = this.resolve('site')?.getCore?.();
|
||||
return core && has(core.experiments.assignments, key)
|
||||
}
|
||||
|
||||
|
||||
private _getOverrideCookie() {
|
||||
const raw = Cookie.get(OVERRIDE_COOKIE);
|
||||
let out: OverrideCookie;
|
||||
|
||||
try {
|
||||
out = raw ? JSON.parse(raw) : {};
|
||||
} catch(err) {
|
||||
out = {} as OverrideCookie;
|
||||
}
|
||||
|
||||
if ( ! out.experiments )
|
||||
out.experiments = {};
|
||||
|
||||
if ( ! out.disabled )
|
||||
out.disabled = [];
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
private _saveOverrideCookie(value?: OverrideCookie) {
|
||||
if ( value ) {
|
||||
if ((! value.experiments || ! Object.keys(value.experiments).length) &&
|
||||
(! value.disabled || ! value.disabled.length)
|
||||
)
|
||||
value = undefined;
|
||||
}
|
||||
|
||||
if ( value )
|
||||
Cookie.set(OVERRIDE_COOKIE, JSON.stringify(value), COOKIE_OPTIONS);
|
||||
else
|
||||
Cookie.remove(OVERRIDE_COOKIE, COOKIE_OPTIONS);
|
||||
}
|
||||
|
||||
|
||||
setTwitchOverride(key: string, value: string) {
|
||||
const overrides = this._getOverrideCookie(),
|
||||
experiments = overrides.experiments,
|
||||
disabled = overrides.disabled;
|
||||
|
||||
experiments[key] = value;
|
||||
|
||||
const idx = disabled.indexOf(key);
|
||||
if (idx != -1)
|
||||
disabled.splice(idx, 1);
|
||||
|
||||
this._saveOverrideCookie(overrides);
|
||||
|
||||
const core = this.resolve('site')?.getCore?.();
|
||||
if ( core )
|
||||
core.experiments.overrides[key] = value;
|
||||
|
||||
this._rebuildTwitchKey(key, true, value);
|
||||
}
|
||||
|
||||
deleteTwitchOverride(key: string) {
|
||||
const overrides = this._getOverrideCookie(),
|
||||
experiments = overrides.experiments;
|
||||
|
||||
if ( ! has(experiments, key) )
|
||||
return;
|
||||
|
||||
const old_val = experiments[key];
|
||||
delete experiments[key];
|
||||
|
||||
this._saveOverrideCookie(overrides);
|
||||
|
||||
const core = this.resolve('site')?.getCore?.();
|
||||
if ( core )
|
||||
delete core.experiments.overrides[key];
|
||||
|
||||
this._rebuildTwitchKey(key, false, old_val);
|
||||
}
|
||||
|
||||
hasTwitchOverride(key: string) { // eslint-disable-line class-methods-use-this
|
||||
const overrides = this._getOverrideCookie(),
|
||||
experiments = overrides.experiments;
|
||||
|
||||
return has(experiments, key);
|
||||
}
|
||||
|
||||
getTwitchAssignment(key: string, channel: string | null = null) {
|
||||
const core = this.resolve('site')?.getCore?.(),
|
||||
exps = core && core.experiments;
|
||||
|
||||
if ( ! exps )
|
||||
return null;
|
||||
|
||||
if ( ! exps.hasInitialized && exps.initialize )
|
||||
try {
|
||||
exps.initialize();
|
||||
} catch(err) {
|
||||
this.log.warn('Error attempting to initialize Twitch experiments tracker.', err);
|
||||
}
|
||||
|
||||
if ( exps.overrides && exps.overrides[key] )
|
||||
return exps.overrides[key];
|
||||
|
||||
const exp_data = exps.experiments[key],
|
||||
type = this.getTwitchType(exp_data?.t ?? 0);
|
||||
|
||||
// channel_id experiments always use getAssignmentById
|
||||
if ( type === 'channel_id' ) {
|
||||
return exps.getAssignmentById(key, {
|
||||
bucketing: {
|
||||
type: 1,
|
||||
value: channel ?? this.settings.get('context.channelID')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Otherwise, just use the default assignment?
|
||||
if ( exps.assignments?.[key] )
|
||||
return exps.assignments[key];
|
||||
|
||||
// If there is no default assignment, we should try to figure out
|
||||
// what assignment they *would* get.
|
||||
|
||||
if ( type === 'device_id' )
|
||||
return exps.selectTreatment(key, exp_data, this.unique_id);
|
||||
|
||||
else if ( type === 'user_id' )
|
||||
// Technically, some experiments are expecting to get the user's
|
||||
// login rather than user ID. But we don't care that much if an
|
||||
// inactive legacy experiment is shown wrong. Meh.
|
||||
return exps.selectTreatment(key, exp_data, this.resolve('site')?.getUser?.()?.id);
|
||||
|
||||
// We don't know what kind of experiment this is.
|
||||
// Give up!
|
||||
return null;
|
||||
}
|
||||
|
||||
getTwitchKeyFromName(name: string) {
|
||||
const experiments = this.getTwitchExperiments();
|
||||
if ( ! experiments )
|
||||
return;
|
||||
|
||||
name = name.toLowerCase();
|
||||
for(const key in experiments)
|
||||
if ( has(experiments, key) ) {
|
||||
const data = experiments[key];
|
||||
if ( data && data.name && data.name.toLowerCase() === name )
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
getTwitchAssignmentByName(name: string, channel: string | null = null) {
|
||||
const key = this.getTwitchKeyFromName(name);
|
||||
if ( ! key )
|
||||
return null;
|
||||
return this.getTwitchAssignment(key, channel);
|
||||
}
|
||||
|
||||
private _rebuildTwitchKey(
|
||||
key: string,
|
||||
is_set: boolean,
|
||||
new_val: string | null
|
||||
) {
|
||||
const core = this.resolve('site')?.getCore?.(),
|
||||
exps = core.experiments,
|
||||
|
||||
old_val = has(exps.assignments, key) ?
|
||||
exps.assignments[key] as string :
|
||||
null;
|
||||
|
||||
if ( old_val !== new_val ) {
|
||||
const value = is_set ? new_val : old_val;
|
||||
this.emit(':twitch-changed', key, value, old_val);
|
||||
this.emit(`:twitch-changed:${key}`, value, old_val);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// FFZ Experiments
|
||||
|
||||
setOverride<
|
||||
K extends keyof ExperimentTypeMap
|
||||
>(key: K, value: ExperimentTypeMap[K]) {
|
||||
const overrides = this.settings.provider.get('experiment-overrides', {});
|
||||
overrides[key] = value;
|
||||
|
||||
this.settings.provider.set('experiment-overrides', overrides);
|
||||
|
||||
this._rebuildKey(key);
|
||||
}
|
||||
|
||||
deleteOverride(key: keyof ExperimentTypeMap) {
|
||||
const overrides = this.settings.provider.get('experiment-overrides');
|
||||
if ( ! overrides || ! has(overrides, key) )
|
||||
return;
|
||||
|
||||
delete overrides[key];
|
||||
if ( Object.keys(overrides).length )
|
||||
this.settings.provider.set('experiment-overrides', overrides);
|
||||
else
|
||||
this.settings.provider.delete('experiment-overrides');
|
||||
|
||||
this._rebuildKey(key);
|
||||
}
|
||||
|
||||
hasOverride(key: keyof ExperimentTypeMap) {
|
||||
const overrides = this.settings.provider.get('experiment-overrides');
|
||||
return overrides ? has(overrides, key): false;
|
||||
}
|
||||
|
||||
get: <K extends keyof ExperimentTypeMap>(
|
||||
key: K
|
||||
) => ExperimentTypeMap[K];
|
||||
|
||||
getAssignment<K extends keyof ExperimentTypeMap>(
|
||||
key: K
|
||||
): ExperimentTypeMap[K] {
|
||||
if ( this.cache.has(key) )
|
||||
return this.cache.get(key) as ExperimentTypeMap[K];
|
||||
|
||||
const experiment = this.experiments[key];
|
||||
if ( ! experiment ) {
|
||||
this.log.warn(`Tried to get assignment for experiment "${key}" which is not known.`);
|
||||
return null as ExperimentTypeMap[K];
|
||||
}
|
||||
|
||||
const overrides = this.settings.provider.get('experiment-overrides'),
|
||||
out = overrides && has(overrides, key) ?
|
||||
overrides[key] :
|
||||
ExperimentManager.selectGroup<ExperimentTypeMap[K]>(key, experiment, this.unique_id ?? '');
|
||||
|
||||
this.cache.set(key, out);
|
||||
return out as ExperimentTypeMap[K];
|
||||
}
|
||||
|
||||
private _rebuildKey(key: keyof ExperimentTypeMap) {
|
||||
if ( ! this.cache.has(key) )
|
||||
return;
|
||||
|
||||
const old_val = this.cache.get(key);
|
||||
this.cache.delete(key);
|
||||
const new_val = this.getAssignment(key);
|
||||
|
||||
if ( new_val !== old_val ) {
|
||||
this.emit(':changed', key, new_val, old_val);
|
||||
this.emit(`:changed:${key}`, new_val, old_val);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static selectGroup<T>(
|
||||
key: string,
|
||||
experiment: FFZExperimentData,
|
||||
unique_id: string
|
||||
): T | null {
|
||||
const seed = key + unique_id + (experiment.seed || ''),
|
||||
total = experiment.groups.reduce((a,b) => a + b.weight, 0);
|
||||
|
||||
let value = (SHA1(seed).words[0] >>> 0) / Math.pow(2, 32);
|
||||
|
||||
for(const group of experiment.groups) {
|
||||
value -= group.weight / total;
|
||||
if ( value <= 0 )
|
||||
return group.value as T;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
169
src/load_tracker.ts
Normal file
169
src/load_tracker.ts
Normal file
|
@ -0,0 +1,169 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Loading Tracker
|
||||
// ============================================================================
|
||||
|
||||
import Module, { GenericModule } from 'utilities/module';
|
||||
import type SettingsManager from './settings';
|
||||
|
||||
|
||||
declare module 'utilities/types' {
|
||||
interface ModuleEventMap {
|
||||
load_tracker: LoadEvents;
|
||||
}
|
||||
interface ModuleMap {
|
||||
load_tracker: LoadTracker;
|
||||
}
|
||||
interface SettingsTypeMap {
|
||||
'chat.update-when-loaded': boolean;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 VueModule 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);
|
||||
|
||||
|
||||
// ========================================================================
|
||||
|
@ -65,7 +138,7 @@ class FrankerFaceZ extends Module {
|
|||
this.inject('site', Site);
|
||||
this.inject('addons', AddonManager);
|
||||
|
||||
this.register('vue', Vue);
|
||||
this.register('vue', VueModule);
|
||||
|
||||
|
||||
// ========================================================================
|
||||
|
@ -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();
|
|
@ -614,7 +614,7 @@ export default class Actions extends Module {
|
|||
},
|
||||
|
||||
onMove: (target, tip, event) => {
|
||||
this.emit('tooltips:mousemove', target, tip, event)
|
||||
this.emit('tooltips:hover', target, tip, event)
|
||||
},
|
||||
|
||||
onLeave: (target, tip, event) => {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -4,14 +4,43 @@
|
|||
// Name and Color Overrides
|
||||
// ============================================================================
|
||||
|
||||
import Module from 'utilities/module';
|
||||
import Module, { GenericModule } from 'utilities/module';
|
||||
import { createElement, ClickOutside } from 'utilities/dom';
|
||||
import Tooltip from 'utilities/tooltip';
|
||||
import type SettingsManager from 'root/src/settings';
|
||||
|
||||
|
||||
export default class Overrides extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
declare module 'utilities/types' {
|
||||
interface ModuleMap {
|
||||
'chat.overrides': Overrides;
|
||||
}
|
||||
interface ModuleEventMap {
|
||||
'chat.overrides': OverrideEvents;
|
||||
}
|
||||
interface ProviderTypeMap {
|
||||
'overrides.colors': Record<string, string | undefined>;
|
||||
'overrides.names': Record<string, string | undefined>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export type OverrideEvents = {
|
||||
':changed': [id: string, type: 'name' | 'color', value: string | undefined];
|
||||
}
|
||||
|
||||
|
||||
export default class Overrides extends Module<'chat.overrides'> {
|
||||
|
||||
// Dependencies
|
||||
settings: SettingsManager = null as any;
|
||||
|
||||
// State and Caching
|
||||
color_cache: Record<string, string | undefined> | null;
|
||||
name_cache: Record<string, string | undefined> | null;
|
||||
|
||||
|
||||
constructor(name?: string, parent?: GenericModule) {
|
||||
super(name, parent);
|
||||
|
||||
this.inject('settings');
|
||||
|
||||
|
@ -35,12 +64,15 @@ export default class Overrides extends Module {
|
|||
});*/
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
onEnable() {
|
||||
this.settings.provider.on('changed', this.onProviderChange, this);
|
||||
}
|
||||
|
||||
renderUserEditor(user, target) {
|
||||
let outside, popup, ve;
|
||||
renderUserEditor(user: any, target: HTMLElement) {
|
||||
let outside: ClickOutside | null,
|
||||
popup: Tooltip | null,
|
||||
ve: any;
|
||||
|
||||
const destroy = () => {
|
||||
const o = outside, p = popup, v = ve;
|
||||
|
@ -56,7 +88,10 @@ export default class Overrides extends Module {
|
|||
v.$destroy();
|
||||
}
|
||||
|
||||
const parent = document.fullscreenElement || document.body.querySelector('#root>div') || document.body;
|
||||
const parent =
|
||||
document.fullscreenElement as HTMLElement
|
||||
?? document.body.querySelector<HTMLElement>('#root>div')
|
||||
?? document.body;
|
||||
|
||||
popup = new Tooltip(parent, [], {
|
||||
logger: this.log,
|
||||
|
@ -88,6 +123,9 @@ export default class Overrides extends Module {
|
|||
const vue = this.resolve('vue'),
|
||||
_editor = import(/* webpackChunkName: "overrides" */ './override-editor.vue');
|
||||
|
||||
if ( ! vue )
|
||||
throw new Error('unable to load vue');
|
||||
|
||||
const [, editor] = await Promise.all([vue.enable(), _editor]);
|
||||
vue.component('override-editor', editor.default);
|
||||
|
||||
|
@ -118,12 +156,13 @@ export default class Overrides extends Module {
|
|||
onShow: async (t, tip) => {
|
||||
await tip.waitForDom();
|
||||
requestAnimationFrame(() => {
|
||||
if ( tip.outer )
|
||||
outside = new ClickOutside(tip.outer, destroy)
|
||||
});
|
||||
},
|
||||
|
||||
onMove: (target, tip, event) => {
|
||||
this.emit('tooltips:mousemove', target, tip, event)
|
||||
this.emit('tooltips:hover', target, tip, event)
|
||||
},
|
||||
|
||||
onLeave: (target, tip, event) => {
|
||||
|
@ -137,30 +176,25 @@ export default class Overrides extends Module {
|
|||
}
|
||||
|
||||
|
||||
onProviderChange(key) {
|
||||
if ( key === 'overrides.colors' )
|
||||
onProviderChange(key: string) {
|
||||
if ( key === 'overrides.colors' && this.color_cache )
|
||||
this.loadColors();
|
||||
else if ( key === 'overrides.names' )
|
||||
else if ( key === 'overrides.names' && this.name_cache )
|
||||
this.loadNames();
|
||||
}
|
||||
|
||||
get colors() {
|
||||
if ( ! this.color_cache )
|
||||
this.loadColors();
|
||||
|
||||
return this.color_cache;
|
||||
return this.color_cache ?? this.loadColors();
|
||||
}
|
||||
|
||||
get names() {
|
||||
if ( ! this.name_cache )
|
||||
this.loadNames();
|
||||
|
||||
return this.name_cache;
|
||||
return this.name_cache ?? this.loadNames();
|
||||
}
|
||||
|
||||
loadColors() {
|
||||
let old_keys,
|
||||
let old_keys: Set<string>,
|
||||
loaded = true;
|
||||
|
||||
if ( ! this.color_cache ) {
|
||||
loaded = false;
|
||||
this.color_cache = {};
|
||||
|
@ -168,7 +202,9 @@ export default class Overrides extends Module {
|
|||
} else
|
||||
old_keys = new Set(Object.keys(this.color_cache));
|
||||
|
||||
for(const [key, val] of Object.entries(this.settings.provider.get('overrides.colors', {}))) {
|
||||
const entries = this.settings.provider.get('overrides.colors');
|
||||
if ( entries )
|
||||
for(const [key, val] of Object.entries(entries)) {
|
||||
old_keys.delete(key);
|
||||
if ( this.color_cache[key] !== val ) {
|
||||
this.color_cache[key] = val;
|
||||
|
@ -182,10 +218,12 @@ export default class Overrides extends Module {
|
|||
if ( loaded )
|
||||
this.emit(':changed', key, 'color', undefined);
|
||||
}
|
||||
|
||||
return this.color_cache;
|
||||
}
|
||||
|
||||
loadNames() {
|
||||
let old_keys,
|
||||
let old_keys: Set<string>,
|
||||
loaded = true;
|
||||
if ( ! this.name_cache ) {
|
||||
loaded = false;
|
||||
|
@ -194,7 +232,9 @@ export default class Overrides extends Module {
|
|||
} else
|
||||
old_keys = new Set(Object.keys(this.name_cache));
|
||||
|
||||
for(const [key, val] of Object.entries(this.settings.provider.get('overrides.names', {}))) {
|
||||
const entries = this.settings.provider.get('overrides.names');
|
||||
if ( entries )
|
||||
for(const [key, val] of Object.entries(entries)) {
|
||||
old_keys.delete(key);
|
||||
if ( this.name_cache[key] !== val ) {
|
||||
this.name_cache[key] = val;
|
||||
|
@ -208,23 +248,19 @@ export default class Overrides extends Module {
|
|||
if ( loaded )
|
||||
this.emit(':changed', key, 'name', undefined);
|
||||
}
|
||||
|
||||
return this.name_cache;
|
||||
}
|
||||
|
||||
getColor(id) {
|
||||
if ( this.colors[id] != null )
|
||||
return this.colors[id];
|
||||
|
||||
return null;
|
||||
getColor(id: string): string | null {
|
||||
return this.colors[id] ?? null;
|
||||
}
|
||||
|
||||
getName(id) {
|
||||
if ( this.names[id] != null )
|
||||
return this.names[id];
|
||||
|
||||
return null;
|
||||
getName(id: string) {
|
||||
return this.names[id] ?? null;
|
||||
}
|
||||
|
||||
setColor(id, color) {
|
||||
setColor(id: string, color?: string) {
|
||||
if ( this.colors[id] !== color ) {
|
||||
this.colors[id] = color;
|
||||
this.settings.provider.set('overrides.colors', this.colors);
|
||||
|
@ -232,7 +268,7 @@ export default class Overrides extends Module {
|
|||
}
|
||||
}
|
||||
|
||||
setName(id, name) {
|
||||
setName(id: string, name?: string) {
|
||||
if ( this.names[id] !== name ) {
|
||||
this.names[id] = name;
|
||||
this.settings.provider.set('overrides.names', this.names);
|
||||
|
@ -240,11 +276,11 @@ export default class Overrides extends Module {
|
|||
}
|
||||
}
|
||||
|
||||
deleteColor(id) {
|
||||
deleteColor(id: string) {
|
||||
this.setColor(id, undefined);
|
||||
}
|
||||
|
||||
deleteName(id) {
|
||||
deleteName(id: string) {
|
||||
this.setName(id, undefined);
|
||||
}
|
||||
}
|
8
src/modules/chat/types.ts
Normal file
8
src/modules/chat/types.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
|
||||
// ============================================================================
|
||||
// Badges
|
||||
// ============================================================================
|
||||
|
||||
export type BadgeAssignment = {
|
||||
id: string;
|
||||
};
|
|
@ -5,20 +5,39 @@
|
|||
// ============================================================================
|
||||
|
||||
import {SourcedSet} from 'utilities/object';
|
||||
import type Chat from '.';
|
||||
import type Room from './room';
|
||||
import type { BadgeAssignment } from './types';
|
||||
|
||||
export default class User {
|
||||
constructor(manager, room, id, login) {
|
||||
|
||||
// Parent
|
||||
manager: Chat;
|
||||
room: Room | null;
|
||||
|
||||
// State
|
||||
destroyed: boolean = false;
|
||||
|
||||
_id: string | null;
|
||||
_login: string | null = null;
|
||||
|
||||
// Storage
|
||||
emote_sets: SourcedSet<string> | null;
|
||||
badges: SourcedSet<BadgeAssignment> | null;
|
||||
|
||||
|
||||
constructor(manager: Chat, room: Room | null, id: string | null, login: string | null) {
|
||||
this.manager = manager;
|
||||
this.room = room;
|
||||
|
||||
this.emote_sets = null; //new SourcedSet;
|
||||
this.badges = null; // new SourcedSet;
|
||||
this.emote_sets = null;
|
||||
this.badges = null;
|
||||
|
||||
this._id = id;
|
||||
this.login = login;
|
||||
|
||||
if ( id )
|
||||
(room || manager).user_ids[id] = this;
|
||||
(room ?? manager).user_ids[id] = this;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
@ -31,6 +50,7 @@ export default class User {
|
|||
this.emote_sets = null;
|
||||
}
|
||||
|
||||
// Badges are not referenced, so we can just dump them all.
|
||||
if ( this.badges )
|
||||
this.badges = null;
|
||||
|
||||
|
@ -45,26 +65,24 @@ export default class User {
|
|||
}
|
||||
}
|
||||
|
||||
merge(other) {
|
||||
merge(other: User) {
|
||||
if ( ! this.login && other.login )
|
||||
this.login = other.login;
|
||||
|
||||
if ( other.emote_sets && other.emote_sets._sources ) {
|
||||
for(const [provider, sets] of other.emote_sets._sources.entries()) {
|
||||
if ( other.emote_sets )
|
||||
for(const [provider, sets] of other.emote_sets.iterateSources()) {
|
||||
for(const set_id of sets)
|
||||
this.addSet(provider, set_id);
|
||||
}
|
||||
}
|
||||
|
||||
if ( other.badges && other.badges._sources ) {
|
||||
for(const [provider, badges] of other.badges._sources.entries()) {
|
||||
if ( other.badges )
|
||||
for(const [provider, badges] of other.badges.iterateSources()) {
|
||||
for(const badge of badges)
|
||||
this.addBadge(provider, badge.id, badge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_unloadAddon(addon_id) {
|
||||
_unloadAddon(addon_id: string) {
|
||||
// TODO: This
|
||||
return 0;
|
||||
}
|
||||
|
@ -107,9 +125,9 @@ export default class User {
|
|||
// Add Badges
|
||||
// ========================================================================
|
||||
|
||||
addBadge(provider, badge_id, data) {
|
||||
addBadge(provider: string, badge_id: string, data?: BadgeAssignment) {
|
||||
if ( this.destroyed )
|
||||
return;
|
||||
return false;
|
||||
|
||||
if ( typeof badge_id === 'number' )
|
||||
badge_id = `${badge_id}`;
|
||||
|
@ -122,8 +140,9 @@ export default class User {
|
|||
if ( ! this.badges )
|
||||
this.badges = new SourcedSet;
|
||||
|
||||
if ( this.badges.has(provider) )
|
||||
for(const old_b of this.badges.get(provider))
|
||||
const existing = this.badges.get(provider);
|
||||
if ( existing )
|
||||
for(const old_b of existing)
|
||||
if ( old_b.id == badge_id ) {
|
||||
Object.assign(old_b, data);
|
||||
return false;
|
||||
|
@ -135,31 +154,35 @@ export default class User {
|
|||
}
|
||||
|
||||
|
||||
getBadge(badge_id) {
|
||||
if ( ! this.badges )
|
||||
return null;
|
||||
|
||||
getBadge(badge_id: string) {
|
||||
if ( this.badges )
|
||||
for(const badge of this.badges._cache)
|
||||
if ( badge.id == badge_id )
|
||||
return badge;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
removeBadge(provider, badge_id) {
|
||||
if ( ! this.badges || ! this.badges.has(provider) )
|
||||
removeBadge(provider: string, badge_id: string) {
|
||||
if ( ! this.badges )
|
||||
return false;
|
||||
|
||||
for(const old_b of this.badges.get(provider))
|
||||
const existing = this.badges.get(provider);
|
||||
if ( existing )
|
||||
for(const old_b of existing)
|
||||
if ( old_b.id == badge_id ) {
|
||||
this.badges.remove(provider, old_b);
|
||||
//this.manager.badges.unrefBadge(badge_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
removeAllBadges(provider) {
|
||||
if ( this.destroyed || ! this.badges )
|
||||
removeAllBadges(provider: string) {
|
||||
if ( ! this.badges )
|
||||
return false;
|
||||
|
||||
if ( ! this.badges.has(provider) )
|
||||
|
@ -175,7 +198,7 @@ export default class User {
|
|||
// Emote Sets
|
||||
// ========================================================================
|
||||
|
||||
addSet(provider, set_id, data) {
|
||||
addSet(provider: string, set_id: string, data?: unknown) {
|
||||
if ( this.destroyed )
|
||||
return;
|
||||
|
||||
|
@ -203,8 +226,8 @@ export default class User {
|
|||
return added;
|
||||
}
|
||||
|
||||
removeAllSets(provider) {
|
||||
if ( this.destroyed || ! this.emote_sets )
|
||||
removeAllSets(provider: string) {
|
||||
if ( ! this.emote_sets )
|
||||
return false;
|
||||
|
||||
const sets = this.emote_sets.get(provider);
|
||||
|
@ -217,8 +240,8 @@ export default class User {
|
|||
return true;
|
||||
}
|
||||
|
||||
removeSet(provider, set_id) {
|
||||
if ( this.destroyed || ! this.emote_sets )
|
||||
removeSet(provider: string, set_id: string) {
|
||||
if ( ! this.emote_sets )
|
||||
return;
|
||||
|
||||
if ( typeof set_id === 'number' )
|
|
@ -633,7 +633,7 @@ export default {
|
|||
// TODO: Update timestamps for pinned chat?
|
||||
}
|
||||
|
||||
this.chat.resolve('site.subpump').inject(item.topic, item.data);
|
||||
this.chat.resolve('site.subpump').simulateMessage(item.topic, item.data);
|
||||
}
|
||||
|
||||
if ( item.chat ) {
|
||||
|
|
|
@ -173,7 +173,7 @@
|
|||
@change="onTwitchChange($event)"
|
||||
>
|
||||
<option
|
||||
v-if="exp.in_use === false"
|
||||
v-if="exp.value === null"
|
||||
:selected="exp.default"
|
||||
>
|
||||
{{ t('setting.experiments.unset', 'unset') }}
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
|
||||
|
@ -119,8 +119,8 @@ export default {
|
|||
result: null
|
||||
});
|
||||
|
||||
this.queryMap[name].variables = deep_copy(query.observableQuery?.variables);
|
||||
this.queryMap[name].result = deep_copy(query.observableQuery?.lastResult?.data ?? null);
|
||||
this.queryMap[name].variables = deep_copy(query.observableQuery?.last?.variables ?? query.observableQuery?.variables);
|
||||
this.queryMap[name].result = deep_copy(query.observableQuery?.lastResult?.data ?? query.observableQuery?.last?.result?.data ?? null);
|
||||
}
|
||||
|
||||
if ( ! this.current )
|
||||
|
|
|
@ -165,7 +165,7 @@
|
|||
<figure class="ffz-i-discord tw-font-size-3" />
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
<!--a
|
||||
:data-title="t('home.twitter', 'Twitter')"
|
||||
class="tw-flex-grow-1 tw-button ffz-tooltip ffz--twitter-button tw-mg-r-1"
|
||||
href="https://twitter.com/frankerfacez"
|
||||
|
@ -175,7 +175,7 @@
|
|||
<span class="tw-button__icon tw-pd-05">
|
||||
<figure class="ffz-i-twitter tw-font-size-3" />
|
||||
</span>
|
||||
</a>
|
||||
</a-->
|
||||
<a
|
||||
:data-title="t('home.github', 'GitHub')"
|
||||
class="tw-flex-grow-1 tw-button ffz-tooltip ffz--github-button"
|
||||
|
@ -189,7 +189,12 @@
|
|||
</a>
|
||||
</div>
|
||||
|
||||
<template v-if="not_extension">
|
||||
<rich-feed
|
||||
url="https://bsky-feed.special.frankerfacez.com/user::frankerfacez.com"
|
||||
:context="context"
|
||||
/>
|
||||
|
||||
<!--template v-if="not_extension">
|
||||
<a
|
||||
:data-theme="theme"
|
||||
class="twitter-timeline"
|
||||
|
@ -198,7 +203,7 @@
|
|||
>
|
||||
{{ t('home.tweets', 'Tweets by FrankerFaceZ') }}
|
||||
</a>
|
||||
</template>
|
||||
</template-->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -221,7 +226,7 @@ export default {
|
|||
addons: null,
|
||||
new_addons: null,
|
||||
unseen: this.item.getUnseen(),
|
||||
not_extension: ! EXTENSION
|
||||
//not_extension: ! EXTENSION
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -243,7 +248,7 @@ export default {
|
|||
ffz.off('addons:data-loaded', this.updateAddons, this);
|
||||
},
|
||||
|
||||
mounted() {
|
||||
/*mounted() {
|
||||
let el;
|
||||
if ( this.not_extension )
|
||||
document.head.appendChild(el = e('script', {
|
||||
|
@ -253,7 +258,7 @@ export default {
|
|||
src: 'https://platform.twitter.com/widgets.js',
|
||||
onLoad: () => el.remove()
|
||||
}));
|
||||
},
|
||||
},*/
|
||||
|
||||
methods: {
|
||||
updateUnseen() {
|
||||
|
|
|
@ -2,10 +2,47 @@
|
|||
<div class="ffz--menu-page">
|
||||
<header class="tw-mg-b-1">
|
||||
<span v-for="i in breadcrumbs" :key="i.full_key">
|
||||
<a v-if="i !== item" href="#" @click="$emit('change-item', i, false)">{{ t(i.i18n_key, i.title) }}</a>
|
||||
<a v-if="i !== item" href="#" @click.prevent="$emit('change-item', i, false)">{{ t(i.i18n_key, i.title) }}</a>
|
||||
<strong v-if="i === item">{{ t(i.i18n_key, i.title) }}</strong>
|
||||
<template v-if="i !== item">» </template>
|
||||
</span>
|
||||
<span v-if="item.header_links" class="ffz--menu-page__header-links">
|
||||
<span class="tw-mg-x-05">•</span>
|
||||
<template v-for="i in item.header_links">
|
||||
<a
|
||||
v-if="i.href && i.href.startsWith('~')"
|
||||
class="tw-mg-r-05"
|
||||
href="#"
|
||||
@click.prevent="$emit('navigate', i.href.slice(1))"
|
||||
>{{
|
||||
t(i.i18n_key, i.title)
|
||||
}}</a>
|
||||
<react-link
|
||||
v-else-if="i.href"
|
||||
class="tw-mg-r-05"
|
||||
:href="i.href"
|
||||
:state="i.state"
|
||||
>{{
|
||||
t(i.i18n_key, i.title)
|
||||
}}</react-link>
|
||||
<a
|
||||
v-else-if="i.navigate"
|
||||
class="tw-mg-r-05"
|
||||
href="#"
|
||||
@click.prevent="navigate(...i.navigate)"
|
||||
>{{
|
||||
t(i.i18n_key, i.title)
|
||||
}}</a>
|
||||
<a
|
||||
v-else-if="i.target"
|
||||
class="tw-mg-r-05"
|
||||
href="#"
|
||||
@click.prevent="$emit('change-item', i.target, false)"
|
||||
>{{
|
||||
t(i.i18n_key, i.title)
|
||||
}}</a>
|
||||
</template>
|
||||
</span>
|
||||
</header>
|
||||
<section v-if="context.currentProfile.ephemeral && item.profile_warning !== false" class="tw-border-t tw-pd-t-1 tw-pd-b-2">
|
||||
<div class="tw-c-background-accent tw-c-text-overlay tw-pd-1">
|
||||
|
|
89
src/modules/main_menu/components/rich-feed.vue
Normal file
89
src/modules/main_menu/components/rich-feed.vue
Normal file
|
@ -0,0 +1,89 @@
|
|||
<template>
|
||||
<div v-if="feed">
|
||||
<chat-rich
|
||||
v-for="(entry, idx) in feed"
|
||||
:key="idx"
|
||||
:data="entry"
|
||||
class="tw-mg-b-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import { maybe_call } from 'utilities/object';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
'chat-rich': async () => {
|
||||
const stuff = await import(/* webpackChunkName: "chat" */ 'src/modules/chat/components');
|
||||
return stuff.default('./chat-rich.vue').default;
|
||||
}
|
||||
},
|
||||
|
||||
props: ['context', 'url'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
error: null,
|
||||
feed: null
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.loadFromURL();
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadFromURL() {
|
||||
if ( this.loading )
|
||||
return;
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.feed = null;
|
||||
|
||||
const chat = this.context.getFFZ().resolve('chat'),
|
||||
url = this.url;
|
||||
|
||||
if ( ! url ) {
|
||||
this.loading = false;
|
||||
this.error = null;
|
||||
this.feed = [];
|
||||
return;
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await chat.get_link_info(url, false, false);
|
||||
} catch(err) {
|
||||
this.loading = false;
|
||||
this.error = err;
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! data?.v ) {
|
||||
this.error = 'Invalid response.';
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! data.feed )
|
||||
data = {feed: [data]};
|
||||
|
||||
this.feed = data.feed.map(entry => {
|
||||
entry.allow_media = true;
|
||||
entry.allow_unsafe = false;
|
||||
|
||||
return {
|
||||
getData: () => entry
|
||||
}
|
||||
});
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
|
@ -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,230 @@
|
|||
'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;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'utilities/types' {
|
||||
interface ModuleMap {
|
||||
metadata: Metadata
|
||||
}
|
||||
interface SettingsTypeMap {
|
||||
'metadata.clip-download': boolean;
|
||||
'metadata.clip-download.force': boolean;
|
||||
'metadata.player-stats': boolean;
|
||||
'metadata.uptime': number;
|
||||
'metadata.stream-delay-warning': number;
|
||||
'metadata.viewers': boolean;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 +312,7 @@ export default class Metadata extends Module {
|
|||
});
|
||||
|
||||
|
||||
this.definitions.viewers = {
|
||||
this.define('viewers', {
|
||||
|
||||
refresh() { return this.settings.get('metadata.viewers') },
|
||||
|
||||
|
@ -131,10 +338,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 +350,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 +372,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 +398,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 +415,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 +434,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 +455,9 @@ export default class Metadata extends Module {
|
|||
</div>
|
||||
</div>);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.definitions['clip-download'] = {
|
||||
this.define('clip-download', {
|
||||
button: true,
|
||||
inherit: true,
|
||||
|
||||
|
@ -259,7 +465,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 +478,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) {
|
||||
|
@ -288,18 +496,25 @@ export default class Metadata extends Module {
|
|||
icon: 'ffz-i-download',
|
||||
|
||||
click(src) {
|
||||
const title = this.settings.get('context.title');
|
||||
const title = this.settings.get('context.title') || 'Untitled';
|
||||
const name = title.replace(/[\\/:"*?<>|]+/, '_') + '.mp4';
|
||||
|
||||
const link = createElement('a', {target: '_blank', download: name, href: src, style: {display: 'none'}});
|
||||
const link = createElement('a', {
|
||||
target: '_blank',
|
||||
download: name,
|
||||
href: src,
|
||||
style: {
|
||||
display: 'none'
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.definitions['player-stats'] = {
|
||||
this.define('player-stats', {
|
||||
button: true,
|
||||
inherit: true,
|
||||
modview: true,
|
||||
|
@ -309,9 +524,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 +589,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 +603,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 +616,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 +638,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 +663,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 +674,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 +687,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 +772,32 @@ export default class Metadata extends Module {
|
|||
tampered
|
||||
];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
getAddonProxy(addon_id, addon, module) {
|
||||
/** @internal */
|
||||
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;
|
||||
|
||||
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 +809,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 +848,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 +965,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 +985,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 +1000,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 +1009,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 +1022,7 @@ export default class Metadata extends Module {
|
|||
<figure class="ffz-i-down-dir" />
|
||||
</span>
|
||||
</div>
|
||||
</button>)}
|
||||
</button>) as HTMLButtonElement}
|
||||
</div>);
|
||||
|
||||
} else
|
||||
|
@ -769,41 +1030,46 @@ 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) {
|
||||
if ( err instanceof Error )
|
||||
this.log.capture(err, {
|
||||
tags: {
|
||||
metadata: key
|
||||
|
@ -813,6 +1079,10 @@ export default class Metadata extends Module {
|
|||
}
|
||||
}
|
||||
|
||||
// el is not going to be null
|
||||
// TypeScript is on drugs
|
||||
// whatever though
|
||||
if ( el ) {
|
||||
if ( el._ffz_outside )
|
||||
el._ffz_outside.destroy();
|
||||
|
||||
|
@ -823,10 +1093,11 @@ export default class Metadata extends Module {
|
|||
}
|
||||
|
||||
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,9 +1121,10 @@ 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(() => {
|
||||
if ( el && tip.outer )
|
||||
el._ffz_outside = new ClickOutside(tip.outer, destroy);
|
||||
}),
|
||||
onHide: destroy
|
||||
|
@ -871,23 +1143,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,17 +1172,19 @@ 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;
|
||||
if ( el.tip?.element ) {
|
||||
el.tip.element.innerHTML = '';
|
||||
setChildren(el.tip.element, tooltip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( typeof def.icon === 'function' ) {
|
||||
const icon = maybe_call(def.icon, this, data);
|
||||
|
@ -928,12 +1202,14 @@ 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) {
|
||||
if ( err instanceof Error )
|
||||
this.log.capture(err, {
|
||||
tags: {
|
||||
metadata: key
|
|
@ -5,16 +5,81 @@
|
|||
// ============================================================================
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'utilities/types' {
|
||||
interface ModuleEventMap {
|
||||
tooltips: TooltipEvents;
|
||||
}
|
||||
interface ModuleMap {
|
||||
tooltips: TooltipProvider;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 +134,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()');
|
||||
|
||||
return Reflect.get(...arguments);
|
||||
warnings = {
|
||||
types: 'Please use tooltips.define()'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return buildAddonProxy(
|
||||
module,
|
||||
this,
|
||||
'tooltips',
|
||||
overrides,
|
||||
warnings
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
@ -140,20 +207,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 +259,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;
|
||||
if ( this.tips ) {
|
||||
this.tips.destroy();
|
||||
this.tips = this._createInstance(tip_element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 +312,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 +324,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';
|
||||
|
|
|
@ -4,19 +4,67 @@
|
|||
// PubSub Client
|
||||
// ============================================================================
|
||||
|
||||
import Module from 'utilities/module';
|
||||
import Module, { GenericModule } from 'utilities/module';
|
||||
import { PUBSUB_CLUSTERS } from 'utilities/constants';
|
||||
import type ExperimentManager from '../experiments';
|
||||
import type SettingsManager from '../settings';
|
||||
import type PubSubClient from 'utilities/pubsub';
|
||||
import type { PubSubCommands } from 'utilities/types';
|
||||
import type { SettingUi_Select_Entry } from '../settings/types';
|
||||
|
||||
declare module 'utilities/types' {
|
||||
interface ModuleMap {
|
||||
pubsub: PubSub;
|
||||
}
|
||||
interface ModuleEventMap {
|
||||
pubsub: PubSubEvents;
|
||||
}
|
||||
interface SettingsTypeMap {
|
||||
'pubsub.use-cluster': keyof typeof PUBSUB_CLUSTERS | null;
|
||||
}
|
||||
interface ExperimentTypeMap {
|
||||
cf_pubsub: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default class PubSub extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
type PubSubCommandData<K extends keyof PubSubCommands> = {
|
||||
topic: string;
|
||||
cmd: K;
|
||||
data: PubSubCommands[K];
|
||||
};
|
||||
|
||||
type PubSubCommandKey = `:command:${keyof PubSubCommands}`;
|
||||
|
||||
type PubSubEvents = {
|
||||
':sub-change': [];
|
||||
':message': [topic: string, data: unknown];
|
||||
} & {
|
||||
[K in keyof PubSubCommands as `:command:${K}`]: [data: PubSubCommands[K], meta: PubSubCommandData<K>];
|
||||
}
|
||||
|
||||
|
||||
export default class PubSub extends Module<'pubsub', PubSubEvents> {
|
||||
|
||||
// Dependencies
|
||||
experiments: ExperimentManager = null as any;
|
||||
settings: SettingsManager = null as any;
|
||||
|
||||
// State
|
||||
_topics: Map<string, Set<unknown>>;
|
||||
_client: PubSubClient | null;
|
||||
|
||||
_mqtt?: typeof PubSubClient | null;
|
||||
_mqtt_loader?: Promise<typeof PubSubClient> | null;
|
||||
|
||||
constructor(name?: string, parent?: GenericModule) {
|
||||
super(name, parent);
|
||||
|
||||
this.inject('settings');
|
||||
this.inject('experiments');
|
||||
|
||||
this.settings.add('pubsub.use-cluster', {
|
||||
default: ctx => {
|
||||
default: () => {
|
||||
if ( this.experiments.getAssignment('cf_pubsub') )
|
||||
return 'Staging';
|
||||
return null;
|
||||
|
@ -33,7 +81,7 @@ export default class PubSub extends Module {
|
|||
data: [{
|
||||
value: null,
|
||||
title: 'Disabled'
|
||||
}].concat(Object.keys(PUBSUB_CLUSTERS).map(x => ({
|
||||
} as SettingUi_Select_Entry<string | null>].concat(Object.keys(PUBSUB_CLUSTERS).map(x => ({
|
||||
value: x,
|
||||
title: x
|
||||
})))
|
||||
|
@ -161,18 +209,18 @@ export default class PubSub extends Module {
|
|||
|
||||
client.on('message', event => {
|
||||
const topic = event.topic,
|
||||
data = event.data;
|
||||
data = event.data as PubSubCommandData<any>;
|
||||
|
||||
if ( ! data?.cmd ) {
|
||||
this.log.debug(`Received message on topic "${topic}":`, data);
|
||||
this.emit(`pubsub:message`, topic, data);
|
||||
this.emit(`:message`, topic, data);
|
||||
return;
|
||||
}
|
||||
|
||||
data.topic = topic;
|
||||
|
||||
this.log.debug(`Received command on topic "${topic}" for command "${data.cmd}":`, data.data);
|
||||
this.emit(`pubsub:command:${data.cmd}`, data.data, data);
|
||||
this.emit(`:command:${data.cmd}` as PubSubCommandKey, data.data, data);
|
||||
});
|
||||
|
||||
// Subscribe to topics.
|
||||
|
@ -196,20 +244,23 @@ export default class PubSub extends Module {
|
|||
// Topics
|
||||
// ========================================================================
|
||||
|
||||
subscribe(referrer, ...topics) {
|
||||
const t = this._topics;
|
||||
subscribe(referrer: unknown, ...topics: string[]) {
|
||||
const topic_map = this._topics;
|
||||
let changed = false;
|
||||
for(const topic of topics) {
|
||||
if ( ! t.has(topic) ) {
|
||||
let refs = topic_map.get(topic);
|
||||
if ( refs )
|
||||
refs.add(referrer);
|
||||
else {
|
||||
if ( this._client )
|
||||
this._client.subscribe(topic);
|
||||
|
||||
t.set(topic, new Set);
|
||||
refs = new Set;
|
||||
refs.add(referrer);
|
||||
|
||||
topic_map.set(topic, refs);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const tp = t.get(topic);
|
||||
tp.add(referrer);
|
||||
}
|
||||
|
||||
if ( changed )
|
||||
|
@ -217,19 +268,19 @@ export default class PubSub extends Module {
|
|||
}
|
||||
|
||||
|
||||
unsubscribe(referrer, ...topics) {
|
||||
const t = this._topics;
|
||||
unsubscribe(referrer: unknown, ...topics: string[]) {
|
||||
const topic_map = this._topics;
|
||||
let changed = false;
|
||||
for(const topic of topics) {
|
||||
if ( ! t.has(topic) )
|
||||
const refs = topic_map.get(topic);
|
||||
if ( ! refs )
|
||||
continue;
|
||||
|
||||
const tp = t.get(topic);
|
||||
tp.delete(referrer);
|
||||
refs.delete(referrer);
|
||||
|
||||
if ( ! tp.size ) {
|
||||
if ( ! refs.size ) {
|
||||
changed = true;
|
||||
t.delete(topic);
|
||||
topic_map.delete(topic);
|
||||
if ( this._client )
|
||||
this._client.unsubscribe(topic);
|
||||
}
|
|
@ -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,11 @@
|
|||
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 DEFINITIONS from './typehandlers';
|
||||
import type { AllSettingsKeys, ContextData, SettingMetadata, SettingType, SettingDefinition, SettingsKeys } from './types';
|
||||
import type SettingsManager from '.';
|
||||
import type SettingsProfile from './profile';
|
||||
import type { SettingsTypeMap } from 'utilities/types';
|
||||
|
||||
/**
|
||||
* Perform a basic check of a setting's requirements to see if they changed.
|
||||
|
@ -16,7 +20,11 @@ import * as DEFINITIONS from './types';
|
|||
* @param {Map} old_cache
|
||||
* @returns Whether or not they changed.
|
||||
*/
|
||||
function compare_requirements(definition, cache, old_cache) {
|
||||
function compare_requirements(
|
||||
definition: SettingDefinition<any>,
|
||||
cache: Map<string, unknown>,
|
||||
old_cache: Map<string, unknown>
|
||||
) {
|
||||
if ( ! definition || ! Array.isArray(definition.requires) )
|
||||
return false;
|
||||
|
||||
|
@ -47,14 +55,44 @@ function compare_requirements(definition, cache, old_cache) {
|
|||
}
|
||||
|
||||
|
||||
export type SettingsContextEvents = {
|
||||
[K in keyof SettingsTypeMap as `changed:${K}`]: [value: SettingsTypeMap[K], old_value: SettingsTypeMap[K]];
|
||||
} & {
|
||||
[K in keyof SettingsTypeMap as `uses_changed:${K}`]: [uses: number[] | null, old_uses: number[] | null];
|
||||
} & {
|
||||
changed: [key: SettingsKeys, value: any, old_value: any];
|
||||
uses_changed: [key: SettingsKeys, uses: number[] | null, old_uses: number[] | null];
|
||||
|
||||
context_changed: [];
|
||||
profiles_changed: [];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The SettingsContext class provides a context through which to read
|
||||
* settings values in addition to emitting events when settings values
|
||||
* are changed.
|
||||
* @extends EventEmitter
|
||||
*/
|
||||
export default class SettingsContext extends EventEmitter {
|
||||
constructor(manager, context) {
|
||||
export default class SettingsContext extends EventEmitter<SettingsContextEvents> {
|
||||
|
||||
parent: SettingsContext | null;
|
||||
manager: SettingsManager;
|
||||
|
||||
order: number[];
|
||||
|
||||
/** @internal */
|
||||
_context: ContextData;
|
||||
private __context: ContextData = null as any;
|
||||
|
||||
private __profiles: SettingsProfile[];
|
||||
|
||||
private __cache: Map<SettingsKeys, unknown>;
|
||||
private __meta: Map<SettingsKeys, SettingMetadata>;
|
||||
|
||||
private __ls_listening: boolean;
|
||||
private __ls_wanted: Map<string, Set<string>>;
|
||||
|
||||
constructor(manager: SettingsContext | SettingsManager, context?: ContextData) {
|
||||
super();
|
||||
|
||||
if ( manager instanceof SettingsContext ) {
|
||||
|
@ -68,7 +106,7 @@ export default class SettingsContext extends EventEmitter {
|
|||
this.manager = manager;
|
||||
}
|
||||
|
||||
this.manager.__contexts.push(this);
|
||||
(this.manager as any).__contexts.push(this);
|
||||
this._context = context || {};
|
||||
|
||||
/*this._context_objects = new Set;
|
||||
|
@ -93,7 +131,7 @@ export default class SettingsContext extends EventEmitter {
|
|||
for(const profile of this.__profiles)
|
||||
profile.off('changed', this._onChanged, this);
|
||||
|
||||
const contexts = this.manager.__contexts,
|
||||
const contexts = (this.manager as any).__contexts,
|
||||
idx = contexts.indexOf(this);
|
||||
|
||||
if ( idx !== -1 )
|
||||
|
@ -106,26 +144,26 @@ export default class SettingsContext extends EventEmitter {
|
|||
// ========================================================================
|
||||
|
||||
_watchLS() {
|
||||
if ( this.__ls_watched )
|
||||
if ( this.__ls_listening )
|
||||
return;
|
||||
|
||||
this.__ls_watched = true;
|
||||
this.__ls_listening = true;
|
||||
this.manager.on(':ls-update', this._onLSUpdate, this);
|
||||
}
|
||||
|
||||
_unwatchLS() {
|
||||
if ( ! this.__ls_watched )
|
||||
if ( ! this.__ls_listening )
|
||||
return;
|
||||
|
||||
this.__ls_watched = false;
|
||||
this.__ls_listening = false;
|
||||
this.manager.off(':ls-update', this._onLSUpdate, this);
|
||||
}
|
||||
|
||||
_onLSUpdate(key) {
|
||||
_onLSUpdate(key: string) {
|
||||
const keys = this.__ls_wanted.get(`ls.${key}`);
|
||||
if ( keys )
|
||||
for(const key of keys)
|
||||
this._update(key, key, []);
|
||||
this._update(key as SettingsKeys, key as SettingsKeys, []);
|
||||
}
|
||||
|
||||
|
||||
|
@ -147,8 +185,8 @@ export default class SettingsContext extends EventEmitter {
|
|||
|
||||
|
||||
selectProfiles() {
|
||||
const new_profiles = [],
|
||||
order = this.order = [];
|
||||
const new_profiles: SettingsProfile[] = [],
|
||||
order: number[] = this.order = [];
|
||||
|
||||
if ( ! this.manager.disable_profiles ) {
|
||||
for(const profile of this.manager.__profiles)
|
||||
|
@ -171,13 +209,13 @@ export default class SettingsContext extends EventEmitter {
|
|||
|
||||
for(const profile of new_profiles)
|
||||
if ( ! this.__profiles.includes(profile) ) {
|
||||
profile.on('changed', this._onChanged, this);
|
||||
profile.on('changed', this._onChanged as any, this);
|
||||
changed_ids.add(profile.id);
|
||||
}
|
||||
|
||||
this.__profiles = new_profiles;
|
||||
this.emit('profiles_changed');
|
||||
this.rebuildCache(changed_ids);
|
||||
this.rebuildCache(/*changed_ids*/);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -203,7 +241,7 @@ export default class SettingsContext extends EventEmitter {
|
|||
const definition = this.manager.definitions.get(key);
|
||||
let changed = false;
|
||||
|
||||
if ( definition && definition.equals ) {
|
||||
if ( ! Array.isArray(definition) && definition?.equals ) {
|
||||
if ( definition.equals === 'requirements' )
|
||||
changed = compare_requirements(definition, this.__cache, old_cache);
|
||||
else if ( typeof definition.equals === 'function' )
|
||||
|
@ -224,7 +262,7 @@ export default class SettingsContext extends EventEmitter {
|
|||
|
||||
if ( changed ) {
|
||||
this.emit('changed', key, new_value, old_value);
|
||||
this.emit(`changed:${key}`, new_value, old_value);
|
||||
this.emit(`changed:${key}`, new_value, old_value as any);
|
||||
}
|
||||
|
||||
if ( ! array_equals(new_uses, old_uses) ) {
|
||||
|
@ -239,12 +277,12 @@ export default class SettingsContext extends EventEmitter {
|
|||
// Context Control
|
||||
// ========================================================================
|
||||
|
||||
context(context) {
|
||||
context(context: ContextData) {
|
||||
return new SettingsContext(this, context);
|
||||
}
|
||||
|
||||
|
||||
updateContext(context) {
|
||||
updateContext(context: ContextData) {
|
||||
let changed = false;
|
||||
|
||||
for(const key in context)
|
||||
|
@ -258,7 +296,7 @@ export default class SettingsContext extends EventEmitter {
|
|||
// This can catch a recursive structure error.
|
||||
}
|
||||
|
||||
this._context[key] = val;
|
||||
this._context[key] = val as any;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
|
@ -325,8 +363,8 @@ export default class SettingsContext extends EventEmitter {
|
|||
}*/
|
||||
|
||||
|
||||
setContext(context) {
|
||||
this._context_objects = new Set;
|
||||
setContext(context: ContextData) {
|
||||
//this._context_objects = new Set;
|
||||
this._context = {};
|
||||
this.updateContext(context);
|
||||
}
|
||||
|
@ -336,11 +374,14 @@ export default class SettingsContext extends EventEmitter {
|
|||
// Data Access
|
||||
// ========================================================================
|
||||
|
||||
_onChanged(key) {
|
||||
_onChanged(key: SettingsKeys) {
|
||||
this._update(key, key, []);
|
||||
}
|
||||
|
||||
_update(key, initial, visited) {
|
||||
_update<
|
||||
K extends SettingsKeys,
|
||||
TValue = SettingType<K>
|
||||
>(key: K, initial: SettingsKeys, visited: SettingsKeys[]) {
|
||||
if ( ! this.__cache.has(key) )
|
||||
return;
|
||||
|
||||
|
@ -349,7 +390,7 @@ export default class SettingsContext extends EventEmitter {
|
|||
|
||||
visited.push(key);
|
||||
|
||||
const old_value = this.__cache.get(key),
|
||||
const old_value = this.__cache.get(key) as TValue | undefined,
|
||||
old_meta = this.__meta.get(key),
|
||||
new_value = this._get(key, key, []),
|
||||
new_meta = this.__meta.get(key),
|
||||
|
@ -359,38 +400,41 @@ export default class SettingsContext extends EventEmitter {
|
|||
|
||||
if ( ! array_equals(new_uses, old_uses) ) {
|
||||
this.emit('uses_changed', key, new_uses, old_uses);
|
||||
this.emit(`uses_changed:${key}`, new_uses, old_uses);
|
||||
this.emit(`uses_changed:${key}` as any, new_uses, old_uses);
|
||||
}
|
||||
|
||||
if ( old_value === new_value )
|
||||
return;
|
||||
|
||||
this.emit('changed', key, new_value, old_value);
|
||||
this.emit(`changed:${key}`, new_value, old_value);
|
||||
this.emit(`changed:${key}` as any, new_value, old_value);
|
||||
|
||||
const definition = this.manager.definitions.get(key);
|
||||
if ( definition && definition.required_by )
|
||||
if ( ! Array.isArray(definition) && definition?.required_by )
|
||||
for(const req_key of definition.required_by)
|
||||
if ( ! req_key.startsWith('context.') && ! req_key.startsWith('ls.') )
|
||||
this._update(req_key, initial, Array.from(visited));
|
||||
this._update(req_key as SettingsKeys, initial, Array.from(visited));
|
||||
}
|
||||
|
||||
|
||||
_get(key, initial, visited) {
|
||||
_get<
|
||||
K extends SettingsKeys,
|
||||
TValue = SettingType<K>
|
||||
>(key: K, initial: SettingsKeys, visited: SettingsKeys[]): TValue {
|
||||
if ( visited.includes(key) )
|
||||
throw new Error(`cyclic dependency when resolving setting "${initial}"`);
|
||||
|
||||
visited.push(key);
|
||||
|
||||
const definition = this.manager.definitions.get(key),
|
||||
raw_type = definition && definition.type,
|
||||
const definition = this.manager.definitions.get(key);
|
||||
const raw_type = ! Array.isArray(definition) && definition?.type,
|
||||
type = raw_type ? DEFINITIONS[raw_type] : DEFINITIONS.basic;
|
||||
|
||||
if ( ! type )
|
||||
throw new Error(`non-existent setting type "${raw_type}"`);
|
||||
|
||||
const raw_value = this._getRaw(key, type),
|
||||
meta = {
|
||||
meta: SettingMetadata = {
|
||||
uses: raw_value ? raw_value[1] : null
|
||||
};
|
||||
|
||||
|
@ -421,8 +465,8 @@ export default class SettingsContext extends EventEmitter {
|
|||
|
||||
keys.add(key);
|
||||
|
||||
} else if ( ! req_key.startsWith('context.') && ! this.__cache.has(req_key) )
|
||||
this._get(req_key, initial, Array.from(visited));
|
||||
} else if ( ! req_key.startsWith('context.') && ! this.__cache.has(req_key as SettingsKeys) )
|
||||
this._get(req_key as SettingsKeys, initial, Array.from(visited));
|
||||
|
||||
if ( definition.process )
|
||||
value = definition.process(this, value, meta);
|
||||
|
@ -440,70 +484,84 @@ export default class SettingsContext extends EventEmitter {
|
|||
}
|
||||
|
||||
|
||||
hasProfile(profile) {
|
||||
if ( typeof profile === 'number' )
|
||||
hasProfile(profile: number | SettingsProfile) {
|
||||
if ( typeof profile === 'number' ) {
|
||||
for(const prof of this.__profiles)
|
||||
if ( prof.id === profile )
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.__profiles.includes(profile);
|
||||
}
|
||||
|
||||
|
||||
_getRaw(key, type) {
|
||||
_getRaw(key: SettingsKeys, type) {
|
||||
if ( ! type )
|
||||
throw new Error(`non-existent type for ${key}`)
|
||||
|
||||
return type.get(key, this.profiles(), this.manager.definitions.get(key), this.manager.log, this);
|
||||
return type.get(
|
||||
key,
|
||||
this.profiles(),
|
||||
this.manager.definitions.get(key),
|
||||
this.manager.log,
|
||||
this
|
||||
);
|
||||
}
|
||||
/* for(const profile of this.__profiles)
|
||||
if ( profile.has(key) )
|
||||
return [profile.get(key), profile]
|
||||
}*/
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Data Access
|
||||
// ========================================================================
|
||||
|
||||
update(key) {
|
||||
update(key: SettingsKeys) {
|
||||
this._update(key, key, []);
|
||||
}
|
||||
|
||||
get(key) {
|
||||
get<
|
||||
K extends AllSettingsKeys,
|
||||
TValue = SettingType<K>
|
||||
>(key: K): TValue {
|
||||
if ( key.startsWith('ls.') )
|
||||
return this.manager.getLS(key.slice(3));
|
||||
return this.manager.getLS(key.slice(3)) as TValue;
|
||||
|
||||
if ( key.startsWith('context.') )
|
||||
//return this.__context[key.slice(8)];
|
||||
return getter(key.slice(8), this.__context);
|
||||
|
||||
if ( this.__cache.has(key) )
|
||||
return this.__cache.get(key);
|
||||
if ( this.__cache.has(key as SettingsKeys) )
|
||||
return this.__cache.get(key as SettingsKeys) as TValue;
|
||||
|
||||
return this._get(key, key, []);
|
||||
return this._get(key as SettingsKeys, key as SettingsKeys, []);
|
||||
}
|
||||
|
||||
getChanges(key, fn, ctx) {
|
||||
getChanges<
|
||||
K extends SettingsKeys,
|
||||
TValue = SettingsTypeMap[K]
|
||||
>(key: K, fn: (value: TValue, old_value: TValue | undefined) => void, ctx?: any) {
|
||||
this.onChange(key, fn, ctx);
|
||||
fn.call(ctx, this.get(key));
|
||||
fn.call(ctx, this.get(key), undefined as TValue);
|
||||
}
|
||||
|
||||
onChange(key, fn, ctx) {
|
||||
this.on(`changed:${key}`, fn, ctx);
|
||||
onChange<
|
||||
K extends SettingsKeys,
|
||||
TValue = SettingsTypeMap[K]
|
||||
>(key: K, fn: (value: TValue, old_value: TValue) => void, ctx?: any) {
|
||||
this.on(`changed:${key}`, fn as any, ctx);
|
||||
}
|
||||
|
||||
|
||||
uses(key) {
|
||||
uses(key: AllSettingsKeys) {
|
||||
if ( key.startsWith('ls.') )
|
||||
return null;
|
||||
|
||||
if ( key.startsWith('context.') )
|
||||
return null;
|
||||
|
||||
if ( ! this.__meta.has(key) )
|
||||
this._get(key, key, []);
|
||||
if ( ! this.__meta.has(key as SettingsKeys) )
|
||||
this._get(key as SettingsKeys, key as SettingsKeys, []);
|
||||
|
||||
return this.__meta.get(key).uses;
|
||||
return this.__meta.get(key as SettingsKeys)?.uses ?? null;
|
||||
}
|
||||
}
|
|
@ -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,19 +277,30 @@ 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 )
|
||||
return NeverMatch;
|
||||
|
||||
if ( router ) {
|
||||
const route = router.getRoute(name);
|
||||
if ( ! route || ! route.parts )
|
||||
return () => false;
|
||||
return NeverMatch;
|
||||
|
||||
let i = 1;
|
||||
for(const part of route.parts) {
|
||||
|
@ -285,9 +312,6 @@ export const Page = {
|
|||
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;
|
||||
};
|
||||
},
|
||||
|
File diff suppressed because it is too large
Load diff
|
@ -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 { SettingDefinition, SettingProcessor, SettingUiDefinition } from "./types";
|
||||
|
||||
const BAD = Symbol('BAD');
|
||||
type BadType = typeof BAD;
|
||||
|
||||
function do_number(
|
||||
input: number | BadType,
|
||||
default_value: number,
|
||||
definition: SettingUiDefinition<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: SettingProcessor<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: SettingProcessor<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;
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
if ( this.ephemeral )
|
||||
return this.provider.get<T>(this.prefix + key, default_value as T);
|
||||
}
|
||||
|
||||
set(key: string, value: unknown) {
|
||||
if ( this.ephemeral ) {
|
||||
if ( this._storage )
|
||||
this._storage.set(key, value);
|
||||
else
|
||||
} else
|
||||
this.provider.set(this.prefix + key, value);
|
||||
this.emit('changed', key, value);
|
||||
|
||||
this.emit('changed', key, value, false);
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
if ( this.ephemeral )
|
||||
delete(key: string) {
|
||||
if ( this.ephemeral ) {
|
||||
if ( this._storage )
|
||||
this._storage.delete(key);
|
||||
else
|
||||
} 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 ) {
|
||||
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 ) {
|
||||
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
|
@ -1,32 +1,38 @@
|
|||
'use strict';
|
||||
|
||||
import type Logger from "utilities/logging";
|
||||
import type SettingsProfile from "./profile";
|
||||
import type { SettingDefinition, SettingsTypeHandler } from "./types";
|
||||
import type SettingsContext from "./context";
|
||||
|
||||
// ============================================================================
|
||||
// Settings Types
|
||||
// ============================================================================
|
||||
|
||||
const DEFAULT = Symbol('default');
|
||||
|
||||
export const basic = {
|
||||
get(key, profiles) {
|
||||
|
||||
export const basic: SettingsTypeHandler = {
|
||||
get<T>(key: string, profiles: SettingsProfile[]) {
|
||||
for(const profile of profiles)
|
||||
if ( profile.has(key) )
|
||||
return [
|
||||
profile.get(key),
|
||||
profile.get(key) as T,
|
||||
[profile.id]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const object_merge = {
|
||||
get(key, profiles, log) {
|
||||
const values = [],
|
||||
sources = [];
|
||||
export const object_merge: SettingsTypeHandler = {
|
||||
get<T>(key: string, profiles: SettingsProfile[], definition: SettingDefinition<any>, log: Logger) {
|
||||
const values: T[] = [],
|
||||
sources: number[] = [];
|
||||
|
||||
for(const profile of profiles)
|
||||
if ( profile.has(key) ) {
|
||||
const val = profile.get(key);
|
||||
if ( typeof val !== 'object' ) {
|
||||
const val = profile.get<T>(key);
|
||||
if ( ! val || typeof val !== 'object' ) {
|
||||
log.warn(`Profile #${profile.id} has an invalid value for "${key}" of type ${typeof val}. Skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
@ -44,14 +50,16 @@ export const object_merge = {
|
|||
}
|
||||
|
||||
|
||||
export const basic_array_merge = {
|
||||
get(key, profiles, log) {
|
||||
const values = [],
|
||||
sources = [];
|
||||
type UnwrapArray<T> = T extends Array<infer U> ? U : T;
|
||||
|
||||
export const basic_array_merge: SettingsTypeHandler = {
|
||||
get<T>(key: string, profiles: SettingsProfile[], definition: SettingDefinition<any>, log: Logger) {
|
||||
const values: UnwrapArray<T>[] = [],
|
||||
sources: number[] = [];
|
||||
|
||||
for(const profile of profiles)
|
||||
if ( profile.has(key) ) {
|
||||
const val = profile.get(key);
|
||||
const val = profile.get<UnwrapArray<T>>(key);
|
||||
if ( ! Array.isArray(val) ) {
|
||||
log.warn(`Profile #${profile.id} has an invalid value for "${key}"`);
|
||||
continue;
|
||||
|
@ -71,7 +79,7 @@ export const basic_array_merge = {
|
|||
}
|
||||
|
||||
|
||||
export const array_merge = {
|
||||
export const array_merge: SettingsTypeHandler = {
|
||||
default(val) {
|
||||
const values = [];
|
||||
for(const v of val)
|
||||
|
@ -81,13 +89,20 @@ export const array_merge = {
|
|||
return values;
|
||||
},
|
||||
|
||||
get(key, profiles, definition, log, ctx) {
|
||||
const values = [],
|
||||
sources = [];
|
||||
let trailing = [];
|
||||
get<T>(
|
||||
key: string,
|
||||
profiles: SettingsProfile[],
|
||||
definition: SettingDefinition<any>,
|
||||
log: Logger,
|
||||
ctx: SettingsContext
|
||||
) {
|
||||
|
||||
const values: UnwrapArray<T>[] = [],
|
||||
sources: number[] = [];
|
||||
let trailing: UnwrapArray<T>[] = [];
|
||||
let had_value = false;
|
||||
|
||||
let profs = profiles;
|
||||
let profs: (SettingsProfile | typeof DEFAULT)[] = profiles;
|
||||
if ( definition.inherit_default )
|
||||
profs = [...profiles, DEFAULT];
|
||||
|
||||
|
@ -109,7 +124,7 @@ export const array_merge = {
|
|||
continue;
|
||||
}
|
||||
|
||||
const trail = [];
|
||||
const trail: UnwrapArray<T>[] = [];
|
||||
|
||||
if ( profile !== DEFAULT )
|
||||
sources.push(profile.id);
|
||||
|
@ -141,3 +156,12 @@ export const array_merge = {
|
|||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default {
|
||||
basic,
|
||||
object_merge,
|
||||
basic_array_merge,
|
||||
array_merge
|
||||
} as Record<string, SettingsTypeHandler>;
|
333
src/settings/types.ts
Normal file
333
src/settings/types.ts
Normal file
|
@ -0,0 +1,333 @@
|
|||
import type SettingsManager from ".";
|
||||
import type { FilterData } from "../utilities/filtering";
|
||||
import type Logger from "../utilities/logging";
|
||||
import type { PathNode } from "../utilities/path-parser";
|
||||
import type { ExtractKey, ExtractSegments, ExtractType, JoinKeyPaths, ObjectKeyPaths, OptionalPromise, OptionallyCallable, PartialPartial, RecursivePartial, SettingsTypeMap } from "../utilities/types";
|
||||
import type SettingsContext from "./context";
|
||||
import type SettingsProfile from "./profile";
|
||||
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 interface ConcreteContextData {
|
||||
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;
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
export type ContextData = RecursivePartial<ConcreteContextData>;
|
||||
|
||||
export interface ConcreteLocalStorageData {
|
||||
test: number;
|
||||
}
|
||||
|
||||
export type LocalStorageData = Partial<ConcreteLocalStorageData>;
|
||||
|
||||
export type SettingsContextKeys = JoinKeyPaths<'context', ObjectKeyPaths<ConcreteContextData>>;
|
||||
export type SettingsLocalStorageKeys = JoinKeyPaths<'ls', ObjectKeyPaths<ConcreteLocalStorageData>> | JoinKeyPaths<'ls.raw', ObjectKeyPaths<ConcreteLocalStorageData>>;
|
||||
export type SettingsKeys = keyof SettingsTypeMap;
|
||||
export type AllSettingsKeys = SettingsKeys | SettingsContextKeys | SettingsLocalStorageKeys;
|
||||
|
||||
export type SettingType<K extends AllSettingsKeys> =
|
||||
K extends `context.${infer Rest}`
|
||||
? ExtractType<ConcreteContextData, ExtractSegments<Rest>> | undefined
|
||||
:
|
||||
K extends `ls.raw.${infer _}`
|
||||
? string | undefined
|
||||
:
|
||||
K extends `ls.${infer Rest}`
|
||||
? Rest extends keyof LocalStorageData
|
||||
? LocalStorageData[Rest]
|
||||
: unknown
|
||||
:
|
||||
K extends keyof SettingsTypeMap
|
||||
? SettingsTypeMap[K]
|
||||
:
|
||||
unknown;
|
||||
|
||||
export type SettingMetadata = {
|
||||
uses: number[];
|
||||
};
|
||||
|
||||
|
||||
// Usable Definitions
|
||||
|
||||
export type OptionalSettingDefinitionKeys = 'type';
|
||||
export type ForbiddenSettingDefinitionKeys = '__source' | 'ui';
|
||||
|
||||
export type SettingDefinition<T> = Omit<
|
||||
PartialPartial<FullSettingDefinition<T>, OptionalSettingDefinitionKeys>,
|
||||
ForbiddenSettingDefinitionKeys
|
||||
> & {
|
||||
ui: SettingUiDefinition<T>;
|
||||
};
|
||||
|
||||
export type OptionalSettingUiDefinitionKeys = 'key' | 'path_tokens' | 'i18n_key';
|
||||
export type ForbiddenSettingUiDefinitionKeys = never;
|
||||
|
||||
export type SettingUiDefinition<T> = PartialPartial<FullSettingUiDefinition<T>, OptionalSettingUiDefinitionKeys>;
|
||||
|
||||
|
||||
// Definitions
|
||||
|
||||
export type FullSettingDefinition<T> = {
|
||||
|
||||
default: ((ctx: SettingsContext) => T) | T,
|
||||
type?: string;
|
||||
|
||||
equals?: 'requirements' | ((new_value: T, old_value: T | undefined, cache: Map<SettingsKeys, unknown>, old_cache: Map<SettingsKeys, unknown>) => boolean);
|
||||
|
||||
process?(ctx: SettingsContext, val: T, meta: SettingMetadata): T;
|
||||
|
||||
// Dependencies
|
||||
required_by?: string[];
|
||||
requires?: string[];
|
||||
|
||||
always_inherit?: boolean;
|
||||
inherit_default?: boolean;
|
||||
|
||||
// Tracking
|
||||
__source?: string | null;
|
||||
|
||||
// UI Stuff
|
||||
ui?: SettingUiDefinition<T>;
|
||||
|
||||
// Reactivity
|
||||
changed?: (value: T) => void;
|
||||
|
||||
};
|
||||
|
||||
|
||||
// UI Definitions
|
||||
|
||||
export type SettingUi_Basic = {
|
||||
key: string;
|
||||
path: string;
|
||||
path_tokens: PathNode[];
|
||||
|
||||
no_filter?: boolean;
|
||||
force_seen?: boolean;
|
||||
|
||||
title: string;
|
||||
i18n_key: string;
|
||||
|
||||
description?: string;
|
||||
desc_i18n_key?: string;
|
||||
|
||||
/**
|
||||
* Optional. If present, this method will be used to retrieve an array of
|
||||
* additional search terms that can be used to search for this setting.
|
||||
*/
|
||||
getExtraTerms?: () => string[];
|
||||
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Each built-in settings component has a type with extra data definitions.
|
||||
// ============================================================================
|
||||
|
||||
// Text Box
|
||||
// ============================================================================
|
||||
|
||||
export type SettingUi_TextBox = SettingUi_Basic & {
|
||||
component: 'setting-text-box';
|
||||
} & (SettingUi_TextBox_Process_Number | SettingUi_TextBox_Process_Other);
|
||||
|
||||
|
||||
// Processing
|
||||
|
||||
export type SettingUi_TextBox_Process_Other = {
|
||||
process?: Exclude<string, 'to_int' | 'to_float'>;
|
||||
}
|
||||
|
||||
export type SettingUi_TextBox_Process_Number = {
|
||||
process: 'to_int' | 'to_float';
|
||||
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
|
||||
|
||||
// Check Box
|
||||
// ============================================================================
|
||||
|
||||
export type SettingUi_CheckBox = SettingUi_Basic & {
|
||||
component: 'setting-check-box';
|
||||
};
|
||||
|
||||
|
||||
// Select Box
|
||||
// ============================================================================
|
||||
|
||||
export type SettingUi_Select<T> = SettingUi_Basic & {
|
||||
component: 'setting-select-box';
|
||||
|
||||
data: OptionallyCallable<[profile: SettingsProfile, current: T], SettingUi_Select_Entry<T>[]>;
|
||||
|
||||
}
|
||||
|
||||
export type SettingUi_Select_Entry<T> = {
|
||||
value: T;
|
||||
title: string;
|
||||
};
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Combined Definitions
|
||||
// ============================================================================
|
||||
|
||||
export type SettingTypeUiDefinition<T> = SettingUi_TextBox | SettingUi_CheckBox | SettingUi_Select<T>;
|
||||
|
||||
|
||||
// We also support other components, if the component doesn't match.
|
||||
export type SettingOtherUiDefinition = SettingUi_Basic & {
|
||||
component: Exclude<string, ExtractKey<SettingTypeUiDefinition<any>, 'component'>>;
|
||||
}
|
||||
|
||||
// The final combined definition.
|
||||
export type FullSettingUiDefinition<T> = SettingTypeUiDefinition<T> | SettingOtherUiDefinition;
|
||||
|
||||
|
||||
// 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>;
|
||||
};
|
||||
|
||||
|
||||
export type ExportedBlobMetadata = {
|
||||
key: string;
|
||||
type?: string;
|
||||
name?: string;
|
||||
modified?: number;
|
||||
mime?: string;
|
||||
};
|
||||
|
||||
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
|
||||
// Type Handlers
|
||||
|
||||
export type SettingsTypeHandler = {
|
||||
|
||||
default?(input: any, definition: SettingDefinition<any>, log: Logger): any;
|
||||
|
||||
get(
|
||||
key: string,
|
||||
profiles: SettingsProfile[],
|
||||
definition: SettingDefinition<any>,
|
||||
log: Logger,
|
||||
ctx: SettingsContext
|
||||
): [unknown, number[]] | null | undefined;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Processors
|
||||
|
||||
export type SettingProcessor<T> = (
|
||||
input: unknown,
|
||||
default_value: T,
|
||||
definition: SettingUiDefinition<T>
|
||||
) => T;
|
||||
|
||||
|
||||
// Validators
|
||||
|
||||
export type SettingValidator<T> = (
|
||||
value: T,
|
||||
definition: SettingUiDefinition<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 { SettingUiDefinition, SettingValidator } from "./types";
|
||||
|
||||
|
||||
function do_number(value: any, definition: SettingUiDefinition<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: SettingValidator<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: SettingValidator<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.`);
|
||||
}
|
||||
|
||||
|
|
|
@ -257,51 +257,6 @@ export default class Channel extends Module {
|
|||
}
|
||||
}
|
||||
|
||||
/*setHost(channel_id, channel_login, target_id, target_login) {
|
||||
const topic = `stream-chat-room-v1.${channel_id}`;
|
||||
|
||||
this.subpump.inject(topic, {
|
||||
type: 'host_target_change',
|
||||
data: {
|
||||
channel_id,
|
||||
channel_login,
|
||||
target_channel_id: target_id || null,
|
||||
target_channel_login: target_login || null,
|
||||
previous_target_channel_id: null,
|
||||
num_viewers: 0
|
||||
}
|
||||
});
|
||||
|
||||
this.subpump.inject(topic, {
|
||||
type: 'host_target_change_v2',
|
||||
data: {
|
||||
channel_id,
|
||||
channel_login,
|
||||
target_channel_id: target_id || null,
|
||||
target_channel_login: target_login || null,
|
||||
previous_target_channel_id: null,
|
||||
num_viewers: 0
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
onPubSub(event) {
|
||||
if ( event.prefix !== 'stream-chat-room-v1' || this.settings.get('channel.hosting.enable') )
|
||||
return;
|
||||
|
||||
const type = event.message.type;
|
||||
if ( type === 'host_target_change' || type === 'host_target_change_v2' ) {
|
||||
this.log.info('Nulling Host Target Change', type);
|
||||
event.message.data.target_channel_id = null;
|
||||
event.message.data.target_channel_login = null;
|
||||
event.message.data.previous_target_channel_id = null;
|
||||
event.message.data.num_viewers = 0;
|
||||
event.markChanged();
|
||||
}
|
||||
}*/
|
||||
|
||||
|
||||
updateSubscription(id, login) {
|
||||
if ( this._subbed_login === login && this._subbed_id === id )
|
||||
return;
|
||||
|
@ -431,10 +386,14 @@ export default class Channel extends Module {
|
|||
this.fine.searchNode(react, node => {
|
||||
let state = node?.memoizedState, i = 0;
|
||||
while(state != null && channel == null && i < 50 ) {
|
||||
state = state?.next;
|
||||
channel = state?.memoizedState?.current?.previous?.result?.data?.user;
|
||||
if (!channel?.lastBroadcast?.game)
|
||||
channel = state?.memoizedState?.current?.result?.data?.user ??
|
||||
state?.memoizedState?.current?.previousData?.user;
|
||||
|
||||
if ( !channel?.lastBroadcast?.game )
|
||||
channel = null;
|
||||
|
||||
if ( ! channel )
|
||||
state = state?.next;
|
||||
i++;
|
||||
}
|
||||
return channel != null;
|
||||
|
@ -583,10 +542,11 @@ export default class Channel extends Module {
|
|||
let state = node?.memoizedState;
|
||||
i=0;
|
||||
while(state != null && channel == null && i < 50) {
|
||||
state = state?.next;
|
||||
channel = state?.memoizedState?.current?.currentObservable?.lastResult?.data?.userOrError;
|
||||
channel = state?.memoizedState?.current?.result?.data?.userOrError ??
|
||||
state?.memoizedState?.current?.previousData?.userOrError;
|
||||
|
||||
if ( ! channel )
|
||||
channel = state?.memoizedState?.current?.previous?.result?.previousData?.userOrError;
|
||||
state = state?.next;
|
||||
i++;
|
||||
}
|
||||
node = node?.return;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -247,7 +247,7 @@ export default class Scroller extends Module {
|
|||
inst.ffz_outside = true;
|
||||
inst._ffz_accessor = `_ffz_contains_${last_id++}`;
|
||||
|
||||
t.on('tooltips:mousemove', this.ffzTooltipHover, this);
|
||||
t.on('tooltips:hover', this.ffzTooltipHover, this);
|
||||
t.on('tooltips:leave', this.ffzTooltipLeave, this);
|
||||
|
||||
inst.scrollToBottom = function() {
|
||||
|
@ -682,7 +682,7 @@ export default class Scroller extends Module {
|
|||
}
|
||||
|
||||
onUnmount(inst) { // eslint-disable-line class-methods-use-this
|
||||
this.off('tooltips:mousemove', inst.ffzTooltipHover, inst);
|
||||
this.off('tooltips:hover', inst.ffzTooltipHover, inst);
|
||||
this.off('tooltips:leave', inst.ffzTooltipLeave, inst);
|
||||
|
||||
if ( inst._ffz_hover_timer ) {
|
||||
|
|
|
@ -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';
|
||||
|
@ -18,6 +18,15 @@ export const CARD_CONTEXTS = ((e ={}) => {
|
|||
return e;
|
||||
})();
|
||||
|
||||
export const CONTENT_FLAGS = [
|
||||
'DrugsIntoxication',
|
||||
'Gambling',
|
||||
'MatureGame',
|
||||
'ProfanityVulgarity',
|
||||
'SexualThemes',
|
||||
'ViolentGrpahic'
|
||||
];
|
||||
|
||||
function formatTerms(data, flags) {
|
||||
if ( data[0].length )
|
||||
data[1].push(addWordSeparators(data[0].join('|')));
|
||||
|
@ -33,12 +42,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');
|
||||
|
@ -252,6 +262,115 @@ export default class Directory extends SiteModule {
|
|||
changed: () => this.updateCards()
|
||||
});
|
||||
|
||||
|
||||
this.settings.add('directory.blur-titles', {
|
||||
default: [],
|
||||
type: 'array_merge',
|
||||
always_inherit: true,
|
||||
ui: {
|
||||
path: 'Directory > Channels >> Hide Thumbnails by Title',
|
||||
component: 'basic-terms'
|
||||
}
|
||||
});
|
||||
|
||||
this.settings.add('__filter:directory.blur-titles', {
|
||||
requires: ['directory.blur-titles'],
|
||||
equals: 'requirements',
|
||||
process(ctx) {
|
||||
const val = ctx.get('directory.blur-titles');
|
||||
if ( ! val || ! val.length )
|
||||
return null;
|
||||
|
||||
const out = [
|
||||
[ // sensitive
|
||||
[], [] // word
|
||||
],
|
||||
[
|
||||
[], []
|
||||
]
|
||||
];
|
||||
|
||||
for(const item of val) {
|
||||
const t = item.t;
|
||||
let v = item.v;
|
||||
|
||||
if ( t === 'glob' )
|
||||
v = glob_to_regex(v);
|
||||
|
||||
else if ( t !== 'raw' )
|
||||
v = escape_regex(v);
|
||||
|
||||
if ( ! v || ! v.length )
|
||||
continue;
|
||||
|
||||
out[item.s ? 0 : 1][item.w ? 0 : 1].push(v);
|
||||
}
|
||||
|
||||
return [
|
||||
formatTerms(out[0], 'g'),
|
||||
formatTerms(out[1], 'gi')
|
||||
];
|
||||
},
|
||||
|
||||
changed: () => this.updateCards()
|
||||
});
|
||||
|
||||
this.settings.add('directory.blur-tags', {
|
||||
default: [],
|
||||
type: 'basic_array_merge',
|
||||
always_inherit: true,
|
||||
ui: {
|
||||
path: 'Directory > Channels >> Hide Thumbnails by Tag',
|
||||
component: 'tag-list-editor'
|
||||
},
|
||||
changed: () => this.updateCards()
|
||||
});
|
||||
|
||||
this.settings.add('directory.block-flags', {
|
||||
default: [],
|
||||
type: 'array_merge',
|
||||
always_inherit: true,
|
||||
process(ctx, val) {
|
||||
const out = new Set;
|
||||
for(const v of val)
|
||||
if ( v?.v )
|
||||
out.add(v.v);
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
ui: {
|
||||
path: 'Directory > Channels >> Block by Flag',
|
||||
component: 'blocked-types',
|
||||
data: () => [...CONTENT_FLAGS]
|
||||
.sort()
|
||||
},
|
||||
|
||||
changed: () => this.updateCards()
|
||||
});
|
||||
|
||||
this.settings.add('directory.blur-flags', {
|
||||
default: [],
|
||||
type: 'array_merge',
|
||||
always_inherit: true,
|
||||
process(ctx, val) {
|
||||
const out = new Set;
|
||||
for(const v of val)
|
||||
if ( v?.v )
|
||||
out.add(v.v);
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
ui: {
|
||||
path: 'Directory > Channels >> Hide Thumbnails by Flag',
|
||||
component: 'blocked-types',
|
||||
data: () => [...CONTENT_FLAGS]
|
||||
.sort()
|
||||
},
|
||||
changed: () => this.updateCards()
|
||||
});
|
||||
|
||||
/*this.settings.add('directory.hide-viewing-history', {
|
||||
default: false,
|
||||
ui: {
|
||||
|
@ -457,22 +576,60 @@ export default class Directory extends SiteModule {
|
|||
const game = props.gameTitle || props.trackingProps?.categoryName || props.trackingProps?.category || props.contextualCardActionProps?.props?.categoryName,
|
||||
tags = props.tagListProps?.freeformTags;
|
||||
|
||||
let bad_tag = false;
|
||||
const blur_flags = this.settings.get('directory.blur-flags', []),
|
||||
block_flags = this.settings.get('directory.block-flags', []);
|
||||
|
||||
el.classList.toggle('ffz-hide-thumbnail', this.settings.provider.get('directory.game.hidden-thumbnails', []).includes(game));
|
||||
el.dataset.ffzType = props.streamType;
|
||||
if ( el._ffz_flags === undefined && (blur_flags.size || block_flags.size) ) {
|
||||
el._ffz_flags = null;
|
||||
this.twitch_data.getStreamFlags(null, props.channelLogin).then(data => {
|
||||
el._ffz_flags = data;
|
||||
this.updateCard(el);
|
||||
});
|
||||
}
|
||||
|
||||
let bad_tag = false,
|
||||
blur_tag = false;
|
||||
|
||||
if ( Array.isArray(tags) ) {
|
||||
const bad_tags = this.settings.get('directory.blocked-tags', []);
|
||||
if ( bad_tags.length ) {
|
||||
const bad_tags = this.settings.get('directory.blocked-tags', []),
|
||||
blur_tags = this.settings.get('directory.blur-tags', []);
|
||||
|
||||
if ( bad_tags.length || blur_tags.length ) {
|
||||
for(const tag of tags) {
|
||||
if ( tag?.name && bad_tags.includes(tag.name.toLowerCase()) ) {
|
||||
if ( tag?.name ) {
|
||||
const lname = tag.name.toLowerCase();
|
||||
if ( bad_tags.includes(lname) )
|
||||
bad_tag = true;
|
||||
if ( blur_tags.includes(lname) )
|
||||
blur_tag = true;
|
||||
}
|
||||
if ( (bad_tag || ! bad_tags.length) && (blur_tag || ! blur_tags.length) )
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let should_blur = blur_tag;
|
||||
if ( ! should_blur )
|
||||
should_blur = this.settings.provider.get('directory.game.hidden-thumbnails', []).includes(game);
|
||||
if ( ! should_blur && blur_flags.size && el._ffz_flags ) {
|
||||
for(const flag of el._ffz_flags)
|
||||
if ( flag?.id && blur_flags.has(flag.id) ) {
|
||||
should_blur = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ( ! should_blur ) {
|
||||
const regexes = this.settings.get('__filter:directory.blur-titles');
|
||||
if ( regexes &&
|
||||
(( regexes[0] && regexes[0].test(props.title) ) ||
|
||||
( regexes[1] && regexes[1].test(props.title) ))
|
||||
)
|
||||
should_blur = true;
|
||||
}
|
||||
|
||||
el.classList.toggle('ffz-hide-thumbnail', should_blur);
|
||||
el.dataset.ffzType = props.streamType;
|
||||
|
||||
let should_hide = false;
|
||||
if ( bad_tag )
|
||||
|
@ -484,6 +641,15 @@ export default class Directory extends SiteModule {
|
|||
else if ( (props.isPromotion || props.sourceType === 'COMMUNITY_BOOST' || props.sourceType === 'PROMOTION' || props.sourceType === 'SPONSORED') && this.settings.get('directory.hide-promoted') )
|
||||
should_hide = true;
|
||||
else {
|
||||
if ( block_flags.size && el._ffz_flags ) {
|
||||
for(const flag of el._ffz_flags)
|
||||
if ( flag?.id && block_flags.has(flag.id) ) {
|
||||
should_hide = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! should_hide ) {
|
||||
const regexes = this.settings.get('__filter:directory.block-titles');
|
||||
if ( regexes &&
|
||||
(( regexes[0] && regexes[0].test(props.title) ) ||
|
||||
|
@ -491,6 +657,7 @@ export default class Directory extends SiteModule {
|
|||
)
|
||||
should_hide = true;
|
||||
}
|
||||
}
|
||||
|
||||
let hide_container = el.closest('.tw-tower > div');
|
||||
if ( ! hide_container )
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -1,15 +1,64 @@
|
|||
'use strict';
|
||||
|
||||
import type SettingsManager from 'root/src/settings';
|
||||
import type { FineWrapper } from 'root/src/utilities/compat/fine';
|
||||
import type Fine from 'root/src/utilities/compat/fine';
|
||||
import type { ReactStateNode } from 'root/src/utilities/compat/react-types';
|
||||
|
||||
// ============================================================================
|
||||
// Loadable Stuff
|
||||
// ============================================================================
|
||||
|
||||
import Module from 'utilities/module';
|
||||
import Module, { GenericModule } from 'utilities/module';
|
||||
import type { AnyFunction } from 'utilities/types';
|
||||
import type Twilight from '..';
|
||||
|
||||
declare module 'utilities/types' {
|
||||
interface ModuleEventMap {
|
||||
|
||||
}
|
||||
interface ModuleMap {
|
||||
'site.loadable': Loadable
|
||||
}
|
||||
interface SettingsTypeMap {
|
||||
'chat.hype.show-pinned': boolean;
|
||||
'layout.turbo-cta': boolean;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
type LoadableNode = ReactStateNode<{
|
||||
component: string;
|
||||
loader: any;
|
||||
}, {
|
||||
Component?: AnyFunction;
|
||||
}>;
|
||||
|
||||
type ErrorBoundaryNode = ReactStateNode<{
|
||||
name: string;
|
||||
onError: any;
|
||||
children: any;
|
||||
}> & {
|
||||
onErrorBoundaryTestEmit: any
|
||||
}
|
||||
|
||||
|
||||
export default class Loadable extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
// Dependencies
|
||||
settings: SettingsManager = null as any;
|
||||
site: Twilight = null as any;
|
||||
fine: Fine = null as any;
|
||||
|
||||
// State
|
||||
overrides: Map<string, boolean>;
|
||||
|
||||
// Fine
|
||||
ErrorBoundaryComponent: FineWrapper<ErrorBoundaryNode>;
|
||||
LoadableComponent: FineWrapper<LoadableNode>;
|
||||
|
||||
constructor(name?: string, parent?: GenericModule) {
|
||||
super(name, parent);
|
||||
|
||||
this.should_enable = true;
|
||||
|
||||
|
@ -19,12 +68,18 @@ export default class Loadable extends Module {
|
|||
|
||||
this.LoadableComponent = this.fine.define(
|
||||
'loadable-component',
|
||||
n => n.props?.component && n.props.loader
|
||||
n =>
|
||||
(n as LoadableNode).props?.component &&
|
||||
(n as LoadableNode).props.loader
|
||||
);
|
||||
|
||||
this.ErrorBoundaryComponent = this.fine.define(
|
||||
'error-boundary-component',
|
||||
n => n.props?.name && n.props?.onError && n.props?.children && n.onErrorBoundaryTestEmit
|
||||
n =>
|
||||
(n as ErrorBoundaryNode).props?.name &&
|
||||
(n as ErrorBoundaryNode).props?.onError &&
|
||||
(n as ErrorBoundaryNode).props?.children &&
|
||||
(n as ErrorBoundaryNode).onErrorBoundaryTestEmit
|
||||
);
|
||||
|
||||
this.overrides = new Map();
|
||||
|
@ -44,9 +99,10 @@ export default class Loadable extends Module {
|
|||
this.log.debug('Found Error Boundary component wrapper.');
|
||||
|
||||
const t = this,
|
||||
old_render = cls.prototype.render;
|
||||
proto = cls.prototype as ErrorBoundaryNode,
|
||||
old_render = proto.render;
|
||||
|
||||
cls.prototype.render = function() {
|
||||
proto.render = function() {
|
||||
try {
|
||||
const type = this.props.name;
|
||||
if ( t.overrides.has(type) && ! t.shouldRender(type) )
|
||||
|
@ -66,32 +122,33 @@ export default class Loadable extends Module {
|
|||
this.log.debug('Found Loadable component wrapper.');
|
||||
|
||||
const t = this,
|
||||
old_render = cls.prototype.render;
|
||||
proto = cls.prototype,
|
||||
old_render = proto.render;
|
||||
|
||||
cls.prototype.render = function() {
|
||||
proto.render = function() {
|
||||
try {
|
||||
const type = this.props.component;
|
||||
if ( t.overrides.has(type) ) {
|
||||
if ( t.overrides.has(type) && this.state ) {
|
||||
let cmp = this.state.Component;
|
||||
if ( typeof cmp === 'function' && ! cmp.ffzWrapped ) {
|
||||
if ( typeof cmp === 'function' && ! (cmp as any).ffzWrapped ) {
|
||||
const React = t.site.getReact(),
|
||||
createElement = React && React.createElement;
|
||||
|
||||
if ( createElement ) {
|
||||
if ( ! cmp.ffzWrapper ) {
|
||||
if ( ! (cmp as any).ffzWrapper ) {
|
||||
const th = this;
|
||||
function FFZWrapper(props, state) {
|
||||
if ( t.shouldRender(th.props.component, props, state) )
|
||||
function FFZWrapper(props: any) {
|
||||
if ( t.shouldRender(th.props.component) )
|
||||
return createElement(cmp, props);
|
||||
return null;
|
||||
}
|
||||
|
||||
FFZWrapper.ffzWrapped = true;
|
||||
FFZWrapper.displayName = `FFZWrapper(${this.props.component})`;
|
||||
cmp.ffzWrapper = FFZWrapper;
|
||||
(cmp as any).ffzWrapper = FFZWrapper;
|
||||
}
|
||||
|
||||
this.state.Component = cmp.ffzWrapper;
|
||||
this.state.Component = (cmp as any).ffzWrapper;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -107,7 +164,7 @@ export default class Loadable extends Module {
|
|||
});
|
||||
}
|
||||
|
||||
toggle(cmp, state = null) {
|
||||
toggle(cmp: string, state: boolean | null = null) {
|
||||
const existing = this.overrides.get(cmp) ?? true;
|
||||
|
||||
if ( state == null )
|
||||
|
@ -121,21 +178,21 @@ export default class Loadable extends Module {
|
|||
}
|
||||
}
|
||||
|
||||
update(cmp) {
|
||||
update(cmp: string) {
|
||||
for(const inst of this.LoadableComponent.instances) {
|
||||
const type = inst?.props?.component;
|
||||
const type = inst.props?.component;
|
||||
if ( type && type === cmp )
|
||||
inst.forceUpdate();
|
||||
}
|
||||
|
||||
for(const inst of this.ErrorBoundaryComponent.instances) {
|
||||
const name = inst?.props?.name;
|
||||
const name = inst.props?.name;
|
||||
if ( name && name === cmp )
|
||||
inst.forceUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
shouldRender(cmp, props) {
|
||||
shouldRender(cmp: string) {
|
||||
return this.overrides.get(cmp) ?? true;
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
@ -109,11 +109,10 @@ export default class ModView extends Module {
|
|||
let state = node.memoizedState;
|
||||
i = 0;
|
||||
while(state != null && channel == null && i < 50) {
|
||||
channel = state?.memoizedState?.current?.result?.data?.user ??
|
||||
state?.memoizedState?.current?.previousData?.user;
|
||||
|
||||
state = state?.next;
|
||||
//channel = state?.memoizedState?.current?.previousData?.result?.data?.user;
|
||||
channel = state?.memoizedState?.current?.currentObservable?.lastResult?.data?.user;
|
||||
if ( ! channel )
|
||||
channel = state?.memoizedState?.current?.previous?.result?.previousData?.user;
|
||||
i++;
|
||||
}
|
||||
node = node?.child;
|
||||
|
@ -226,8 +225,9 @@ export default class ModView extends Module {
|
|||
|
||||
let channel = null, state = root?.return?.memoizedState, i = 0;
|
||||
while(state != null && channel == null && i < 50 ) {
|
||||
channel = state?.memoizedState?.current?.result?.data?.channel ??
|
||||
state?.memoizedState?.current?.previousData?.channel;
|
||||
state = state?.next;
|
||||
channel = state?.memoizedState?.current?.previous?.result?.data?.channel;
|
||||
i++;
|
||||
}
|
||||
|
||||
|
|
|
@ -4,12 +4,50 @@
|
|||
// Sub Button
|
||||
// ============================================================================
|
||||
|
||||
import Module from 'utilities/module';
|
||||
import Module, { GenericModule } from 'utilities/module';
|
||||
import {createElement} from 'utilities/dom';
|
||||
import type SettingsManager from 'src/settings';
|
||||
import type TranslationManager from 'src/i18n';
|
||||
import type Fine from 'utilities/compat/fine';
|
||||
import type { FineWrapper } from 'utilities/compat/fine';
|
||||
import type { ReactStateNode } from 'root/src/utilities/compat/react-types';
|
||||
|
||||
declare module 'utilities/types' {
|
||||
interface ModuleMap {
|
||||
'site.sub_button': SubButton;
|
||||
}
|
||||
interface SettingsTypeMap {
|
||||
'layout.swap-sidebars': unknown;
|
||||
'sub-button.prime-notice': boolean;
|
||||
}
|
||||
}
|
||||
|
||||
type SubButtonNode = ReactStateNode<{
|
||||
data?: {
|
||||
user?: {
|
||||
self?: {
|
||||
canPrimeSubscribe: boolean;
|
||||
subscriptionBenefit: unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
}> & {
|
||||
handleSubMenuAction: any;
|
||||
openSubModal: any;
|
||||
};
|
||||
|
||||
export default class SubButton extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
// Dependencies
|
||||
i18n: TranslationManager = null as any;
|
||||
fine: Fine = null as any;
|
||||
settings: SettingsManager = null as any;
|
||||
|
||||
// Stuff
|
||||
SubButton: FineWrapper<SubButtonNode>;
|
||||
|
||||
constructor(name?: string, parent?: GenericModule) {
|
||||
super(name, parent);
|
||||
|
||||
this.should_enable = true;
|
||||
|
||||
|
@ -32,39 +70,20 @@ export default class SubButton extends Module {
|
|||
|
||||
this.SubButton = this.fine.define(
|
||||
'sub-button',
|
||||
n => n.handleSubMenuAction && n.openSubModal,
|
||||
n =>
|
||||
(n as SubButtonNode).handleSubMenuAction &&
|
||||
(n as SubButtonNode).openSubModal,
|
||||
['user', 'user-home', 'user-video', 'user-clip', 'video', 'user-videos', 'user-clips', 'user-collections', 'user-events', 'user-followers', 'user-following']
|
||||
);
|
||||
}
|
||||
|
||||
onEnable() {
|
||||
this.settings.on(':changed:layout.swap-sidebars', () => this.SubButton.forceUpdate())
|
||||
this.settings.on(':changed:layout.swap-sidebars', () =>
|
||||
this.SubButton.forceUpdate());
|
||||
|
||||
this.SubButton.ready((cls, instances) => {
|
||||
const t = this,
|
||||
old_render = cls.prototype.render;
|
||||
|
||||
cls.prototype.render = function() {
|
||||
try {
|
||||
const old_direction = this.props.balloonDirection;
|
||||
if ( old_direction !== undefined ) {
|
||||
const should_be_left = t.settings.get('layout.swap-sidebars'),
|
||||
is_left = old_direction.includes('--left');
|
||||
|
||||
if ( should_be_left && ! is_left )
|
||||
this.props.balloonDirection = old_direction.replace('--right', '--left');
|
||||
else if ( ! should_be_left && is_left )
|
||||
this.props.balloonDirection = old_direction.replace('--left', '--right');
|
||||
}
|
||||
} catch(err) { /* no-op */ }
|
||||
|
||||
return old_render.call(this);
|
||||
}
|
||||
|
||||
for(const inst of instances)
|
||||
this.updateSubButton(inst);
|
||||
|
||||
this.SubButton.forceUpdate();
|
||||
});
|
||||
|
||||
this.SubButton.on('mount', this.updateSubButton, this);
|
||||
|
@ -72,9 +91,9 @@ export default class SubButton extends Module {
|
|||
}
|
||||
|
||||
|
||||
updateSubButton(inst) {
|
||||
const container = this.fine.getChildNode(inst),
|
||||
btn = container && container.querySelector('button[data-a-target="subscribe-button"]');
|
||||
updateSubButton(inst: SubButtonNode) {
|
||||
const container = this.fine.getChildNode<HTMLElement>(inst),
|
||||
btn = container?.querySelector('button[data-a-target="subscribe-button"]');
|
||||
if ( ! btn )
|
||||
return;
|
||||
|
|
@ -4,12 +4,38 @@
|
|||
// Staging Selector
|
||||
// ============================================================================
|
||||
|
||||
import Module from 'utilities/module';
|
||||
import Module, { GenericModule } from 'utilities/module';
|
||||
import { API_SERVER, SERVER, STAGING_API, STAGING_CDN } from './utilities/constants';
|
||||
import type SettingsManager from './settings';
|
||||
|
||||
export default class StagingSelector extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
declare module 'utilities/types' {
|
||||
interface ModuleMap {
|
||||
staging: StagingSelector;
|
||||
}
|
||||
interface ModuleEventMap {
|
||||
staging: StagingEvents;
|
||||
}
|
||||
interface SettingsTypeMap {
|
||||
'data.use-staging': boolean;
|
||||
}
|
||||
}
|
||||
|
||||
type StagingEvents = {
|
||||
':updated': [api: string, cdn: string];
|
||||
}
|
||||
|
||||
export default class StagingSelector extends Module<'staging', StagingEvents> {
|
||||
|
||||
// Dependencies
|
||||
settings: SettingsManager = null as any;
|
||||
|
||||
// State
|
||||
api: string = API_SERVER;
|
||||
cdn: string = SERVER;
|
||||
active: boolean = false;
|
||||
|
||||
constructor(name?: string, parent?: GenericModule) {
|
||||
super(name, parent);
|
||||
|
||||
this.inject('settings');
|
||||
|
||||
|
@ -26,11 +52,12 @@ export default class StagingSelector extends Module {
|
|||
this.updateStaging(false);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
onEnable() {
|
||||
this.settings.getChanges('data.use-staging', this.updateStaging, this);
|
||||
}
|
||||
|
||||
updateStaging(val) {
|
||||
private updateStaging(val: boolean) {
|
||||
this.active = val;
|
||||
|
||||
this.api = val
|
|
@ -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');
|
||||
}
|
|
@ -1,669 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
export function hue2rgb(p, q, t) {
|
||||
if ( t < 0 ) t += 1;
|
||||
if ( t > 1 ) t -= 1;
|
||||
if ( t < 1/6 )
|
||||
return p + (q-p) * 6 * t;
|
||||
if ( t < 1/2 )
|
||||
return q;
|
||||
if ( t < 2/3 )
|
||||
return p + (q-p) * (2/3 - t) * 6;
|
||||
return p;
|
||||
}
|
||||
|
||||
export function bit2linear(channel) {
|
||||
// http://www.brucelindbloom.com/Eqn_RGB_to_XYZ.html
|
||||
// This converts rgb 8bit to rgb linear, lazy because the other algorithm is really really dumb
|
||||
//return Math.pow(channel, 2.2);
|
||||
|
||||
// CSS Colors Level 4 says 0.03928, Bruce Lindbloom who cared to write all algos says 0.04045, used bruce because whynawt
|
||||
return (channel <= 0.04045) ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
export function linear2bit(channel) {
|
||||
// Using lazy conversion in the other direction as well
|
||||
//return Math.pow(channel, 1/2.2);
|
||||
|
||||
// I'm honestly not sure about 0.0031308, I've only seen it referenced on Bruce Lindbloom's site
|
||||
return (channel <= 0.0031308) ? channel * 12.92 : Math.pow(1.055 * channel, 1/2.4) - 0.055;
|
||||
}
|
||||
|
||||
|
||||
export const Color = {};
|
||||
|
||||
Color._canvas = null;
|
||||
Color._context = null;
|
||||
|
||||
Color.CVDMatrix = {
|
||||
protanope: [ // reds are greatly reduced (1% men)
|
||||
0.0, 2.02344, -2.52581,
|
||||
0.0, 1.0, 0.0,
|
||||
0.0, 0.0, 1.0
|
||||
],
|
||||
deuteranope: [ // greens are greatly reduced (1% men)
|
||||
1.0, 0.0, 0.0,
|
||||
0.494207, 0.0, 1.24827,
|
||||
0.0, 0.0, 1.0
|
||||
],
|
||||
tritanope: [ // blues are greatly reduced (0.003% population)
|
||||
1.0, 0.0, 0.0,
|
||||
0.0, 1.0, 0.0,
|
||||
-0.395913, 0.801109, 0.0
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
const RGBAColor = Color.RGBA = function(r, g, b, a) {
|
||||
this.r = r||0; this.g = g||0; this.b = b||0; this.a = a||0;
|
||||
};
|
||||
|
||||
const HSVAColor = Color.HSVA = function(h, s, v, a) {
|
||||
this.h = h||0; this.s = s||0; this.v = v||0; this.a = a||0;
|
||||
};
|
||||
|
||||
const HSLAColor = Color.HSLA = function(h, s, l, a) {
|
||||
this.h = h||0; this.s = s||0; this.l = l||0; this.a = a||0;
|
||||
};
|
||||
|
||||
const XYZAColor = Color.XYZA = function(x, y, z, a) {
|
||||
this.x = x||0; this.y = y||0; this.z = z||0; this.a = a||0;
|
||||
};
|
||||
|
||||
const LUVAColor = Color.LUVA = function(l, u, v, a) {
|
||||
this.l = l||0; this.u = u||0; this.v = v||0; this.a = a||0;
|
||||
};
|
||||
|
||||
|
||||
// RGBA Colors
|
||||
|
||||
RGBAColor.prototype.eq = function(rgb) {
|
||||
return rgb.r === this.r && rgb.g === this.g && rgb.b === this.b && rgb.a === this.a;
|
||||
}
|
||||
|
||||
RGBAColor.fromName = function(name) {
|
||||
let context = Color._context;
|
||||
if ( ! context ) {
|
||||
const canvas = Color._canvas = document.createElement('canvas');
|
||||
context = Color._context = canvas.getContext('2d');
|
||||
}
|
||||
|
||||
context.clearRect(0,0,1,1);
|
||||
context.fillStyle = name;
|
||||
context.fillRect(0,0,1,1);
|
||||
const data = context.getImageData(0,0,1,1);
|
||||
|
||||
if ( ! data || ! data.data || data.data.length !== 4 )
|
||||
return null;
|
||||
|
||||
return new RGBAColor(data.data[0], data.data[1], data.data[2], data.data[3] / 255);
|
||||
}
|
||||
|
||||
RGBAColor.fromCSS = function(rgb) {
|
||||
if ( ! rgb )
|
||||
return null;
|
||||
|
||||
rgb = rgb.trim();
|
||||
|
||||
if ( rgb.charAt(0) === '#' )
|
||||
return RGBAColor.fromHex(rgb);
|
||||
|
||||
const match = /rgba?\( *(\d+%?) *, *(\d+%?) *, *(\d+%?) *(?:, *([\d.]+))?\)/i.exec(rgb);
|
||||
if ( match ) {
|
||||
let r = match[1],
|
||||
g = match[2],
|
||||
b = match[3],
|
||||
a = match[4];
|
||||
|
||||
if ( r.charAt(r.length-1) === '%' )
|
||||
r = 255 * (parseInt(r,10) / 100);
|
||||
else
|
||||
r = parseInt(r,10);
|
||||
|
||||
if ( g.charAt(g.length-1) === '%' )
|
||||
g = 255 * (parseInt(g,10) / 100);
|
||||
else
|
||||
g = parseInt(g,10);
|
||||
|
||||
if ( b.charAt(b.length-1) === '%' )
|
||||
b = 255 * (parseInt(b,10) / 100);
|
||||
else
|
||||
b = parseInt(b,10);
|
||||
|
||||
if ( a )
|
||||
if ( a.charAt(a.length-1) === '%' )
|
||||
a = parseInt(a,10) / 100;
|
||||
else
|
||||
a = parseFloat(a);
|
||||
else
|
||||
a = 1;
|
||||
|
||||
return new RGBAColor(
|
||||
Math.min(Math.max(0, r), 255),
|
||||
Math.min(Math.max(0, g), 255),
|
||||
Math.min(Math.max(0, b), 255),
|
||||
Math.min(Math.max(0, a), 1)
|
||||
);
|
||||
}
|
||||
|
||||
return RGBAColor.fromName(rgb);
|
||||
}
|
||||
|
||||
RGBAColor.fromHex = function(code, alpha = 1) {
|
||||
if ( code.charAt(0) === '#' )
|
||||
code = code.slice(1);
|
||||
|
||||
if ( code.length === 3 )
|
||||
code = `${code[0]}${code[0]}${code[1]}${code[1]}${code[2]}${code[2]}`;
|
||||
|
||||
else if ( code.length === 4 )
|
||||
code = `${code[0]}${code[0]}${code[1]}${code[1]}${code[2]}${code[2]}${code[3]}${code[3]}`;
|
||||
|
||||
if ( code.length === 8 ) {
|
||||
alpha = parseInt(code.slice(6), 16) / 255;
|
||||
code = code.slice(0, 6);
|
||||
|
||||
} else if ( code.length !== 6 )
|
||||
throw new Error('invalid hex code');
|
||||
|
||||
const raw = parseInt(code, 16);
|
||||
return new RGBAColor(
|
||||
(raw >> 16), // Red
|
||||
(raw >> 8 & 0x00FF), // Green
|
||||
(raw & 0x0000FF), // Blue,
|
||||
alpha // Alpha
|
||||
);
|
||||
}
|
||||
|
||||
RGBAColor.fromHSVA = function(h, s, v, a) {
|
||||
let r, g, b;
|
||||
|
||||
const i = Math.floor(h * 6),
|
||||
f = h * 6 - i,
|
||||
p = v * (1 - s),
|
||||
q = v * (1 - f * s),
|
||||
t = v * (1 - (1 - f) * s);
|
||||
|
||||
switch(i % 6) {
|
||||
case 0: r = v; g = t; b = p; break;
|
||||
case 1: r = q; g = v; b = p; break;
|
||||
case 2: r = p; g = v; b = t; break;
|
||||
case 3: r = p; g = q; b = v; break;
|
||||
case 4: r = t; g = p; b = v; break;
|
||||
case 5: r = v; g = p; b = q;
|
||||
}
|
||||
|
||||
return new RGBAColor(
|
||||
Math.round(Math.min(Math.max(0, r*255), 255)),
|
||||
Math.round(Math.min(Math.max(0, g*255), 255)),
|
||||
Math.round(Math.min(Math.max(0, b*255), 255)),
|
||||
a === undefined ? 1 : a
|
||||
);
|
||||
}
|
||||
|
||||
RGBAColor.fromXYZA = function(x, y, z, a) {
|
||||
const R = 3.240479 * x - 1.537150 * y - 0.498535 * z,
|
||||
G = -0.969256 * x + 1.875992 * y + 0.041556 * z,
|
||||
B = 0.055648 * x - 0.204043 * y + 1.057311 * z;
|
||||
|
||||
// Make sure we end up in a real color space
|
||||
return new RGBAColor(
|
||||
Math.max(0, Math.min(255, 255 * linear2bit(R))),
|
||||
Math.max(0, Math.min(255, 255 * linear2bit(G))),
|
||||
Math.max(0, Math.min(255, 255 * linear2bit(B))),
|
||||
a === undefined ? 1 : a
|
||||
);
|
||||
}
|
||||
|
||||
RGBAColor.fromHSLA = function(h, s, l, a) {
|
||||
if ( s === 0 ) {
|
||||
const v = Math.round(Math.min(Math.max(0, 255*l), 255));
|
||||
return new RGBAColor(v, v, v, a === undefined ? 1 : a);
|
||||
}
|
||||
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s,
|
||||
p = 2 * l - q;
|
||||
|
||||
return new RGBAColor(
|
||||
Math.round(Math.min(Math.max(0, 255 * hue2rgb(p, q, h + 1/3)), 255)),
|
||||
Math.round(Math.min(Math.max(0, 255 * hue2rgb(p, q, h)), 255)),
|
||||
Math.round(Math.min(Math.max(0, 255 * hue2rgb(p, q, h - 1/3)), 255)),
|
||||
a === undefined ? 1 : a
|
||||
);
|
||||
}
|
||||
|
||||
RGBAColor.prototype.toRGBA = function() { return this; }
|
||||
RGBAColor.prototype.toHSVA = function() { return HSVAColor.fromRGBA(this.r, this.g, this.b, this.a); }
|
||||
RGBAColor.prototype.toHSLA = function() { return HSLAColor.fromRGBA(this.r, this.g, this.b, this.a); }
|
||||
RGBAColor.prototype.toCSS = function() { return `rgb${this.a !== 1 ? 'a' : ''}(${Math.round(this.r)},${Math.round(this.g)},${Math.round(this.b)}${this.a !== 1 ? `,${this.a}` : ''})`; }
|
||||
RGBAColor.prototype.toXYZA = function() { return XYZAColor.fromRGBA(this.r, this.g, this.b, this.a); }
|
||||
RGBAColor.prototype.toLUVA = function() { return this.toXYZA().toLUVA(); }
|
||||
|
||||
RGBAColor.prototype.toHex = function() {
|
||||
const rgb = this.b | (this.g << 8) | (this.r << 16);
|
||||
return `#${(0x1000000 + rgb).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
|
||||
RGBAColor.prototype.get_Y = function() {
|
||||
return ((0.299 * this.r) + ( 0.587 * this.g) + ( 0.114 * this.b)) / 255;
|
||||
}
|
||||
|
||||
|
||||
RGBAColor.prototype.luminance = function() {
|
||||
const r = bit2linear(this.r / 255),
|
||||
g = bit2linear(this.g / 255),
|
||||
b = bit2linear(this.b / 255);
|
||||
|
||||
return (0.2126 * r) + (0.7152 * g) + (0.0722 * b);
|
||||
}
|
||||
|
||||
|
||||
RGBAColor.prototype.brighten = function(amount) {
|
||||
amount = typeof amount === `number` ? amount : 1;
|
||||
amount = Math.round(255 * (amount / 100));
|
||||
|
||||
return new RGBAColor(
|
||||
Math.max(0, Math.min(255, this.r + amount)),
|
||||
Math.max(0, Math.min(255, this.g + amount)),
|
||||
Math.max(0, Math.min(255, this.b + amount)),
|
||||
this.a
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
RGBAColor.prototype.daltonize = function(type) {
|
||||
let cvd;
|
||||
if ( typeof type === 'string' ) {
|
||||
if ( Color.CVDMatrix.hasOwnProperty(type) )
|
||||
cvd = Color.CVDMatrix[type];
|
||||
else
|
||||
throw new Error('Invalid CVD matrix');
|
||||
} else
|
||||
cvd = type;
|
||||
|
||||
const cvd_a = cvd[0], cvd_b = cvd[1], cvd_c = cvd[2],
|
||||
cvd_d = cvd[3], cvd_e = cvd[4], cvd_f = cvd[5],
|
||||
cvd_g = cvd[6], cvd_h = cvd[7], cvd_i = cvd[8];
|
||||
|
||||
//let L, M, S, l, m, s, R, G, B, RR, GG, BB;
|
||||
|
||||
// RGB to LMS matrix conversion
|
||||
const L = (17.8824 * this.r) + (43.5161 * this.g) + (4.11935 * this.b),
|
||||
M = (3.45565 * this.r) + (27.1554 * this.g) + (3.86714 * this.b),
|
||||
S = (0.0299566 * this.r) + (0.184309 * this.g) + (1.46709 * this.b);
|
||||
|
||||
// Simulate color blindness
|
||||
const l = (cvd_a * L) + (cvd_b * M) + (cvd_c * S),
|
||||
m = (cvd_d * L) + (cvd_e * M) + (cvd_f * S),
|
||||
s = (cvd_g * L) + (cvd_h * M) + (cvd_i * S);
|
||||
|
||||
// LMS to RGB matrix conversion
|
||||
let R = (0.0809444479 * l) + (-0.130504409 * m) + (0.116721066 * s),
|
||||
G = (-0.0102485335 * l) + (0.0540193266 * m) + (-0.113614708 * s),
|
||||
B = (-0.000365296938 * l) + (-0.00412161469 * m) + (0.693511405 * s);
|
||||
|
||||
// Isolate invisible colors to color vision deficiency (calculate error matrix)
|
||||
R = this.r - R;
|
||||
G = this.g - G;
|
||||
B = this.b - B;
|
||||
|
||||
// Shift colors towards visible spectrum (apply error modifications)
|
||||
const RR = (0.0 * R) + (0.0 * G) + (0.0 * B),
|
||||
GG = (0.7 * R) + (1.0 * G) + (0.0 * B),
|
||||
BB = (0.7 * R) + (0.0 * G) + (1.0 * B);
|
||||
|
||||
// Add compensation to original values
|
||||
R = Math.min(Math.max(0, RR + this.r), 255);
|
||||
G = Math.min(Math.max(0, GG + this.g), 255);
|
||||
B = Math.min(Math.max(0, BB + this.b), 255);
|
||||
|
||||
return new RGBAColor(R, G, B, this.a);
|
||||
}
|
||||
|
||||
RGBAColor.prototype._r = function(r) { return new RGBAColor(r, this.g, this.b, this.a); }
|
||||
RGBAColor.prototype._g = function(g) { return new RGBAColor(this.r, g, this.b, this.a); }
|
||||
RGBAColor.prototype._b = function(b) { return new RGBAColor(this.r, this.g, b, this.a); }
|
||||
RGBAColor.prototype._a = function(a) { return new RGBAColor(this.r, this.g, this.b, a); }
|
||||
|
||||
|
||||
// HSL Colors
|
||||
|
||||
HSLAColor.prototype.eq = function(hsl) {
|
||||
return hsl.h === this.h && hsl.s === this.s && hsl.l === this.l && hsl.a === this.a;
|
||||
}
|
||||
|
||||
HSLAColor.fromRGBA = function(r, g, b, a) {
|
||||
r /= 255; g /= 255; b /= 255;
|
||||
|
||||
const max = Math.max(r,g,b),
|
||||
min = Math.min(r,g,b),
|
||||
|
||||
l = Math.min(Math.max(0, (max+min) / 2), 1),
|
||||
d = Math.min(Math.max(0, max - min), 1);
|
||||
|
||||
let h, s;
|
||||
|
||||
if ( d === 0 )
|
||||
h = s = 0;
|
||||
else {
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch(max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
case b:
|
||||
h = (r - g) / d + 4;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return new HSLAColor(h, s, l, a === undefined ? 1 : a);
|
||||
}
|
||||
|
||||
HSLAColor.prototype.targetLuminance = function (target) {
|
||||
let s = this.s,
|
||||
min = 0,
|
||||
max = 1;
|
||||
|
||||
s *= Math.pow(this.l > 0.5 ? -this.l : this.l - 1, 7) + 1;
|
||||
|
||||
let d = (max - min) / 2,
|
||||
mid = min + d;
|
||||
|
||||
for (; d > 1/65536; d /= 2, mid = min + d) {
|
||||
const luminance = RGBAColor.fromHSLA(this.h, s, mid, 1).luminance()
|
||||
if (luminance > target) {
|
||||
max = mid;
|
||||
} else {
|
||||
min = mid;
|
||||
}
|
||||
}
|
||||
|
||||
return new HSLAColor(this.h, s, mid, this.a);
|
||||
}
|
||||
|
||||
HSLAColor.prototype.toRGBA = function() { return RGBAColor.fromHSLA(this.h, this.s, this.l, this.a); }
|
||||
HSLAColor.prototype.toCSS = function() { return `hsl${this.a !== 1 ? 'a' : ''}(${Math.round(this.h*360)},${Math.round(this.s*100)}%,${Math.round(this.l*100)}%${this.a !== 1 ? `,${this.a}` : ''})`; }
|
||||
HSLAColor.prototype.toHSLA = function() { return this; }
|
||||
HSLAColor.prototype.toHSVA = function() { return this.toRGBA().toHSVA(); }
|
||||
HSLAColor.prototype.toXYZA = function() { return this.toRGBA().toXYZA(); }
|
||||
HSLAColor.prototype.toLUVA = function() { return this.toRGBA().toLUVA(); }
|
||||
|
||||
|
||||
HSLAColor.prototype._h = function(h) { return new HSLAColor(h, this.s, this.l, this.a); }
|
||||
HSLAColor.prototype._s = function(s) { return new HSLAColor(this.h, s, this.l, this.a); }
|
||||
HSLAColor.prototype._l = function(l) { return new HSLAColor(this.h, this.s, l, this.a); }
|
||||
HSLAColor.prototype._a = function(a) { return new HSLAColor(this.h, this.s, this.l, a); }
|
||||
|
||||
|
||||
// HSV Colors
|
||||
|
||||
HSVAColor.prototype.eq = function(hsv) { return hsv.h === this.h && hsv.s === this.s && hsv.v === this.v && hsv.a === this.a; }
|
||||
|
||||
HSVAColor.fromRGBA = function(r, g, b, a) {
|
||||
r /= 255; g /= 255; b /= 255;
|
||||
|
||||
const max = Math.max(r, g, b),
|
||||
min = Math.min(r, g, b),
|
||||
d = Math.min(Math.max(0, max - min), 1),
|
||||
|
||||
s = max === 0 ? 0 : d / max,
|
||||
v = max;
|
||||
|
||||
let h;
|
||||
|
||||
if ( d === 0 )
|
||||
h = 0;
|
||||
else {
|
||||
switch(max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
case b:
|
||||
h = (r - g) / d + 4;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return new HSVAColor(h, s, v, a === undefined ? 1 : a);
|
||||
}
|
||||
|
||||
|
||||
HSVAColor.prototype.toRGBA = function() { return RGBAColor.fromHSVA(this.h, this.s, this.v, this.a); }
|
||||
HSVAColor.prototype.toHSVA = function() { return this; }
|
||||
HSVAColor.prototype.toHSLA = function() { return this.toRGBA().toHSLA(); }
|
||||
HSVAColor.prototype.toXYZA = function() { return this.toRGBA().toXYZA(); }
|
||||
HSVAColor.prototype.toLUVA = function() { return this.toRGBA().toLUVA(); }
|
||||
|
||||
|
||||
HSVAColor.prototype._h = function(h) { return new HSVAColor(h, this.s, this.v, this.a); }
|
||||
HSVAColor.prototype._s = function(s) { return new HSVAColor(this.h, s, this.v, this.a); }
|
||||
HSVAColor.prototype._v = function(v) { return new HSVAColor(this.h, this.s, v, this.a); }
|
||||
HSVAColor.prototype._a = function(a) { return new HSVAColor(this.h, this.s, this.v, a); }
|
||||
|
||||
|
||||
// XYZ Colors
|
||||
|
||||
XYZAColor.prototype.eq = function(xyz) { return xyz.x === this.x && xyz.y === this.y && xyz.z === this.z; }
|
||||
|
||||
XYZAColor.fromRGBA = function(r, g, b, a) {
|
||||
const R = bit2linear(r / 255),
|
||||
G = bit2linear(g / 255),
|
||||
B = bit2linear(b / 255);
|
||||
|
||||
return new XYZAColor(
|
||||
0.412453 * R + 0.357580 * G + 0.180423 * B,
|
||||
0.212671 * R + 0.715160 * G + 0.072169 * B,
|
||||
0.019334 * R + 0.119193 * G + 0.950227 * B,
|
||||
a === undefined ? 1 : a
|
||||
);
|
||||
}
|
||||
|
||||
XYZAColor.fromLUVA = function(l, u, v, alpha) {
|
||||
const deltaGammaFactor = 1 / (XYZAColor.WHITE.x + 15 * XYZAColor.WHITE.y + 3 * XYZAColor.WHITE.z),
|
||||
uDeltaGamma = 4 * XYZAColor.WHITE.x * deltaGammaFactor,
|
||||
vDeltagamma = 9 * XYZAColor.WHITE.y * deltaGammaFactor;
|
||||
|
||||
// XYZAColor.EPSILON * XYZAColor.KAPPA = 8
|
||||
const Y = (l > 8) ? Math.pow((l + 16) / 116, 3) : l / XYZAColor.KAPPA,
|
||||
a = 1/3 * (((52 * l) / (u + 13 * l * uDeltaGamma)) - 1),
|
||||
b = -5 * Y,
|
||||
c = -1/3,
|
||||
d = Y * (((39 * l) / (v + 13 * l * vDeltagamma)) - 5),
|
||||
|
||||
X = (d - b) / (a - c),
|
||||
Z = X * a + b;
|
||||
|
||||
return new XYZAColor(X, Y, Z, alpha === undefined ? 1 : alpha);
|
||||
}
|
||||
|
||||
|
||||
XYZAColor.prototype.toRGBA = function() { return RGBAColor.fromXYZA(this.x, this.y, this.z, this.a); }
|
||||
XYZAColor.prototype.toLUVA = function() { return LUVAColor.fromXYZA(this.x, this.y, this.z, this.a); }
|
||||
XYZAColor.prototype.toHSLA = function() { return this.toRGBA().toHSLA(); }
|
||||
XYZAColor.prototype.toHSVA = function() { return this.toRGBA().toHSVA(); }
|
||||
XYZAColor.prototype.toXYZA = function() { return this; }
|
||||
|
||||
|
||||
XYZAColor.prototype._x = function(x) { return new XYZAColor(x, this.y, this.z, this.a); }
|
||||
XYZAColor.prototype._y = function(y) { return new XYZAColor(this.x, y, this.z, this.a); }
|
||||
XYZAColor.prototype._z = function(z) { return new XYZAColor(this.x, this.y, z, this.a); }
|
||||
XYZAColor.prototype._a = function(a) { return new XYZAColor(this.x, this.y, this.z, a); }
|
||||
|
||||
|
||||
// LUV Colors
|
||||
|
||||
XYZAColor.EPSILON = Math.pow(6 / 29, 3);
|
||||
XYZAColor.KAPPA = Math.pow(29 / 3, 3);
|
||||
XYZAColor.WHITE = (new RGBAColor(255, 255, 255, 1)).toXYZA();
|
||||
|
||||
|
||||
LUVAColor.prototype.eq = function(luv) { return luv.l === this.l && luv.u === this.u && luv.v === this.v; }
|
||||
|
||||
LUVAColor.fromXYZA = function(X, Y, Z, a) {
|
||||
const deltaGammaFactor = 1 / (XYZAColor.WHITE.x + 15 * XYZAColor.WHITE.y + 3 * XYZAColor.WHITE.z),
|
||||
uDeltaGamma = 4 * XYZAColor.WHITE.x * deltaGammaFactor,
|
||||
vDeltagamma = 9 * XYZAColor.WHITE.y * deltaGammaFactor,
|
||||
|
||||
yGamma = Y / XYZAColor.WHITE.y;
|
||||
|
||||
let deltaDivider = (X + 15 * Y + 3 * Z);
|
||||
if (deltaDivider === 0) {
|
||||
deltaDivider = 1;
|
||||
}
|
||||
|
||||
const deltaFactor = 1 / deltaDivider,
|
||||
|
||||
uDelta = 4 * X * deltaFactor,
|
||||
vDelta = 9 * Y * deltaFactor,
|
||||
|
||||
L = (yGamma > XYZAColor.EPSILON) ? 116 * Math.pow(yGamma, 1/3) - 16 : XYZAColor.KAPPA * yGamma,
|
||||
u = 13 * L * (uDelta - uDeltaGamma),
|
||||
v = 13 * L * (vDelta - vDeltagamma);
|
||||
|
||||
return new LUVAColor(L, u, v, a === undefined ? 1 : a);
|
||||
}
|
||||
|
||||
|
||||
LUVAColor.prototype.toXYZA = function() { return XYZAColor.fromLUVA(this.l, this.u, this.v, this.a); }
|
||||
LUVAColor.prototype.toRGBA = function() { return this.toXYZA().toRGBA(); }
|
||||
LUVAColor.prototype.toHSLA = function() { return this.toXYZA().toHSLA(); }
|
||||
LUVAColor.prototype.toHSVA = function() { return this.toXYZA().toHSVA(); }
|
||||
LUVAColor.prototype.toLUVA = function() { return this; }
|
||||
|
||||
|
||||
LUVAColor.prototype._l = function(l) { return new LUVAColor(l, this.u, this.v, this.a); }
|
||||
LUVAColor.prototype._u = function(u) { return new LUVAColor(this.l, u, this.v, this.a); }
|
||||
LUVAColor.prototype._v = function(v) { return new LUVAColor(this.l, this.u, v, this.a); }
|
||||
LUVAColor.prototype._a = function(a) { return new LUVAColor(this.l, this.u, this.v, a); }
|
||||
|
||||
|
||||
|
||||
export class ColorAdjuster {
|
||||
constructor(base = '#232323', mode = 0, contrast = 4.5) {
|
||||
this._contrast = contrast;
|
||||
this._base = base;
|
||||
this._mode = mode;
|
||||
|
||||
this.rebuildContrast();
|
||||
}
|
||||
|
||||
get contrast() { return this._contrast }
|
||||
set contrast(val) { this._contrast = val; this.rebuildContrast() }
|
||||
|
||||
get base() { return this._base }
|
||||
set base(val) { this._base = val; this.rebuildContrast() }
|
||||
|
||||
get dark() { return this._dark }
|
||||
|
||||
get mode() { return this._mode }
|
||||
set mode(val) { this._mode = val; this.rebuildContrast() }
|
||||
|
||||
|
||||
rebuildContrast() {
|
||||
this._cache = new Map;
|
||||
|
||||
const base = RGBAColor.fromCSS(this._base),
|
||||
lum = base.luminance();
|
||||
|
||||
const dark = this._dark = lum < 0.5;
|
||||
|
||||
if ( dark ) {
|
||||
this._luv = new XYZAColor(
|
||||
0,
|
||||
(this._contrast * (base.toXYZA().y + 0.05) - 0.05),
|
||||
0,
|
||||
1
|
||||
).toLUVA().l;
|
||||
|
||||
this._luma = this._contrast * (base.luminance() + 0.05) - 0.05;
|
||||
|
||||
} else {
|
||||
this._luv = new XYZAColor(
|
||||
0,
|
||||
(base.toXYZA().y + 0.05) / this._contrast - 0.05,
|
||||
0,
|
||||
1
|
||||
).toLUVA().l;
|
||||
|
||||
this._luma = (base.luminance() + 0.05) / this._contrast - 0.05;
|
||||
}
|
||||
}
|
||||
|
||||
process(color, throw_errors = false) {
|
||||
if ( this._mode === -1 )
|
||||
return '';
|
||||
else if ( this._mode === 0 )
|
||||
return color;
|
||||
|
||||
if ( color instanceof RGBAColor )
|
||||
color = color.toCSS();
|
||||
|
||||
if ( ! color )
|
||||
return null;
|
||||
|
||||
if ( this._cache.has(color) )
|
||||
return this._cache.get(color);
|
||||
|
||||
let rgb;
|
||||
|
||||
try {
|
||||
rgb = RGBAColor.fromCSS(color);
|
||||
} catch(err) {
|
||||
if ( throw_errors )
|
||||
throw err;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( this._mode === 1 ) {
|
||||
// HSL Luma
|
||||
const luma = rgb.luminance();
|
||||
|
||||
if ( this._dark ? luma < this._luma : luma > this._luma )
|
||||
rgb = rgb.toHSLA().targetLuminance(this._luma).toRGBA();
|
||||
|
||||
} else if ( this._mode === 2 ) {
|
||||
// LUV
|
||||
const luv = rgb.toLUVA();
|
||||
if ( this._dark ? luv.l < this._luv : luv.l > this._luv )
|
||||
rgb = luv._l(this._luv).toRGBA();
|
||||
|
||||
} else if ( this._mode === 3 ) {
|
||||
// HSL Loop (aka BTTV Style)
|
||||
if ( this._dark )
|
||||
while ( rgb.get_Y() < 0.5 ) {
|
||||
const hsl = rgb.toHSLA();
|
||||
rgb = hsl._l(Math.min(Math.max(0, 0.1 + 0.9 * hsl.l), 1)).toRGBA();
|
||||
}
|
||||
|
||||
else
|
||||
while ( rgb.get_Y() >= 0.5 ) {
|
||||
const hsl = rgb.toHSLA();
|
||||
rgb = hsl._l(Math.min(Math.max(0, 0.9 * hsl.l), 1)).toRGBA();
|
||||
}
|
||||
|
||||
} else if ( this._mode === 4 ) {
|
||||
// RGB Loop
|
||||
let i = 0;
|
||||
if ( this._dark )
|
||||
while ( rgb.luminance() < 0.15 && i++ < 127 )
|
||||
rgb = rgb.brighten();
|
||||
|
||||
else
|
||||
while ( rgb.luminance() > 0.3 && i++ < 127 )
|
||||
rgb = rgb.brighten(-1);
|
||||
}
|
||||
|
||||
const out = rgb.toCSS();
|
||||
this._cache.set(color, out);
|
||||
return out;
|
||||
}
|
||||
}
|
895
src/utilities/color.ts
Normal file
895
src/utilities/color.ts
Normal file
|
@ -0,0 +1,895 @@
|
|||
'use strict';
|
||||
|
||||
export type CVDMatrix = [number, number, number, number, number, number, number, number, number];
|
||||
|
||||
export function hue2rgb(p: number, q: number, t: number) {
|
||||
if ( t < 0 ) t += 1;
|
||||
if ( t > 1 ) t -= 1;
|
||||
if ( t < 1/6 )
|
||||
return p + (q-p) * 6 * t;
|
||||
if ( t < 1/2 )
|
||||
return q;
|
||||
if ( t < 2/3 )
|
||||
return p + (q-p) * (2/3 - t) * 6;
|
||||
return p;
|
||||
}
|
||||
|
||||
export function bit2linear(channel: number) {
|
||||
// http://www.brucelindbloom.com/Eqn_RGB_to_XYZ.html
|
||||
// This converts rgb 8bit to rgb linear, lazy because the other algorithm is really really dumb
|
||||
//return Math.pow(channel, 2.2);
|
||||
|
||||
// CSS Colors Level 4 says 0.03928, Bruce Lindbloom who cared to write all algos says 0.04045, used bruce because whynawt
|
||||
return (channel <= 0.04045) ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
export function linear2bit(channel: number) {
|
||||
// Using lazy conversion in the other direction as well
|
||||
//return Math.pow(channel, 1/2.2);
|
||||
|
||||
// I'm honestly not sure about 0.0031308, I've only seen it referenced on Bruce Lindbloom's site
|
||||
return (channel <= 0.0031308) ? channel * 12.92 : Math.pow(1.055 * channel, 1/2.4) - 0.055;
|
||||
}
|
||||
|
||||
|
||||
export interface BaseColor {
|
||||
eq(other: BaseColor | null | undefined, ignoreAlpha: boolean): boolean;
|
||||
|
||||
toCSS(): string;
|
||||
toHex(): string;
|
||||
|
||||
toRGBA(): RGBAColor;
|
||||
toHSVA(): HSVAColor;
|
||||
toHSLA(): HSLAColor;
|
||||
toXYZA(): XYZAColor;
|
||||
toLUVA(): LUVAColor;
|
||||
}
|
||||
|
||||
|
||||
class RGBAColor implements BaseColor {
|
||||
|
||||
readonly r: number;
|
||||
readonly g: number;
|
||||
readonly b: number;
|
||||
readonly a: number;
|
||||
|
||||
constructor(r: number, g: number, b: number, a?: number) {
|
||||
this.r = r || 0;
|
||||
this.g = g || 0;
|
||||
this.b = b || 0;
|
||||
this.a = a || 0;
|
||||
}
|
||||
|
||||
eq(other?: BaseColor | null, ignoreAlpha = false): boolean {
|
||||
if ( other instanceof RGBAColor )
|
||||
return this.r === other.r && this.g === other.g && this.b === other.b && (ignoreAlpha || this.a === other.a);
|
||||
return other ? this.eq(other.toRGBA(), ignoreAlpha) : false;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Updates
|
||||
// ========================================================================
|
||||
|
||||
_r(r: number) { return new RGBAColor(r, this.g, this.b, this.a); }
|
||||
_g(g: number) { return new RGBAColor(this.r, g, this.b, this.a); }
|
||||
_b(b: number) { return new RGBAColor(this.r, this.g, b, this.a); }
|
||||
_a(a: number) { return new RGBAColor(this.r, this.g, this.b, a); }
|
||||
|
||||
// ========================================================================
|
||||
// Conversion: to RGBA
|
||||
// ========================================================================
|
||||
|
||||
static fromName(name: string) {
|
||||
const ctx = Color.getContext();
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
ctx.fillStyle = name;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
const data = ctx.getImageData(0, 0, 1, 1);
|
||||
if ( data?.data?.length !== 4 )
|
||||
return null;
|
||||
|
||||
return new RGBAColor(data.data[0], data.data[1], data.data[2], data.data[3]);
|
||||
}
|
||||
|
||||
static fromCSS(input: string) {
|
||||
input = input && input.trim();
|
||||
if ( ! input?.length )
|
||||
return null;
|
||||
|
||||
if ( input.charAt(0) === '#' )
|
||||
return RGBAColor.fromHex(input);
|
||||
|
||||
// fillStyle can handle rgba() inputs
|
||||
/*const match = /rgba?\( *(\d+%?) *, *(\d+%?) *, *(\d+%?) *(?:, *([\d.]+))?\)/i.exec(input);
|
||||
if ( match ) {
|
||||
let r: number, g: number, b: number, a: number;
|
||||
let rS = match[1],
|
||||
gS = match[2],
|
||||
bS = match[3],
|
||||
aS = match[4];
|
||||
|
||||
if ( rS.charAt(rS.length-1) === '%' )
|
||||
r = 255 * (parseInt(rS,10) / 100);
|
||||
else
|
||||
r = parseInt(rS,10);
|
||||
|
||||
if ( gS.charAt(gS.length-1) === '%' )
|
||||
g = 255 * (parseInt(gS,10) / 100);
|
||||
else
|
||||
g = parseInt(gS,10);
|
||||
|
||||
if ( bS.charAt(bS.length-1) === '%' )
|
||||
b = 255 * (parseInt(bS,10) / 100);
|
||||
else
|
||||
b = parseInt(bS,10);
|
||||
|
||||
if ( aS )
|
||||
if ( aS.charAt(aS.length-1) === '%' )
|
||||
a = parseInt(aS,10) / 100;
|
||||
else
|
||||
a = parseFloat(aS);
|
||||
else
|
||||
a = 1;
|
||||
|
||||
return new RGBAColorA(
|
||||
Math.min(Math.max(0, r), 255),
|
||||
Math.min(Math.max(0, g), 255),
|
||||
Math.min(Math.max(0, b), 255),
|
||||
Math.min(Math.max(0, a), 1)
|
||||
);
|
||||
}*/
|
||||
|
||||
return RGBAColor.fromName(input);
|
||||
}
|
||||
|
||||
static fromHex(input: string) {
|
||||
if ( input.charAt(0) === '#' )
|
||||
input = input.slice(1);
|
||||
|
||||
let raw: number;
|
||||
let alpha: number = 255;
|
||||
|
||||
if ( input.length === 4 ) {
|
||||
alpha = parseInt(input[3], 16) * 17;
|
||||
input = input.slice(0, 3);
|
||||
} else if ( input.length === 8 ) {
|
||||
alpha = parseInt(input.slice(6), 16);
|
||||
input = input.slice(0, 6);
|
||||
}
|
||||
|
||||
if ( input.length === 3 )
|
||||
raw =
|
||||
((parseInt(input[0], 16) * 17) << 16) +
|
||||
((parseInt(input[1], 16) * 17) << 8) +
|
||||
parseInt(input[2], 16) * 17;
|
||||
|
||||
else
|
||||
raw = parseInt(input, 16);
|
||||
|
||||
return new RGBAColor(
|
||||
(raw >> 16), // Red
|
||||
(raw >> 8 & 0x00FF), // Green
|
||||
(raw & 0xFF), // Blue
|
||||
alpha / 255 // Alpha (scaled from 0 to 1)
|
||||
);
|
||||
}
|
||||
|
||||
static fromHSVA(h: number, s: number, v: number, a?: number) {
|
||||
let r: number, g: number, b: number;
|
||||
|
||||
const i = Math.floor(h * 6),
|
||||
f = h * 6 - i,
|
||||
p = v * (1 - s),
|
||||
q = v * (1 - f * s),
|
||||
t = v * (1 - (1 - f) * s);
|
||||
|
||||
switch(i % 6) {
|
||||
case 0: r = v; g = t; b = p; break;
|
||||
case 1: r = q; g = v; b = p; break;
|
||||
case 2: r = p; g = v; b = t; break;
|
||||
case 3: r = p; g = q; b = v; break;
|
||||
case 4: r = t; g = p; b = v; break;
|
||||
default: // case 5:
|
||||
r = v; g = p; b = q;
|
||||
}
|
||||
|
||||
return new RGBAColor(
|
||||
Math.round(Math.min(Math.max(0, r*255), 255)),
|
||||
Math.round(Math.min(Math.max(0, g*255), 255)),
|
||||
Math.round(Math.min(Math.max(0, b*255), 255)),
|
||||
a === undefined ? 1 : a
|
||||
);
|
||||
}
|
||||
|
||||
static fromHSLA(h: number, s: number, l: number, a?: number) {
|
||||
if ( s === 0 ) {
|
||||
const v = Math.round(Math.min(Math.max(0, 255*l), 255));
|
||||
return new RGBAColor(v, v, v, a === undefined ? 1 : a);
|
||||
}
|
||||
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s,
|
||||
p = 2 * l - q;
|
||||
|
||||
return new RGBAColor(
|
||||
Math.round(Math.min(Math.max(0, 255 * hue2rgb(p, q, h + 1/3)), 255)),
|
||||
Math.round(Math.min(Math.max(0, 255 * hue2rgb(p, q, h)), 255)),
|
||||
Math.round(Math.min(Math.max(0, 255 * hue2rgb(p, q, h - 1/3)), 255)),
|
||||
a === undefined ? 1 : a
|
||||
);
|
||||
}
|
||||
|
||||
static fromXYZA(x: number, y: number, z: number, a?: number) {
|
||||
const R = 3.240479 * x - 1.537150 * y - 0.498535 * z,
|
||||
G = -0.969256 * x + 1.875992 * y + 0.041556 * z,
|
||||
B = 0.055648 * x - 0.204043 * y + 1.057311 * z;
|
||||
|
||||
// Make sure we end up in a real color space
|
||||
return new RGBAColor(
|
||||
Math.max(0, Math.min(255, 255 * linear2bit(R))),
|
||||
Math.max(0, Math.min(255, 255 * linear2bit(G))),
|
||||
Math.max(0, Math.min(255, 255 * linear2bit(B))),
|
||||
a === undefined ? 1 : a
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Conversion: from RGBA
|
||||
// ========================================================================
|
||||
|
||||
// CSS
|
||||
toCSS() {
|
||||
if ( this.a !== 1 )
|
||||
return `rgba(${this.r},${this.g},${this.b},${this.a})`;
|
||||
return this.toHex();
|
||||
}
|
||||
|
||||
toHex() {
|
||||
const value = (this.r << 16) + (this.g << 8) + this.b;
|
||||
return `#${value.toString(16).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
// Color Spaces
|
||||
toRGBA() { return this; }
|
||||
toHSVA() { return HSVAColor.fromRGBA(this.r, this.g, this.b, this.a); }
|
||||
toHSLA() { return HSLAColor.fromRGBA(this.r, this.g, this.b, this.a); }
|
||||
toXYZA() { return XYZAColor.fromRGBA(this.r, this.g, this.b, this.a); }
|
||||
toLUVA() { return this.toXYZA().toLUVA(); }
|
||||
|
||||
// ========================================================================
|
||||
// Processing
|
||||
// ========================================================================
|
||||
|
||||
get_Y() {
|
||||
return ((0.299 * this.r) + ( 0.587 * this.g) + ( 0.114 * this.b)) / 255;
|
||||
}
|
||||
|
||||
luminance() {
|
||||
const r = bit2linear(this.r / 255),
|
||||
g = bit2linear(this.g / 255),
|
||||
b = bit2linear(this.b / 255);
|
||||
|
||||
return (0.2126 * r) + (0.7152 * g) + (0.0722 * b);
|
||||
}
|
||||
|
||||
/** @deprecated This is a horrible function. */
|
||||
brighten(amount?: number) {
|
||||
amount = typeof amount === `number` ? amount : 1;
|
||||
amount = Math.round(255 * (amount / 100));
|
||||
|
||||
return new RGBAColor(
|
||||
Math.max(0, Math.min(255, this.r + amount)),
|
||||
Math.max(0, Math.min(255, this.g + amount)),
|
||||
Math.max(0, Math.min(255, this.b + amount)),
|
||||
this.a
|
||||
);
|
||||
}
|
||||
|
||||
daltonize(type: string | CVDMatrix) {
|
||||
let cvd: CVDMatrix;
|
||||
if ( typeof type === 'string' ) {
|
||||
if ( Color.CVDMatrix.hasOwnProperty(type) )
|
||||
cvd = Color.CVDMatrix[type];
|
||||
else
|
||||
throw new Error('Invalid CVD matrix');
|
||||
} else
|
||||
cvd = type;
|
||||
|
||||
const cvd_a = cvd[0], cvd_b = cvd[1], cvd_c = cvd[2],
|
||||
cvd_d = cvd[3], cvd_e = cvd[4], cvd_f = cvd[5],
|
||||
cvd_g = cvd[6], cvd_h = cvd[7], cvd_i = cvd[8];
|
||||
|
||||
//let L, M, S, l, m, s, R, G, B, RR, GG, BB;
|
||||
|
||||
// RGB to LMS matrix conversion
|
||||
const L = (17.8824 * this.r) + (43.5161 * this.g) + (4.11935 * this.b),
|
||||
M = (3.45565 * this.r) + (27.1554 * this.g) + (3.86714 * this.b),
|
||||
S = (0.0299566 * this.r) + (0.184309 * this.g) + (1.46709 * this.b);
|
||||
|
||||
// Simulate color blindness
|
||||
const l = (cvd_a * L) + (cvd_b * M) + (cvd_c * S),
|
||||
m = (cvd_d * L) + (cvd_e * M) + (cvd_f * S),
|
||||
s = (cvd_g * L) + (cvd_h * M) + (cvd_i * S);
|
||||
|
||||
// LMS to RGB matrix conversion
|
||||
let R = (0.0809444479 * l) + (-0.130504409 * m) + (0.116721066 * s),
|
||||
G = (-0.0102485335 * l) + (0.0540193266 * m) + (-0.113614708 * s),
|
||||
B = (-0.000365296938 * l) + (-0.00412161469 * m) + (0.693511405 * s);
|
||||
|
||||
// Isolate invisible colors to color vision deficiency (calculate error matrix)
|
||||
R = this.r - R;
|
||||
G = this.g - G;
|
||||
B = this.b - B;
|
||||
|
||||
// Shift colors towards visible spectrum (apply error modifications)
|
||||
const RR = (0.0 * R) + (0.0 * G) + (0.0 * B),
|
||||
GG = (0.7 * R) + (1.0 * G) + (0.0 * B),
|
||||
BB = (0.7 * R) + (0.0 * G) + (1.0 * B);
|
||||
|
||||
// Add compensation to original values
|
||||
R = Math.min(Math.max(0, RR + this.r), 255);
|
||||
G = Math.min(Math.max(0, GG + this.g), 255);
|
||||
B = Math.min(Math.max(0, BB + this.b), 255);
|
||||
|
||||
return new RGBAColor(R, G, B, this.a);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class HSVAColor implements BaseColor {
|
||||
|
||||
readonly h: number;
|
||||
readonly s: number;
|
||||
readonly v: number;
|
||||
readonly a: number;
|
||||
|
||||
constructor(h: number, s: number, v: number, a?: number) {
|
||||
this.h = h || 0;
|
||||
this.s = s || 0;
|
||||
this.v = v || 0;
|
||||
this.a = a || 0;
|
||||
}
|
||||
|
||||
eq(other?: BaseColor | null, ignoreAlpha = false): boolean {
|
||||
if ( other instanceof HSVAColor )
|
||||
return this.h === other.h && this.s === other.s && this.v === other.v && (ignoreAlpha || this.a === other.a);
|
||||
return other ? this.eq(other.toHSVA(), ignoreAlpha) : false;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Updates
|
||||
// ========================================================================
|
||||
|
||||
_h(h: number) { return new HSVAColor(h, this.s, this.v, this.a); }
|
||||
_s(s: number) { return new HSVAColor(this.h, s, this.v, this.a); }
|
||||
_v(v: number) { return new HSVAColor(this.h, this.s, v, this.a); }
|
||||
_a(a: number) { return new HSVAColor(this.h, this.s, this.v, a); }
|
||||
|
||||
// ========================================================================
|
||||
// Conversion: to HSVA
|
||||
// ========================================================================
|
||||
|
||||
static fromRGBA(r: number, g: number, b: number, a?: number) {
|
||||
r /= 255; g /= 255; b /= 255;
|
||||
|
||||
const max = Math.max(r, g, b),
|
||||
min = Math.min(r, g, b),
|
||||
d = Math.min(Math.max(0, max - min), 1),
|
||||
|
||||
s = max === 0 ? 0 : d / max,
|
||||
v = max;
|
||||
|
||||
let h;
|
||||
|
||||
if ( d === 0 )
|
||||
h = 0;
|
||||
else {
|
||||
switch(max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
default: // case b:
|
||||
h = (r - g) / d + 4;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return new HSVAColor(
|
||||
h,
|
||||
s,
|
||||
v,
|
||||
a === undefined ? 1 : a
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Conversion: from HSVA
|
||||
// ========================================================================
|
||||
|
||||
toCSS() { return this.toRGBA().toCSS(); }
|
||||
toHex() { return this.toRGBA().toHex(); }
|
||||
|
||||
toRGBA() { return RGBAColor.fromHSVA(this.h, this.s, this.v, this.a); }
|
||||
toHSVA() { return this; }
|
||||
toHSLA() { return this.toRGBA().toHSLA(); }
|
||||
toXYZA() { return this.toRGBA().toXYZA(); }
|
||||
toLUVA() { return this.toRGBA().toLUVA(); }
|
||||
|
||||
}
|
||||
|
||||
|
||||
class HSLAColor implements BaseColor {
|
||||
|
||||
readonly h: number;
|
||||
readonly s: number;
|
||||
readonly l: number;
|
||||
readonly a: number;
|
||||
|
||||
constructor(h: number, s: number, l: number, a?: number) {
|
||||
this.h = h || 0;
|
||||
this.s = s || 0;
|
||||
this.l = l || 0;
|
||||
this.a = a || 0;
|
||||
}
|
||||
|
||||
eq(other?: BaseColor | null, ignoreAlpha = false): boolean {
|
||||
if ( other instanceof HSLAColor )
|
||||
return this.h === other.h && this.s === other.s && this.l === other.l && (ignoreAlpha || this.a === other.a);
|
||||
return other ? this.eq(other.toHSLA(), ignoreAlpha) : false;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Updates
|
||||
// ========================================================================
|
||||
|
||||
_h(h: number) { return new HSLAColor(h, this.s, this.l, this.a); }
|
||||
_s(s: number) { return new HSLAColor(this.h, s, this.l, this.a); }
|
||||
_l(l: number) { return new HSLAColor(this.h, this.s, l, this.a); }
|
||||
_a(a: number) { return new HSLAColor(this.h, this.s, this.l, a); }
|
||||
|
||||
// ========================================================================
|
||||
// Conversion: to HSLA
|
||||
// ========================================================================
|
||||
|
||||
static fromRGBA(r: number, g: number, b: number, a?: number) {
|
||||
r /= 255; g /= 255; b /= 255;
|
||||
|
||||
const max = Math.max(r,g,b),
|
||||
min = Math.min(r,g,b),
|
||||
|
||||
l = Math.min(Math.max(0, (max+min) / 2), 1),
|
||||
d = Math.min(Math.max(0, max - min), 1);
|
||||
|
||||
let h, s;
|
||||
|
||||
if ( d === 0 )
|
||||
h = s = 0;
|
||||
else {
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch(max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
default: //case b:
|
||||
h = (r - g) / d + 4;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return new HSLAColor(h, s, l, a === undefined ? 1 : a);
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Conversion: from HSLA
|
||||
// ========================================================================
|
||||
|
||||
toCSS() {
|
||||
const a = this.a;
|
||||
return `hsl${a !== 1 ? 'a' : ''}(${Math.round(this.h*360)},${Math.round(this.s*100)}%,${Math.round(this.l*100)}%${a !== 1 ? `,${this.a}` : ''})`;
|
||||
}
|
||||
|
||||
toHex() { return this.toRGBA().toHex(); }
|
||||
|
||||
toRGBA() { return RGBAColor.fromHSLA(this.h, this.s, this.l, this.a); }
|
||||
toHSLA() { return this; }
|
||||
toHSVA() { return this.toRGBA().toHSVA(); }
|
||||
toXYZA() { return this.toRGBA().toXYZA(); }
|
||||
toLUVA() { return this.toRGBA().toLUVA(); }
|
||||
|
||||
// ========================================================================
|
||||
// Processing
|
||||
// ========================================================================
|
||||
|
||||
targetLuminance(target: number) {
|
||||
let s = this.s,
|
||||
min = 0,
|
||||
max = 1;
|
||||
|
||||
s *= Math.pow(this.l > 0.5 ? -this.l : this.l - 1, 7) + 1;
|
||||
|
||||
let d = (max - min) / 2,
|
||||
mid = min + d;
|
||||
|
||||
for (; d > 1/65536; d /= 2, mid = min + d) {
|
||||
const luminance = RGBAColor.fromHSLA(this.h, s, mid, 1).luminance();
|
||||
if (luminance > target) {
|
||||
max = mid;
|
||||
} else {
|
||||
min = mid;
|
||||
}
|
||||
}
|
||||
|
||||
return new HSLAColor(this.h, s, mid, this.a);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
class XYZAColor implements BaseColor {
|
||||
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly z: number;
|
||||
readonly a: number;
|
||||
|
||||
constructor(x: number, y: number, z: number, a?: number) {
|
||||
this.x = x || 0;
|
||||
this.y = y || 0;
|
||||
this.z = z || 0;
|
||||
this.a = a || 0;
|
||||
}
|
||||
|
||||
eq(other?: BaseColor, ignoreAlpha = false): boolean {
|
||||
if ( other instanceof XYZAColor )
|
||||
return this.x === other.x && this.y === other.y && this.z === other.z && (ignoreAlpha || this.a === other.a);
|
||||
return other ? this.eq(other.toXYZA(), ignoreAlpha) : false;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Updates
|
||||
// ========================================================================
|
||||
|
||||
_x(x: number) { return new XYZAColor(x, this.y, this.z, this.a); }
|
||||
_y(y: number) { return new XYZAColor(this.x, y, this.z, this.a); }
|
||||
_z(z: number) { return new XYZAColor(this.x, this.y, z, this.a); }
|
||||
_a(a: number) { return new XYZAColor(this.x, this.y, this.z, a); }
|
||||
|
||||
// ========================================================================
|
||||
// Conversion: to XYZA
|
||||
// ========================================================================
|
||||
|
||||
static EPSILON = Math.pow(6 / 29, 3);
|
||||
static KAPPA = Math.pow(29 / 3, 3);
|
||||
static WHITE = null as any; // Gotta do this late to avoid an error.
|
||||
|
||||
static fromRGBA(r: number, g: number, b: number, a?: number) {
|
||||
const R = bit2linear(r / 255),
|
||||
G = bit2linear(g / 255),
|
||||
B = bit2linear(b / 255);
|
||||
|
||||
return new XYZAColor(
|
||||
0.412453 * R + 0.357580 * G + 0.180423 * B,
|
||||
0.212671 * R + 0.715160 * G + 0.072169 * B,
|
||||
0.019334 * R + 0.119193 * G + 0.950227 * B,
|
||||
a === undefined ? 1 : a
|
||||
);
|
||||
}
|
||||
|
||||
static fromLUVA(l: number, u: number, v: number, alpha?: number) {
|
||||
const deltaGammaFactor = 1 / (XYZAColor.WHITE.x + 15 * XYZAColor.WHITE.y + 3 * XYZAColor.WHITE.z),
|
||||
uDeltaGamma = 4 * XYZAColor.WHITE.x * deltaGammaFactor,
|
||||
vDeltagamma = 9 * XYZAColor.WHITE.y * deltaGammaFactor;
|
||||
|
||||
// XYZAColor.EPSILON * XYZAColor.KAPPA = 8
|
||||
const Y = (l > 8) ? Math.pow((l + 16) / 116, 3) : l / XYZAColor.KAPPA,
|
||||
a = 1/3 * (((52 * l) / (u + 13 * l * uDeltaGamma)) - 1),
|
||||
b = -5 * Y,
|
||||
c = -1/3,
|
||||
d = Y * (((39 * l) / (v + 13 * l * vDeltagamma)) - 5),
|
||||
|
||||
X = (d - b) / (a - c),
|
||||
Z = X * a + b;
|
||||
|
||||
return new XYZAColor(X, Y, Z, alpha === undefined ? 1 : alpha);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Conversion: from XYZA
|
||||
// ========================================================================
|
||||
|
||||
toCSS() { return this.toRGBA().toCSS(); }
|
||||
toHex() { return this.toRGBA().toHex(); }
|
||||
|
||||
toRGBA() { return RGBAColor.fromXYZA(this.x, this.y, this.z, this.a); }
|
||||
toHSLA() { return this.toRGBA().toHSLA(); }
|
||||
toHSVA() { return this.toRGBA().toHSVA(); }
|
||||
toXYZA() { return this; }
|
||||
toLUVA() { return LUVAColor.fromXYZA(this.x, this.y, this.z, this.a); }
|
||||
}
|
||||
|
||||
// Assign this now that XYZAColor exists.
|
||||
XYZAColor.WHITE = new RGBAColor(255,255,255,1).toXYZA();
|
||||
|
||||
|
||||
class LUVAColor implements BaseColor {
|
||||
|
||||
readonly l: number;
|
||||
readonly u: number;
|
||||
readonly v: number;
|
||||
readonly a: number;
|
||||
|
||||
constructor(l: number, u: number, v: number, a?: number) {
|
||||
this.l = l || 0;
|
||||
this.u = u || 0;
|
||||
this.v = v || 0;
|
||||
this.a = a || 0;
|
||||
}
|
||||
|
||||
eq(other?: BaseColor | null, ignoreAlpha = false): boolean {
|
||||
if ( other instanceof LUVAColor )
|
||||
return this.l === other.l && this.u === other.u && this.v === other.v && (ignoreAlpha || this.a === other.a);
|
||||
return other ? this.eq(other.toLUVA(), ignoreAlpha) : false;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Updates
|
||||
// ========================================================================
|
||||
|
||||
_l(l: number) { return new LUVAColor(l, this.u, this.v, this.a); }
|
||||
_u(u: number) { return new LUVAColor(this.l, u, this.v, this.a); }
|
||||
_v(v: number) { return new LUVAColor(this.l, this.u, v, this.a); }
|
||||
_a(a: number) { return new LUVAColor(this.l, this.u, this.v, a); }
|
||||
|
||||
// ========================================================================
|
||||
// Conversion: to LUVA
|
||||
// ========================================================================
|
||||
|
||||
static fromXYZA(X: number, Y: number, Z: number, a?: number) {
|
||||
const deltaGammaFactor = 1 / (XYZAColor.WHITE.x + 15 * XYZAColor.WHITE.y + 3 * XYZAColor.WHITE.z),
|
||||
uDeltaGamma = 4 * XYZAColor.WHITE.x * deltaGammaFactor,
|
||||
vDeltagamma = 9 * XYZAColor.WHITE.y * deltaGammaFactor,
|
||||
|
||||
yGamma = Y / XYZAColor.WHITE.y;
|
||||
|
||||
let deltaDivider = (X + 15 * Y + 3 * Z);
|
||||
if (deltaDivider === 0) {
|
||||
deltaDivider = 1;
|
||||
}
|
||||
|
||||
const deltaFactor = 1 / deltaDivider,
|
||||
|
||||
uDelta = 4 * X * deltaFactor,
|
||||
vDelta = 9 * Y * deltaFactor,
|
||||
|
||||
L = (yGamma > XYZAColor.EPSILON) ? 116 * Math.pow(yGamma, 1/3) - 16 : XYZAColor.KAPPA * yGamma,
|
||||
u = 13 * L * (uDelta - uDeltaGamma),
|
||||
v = 13 * L * (vDelta - vDeltagamma);
|
||||
|
||||
return new LUVAColor(L, u, v, a === undefined ? 1 : a);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Conversion: from LUVA
|
||||
// ========================================================================
|
||||
|
||||
toCSS() { return this.toRGBA().toCSS(); }
|
||||
toHex() { return this.toRGBA().toHex(); }
|
||||
|
||||
toRGBA() { return this.toXYZA().toRGBA(); }
|
||||
toHSLA() { return this.toRGBA().toHSLA(); }
|
||||
toHSVA() { return this.toRGBA().toHSVA(); }
|
||||
toXYZA() { return XYZAColor.fromLUVA(this.l, this.u, this.v, this.a); }
|
||||
toLUVA() { return this;}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
type ColorType = {
|
||||
_canvas?: HTMLCanvasElement;
|
||||
_context?: CanvasRenderingContext2D;
|
||||
|
||||
getCanvas(): HTMLCanvasElement;
|
||||
getContext(): CanvasRenderingContext2D
|
||||
|
||||
CVDMatrix: Record<string, CVDMatrix>;
|
||||
|
||||
RGBA: typeof RGBAColor;
|
||||
HSVA: typeof HSVAColor;
|
||||
HSLA: typeof HSLAColor;
|
||||
XYZA: typeof XYZAColor;
|
||||
LUVA: typeof LUVAColor;
|
||||
|
||||
fromCSS(input: string): RGBAColor | null;
|
||||
}
|
||||
|
||||
|
||||
export const Color: ColorType = {
|
||||
CVDMatrix: {
|
||||
protanope: [ // reds are greatly reduced (1% men)
|
||||
0.0, 2.02344, -2.52581,
|
||||
0.0, 1.0, 0.0,
|
||||
0.0, 0.0, 1.0
|
||||
],
|
||||
deuteranope: [ // greens are greatly reduced (1% men)
|
||||
1.0, 0.0, 0.0,
|
||||
0.494207, 0.0, 1.24827,
|
||||
0.0, 0.0, 1.0
|
||||
],
|
||||
tritanope: [ // blues are greatly reduced (0.003% population)
|
||||
1.0, 0.0, 0.0,
|
||||
0.0, 1.0, 0.0,
|
||||
-0.395913, 0.801109, 0.0
|
||||
]
|
||||
},
|
||||
|
||||
getCanvas() {
|
||||
if ( ! Color._canvas )
|
||||
Color._canvas = document.createElement('canvas');
|
||||
return Color._canvas;
|
||||
},
|
||||
getContext: () => {
|
||||
if ( ! Color._context )
|
||||
Color._context = Color.getCanvas().getContext('2d') as CanvasRenderingContext2D;
|
||||
return Color._context;
|
||||
},
|
||||
|
||||
RGBA: RGBAColor,
|
||||
HSVA: HSVAColor,
|
||||
HSLA: HSLAColor,
|
||||
XYZA: XYZAColor,
|
||||
LUVA: LUVAColor,
|
||||
|
||||
fromCSS(input: string) {
|
||||
return RGBAColor.fromCSS(input);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
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;
|
||||
this._mode = mode;
|
||||
|
||||
this.rebuildContrast();
|
||||
}
|
||||
|
||||
get contrast() { return this._contrast }
|
||||
set contrast(val) { this._contrast = val; this.rebuildContrast() }
|
||||
|
||||
get base() { return this._base }
|
||||
set base(val) { this._base = val; this.rebuildContrast() }
|
||||
|
||||
get dark() { return this._dark }
|
||||
|
||||
get mode() { return this._mode }
|
||||
set mode(val) { this._mode = val; this.rebuildContrast() }
|
||||
|
||||
|
||||
rebuildContrast() {
|
||||
this._cache = new Map;
|
||||
|
||||
const base = RGBAColor.fromCSS(this._base);
|
||||
if ( ! base )
|
||||
throw new Error('Invalid base color');
|
||||
|
||||
const lum = base.luminance(),
|
||||
dark = this._dark = lum < 0.5;
|
||||
|
||||
if ( dark ) {
|
||||
this._luv = new XYZAColor(
|
||||
0,
|
||||
(this._contrast * (base.toXYZA().y + 0.05) - 0.05),
|
||||
0,
|
||||
1
|
||||
).toLUVA().l;
|
||||
|
||||
this._luma = this._contrast * (base.luminance() + 0.05) - 0.05;
|
||||
|
||||
} else {
|
||||
this._luv = new XYZAColor(
|
||||
0,
|
||||
(base.toXYZA().y + 0.05) / this._contrast - 0.05,
|
||||
0,
|
||||
1
|
||||
).toLUVA().l;
|
||||
|
||||
this._luma = (base.luminance() + 0.05) / this._contrast - 0.05;
|
||||
}
|
||||
}
|
||||
|
||||
process(color: BaseColor | string, throw_errors = false) {
|
||||
if ( this._mode === -1 )
|
||||
return '';
|
||||
|
||||
else if ( this._mode === 0 )
|
||||
return color;
|
||||
|
||||
if ( typeof color !== 'string' )
|
||||
color = color.toCSS();
|
||||
|
||||
if ( ! color )
|
||||
return null;
|
||||
|
||||
if ( this._cache.has(color) )
|
||||
return this._cache.get(color);
|
||||
|
||||
let rgb;
|
||||
|
||||
try {
|
||||
rgb = RGBAColor.fromCSS(color);
|
||||
} catch(err) {
|
||||
if ( throw_errors )
|
||||
throw err;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( ! rgb )
|
||||
return null;
|
||||
|
||||
if ( this._mode === 1 ) {
|
||||
// HSL Luma
|
||||
const luma = rgb.luminance();
|
||||
|
||||
if ( this._dark ? luma < this._luma : luma > this._luma )
|
||||
rgb = rgb.toHSLA().targetLuminance(this._luma).toRGBA();
|
||||
|
||||
} else if ( this._mode === 2 ) {
|
||||
// LUV
|
||||
const luv = rgb.toLUVA();
|
||||
if ( this._dark ? luv.l < this._luv : luv.l > this._luv )
|
||||
rgb = luv._l(this._luv).toRGBA();
|
||||
|
||||
} else if ( this._mode === 3 ) {
|
||||
// HSL Loop (aka BTTV Style)
|
||||
if ( this._dark )
|
||||
while ( rgb.get_Y() < 0.5 ) {
|
||||
const hsl = rgb.toHSLA();
|
||||
rgb = hsl._l(Math.min(Math.max(0, 0.1 + 0.9 * hsl.l), 1)).toRGBA();
|
||||
}
|
||||
|
||||
else
|
||||
while ( rgb.get_Y() >= 0.5 ) {
|
||||
const hsl = rgb.toHSLA();
|
||||
rgb = hsl._l(Math.min(Math.max(0, 0.9 * hsl.l), 1)).toRGBA();
|
||||
}
|
||||
|
||||
} else if ( this._mode === 4 ) {
|
||||
// RGB Loop
|
||||
let i = 0;
|
||||
if ( this._dark )
|
||||
while ( rgb.luminance() < 0.15 && i++ < 127 )
|
||||
rgb = rgb.brighten();
|
||||
|
||||
else
|
||||
while ( rgb.luminance() > 0.3 && i++ < 127 )
|
||||
rgb = rgb.brighten(-1);
|
||||
}
|
||||
|
||||
const out = rgb.toCSS();
|
||||
this._cache.set(color, out);
|
||||
return out;
|
||||
}
|
||||
}
|
|
@ -6,11 +6,28 @@
|
|||
// ============================================================================
|
||||
|
||||
import {EventEmitter} from 'utilities/events';
|
||||
import Module from 'utilities/module';
|
||||
import Module, { GenericModule } from 'utilities/module';
|
||||
|
||||
export default class Elemental extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
declare module 'utilities/types' {
|
||||
interface ModuleMap {
|
||||
'site.elemental': Elemental;
|
||||
}
|
||||
}
|
||||
|
||||
export default class Elemental extends Module<'site.elemental'> {
|
||||
|
||||
private _wrappers: Map<string, ElementalWrapper<any>>;
|
||||
private _observer: MutationObserver | null;
|
||||
private _watching: Set<ElementalWrapper<any>>;
|
||||
private _live_watching: ElementalWrapper<any>[] | null;
|
||||
private _route?: string | null;
|
||||
private _timer?: number | null;
|
||||
|
||||
private _timeout?: ReturnType<typeof setTimeout> | null;
|
||||
private _clean_all?: ReturnType<typeof requestAnimationFrame> | null;
|
||||
|
||||
constructor(name?: string, parent?: GenericModule) {
|
||||
super(name, parent);
|
||||
|
||||
this._pruneLive = this._pruneLive.bind(this);
|
||||
|
||||
|
@ -21,27 +38,35 @@ export default class Elemental extends Module {
|
|||
this._live_watching = null;
|
||||
}
|
||||
|
||||
|
||||
/** @internal */
|
||||
onDisable() {
|
||||
this._stopWatching();
|
||||
}
|
||||
|
||||
|
||||
define(key, selector, routes, opts = null, limit = 0, timeout = 5000, remove = true) {
|
||||
define<TElement extends HTMLElement = HTMLElement>(
|
||||
key: string,
|
||||
selector: string,
|
||||
routes?: string[] | false | null,
|
||||
opts: MutationObserverInit | null = null,
|
||||
limit = 0,
|
||||
timeout = 5000,
|
||||
remove = true
|
||||
) {
|
||||
if ( this._wrappers.has(key) )
|
||||
return this._wrappers.get(key);
|
||||
return this._wrappers.get(key) as ElementalWrapper<TElement>;
|
||||
|
||||
if ( ! selector || typeof selector !== 'string' || ! selector.length )
|
||||
throw new Error('cannot find definition and no selector provided');
|
||||
|
||||
const wrapper = new ElementalWrapper(key, selector, routes, opts, limit, timeout, remove, this);
|
||||
const wrapper = new ElementalWrapper<TElement>(key, selector, routes, opts, limit, timeout, remove, this);
|
||||
this._wrappers.set(key, wrapper);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
|
||||
route(route) {
|
||||
route(route: string | null) {
|
||||
this._route = route;
|
||||
this._timer = Date.now();
|
||||
this._updateLiveWatching();
|
||||
|
@ -76,24 +101,27 @@ export default class Elemental extends Module {
|
|||
}
|
||||
|
||||
|
||||
_isActive(watcher, now) {
|
||||
private _isActive(watcher: ElementalWrapper<any>, now: number) {
|
||||
if ( watcher.routes === false )
|
||||
return false;
|
||||
|
||||
if ( this._route && watcher.routes.length && ! watcher.routes.includes(this._route) )
|
||||
return false;
|
||||
|
||||
if ( watcher.timeout > 0 && (now - this._timer) > watcher.timeout )
|
||||
if ( watcher.timeout > 0 && (now - (this._timer as number)) > watcher.timeout )
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
_updateLiveWatching() {
|
||||
private _updateLiveWatching() {
|
||||
if ( this._timeout ) {
|
||||
clearTimeout(this._timeout);
|
||||
this._timeout = null;
|
||||
}
|
||||
|
||||
const lw = this._live_watching = [],
|
||||
const lw: ElementalWrapper<any>[] = this._live_watching = [],
|
||||
now = Date.now();
|
||||
let min_timeout = Number.POSITIVE_INFINITY;
|
||||
|
||||
|
@ -115,16 +143,17 @@ export default class Elemental extends Module {
|
|||
this._startWatching();
|
||||
}
|
||||
|
||||
_pruneLive() {
|
||||
private _pruneLive() {
|
||||
this._updateLiveWatching();
|
||||
}
|
||||
|
||||
_checkWatchers(muts) {
|
||||
private _checkWatchers(muts: Node[]) {
|
||||
if ( this._live_watching )
|
||||
for(const watcher of this._live_watching)
|
||||
watcher.checkElements(muts);
|
||||
watcher.checkElements(muts as Element[]);
|
||||
}
|
||||
|
||||
_startWatching() {
|
||||
private _startWatching() {
|
||||
if ( ! this._observer && this._live_watching && this._live_watching.length ) {
|
||||
this.log.info('Installing MutationObserver.');
|
||||
|
||||
|
@ -136,7 +165,7 @@ export default class Elemental extends Module {
|
|||
}
|
||||
}
|
||||
|
||||
_stopWatching() {
|
||||
private _stopWatching() {
|
||||
if ( this._observer ) {
|
||||
this.log.info('Stopping MutationObserver.');
|
||||
this._observer.disconnect();
|
||||
|
@ -152,7 +181,7 @@ export default class Elemental extends Module {
|
|||
}
|
||||
|
||||
|
||||
listen(inst, ensure_live = true) {
|
||||
listen(inst: ElementalWrapper<any>, ensure_live = true) {
|
||||
if ( this._watching.has(inst) )
|
||||
return;
|
||||
|
||||
|
@ -163,7 +192,7 @@ export default class Elemental extends Module {
|
|||
this._updateLiveWatching();
|
||||
}
|
||||
|
||||
unlisten(inst) {
|
||||
unlisten(inst: ElementalWrapper<any>) {
|
||||
if ( ! this._watching.has(inst) )
|
||||
return;
|
||||
|
||||
|
@ -175,20 +204,64 @@ export default class Elemental extends Module {
|
|||
|
||||
let elemental_id = 0;
|
||||
|
||||
export class ElementalWrapper extends EventEmitter {
|
||||
constructor(name, selector, routes, opts, limit, timeout, remove, elemental) {
|
||||
type ElementalParam = `_ffz$elemental$${number}`;
|
||||
type ElementalRemoveParam = `_ffz$elemental_remove$${number}`;
|
||||
|
||||
declare global {
|
||||
interface HTMLElement {
|
||||
[key: ElementalParam]: MutationObserver | null;
|
||||
[key: ElementalRemoveParam]: MutationObserver | null;
|
||||
}
|
||||
}
|
||||
|
||||
type ElementalWrapperEvents<TElement extends HTMLElement> = {
|
||||
mount: [element: TElement];
|
||||
unmount: [element: TElement];
|
||||
mutate: [element: TElement, mutations: MutationRecord[]];
|
||||
}
|
||||
|
||||
export class ElementalWrapper<
|
||||
TElement extends HTMLElement = HTMLElement
|
||||
> extends EventEmitter<ElementalWrapperEvents<TElement>> {
|
||||
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
readonly selector: string;
|
||||
readonly routes: string[] | false;
|
||||
readonly opts: MutationObserverInit | null;
|
||||
readonly limit: number;
|
||||
readonly timeout: number;
|
||||
readonly check_removal: boolean;
|
||||
count: number;
|
||||
readonly instances: Set<TElement>;
|
||||
readonly elemental: Elemental;
|
||||
|
||||
readonly param: ElementalParam;
|
||||
readonly remove_param: ElementalRemoveParam;
|
||||
|
||||
private _stimer?: ReturnType<typeof setTimeout> | null;
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
selector: string,
|
||||
routes: string[] | false | undefined | null,
|
||||
opts: MutationObserverInit | null,
|
||||
limit: number,
|
||||
timeout: number,
|
||||
remove: boolean,
|
||||
elemental: Elemental
|
||||
) {
|
||||
super();
|
||||
|
||||
this.id = elemental_id++;
|
||||
this.param = `_ffz$elemental$${this.id}`;
|
||||
this.remove_param = `_ffz$elemental_remove$${this.id}`;
|
||||
this.mut_param = `_ffz$elemental_mutating${this.id}`;
|
||||
|
||||
this._schedule = this._schedule.bind(this);
|
||||
|
||||
this.name = name;
|
||||
this.selector = selector;
|
||||
this.routes = routes || [];
|
||||
this.routes = routes ?? [];
|
||||
this.opts = opts;
|
||||
this.limit = limit;
|
||||
this.timeout = timeout;
|
||||
|
@ -199,7 +272,6 @@ export class ElementalWrapper extends EventEmitter {
|
|||
|
||||
this.count = 0;
|
||||
this.instances = new Set;
|
||||
this.observers = new Map;
|
||||
this.elemental = elemental;
|
||||
|
||||
this.check();
|
||||
|
@ -224,6 +296,7 @@ export class ElementalWrapper extends EventEmitter {
|
|||
}
|
||||
|
||||
_schedule() {
|
||||
if ( this._stimer )
|
||||
clearTimeout(this._stimer);
|
||||
this._stimer = null;
|
||||
|
||||
|
@ -234,18 +307,19 @@ export class ElementalWrapper extends EventEmitter {
|
|||
}
|
||||
|
||||
check() {
|
||||
const matches = document.querySelectorAll(this.selector);
|
||||
for(const el of matches)
|
||||
const matches = document.querySelectorAll<TElement>(this.selector);
|
||||
// TypeScript is stupid and thinks NodeListOf<Element> doesn't have an iterator
|
||||
for(const el of matches as unknown as Iterable<TElement>)
|
||||
this.add(el);
|
||||
}
|
||||
|
||||
checkElements(els) {
|
||||
checkElements(els: Iterable<Element>) {
|
||||
if ( this.atLimit )
|
||||
return this.schedule();
|
||||
|
||||
for(const el of els) {
|
||||
const matches = el.querySelectorAll(this.selector);
|
||||
for(const match of matches)
|
||||
const matches = el.querySelectorAll<TElement>(this.selector);
|
||||
for(const match of matches as unknown as Iterable<TElement>)
|
||||
this.add(match);
|
||||
|
||||
if ( this.atLimit )
|
||||
|
@ -264,66 +338,66 @@ export class ElementalWrapper extends EventEmitter {
|
|||
return Array.from(this.instances);
|
||||
}
|
||||
|
||||
each(fn) {
|
||||
each(fn: (element: TElement) => void) {
|
||||
for(const el of this.instances)
|
||||
fn(el);
|
||||
}
|
||||
|
||||
add(el) {
|
||||
if ( this.instances.has(el) )
|
||||
add(element: TElement) {
|
||||
if ( this.instances.has(element) )
|
||||
return;
|
||||
|
||||
this.instances.add(el);
|
||||
this.instances.add(element);
|
||||
this.count++;
|
||||
|
||||
if ( this.check_removal ) {
|
||||
if ( this.check_removal && element.parentNode ) {
|
||||
const remove_check = new MutationObserver(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if ( ! document.contains(el) )
|
||||
this.remove(el);
|
||||
if ( ! document.contains(element) )
|
||||
this.remove(element);
|
||||
});
|
||||
});
|
||||
|
||||
remove_check.observe(el.parentNode, {childList: true});
|
||||
el[this.remove_param] = remove_check;
|
||||
remove_check.observe(element.parentNode, {childList: true});
|
||||
(element as HTMLElement)[this.remove_param] = remove_check;
|
||||
}
|
||||
|
||||
if ( this.opts ) {
|
||||
const observer = new MutationObserver(muts => {
|
||||
if ( ! document.contains(el) ) {
|
||||
this.remove(el);
|
||||
if ( ! document.contains(element) ) {
|
||||
this.remove(element);
|
||||
} else if ( ! this.__running.size )
|
||||
this.emit('mutate', el, muts);
|
||||
this.emit('mutate', element, muts);
|
||||
});
|
||||
|
||||
observer.observe(el, this.opts);
|
||||
el[this.param] = observer;
|
||||
observer.observe(element, this.opts);
|
||||
(element as HTMLElement)[this.param] = observer;
|
||||
}
|
||||
|
||||
this.schedule();
|
||||
this.emit('mount', el);
|
||||
this.emit('mount', element);
|
||||
}
|
||||
|
||||
remove(el) {
|
||||
const observer = el[this.param];
|
||||
remove(element: TElement) {
|
||||
const observer = element[this.param];
|
||||
if ( observer ) {
|
||||
observer.disconnect();
|
||||
el[this.param] = null;
|
||||
(element as HTMLElement)[this.param] = null;
|
||||
}
|
||||
|
||||
const remove_check = el[this.remove_param];
|
||||
const remove_check = element[this.remove_param];
|
||||
if ( remove_check ) {
|
||||
remove_check.disconnect();
|
||||
el[this.remove_param] = null;
|
||||
(element as HTMLElement)[this.remove_param] = null;
|
||||
}
|
||||
|
||||
if ( ! this.instances.has(el) )
|
||||
if ( ! this.instances.has(element) )
|
||||
return;
|
||||
|
||||
this.instances.delete(el);
|
||||
this.instances.delete(element);
|
||||
this.count--;
|
||||
|
||||
this.schedule();
|
||||
this.emit('unmount', el);
|
||||
this.emit('unmount', element);
|
||||
}
|
||||
}
|
|
@ -5,13 +5,56 @@
|
|||
// ============================================================================
|
||||
|
||||
import {parse, tokensToRegExp, tokensToFunction} from 'path-to-regexp';
|
||||
import Module from 'utilities/module';
|
||||
import {has, deep_equals} from 'utilities/object';
|
||||
import Module, { GenericModule } from 'utilities/module';
|
||||
import {has, deep_equals, sleep} from 'utilities/object';
|
||||
import type Fine from './fine';
|
||||
import type { OptionalPromise } from 'utilities/types';
|
||||
|
||||
declare module 'utilities/types' {
|
||||
interface ModuleEventMap {
|
||||
'site.router': FineRouterEvents;
|
||||
}
|
||||
interface ModuleMap {
|
||||
'site.router': FineRouter;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default class FineRouter extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
type FineRouterEvents = {
|
||||
':updated-route-names': [];
|
||||
':updated-routes': [];
|
||||
|
||||
':route': [route: RouteInfo | null, match: unknown];
|
||||
};
|
||||
|
||||
export type RouteInfo = {
|
||||
name: string;
|
||||
domain: string | null;
|
||||
|
||||
};
|
||||
|
||||
|
||||
export default class FineRouter extends Module<'site.router', FineRouterEvents> {
|
||||
|
||||
// Dependencies
|
||||
fine: Fine = null as any;
|
||||
|
||||
// Storage
|
||||
routes: Record<string, RouteInfo>;
|
||||
route_names: Record<string, string>;
|
||||
private __routes: RouteInfo[];
|
||||
|
||||
// State
|
||||
current: RouteInfo | null;
|
||||
current_name: string | null;
|
||||
current_state: unknown | null;
|
||||
match: unknown | null;
|
||||
location: unknown | null;
|
||||
|
||||
|
||||
|
||||
constructor(name?: string, parent?: GenericModule) {
|
||||
super(name, parent);
|
||||
this.inject('..fine');
|
||||
|
||||
this.__routes = [];
|
||||
|
@ -20,16 +63,18 @@ export default class FineRouter extends Module {
|
|||
this.route_names = {};
|
||||
this.current = null;
|
||||
this.current_name = null;
|
||||
this.current_state = null;
|
||||
this.match = null;
|
||||
this.location = null;
|
||||
}
|
||||
|
||||
onEnable() {
|
||||
/** @internal */
|
||||
onEnable(): OptionalPromise<void> {
|
||||
const thing = this.fine.searchTree(null, n => n.props && n.props.history),
|
||||
history = this.history = thing && thing.props && thing.props.history;
|
||||
|
||||
if ( ! history )
|
||||
return new Promise(r => setTimeout(r, 50)).then(() => this.onEnable());
|
||||
return sleep(50).then(() => this.onEnable());
|
||||
|
||||
history.listen(location => {
|
||||
if ( this.enabled )
|
||||
|
@ -43,7 +88,7 @@ export default class FineRouter extends Module {
|
|||
this.history.push(this.getURL(route, data, opts), state);
|
||||
}
|
||||
|
||||
_navigateTo(location) {
|
||||
private _navigateTo(location) {
|
||||
this.log.debug('New Location', location);
|
||||
const host = window.location.host,
|
||||
path = location.pathname,
|
||||
|
@ -66,7 +111,7 @@ export default class FineRouter extends Module {
|
|||
this._pickRoute();
|
||||
}
|
||||
|
||||
_pickRoute() {
|
||||
private _pickRoute() {
|
||||
const path = this.location,
|
||||
host = this.domain;
|
||||
|
||||
|
@ -85,7 +130,6 @@ export default class FineRouter extends Module {
|
|||
this.current_name = route.name;
|
||||
this.match = match;
|
||||
this.emit(':route', route, match);
|
||||
this.emit(`:route:${route.name}`, ...match);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -117,7 +161,7 @@ export default class FineRouter extends Module {
|
|||
return r.url(data, opts);
|
||||
}
|
||||
|
||||
getRoute(name) {
|
||||
getRoute(name: string) {
|
||||
return this.routes[name];
|
||||
}
|
||||
|
||||
|
@ -132,7 +176,7 @@ export default class FineRouter extends Module {
|
|||
return this.route_names;
|
||||
}
|
||||
|
||||
getRouteName(route) {
|
||||
getRouteName(route: string) {
|
||||
if ( ! this.route_names[route] )
|
||||
this.route_names[route] = route
|
||||
.replace(/^dash-([a-z])/, (_, letter) =>
|
||||
|
@ -143,7 +187,7 @@ export default class FineRouter extends Module {
|
|||
return this.route_names[route];
|
||||
}
|
||||
|
||||
routeName(route, name, process = true) {
|
||||
routeName(route: string | Record<string, string>, name?: string, process: boolean = true) {
|
||||
if ( typeof route === 'object' ) {
|
||||
for(const key in route)
|
||||
if ( has(route, key) )
|
||||
|
@ -154,11 +198,13 @@ export default class FineRouter extends Module {
|
|||
return;
|
||||
}
|
||||
|
||||
if ( name ) {
|
||||
this.route_names[route] = name;
|
||||
|
||||
if ( process )
|
||||
this.emit(':updated-route-names');
|
||||
}
|
||||
}
|
||||
|
||||
route(name, path, domain = null, state_fn = null, process = true) {
|
||||
if ( typeof name === 'object' ) {
|
||||
|
@ -173,6 +219,7 @@ export default class FineRouter extends Module {
|
|||
this._pickRoute();
|
||||
this.emit(':updated-routes');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -1,813 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Fine Lib
|
||||
// It controls React.
|
||||
// ============================================================================
|
||||
|
||||
import {EventEmitter} from 'utilities/events';
|
||||
import Module from 'utilities/module';
|
||||
|
||||
|
||||
export default class Fine extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this._wrappers = new Map;
|
||||
this._known_classes = new Map;
|
||||
this._observer = null;
|
||||
this._waiting = [];
|
||||
this._live_waiting = null;
|
||||
}
|
||||
|
||||
|
||||
async onEnable(tries=0) {
|
||||
// TODO: Move awaitElement to utilities/dom
|
||||
if ( ! this.root_element )
|
||||
this.root_element = await this.parent.awaitElement(this.selector || 'body #root');
|
||||
|
||||
if ( ! this.root_element || ! this.root_element._reactRootContainer ) {
|
||||
if ( tries > 500 )
|
||||
throw new Error('Unable to find React after 25 seconds');
|
||||
this.root_element = null;
|
||||
return new Promise(r => setTimeout(r, 50)).then(() => this.onEnable(tries+1));
|
||||
}
|
||||
|
||||
this.react_root = this.root_element._reactRootContainer;
|
||||
if ( this.react_root._internalRoot && this.react_root._internalRoot.current )
|
||||
this.react_root = this.react_root._internalRoot;
|
||||
|
||||
this.react = this.react_root.current.child;
|
||||
}
|
||||
|
||||
onDisable() {
|
||||
this.react_root = this.root_element = this.react = this.accessor = null;
|
||||
}
|
||||
|
||||
|
||||
static findAccessor(element) {
|
||||
for(const key in element)
|
||||
if ( key.startsWith('__reactInternalInstance$') )
|
||||
return key;
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Low Level Accessors
|
||||
// ========================================================================
|
||||
|
||||
getReactInstance(element) {
|
||||
if ( ! this.accessor )
|
||||
this.accessor = Fine.findAccessor(element);
|
||||
if ( ! this.accessor )
|
||||
return;
|
||||
|
||||
return element[this.accessor] || (element._reactRootContainer && element._reactRootContainer._internalRoot && element._reactRootContainer._internalRoot.current) || (element._reactRootContainer && element._reactRootContainer.current);
|
||||
}
|
||||
|
||||
getOwner(instance) {
|
||||
if ( instance._reactInternalFiber )
|
||||
instance = instance._reactInternalFiber;
|
||||
else if ( instance instanceof Node )
|
||||
instance = this.getReactInstance(instance);
|
||||
|
||||
if ( ! instance )
|
||||
return null;
|
||||
|
||||
return instance.return;
|
||||
}
|
||||
|
||||
getParentNode(instance, max_depth = 100, traverse_roots = false) {
|
||||
/*if ( instance._reactInternalFiber )
|
||||
instance = instance._reactInternalFiber;
|
||||
else if ( instance instanceof Node )
|
||||
instance = this.getReactInstance(instance);
|
||||
|
||||
while( instance )
|
||||
if ( instance.stateNode instanceof Node )
|
||||
return instance.stateNode
|
||||
else
|
||||
instance = instance.parent;*/
|
||||
|
||||
return this.searchParent(instance, n => n instanceof Node, max_depth, 0, traverse_roots);
|
||||
}
|
||||
|
||||
getChildNode(instance, max_depth = 100, traverse_roots = false) {
|
||||
/*if ( instance._reactInternalFiber )
|
||||
instance = instance._reactInternalFiber;
|
||||
else if ( instance instanceof Node )
|
||||
instance = this.getReactInstance(instance);
|
||||
|
||||
while( instance )
|
||||
if ( instance.stateNode instanceof Node )
|
||||
return instance.stateNode
|
||||
else {
|
||||
max_depth--;
|
||||
if ( max_depth < 0 )
|
||||
return null;
|
||||
instance = instance.child;
|
||||
}*/
|
||||
|
||||
return this.searchTree(instance, n => n instanceof Node, max_depth, 0, traverse_roots);
|
||||
}
|
||||
|
||||
getHostNode(instance, max_depth = 100) {
|
||||
return this.getChildNode(instance, max_depth);
|
||||
}
|
||||
|
||||
getParent(instance) {
|
||||
return this.getOwner(instance);
|
||||
}
|
||||
|
||||
getFirstChild(node) {
|
||||
if ( node._reactInternalFiber )
|
||||
node = node._reactInternalFiber;
|
||||
else if ( node instanceof Node )
|
||||
node = this.getReactInstance(node);
|
||||
|
||||
if ( ! node )
|
||||
return null;
|
||||
|
||||
return node.child;
|
||||
}
|
||||
|
||||
getChildren(node) {
|
||||
if ( node._reactInternalFiber )
|
||||
node = node._reactInternalFiber;
|
||||
else if ( node instanceof Node )
|
||||
node = this.getReactInstance(node);
|
||||
|
||||
if ( ! node )
|
||||
return null;
|
||||
|
||||
const children = [];
|
||||
let child = node.child;
|
||||
while(child) {
|
||||
children.push(child);
|
||||
child = child.sibling;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
searchParent(node, criteria, max_depth=15, depth=0, traverse_roots = true) {
|
||||
if ( node._reactInternalFiber )
|
||||
node = node._reactInternalFiber;
|
||||
else if ( node instanceof Node )
|
||||
node = this.getReactInstance(node);
|
||||
|
||||
if ( ! node || node._ffz_no_scan || depth > max_depth )
|
||||
return null;
|
||||
|
||||
if ( typeof criteria === 'string' ) {
|
||||
const wrapper = this._wrappers.get(criteria);
|
||||
if ( ! wrapper )
|
||||
throw new Error('invalid critera');
|
||||
|
||||
if ( ! wrapper._class )
|
||||
return null;
|
||||
|
||||
criteria = n => n && n.constructor === wrapper._class;
|
||||
}
|
||||
|
||||
const inst = node.stateNode;
|
||||
if ( inst && criteria(inst) )
|
||||
return inst;
|
||||
|
||||
if ( node.return ) {
|
||||
const result = this.searchParent(node.return, criteria, max_depth, depth+1, traverse_roots);
|
||||
if ( result )
|
||||
return result;
|
||||
}
|
||||
|
||||
// Stupid code for traversing up into another React root.
|
||||
if ( traverse_roots && inst && inst.containerInfo ) {
|
||||
const parent = inst.containerInfo.parentElement,
|
||||
parent_node = parent && this.getReactInstance(parent);
|
||||
|
||||
if ( parent_node ) {
|
||||
const result = this.searchParent(parent_node, criteria, max_depth, depth+1, traverse_roots);
|
||||
if ( result )
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
searchNode(node, criteria, max_depth=15, depth=0, traverse_roots = true) {
|
||||
if ( ! node )
|
||||
node = this.react;
|
||||
else if ( node._reactInternalFiber )
|
||||
node = node._reactInternalFiber;
|
||||
else if ( node instanceof Node )
|
||||
node = this.getReactInstance(node);
|
||||
|
||||
if ( ! node || node._ffz_no_scan || depth > max_depth )
|
||||
return null;
|
||||
|
||||
if ( typeof criteria === 'string' ) {
|
||||
const wrapper = this._wrappers.get(criteria);
|
||||
if ( ! wrapper )
|
||||
throw new Error('invalid critera');
|
||||
|
||||
if ( ! wrapper._class )
|
||||
return null;
|
||||
|
||||
criteria = n => n && n.constructor === wrapper._class;
|
||||
}
|
||||
|
||||
if ( node && criteria(node) )
|
||||
return node;
|
||||
|
||||
if ( node.child ) {
|
||||
let child = node.child;
|
||||
while(child) {
|
||||
const result = this.searchNode(child, criteria, max_depth, depth+1, traverse_roots);
|
||||
if ( result )
|
||||
return result;
|
||||
child = child.sibling;
|
||||
}
|
||||
}
|
||||
|
||||
const inst = node.stateNode;
|
||||
if ( traverse_roots && inst && inst.props && inst.props.root ) {
|
||||
const root = inst.props.root._reactRootContainer;
|
||||
if ( root ) {
|
||||
let child = root._internalRoot && root._internalRoot.current || root.current;
|
||||
while(child) {
|
||||
const result = this.searchNode(child, criteria, max_depth, depth+1, traverse_roots);
|
||||
if ( result )
|
||||
return result;
|
||||
|
||||
child = child.sibling;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
searchTree(node, criteria, max_depth=15, depth=0, traverse_roots = true, multi = false) {
|
||||
if ( ! node )
|
||||
node = this.react;
|
||||
else if ( node._reactInternalFiber )
|
||||
node = node._reactInternalFiber;
|
||||
else if ( node instanceof Node )
|
||||
node = this.getReactInstance(node);
|
||||
|
||||
if ( multi ) {
|
||||
if ( !(multi instanceof Set) )
|
||||
multi = new Set;
|
||||
}
|
||||
|
||||
if ( multi && ! (multi instanceof Set) )
|
||||
multi = new Set;
|
||||
|
||||
if ( ! node || node._ffz_no_scan || depth > max_depth )
|
||||
return multi ? multi : null;
|
||||
|
||||
if ( typeof criteria === 'string' ) {
|
||||
const wrapper = this._wrappers.get(criteria);
|
||||
if ( ! wrapper )
|
||||
throw new Error('invalid critera');
|
||||
|
||||
if ( ! wrapper._class )
|
||||
return multi ? multi : null;
|
||||
|
||||
criteria = n => n && n.constructor === wrapper._class;
|
||||
}
|
||||
|
||||
const inst = node.stateNode;
|
||||
if ( inst && criteria(inst, node) ) {
|
||||
if ( multi )
|
||||
multi.add(inst);
|
||||
else
|
||||
return inst;
|
||||
}
|
||||
|
||||
if ( node.child ) {
|
||||
let child = node.child;
|
||||
while(child) {
|
||||
const result = this.searchTree(child, criteria, max_depth, depth+1, traverse_roots, multi);
|
||||
if ( result && ! multi )
|
||||
return result;
|
||||
child = child.sibling;
|
||||
}
|
||||
}
|
||||
|
||||
if ( traverse_roots && inst && inst.props && inst.props.root ) {
|
||||
const root = inst.props.root._reactRootContainer;
|
||||
if ( root ) {
|
||||
let child = root._internalRoot && root._internalRoot.current || root.current;
|
||||
while(child) {
|
||||
const result = this.searchTree(child, criteria, max_depth, depth+1, traverse_roots, multi);
|
||||
if ( result && ! multi )
|
||||
return result;
|
||||
|
||||
child = child.sibling;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( multi )
|
||||
return multi;
|
||||
}
|
||||
|
||||
|
||||
findAllMatching(node, criteria, max_depth=15, single_class = true, parents=false, depth=0, traverse_roots=true) {
|
||||
const matches = new Set;
|
||||
let crit = n => ! matches.has(n) && criteria(n);
|
||||
|
||||
while(true) {
|
||||
const match = parents ?
|
||||
this.searchParent(node, crit, max_depth, depth, traverse_roots) :
|
||||
this.searchTree(node, crit, max_depth, depth, traverse_roots);
|
||||
|
||||
if ( ! match )
|
||||
break;
|
||||
|
||||
if ( single_class && ! matches.size ) {
|
||||
const klass = match.constructor;
|
||||
crit = n => ! matches.has(n) && (n instanceof klass) && criteria(n);
|
||||
}
|
||||
|
||||
matches.add(match);
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
|
||||
searchAll(node, criterias, max_depth=15, depth=0, data, traverse_roots = true) {
|
||||
if ( ! node )
|
||||
node = this.react;
|
||||
else if ( node._reactInternalFiber )
|
||||
node = node._reactInternalFiber;
|
||||
else if ( node instanceof Node )
|
||||
node = this.getReactInstance(node);
|
||||
|
||||
if ( ! data )
|
||||
data = {
|
||||
seen: new Set,
|
||||
classes: criterias.map(() => null),
|
||||
out: criterias.map(() => ({
|
||||
cls: null, instances: new Set, depth: null
|
||||
})),
|
||||
max_depth: depth
|
||||
};
|
||||
|
||||
if ( ! node || node._ffz_no_scan || depth > max_depth )
|
||||
return data.out;
|
||||
|
||||
if ( depth > data.max_depth )
|
||||
data.max_depth = depth;
|
||||
|
||||
const inst = node.stateNode;
|
||||
if ( inst ) {
|
||||
const cls = inst.constructor,
|
||||
idx = data.classes.indexOf(cls);
|
||||
|
||||
if ( idx !== -1 )
|
||||
data.out[idx].instances.add(inst);
|
||||
|
||||
else if ( ! data.seen.has(cls) ) {
|
||||
let i = criterias.length;
|
||||
while(i-- > 0)
|
||||
if ( criterias[i](inst) ) {
|
||||
data.classes[i] = data.out[i].cls = cls;
|
||||
data.out[i].instances.add(inst);
|
||||
data.out[i].depth = depth;
|
||||
break;
|
||||
}
|
||||
|
||||
data.seen.add(cls);
|
||||
}
|
||||
}
|
||||
|
||||
let child = node.child;
|
||||
while(child) {
|
||||
this.searchAll(child, criterias, max_depth, depth+1, data, traverse_roots);
|
||||
child = child.sibling;
|
||||
}
|
||||
|
||||
if ( traverse_roots && inst && inst.props && inst.props.root ) {
|
||||
const root = inst.props.root._reactRootContainer;
|
||||
if ( root ) {
|
||||
let child = root._internalRoot && root._internalRoot.current || root.current;
|
||||
while(child) {
|
||||
this.searchAll(child, criterias, max_depth, depth+1, data, traverse_roots);
|
||||
child = child.sibling;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data.out;
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Class Wrapping
|
||||
// ========================================================================
|
||||
|
||||
route(route) {
|
||||
this._route = route;
|
||||
this._updateLiveWaiting();
|
||||
}
|
||||
|
||||
|
||||
_updateLiveWaiting() {
|
||||
const lw = this._live_waiting = [],
|
||||
crt = this._waiting_crit = [],
|
||||
route = this._route;
|
||||
|
||||
if ( this._waiting )
|
||||
for(const waiter of this._waiting)
|
||||
if ( ! route || ! waiter.routes.length || waiter.routes.includes(route) ) {
|
||||
lw.push(waiter);
|
||||
crt.push(waiter.criteria);
|
||||
}
|
||||
|
||||
if ( ! this._live_waiting.length )
|
||||
this._stopWaiting();
|
||||
else if ( ! this._waiting_timer )
|
||||
this._startWaiting();
|
||||
}
|
||||
|
||||
|
||||
define(key, criteria, routes) {
|
||||
if ( this._wrappers.has(key) )
|
||||
return this._wrappers.get(key);
|
||||
|
||||
if ( ! criteria )
|
||||
throw new Error('cannot find definition and no criteria provided');
|
||||
|
||||
const wrapper = new FineWrapper(key, criteria, routes, this);
|
||||
this._wrappers.set(key, wrapper);
|
||||
|
||||
const data = this.searchAll(this.react, [criteria], 1000)[0];
|
||||
if ( data.cls ) {
|
||||
wrapper._set(data.cls, data.instances);
|
||||
this._known_classes.set(data.cls, wrapper);
|
||||
|
||||
} else if ( routes !== false ) {
|
||||
this._waiting.push(wrapper);
|
||||
this._updateLiveWaiting();
|
||||
}
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
|
||||
wrap(key, cls) {
|
||||
let wrapper;
|
||||
if ( this._wrappers.has(key) )
|
||||
wrapper = this._wrappers.get(key);
|
||||
else {
|
||||
wrapper = new FineWrapper(key, null, undefined, this);
|
||||
this._wrappers.set(key, wrapper);
|
||||
}
|
||||
|
||||
if ( cls ) {
|
||||
if ( wrapper._class || wrapper.criteria )
|
||||
throw new Error('tried setting a class on an already initialized FineWrapper');
|
||||
|
||||
wrapper._set(cls, new Set);
|
||||
this._known_classes.set(cls, wrapper);
|
||||
}
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
|
||||
_checkWaiters(nodes) {
|
||||
if ( ! this._live_waiting )
|
||||
return;
|
||||
|
||||
if ( ! Array.isArray(nodes) )
|
||||
nodes = [nodes];
|
||||
|
||||
for(let node of nodes) {
|
||||
if ( ! node )
|
||||
node = this.react;
|
||||
else if ( node._reactInternalFiber )
|
||||
node = node._reactInternalFiber;
|
||||
else if ( node instanceof Node )
|
||||
node = this.getReactInstance(node);
|
||||
|
||||
if ( ! node || node._ffz_no_scan || ! this._live_waiting.length )
|
||||
continue;
|
||||
|
||||
const data = this.searchAll(node, this._waiting_crit, 1000);
|
||||
let i = data.length;
|
||||
while(i-- > 0) {
|
||||
if ( data[i].cls ) {
|
||||
const d = data[i],
|
||||
w = this._live_waiting.splice(i, 1)[0];
|
||||
|
||||
this._waiting_crit.splice(i, 1);
|
||||
|
||||
const idx = this._waiting.indexOf(w);
|
||||
if ( idx !== -1 )
|
||||
this._waiting.splice(idx, 1);
|
||||
|
||||
this.log.debug(`Found class for "${w.name}" at depth ${d.depth}`);
|
||||
w._set(d.cls, d.instances);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! this._live_waiting.length )
|
||||
this._stopWaiting();
|
||||
}
|
||||
|
||||
|
||||
_startWaiting() {
|
||||
this.log.info('Installing MutationObserver.');
|
||||
this._waiting_timer = setInterval(() => this._checkWaiters(), 500);
|
||||
|
||||
if ( ! this._observer )
|
||||
this._observer = new MutationObserver(mutations =>
|
||||
this._checkWaiters(mutations.map(x => x.target))
|
||||
);
|
||||
|
||||
this._observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
_stopWaiting() {
|
||||
this.log.info('Stopping MutationObserver.');
|
||||
|
||||
if ( this._observer )
|
||||
this._observer.disconnect();
|
||||
|
||||
if ( this._waiting_timer )
|
||||
clearInterval(this._waiting_timer);
|
||||
|
||||
this._live_waiting = null;
|
||||
this._waiting_crit = null;
|
||||
this._waiting_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const EVENTS = {
|
||||
'will-mount': 'UNSAFE_componentWillMount',
|
||||
mount: 'componentDidMount',
|
||||
render: 'render',
|
||||
'receive-props': 'UNSAFE_componentWillReceiveProps',
|
||||
'should-update': 'shouldComponentUpdate',
|
||||
'will-update': 'UNSAFE_componentWillUpdate',
|
||||
update: 'componentDidUpdate',
|
||||
unmount: 'componentWillUnmount'
|
||||
}
|
||||
|
||||
|
||||
export class FineWrapper extends EventEmitter {
|
||||
constructor(name, criteria, routes, fine) {
|
||||
super();
|
||||
|
||||
this.name = name;
|
||||
this.criteria = criteria;
|
||||
this.fine = fine;
|
||||
|
||||
this.instances = new Set;
|
||||
this.routes = routes || [];
|
||||
|
||||
this._wrapped = new Set;
|
||||
this._class = null;
|
||||
}
|
||||
|
||||
get first() {
|
||||
return this.toArray()[0];
|
||||
}
|
||||
|
||||
toArray() {
|
||||
return Array.from(this.instances);
|
||||
}
|
||||
|
||||
check(node = null, max_depth = 1000) {
|
||||
if ( this._class )
|
||||
return;
|
||||
|
||||
const instances = this.fine.findAllMatching(node, this.criteria, max_depth);
|
||||
if ( instances.size ) {
|
||||
const insts = Array.from(instances);
|
||||
this._set(insts[0].constructor, insts);
|
||||
}
|
||||
}
|
||||
|
||||
ready(fn) {
|
||||
if ( this._class )
|
||||
fn(this._class, this.instances);
|
||||
else
|
||||
this.once('set', fn);
|
||||
}
|
||||
|
||||
each(fn) {
|
||||
for(const inst of this.instances)
|
||||
fn(inst);
|
||||
}
|
||||
|
||||
updateInstances(node = null, max_depth = 1000) {
|
||||
if ( ! this._class )
|
||||
return;
|
||||
|
||||
const instances = this.fine.findAllMatching(node, n => n.constructor === this._class, max_depth);
|
||||
|
||||
for(const inst of instances) {
|
||||
inst._ffz_mounted = true;
|
||||
this.instances.add(inst);
|
||||
}
|
||||
}
|
||||
|
||||
_set(cls, instances) {
|
||||
if ( this._class )
|
||||
throw new Error('already have a class');
|
||||
|
||||
this._class = cls;
|
||||
this._wrapped.add('UNSAFE_componentWillMount');
|
||||
this._wrapped.add('componentWillUnmount');
|
||||
|
||||
cls._ffz_wrapper = this;
|
||||
|
||||
const t = this,
|
||||
_instances = this.instances,
|
||||
proto = cls.prototype,
|
||||
o_mount = proto.UNSAFE_componentWillMount,
|
||||
o_unmount = proto.componentWillUnmount,
|
||||
|
||||
mount = proto.UNSAFE_componentWillMount = o_mount ?
|
||||
function(...args) {
|
||||
this._ffz_mounted = true;
|
||||
_instances.add(this);
|
||||
t.emit('will-mount', this, ...args);
|
||||
return o_mount.apply(this, args);
|
||||
} :
|
||||
function(...args) {
|
||||
this._ffz_mounted = true;
|
||||
_instances.add(this);
|
||||
t.emit('will-mount', this, ...args);
|
||||
},
|
||||
|
||||
unmount = proto.componentWillUnmount = o_unmount ?
|
||||
function(...args) {
|
||||
t.emit('unmount', this, ...args);
|
||||
_instances.delete(this);
|
||||
this._ffz_mounted = false;
|
||||
return o_unmount.apply(this, args);
|
||||
} :
|
||||
function(...args) {
|
||||
t.emit('unmount', this, ...args);
|
||||
_instances.delete(this);
|
||||
this._ffz_mounted = false;
|
||||
};
|
||||
|
||||
this.__UNSAFE_componentWillMount = [mount, o_mount];
|
||||
this.__componentWillUnmount = [unmount, o_unmount];
|
||||
|
||||
for(const event of this.events())
|
||||
this._maybeWrap(event);
|
||||
|
||||
if ( instances )
|
||||
for(const inst of instances) {
|
||||
// How do we check mounted state for fibers?
|
||||
// Just assume they're mounted for now I guess.
|
||||
inst._ffz_mounted = true;
|
||||
_instances.add(inst);
|
||||
}
|
||||
|
||||
this.emit('set', cls, _instances);
|
||||
}
|
||||
|
||||
_add(instances) {
|
||||
for(const inst of instances)
|
||||
this.instances.add(inst);
|
||||
}
|
||||
|
||||
|
||||
_maybeWrap(event) {
|
||||
const key = EVENTS[event];
|
||||
if ( ! this._class || ! key || this._wrapped.has(key) )
|
||||
return;
|
||||
|
||||
this._wrap(event, key);
|
||||
}
|
||||
|
||||
_wrap(event, key) {
|
||||
if ( this._wrapped.has(key) )
|
||||
return;
|
||||
|
||||
const t = this,
|
||||
proto = this._class.prototype,
|
||||
original = proto[key],
|
||||
|
||||
fn = proto[key] = original ?
|
||||
function(...args) {
|
||||
if ( ! this._ffz_mounted ) {
|
||||
this._ffz_mounted = true;
|
||||
t.instances.add(this);
|
||||
t.emit('late-mount', this);
|
||||
}
|
||||
|
||||
t.emit(event, this, ...args);
|
||||
return original.apply(this, args);
|
||||
} :
|
||||
|
||||
key === 'shouldComponentUpdate' ?
|
||||
function(...args) {
|
||||
if ( ! this._ffz_mounted ) {
|
||||
this._ffz_mounted = true;
|
||||
t.instances.add(this);
|
||||
t.emit('late-mount', this);
|
||||
}
|
||||
|
||||
t.emit(event, this, ...args);
|
||||
return true;
|
||||
}
|
||||
:
|
||||
function(...args) {
|
||||
if ( ! this._ffz_mounted ) {
|
||||
this._ffz_mounted = true;
|
||||
t.instances.add(this);
|
||||
t.emit('late-mount', this);
|
||||
}
|
||||
|
||||
t.emit(event, this, ...args);
|
||||
};
|
||||
|
||||
this[`__${key}`] = [fn, original];
|
||||
}
|
||||
|
||||
_unwrap(key) {
|
||||
if ( ! this._wrapped.has(key) )
|
||||
return;
|
||||
|
||||
const k = `__${key}`,
|
||||
proto = this._class.prototype,
|
||||
[fn, original] = this[k];
|
||||
|
||||
if ( proto[key] !== fn )
|
||||
throw new Error('unable to unwrap -- prototype modified');
|
||||
|
||||
proto[key] = original;
|
||||
this[k] = undefined;
|
||||
this._wrapped.delete(key);
|
||||
}
|
||||
|
||||
|
||||
forceUpdate() {
|
||||
for(const inst of this.instances)
|
||||
try {
|
||||
inst.forceUpdate();
|
||||
this.fine.emit('site:dom-update', this.name, inst);
|
||||
|
||||
} catch(err) {
|
||||
this.fine.log.capture(err, {
|
||||
tags: {
|
||||
fine_wrapper: this.name
|
||||
}
|
||||
});
|
||||
|
||||
this.fine.log.error(`An error occurred when calling forceUpdate on an instance of ${this.name}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
on(event, fn, ctx) {
|
||||
this._maybeWrap(event);
|
||||
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);
|
||||
}
|
||||
}
|
1032
src/utilities/compat/fine.ts
Normal file
1032
src/utilities/compat/fine.ts
Normal file
File diff suppressed because it is too large
Load diff
75
src/utilities/compat/react-types.ts
Normal file
75
src/utilities/compat/react-types.ts
Normal file
|
@ -0,0 +1,75 @@
|
|||
|
||||
// You might be wondering why we're homebrewing React types when we could just
|
||||
// reply on @types/react.
|
||||
//
|
||||
// It's simple. TypeScript is obtuse and refuses to NOT use @types/react if
|
||||
// the package is installed. That breaks our own JSX use, and so we can't use
|
||||
// those types.
|
||||
|
||||
declare global {
|
||||
interface Node {
|
||||
[key: ReactAccessor]: ReactNode | undefined;
|
||||
_reactRootContainer?: ReactRoot;
|
||||
_ffz_no_scan?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export type ReactAccessor = `__reactInternalInstance$${string}`;
|
||||
|
||||
export type ReactRoot = {
|
||||
_internalRoot?: ReactRoot;
|
||||
current: ReactNode | null;
|
||||
};
|
||||
|
||||
export type ReactNode = {
|
||||
alternate: ReactNode | null;
|
||||
child: ReactNode | null;
|
||||
return: ReactNode | null;
|
||||
sibling: ReactNode | null;
|
||||
stateNode: ReactStateNode | Node | null;
|
||||
};
|
||||
|
||||
|
||||
export type ReactStateNode<
|
||||
TProps extends {} = {},
|
||||
TState extends {} = {},
|
||||
TSnapshot extends {} = {}
|
||||
> = {
|
||||
|
||||
// FFZ Helpers
|
||||
_ffz_no_scan?: boolean;
|
||||
_ffz_mounted?: boolean;
|
||||
|
||||
// Access to the internal node.
|
||||
_reactInternalFiber: ReactNode | null;
|
||||
|
||||
// Stuff
|
||||
props: TProps;
|
||||
state: TState | null;
|
||||
|
||||
// Lifecycle Methods
|
||||
componentDidMount?(): void;
|
||||
componentDidUpdate?(prevProps: TProps, prevState: TState, snapshot: TSnapshot | null): void;
|
||||
componentWillUnmount?(): void;
|
||||
shouldComponentUpdate?(nextProps: TProps, nextState: TState): boolean;
|
||||
getSnapshotBeforeUpdate?(prevProps: TProps, prevState: TState): TSnapshot | null;
|
||||
componentDidCatch?(error: any, info: any): void;
|
||||
|
||||
/** @deprecated Will be removed in React 17 */
|
||||
UNSAFE_componentWillMount?(): void;
|
||||
/** @deprecated Will be removed in React 17 */
|
||||
UNSAFE_componentWillReceiveProps?(nextProps: TProps): void;
|
||||
/** @deprecated Will be removed in React 17 */
|
||||
UNSAFE_componentWillUpdate?(nextProps: TProps, nextState: TState): void;
|
||||
|
||||
setState(
|
||||
updater: Partial<TState> | ((state: TState, props: TProps) => Partial<TState>),
|
||||
callback?: () => void
|
||||
): void;
|
||||
|
||||
// TODO: Implement proper return type.
|
||||
render(): any;
|
||||
|
||||
forceUpdate(callback?: () => void): void;
|
||||
|
||||
};
|
|
@ -5,11 +5,88 @@
|
|||
// It controls Twitch PubSub.
|
||||
// ============================================================================
|
||||
|
||||
import Module from 'utilities/module';
|
||||
import Module, { GenericModule } 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;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'utilities/types' {
|
||||
interface ModuleEventMap {
|
||||
'site.subpump': SubpumpEvents;
|
||||
}
|
||||
interface ModuleMap {
|
||||
'site.subpump': Subpump;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 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 +101,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 +153,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 +166,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 +201,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,66 +246,24 @@ export default class Subpump extends Module {
|
|||
}
|
||||
}
|
||||
|
||||
hookOldClient(client) {
|
||||
const t = this,
|
||||
orig_message = client._onMessage;
|
||||
|
||||
this.is_old = true;
|
||||
|
||||
client._unbindPrimary(client._primarySocket);
|
||||
|
||||
client._onMessage = function(e) {
|
||||
if ( t.handleMessage(e) )
|
||||
return;
|
||||
|
||||
return orig_message.call(this, e);
|
||||
};
|
||||
|
||||
client._bindPrimary(client._primarySocket);
|
||||
|
||||
const listener = client._listens,
|
||||
orig_on = listener.on,
|
||||
orig_off = listener.off;
|
||||
|
||||
listener.on = function(topic, fn, ctx) {
|
||||
const has_topic = !! listener._events?.[topic],
|
||||
out = orig_on.call(this, topic, fn, ctx);
|
||||
|
||||
if ( ! has_topic )
|
||||
t.emit(':add-topic', topic)
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
listener.off = function(topic, fn) {
|
||||
const has_topic = !! listener._events?.[topic],
|
||||
out = orig_off.call(this, topic, fn);
|
||||
|
||||
if ( has_topic && ! listener._events?.[topic] )
|
||||
t.emit(':remove-topic', topic);
|
||||
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
inject(topic, message) {
|
||||
simulateMessage(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 {
|
||||
} 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;
|
||||
}
|
||||
|
||||
}
|
224
src/utilities/css-tweaks.ts
Normal file
224
src/utilities/css-tweaks.ts
Normal file
|
@ -0,0 +1,224 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// CSS Tweaks
|
||||
// Tweak some CSS
|
||||
// ============================================================================
|
||||
|
||||
import Module, { GenericModule } from 'utilities/module';
|
||||
import {ManagedStyle} from 'utilities/dom';
|
||||
import {has, once} from 'utilities/object';
|
||||
|
||||
declare module "utilities/types" {
|
||||
interface ModuleMap {
|
||||
'site.css_tweaks': CSSTweaks;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
}
|
13
src/utilities/data/stream-flags.gql
Normal file
13
src/utilities/data/stream-flags.gql
Normal file
|
@ -0,0 +1,13 @@
|
|||
query FFZ_StreamFlags($ids: [ID!], $logins: [String!]) {
|
||||
users(ids: $ids, logins: $logins) {
|
||||
id
|
||||
login
|
||||
stream {
|
||||
id
|
||||
contentClassificationLabels {
|
||||
id
|
||||
localizedName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
||||
el: HTMLElement | null;
|
||||
cb: ((event: MouseEvent) => void) | null;
|
||||
_fn: ((event: MouseEvent) => void) | null;
|
||||
|
||||
constructor(element: HTMLElement, callback: ((event: MouseEvent) => void)) {
|
||||
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;
|
1019
src/utilities/events.ts
Normal file
1019
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,20 +1,23 @@
|
|||
'use strict';
|
||||
|
||||
import type { DefinitionNode, DocumentNode, FieldNode, FragmentDefinitionNode, OperationDefinitionNode, SelectionNode, SelectionSetNode } from 'graphql';
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// GraphQL Document Manipulation
|
||||
// ============================================================================
|
||||
|
||||
export const MERGE_METHODS = {
|
||||
Document: (a, b) => {
|
||||
export const MERGE_METHODS: Record<string, (a: any, b: any) => any> = {
|
||||
Document: (a: DocumentNode, b: DocumentNode) => {
|
||||
if ( a.definitions && b.definitions )
|
||||
a.definitions = mergeList(a.definitions, b.definitions);
|
||||
(a as any).definitions = mergeList(a.definitions as DefinitionNode[], b.definitions as any);
|
||||
else if ( b.definitions )
|
||||
a.definitions = b.definitions;
|
||||
(a as any).definitions = b.definitions;
|
||||
|
||||
return a;
|
||||
},
|
||||
|
||||
Field: (a, b) => {
|
||||
Field: (a: FieldNode, b: FieldNode) => {
|
||||
if ( a.name && (! b.name || b.name.value !== a.name.value) )
|
||||
return a;
|
||||
|
||||
|
@ -22,14 +25,14 @@ export const MERGE_METHODS = {
|
|||
// TODO: directives
|
||||
|
||||
if ( a.selectionSet && b.selectionSet )
|
||||
a.selectionSet = merge(a.selectionSet, b.selectionSet);
|
||||
(a as any).selectionSet = merge(a.selectionSet, b.selectionSet);
|
||||
else if ( b.selectionSet )
|
||||
a.selectionSet = b.selectionSet;
|
||||
(a as any).selectionSet = b.selectionSet;
|
||||
|
||||
return a;
|
||||
},
|
||||
|
||||
OperationDefinition: (a, b) => {
|
||||
OperationDefinition: (a: OperationDefinitionNode, b: OperationDefinitionNode) => {
|
||||
if ( a.operation !== b.operation )
|
||||
return a;
|
||||
|
||||
|
@ -37,14 +40,14 @@ export const MERGE_METHODS = {
|
|||
// TODO: directives
|
||||
|
||||
if ( a.selectionSet && b.selectionSet )
|
||||
a.selectionSet = merge(a.selectionSet, b.selectionSet);
|
||||
(a as any).selectionSet = merge(a.selectionSet, b.selectionSet);
|
||||
else if ( b.selectionSet )
|
||||
a.selectionSet = b.selectionSet;
|
||||
(a as any).selectionSet = b.selectionSet;
|
||||
|
||||
return a;
|
||||
},
|
||||
|
||||
FragmentDefinition: (a, b) => {
|
||||
FragmentDefinition: (a: FragmentDefinitionNode, b: FragmentDefinitionNode) => {
|
||||
if ( a.typeCondition && b.typeCondition ) {
|
||||
if ( a.typeCondition.kind !== b.typeCondition.kind )
|
||||
return a;
|
||||
|
@ -56,16 +59,16 @@ export const MERGE_METHODS = {
|
|||
// TODO: directives
|
||||
|
||||
if ( a.selectionSet && b.selectionSet )
|
||||
a.selectionSet = merge(a.selectionSet, b.selectionSet);
|
||||
(a as any).selectionSet = merge(a.selectionSet, b.selectionSet);
|
||||
else if ( b.selectionSet )
|
||||
a.selectionSet = b.selectionSet;
|
||||
(a as any).selectionSet = b.selectionSet;
|
||||
|
||||
return a;
|
||||
},
|
||||
|
||||
SelectionSet: (a, b) => {
|
||||
SelectionSet: (a: SelectionSetNode, b: SelectionSetNode) => {
|
||||
if ( a.selections && b.selections )
|
||||
a.selections = mergeList(a.selections, b.selections);
|
||||
a.selections = mergeList(a.selections as SelectionNode[], b.selections as any);
|
||||
else if ( b.selections )
|
||||
a.selections = b.selections;
|
||||
|
||||
|
@ -73,10 +76,10 @@ export const MERGE_METHODS = {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
export function mergeList(a, b) {
|
||||
// TODO: Type safe this
|
||||
export function mergeList(a: any[], b: any[]) {
|
||||
let has_operation = false;
|
||||
const a_names = {};
|
||||
const a_names: Record<string, any> = {};
|
||||
for(const item of a) {
|
||||
if ( ! item || ! item.name || item.name.kind !== 'Name' )
|
||||
continue;
|
||||
|
@ -114,7 +117,7 @@ export function mergeList(a, b) {
|
|||
}
|
||||
|
||||
|
||||
export default function merge(a, b) {
|
||||
export default function merge(a: any, b: any) {
|
||||
if ( a.kind !== b.kind )
|
||||
return a;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
1187
src/utilities/module.ts
Normal file
1187
src/utilities/module.ts
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,952 +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 class TranslatableError extends Error {
|
||||
constructor(message/*:string*/, key/*:string*/, data/*:object*/) {
|
||||
super(message);
|
||||
this.i18n_key = key;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
toString() {
|
||||
const ffz = window.FrankerFaceZ?.get?.(),
|
||||
i18n = ffz?.resolve?.('i18n');
|
||||
|
||||
if ( i18n && this.i18n_key )
|
||||
return i18n.t(this.i18n_key, this.message, this.data);
|
||||
|
||||
return this.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 ) {
|
||||
if ( this._onMouseOver )
|
||||
this.parent.removeEventListener('mouseover', this._onMouseOver);
|
||||
this.parent.removeEventListener('mouseout', this._onMouseOut);
|
||||
} else
|
||||
for(const el of this.elements) {
|
||||
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,17 @@ export default class TranslationCore {
|
|||
}
|
||||
}
|
||||
|
||||
toLocaleString(thing) {
|
||||
if ( thing && thing.toLocaleString )
|
||||
return thing.toLocaleString(this._locale);
|
||||
toLocaleString(thing: any) {
|
||||
if ( thing?.toLocaleString )
|
||||
return thing.toLocaleString(this._locale) as string;
|
||||
else if ( typeof thing !== 'string' )
|
||||
return `${thing}`;
|
||||
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 +353,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 +367,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 +386,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 +409,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 +428,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 +447,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 +478,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 +496,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 +511,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 +562,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 +575,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 +601,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 +625,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,188 +640,30 @@ function listToString(list) {
|
|||
// Plural Handling
|
||||
// ============================================================================
|
||||
|
||||
const CARDINAL_TO_LANG = {
|
||||
arabic: ['ar'],
|
||||
czech: ['cs'],
|
||||
danish: ['da'],
|
||||
german: ['de', 'el', 'en', 'es', 'fi', 'hu', 'it', 'nl', 'no', 'nb', 'tr', 'sv'],
|
||||
hebrew: ['he'],
|
||||
persian: ['fa'],
|
||||
polish: ['pl'],
|
||||
serbian: ['sr'],
|
||||
french: ['fr', 'pt'],
|
||||
russian: ['ru','uk'],
|
||||
slov: ['sl']
|
||||
}
|
||||
let cardinal_i18n: Intl.PluralRules | null = null,
|
||||
cardinal_locale: string | null = null;
|
||||
|
||||
const CARDINAL_TYPES = {
|
||||
other: () => 5,
|
||||
|
||||
arabic(n) {
|
||||
if ( n === 0 ) return 0;
|
||||
if ( n === 1 ) return 1;
|
||||
if ( n === 2 ) return 2;
|
||||
const n1 = n % 1000;
|
||||
if ( n1 >= 3 && n1 <= 10 ) return 3;
|
||||
return n1 >= 11 ? 4 : 5;
|
||||
},
|
||||
|
||||
czech: (n,i,v) => {
|
||||
if ( v !== 0 ) return 4;
|
||||
if ( i === 1 ) return 1;
|
||||
if ( i >= 2 && i <= 4 ) return 3;
|
||||
return 5;
|
||||
},
|
||||
|
||||
danish: (n,i,v,t) => (n === 1 || (t !== 0 && (i === 0 || i === 1))) ? 1 : 5,
|
||||
french: (n, i) => (i === 0 || i === 1) ? 1 : 5,
|
||||
german: n => n === 1 ? 1 : 5,
|
||||
|
||||
hebrew(n) {
|
||||
if ( n === 1 ) return 1;
|
||||
if ( n === 2 ) return 2;
|
||||
return (n > 10 && n % 10 === 0) ? 4 : 5;
|
||||
},
|
||||
|
||||
persian: (n, i) => (i === 0 || n === 1) ? 1 : 5,
|
||||
|
||||
slov(n, i, v) {
|
||||
if ( v !== 0 ) return 3;
|
||||
const n1 = n % 100;
|
||||
if ( n1 === 1 ) return 1;
|
||||
if ( n1 === 2 ) return 2;
|
||||
if ( n1 === 3 || n1 === 4 ) return 3;
|
||||
return 5;
|
||||
},
|
||||
|
||||
serbian(n, i, v, t) {
|
||||
if ( v !== 0 ) return 5;
|
||||
const i1 = i % 10, i2 = i % 100;
|
||||
const t1 = t % 10, t2 = t % 100;
|
||||
if ( i1 === 1 && i2 !== 11 ) return 1;
|
||||
if ( t1 === 1 && t2 !== 11 ) return 1;
|
||||
if ( i1 >= 2 && i1 <= 4 && !(i2 >= 12 && i2 <= 14) ) return 3;
|
||||
if ( t1 >= 2 && t1 <= 4 && !(t2 >= 12 && t2 <= 14) ) return 3;
|
||||
return 5;
|
||||
},
|
||||
|
||||
polish(n, i, v) {
|
||||
if ( v !== 0 ) return 5;
|
||||
if ( n === 1 ) return 1;
|
||||
const n1 = n % 10, n2 = n % 100;
|
||||
if ( n1 >= 2 && n1 <= 4 && !(n2 >= 12 && n2 <= 14) ) return 3;
|
||||
if ( i !== 1 && (n1 === 0 || n1 === 1) ) return 4;
|
||||
if ( n1 >= 5 && n1 <= 9 ) return 4;
|
||||
if ( n2 >= 12 && n2 <= 14 ) return 4;
|
||||
return 5;
|
||||
},
|
||||
|
||||
russian(n,i,v) {
|
||||
const n1 = n % 10, n2 = n % 100;
|
||||
if ( n1 === 1 && n2 !== 11 ) return 1;
|
||||
if ( v === 0 && (n1 >= 2 && n1 <= 4) && (n2 < 12 || n2 > 14) ) return 3;
|
||||
return ( v === 0 && (n1 === 0 || (n1 >= 5 && n1 <= 9) || (n2 >= 11 || n2 <= 14)) ) ? 4 : 5
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const ORDINAL_TO_LANG = {
|
||||
english: ['en'],
|
||||
hungarian: ['hu'],
|
||||
italian: ['it'],
|
||||
one: ['fr', 'lo', 'ms'],
|
||||
swedish: ['sv'],
|
||||
ukranian: ['uk']
|
||||
};
|
||||
|
||||
const ORDINAL_TYPES = {
|
||||
other: () => 5,
|
||||
one: n => n === 1 ? 1 : 5,
|
||||
|
||||
english(n) {
|
||||
const n1 = n % 10, n2 = n % 100;
|
||||
if ( n1 === 1 && n2 !== 11 ) return 1;
|
||||
if ( n1 === 2 && n2 !== 12 ) return 2;
|
||||
if ( n1 === 3 && n2 !== 13 ) return 3;
|
||||
return 5;
|
||||
},
|
||||
|
||||
ukranian(n) {
|
||||
const n1 = n % 10, n2 = n % 100;
|
||||
if ( n1 === 3 && n2 !== 13 ) return 3;
|
||||
return 5;
|
||||
},
|
||||
|
||||
hungarian: n => (n === 1 || n === 5) ? 1 : 5,
|
||||
italian: n => (n === 11 || n === 8 || n === 80 || n === 800) ? 4 : 5,
|
||||
|
||||
swedish(n) {
|
||||
const n1 = n % 10, n2 = n % 100;
|
||||
return ((n1 === 1 || n1 === 2) && (n2 !== 11 && n2 !== 12)) ? 1 : 5;
|
||||
}
|
||||
}
|
||||
|
||||
const PLURAL_TO_NAME = [
|
||||
'zero', // 0
|
||||
'one', // 1
|
||||
'two', // 2
|
||||
'few', // 3
|
||||
'many', // 4
|
||||
'other' // 5
|
||||
];
|
||||
|
||||
const CARDINAL_LANG_TO_TYPE = {},
|
||||
ORDINAL_LANG_TO_TYPE = {};
|
||||
|
||||
for(const type of Object.keys(CARDINAL_TO_LANG))
|
||||
for(const lang of CARDINAL_TO_LANG[type])
|
||||
CARDINAL_LANG_TO_TYPE[lang] = type;
|
||||
|
||||
for(const type of Object.keys(ORDINAL_TO_LANG))
|
||||
for(const lang of ORDINAL_TO_LANG[type])
|
||||
ORDINAL_LANG_TO_TYPE[lang] = type;
|
||||
|
||||
function executePlural(fn, input) {
|
||||
input = Math.abs(Number(input));
|
||||
const i = Math.floor(input);
|
||||
let v, t;
|
||||
|
||||
if ( i === input ) {
|
||||
v = 0;
|
||||
t = 0;
|
||||
} else {
|
||||
t = `${input}`.split('.')[1]
|
||||
v = t ? t.length : 0;
|
||||
t = t ? Number(t) : 0;
|
||||
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 PLURAL_TO_NAME[fn(
|
||||
input,
|
||||
i,
|
||||
v,
|
||||
t
|
||||
)]
|
||||
return cardinal_i18n.select(input);
|
||||
}
|
||||
|
||||
let ordinal_i18n: Intl.PluralRules | null = null,
|
||||
ordinal_locale: string | null = null;
|
||||
|
||||
export function getCardinalName(locale, input) {
|
||||
let type = CARDINAL_LANG_TO_TYPE[locale];
|
||||
if ( ! type ) {
|
||||
const idx = locale.indexOf('-');
|
||||
type = (idx !== -1 && CARDINAL_LANG_TO_TYPE[locale.slice(0, idx)]) || 'other';
|
||||
CARDINAL_LANG_TO_TYPE[locale] = type;
|
||||
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 executePlural(CARDINAL_TYPES[type], input);
|
||||
}
|
||||
|
||||
export function getOrdinalName(locale, input) {
|
||||
let type = ORDINAL_LANG_TO_TYPE[locale];
|
||||
if ( ! type ) {
|
||||
const idx = locale.indexOf('-');
|
||||
type = (idx !== -1 && ORDINAL_LANG_TO_TYPE[locale.slice(0, idx)]) || 'other';
|
||||
ORDINAL_LANG_TO_TYPE[locale] = type;
|
||||
}
|
||||
|
||||
return executePlural(ORDINAL_TYPES[type], input);
|
||||
return ordinal_i18n.select(input);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue