1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-27 12:55:55 +00:00

More type progress.

This commit is contained in:
SirStendec 2023-11-16 18:41:50 -05:00
parent fed7d3e103
commit 136a2491c8
42 changed files with 3451 additions and 2311 deletions

View file

@ -25,7 +25,10 @@
},
"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",
@ -66,7 +69,7 @@
"dependencies": {
"@ffz/icu-msgparser": "^2.0.0",
"@popperjs/core": "^2.11.8",
"crypto-js": "^3.3.0",
"crypto-js": "^4.2.0",
"dayjs": "^1.10.7",
"denoflare-mqtt": "^0.0.2",
"displacejs": "^1.4.1",
@ -74,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",

41
pnpm-lock.yaml generated
View file

@ -18,8 +18,8 @@ dependencies:
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,9 +97,18 @@ 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
@ -592,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:
@ -638,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
@ -701,6 +718,12 @@ 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
@ -2173,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):
@ -3701,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:
@ -5652,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==}

View file

@ -18,7 +18,7 @@ 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

View file

@ -5,13 +5,33 @@
// ============================================================================
import {DEBUG, SERVER} from 'utilities/constants';
import Module from 'utilities/module';
import {has, deep_copy} from 'utilities/object';
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';
declare module 'utilities/types' {
interface ModuleMap {
experiments: ExperimentManager;
}
interface ProviderTypeMap {
'experiment-overrides': Record<string, unknown>
}
}
declare global {
interface Window {
__twilightSettings?: {
experiments?: Record<string, TwitchExperimentData>;
}
}
}
const OVERRIDE_COOKIE = 'experiment_overrides',
COOKIE_OPTIONS = {
expires: 7,
@ -21,9 +41,68 @@ const OVERRIDE_COOKIE = 'experiment_overrides',
// 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
// 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: `:changed:${string}`]: [new_value: any, old_value: any];
[key: `:twitch-changed:${string}`]: [new_value: string | null, old_value: string | null];
}
// ============================================================================
// 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,b) {
if ( a.rarity < b.rarity )
@ -44,9 +123,24 @@ function sortExperimentLog(a,b) {
// Experiment Manager
// ============================================================================
export default class ExperimentManager extends Module {
constructor(...args) {
super(...args);
export default class ExperimentManager extends Module<'experiments', ExperimentEvents> {
// Dependencies
settings: SettingsManager = null as any;
// State
unique_id?: string;
experiments: Record<string, FFZExperimentData>;
private cache: Map<string, unknown>;
// Helpers
Cookie: typeof Cookie;
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
this.get = this.getAssignment;
@ -81,23 +175,23 @@ export default class ExperimentManager extends Module {
unique_id: () => this.unique_id,
ffz_data: () => deep_copy(this.experiments),
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),
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: key => this.getAssignment(key),
hasOverride: key => this.hasOverride(key),
setOverride: (key, val) => this.setOverride(key, val),
deleteOverride: key => this.deleteOverride(key),
getAssignment: (key: string) => this.getAssignment(key),
hasOverride: (key: string) => this.hasOverride(key),
setOverride: (key: string, val: any) => this.setOverride(key, val),
deleteOverride: (key: string) => this.deleteOverride(key),
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)
});
this.unique_id = Cookie.get('unique_id');
@ -112,7 +206,7 @@ export default class ExperimentManager extends Module {
if ( DEBUG )
return false;
const ts = this.settings.provider.get('exp-lock', 0);
const ts = this.settings.provider.get<number>('exp-lock', 0);
if ( isNaN(ts) || ! isFinite(ts) )
return true;
@ -129,14 +223,17 @@ export default class ExperimentManager extends Module {
async loadExperiments() {
let data;
let data: Record<string, FFZExperimentData> | null;
try {
data = await fetch(DEBUG ? EXPERIMENTS : `${SERVER}/script/experiments.json?_=${getBuster()}`).then(r =>
r.ok ? r.json() : null);
data = await fetchJSON(DEBUG
? EXPERIMENTS
: `${SERVER}/script/experiments.json?_=${getBuster()}`
);
} catch(err) {
this.log.warn('Unable to load experiment data.', err);
return;
}
if ( ! data )
@ -153,8 +250,8 @@ export default class ExperimentManager extends Module {
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.emit(':changed', key, new_val, old_val);
this.emit(`:changed:${key}`, new_val, old_val);
}
}
@ -162,21 +259,25 @@ export default class ExperimentManager extends Module {
//this.emit(':loaded');
}
/** @internal */
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);
updateExperiment(key: string, data: FFZExperimentData | ExperimentGroup[]) {
this.log.info(`Received updated data for experiment "${key}" via PubSub.`, data);
if ( Array.isArray(data) ) {
if ( ! this.experiments[key] )
return;
if ( data.groups )
this.experiments[key] = data;
else
this.experiments[key].groups = data;
} else if ( data?.groups )
this.experiments[key] = data;
this._rebuildKey(key);
}
@ -261,7 +362,7 @@ export default class ExperimentManager extends Module {
// Twitch Experiments
getTwitchType(type) {
getTwitchType(type: number) {
const core = this.resolve('site')?.getCore?.();
if ( core?.experiments?.getExperimentType )
return core.experiments.getExperimentType(type);
@ -275,10 +376,9 @@ export default class ExperimentManager extends Module {
return type;
}
getTwitchTypeByKey(key) {
const core = this.resolve('site')?.getCore?.(),
exps = core && core.experiments,
exp = exps?.experiments?.[key];
getTwitchTypeByKey(key: string) {
const exps = this.getTwitchExperiments(),
exp = exps?.[key];
if ( exp?.t )
return this.getTwitchType(exp.t);
@ -286,30 +386,67 @@ export default class ExperimentManager extends Module {
return null;
}
getTwitchExperiments() {
getTwitchExperiments(): Record<string, TwitchExperimentData> {
if ( window.__twilightSettings )
return window.__twilightSettings.experiments;
return window.__twilightSettings.experiments ?? {};
const core = this.resolve('site')?.getCore?.();
return core && core.experiments.experiments;
return core && core.experiments.experiments || {};
}
usingTwitchExperiment(key) {
usingTwitchExperiment(key: string) {
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 || [];
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.remove(idx);
Cookie.set(OVERRIDE_COOKIE, overrides, COOKIE_OPTIONS);
disabled.splice(idx, 1);
this._saveOverrideCookie(overrides);
const core = this.resolve('site')?.getCore?.();
if ( core )
@ -318,15 +455,17 @@ export default class ExperimentManager extends Module {
this._rebuildTwitchKey(key, true, value);
}
deleteTwitchOverride(key) {
const overrides = Cookie.getJSON(OVERRIDE_COOKIE),
experiments = overrides?.experiments;
if ( ! experiments || ! has(experiments, key) )
deleteTwitchOverride(key: string) {
const overrides = this._getOverrideCookie(),
experiments = overrides.experiments;
if ( ! has(experiments, key) )
return;
const old_val = experiments[key];
delete experiments[key];
Cookie.set(OVERRIDE_COOKIE, overrides, COOKIE_OPTIONS);
this._saveOverrideCookie(overrides);
const core = this.resolve('site')?.getCore?.();
if ( core )
@ -335,13 +474,14 @@ export default class ExperimentManager extends Module {
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);
hasTwitchOverride(key: string) { // eslint-disable-line class-methods-use-this
const overrides = this._getOverrideCookie(),
experiments = overrides.experiments;
return has(experiments, key);
}
getTwitchAssignment(key, channel = null) {
getTwitchAssignment(key: string, channel: string | null = null) {
const core = this.resolve('site')?.getCore?.(),
exps = core && core.experiments;
@ -355,22 +495,47 @@ export default class ExperimentManager extends Module {
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] )
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) {
getTwitchKeyFromName(name: string) {
const experiments = this.getTwitchExperiments();
if ( ! experiments )
return undefined;
return;
name = name.toLowerCase();
for(const key in experiments)
@ -381,30 +546,37 @@ export default class ExperimentManager extends Module {
}
}
getTwitchAssignmentByName(name, channel = null) {
return this.getTwitchAssignment(this.getTwitchKeyFromName(name), channel);
getTwitchAssignmentByName(name: string, channel: string | null = null) {
const key = this.getTwitchKeyFromName(name);
if ( ! key )
return null;
return this.getTwitchAssignment(key, channel);
}
_rebuildTwitchKey(key, is_set, new_val) {
_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] :
undefined;
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);
this.emit(`:twitch-changed:${key}`, value);
this.emit(':twitch-changed', key, value, old_val);
this.emit(`:twitch-changed:${key}`, value, old_val);
}
}
// FFZ Experiments
setOverride(key, value = null) {
const overrides = this.settings.provider.get('experiment-overrides') || {};
setOverride(key: string, value: unknown = null) {
const overrides = this.settings.provider.get('experiment-overrides', {});
overrides[key] = value;
this.settings.provider.set('experiment-overrides', overrides);
@ -412,25 +584,30 @@ export default class ExperimentManager extends Module {
this._rebuildKey(key);
}
deleteOverride(key) {
deleteOverride(key: string) {
const overrides = this.settings.provider.get('experiment-overrides');
if ( ! overrides || ! has(overrides, key) )
return;
delete overrides[key];
this.settings.provider.set('experiment-overrides', overrides);
if ( Object.keys(overrides).length )
this.settings.provider.set('experiment-overrides', overrides);
else
this.settings.provider.delete('experiment-overrides');
this._rebuildKey(key);
}
hasOverride(key) {
hasOverride(key: string) {
const overrides = this.settings.provider.get('experiment-overrides');
return overrides && has(overrides, key);
return overrides ? has(overrides, key): false;
}
getAssignment(key) {
get: <T>(key: string) => T | null;
getAssignment<T>(key: string): T | null {
if ( this.cache.has(key) )
return this.cache.get(key);
return this.cache.get(key) as T;
const experiment = this.experiments[key];
if ( ! experiment ) {
@ -440,14 +617,14 @@ export default class ExperimentManager extends Module {
const overrides = this.settings.provider.get('experiment-overrides'),
out = overrides && has(overrides, key) ?
overrides[key] :
ExperimentManager.selectGroup(key, experiment, this.unique_id);
overrides[key] as T :
ExperimentManager.selectGroup<T>(key, experiment, this.unique_id ?? '');
this.cache.set(key, out);
return out;
}
_rebuildKey(key) {
_rebuildKey(key: string) {
if ( ! this.cache.has(key) )
return;
@ -456,13 +633,17 @@ export default class ExperimentManager extends Module {
const new_val = this.getAssignment(key);
if ( new_val !== old_val ) {
this.emit(':changed', key, new_val);
this.emit(`:changed:${key}`, new_val);
this.emit(':changed', key, new_val, old_val);
this.emit(`:changed:${key}`, new_val, old_val);
}
}
static selectGroup(key, experiment, unique_id) {
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);
@ -471,7 +652,7 @@ export default class ExperimentManager extends Module {
for(const group of experiment.groups) {
value -= group.weight / total;
if ( value <= 0 )
return group.value;
return group.value as T;
}
return null;

View file

@ -7,6 +7,20 @@
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>;

View file

@ -16,7 +16,7 @@ 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';
@ -138,7 +138,7 @@ class FrankerFaceZ extends Module {
this.inject('site', Site);
this.inject('addons', AddonManager);
this.register('vue', Vue);
this.register('vue', VueModule);
// ========================================================================

View file

@ -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 ) {

View file

@ -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') }}

View file

@ -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.app/FFZ_SPECIAL_FEED::stendec.dev"
: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() {

View file

@ -0,0 +1,88 @@
<template>
<div v-if="feed">
<chat-rich
v-for="entry in feed"
: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>

View file

@ -34,6 +34,20 @@ declare global {
}
}
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. */
@ -482,10 +496,17 @@ 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();

View file

@ -20,6 +20,16 @@ declare global {
}
}
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

View file

@ -8,6 +8,10 @@ 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 './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 './typehandlers';
* @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;
}
}

View file

@ -4,29 +4,31 @@
// Settings System
// ============================================================================
import { DEBUG } from 'utilities/constants';
import Module, { GenericModule, buildAddonProxy } from 'utilities/module';
import {deep_equals, has, debounce, deep_copy} from 'utilities/object';
import {parse as parse_path} from 'utilities/path-parser';
import {PathNode, parse as parse_path} from 'utilities/path-parser';
import SettingsProfile from './profile';
import SettingsContext from './context';
//import MigrationManager from './migration';
import * as PROCESSORS from './processors';
import * as VALIDATORS from './validators';
import * as FILTERS from './filters';
import * as CLEARABLES from './clearables';
import type { SettingsProfileMetadata, ContextData, ExportedFullDump, SettingsClearable, SettingsDefinition, SettingsProcessor, SettingsUiDefinition, SettingsValidator } from './types';
import type { FilterType } from '../utilities/filtering';
import type { SettingsProfileMetadata, ContextData, ExportedFullDump, SettingsClearable, SettingDefinition, SettingProcessor, SettingUiDefinition, SettingValidator, SettingType, ExportedBlobMetadata, SettingsKeys, AllSettingsKeys, ConcreteLocalStorageData } from './types';
import type { FilterType } from 'utilities/filtering';
import { AdvancedSettingsProvider, IndexedDBProvider, LocalStorageProvider, Providers, type SettingsProvider } from './providers';
import type { AddonInfo } from '../utilities/types';
import type { AddonInfo, SettingsTypeMap } from 'utilities/types';
export {parse as parse_path} from 'utilities/path-parser';
function postMessage(target: Window, msg) {
// TODO: Special types for msg.
function postMessage(target: MessageEventSource, msg: any) {
try {
target.postMessage(msg, '*');
(target as Window).postMessage(msg, '*');
return true;
} catch(err) {
return false;
@ -36,14 +38,33 @@ function postMessage(target: Window, msg) {
export const NO_SYNC_KEYS = ['session'];
// ============================================================================
// Registration
// ============================================================================
declare module 'utilities/types' {
interface ModuleEventMap {
settings: SettingsEvents;
}
interface ModuleMap {
settings: SettingsManager;
}
}
// ============================================================================
// Events
// ============================================================================
// TODO: Check settings keys for better typing on events.
export type SettingsEvents = {
[key: `:changed:${string}`]: [value: any, old_value: any];
[key: `:uses_changed:${string}`]: [uses: number[], old_uses: number[]];
[K in keyof SettingsTypeMap as `:changed:${K}`]: [value: SettingsTypeMap[K], old_value: SettingsTypeMap[K]];
} & {
[key: `:uses_changed:${string}`]: [uses: number[] | null, old_uses: number[] | null];
':added-definition': [key: string, definition: SettingsDefinition<any>];
':removed-definition': [key: string, definition: SettingsDefinition<any>];
':added-definition': [key: SettingsKeys, definition: SettingDefinition<any>];
':removed-definition': [key: SettingsKeys, definition: SettingDefinition<any>];
':quota-exceeded': [];
':change-provider': [];
@ -81,29 +102,35 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
// Storage of Things
clearables: Record<string, SettingsClearable>;
filters: Record<string, FilterType<any, ContextData>>;
processors: Record<string, SettingsProcessor<any>>;
processors: Record<string, SettingProcessor<any>>;
providers: Record<string, typeof SettingsProvider>;
validators: Record<string, SettingsValidator<any>>;
validators: Record<string, SettingValidator<any>>;
// Storage of Settings
ui_structures: Map<string, SettingsUiDefinition<any>>;
definitions: Map<string, SettingsDefinition<any> | string[]>;
ui_structures: Map<string, SettingDefinition<any>>;
definitions: Map<string, SettingDefinition<any> | string[]>;
// Storage of State
provider: SettingsProvider | null = null;
// The provider *can* technically be null but it won't ever be in practice.
// So we don't set the type to null to avoid making annoying checks everywhere.
provider: SettingsProvider = null as any;
main_context: SettingsContext;
private _context_proxies: Set<MessageEventSource>;
private _update_timer?: ReturnType<typeof setTimeout> | null;
private _time_timer?: ReturnType<typeof setTimeout> | null;
private _time_next?: number | null;
private _active_provider: string = 'local';
private _idb: IndexedDBProvider | null = null;
private _provider_waiter?: Promise<SettingsProvider> | null;
private _provider_resolve?: ((input: SettingsProvider) => void) | null;
private __contexts: SettingsContext[];
private __profiles: SettingsProfile[];
/** @internal */
__contexts: SettingsContext[];
/** @internal */
__profiles: SettingsProfile[];
private __profile_ids: Record<number, SettingsProfile | null>;
/**
@ -123,8 +150,6 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
this.providers[key] = provider;
}
const test = this.resolve('load_tracker');
// This cannot be modified at a future time, as providers NEED
// to be ready very early in FFZ intitialization. Seal it.
Object.seal(this.providers);
@ -204,11 +229,11 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
// Also create the main context as early as possible.
this.main_context = new SettingsContext(this);
this.main_context.on('changed', (key, new_value, old_value) => {
this.main_context.on('changed', (key: keyof SettingsTypeMap, new_value, old_value) => {
this.emit(`:changed:${key}`, new_value, old_value);
});
this.main_context.on('uses_changed', (key, new_uses, old_uses) => {
this.main_context.on('uses_changed', (key: keyof SettingsTypeMap, new_uses, old_uses) => {
this.emit(`:uses_changed:${key}`, new_uses, old_uses);
});
@ -218,7 +243,7 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
window.addEventListener('message', event => {
const type = event.data?.ffz_type;
if ( type === 'request-context' ) {
if ( type === 'request-context' && event.source ) {
this._context_proxies.add(event.source);
this._updateContextProxies(event.source);
}
@ -234,7 +259,7 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
this.enable();
}
_updateContextProxies(proxy) {
_updateContextProxies(proxy?: MessageEventSource) {
if ( ! proxy && ! this._context_proxies.size )
return;
@ -266,7 +291,7 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
generateLog() {
const out = [];
for(const [key, value] of this.main_context.__cache.entries())
for(const [key, value] of (this.main_context as any).__cache.entries())
out.push(`${key}: ${JSON.stringify(value)}`);
return out.join('\n');
@ -390,6 +415,9 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
if ( ! this.__ls_timer )
this.__ls_timer = setTimeout(this._updateLS, 0);
}
}
private _hookLS() {
@ -532,7 +560,7 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
out.file('settings.json', JSON.stringify(settings));
// Blob Settings
const metadata = {};
const metadata: Record<string, ExportedBlobMetadata> = {};
if ( this.provider instanceof AdvancedSettingsProvider && this.provider.supportsBlobs ) {
const keys = await this.provider.blobKeys();
@ -542,7 +570,9 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
if ( ! blob )
continue;
const md = {key};
const md: ExportedBlobMetadata = {
key
};
if ( blob instanceof File ) {
md.type = 'file';
@ -664,9 +694,6 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
if ( this.providers[wanted] ) {
const provider = new (this.providers[wanted] as any)(this) as SettingsProvider;
if ( wanted === 'idb' )
this._idb = provider as IndexedDBProvider;
this._active_provider = wanted;
return provider;
}
@ -721,7 +748,7 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
return;
const old_provider = this.provider;
this.provider = null;
this.provider = null as any;
// Let all other tabs know what's up.
old_provider.broadcastTransfer();
@ -1101,15 +1128,38 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
// Context Helpers
// ========================================================================
context(env) { return this.main_context.context(env) }
get(key) { return this.main_context.get(key); }
getChanges(key, fn, ctx) { return this.main_context.getChanges(key, fn, ctx); }
onChange(key, fn, ctx) { return this.main_context.onChange(key, fn, ctx); }
uses(key: string) { return this.main_context.uses(key) }
update(key: string) { return this.main_context.update(key) }
context(env: ContextData) { return this.main_context.context(env) }
updateContext(context: Partial<ContextData>) { return this.main_context.updateContext(context) }
setContext(context: Partial<ContextData>) { return this.main_context.setContext(context) }
get<
K extends AllSettingsKeys,
TValue = SettingType<K>
>(
key: K
): TValue { return this.main_context.get(key); }
getChanges<
K extends SettingsKeys,
TValue = SettingType<K>
>(
key: K,
fn: (val: TValue) => void,
ctx?: any
) { return this.main_context.getChanges(key, fn, ctx); }
onChange<
K extends SettingsKeys,
TValue = SettingType<K>
>(
key: K,
fn: (val: TValue) => void,
ctx?: any
) { return this.main_context.onChange(key, fn, ctx); }
uses<K extends AllSettingsKeys>(key: K) { return this.main_context.uses(key) }
update<K extends SettingsKeys>(key: K) { return this.main_context.update(key) }
updateContext(context: ContextData) { return this.main_context.updateContext(context) }
setContext(context: ContextData) { return this.main_context.setContext(context) }
// ========================================================================
@ -1123,11 +1173,18 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
const overrides: Record<string, any> = {},
is_dev = DEBUG || addon?.dev;
overrides.add = <T,>(key: string, definition: SettingsDefinition<T>) => {
overrides.add = <
K extends SettingsKeys,
TValue = SettingType<K>,
>(key: K, definition: SettingDefinition<TValue>) => {
return this.add(key, definition, addon_id);
};
overrides.addUI = <T,>(key: string, definition: SettingsUiDefinition<T>) => {
// TODO: Update addUI here too
overrides.addUI = <
K extends string,
TValue = K extends SettingsKeys ? SettingType<K> : unknown,
>(key: K, definition: SettingUiDefinition<TValue>) => {
return this.addUI(key, definition, addon_id);
};
@ -1142,7 +1199,10 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
// Definitions
// ========================================================================
add<T>(key: string, definition: SettingsDefinition<T>, source?: string) {
add<
K extends SettingsKeys,
TValue = SettingType<K>
>(key: K, definition: SettingDefinition<TValue>, source?: string) {
const old_definition = this.definitions.get(key),
required_by = (Array.isArray(old_definition)
@ -1179,8 +1239,10 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
if ( ! ui.key && ui.title )
ui.key = ui.title.toSnakeCase();
if ( (ui.component === 'setting-select-box' || ui.component === 'setting-combo-box') && Array.isArray(ui.data) && ! ui.no_i18n
&& key !== 'ffzap.core.highlight_sound' ) { // TODO: Remove workaround.
if ( (ui.component === 'setting-select-box' ||
ui.component === 'setting-combo-box') &&
Array.isArray(ui.data) && ! ui.no_i18n
) {
const i18n_base = `${ui.i18n_key || `setting.entry.${key}`}.values`;
for(const value of ui.data) {
if ( value.i18n_key === undefined && value.value !== undefined )
@ -1190,7 +1252,7 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
}
if ( definition.changed )
this.on(`:changed:${key}`, definition.changed);
this.on(`:changed:${key}` as any, definition.changed);
this.definitions.set(key, definition);
@ -1220,36 +1282,39 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
if ( Array.isArray(definition.requires) )
for(const req_key of definition.requires) {
let req = this.definitions.get(req_key);
if ( req.required_by )
req = req.required_by;
if ( Array.isArray(req) ) {
const idx = req.indexOf(key);
if ( idx !== -1 )
req.splice(idx, 1);
}
} else if ( req?.required_by )
req = req.required_by;
}
if ( definition.changed )
this.off(`:changed:${key}`, definition.changed);
this.off(`:changed:${key}` as any, definition.changed);
this.emit(':removed-definition', key, definition);
this.emit(':removed-definition', key as any, definition);
}
addUI(key, definition, source) {
if ( typeof key === 'object' ) {
for(const k in key)
if ( has(key, k) )
this.add(k, key[k]);
return;
}
// TODO: Update this because addUI doesn't use keys or types
addUI<
K extends string,
TValue = K extends SettingsKeys ? SettingType<K> : unknown
>(key: K, definition: Partial<SettingUiDefinition<TValue>>, source?: string) {
if ( ! definition.ui )
definition = {ui: definition};
let def: SettingDefinition<TValue>;
if ( (definition as any).ui )
def = (definition as any);
else
def = {
ui: definition as SettingUiDefinition<TValue>
} as SettingDefinition<TValue>;
definition.__source = source;
def.__source = source;
const ui = definition.ui;
const ui = def.ui as SettingUiDefinition<TValue>;
ui.path_tokens = ui.path_tokens ?
format_path_tokens(ui.path_tokens) :
ui.path ?
@ -1260,12 +1325,12 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
ui.key = ui.title.toSnakeCase();
const old_definition = this.ui_structures.get(key);
this.ui_structures.set(key, definition);
this.ui_structures.set(key, def);
// Do not re-emit `added-definition` when re-adding an existing
// setting. Prevents the settings UI from goofing up.
if ( ! old_definition )
this.emit(':added-definition', key, definition);
this.emit(':added-definition', key as any, def);
}
@ -1288,7 +1353,7 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
}
addProcessor(key: string | Record<string, SettingsProcessor<any>>, processor?: SettingsProcessor<any>) {
addProcessor(key: string | Record<string, SettingProcessor<any>>, processor?: SettingProcessor<any>) {
if ( typeof key === 'object' ) {
for(const [k, value] of Object.entries(key))
this.addProcessor(k, value);
@ -1300,7 +1365,7 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
this.processors[key] = processor;
}
getProcessor<T>(key: string): SettingsProcessor<T> | null {
getProcessor<T>(key: string): SettingProcessor<T> | null {
return this.processors[key] ?? null;
}
@ -1308,7 +1373,7 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
return deep_copy(this.processors);
}
addValidator(key: string | Record<string, SettingsValidator<any>>, validator?: SettingsValidator<any>) {
addValidator(key: string | Record<string, SettingValidator<any>>, validator?: SettingValidator<any>) {
if ( typeof key === 'object' ) {
for(const [k, value] of Object.entries(key))
this.addValidator(k, value);
@ -1320,7 +1385,7 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
this.validators[key] = validator;
}
getValidator<T>(key: string): SettingsValidator<T> | null {
getValidator<T>(key: string): SettingValidator<T> | null {
return this.validators[key] ?? null;
}
@ -1330,14 +1395,14 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
}
export function format_path_tokens(tokens) {
export function format_path_tokens(tokens: (string | PathNode)[]) {
for(let i=0, l = tokens.length; i < l; i++) {
const token = tokens[i];
if ( typeof token === 'string' ) {
tokens[i] = {
key: token.toSnakeCase(),
title: token
}
};
continue;
}
@ -1346,5 +1411,5 @@ export function format_path_tokens(tokens) {
token.key = token.title.toSnakeCase();
}
return tokens;
return tokens as PathNode[];
}

View file

@ -1,6 +1,6 @@
'use strict';
import type { SettingsDefinition, SettingsProcessor, SettingsUiDefinition } from "./types";
import type { SettingDefinition, SettingProcessor, SettingUiDefinition } from "./types";
const BAD = Symbol('BAD');
type BadType = typeof BAD;
@ -8,7 +8,7 @@ type BadType = typeof BAD;
function do_number(
input: number | BadType,
default_value: number,
definition: SettingsUiDefinition<number>
definition: SettingUiDefinition<number>
) {
if ( typeof input !== 'number' || isNaN(input) || ! isFinite(input) )
input = BAD;
@ -38,7 +38,7 @@ function do_number(
return input === BAD ? default_value : input;
}
export const to_int: SettingsProcessor<number> = (
export const to_int: SettingProcessor<number> = (
value,
default_value,
definition
@ -51,7 +51,7 @@ export const to_int: SettingsProcessor<number> = (
return do_number(value as number, default_value, definition);
}
export const to_float: SettingsProcessor<number> = (
export const to_float: SettingProcessor<number> = (
value: unknown,
default_value,
definition

View file

@ -10,7 +10,7 @@ import { isValidBlob, deserializeBlob, serializeBlob, BlobLike, SerializedBlobLi
import {EventEmitter} from 'utilities/events';
import {has, once} from 'utilities/object';
import type SettingsManager from '.';
import type { OptionalArray, OptionalPromise } from '../utilities/types';
import type { OptionalArray, OptionalPromise, ProviderTypeMap } from '../utilities/types';
const DB_VERSION = 1,
NOT_WWW_TWITCH = window.location.host !== 'www.twitch.tv',
@ -96,10 +96,23 @@ export abstract class SettingsProvider extends EventEmitter<ProviderEvents> {
abstract flush(): OptionalPromise<void>;
abstract get<T>(key: string, default_value: T): T;
abstract get<T>(key: string): T | null;
abstract get<K extends keyof ProviderTypeMap>(
key: K,
default_value: ProviderTypeMap[K]
): ProviderTypeMap[K];
abstract get<K extends keyof ProviderTypeMap>(
key: K
): ProviderTypeMap[K] | null;
abstract get<T>(
key: Exclude<string, keyof ProviderTypeMap>,
default_value: T
): T;
abstract get<T>(
key: Exclude<string, keyof ProviderTypeMap>
): T | null;
abstract set(key: string, value: any): void;
abstract set<K extends keyof ProviderTypeMap>(key: K, value: ProviderTypeMap[K]): void;
abstract set<K extends string>(key: Exclude<K, keyof ProviderTypeMap>, value: unknown): void;
abstract delete(key: string): void;
abstract clear(): void;
@ -301,8 +314,10 @@ export class LocalStorageProvider extends SettingsProvider {
}
}
get<T>(key: string, default_value?: T): T {
get<T>(
key: string,
default_value?: T
): T {
return this._cached.has(key)
? this._cached.get(key)
: default_value;

View file

@ -1,6 +1,7 @@
import type SettingsManager from ".";
import type { FilterData } from "../utilities/filtering";
import type { OptionalPromise, OptionallyCallable, RecursivePartial } from "../utilities/types";
import type { PathNode } from "../utilities/path-parser";
import type { ExtractSegments, ExtractType, JoinKeyPaths, ObjectKeyPaths, OptionalPromise, OptionallyCallable, RecursivePartial, SettingsTypeMap } from "../utilities/types";
import type SettingsContext from "./context";
import type { SettingsProvider } from "./providers";
@ -24,7 +25,7 @@ export type SettingsClearable = {
// Context
export type ContextData = RecursivePartial<{
export interface ConcreteContextData {
addonDev: boolean;
category: string;
@ -62,17 +63,52 @@ export type ContextData = RecursivePartial<{
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[];
};
// Definitions
export type SettingsDefinition<T> = {
export type SettingDefinition<T> = {
default: T,
type?: string;
process?(this: SettingsManager, ctx: SettingsContext, val: T): T;
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[];
@ -82,17 +118,26 @@ export type SettingsDefinition<T> = {
__source?: string | null;
// UI Stuff
ui?: SettingsUiDefinition<T>;
ui?: SettingUiDefinition<T>;
// Reactivity
changed?: () => void;
};
export type SettingsUiDefinition<T> = {
export type SettingUiDefinition<T> = {
i18n_key?: string;
key: string;
path: string;
path_tokens?: PathNode[];
component: string;
no_i18n?: boolean;
// TODO: Handle this better.
data: any;
process?: string;
/**
@ -128,6 +173,15 @@ export type ExportedFullDump = {
};
export type ExportedBlobMetadata = {
key: string;
type?: string;
name?: string;
modified?: number;
mime?: string;
};
// Profiles
@ -153,16 +207,16 @@ export type SettingsProfileMetadata = {
// Processors
export type SettingsProcessor<T> = (
export type SettingProcessor<T> = (
input: unknown,
default_value: T,
definition: SettingsUiDefinition<T>
definition: SettingUiDefinition<T>
) => T;
// Validators
export type SettingsValidator<T> = (
export type SettingValidator<T> = (
value: T,
definition: SettingsUiDefinition<T>
definition: SettingUiDefinition<T>
) => boolean;

View file

@ -1,9 +1,9 @@
'use strict';
import type { SettingsUiDefinition, SettingsValidator } from "./types";
import type { SettingUiDefinition, SettingValidator } from "./types";
function do_number(value: any, definition: SettingsUiDefinition<number>) {
function do_number(value: any, definition: SettingUiDefinition<number>) {
if ( typeof value !== 'number' || isNaN(value) || ! isFinite(value) )
return false;
@ -29,7 +29,7 @@ function do_number(value: any, definition: SettingsUiDefinition<number>) {
return true;
}
export const process_to_int: SettingsValidator<number> = (
export const process_to_int: SettingValidator<number> = (
value,
definition
) => {
@ -41,7 +41,7 @@ export const process_to_int: SettingsValidator<number> = (
return do_number(value, definition);
}
export const process_to_float: SettingsValidator<number> = (
export const process_to_float: SettingValidator<number> = (
value,
definition
) => {

View file

@ -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;

View file

@ -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;
}

View file

@ -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;

View file

@ -1,682 +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 {
/*
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),
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
View 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;
}
}

View file

@ -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) {
for(const watcher of this._live_watching)
watcher.checkElements(muts);
private _checkWatchers(muts: Node[]) {
if ( this._live_watching )
for(const watcher of this._live_watching)
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,7 +296,8 @@ export class ElementalWrapper extends EventEmitter {
}
_schedule() {
clearTimeout(this._stimer);
if ( this._stimer )
clearTimeout(this._stimer);
this._stimer = null;
if ( this.limit === 0 || this.count < this.limit )
@ -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);
}
}

View file

@ -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,10 +198,12 @@ export default class FineRouter extends Module {
return;
}
this.route_names[route] = name;
if ( name ) {
this.route_names[route] = name;
if ( process )
this.emit(':updated-route-names');
if ( process )
this.emit(':updated-route-names');
}
}
route(name, path, domain = null, state_fn = null, process = true) {
@ -173,6 +219,7 @@ export default class FineRouter extends Module {
this._pickRoute();
this.emit(':updated-routes');
}
return;
}

View file

@ -1,798 +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);
}
once(event, fn, ctx) {
this._maybeWrap(event);
return super.once(event, fn, ctx);
}
many(event, ttl, fn, ctx) {
this._maybeWrap(event);
return super.many(event, ttl, fn, ctx);
}
waitFor(event) {
this._maybeWrap(event);
return super.waitFor(event);
}
}

1032
src/utilities/compat/fine.ts Normal file

File diff suppressed because it is too large Load diff

View 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;
};

View file

@ -5,7 +5,7 @@
// It controls Twitch PubSub.
// ============================================================================
import Module, { GenericModule, ModuleEvents } from 'utilities/module';
import Module, { GenericModule } from 'utilities/module';
import { FFZEvent } from 'utilities/events';
declare global {
@ -15,6 +15,16 @@ declare global {
}
}
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
@ -236,51 +246,7 @@ export default class Subpump extends Module<'site.subpump', SubpumpEvents> {
}
}
/*
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: string, message: any) {
simulateMessage(topic: string, message: any) {
if ( ! this.instance )
throw new Error('No PubSub instance available');

View file

@ -137,20 +137,24 @@ export class EventEmitter<
this.__dead_events = 0;
}
private __cleanListeners() {
protected __cleanListeners() {
if ( ! this.__dead_events )
return;
return [];
const new_listeners: Record<string, ListenerInfo[]> = {},
old_listeners = this.__listeners;
old_listeners = this.__listeners,
removed: string[] = [];
for(const [key, val] of Object.entries(old_listeners)) {
if ( val?.length )
new_listeners[key] = val;
else
removed.push(key);
}
this.__listeners = new_listeners;
this.__dead_events = 0;
return removed;
}
private __sortListeners(event: string) {
@ -266,6 +270,8 @@ export class EventEmitter<
this.__sortListeners(event);
} else
this.__listeners[event] = [info];
return this;
}
/**
@ -334,6 +340,8 @@ export class EventEmitter<
this.__sortListeners(event);
} else
this.__listeners[event] = [info];
return this;
}
/**
@ -409,7 +417,7 @@ export class EventEmitter<
this.off(evt as any, fn, ctx);
}
return;
return this;
}
if ( this.__running.has(event) )
@ -417,7 +425,7 @@ export class EventEmitter<
let list = this.__listeners[event];
if ( ! list )
return;
return this;
// If fn and ctx were both not provided, then clear the list.
if ( ! fn && ! ctx )
@ -439,6 +447,8 @@ export class EventEmitter<
// dead event so we can clean it up later.
if ( ! list )
this.__dead_events++;
return this;
}
/**

View file

@ -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;

View file

@ -5,10 +5,10 @@
// Modules are cool.
// ============================================================================
import EventEmitter, { EventKey, EventListener, EventMap, NamespacedEventArgs, NamespacedEventKey, NamespacedEvents } from 'utilities/events';
import EventEmitter, { EventListener, EventMap, NamespacedEventArgs, NamespacedEventKey, NamespacedEvents } from 'utilities/events';
import {has} from 'utilities/object';
import type Logger from './logging';
import type { AddonInfo, KnownEvents, ModuleKeys, ModuleMap, ModuleMap, ModuleMap, ModuleMap, OptionalPromise } from './types';
import type { AddonInfo, KnownEvents, ModuleKeys, ModuleMap, OptionalPromise } from './types';
import type { Addon } from './addon';
@ -233,7 +233,7 @@ export class Module<
// to need to put conditional checks literally everywhere we use the
// logger system. Just no.
if ( ! this.__log )
this.__log = this.parent && (this.parent as Module).log?.get?.(this.name);
this.__log = this.parent && (this.parent as GenericModule).log?.get?.(this.name);
return this.__log as Logger;
}
@ -805,8 +805,10 @@ export class Module<
}
resolve<
TPath extends ModuleKeys,
TReturn extends GenericModule = ModuleMap[TPath]
TPath extends string,
TReturn = TPath extends keyof ModuleMap
? ModuleMap[TPath]
: GenericModule
>(name: TPath): TReturn | null {
let module = this.__resolve(name);
if ( !(module instanceof Module) )

View file

@ -1,5 +1,6 @@
import {BAD_HOTKEYS, TWITCH_EMOTE_V2, WORD_SEPARATORS} from 'utilities/constants';
import type { ExtractSegments, ExtractType } from 'utilities/types';
const HOP = Object.prototype.hasOwnProperty;
@ -78,8 +79,8 @@ export class TranslatableError extends Error {
}
toString() {
const ffz = window.FrankerFaceZ?.get?.(),
i18n = ffz?.resolve?.('i18n');
const ffz = window.FrankerFaceZ?.get(),
i18n = ffz?.resolve('i18n');
if ( i18n && this.i18n_key )
return i18n.t(this.i18n_key, this.message, this.data);
@ -460,7 +461,7 @@ export function once<TFunc extends AnyFunction, TReturn = Awaited<ReturnType<TFu
* @param b The second array
* @returns Whether or not they match
*/
export function array_equals(a: any[], b: any[]) {
export function array_equals(a?: any[] | null, b?: any[] | null) {
if ( ! Array.isArray(a) || ! Array.isArray(b) || a.length !== b.length )
return false;
@ -655,40 +656,6 @@ export function substr_count(str: string, needle: string) {
// These types are all used by get()
export type ExtractSegments<Input extends string> =
Input extends `${infer Match}.${infer Rest}`
? [ Match, ...ExtractSegments<Rest> ]
: [ Input ];
export type ArrayShift<T extends any[]> = T extends [any, ...infer Rest]
// This is needed to avoid it returning an empty array. There's probably
// a more elegant solution, but I don't know it.
? Rest extends [any, ...any[]]
? Rest
: undefined
: undefined;
export type ExtractType<T, Path extends string[], Key = Path[0], Rest = ArrayShift<Path>> =
Key extends "@each"
? ExtractEach<T, Rest>
:
Key extends "@last"
? T extends any[]
? ExtractEach<T, Rest>
: never
:
Key extends keyof T
? Rest extends string[]
? ExtractType<T[Key], Rest>
: T[Key]
:
null;
export type ExtractEach<T, Rest> =
Rest extends string[]
? { [K in keyof T]: ExtractType<T[K], Rest> }
: T;
/**
* Get a value from an object at a path.
* @param path The path to follow, using periods to go down a level.

View file

@ -15,8 +15,8 @@ type ParseContext = {
export type PathNode = {
title: string;
key: string;
page: boolean;
tab: boolean;
page?: boolean;
tab?: boolean;
}
function parseAST(ctx: ParseContext) {

View file

@ -335,8 +335,10 @@ export class TranslationCore {
}
toLocaleString(thing: any) {
if ( thing && thing.toLocaleString )
return thing.toLocaleString(this._locale);
if ( thing?.toLocaleString )
return thing.toLocaleString(this._locale) as string;
else if ( typeof thing !== 'string' )
return `${thing}`;
return thing;
}
@ -638,171 +640,6 @@ function listToString(list: any[]): string {
// 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']
}
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;
}
return PLURAL_TO_NAME[fn(
input,
i,
v,
t
)]
}
*/
let cardinal_i18n: Intl.PluralRules | null = null,
cardinal_locale: string | null = null;
@ -830,27 +667,3 @@ export function getOrdinalName(locale: string, input: number) {
return ordinal_i18n.select(input);
}
/*
export function getCardinalName(locale: string, input: number) {
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;
}
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);
}*/

View file

@ -5,10 +5,19 @@
// Get data, from Twitch.
// ============================================================================
import Module from 'utilities/module';
import Module, { GenericModule } from 'utilities/module';
import {get, debounce, TranslatableError} from 'utilities/object';
import type Apollo from './compat/apollo';
import type { DocumentNode } from 'graphql';
const LANGUAGE_MATCHER = /^auto___lang_(\w+)$/;
declare module 'utilities/types' {
interface ModuleEventMap {
}
interface ModuleMap {
'site.twitch_data': TwitchData;
}
}
/**
* PaginatedResult
@ -25,10 +34,23 @@ const LANGUAGE_MATCHER = /^auto___lang_(\w+)$/;
* @extends Module
*/
export default class TwitchData extends Module {
constructor(...args) {
super(...args);
this.site = this.parent;
apollo: Apollo = null as any;
site: GenericModule = null as any;
private _waiting_user_ids: Map<string, unknown>;
private _waiting_user_logins: Map<string, unknown>;
private _waiting_stream_ids: Map<string, unknown>;
private _waiting_stream_logins: Map<string, unknown>;
private tag_cache: Map<string, unknown>;
private _waiting_tags: Map<string, unknown>;
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
this.site = this.parent as GenericModule;
this.inject('site.apollo');
@ -41,17 +63,22 @@ export default class TwitchData extends Module {
this.tag_cache = new Map;
this._waiting_tags = new Map;
this._loadTags = debounce(this._loadTags, 50);
this._loadStreams = debounce(this._loadStreams, 50);
// The return type doesn't match, because this method returns
// a void and not a Promise. We don't care.
this._loadStreams = debounce(this._loadStreams, 50) as any;
}
queryApollo(query, variables, options) {
let thing;
if ( ! variables && ! options && query.query )
queryApollo(
query: DocumentNode | {query: DocumentNode, variables: any},
variables?: any,
options?: any
) {
let thing: {query: DocumentNode, variables: any};
if ( ! variables && ! options && 'query' in query && query.query )
thing = query;
else {
thing = {
query,
query: query as DocumentNode,
variables
};
@ -62,13 +89,17 @@ export default class TwitchData extends Module {
return this.apollo.client.query(thing);
}
mutate(mutation, variables, options) {
let thing;
if ( ! variables && ! options && mutation.mutation )
mutate(
mutation: DocumentNode | {mutation: DocumentNode, variables: any},
variables?: any,
options?: any
) {
let thing: {mutation: DocumentNode, variables: any};
if ( ! variables && ! options && 'mutation' in mutation && mutation.mutation )
thing = mutation;
else {
thing = {
mutation,
mutation: mutation as DocumentNode,
variables
};
@ -95,6 +126,7 @@ export default class TwitchData extends Module {
// ========================================================================
async getBadges() {
const data = await this.queryApollo(
await import(/* webpackChunkName: 'queries' */ './data/global-badges.gql')
);
@ -116,7 +148,11 @@ export default class TwitchData extends Module {
* next page of results.
* @returns {PaginatedResult} The results
*/
async getMatchingCategories(query, first = 15, cursor = null) {
async getMatchingCategories(
query: string,
first: number = 15,
cursor: string | null = null
) {
const data = await this.queryApollo(
await import(/* webpackChunkName: 'queries' */ './data/search-category.gql'),
{
@ -852,7 +888,7 @@ export default class TwitchData extends Module {
*
* console.log(await this.twitch_data.getMatchingTags("Rainbo"));
*/
async getMatchingTags(query) {
async getMatchingTags(query: string) {
const data = await this.queryApollo({
query: await import(/* webpackChunkName: 'queries' */ './data/tag-search.gql'),
variables: {

View file

@ -1,7 +1,5 @@
import type ExperimentManager from "../experiments";
import type TranslationManager from "../i18n";
import type LoadTracker from "../load_tracker";
import type { LoadEvents } from "../load_tracker";
import type Chat from "../modules/chat";
import type Actions from "../modules/chat/actions/actions";
import type Badges from "../modules/chat/badges";
@ -11,25 +9,17 @@ import type Overrides from "../modules/chat/overrides";
import type EmoteCard from "../modules/emote_card";
import type LinkCard from "../modules/link_card";
import type MainMenu from "../modules/main_menu";
import type Metadata from "../modules/metadata";
import type TooltipProvider from "../modules/tooltips";
import type { TooltipEvents } from "../modules/tooltips";
import type TranslationUI from "../modules/translation_ui";
import type PubSub from "../pubsub";
import type { SettingsEvents } from "../settings";
import type SettingsManager from "../settings";
import type SocketClient from "../socket";
import type StagingSelector from "../staging";
import type Apollo from "./compat/apollo";
import type Elemental from "./compat/elemental";
import type Fine from "./compat/fine";
import type Subpump from "./compat/subpump";
import type { SubpumpEvents } from "./compat/subpump";
import type WebMunch from "./compat/webmunch";
import type CSSTweaks from "./css-tweaks";
import type { NamespacedEvents } from "./events";
import type TwitchData from "./twitch-data";
import type Vue from "./vue";
/**
* AddonInfo represents the data contained in an add-on's manifest.
@ -98,6 +88,52 @@ export type AddonInfo = {
};
// These types are used by get()
export type ExtractSegments<Input extends string> =
Input extends `${infer Match}.${infer Rest}`
? [ Match, ...ExtractSegments<Rest> ]
: [ Input ];
export type ArrayShift<T extends any[]> = T extends [any, ...infer Rest]
// This is needed to avoid it returning an empty array. There's probably
// a more elegant solution, but I don't know it.
? Rest extends [any, ...any[]]
? Rest
: undefined
: undefined;
export type ExtractType<T, Path extends string[], Key = Path[0], Rest = ArrayShift<Path>> =
Key extends "@each"
? ExtractEach<T, Rest>
:
Key extends "@last"
? T extends any[]
? ExtractEach<T, Rest>
: never
:
Key extends keyof T
? Rest extends string[]
? ExtractType<T[Key], Rest>
: T[Key]
:
never;
export type ExtractEach<T, Rest> =
Rest extends string[]
? { [K in keyof T]: ExtractType<T[K], Rest> }
: T;
export type AnyFunction = (...args: any[]) => any;
export interface ClassType<T> {
new (...args: any[]): T;
prototype: T;
}
export type OptionallyThisCallable<TThis, TArgs extends any[], TReturn> = TReturn | ((this: TThis, ...args: TArgs) => TReturn);
export type OptionallyCallable<TArgs extends any[], TReturn> = TReturn | ((...args: TArgs) => TReturn);
@ -105,6 +141,11 @@ export type OptionalPromise<T> = T | Promise<T>;
export type OptionalArray<T> = T | T[];
export type UnionToIntersection<Union> = (
Union extends any ? (k: Union) => void : never
) extends (k: infer Intersection) => void ? Intersection : never;
export type RecursivePartial<T> = {
[K in keyof T]?: T[K] extends object
? RecursivePartial<T[K]>
@ -112,6 +153,47 @@ export type RecursivePartial<T> = {
};
export type JoinKeyPaths<K, P, Separator extends string = '.'> = K extends string ?
P extends string ?
`${K}${P extends '' ? '' : Separator}${P}`
: never : never;
export type ObjectKeyPaths<T, Separator extends string = '.', Prefix extends string = ''> =
T extends object ?
(Prefix extends '' ? never : Prefix) | { [K in keyof T]-?: JoinKeyPaths<K, ObjectKeyPaths<T[K], Separator>, Separator> }[keyof T]
: Prefix;
export type ExtractFunctionNames<T, IncludeOptional extends boolean = false> = {
[K in keyof T]:
T[K] extends AnyFunction
? K
: IncludeOptional extends true ?
T[K] extends AnyFunction | undefined
? K
: never
: never;
}[keyof T];
/**
* Extract all of the functions from a type. If IncludeOptional is set to
* true, then also include functions that are possibly undefined. If
* UnwrapOptional is set to true, which it is by default, the possibly
* undefined functions are unwrapped in the resulting type. This makes it
* easier to extract function parameters, etc.
*/
export type ExtractFunctions<T, IncludeOptional extends boolean = false, UnwrapOptional extends boolean = true> = {
[K in ExtractFunctionNames<T, IncludeOptional>]: T[K] extends undefined | (infer U)
? UnwrapOptional extends true
? U
: T[K]
: T[K];
};
export type MaybeParameters<T> = T extends AnyFunction ? Parameters<T> : never[];
export type ClientVersion = {
major: number;
minor: number;
@ -135,6 +217,15 @@ export type Mousetrap = {
export type DomFragment = Node | string | null | undefined | DomFragment[];
export interface SettingsTypeMap {
};
export interface ProviderTypeMap {
};
// TODO: Move this event into addons.
type AddonEvent = {
@ -142,15 +233,9 @@ type AddonEvent = {
};
export type KnownEvents =
AddonEvent &
NamespacedEvents<'load_tracker', LoadEvents> &
NamespacedEvents<'settings', SettingsEvents> &
NamespacedEvents<'site.subpump', SubpumpEvents> &
NamespacedEvents<'tooltips', TooltipEvents>;
export interface ModuleEventMap { };
export type ModuleMap = {
export interface ModuleMap {
'chat': Chat;
'chat.actions': Actions;
'chat.badges': Badges;
@ -161,24 +246,22 @@ export type ModuleMap = {
'experiments': ExperimentManager;
'i18n': TranslationManager;
'link_card': LinkCard;
'load_tracker': LoadTracker;
'main_menu': MainMenu;
'metadata': Metadata;
'pubsub': PubSub;
'settings': SettingsManager;
'site.apollo': Apollo;
'site.css_tweaks': CSSTweaks;
'site.elemental': Elemental;
'site.fine': Fine;
'site.subpump': Subpump;
'site.twitch_data': TwitchData;
'site.web_munch': WebMunch;
'socket': SocketClient;
'staging': StagingSelector;
'tooltips': TooltipProvider;
'translation_ui': TranslationUI;
'vue': Vue;
};
export type KnownEvents = AddonEvent & UnionToIntersection<{
[K in keyof ModuleEventMap]: NamespacedEvents<K, ModuleEventMap[K]>
}[keyof ModuleEventMap]>;
export type ModuleKeys = string & keyof ModuleMap;

View file

@ -5,14 +5,46 @@
// Loads Vue + Translation Shim
// ============================================================================
import Module from 'utilities/module';
import Module, { GenericModule } from 'utilities/module';
import {has} from 'utilities/object';
import {DEBUG} from 'utilities/constants';
import type TranslationManager from '../i18n';
import type { VueConstructor } from 'vue';
import type Vue from 'vue';
import type { CombinedVueInstance } from 'vue/types/vue';
import type { MessageNode } from '@ffz/icu-msgparser';
declare global {
interface Window {
ffzVue: VueConstructor<Vue>;
}
}
declare module 'utilities/types' {
interface ModuleEventMap {
}
interface ModuleMap {
vue: VueModule
}
}
export class Vue extends Module {
constructor(...args) {
super(...args);
export class VueModule extends Module<'vue'> {
private _components: Record<string, any> | null;
Vue: VueConstructor<Vue> | null = null;
i18n: TranslationManager = null as any;
_vue_i18n?: CombinedVueInstance<any, any, any, any, any> | null;
key: string;
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
this.key = 'ffz-stuff';
this._components = {};
this.inject('i18n');
}
@ -36,7 +68,7 @@ export class Vue extends Module {
this.component(Components.default);
Vue.use(ObserveVisibility);
Vue.use(ObserveVisibility as any);
Vue.mixin(Clickaway.mixin);
/*if ( ! DEBUG && this.root.raven )
@ -50,30 +82,30 @@ export class Vue extends Module {
Vue.use(this);
}
component(name, component) {
if ( typeof name === 'function' ) {
for(const key of name.keys())
this.component(key.slice(2, key.length - 4), name(key).default);
component(id: string | any, constructor?: any) {
if ( typeof id === 'function' ) {
for(const key of id.keys())
this.component(key.slice(2, key.length - 4), id(key).default);
} else if ( typeof name === 'object' ) {
for(const key in name)
if ( has(name, key) )
this.component(key, name[key]);
} else if ( typeof id === 'object' ) {
for(const key in id)
if ( has(id, key) )
this.component(key, id[key]);
} else if ( this.Vue )
this.Vue.component(name, component);
this.Vue.component(id, constructor);
else
this._components[name] = component;
else if ( this._components )
this._components[id] = constructor;
}
install(vue) {
install(vue: typeof Vue) {
// This is a mess. I'm sure there's an easier way to tie the systems
// together. However, for now, this works.
const t = this;
if ( ! this._vue_i18n ) {
this._vue_i18n = new this.Vue({
this._vue_i18n = new vue({
data() {
return {
locale: t.i18n.locale,
@ -82,47 +114,51 @@ export class Vue extends Module {
},
methods: {
tNumber_(val, format) {
tNumber_(val: number, format?: string) {
this.locale;
return t.i18n.formatNumber(val, format);
},
tDate_(val, format) {
tDate_(val: string | Date, format?: string) {
this.locale;
return t.i18n.formatDate(val, format);
},
tTime_(val, format) {
tTime_(val: string | Date, format?: string) {
this.locale;
return t.i18n.formatTime(val, format);
},
tDateTime_(val, format) {
tDateTime_(val: string | Date, format?: string) {
this.locale;
return t.i18n.formatDateTime(val, format);
},
t_(key, phrase, options) {
this.locale && this.phrases[key];
t_(key: string, phrase: string, options: any) {
// Access properties to trigger reactivity.
this.locale && (this as any).phrases[key];
return t.i18n.t(key, phrase, options);
},
tList_(key, phrase, options) {
this.locale && this.phrases[key];
tList_(key: string, phrase: string, options: any) {
// Access properties to trigger reactivity.
this.locale && (this as any).phrases[key];
return t.i18n.tList(key, phrase, options);
},
tNode_(node, data) {
tNode_(node: MessageNode, data: any) {
// Access properties to trigger reactivity.
this.locale;
return t.i18n.formatNode(node, data);
},
setLocale(locale) {
setLocale(locale: string) {
t.i18n.locale = locale;
}
}
});
// On i18n events, update values for reactivity.
this.on('i18n:transform', () => {
this._vue_i18n.locale = this.i18n.locale;
this._vue_i18n.phrases = {};
@ -165,11 +201,11 @@ export class Vue extends Module {
render(createElement) {
return createElement(
this.tag || 'span',
this.$i18n.tList_(
(this as any).$i18n.tList_(
this.phrase,
this.default,
Object.assign({}, this.data, this.$scopedSlots)
).map(out => {
).map((out: any) => {
if ( typeof out === 'function' )
return out();
return out;
@ -198,29 +234,29 @@ export class Vue extends Module {
return t.i18n;
},
t(key, phrase, options) {
return this.$i18n.t_(key, phrase, options);
return (this as any).$i18n.t_(key, phrase, options);
},
tList(key, phrase, options) {
return this.$i18n.tList_(key, phrase, options);
return (this as any).$i18n.tList_(key, phrase, options);
},
tNode(node, data) {
return this.$i18n.tNode_(node, data);
return (this as any).$i18n.tNode_(node, data);
},
tNumber(val, format) {
return this.$i18n.tNumber_(val, format);
return (this as any).$i18n.tNumber_(val, format);
},
tDate(val, format) {
return this.$i18n.tDate_(val, format);
return (this as any).$i18n.tDate_(val, format);
},
tTime(val, format) {
return this.$i18n.tTime_(val, format);
return (this as any).$i18n.tTime_(val, format);
},
tDateTime(val, format) {
return this.$i18n.tDateTime_(val, format);
return (this as any).$i18n.tDateTime_(val, format);
}
}
});
}
}
export default Vue;
export default VueModule;

View file

@ -298,7 +298,7 @@
.ffz--field-inline {
flex: 1;
width: unset;
min-width: 150px;
min-width: 140px;
}
.ffz--field-icon {

View file

@ -8,6 +8,7 @@
],
"allowJs": true,
"jsx": "preserve",
"jsxFactory": "createElement",
"alwaysStrict": true,
"strict": true,
"strictNullChecks": true,

View file

@ -2,3 +2,15 @@ declare module "*.scss" {
const content: string;
export default content;
}
declare module "*.json" {
const content: string;
export default content;
}
declare module "*.gql" {
import { DocumentNode } from "graphql";
const Schema: DocumentNode;
export = Schema;
}