mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-06-27 21:05:53 +00:00
I'm almost finished implementing a replacement for emote cards, but they aren't quite ready yet. Please wait just a bit longer. * Added: Support for modifier emote effects, as well as settings to disable them. * Changed: Update the chat types enum to match changes to Twitch's internals. * Changed: Implement a new data structure for more efficiently storing bulk user to emote set mappings. * Changed: Implement support for loading data from staging. * Experiments: Push the new chat line rendering experiment to 20%. Let's see if it works properly.
805 lines
No EOL
17 KiB
JavaScript
805 lines
No EOL
17 KiB
JavaScript
'use strict';
|
|
|
|
import {BAD_HOTKEYS, TWITCH_EMOTE_V2, WORD_SEPARATORS} from 'utilities/constants';
|
|
|
|
const HOP = Object.prototype.hasOwnProperty;
|
|
|
|
export function getTwitchEmoteURL(id, scale, animated = false, dark = true) {
|
|
return `${TWITCH_EMOTE_V2}/${id}/${animated ? 'default' : 'static'}/${dark ? 'dark' : 'light'}/${scale == 4 ? 3 : scale}.0`
|
|
}
|
|
|
|
export function getTwitchEmoteSrcSet(id, animated = false, dark = true, big = false) {
|
|
if ( big )
|
|
return `${getTwitchEmoteURL(id, 2, animated, dark)} 1x, ${getTwitchEmoteURL(id, 4, animated, dark)} 2x`;
|
|
|
|
return `${getTwitchEmoteURL(id, 1, animated, dark)} 1x, ${getTwitchEmoteURL(id, 2, animated, dark)} 2x, ${getTwitchEmoteURL(id, 4, animated, dark)} 4x`;
|
|
}
|
|
|
|
export function isValidShortcut(key) {
|
|
if ( ! key )
|
|
return false;
|
|
|
|
key = key.toLowerCase().trim();
|
|
return ! BAD_HOTKEYS.includes(key);
|
|
}
|
|
|
|
// Source: https://gist.github.com/jed/982883 (WTFPL)
|
|
export function generateUUID(input) {
|
|
return input // if the placeholder was passed, return
|
|
? ( // a random number from 0 to 15
|
|
input ^ // unless b is 8,
|
|
Math.random() // in which case
|
|
* 16 // a random number from
|
|
>> input/4 // 8 to 11
|
|
).toString(16) // in hexadecimal
|
|
: ( // or otherwise a concatenated string:
|
|
[1e7] + // 10000000 +
|
|
-1e3 + // -1000 +
|
|
-4e3 + // -4000 +
|
|
-8e3 + // -80000000 +
|
|
-1e11 // -100000000000,
|
|
).replace( // replacing
|
|
/[018]/g, // zeroes, ones, and eights with
|
|
generateUUID // random hex digits
|
|
);
|
|
}
|
|
|
|
|
|
export function has(object, key) {
|
|
return object ? HOP.call(object, key) : false;
|
|
}
|
|
|
|
|
|
export function sleep(delay) {
|
|
return new Promise(s => setTimeout(s, delay));
|
|
}
|
|
|
|
export function make_enum(...array) {
|
|
const out = {};
|
|
|
|
for(let i=0; i < array.length; i++) {
|
|
const word = array[i];
|
|
out[word] = i;
|
|
out[i] = word;
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
export function make_enum_flags(...array) {
|
|
const out = {};
|
|
|
|
out.None = 0;
|
|
out[0] = 'None';
|
|
|
|
for(let i = 0; i < array.length; i++) {
|
|
const word = array[i],
|
|
value = Math.pow(2, i);
|
|
out[word] = value;
|
|
out[value] = word;
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
|
|
export function timeout(promise, delay) {
|
|
return new Promise((resolve, reject) => {
|
|
let resolved = false;
|
|
const timer = setTimeout(() => {
|
|
if ( ! resolved ) {
|
|
resolved = true;
|
|
reject(new Error('timeout'));
|
|
}
|
|
}, delay);
|
|
|
|
promise.then(result => {
|
|
if ( ! resolved ) {
|
|
resolved = true;
|
|
clearTimeout(timer);
|
|
resolve(result);
|
|
}
|
|
}).catch(err => {
|
|
if ( ! resolved ) {
|
|
resolved = true;
|
|
clearTimeout(timer);
|
|
reject(err);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
export class Mutex {
|
|
constructor(limit = 1) {
|
|
this.limit = limit;
|
|
this._active = 0;
|
|
this._waiting = [];
|
|
|
|
this._done = this._done.bind(this);
|
|
}
|
|
|
|
get available() { return this._active < this.limit }
|
|
|
|
_done() {
|
|
this._active--;
|
|
|
|
while(this._active < this.limit && this._waiting.length > 0) {
|
|
this._active++;
|
|
const waiter = this._waiting.shift();
|
|
waiter(this._done);
|
|
}
|
|
}
|
|
|
|
wait() {
|
|
if ( this._active < this.limit) {
|
|
this._active++;
|
|
return Promise.resolve(this._done);
|
|
}
|
|
|
|
return new Promise(s => this._waiting.push(s));
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Return a wrapper for a function that will only execute the function
|
|
* a period of time after it has stopped being called.
|
|
* @param {Function} fn The function to wrap.
|
|
* @param {Integer} delay The time to wait, in milliseconds
|
|
* @param {Boolean} immediate If immediate is true, trigger the function immediately rather than eventually.
|
|
* @returns {Function} wrapped function
|
|
*/
|
|
export function debounce(fn, delay, immediate) {
|
|
let timer;
|
|
if ( immediate ) {
|
|
const later = () => timer = null;
|
|
if ( immediate === 2 )
|
|
// Special Mode! Run immediately OR later.
|
|
return function(...args) {
|
|
if ( timer ) {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => {
|
|
timer = null;
|
|
fn.apply(this, args); // eslint-disable-line no-invalid-this
|
|
}, delay);
|
|
} else {
|
|
fn.apply(this, args); // eslint-disable-line no-invalid-this
|
|
timer = setTimeout(later, delay);
|
|
}
|
|
}
|
|
|
|
return function(...args) {
|
|
if ( ! timer )
|
|
fn.apply(this, args); // eslint-disable-line no-invalid-this
|
|
else
|
|
clearTimeout(timer);
|
|
|
|
timer = setTimeout(later, delay);
|
|
}
|
|
}
|
|
|
|
return function(...args) {
|
|
if ( timer )
|
|
clearTimeout(timer);
|
|
|
|
timer = setTimeout(fn.bind(this, ...args), delay); // eslint-disable-line no-invalid-this
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Make sure that a given asynchronous function is only called once
|
|
* at a time.
|
|
*/
|
|
|
|
export function once(fn) {
|
|
let waiters;
|
|
|
|
return function(...args) {
|
|
return new Promise(async (s,f) => {
|
|
if ( waiters )
|
|
return waiters.push([s,f]);
|
|
|
|
waiters = [[s,f]];
|
|
let result;
|
|
try {
|
|
result = await fn.call(this, ...args); // eslint-disable-line no-invalid-this
|
|
} catch(err) {
|
|
for(const w of waiters)
|
|
w[1](err);
|
|
waiters = null;
|
|
return;
|
|
}
|
|
|
|
for(const w of waiters)
|
|
w[0](result);
|
|
|
|
waiters = null;
|
|
})
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Check that two arrays are the same length and that each array has the same
|
|
* items in the same indices.
|
|
* @param {Array} a The first array
|
|
* @param {Array} b The second array
|
|
* @returns {boolean} Whether or not they match
|
|
*/
|
|
export function array_equals(a, b) {
|
|
if ( ! Array.isArray(a) || ! Array.isArray(b) || a.length !== b.length )
|
|
return false;
|
|
|
|
let i = a.length;
|
|
while(i--)
|
|
if ( a[i] !== b[i] )
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
export function deep_equals(object, other, ignore_undefined = false, seen, other_seen) {
|
|
if ( object === other )
|
|
return true;
|
|
if ( typeof object !== typeof other )
|
|
return false;
|
|
if ( typeof object !== 'object' )
|
|
return false;
|
|
if ( (object === null) !== (other === null) )
|
|
return false;
|
|
|
|
if ( ! seen )
|
|
seen = new Set;
|
|
|
|
if ( ! other_seen )
|
|
other_seen = new Set;
|
|
|
|
if ( seen.has(object) || other_seen.has(other) )
|
|
throw new Error('recursive structure detected');
|
|
|
|
seen.add(object);
|
|
other_seen.add(other);
|
|
|
|
const source_keys = Object.keys(object),
|
|
dest_keys = Object.keys(other);
|
|
|
|
if ( ! ignore_undefined && ! set_equals(new Set(source_keys), new Set(dest_keys)) )
|
|
return false;
|
|
|
|
for(const key of source_keys)
|
|
if ( ! deep_equals(object[key], other[key], ignore_undefined, new Set(seen), new Set(other_seen)) )
|
|
return false;
|
|
|
|
if ( ignore_undefined )
|
|
for(const key of dest_keys)
|
|
if ( ! source_keys.includes(key) ) {
|
|
if ( ! deep_equals(object[key], other[key], ignore_undefined, new Set(seen), new Set(other_seen)) )
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
export function shallow_object_equals(a, b) {
|
|
if ( typeof a !== 'object' || typeof b !== 'object' )
|
|
return false;
|
|
|
|
const keys = Object.keys(a);
|
|
if ( ! set_equals(new Set(keys), new Set(Object.keys(b))) )
|
|
return false;
|
|
|
|
for(const key of keys)
|
|
if ( a[key] !== b[key] )
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
export function map_equals(a, b) {
|
|
if ( !(a instanceof Map) || !(b instanceof Map) || a.size !== b.size )
|
|
return false;
|
|
|
|
for(const [key, val] of a)
|
|
if ( ! b.has(key) || b.get(key) !== val )
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
export function set_equals(a,b) {
|
|
if ( !(a instanceof Set) || !(b instanceof Set) || a.size !== b.size )
|
|
return false;
|
|
|
|
for(const v of a)
|
|
if ( ! b.has(v) )
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
/**
|
|
* Special logic to ensure that a target object is matched by a filter.
|
|
* @param {object} filter The filter object
|
|
* @param {object} target The object to check it against
|
|
* @returns {boolean} Whether or not it matches
|
|
*/
|
|
export function filter_match(filter, target) {
|
|
for(const key in filter) {
|
|
if ( HOP.call(filter, key) ) {
|
|
const filter_value = filter[key],
|
|
target_value = target[key],
|
|
type = typeof filter_value;
|
|
|
|
if ( type === 'function' ) {
|
|
if ( ! filter_value(target_value) )
|
|
return false;
|
|
|
|
} else if ( Array.isArray(filter_value) ) {
|
|
if ( Array.isArray(target_value) ) {
|
|
for(const val of filter_value)
|
|
if ( ! target_value.includes(val) )
|
|
return false;
|
|
|
|
} else if ( ! filter_value.include(target_value) )
|
|
return false;
|
|
|
|
} else if ( typeof target_value !== type )
|
|
return false;
|
|
|
|
else if ( type === 'object' ) {
|
|
if ( ! filter_match(filter_value, target_value) )
|
|
return false;
|
|
|
|
} else if ( filter_value !== target_value )
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
export function substr_count(str, needle) {
|
|
let i = 0, idx = 0;
|
|
while( idx < str.length ) {
|
|
const x = str.indexOf(needle, idx);
|
|
if ( x === -1 )
|
|
break;
|
|
|
|
i++;
|
|
idx = x + 1;
|
|
}
|
|
|
|
return i;
|
|
}
|
|
|
|
|
|
/**
|
|
* Get a value from an object at a path.
|
|
* @param {string|Array} path The path to follow, using periods to go down a level.
|
|
* @param {object|Array} object The starting object.
|
|
* @returns {*} The value at that point in the path, or undefined if part of the path doesn't exist.
|
|
*/
|
|
export function get(path, object) {
|
|
if ( HOP.call(object, path) )
|
|
return object[path];
|
|
|
|
if ( typeof path === 'string' )
|
|
path = path.split('.');
|
|
|
|
for(let i=0, l = path.length; i < l; i++) {
|
|
const part = path[i];
|
|
if ( part === '@each' ) {
|
|
const p = path.slice(i + 1);
|
|
if ( p.length ) {
|
|
if ( Array.isArray )
|
|
object = object.map(x => get(p, x));
|
|
else {
|
|
const new_object = {};
|
|
for(const key in object)
|
|
if ( HOP.call(object, key) )
|
|
new_object[key] = get(p, object[key]);
|
|
object = new_object;
|
|
}
|
|
}
|
|
|
|
break;
|
|
|
|
} else if ( part === '@last' )
|
|
object = object[object.length - 1];
|
|
else
|
|
object = object[path[i]];
|
|
|
|
if ( ! object )
|
|
break;
|
|
}
|
|
|
|
return object;
|
|
}
|
|
|
|
|
|
/**
|
|
* Copy an object so that it can be safely serialized. If an object
|
|
* is not serializable, such as a promise, returns null.
|
|
*
|
|
* @export
|
|
* @param {*} object The thing to copy.
|
|
* @param {Number} [depth=2] The maximum depth to explore the object.
|
|
* @param {Set} [seen=null] A Set of seen objects. Internal use only.
|
|
* @returns {Object} The copy to safely store or use.
|
|
*/
|
|
export function shallow_copy(object, depth = 2, seen = null) {
|
|
if ( object == null )
|
|
return object;
|
|
|
|
if ( object instanceof Promise || typeof object === 'function' )
|
|
return null;
|
|
|
|
if ( typeof object !== 'object' )
|
|
return object;
|
|
|
|
if ( depth === 0 )
|
|
return null;
|
|
|
|
if ( ! seen )
|
|
seen = new Set;
|
|
|
|
seen.add(object);
|
|
|
|
if ( Array.isArray(object) ) {
|
|
const out = [];
|
|
for(const val of object) {
|
|
if ( seen.has(val) )
|
|
continue;
|
|
|
|
out.push(shallow_copy(val, depth - 1, new Set(seen)));
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
const out = {};
|
|
for(const [key, val] of Object.entries(object) ) {
|
|
if ( seen.has(val) )
|
|
continue;
|
|
|
|
out[key] = shallow_copy(val, depth - 1, new Set(seen));
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
|
|
export function deep_copy(object, seen) {
|
|
if ( object === null )
|
|
return null;
|
|
else if ( object === undefined )
|
|
return undefined;
|
|
|
|
if ( object instanceof Promise )
|
|
return new Promise((s,f) => object.then(s).catch(f));
|
|
|
|
if ( typeof object === 'function' )
|
|
return function(...args) { return object.apply(this, args); } // eslint-disable-line no-invalid-this
|
|
|
|
if ( typeof object !== 'object' )
|
|
return object;
|
|
|
|
if ( ! seen )
|
|
seen = new Set;
|
|
|
|
if ( seen.has(object) )
|
|
throw new Error('recursive structure detected');
|
|
|
|
seen.add(object);
|
|
|
|
if ( Array.isArray(object) )
|
|
return object.map(x => deep_copy(x, new Set(seen)));
|
|
|
|
const out = {};
|
|
for(const key in object)
|
|
if ( HOP.call(object, key) ) {
|
|
const val = object[key];
|
|
if ( typeof val === 'object' )
|
|
out[key] = deep_copy(val, new Set(seen));
|
|
else
|
|
out[key] = val;
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
|
|
export function maybe_call(fn, ctx, ...args) {
|
|
if ( typeof fn === 'function' ) {
|
|
if ( ctx )
|
|
return fn.call(ctx, ...args);
|
|
return fn(...args);
|
|
}
|
|
|
|
return fn;
|
|
}
|
|
|
|
|
|
const SPLIT_REGEX = /[^\uD800-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDFFF]/g;
|
|
|
|
export function split_chars(str) {
|
|
if ( str === '' )
|
|
return [];
|
|
|
|
return str.match(SPLIT_REGEX);
|
|
}
|
|
|
|
|
|
export function pick_random(obj) {
|
|
if ( ! obj )
|
|
return null;
|
|
|
|
if ( ! Array.isArray(obj) )
|
|
return obj[pick_random(Object.keys(obj))]
|
|
|
|
return obj[Math.floor(Math.random() * obj.length)];
|
|
}
|
|
|
|
|
|
export const escape_regex = RegExp.escape || function escape_regex(str) {
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
|
|
export function addWordSeparators(str) {
|
|
return `(^|.*?${WORD_SEPARATORS})(?:${str})(?=$|${WORD_SEPARATORS})`
|
|
}
|
|
|
|
|
|
const CONTROL_CHARS = '/$^+.()=!|';
|
|
|
|
export function glob_to_regex(input) {
|
|
if ( typeof input !== 'string' )
|
|
throw new TypeError('input must be a string');
|
|
|
|
let output = '',
|
|
groups = 0;
|
|
|
|
for(let i=0, l=input.length; i<l; i++) {
|
|
const char = input[i];
|
|
|
|
if ( CONTROL_CHARS.includes(char) )
|
|
output += `\\${char}`;
|
|
|
|
else if ( char === '\\' ) {
|
|
i++;
|
|
const next = input[i];
|
|
if ( next ) {
|
|
if ( CONTROL_CHARS.includes(next) )
|
|
output += `\\${next}`;
|
|
else
|
|
output += next;
|
|
}
|
|
|
|
} else if ( char === '?' )
|
|
output += '.';
|
|
|
|
else if ( char === '[' ) {
|
|
output += char;
|
|
const next = input[i + 1];
|
|
if ( next === '!' ) {
|
|
i++;
|
|
output += '^';
|
|
}
|
|
|
|
} else if ( char === ']' )
|
|
output += char;
|
|
|
|
else if ( char === '{' ) {
|
|
output += '(?:';
|
|
groups++;
|
|
|
|
} else if ( char === '}' ) {
|
|
if ( groups > 0 ) {
|
|
output += ')';
|
|
groups--;
|
|
}
|
|
|
|
} else if ( char === ',' && groups > 0 )
|
|
output += '|';
|
|
|
|
else if ( char === '*' ) {
|
|
let count = 1;
|
|
while(input[i+1] === '*') {
|
|
count++;
|
|
i++;
|
|
}
|
|
|
|
if ( count > 1 )
|
|
output += '.*?';
|
|
else
|
|
output += '[^\\s]*?';
|
|
|
|
} else
|
|
output += char;
|
|
}
|
|
|
|
/*while(groups > 0) {
|
|
output += ')';
|
|
groups--;
|
|
}*/
|
|
|
|
return output;
|
|
}
|
|
|
|
|
|
/**
|
|
* Truncate a string. Tries to intelligently break the string in white-space
|
|
* if possible, without back-tracking. The returned string can be up to
|
|
* `ellipsis.length + target + overage` characters long.
|
|
* @param {String} str The string to truncate.
|
|
* @param {Number} target The target length for the result
|
|
* @param {Number} overage Accept up to this many additional characters for a better result
|
|
* @param {String} [ellipsis='…'] The string to append when truncating
|
|
* @param {Boolean} [break_line=true] If true, attempt to break at the first LF
|
|
* @param {Boolean} [trim=true] If true, runs trim() on the string before truncating
|
|
* @returns {String} The truncated string
|
|
*/
|
|
export function truncate(str, target = 100, overage = 15, ellipsis = '…', break_line = true, trim = true) {
|
|
if ( ! str || ! str.length )
|
|
return str;
|
|
|
|
if ( trim )
|
|
str = str.trim();
|
|
|
|
let idx = break_line ? str.indexOf('\n') : -1;
|
|
if ( idx === -1 || idx > target )
|
|
idx = target;
|
|
|
|
if ( str.length <= idx )
|
|
return str;
|
|
|
|
let out = str.slice(0, idx).trimRight();
|
|
if ( overage > 0 && out.length >= idx ) {
|
|
let next_space = str.slice(idx).search(/\s+/);
|
|
if ( next_space === -1 && overage + idx > str.length )
|
|
next_space = str.length - idx;
|
|
|
|
if ( next_space !== -1 && next_space <= overage ) {
|
|
if ( str.length <= (idx + next_space) )
|
|
return str;
|
|
|
|
out = str.slice(0, idx + next_space);
|
|
}
|
|
}
|
|
|
|
return out + ellipsis;
|
|
}
|
|
|
|
|
|
|
|
function decimalToHex(number) {
|
|
return number.toString(16).padStart(2, '0')
|
|
}
|
|
|
|
|
|
export function generateHex(length = 40) {
|
|
const arr = new Uint8Array(length / 2);
|
|
window.crypto.getRandomValues(arr);
|
|
return Array.from(arr, decimalToHex).join('')
|
|
}
|
|
|
|
|
|
export class SourcedSet {
|
|
constructor(use_set = false) {
|
|
this._use_set = use_set;
|
|
this._cache = use_set ? new Set : [];
|
|
}
|
|
|
|
_rebuild() {
|
|
if ( ! this._sources )
|
|
return;
|
|
|
|
const use_set = this._use_set,
|
|
cache = this._cache = use_set ? new Set : [];
|
|
for(const items of this._sources.values())
|
|
for(const i of items)
|
|
if ( use_set )
|
|
cache.add(i);
|
|
else if ( ! cache.includes(i) )
|
|
this._cache.push(i);
|
|
}
|
|
|
|
get(key) { return this._sources && this._sources.get(key) }
|
|
has(key) { return this._sources ? this._sources.has(key) : false }
|
|
|
|
sourceIncludes(key, val) {
|
|
const src = this._sources && this._sources.get(key);
|
|
return src && src.includes(val);
|
|
}
|
|
|
|
includes(val) {
|
|
return this._use_set ? this._cache.has(val) : this._cache.includes(val);
|
|
}
|
|
|
|
delete(key) {
|
|
if ( this._sources && this._sources.has(key) ) {
|
|
this._sources.delete(key);
|
|
this._rebuild();
|
|
}
|
|
}
|
|
|
|
extend(key, ...items) {
|
|
if ( ! this._sources )
|
|
this._sources = new Map;
|
|
|
|
const had = this.has(key);
|
|
if ( had )
|
|
items = [...this._sources.get(key), ...items];
|
|
|
|
this._sources.set(key, items);
|
|
if ( had )
|
|
this._rebuild();
|
|
else
|
|
for(const i of items)
|
|
if ( this._use_set )
|
|
this._cache.add(i);
|
|
else if ( ! this._cache.includes(i) )
|
|
this._cache.push(i);
|
|
}
|
|
|
|
set(key, val) {
|
|
if ( ! this._sources )
|
|
this._sources = new Map;
|
|
|
|
const had = this.has(key);
|
|
if ( ! Array.isArray(val) )
|
|
val = [val];
|
|
|
|
this._sources.set(key, val);
|
|
if ( had )
|
|
this._rebuild();
|
|
else
|
|
for(const i of val)
|
|
if ( this._use_set )
|
|
this._cache.add(i);
|
|
else if ( ! this._cache.includes(i) )
|
|
this._cache.push(i);
|
|
}
|
|
|
|
push(key, val) {
|
|
if ( ! this._sources )
|
|
return this.set(key, val);
|
|
|
|
const old_val = this._sources.get(key);
|
|
if ( old_val === undefined )
|
|
return this.set(key, val);
|
|
|
|
else if ( old_val.includes(val) )
|
|
return;
|
|
|
|
old_val.push(val);
|
|
if ( this._use_set )
|
|
this._cache.add(val);
|
|
else if ( ! this._cache.includes(val) )
|
|
this._cache.push(val);
|
|
}
|
|
|
|
remove(key, val) {
|
|
if ( ! this.has(key) )
|
|
return;
|
|
|
|
const old_val = this._sources.get(key),
|
|
idx = old_val.indexOf(val);
|
|
|
|
if ( idx === -1 )
|
|
return;
|
|
|
|
old_val.splice(idx, 1);
|
|
this._rebuild();
|
|
}
|
|
} |