-
-
-
-
+
+
+
+
+
+
+
+ {{ t('setting.actions.empty-search', 'no results') }}
+
+
+
+
@@ -53,8 +74,9 @@
let id = 0;
-import {escape_regex, deep_copy} from 'utilities/object';
+import {escape_regex, deep_copy, debounce} from 'utilities/object';
import {load, ICONS as FA_ICONS, ALIASES as FA_ALIASES} from 'utilities/font-awesome';
+import { maybeLoad } from '../utilities/font-awesome';
const FFZ_ICONS = [
'cancel',
@@ -107,6 +129,7 @@ const FFZ_ICONS = [
'cw',
'up-dir',
'up-big',
+ 'play',
'link-ext',
'twitter',
'github',
@@ -140,11 +163,30 @@ const ICONS = FFZ_ICONS
.concat(FA_ICONS.filter(x => ! FFZ_ICONS.includes(x)).map(x => [`ffz-fa fa-${x}`, FA_ALIASES[x] ? FA_ALIASES[x].join(' ') : x]));
export default {
- props: ['value'],
+ props: {
+ value: String,
+ alwaysOpen: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ clearable: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ direction: {
+ type: String,
+ required: false,
+ default: 'down'
+ }
+ },
data() {
return {
id: id++,
+ open: false,
+ val: this.value,
search: '',
icons: deep_copy(ICONS)
}
@@ -159,25 +201,81 @@ export default {
reg = new RegExp('(?:^|-| )' + escape_regex(search), 'i');
return this.icons.filter(x => reg.test(x[1]));
+ },
+
+ isOpen() {
+ return this.alwaysOpen || this.open
}
},
- mounted() {
- load();
+ watch: {
+ value() {
+ this.val = this.value;
+ },
- this.$nextTick(() => {
- if ( this.value ) {
- const el = this.$el.querySelector('.tw-interactable--selected');
- if ( el )
- el.scrollIntoViewIfNeeded();
+ isOpen() {
+ if ( ! this.isOpen ) {
+ requestAnimationFrame(() => {
+ const ffz = FrankerFaceZ.get();
+ if ( ffz )
+ ffz.emit('tooltips:cleanup');
+ });
+ return;
}
- });
+
+ load();
+
+ this.$nextTick(() => {
+ if ( this.val ) {
+ const root = this.$refs.list,
+ el = root && root.querySelector('.tw-interactable--selected');
+
+ if ( el )
+ el.scrollIntoViewIfNeeded();
+ }
+ });
+ }
+ },
+
+ created() {
+ this.maybeClose = debounce(this.maybeClose, 10);
+ },
+
+ mounted() {
+ maybeLoad(this.val);
},
methods: {
- change(val) {
- this.value = val;
- this.$emit('input', this.value);
+ update() {
+ if ( this.open )
+ this.search = this.$refs.input.value;
+ },
+
+ close() {
+ this.open = false;
+ },
+
+ change(val, close = true) {
+ this.val = val;
+ this.$emit('input', this.val);
+ if ( close )
+ this.open = false;
+ },
+
+ onFocus(open = true) {
+ this.focused = true;
+ if ( open )
+ this.open = true;
+ },
+
+ onBlur() {
+ this.focused = false;
+ this.maybeClose();
+ },
+
+ maybeClose() {
+ if ( ! this.focused )
+ this.open = false;
}
}
}
diff --git a/src/utilities/constants.js b/src/utilities/constants.js
index d55cf4e5..949a24ee 100644
--- a/src/utilities/constants.js
+++ b/src/utilities/constants.js
@@ -73,7 +73,8 @@ export const WS_CLUSTERS = {
['wss://andknuckles.frankerfacez.com/', 0.8],
['wss://tuturu.frankerfacez.com/', 1],
['wss://lilz.frankerfacez.com/', 1],
- ['wss://yoohoo.frankerfacez.com/', 1]
+ ['wss://yoohoo.frankerfacez.com/', 1],
+ ['wss://pog.frankerfacez.com/', 1]
],
Development: [
diff --git a/src/utilities/object.js b/src/utilities/object.js
index 7b0f0413..f97b3f68 100644
--- a/src/utilities/object.js
+++ b/src/utilities/object.js
@@ -73,6 +73,52 @@ export function timeout(promise, delay) {
}
+/**
+ * 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.
diff --git a/src/utilities/translation-core.js b/src/utilities/translation-core.js
index 08ccc313..56cea45e 100644
--- a/src/utilities/translation-core.js
+++ b/src/utilities/translation-core.js
@@ -72,7 +72,7 @@ export const DEFAULT_TYPES = {
},
humantime(val, node) {
- return this.formatHumanTime(val, node.f);
+ return this.formatHumanTime(val, 1, node.f);
},
en_plural: v => v !== 1 ? 's' : ''
@@ -226,26 +226,28 @@ export default class TranslationCore {
return thing;
}
- formatHumanTime(value, factor) {
+ formatHumanTime(value, factor, round = false) {
if ( value instanceof Date )
value = (Date.now() - value.getTime()) / 1000;
value = Math.floor(value);
factor = Number(factor) || 1;
- const years = Math.floor((value * factor) / 31536000) / factor;
+ const fn = round ? Math.round : Math.floor;
+
+ const years = fn((value * factor) / 31536000) / factor;
if ( years >= 1 )
return this.t('human-time.years', '{count,number} year{count,en_plural}', years);
- const days = Math.floor((value %= 31536000) / 86400);
+ const days = fn((value %= 31536000) / 86400);
if ( days >= 1 )
return this.t('human-time.days', '{count,number} day{count,en_plural}', days);
- const hours = Math.floor((value %= 86400) / 3600);
+ const hours = fn((value %= 86400) / 3600);
if ( hours >= 1 )
return this.t('human-time.hours', '{count,number} hour{count,en_plural}', hours);
- const minutes = Math.floor((value %= 3600) / 60);
+ const minutes = fn((value %= 3600) / 60);
if ( minutes >= 1 )
return this.t('human-time.minutes', '{count,number} minute{count,en_plural}', minutes);
@@ -436,29 +438,33 @@ export default class TranslationCore {
return this._processAST(...this._preTransform(key, phrase, options, use_default));
}
+ formatNode(node, data, locale = null, out = null, ast = null) {
+ if ( ! node || typeof node !== 'object' )
+ return node;
+
+ if ( locale == null )
+ locale = this.locale;
+
+ let val = get(node.v, data);
+ if ( val == null )
+ return null;
+
+ if ( node.t ) {
+ if ( this.types[node.t] )
+ return this.types[node.t].call(this, val, node, locale, out, ast, data);
+ else if ( this.warn )
+ this.warn(`Encountered unknown type "${node.t}" when formatting node.`);
+ }
+
+ return val;
+ }
+
_processAST(ast, data, locale) {
const out = [];
for(const node of ast) {
- if ( typeof node === 'string' ) {
- out.push(node);
- continue;
-
- } else if ( ! node || typeof node !== 'object' )
- continue;
-
- let val = get(node.v, data);
- if ( val == null )
- continue;
-
- if ( node.t ) {
- if ( this.types[node.t] )
- val = this.types[node.t].call(this, val, node, locale, out, ast, data);
- else if ( this.warn )
- this.warn(`Encountered unknown type "${node.t}" when processing AST.`);
- }
-
- if ( val )
+ const val = this.formatNode(node, data, locale, out, ast);
+ if( val != null )
out.push(val);
}
diff --git a/src/utilities/vue.js b/src/utilities/vue.js
index 9fe56cf6..44aa5bb3 100644
--- a/src/utilities/vue.js
+++ b/src/utilities/vue.js
@@ -19,13 +19,25 @@ export class Vue extends Module {
async onLoad() {
const Vue = window.ffzVue = this.Vue = (await import(/* webpackChunkName: "vue" */ 'vue')).default,
- ObserveVisibility = await import(/* webpackChunkName: "vue" */ 'vue-observe-visibility'),
- RavenVue = await import(/* webpackChunkName: "vue" */ 'raven-js/plugins/vue'),
components = this._components;
- this.component((await import(/* webpackChunkName: "vue" */ 'src/std-components/index.js')).default);
+ const [
+ ObserveVisibility,
+ Clickaway,
+ RavenVue,
+ Components
+
+ ] = await Promise.all([
+ import(/* webpackChunkName: "vue" */ 'vue-observe-visibility'),
+ import(/* webpackChunkName: "vue" */ 'vue-clickaway'),
+ import(/* webpackChunkName: "vue" */ 'raven-js/plugins/vue'),
+ import(/* webpackChunkName: "vue" */ 'src/std-components/index.js')
+ ]);
+
+ this.component(Components.default);
Vue.use(ObserveVisibility);
+ Vue.mixin(Clickaway.mixin);
if ( ! DEBUG && this.root.raven )
this.root.raven.addPlugin(RavenVue, Vue);
@@ -80,6 +92,11 @@ export class Vue extends Module {
return t.i18n.tList(key, phrase, options);
},
+ tNode_(node, data) {
+ this.locale;
+ return t.i18n.formatNode(node, data);
+ },
+
setLocale(locale) {
t.i18n.locale = locale;
}
@@ -158,6 +175,9 @@ export class Vue extends Module {
},
tList(key, phrase, options) {
return this.$i18n.tList_(key, phrase, options);
+ },
+ tNode(node, data) {
+ return this.$i18n.tNode_(node, data);
}
}
});
diff --git a/styles/icons.scss b/styles/icons.scss
index f79dcde0..88fa772a 100644
--- a/styles/icons.scss
+++ b/styles/icons.scss
@@ -124,6 +124,7 @@
.ffz-i-cw:before { content: '\e82f'; } /* '' */
.ffz-i-up-dir:before { content: '\e830'; } /* '' */
.ffz-i-up-big:before { content: '\e831'; } /* '' */
+.ffz-i-play:before { content: '\e832'; } /* '' */
.ffz-i-link-ext:before { content: '\f08e'; } /* '' */
.ffz-i-twitter:before { content: '\f099'; } /* '' */
.ffz-i-github:before { content: '\f09b'; } /* '' */
diff --git a/styles/widgets.scss b/styles/widgets.scss
index 336dd178..5af40140 100644
--- a/styles/widgets.scss
+++ b/styles/widgets.scss
@@ -20,6 +20,11 @@
.ffz-monospace { font-family: monospace }
.ffz-bottom-100 { bottom: 100% }
+.ffz--autocomplete {
+ .scrollable-area {
+ max-height: 20rem;
+ }
+}
.ffz--widget {
input, select {
diff --git a/styles/widgets/icon-picker.scss b/styles/widgets/icon-picker.scss
index 661f059a..5c87746f 100644
--- a/styles/widgets/icon-picker.scss
+++ b/styles/widgets/icon-picker.scss
@@ -5,7 +5,7 @@
.ffz-icon {
width: auto !important;
- > * {
+ figure {
pointer-events: none;
}
}