diff --git a/package-lock.json b/package-lock.json index 3c4cf1d8..cdcc5640 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8727,9 +8727,9 @@ } }, "sortablejs": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.7.0.tgz", - "integrity": "sha1-gKKyNwq9Vo4c7IwnETHvMKkE+ig=" + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.9.0.tgz", + "integrity": "sha512-Ot6bYJ6PoqPmpsqQYXjn1+RKrY2NWQvQt/o4jfd/UYwVWndyO5EPO8YHbnm5HIykf8ENsm4JUrdAvolPT86yYA==" }, "source-list-map": { "version": "2.0.0", diff --git a/package.json b/package.json index 4b80f682..82d4048d 100755 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "raven-js": "^3.24.2", "react": "^16.4.1", "safe-regex": "^1.1.0", - "sortablejs": "^1.7.0", + "sortablejs": "^1.9.0", "vue": "^2.5.16", "vue-clickaway": "^2.2.2", "vue-color": "^2.4.6", diff --git a/res/font/ffz-fontello.eot b/res/font/ffz-fontello.eot index 1adccb3b..fe672350 100644 Binary files a/res/font/ffz-fontello.eot and b/res/font/ffz-fontello.eot differ diff --git a/res/font/ffz-fontello.svg b/res/font/ffz-fontello.svg index b519b454..7a1d4eb1 100644 --- a/res/font/ffz-fontello.svg +++ b/res/font/ffz-fontello.svg @@ -128,6 +128,8 @@ + + diff --git a/res/font/ffz-fontello.ttf b/res/font/ffz-fontello.ttf index 52093499..73be6fc1 100644 Binary files a/res/font/ffz-fontello.ttf and b/res/font/ffz-fontello.ttf differ diff --git a/res/font/ffz-fontello.woff b/res/font/ffz-fontello.woff index 9063a7c8..c32dc3ec 100644 Binary files a/res/font/ffz-fontello.woff and b/res/font/ffz-fontello.woff differ diff --git a/res/font/ffz-fontello.woff2 b/res/font/ffz-fontello.woff2 index 2a6eb277..e211fd39 100644 Binary files a/res/font/ffz-fontello.woff2 and b/res/font/ffz-fontello.woff2 differ diff --git a/src/main.js b/src/main.js index 94c092c9..f5d49ac3 100644 --- a/src/main.js +++ b/src/main.js @@ -151,7 +151,7 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}` FrankerFaceZ.Logger = Logger; const VER = FrankerFaceZ.version_info = { - major: 4, minor: 3, revision: 2, + major: 4, minor: 4, revision: 0, commit: __git_commit__, build: __webpack_hash__, toString: () => diff --git a/src/modules/main_menu/components/filter-editor.vue b/src/modules/main_menu/components/filter-editor.vue index f25e227c..5e39b54f 100644 --- a/src/modules/main_menu/components/filter-editor.vue +++ b/src/modules/main_menu/components/filter-editor.vue @@ -1,52 +1,50 @@ - - - - - - - - Channel - - - - - - is one of - is not one of - - - - - - - - - - - {{ t('setting.filters.delete', 'Delete') }} - - - - + + {{ t('setting.filters.empty', '(no filters)') }} + + + + + + {{ t(filter.i18n, filter.title) }} + + + + + + {{ t('setting.filters.add', 'Add') }} + + + - {{ t('', 'Add New Rule') }} + {{ t('setting.filters.add-new', 'Add New Rule') }} @@ -54,12 +52,193 @@ \ No newline at end of file diff --git a/src/modules/main_menu/components/profile-editor.vue b/src/modules/main_menu/components/profile-editor.vue index 65655562..1daefcd3 100644 --- a/src/modules/main_menu/components/profile-editor.vue +++ b/src/modules/main_menu/components/profile-editor.vue @@ -20,11 +20,67 @@ {{ t('setting.delete', 'Delete') }} - + + + + + + + {{ t('setting.backup-restore.error', 'There was an error processing this backup.') }} + + + {{ export_error_message }} + + + + + + {{ t('setting.close', 'Close') }} + + + + + + + {{ export_message }} + + + + + {{ t('setting.close', 'Close') }} + + + + + + + {{ t('setting.profile.updates', 'This profile will update automatically from the following URL:') }} + + + + + {{ url }} + + @@ -41,7 +97,7 @@ id="ffz:editor:name" ref="name" v-model="name" - class="tw-input" + class="tw-full-width tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 tw-input" > @@ -54,7 +110,7 @@ id="ffz:editor:description" ref="desc" v-model="desc" - class="tw-input" + class="tw-full-width tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 tw-input" /> @@ -71,9 +127,8 @@ @@ -81,21 +136,37 @@ \ No newline at end of file diff --git a/src/settings/components/nested.vue b/src/settings/components/nested.vue new file mode 100644 index 00000000..83251f65 --- /dev/null +++ b/src/settings/components/nested.vue @@ -0,0 +1,30 @@ + + + + {{ t(type.i18n, type.title) }} + + + + + + + \ No newline at end of file diff --git a/src/settings/components/page.vue b/src/settings/components/page.vue new file mode 100644 index 00000000..7f854710 --- /dev/null +++ b/src/settings/components/page.vue @@ -0,0 +1,120 @@ + + + + + {{ t(type.i18n, type.title) }} + + + + + {{ getName(key) }} + + + + + + + + {{ t(part.i18n, part.title) }} + + + + + + + + + \ No newline at end of file diff --git a/src/settings/filters.js b/src/settings/filters.js new file mode 100644 index 00000000..13d011de --- /dev/null +++ b/src/settings/filters.js @@ -0,0 +1,144 @@ +'use strict'; + +// ============================================================================ +// Profile Filters for Settings +// ============================================================================ + +import {createTester} from 'utilities/filtering'; + +// Logical Components + +export const Invert = { + createTest(config, rule_types) { + return createTester(config, rule_types, true) + }, + + maxRules: 1, + childRules: true, + + tall: true, + title: 'Invert', + i18n: 'settings.filter.invert', + + default: () => [], + editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue') +}; + +export const Or = { + createTest(config, rule_types) { + return createTester(config, rule_types, false, true); + }, + + childRules: true, + + tall: true, + title: 'Or', + i18n: 'settings.filter.or', + + default: () => [], + editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue') +}; + + +// Context Stuff + +export const TheaterMode = { + createTest(config) { + return ctx => ctx.ui && ctx.ui.theatreModeEnabled === config; + }, + + title: 'Theater Mode', + i18n: 'settings.filter.theater', + + default: true, + + editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue') +}; + +export const Moderator = { + createTest(config) { + return ctx => ctx.moderator === config; + }, + + title: 'Is Moderator', + i18n: 'settings.filter.moderator', + + default: true, + editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue') +}; + +export const SquadMode = { + createTest(config) { + return ctx => ctx.ui && ctx.ui.squadModeEnabled === config; + }, + + title: 'Squad Mode', + i18n: 'settings.filter.squad', + + default: true, + editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue') +}; + +export const NativeDarkTheme = { + createTest(config) { + const val = config ? 1 : 0; + return ctx => ctx.ui && ctx.ui.theme === val; + }, + + title: 'Dark Theme', + i18n: 'settings.filter.native-dark', + + default: true, + editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue') +}; + +export const Page = { + createTest(config = {}) { + const name = config.route, + parts = []; + + if ( Object.keys(config.values).length ) { + const ffz = FrankerFaceZ.get(), + router = ffz && ffz.resolve('site.router'); + + if ( router ) { + const route = router.getRoute(name); + if ( ! route || ! route.parts ) + return () => false; + + let i = 1; + for(const part of route.parts) { + if ( typeof part === 'object' ) { + if ( config.values[part.name] != null ) + parts.push([i, config.values[part.name]]); + + i++; + } + } + + } else + return () => false; + } + + return ctx => { + if ( ! ctx.route || ! ctx.route_data || ctx.route.name !== name ) + return false; + + for(const [index, value] of parts) + if ( ctx.route_data[index] !== value ) + return false; + + return true; + } + }, + + tall: true, + title: 'Current Page', + i18n: 'settings.filter.page', + + default: () => ({ + route: 'front-page', + values: {} + }), + editor: () => import(/* webpackChunkName: 'main-menu' */ './components/page.vue') +}; \ No newline at end of file diff --git a/src/settings/index.js b/src/settings/index.js index 36d72cb2..98d3e053 100644 --- a/src/settings/index.js +++ b/src/settings/index.js @@ -5,7 +5,7 @@ // ============================================================================ import Module from 'utilities/module'; -import {has} from 'utilities/object'; +import {deep_equals, has} from 'utilities/object'; import {CloudStorageProvider, LocalStorageProvider} from './providers'; import SettingsProfile from './profile'; @@ -92,6 +92,8 @@ export default class SettingsManager extends Module { const duration = performance.now() - this._start_time; this.log.info(`Initialization complete after ${duration.toFixed(5)}ms -- Values: ${this.provider.size} -- Profiles: ${this.__profiles.length}`) + + this.scheduleUpdates(); } @@ -116,6 +118,34 @@ export default class SettingsManager extends Module { } + scheduleUpdates() { + if ( this._update_timer ) + clearTimeout(this._update_timer); + + this._update_timer = setTimeout(() => this.checkUpdates(), 5000); + } + + + checkUpdates() { + const promises = []; + for(const profile of this.__profiles) { + if ( ! profile || ! profile.url ) + continue; + + const out = profile.checkUpdate(); + promises.push(out instanceof Promise ? out : Promise.resolve(out)); + } + + Promise.all(promises).then(data => { + let success = 0; + for(const thing of data) + if ( thing ) + success++; + + this.log.info(`Successfully refreshed ${success} of ${data.length} profiles from remote URLs.`); + }); + } + // ======================================================================== // Provider Interaction @@ -194,7 +224,6 @@ export default class SettingsManager extends Module { // to keys. old_ids = new Set(old_profiles.map(x => x.id)), - moved_ids = new Set, new_ids = new Set, changed_ids = new Set, @@ -203,30 +232,38 @@ export default class SettingsManager extends Module { SettingsProfile.Default ]); - let changed = false; + let reordered = false, + changed = false; + for(const profile_data of raw_profiles) { const id = profile_data.id, + slot_id = profiles.length, old_profile = old_profile_ids[id], - old_slot_id = parseInt(old_profiles[profiles.length] || -1, 10); + old_slot_id = old_profile ? old_profiles.indexOf(old_profile) : -1; old_ids.delete(id); - if ( old_slot_id !== id ) { - moved_ids.add(old_slot_id); - moved_ids.add(id); + if ( old_slot_id !== slot_id ) + reordered = true; + + // Monkey patch to the new profile format... + if ( profile_data.context && ! Array.isArray(profile_data.context) ) { + if ( profile_data.context.moderator ) + profile_data.context = SettingsProfile.Moderation.context; + else + profile_data.context = null; } - // TODO: Better method for checking if the profile data has changed. - if ( old_profile && JSON.stringify(old_profile.data) === JSON.stringify(profile_data) ) { + if ( old_profile && deep_equals(old_profile.data, profile_data, true) ) { // Did the order change? - if ( old_profiles[profiles.length] !== old_profile ) + if ( old_slot_id !== slot_id ) changed = true; profiles.push(profile_ids[id] = old_profile); continue; } - const new_profile = profiles.push(profile_ids[id] = new SettingsProfile(this, profile_data)); + const new_profile = profile_ids[id] = new SettingsProfile(this, profile_data); if ( old_profile ) { // Move all the listeners over. new_profile.__listeners = old_profile.__listeners; @@ -237,6 +274,7 @@ export default class SettingsManager extends Module { } else new_ids.add(id); + profiles.push(new_profile); changed = true; } @@ -252,7 +290,7 @@ export default class SettingsManager extends Module { for(const id of changed_ids) this.emit(':profile-changed', profile_ids[id]); - if ( moved_ids.size ) + if ( reordered ) this.emit(':profiles-reordered'); } diff --git a/src/settings/profile.js b/src/settings/profile.js index 76ef23f6..f4c9f04c 100644 --- a/src/settings/profile.js +++ b/src/settings/profile.js @@ -5,8 +5,12 @@ // ============================================================================ import {EventEmitter} from 'utilities/events'; -import {has, filter_match} from 'utilities/object'; +import {has} from 'utilities/object'; +import {createTester} from 'utilities/filtering'; +const fetchJSON = (url, options) => { + return fetch(url, options).then(r => r.ok ? r.json() : null).catch(() => null); +} /** * Instances of SettingsProfile are used for getting and setting raw settings @@ -35,6 +39,8 @@ export default class SettingsProfile extends EventEmitter { description: this.description, desc_i18n_key: this.desc_i18n_key, + url: this.url, + context: this.context } } @@ -43,24 +49,18 @@ export default class SettingsProfile extends EventEmitter { if ( typeof val !== 'object' ) throw new TypeError('data must be an object'); + this.matcher = null; + for(const key in val) if ( has(val, key) ) this[key] = val[key]; } matches(context) { - // If we don't have any specific context, then we work! - if ( ! this.context ) - return true; + if ( ! this.matcher ) + this.matcher = createTester(this.context, require('./filters')); - // If we do have context and didn't get any, then we don't! - else if ( ! context ) - return false; - - // Got context? Have context? One-sided deep comparison time. - // Let's go for a walk! - - return filter_match(this.context, context); + return this.matcher(context); } @@ -69,6 +69,46 @@ export default class SettingsProfile extends EventEmitter { } + getBackup() { + const out = { + version: 2, + type: 'profile', + profile: this.data, + values: {} + }; + + for(const [k,v] of this.entries()) + out.values[k] = v; + + return out; + } + + + async checkUpdate() { + if ( ! this.url ) + return false; + + const data = fetchJSON(this.url); + if ( ! data || ! data.type === 'profile' || ! data.profile || ! data.values ) + return false; + + delete data.profile.id; + this.data = data.profile; + + const old_keys = new Set(this.keys()); + + for(const [key, value] of Object.entries(data.values)) { + old_keys.delete(key); + this.set(key, value); + } + + for(const key of old_keys) + this.delete(key); + + return true; + } + + // ======================================================================== // Context // ======================================================================== @@ -78,6 +118,7 @@ export default class SettingsProfile extends EventEmitter { throw new Error('cannot set context of default profile'); this.context = Object.assign(this.context || {}, context); + this.matcher = null; this.manager._saveProfiles(); } @@ -86,6 +127,7 @@ export default class SettingsProfile extends EventEmitter { throw new Error('cannot set context of default profile'); this.context = context; + this.matcher = null; this.manager._saveProfiles(); } @@ -172,7 +214,10 @@ SettingsProfile.Moderation = { description: 'Settings that apply when you are a moderator of the current channel.', - context: { - moderator: true - } + context: [ + { + type: 'Moderator', + data: true + } + ] } \ No newline at end of file diff --git a/src/sites/twitch-twilight/index.js b/src/sites/twitch-twilight/index.js index d3fd5507..60347595 100644 --- a/src/sites/twitch-twilight/index.js +++ b/src/sites/twitch-twilight/index.js @@ -39,6 +39,7 @@ export default class Twilight extends BaseSite { this.web_munch.known(Twilight.KNOWN_MODULES); this.router.route(Twilight.ROUTES); + this.router.routeName(Twilight.ROUTE_NAMES); } onEnable() { @@ -181,25 +182,35 @@ Twilight.CHAT_ROUTES = [ 'user', 'dash', 'embed-chat' -] +]; + + +Twilight.ROUTE_NAMES = { + 'dir': 'Browse', + 'dir-following': 'Following', + 'dir-all': 'Browse Live Channels', + 'dash': 'Dashboard', + 'popout': 'Popout Chat', + 'user-video': 'Channel Video' +}; Twilight.ROUTES = { 'front-page': '/', 'collection': '/collections/:collectionID', 'dir': '/directory', - 'dir-community': '/communities/:communityName', - 'dir-community-index': '/directory/communities', - 'dir-creative': '/directory/creative', + //'dir-community': '/communities/:communityName', + //'dir-community-index': '/directory/communities', + //'dir-creative': '/directory/creative', 'dir-following': '/directory/following/:category?', - 'dir-game-clips': '/directory/game/:gameName/clips', - 'dir-game-details': '/directory/game/:gameName/details', - 'dir-game-videos': '/directory/game/:gameName/videos/:filter', 'dir-game-index': '/directory/game/:gameName', + 'dir-game-clips': '/directory/game/:gameName/clips', + 'dir-game-videos': '/directory/game/:gameName/videos/:filter', + //'dir-game-details': '/directory/game/:gameName/details', 'dir-all': '/directory/all/:filter?', - 'dir-category': '/directory/:category?', + //'dir-category': '/directory/:category?', 'dash': '/:userName/dashboard/:live?', - 'dash-automod': '/:userName/dashboard/settings/automod', + //'dash-automod': '/:userName/dashboard/settings/automod', 'event': '/event/:eventName', 'popout': '/popout/:userName/chat', 'video': '/videos/:videoID', @@ -217,7 +228,7 @@ Twilight.ROUTES = { 'user': '/:userName', 'squad': '/:userName/squad', 'embed-chat': '/embed/:userName/chat' -} +}; Twilight.DIALOG_EXCLUSIVE = '.twilight-main,.twilight-minimal-root>div,#root>div>.tw-full-height,.clips-root'; diff --git a/src/sites/twitch-twilight/modules/chat/index.js b/src/sites/twitch-twilight/modules/chat/index.js index d227036a..6d34af64 100644 --- a/src/sites/twitch-twilight/modules/chat/index.js +++ b/src/sites/twitch-twilight/modules/chat/index.js @@ -227,11 +227,37 @@ export default class ChatHook extends Module { }); this.settings.add('chat.bits.show-pinned', { - default: true, + requires: ['chat.bits.show'], + default: null, + process(ctx, val) { + if ( val != null ) + return val; + + return ctx.get('chat.bits.show') + }, + ui: { path: 'Chat > Bits and Cheering >> Appearance', title: 'Display Top Cheerers', + description: 'By default, this inherits its value from Display Bits.', + component: 'setting-check-box' + } + }); + this.settings.add('chat.bits.show-rewards', { + requires: ['chat.bits.show'], + default: null, + process(ctx, val) { + if ( val != null ) + return val; + + return ctx.get('chat.bits.show') + }, + + ui: { + path: 'Chat > Bits and Cheering >> Behavior', + title: 'Display messages when a cheer shares rewards to people in chat.', + description: 'By default, this inherits its value from Display Bits. This setting only affects newly arrived messages.', component: 'setting-check-box' } }); @@ -688,6 +714,9 @@ export default class ChatHook extends Module { const types = t.chat_types || {}, mod_types = t.mod_types || {}; + if ( msg.type === types.RewardGift && ! t.chat.context.get('chat.bits.show-rewards') ) + return; + if ( msg.type === types.Message ) { const m = t.chat.standardizeMessage(msg), cont = inst._ffz_connector, @@ -1540,9 +1569,9 @@ export default class ChatHook extends Module { moderator: props.isCurrentUserModerator, channel: props.channelLogin && props.channelLogin.toLowerCase(), channelID: props.channelID, - ui: { + /*ui: { theme: props.theme - } + }*/ }); } @@ -1580,9 +1609,9 @@ export default class ChatHook extends Module { moderator: props.isCurrentUserModerator, channel: props.channelLogin && props.channelLogin.toLowerCase(), channelID: props.channelID, - ui: { + /*ui: { theme: props.theme - } + }*/ }); } diff --git a/src/sites/twitch-twilight/modules/chat/scroller.js b/src/sites/twitch-twilight/modules/chat/scroller.js index 27d8b303..4bf5f777 100644 --- a/src/sites/twitch-twilight/modules/chat/scroller.js +++ b/src/sites/twitch-twilight/modules/chat/scroller.js @@ -36,7 +36,7 @@ export default class Scroller extends Module { this.settings.add('chat.scroller.freeze', { default: 0, ui: { - path: 'Chat > Behavior >> Scrolling @{"description": "Please note that FrankerFaceZ is dependant on Twitch\'s own scrolling code working correctly. There are bugs with Twitch\'s scrolling code that have existed for more than six months. If you are using Firefox, Edge, or other non-Webkit browsers, expect to have issues."}', + path: 'Chat > Behavior >> Scrolling @{"description": "Please note that FrankerFaceZ is dependent on Twitch\'s own scrolling code working correctly. There are bugs with Twitch\'s scrolling code that have existed for more than six months. If you are using Firefox, Edge, or other non-Webkit browsers, expect to have issues."}', title: 'Pause Chat Scrolling', description: 'Automatically stop chat from scrolling when moving the mouse over it or holding a key.', component: 'setting-select-box', @@ -223,7 +223,8 @@ export default class Scroller extends Module { this._ffz_installed = true; const inst = this; - this._ffz_accessor = `_ffz_contains_${last_id++}`; + inst.ffz_outside = true; + inst._ffz_accessor = `_ffz_contains_${last_id++}`; t.on('tooltips:mousemove', this.ffzTooltipHover, this); t.on('tooltips:leave', this.ffzTooltipLeave, this); diff --git a/src/sites/twitch-twilight/modules/chat/settings_menu.jsx b/src/sites/twitch-twilight/modules/chat/settings_menu.jsx index 8e9a6c72..771afd18 100644 --- a/src/sites/twitch-twilight/modules/chat/settings_menu.jsx +++ b/src/sites/twitch-twilight/modules/chat/settings_menu.jsx @@ -185,6 +185,14 @@ export default class SettingsMenu extends Module { }); } + closeMenu(inst) { + const super_parent = this.fine.searchParent(inst, n => n.setChatInputRef && n.setAutocompleteInputRef, 100), + parent = super_parent && this.fine.searchTree(super_parent, n => n.props && n.props.isSettingsOpen && n.onClickSettings); + + if ( parent ) + parent.onClickSettings(); + } + click(inst, event) { // If we're on a page with minimal root, we want to open settings // in a popout as we're almost certainly within Popout Chat. @@ -219,7 +227,6 @@ export default class SettingsMenu extends Module { this.emit('site.menu_button:clicked'); } - const parent = this.fine.searchParent(inst, n => n.toggleBalloonId); - parent && parent.handleButtonClick(); + this.closeMenu(inst); } } \ No newline at end of file diff --git a/src/utilities/compat/fine-router.js b/src/utilities/compat/fine-router.js index 11875958..e847262c 100644 --- a/src/utilities/compat/fine-router.js +++ b/src/utilities/compat/fine-router.js @@ -15,7 +15,9 @@ export default class FineRouter extends Module { this.inject('..fine'); this.__routes = []; + this.routes = {}; + this.route_names = {}; this.current = null; this.current_name = null; this.match = null; @@ -71,12 +73,45 @@ export default class FineRouter extends Module { this.emit(':route', null, null); } - route(name, path) { + getRoute(name) { + return this.routes[name]; + } + + getRoutes() { + return this.routes; + } + + getRouteNames() { + return this.route_names; + } + + getRouteName(route) { + if ( ! this.route_names[route] ) + this.route_names[route] = route.replace(/(^|-)([a-z])/g, (_, spacer, letter) => `${spacer ? ' ' : ''}${letter.toLocaleUpperCase()}`); + + return this.route_names[route]; + } + + routeName(route, name) { + if ( typeof route === 'object' ) { + for(const key in route) + if ( has(route, key) ) + this.routeName(key, route[key]); + + return; + } + + this.route_names[route] = name; + } + + route(name, path, sort = true) { if ( typeof name === 'object' ) { for(const key in name) if ( has(name, key) ) - this.route(key, name[key]); + this.route(key, name[key], false); + if ( sort ) + this.__routes.sort((a,b) => b.score - a.score); return; } @@ -95,6 +130,7 @@ export default class FineRouter extends Module { } this.__routes.push(route); - this.__routes.sort((a,b) => b.score - a.score); + if ( sort ) + this.__routes.sort((a,b) => b.score - a.score); } } \ No newline at end of file diff --git a/src/utilities/dom.js b/src/utilities/dom.js index b15935dd..9da57a66 100644 --- a/src/utilities/dom.js +++ b/src/utilities/dom.js @@ -132,6 +132,20 @@ export function setChildren(el, children, no_sanitize, no_empty) { } +export function findSharedParent(element, other, selector) { + while(element) { + if ( element.contains(other) ) + return true; + + element = element.parentElement; + if ( selector ) + element = element && element.closest(selector); + } + + return false; +} + + export function openFile(contentType, multiple) { return new Promise(resolve => { const input = document.createElement('input'); diff --git a/src/utilities/filtering.js b/src/utilities/filtering.js index f68a3d81..c561b59f 100644 --- a/src/utilities/filtering.js +++ b/src/utilities/filtering.js @@ -4,3 +4,34 @@ // Advanced Filter System // ============================================================================ +export function createTester(rules, filter_types, inverted = false, or = false) { + if ( ! Array.isArray(rules) || ! filter_types ) + return inverted ? () => false : () => true; + + const tests = [], + names = []; + + let i = 0; + for(const rule of rules) { + if ( ! rule || ! rule.type ) + continue; + + const type = filter_types[rule.type]; + if ( ! type ) + continue; + + i++; + tests.push(type.createTest(rule.data, filter_types)); + names.push(`f${i}`); + } + + if ( ! tests.length ) + return inverted ? () => false : () => true; + + if ( tests.length === 1 ) + return inverted ? ctx => ! tests[0](ctx) : tests[0]; + + return new Function(...names, 'ctx', + `return ${inverted ? `!(` : ''}${names.map(name => `${name}(ctx)`).join(or ? ' || ' : ' && ')}${inverted ? ')' : ''};` + ).bind(null, ...tests); +} \ No newline at end of file diff --git a/src/utilities/object.js b/src/utilities/object.js index 64797db2..bdad79f1 100644 --- a/src/utilities/object.js +++ b/src/utilities/object.js @@ -172,6 +172,47 @@ export function array_equals(a, b) { } +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 ( ! 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 && ! array_equals(source_keys, 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' || ! array_equals(Object.keys(a), Object.keys(b)) ) return false; @@ -312,6 +353,12 @@ export function deep_copy(object, seen) { 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; diff --git a/styles/icons.scss b/styles/icons.scss index 88fa772a..dce59a1e 100644 --- a/styles/icons.scss +++ b/styles/icons.scss @@ -135,6 +135,7 @@ .ffz-i-keyboard:before { content: '\f11c'; } /* '' */ .ffz-i-calendar-empty:before { content: '\f133'; } /* '' */ .ffz-i-ellipsis-vert:before { content: '\f142'; } /* '' */ +.ffz-i-language:before { content: '\f1ab'; } /* '' */ .ffz-i-twitch:before { content: '\f1e8'; } /* '' */ .ffz-i-bell-off:before { content: '\f1f7'; } /* '' */ .ffz-i-trash:before { content: '\f1f8'; } /* '' */ diff --git a/styles/widgets.scss b/styles/widgets.scss index 5af40140..6cca023a 100644 --- a/styles/widgets.scss +++ b/styles/widgets.scss @@ -12,6 +12,7 @@ @import "./widgets/color-picker.scss"; @import "./widgets/icon-picker.scss"; +@import "./widgets/check-box.scss"; .tw-display-inline { display: inline !important } .tw-width-auto { width: auto !important } @@ -26,6 +27,10 @@ } } +textarea.tw-input { + height: unset; +} + .ffz--widget { input, select { min-width: 20rem; @@ -125,15 +130,23 @@ } } - .ffz--profile { - .ffz-i-ok { color: green } - } - .sortable-ghost { opacity: 0.25 } } +.ffz--profile__icon { + &.ffz-i-ok, + .ffz-i-ok { + color: green; + } + + &.ffz-i-cancel, + .ffz-i-cancel { + color: red; + } +} + .ffz--filter-editor { .ffz--rule { diff --git a/styles/widgets/check-box.scss b/styles/widgets/check-box.scss new file mode 100644 index 00000000..bfc95c89 --- /dev/null +++ b/styles/widgets/check-box.scss @@ -0,0 +1,23 @@ +.tw-checkbox__input { + &:indeterminate + .tw-checkbox__label { + &:before { + background: #7d5bbe; + border: 1px solid #7d5bbe; + } + + &:after { + content: ''; + display: block; + position: absolute; + top: 50%; + left: .4rem; + + height: 0; + width: .8rem; + + margin-top: -.1rem; + border-bottom: 2px solid #fff; + border-radius: 1rem; + } + } +} \ No newline at end of file