diff options
Diffstat (limited to 'src/components')
27 files changed, 562 insertions, 408 deletions
diff --git a/src/components/color_input/color_input.scss b/src/components/color_input/color_input.scss index 8e9923cf..3de31fde 100644 --- a/src/components/color_input/color_input.scss +++ b/src/components/color_input/color_input.scss @@ -27,16 +27,16 @@ &.nativeColor { flex: 0 0 2em; min-width: 2em; - align-self: center; - height: 100%; + align-self: stretch; + min-height: 100%; } } .computedIndicator, .transparentIndicator { flex: 0 0 2em; min-width: 2em; - align-self: center; - height: 100%; + align-self: stretch; + min-height: 100%; } .transparentIndicator { // forgot to install counter-strike source, ooops diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js index ffc0ffac..ba5f7552 100644 --- a/src/components/emoji_input/emoji_input.js +++ b/src/components/emoji_input/emoji_input.js @@ -1,5 +1,6 @@ import Completion from '../../services/completion/completion.js' import EmojiPicker from '../emoji_picker/emoji_picker.vue' +import Popover from 'src/components/popover/popover.vue' import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue' import { take } from 'lodash' import { findOffset } from '../../services/offset_finder/offset_finder.service.js' @@ -109,18 +110,20 @@ const EmojiInput = { data () { return { input: undefined, + caretEl: undefined, highlighted: 0, caret: 0, focused: false, blurTimeout: null, - showPicker: false, temporarilyHideSuggestions: false, - keepOpen: false, disableClickOutside: false, - suggestions: [] + suggestions: [], + overlayStyle: {}, + pickerShown: false } }, components: { + Popover, EmojiPicker, UnicodeDomainIndicator }, @@ -128,15 +131,21 @@ const EmojiInput = { padEmoji () { return this.$store.getters.mergedConfig.padEmoji }, + preText () { + return this.modelValue.slice(0, this.caret) + }, + postText () { + return this.modelValue.slice(this.caret) + }, showSuggestions () { return this.focused && this.suggestions && this.suggestions.length > 0 && - !this.showPicker && + !this.pickerShown && !this.temporarilyHideSuggestions }, textAtCaret () { - return (this.wordAtCaret || {}).word || '' + return this.wordAtCaret?.word }, wordAtCaret () { if (this.modelValue && this.caret) { @@ -188,13 +197,35 @@ const EmojiInput = { return emoji.displayText } + }, + onInputScroll () { + this.$refs.hiddenOverlay.scrollTo({ + top: this.input.scrollTop, + left: this.input.scrollLeft + }) } }, mounted () { - const { root } = this.$refs + const { root, hiddenOverlayCaret, suggestorPopover } = this.$refs const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea') if (!input) return this.input = input + this.caretEl = hiddenOverlayCaret + if (suggestorPopover.setAnchorEl) { + suggestorPopover.setAnchorEl(this.caretEl) // unit test compat + this.$refs.picker.setAnchorEl(this.caretEl) + } else { + console.warn('setAnchorEl not found, are we in a unit test?') + } + const style = getComputedStyle(this.input) + this.overlayStyle.padding = style.padding + this.overlayStyle.border = style.border + this.overlayStyle.margin = style.margin + this.overlayStyle.lineHeight = style.lineHeight + this.overlayStyle.fontFamily = style.fontFamily + this.overlayStyle.fontSize = style.fontSize + this.overlayStyle.wordWrap = style.wordWrap + this.overlayStyle.whiteSpace = style.whiteSpace this.resize() input.addEventListener('blur', this.onBlur) input.addEventListener('focus', this.onFocus) @@ -204,6 +235,7 @@ const EmojiInput = { input.addEventListener('click', this.onClickInput) input.addEventListener('transitionend', this.onTransition) input.addEventListener('input', this.onInput) + input.addEventListener('scroll', this.onInputScroll) }, unmounted () { const { input } = this @@ -216,45 +248,43 @@ const EmojiInput = { input.removeEventListener('click', this.onClickInput) input.removeEventListener('transitionend', this.onTransition) input.removeEventListener('input', this.onInput) + input.removeEventListener('scroll', this.onInputScroll) } }, watch: { - showSuggestions: function (newValue) { + showSuggestions: function (newValue, oldValue) { this.$emit('shown', newValue) + if (newValue) { + this.$refs.suggestorPopover.showPopover() + } else { + this.$refs.suggestorPopover.hidePopover() + } }, textAtCaret: async function (newWord) { + if (newWord === undefined) return const firstchar = newWord.charAt(0) - this.suggestions = [] - if (newWord === firstchar) return + if (newWord === firstchar) { + this.suggestions = [] + return + } const matchedSuggestions = await this.suggest(newWord, this.maybeLocalizedEmojiNamesAndKeywords) // Async: cancel if textAtCaret has changed during wait - if (this.textAtCaret !== newWord) return - if (matchedSuggestions.length <= 0) return + if (this.textAtCaret !== newWord || matchedSuggestions.length <= 0) { + this.suggestions = [] + return + } this.suggestions = take(matchedSuggestions, 5) .map(({ imageUrl, ...rest }) => ({ ...rest, img: imageUrl || '' })) - }, - suggestions: { - handler (newValue) { - this.$nextTick(this.resize) - }, - deep: true } }, methods: { - focusPickerInput () { - const pickerEl = this.$refs.picker.$el - if (!pickerEl) return - const pickerInput = pickerEl.querySelector('input') - if (pickerInput) pickerInput.focus() - }, triggerShowPicker () { - this.showPicker = true this.$nextTick(() => { + this.$refs.picker.showPicker() this.scrollIntoView() - this.focusPickerInput() }) // This temporarily disables "click outside" handler // since external trigger also means click originates @@ -266,11 +296,12 @@ const EmojiInput = { }, togglePicker () { this.input.focus() - this.showPicker = !this.showPicker - if (this.showPicker) { + if (!this.pickerShown) { this.scrollIntoView() + this.$refs.picker.showPicker() this.$refs.picker.startEmojiLoad() - this.$nextTick(this.focusPickerInput) + } else { + this.$refs.picker.hidePicker() } }, replace (replacement) { @@ -307,7 +338,6 @@ const EmojiInput = { spaceAfter, after ].join('') - this.keepOpen = keepOpen this.$emit('update:modelValue', newValue) const position = this.caret + (insertion + spaceAfter + spaceBefore).length if (!keepOpen) { @@ -407,8 +437,11 @@ const EmojiInput = { } }) }, - onTransition (e) { - this.resize() + onPickerShown () { + this.pickerShown = true + }, + onPickerClosed () { + this.pickerShown = false }, onBlur (e) { // Clicking on any suggestion removes focus from autocomplete, @@ -416,7 +449,6 @@ const EmojiInput = { this.blurTimeout = setTimeout(() => { this.focused = false this.setCaret(e) - this.resize() }, 200) }, onClick (e, suggestion) { @@ -428,18 +460,13 @@ const EmojiInput = { this.blurTimeout = null } - if (!this.keepOpen) { - this.showPicker = false - } this.focused = true this.setCaret(e) - this.resize() this.temporarilyHideSuggestions = false }, onKeyUp (e) { const { key } = e this.setCaret(e) - this.resize() // Setting hider in keyUp to prevent suggestions from blinking // when moving away from suggested spot @@ -451,7 +478,6 @@ const EmojiInput = { }, onPaste (e) { this.setCaret(e) - this.resize() }, onKeyDown (e) { const { ctrlKey, shiftKey, key } = e @@ -496,58 +522,24 @@ const EmojiInput = { this.input.focus() } } - - this.showPicker = false - this.resize() }, onInput (e) { - this.showPicker = false this.setCaret(e) - this.resize() this.$emit('update:modelValue', e.target.value) }, - onClickInput (e) { - this.showPicker = false - }, - onClickOutside (e) { - if (this.disableClickOutside) return - this.showPicker = false - }, onStickerUploaded (e) { - this.showPicker = false this.$emit('sticker-uploaded', e) }, onStickerUploadFailed (e) { - this.showPicker = false this.$emit('sticker-upload-Failed', e) }, setCaret ({ target: { selectionStart } }) { this.caret = selectionStart + this.$nextTick(() => { + this.$refs.suggestorPopover.updateStyles() + }) }, resize () { - const panel = this.$refs.panel - if (!panel) return - const picker = this.$refs.picker.$el - const panelBody = this.$refs['panel-body'] - const { offsetHeight, offsetTop } = this.input - const offsetBottom = offsetTop + offsetHeight - - this.setPlacement(panelBody, panel, offsetBottom) - this.setPlacement(picker, picker, offsetBottom) - }, - setPlacement (container, target, offsetBottom) { - if (!container || !target) return - - target.style.top = offsetBottom + 'px' - target.style.bottom = 'auto' - - if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) { - target.style.top = 'auto' - target.style.bottom = this.input.offsetHeight + 'px' - } - }, - overflowsBottom (el) { - return el.getBoundingClientRect().bottom > window.innerHeight } } } diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue index 43581dbf..c9bbc18f 100644 --- a/src/components/emoji_input/emoji_input.vue +++ b/src/components/emoji_input/emoji_input.vue @@ -1,11 +1,23 @@ <template> <div ref="root" - v-click-outside="onClickOutside" class="emoji-input" :class="{ 'with-picker': !hideEmojiButton }" > <slot /> + <!-- TODO: make the 'x' disappear if at the end maybe? --> + <div + ref="hiddenOverlay" + class="hidden-overlay" + :style="overlayStyle" + > + <span>{{ preText }}</span> + <span + ref="hiddenOverlayCaret" + class="caret" + >x</span> + <span>{{ postText }}</span> + </div> <template v-if="enableEmojiPicker"> <button v-if="!hideEmojiButton" @@ -18,59 +30,61 @@ <EmojiPicker v-if="enableEmojiPicker" ref="picker" - :class="{ hide: !showPicker }" - :showing="showPicker" :enable-sticker-picker="enableStickerPicker" class="emoji-picker-panel" @emoji="insert" @sticker-uploaded="onStickerUploaded" @sticker-upload-failed="onStickerUploadFailed" + @show="onPickerShown" + @close="onPickerClosed" /> </template> - <div - ref="panel" + <Popover + ref="suggestorPopover" class="autocomplete-panel" - :class="{ hide: !showSuggestions }" + placement="bottom" > - <div - ref="panel-body" - class="autocomplete-panel-body" - > + <template #content> <div - v-for="(suggestion, index) in suggestions" - :key="index" - class="autocomplete-item" - :class="{ highlighted: index === highlighted }" - @click.stop.prevent="onClick($event, suggestion)" + ref="panel-body" + class="autocomplete-panel-body" > - <span class="image"> - <img - v-if="suggestion.img" - :src="suggestion.img" - > - <span v-else>{{ suggestion.replacement }}</span> - </span> - <div class="label"> - <span - v-if="suggestion.user" - class="displayText" - > - {{ suggestion.displayText }}<UnicodeDomainIndicator - :user="suggestion.user" - :at="false" - /> + <div + v-for="(suggestion, index) in suggestions" + :key="index" + class="autocomplete-item" + :class="{ highlighted: index === highlighted }" + @click.stop.prevent="onClick($event, suggestion)" + > + <span class="image"> + <img + v-if="suggestion.img" + :src="suggestion.img" + > + <span v-else>{{ suggestion.replacement }}</span> </span> - <span - v-if="!suggestion.user" - class="displayText" - > - {{ maybeLocalizedEmojiName(suggestion) }} - </span> - <span class="detailText">{{ suggestion.detailText }}</span> + <div class="label"> + <span + v-if="suggestion.user" + class="displayText" + > + {{ suggestion.displayText }}<UnicodeDomainIndicator + :user="suggestion.user" + :at="false" + /> + </span> + <span + v-if="!suggestion.user" + class="displayText" + > + {{ maybeLocalizedEmojiName(suggestion) }} + </span> + <span class="detailText">{{ suggestion.detailText }}</span> + </div> </div> </div> - </div> - </div> + </template> + </Popover> </div> </template> @@ -102,6 +116,7 @@ color: var(--text, $fallback--text); } } + .emoji-picker-panel { position: absolute; z-index: 20; @@ -112,89 +127,83 @@ } } - .autocomplete { - &-panel { - position: absolute; - z-index: 20; - margin-top: 2px; - - &.hide { - display: none - } + input, textarea { + flex: 1 0 auto; + } - &-body { - margin: 0 0.5em 0 0.5em; - border-radius: $fallback--tooltipRadius; - border-radius: var(--tooltipRadius, $fallback--tooltipRadius); - box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); - box-shadow: var(--popupShadow); - min-width: 75%; - background-color: $fallback--bg; - background-color: var(--popover, $fallback--bg); - color: $fallback--link; - color: var(--popoverText, $fallback--link); - --faint: var(--popoverFaintText, $fallback--faint); - --faintLink: var(--popoverFaintLink, $fallback--faint); - --lightText: var(--popoverLightText, $fallback--lightText); - --postLink: var(--popoverPostLink, $fallback--link); - --postFaintLink: var(--popoverPostFaintLink, $fallback--link); - --icon: var(--popoverIcon, $fallback--icon); - } + .hidden-overlay { + opacity: 0; + pointer-events: none; + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + overflow: hidden; + /* DEBUG STUFF */ + color: red; + /* set opacity to non-zero to see the overlay */ + + .caret { + width: 0; + margin-right: calc(-1ch - 1px); + border: 1px solid red; } + } +} +.autocomplete { + &-panel { + position: absolute; + } - &-item { - display: flex; - cursor: pointer; - padding: 0.2em 0.4em; - border-bottom: 1px solid rgba(0, 0, 0, 0.4); + &-item { + display: flex; + cursor: pointer; + padding: 0.2em 0.4em; + border-bottom: 1px solid rgba(0, 0, 0, 0.4); + height: 32px; + + .image { + width: 32px; height: 32px; + line-height: 32px; + text-align: center; + font-size: 32px; - .image { + margin-right: 4px; + + img { width: 32px; height: 32px; - line-height: 32px; - text-align: center; - font-size: 32px; - - margin-right: 4px; - - img { - width: 32px; - height: 32px; - object-fit: contain; - } + object-fit: contain; } + } - .label { - display: flex; - flex-direction: column; - justify-content: center; - margin: 0 0.1em 0 0.2em; - - .displayText { - line-height: 1.5; - } + .label { + display: flex; + flex-direction: column; + justify-content: center; + margin: 0 0.1em 0 0.2em; - .detailText { - font-size: 9px; - line-height: 9px; - } + .displayText { + line-height: 1.5; } - &.highlighted { - background-color: $fallback--fg; - background-color: var(--selectedMenuPopover, $fallback--fg); - color: var(--selectedMenuPopoverText, $fallback--text); - --faint: var(--selectedMenuPopoverFaintText, $fallback--faint); - --faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint); - --lightText: var(--selectedMenuPopoverLightText, $fallback--lightText); - --icon: var(--selectedMenuPopoverIcon, $fallback--icon); + .detailText { + font-size: 9px; + line-height: 9px; } } - } - input, textarea { - flex: 1 0 auto; + &.highlighted { + background-color: $fallback--fg; + background-color: var(--selectedMenuPopover, $fallback--fg); + color: var(--selectedMenuPopoverText, $fallback--text); + --faint: var(--selectedMenuPopoverFaintText, $fallback--faint); + --faintLink: var(--selectedMenuPopoverFaintLink, $fallback--faint); + --lightText: var(--selectedMenuPopoverLightText, $fallback--lightText); + --icon: var(--selectedMenuPopoverIcon, $fallback--icon); + } } } </style> diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js index fafc2af1..dd5e5217 100644 --- a/src/components/emoji_picker/emoji_picker.js +++ b/src/components/emoji_picker/emoji_picker.js @@ -1,5 +1,6 @@ import { defineAsyncComponent } from 'vue' import Checkbox from '../checkbox/checkbox.vue' +import Popover from 'src/components/popover/popover.vue' import StillImage from '../still-image/still-image.vue' import { ensureFinalFallback } from '../../i18n/languages.js' import lozad from 'lozad' @@ -87,10 +88,6 @@ const EmojiPicker = { required: false, type: Boolean, default: false - }, - showing: { - required: true, - type: Boolean } }, data () { @@ -111,15 +108,32 @@ const EmojiPicker = { components: { StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')), Checkbox, - StillImage + StillImage, + Popover }, methods: { + showPicker () { + this.$refs.popover.showPopover() + this.onShowing() + }, + hidePicker () { + this.$refs.popover.hidePopover() + }, + setAnchorEl (el) { + this.$refs.popover.setAnchorEl(el) + }, setGroupRef (name) { return el => { this.groupRefs[name] = el } }, setEmojiRef (name) { return el => { this.emojiRefs[name] = el } }, + onPopoverShown () { + this.$emit('show') + }, + onPopoverClosed () { + this.$emit('close') + }, onStickerUploaded (e) { this.$emit('sticker-uploaded', e) }, @@ -128,6 +142,9 @@ const EmojiPicker = { }, onEmoji (emoji) { const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement + if (!this.keepOpen) { + this.$refs.popover.hidePopover() + } this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen }) }, onScroll (e) { @@ -223,6 +240,9 @@ const EmojiPicker = { }, onShowing () { const oldContentLoaded = this.contentLoaded + this.$nextTick(() => { + this.$refs.search.focus() + }) this.contentLoaded = true this.waitForDomAndInitializeLazyLoad() this.filteredEmojiGroups = this.getFilteredEmojiGroups() @@ -251,16 +271,6 @@ const EmojiPicker = { allCustomGroups () { this.waitForDomAndInitializeLazyLoad() this.filteredEmojiGroups = this.getFilteredEmojiGroups() - }, - showing (val) { - if (val) { - this.onShowing() - } - } - }, - mounted () { - if (this.showing) { - this.onShowing() } }, destroyed () { diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss index 016c46d7..53363ec1 100644 --- a/src/components/emoji_picker/emoji_picker.scss +++ b/src/components/emoji_picker/emoji_picker.scss @@ -6,14 +6,10 @@ $emoji-picker-header-picture-height: 32px; $emoji-picker-emoji-size: 32px; .emoji-picker { + width: 25em; + max-width: 100vw; display: flex; flex-direction: column; - position: absolute; - right: 0; - left: 0; - margin: 0 !important; - // TODO: actually use popover in emoji picker - z-index: var(--ZI_popovers); background-color: $fallback--bg; background-color: var(--popover, $fallback--bg); color: $fallback--link; diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue index 57bb0037..ff56d637 100644 --- a/src/components/emoji_picker/emoji_picker.vue +++ b/src/components/emoji_picker/emoji_picker.vue @@ -1,129 +1,136 @@ <template> - <div - class="emoji-picker panel panel-default panel-body" + <Popover + ref="popover" + trigger="click" + popover-class="emoji-picker popover-default" + @show="onPopoverShown" + @close="onPopoverClosed" > - <div class="heading"> - <span - ref="header" - class="emoji-tabs" - > + <template #content> + <div class="heading"> <span - v-for="group in filteredEmojiGroups" - :ref="setGroupRef('group-header-' + group.id)" - :key="group.id" - class="emoji-tabs-item" - :class="{ - active: activeGroupView === group.id - }" - :title="group.text" - @click.prevent="highlight(group.id)" + ref="header" + class="emoji-tabs" > <span - v-if="group.image" - class="emoji-picker-header-image" + v-for="group in filteredEmojiGroups" + :ref="setGroupRef('group-header-' + group.id)" + :key="group.id" + class="emoji-tabs-item" + :class="{ + active: activeGroupView === group.id + }" + :title="group.text" + @click.prevent="highlight(group.id)" > - <still-image - :alt="group.text" - :src="group.image" + <span + v-if="group.image" + class="emoji-picker-header-image" + > + <still-image + :alt="group.text" + :src="group.image" + /> + </span> + <FAIcon + v-else + :icon="group.icon" + fixed-width /> </span> - <FAIcon - v-else - :icon="group.icon" - fixed-width - /> </span> - </span> - <span - v-if="stickerPickerEnabled" - class="additional-tabs" - > <span - class="stickers-tab-icon additional-tabs-item" - :class="{active: showingStickers}" - :title="$t('emoji.stickers')" - @click.prevent="toggleStickers" + v-if="stickerPickerEnabled" + class="additional-tabs" > - <FAIcon - icon="sticky-note" - fixed-width - /> + <span + class="stickers-tab-icon additional-tabs-item" + :class="{active: showingStickers}" + :title="$t('emoji.stickers')" + @click.prevent="toggleStickers" + > + <FAIcon + icon="sticky-note" + fixed-width + /> + </span> </span> - </span> - </div> - <div - v-if="contentLoaded" - class="content" - > + </div> <div - class="emoji-content" - :class="{hidden: showingStickers}" + v-if="contentLoaded" + class="content" > - <div class="emoji-search"> - <input - v-model="keyword" - type="text" - class="form-control" - :placeholder="$t('emoji.search_emoji')" - @input="$event.target.composing = false" - > - </div> <div - ref="emoji-groups" - class="emoji-groups" - :class="groupsScrolledClass" - @scroll="onScroll" + class="emoji-content" + :class="{hidden: showingStickers}" > + <div class="emoji-search"> + <input + ref="search" + v-model="keyword" + type="text" + class="form-control" + :placeholder="$t('emoji.search_emoji')" + @input="$event.target.composing = false" + > + </div> <div - v-for="group in filteredEmojiGroups" - :key="group.id" - class="emoji-group" + ref="emoji-groups" + class="emoji-groups" + :class="groupsScrolledClass" + @scroll="onScroll" > - <h6 - :ref="setGroupRef('group-' + group.id)" - class="emoji-group-title" - > - {{ group.text }} - </h6> - <span - v-for="emoji in group.emojis" - :key="group.id + emoji.displayText" - :title="maybeLocalizedEmojiName(emoji)" - class="emoji-item" - @click.stop.prevent="onEmoji(emoji)" + <div + v-for="group in filteredEmojiGroups" + :key="group.id" + class="emoji-group" > + <h6 + :ref="setGroupRef('group-' + group.id)" + class="emoji-group-title" + > + {{ group.text }} + </h6> <span - v-if="!emoji.imageUrl" - class="emoji-picker-emoji -unicode" - >{{ emoji.replacement }}</span> - <still-image - v-else - :ref="setEmojiRef(group.id + emoji.displayText)" - class="emoji-picker-emoji -custom" - :data-src="emoji.imageUrl" - :data-emoji-name="group.id + emoji.displayText" - /> - </span> - <span :ref="setGroupRef('group-end-' + group.id)" /> + v-for="emoji in group.emojis" + :key="group.id + emoji.displayText" + :title="maybeLocalizedEmojiName(emoji)" + class="emoji-item" + @click.stop.prevent="onEmoji(emoji)" + > + <span + v-if="!emoji.imageUrl" + class="emoji-picker-emoji -unicode" + >{{ emoji.replacement }}</span> + <still-image + v-else + :ref="setEmojiRef(group.id + emoji.displayText)" + class="emoji-picker-emoji -custom" + :data-src="emoji.imageUrl" + :data-emoji-name="group.id + emoji.displayText" + /> + </span> + <span :ref="setGroupRef('group-end-' + group.id)" /> + </div> + </div> + <div class="keep-open"> + <Checkbox v-model="keepOpen"> + {{ $t('emoji.keep_open') }} + </Checkbox> </div> </div> - <div class="keep-open"> - <Checkbox v-model="keepOpen"> - {{ $t('emoji.keep_open') }} - </Checkbox> + <div + v-if="showingStickers" + class="stickers-content" + > + <sticker-picker + @uploaded="onStickerUploaded" + @upload-failed="onStickerUploadFailed" + /> </div> </div> - <div - v-if="showingStickers" - class="stickers-content" - > - <sticker-picker - @uploaded="onStickerUploaded" - @upload-failed="onStickerUploadFailed" - /> - </div> - </div> - </div> + </template> + </Popover> </template> <script src="./emoji_picker.js"></script> diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue index 4ea8b6a2..4eb22a65 100644 --- a/src/components/emoji_reactions/emoji_reactions.vue +++ b/src/components/emoji_reactions/emoji_reactions.vue @@ -1,5 +1,5 @@ <template> - <div class="emoji-reactions"> + <div class="EmojiReactions"> <UserListPopover v-for="(reaction) in emojiReactions" :key="reaction.name" @@ -7,7 +7,7 @@ > <button class="emoji-reaction btn button-default" - :class="{ 'picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }" + :class="{ '-picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }" @click="emojiOnClick(reaction.name, $event)" @mouseenter="fetchEmojiReactionsByIfMissing()" > @@ -30,53 +30,55 @@ <style lang="scss"> @import '../../_variables.scss'; -.emoji-reactions { +.EmojiReactions { display: flex; margin-top: 0.25em; flex-wrap: wrap; -} -.emoji-reaction { - padding: 0 0.5em; - margin-right: 0.5em; - margin-top: 0.5em; - display: flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - .reaction-emoji { - width: 1.25em; - margin-right: 0.25em; - } - &:focus { - outline: none; - } + .emoji-reaction { + padding: 0 0.5em; + margin-right: 0.5em; + margin-top: 0.5em; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; - &.not-clickable { - cursor: default; - &:hover { - box-shadow: $fallback--buttonShadow; - box-shadow: var(--buttonShadow); + .reaction-emoji { + width: 1.25em; + margin-right: 0.25em; + } + + &:focus { + outline: none; + } + + &.not-clickable { + cursor: default; + &:hover { + box-shadow: $fallback--buttonShadow; + box-shadow: var(--buttonShadow); + } + } + + &.-picked-reaction { + border: 1px solid var(--accent, $fallback--link); + margin-left: -1px; // offset the border, can't use inset shadows either + margin-right: calc(0.5em - 1px); } } -} -.emoji-reaction-expand { - padding: 0 0.5em; - margin-right: 0.5em; - margin-top: 0.5em; - display: flex; - align-items: center; - justify-content: center; - &:hover { - text-decoration: underline; + .emoji-reaction-expand { + padding: 0 0.5em; + margin-right: 0.5em; + margin-top: 0.5em; + display: flex; + align-items: center; + justify-content: center; + &:hover { + text-decoration: underline; + } } -} -.picked-reaction { - border: 1px solid var(--accent, $fallback--link); - margin-left: -1px; // offset the border, can't use inset shadows either - margin-right: calc(0.5em - 1px); } - </style> diff --git a/src/components/favorite_button/favorite_button.js b/src/components/favorite_button/favorite_button.js index c996cba2..cf3378c9 100644 --- a/src/components/favorite_button/favorite_button.js +++ b/src/components/favorite_button/favorite_button.js @@ -39,7 +39,10 @@ const FavoriteButton = { } }, computed: { - ...mapGetters(['mergedConfig']) + ...mapGetters(['mergedConfig']), + remoteInteractionLink () { + return this.$store.getters.remoteInteractionLink({ statusId: this.status.id }) + } } } diff --git a/src/components/favorite_button/favorite_button.vue b/src/components/favorite_button/favorite_button.vue index 74a1dfbb..ea01720a 100644 --- a/src/components/favorite_button/favorite_button.vue +++ b/src/components/favorite_button/favorite_button.vue @@ -33,13 +33,19 @@ /> </FALayers> </button> - <span v-else> + <a + v-else + class="button-unstyled interactive" + target="_blank" + role="button" + :href="remoteInteractionLink" + > <FAIcon class="fa-scale-110 fa-old-padding" :title="$t('tool_tip.favorite')" :icon="['far', 'star']" /> - </span> + </a> <span v-if="!mergedConfig.hidePostStats && status.fave_num > 0" class="action-counter" diff --git a/src/components/mobile_nav/mobile_nav.vue b/src/components/mobile_nav/mobile_nav.vue index 6e732d1f..d642008b 100644 --- a/src/components/mobile_nav/mobile_nav.vue +++ b/src/components/mobile_nav/mobile_nav.vue @@ -11,7 +11,7 @@ <button class="button-unstyled mobile-nav-button" :title="$t('nav.mobile_sidebar')" - :aria-expanaded="this.$refs.sideDrawer && !this.$refs.sideDrawer.closed" + :aria-expanaded="$refs.sideDrawer && !$refs.sideDrawer.closed" @click.stop.prevent="toggleMobileSidebar()" > <FAIcon @@ -51,7 +51,7 @@ > <div class="mobile-notifications-header"> <span class="title">{{ $t('notifications.notifications') }}</span> - <span class="spacer"/> + <span class="spacer" /> <button v-if="notificationsAtTop" class="button-unstyled mobile-nav-button" @@ -79,8 +79,8 @@ </div> <div id="mobile-notifications" - class="mobile-notifications" ref="mobileNotifications" + class="mobile-notifications" @scroll="onScroll" /> </aside> diff --git a/src/components/navigation/navigation_pins.vue b/src/components/navigation/navigation_pins.vue index 9cb4b536..6a9ed6f5 100644 --- a/src/components/navigation/navigation_pins.vue +++ b/src/components/navigation/navigation_pins.vue @@ -61,7 +61,7 @@ &.router-link-active { color: $fallback--text; - color: var(--selectedMenuText, $fallback--text); + color: var(--panelText, $fallback--text); border-bottom: 4px solid; & .svg-inline--fa, diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue index 3d5878d4..633efca6 100644 --- a/src/components/notifications/notifications.vue +++ b/src/components/notifications/notifications.vue @@ -22,8 +22,8 @@ >{{ unseenCount }}</span> </div> <div - class="rightside-button" v-if="showScrollTop" + class="rightside-button" > <button class="button-unstyled scroll-to-top-button" diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index 72b7c511..d44b266b 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -56,6 +56,10 @@ const Popover = { // lockReEntry is a flag that is set when mouse cursor is leaving the popover's content // so that if mouse goes back into popover it won't be re-shown again to prevent annoyance // with popovers refusing to be hidden when user wants to interact with something in below popover + anchorEl: null, + // There's an issue where having teleport enabled by default causes things just... + // not render at all, i.e. main post status form and its emoji inputs + teleport: false, lockReEntry: false, hidden: true, styles: {}, @@ -64,10 +68,15 @@ const Popover = { // used to avoid blinking if hovered onto popover graceTimeout: null, parentPopover: null, + disableClickOutside: false, childrenShown: new Set() } }, methods: { + setAnchorEl (el) { + this.anchorEl = el + this.updateStyles() + }, containerBoundingClientRect () { const container = this.boundToSelector ? this.$el.closest(this.boundToSelector) : this.$el.offsetParent return container.getBoundingClientRect() @@ -80,7 +89,7 @@ const Popover = { // Popover will be anchored around this element, trigger ref is the container, so // its children are what are inside the slot. Expect only one v-slot:trigger. - const anchorEl = (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el + const anchorEl = this.anchorEl || (this.$refs.trigger && this.$refs.trigger.children[0]) || this.$el // SVGs don't have offsetWidth/Height, use fallback const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth @@ -231,6 +240,10 @@ const Popover = { }, showPopover () { if (this.disabled) return + this.disableClickOutside = true + setTimeout(() => { + this.disableClickOutside = false + }, 0) const wasHidden = this.hidden this.hidden = false this.parentPopover && this.parentPopover.onChildPopoverState(this, true) @@ -291,6 +304,7 @@ const Popover = { } }, onClickOutside (e) { + if (this.disableClickOutside) return if (this.hidden) return if (this.$refs.content && this.$refs.content.contains(e.target)) return if (this.$el.contains(e.target)) return @@ -324,6 +338,7 @@ const Popover = { } }, mounted () { + this.teleport = true let scrollable = this.$refs.trigger.closest('.column.-scrollable') || this.$refs.trigger.closest('.mobile-notifications') if (!scrollable) scrollable = window diff --git a/src/components/popover/popover.vue b/src/components/popover/popover.vue index 9506728e..2869d736 100644 --- a/src/components/popover/popover.vue +++ b/src/components/popover/popover.vue @@ -12,7 +12,10 @@ > <slot name="trigger" /> </button> - <teleport to="#popovers"> + <teleport + :disabled="!teleport" + to="#popovers" + > <transition name="fade"> <div v-if="!hidden" diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 5c536b74..eb55cfcc 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -501,7 +501,6 @@ const PostStatusForm = { if (target.value === '') { target.style.height = null this.$emit('resize') - this.$refs['emoji-input'].resize() return } @@ -588,8 +587,6 @@ const PostStatusForm = { } else { scrollerRef.scrollTop = targetScroll } - - this.$refs['emoji-input'].resize() }, showEmojiPicker () { this.$refs.textarea.focus() diff --git a/src/components/quick_filter_settings/quick_filter_settings.vue b/src/components/quick_filter_settings/quick_filter_settings.vue index 87fcd716..f2aa61ee 100644 --- a/src/components/quick_filter_settings/quick_filter_settings.vue +++ b/src/components/quick_filter_settings/quick_filter_settings.vue @@ -3,7 +3,7 @@ trigger="click" class="QuickFilterSettings" :bound-to="{ x: 'container' }" - :triggerAttrs="{ title: $t('timeline.quick_filter_settings') }" + :trigger-attrs="{ title: $t('timeline.quick_filter_settings') }" > <template #content> <div class="dropdown-menu"> diff --git a/src/components/quick_view_settings/quick_view_settings.vue b/src/components/quick_view_settings/quick_view_settings.vue index d7c9bf3b..4bd81c5b 100644 --- a/src/components/quick_view_settings/quick_view_settings.vue +++ b/src/components/quick_view_settings/quick_view_settings.vue @@ -3,7 +3,7 @@ trigger="click" class="QuickViewSettings" :bound-to="{ x: 'container' }" - :triggerAttrs="{ title: $t('timeline.quick_view_settings') }" + :trigger-attrs="{ title: $t('timeline.quick_view_settings') }" > <template #content> <div class="dropdown-menu"> diff --git a/src/components/react_button/react_button.js b/src/components/react_button/react_button.js index e65bfd93..2a0dac85 100644 --- a/src/components/react_button/react_button.js +++ b/src/components/react_button/react_button.js @@ -1,4 +1,5 @@ import Popover from '../popover/popover.vue' +import { ensureFinalFallback } from '../../i18n/languages.js' import { library } from '@fortawesome/fontawesome-svg-core' import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons' import { faSmileBeam } from '@fortawesome/free-regular-svg-icons' @@ -43,31 +44,73 @@ const ReactButton = { const input = this.$el.querySelector('input') if (input) input.focus() }) + }, + // Vaguely adjusted copypaste from emoji_input and emoji_picker! + maybeLocalizedEmojiNamesAndKeywords (emoji) { + const names = [emoji.displayText] + const keywords = [] + + if (emoji.displayTextI18n) { + names.push(this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)) + } + + if (emoji.annotations) { + this.languages.forEach(lang => { + names.push(emoji.annotations[lang]?.name) + + keywords.push(...(emoji.annotations[lang]?.keywords || [])) + }) + } + + return { + names: names.filter(k => k), + keywords: keywords.filter(k => k) + } + }, + maybeLocalizedEmojiName (emoji) { + if (!emoji.annotations) { + return emoji.displayText + } + + if (emoji.displayTextI18n) { + return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args) + } + + for (const lang of this.languages) { + if (emoji.annotations[lang]?.name) { + return emoji.annotations[lang].name + } + } + + return emoji.displayText } }, computed: { commonEmojis () { - return [ - { displayText: 'thumbsup', replacement: '👍' }, - { displayText: 'angry', replacement: '😠' }, - { displayText: 'eyes', replacement: '👀' }, - { displayText: 'joy', replacement: '😂' }, - { displayText: 'fire', replacement: '🔥' } - ] + const hardcodedSet = new Set(['👍', '😠', '👀', '😂', '🔥']) + return this.$store.getters.standardEmojiList.filter(emoji => hardcodedSet.has(emoji.replacement)) + }, + languages () { + return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage) }, emojis () { if (this.filterWord !== '') { - const filterWordLowercase = trim(this.filterWord.toLowerCase()) + const keywordLowercase = trim(this.filterWord.toLowerCase()) + const orderedEmojiList = [] for (const emoji of this.$store.getters.standardEmojiList) { - if (emoji.replacement === this.filterWord) return [emoji] + const indices = this.maybeLocalizedEmojiNamesAndKeywords(emoji) + .keywords + .map(k => k.toLowerCase().indexOf(keywordLowercase)) + .filter(k => k > -1) + + const indexOfKeyword = indices.length ? Math.min(...indices) : -1 - const indexOfFilterWord = emoji.displayText.toLowerCase().indexOf(filterWordLowercase) - if (indexOfFilterWord > -1) { - if (!Array.isArray(orderedEmojiList[indexOfFilterWord])) { - orderedEmojiList[indexOfFilterWord] = [] + if (indexOfKeyword > -1) { + if (!Array.isArray(orderedEmojiList[indexOfKeyword])) { + orderedEmojiList[indexOfKeyword] = [] } - orderedEmojiList[indexOfFilterWord].push(emoji) + orderedEmojiList[indexOfKeyword].push(emoji) } } return orderedEmojiList.flat() diff --git a/src/components/react_button/react_button.vue b/src/components/react_button/react_button.vue index 254c49db..0c5fe321 100644 --- a/src/components/react_button/react_button.vue +++ b/src/components/react_button/react_button.vue @@ -24,7 +24,7 @@ v-for="emoji in commonEmojis" :key="emoji.replacement" class="emoji-button" - :title="emoji.displayText" + :title="maybeLocalizedEmojiName(emoji)" @click="addReaction($event, emoji.replacement, close)" > {{ emoji.replacement }} @@ -34,7 +34,7 @@ v-for="(emoji, key) in emojis" :key="key" class="emoji-button" - :title="emoji.displayText" + :title="maybeLocalizedEmojiName(emoji)" @click="addReaction($event, emoji.replacement, close)" > {{ emoji.replacement }} diff --git a/src/components/reply_button/reply_button.js b/src/components/reply_button/reply_button.js index d6382982..543d25ac 100644 --- a/src/components/reply_button/reply_button.js +++ b/src/components/reply_button/reply_button.js @@ -17,6 +17,9 @@ const ReplyButton = { computed: { loggedIn () { return !!this.$store.state.users.currentUser + }, + remoteInteractionLink () { + return this.$store.getters.remoteInteractionLink({ statusId: this.status.id }) } } } diff --git a/src/components/reply_button/reply_button.vue b/src/components/reply_button/reply_button.vue index ea97fbaa..dada511b 100644 --- a/src/components/reply_button/reply_button.vue +++ b/src/components/reply_button/reply_button.vue @@ -26,13 +26,19 @@ /> </FALayers> </button> - <span v-else> + <a + v-else + class="button-unstyled interactive" + target="_blank" + role="button" + :href="remoteInteractionLink" + > <FAIcon icon="reply" class="fa-scale-110 fa-old-padding" :title="$t('tool_tip.reply')" /> - </span> + </a> <span v-if="status.replies_count > 0" class="action-counter" diff --git a/src/components/retweet_button/retweet_button.js b/src/components/retweet_button/retweet_button.js index b7911814..4d92b5fa 100644 --- a/src/components/retweet_button/retweet_button.js +++ b/src/components/retweet_button/retweet_button.js @@ -36,6 +36,9 @@ const RetweetButton = { computed: { mergedConfig () { return this.$store.getters.mergedConfig + }, + remoteInteractionLink () { + return this.$store.getters.remoteInteractionLink({ statusId: this.status.id }) } } } diff --git a/src/components/retweet_button/retweet_button.vue b/src/components/retweet_button/retweet_button.vue index 396d1200..240828e3 100644 --- a/src/components/retweet_button/retweet_button.vue +++ b/src/components/retweet_button/retweet_button.vue @@ -40,13 +40,19 @@ :title="$t('timeline.no_retweet_hint')" /> </span> - <span v-else> + <a + v-else + class="button-unstyled interactive" + target="_blank" + role="button" + :href="remoteInteractionLink" + > <FAIcon class="fa-scale-110 fa-old-padding" icon="retweet" :title="$t('tool_tip.repeat')" /> - </span> + </a> <span v-if="!mergedConfig.hidePostStats && status.repeat_num > 0" class="no-event" diff --git a/src/components/search/search.js b/src/components/search/search.js index 76ac30ef..877d6f30 100644 --- a/src/components/search/search.js +++ b/src/components/search/search.js @@ -8,6 +8,7 @@ import { faCircleNotch, faSearch } from '@fortawesome/free-solid-svg-icons' +import { uniqBy } from 'lodash' library.add( faCircleNotch, @@ -32,7 +33,11 @@ const Search = { userIds: [], statuses: [], hashtags: [], - currenResultTab: 'statuses' + currenResultTab: 'statuses', + + statusesOffset: 0, + lastStatusFetchCount: 0, + lastQuery: '' } }, computed: { @@ -61,26 +66,42 @@ const Search = { this.$router.push({ name: 'search', query: { query } }) this.$refs.searchInput.focus() }, - search (query) { + search (query, searchType = null) { if (!query) { this.loading = false return } this.loading = true - this.userIds = [] - this.statuses = [] - this.hashtags = [] this.$refs.searchInput.blur() + if (this.lastQuery !== query) { + this.userIds = [] + this.hashtags = [] + this.statuses = [] + + this.statusesOffset = 0 + this.lastStatusFetchCount = 0 + } - this.$store.dispatch('search', { q: query, resolve: true }) + this.$store.dispatch('search', { q: query, resolve: true, offset: this.statusesOffset, type: searchType }) .then(data => { this.loading = false - this.userIds = map(data.accounts, 'id') - this.statuses = data.statuses - this.hashtags = data.hashtags + + const oldLength = this.statuses.length + + // Always append to old results. If new results are empty, this doesn't change anything + this.userIds = this.userIds.concat(map(data.accounts, 'id')) + this.statuses = uniqBy(this.statuses.concat(data.statuses), 'id') + this.hashtags = this.hashtags.concat(data.hashtags) + this.currenResultTab = this.getActiveTab() this.loaded = true + + // Offset from whatever we already have + this.statusesOffset = this.statuses.length + // Because the amount of new statuses can actually be zero, compare to old lenght instead + this.lastStatusFetchCount = this.statuses.length - oldLength + this.lastQuery = query }) }, resultCount (tabName) { diff --git a/src/components/search/search.vue b/src/components/search/search.vue index b7bfc1f3..6fc6a0de 100644 --- a/src/components/search/search.vue +++ b/src/components/search/search.vue @@ -22,7 +22,7 @@ </button> </div> <div - v-if="loading" + v-if="loading && statusesOffset == 0" class="text-center loading-icon" > <FAIcon @@ -55,12 +55,6 @@ </div> <div class="panel-body"> <div v-if="currenResultTab === 'statuses'"> - <div - v-if="visibleStatuses.length === 0 && !loading && loaded" - class="search-result-heading" - > - <h4>{{ $t('search.no_results') }}</h4> - </div> <Status v-for="status in visibleStatuses" :key="status.id" @@ -71,6 +65,33 @@ :statusoid="status" :no-heading="false" /> + <button + v-if="!loading && loaded && lastStatusFetchCount > 0" + class="more-statuses-button button-unstyled -link -fullwidth" + @click.prevent="search(searchTerm, 'statuses')" + > + <div class="new-status-notification text-center"> + {{ $t('search.load_more') }} + </div> + </button> + <div + v-else-if="loading && statusesOffset > 0" + class="text-center loading-icon" + > + <FAIcon + icon="circle-notch" + spin + size="lg" + /> + </div> + <div + v-if="(visibleStatuses.length === 0 || lastStatusFetchCount === 0) && !loading && loaded" + class="search-result-heading" + > + <h4> + {{ visibleStatuses.length === 0 ? $t('search.no_results') : $t('search.no_more_results') }} + </h4> + </div> </div> <div v-else-if="currenResultTab === 'people'"> <div @@ -208,6 +229,11 @@ color: $fallback--text; color: var(--text, $fallback--text); } -} + } + + .more-statuses-button { + height: 3.5em; + line-height: 3.5em; + } </style> diff --git a/src/components/staff_panel/staff_panel.js b/src/components/staff_panel/staff_panel.js index a7fbc718..46a92ac7 100644 --- a/src/components/staff_panel/staff_panel.js +++ b/src/components/staff_panel/staff_panel.js @@ -13,7 +13,7 @@ const StaffPanel = { }, computed: { groupedStaffAccounts () { - const staffAccounts = map(this.staffAccounts, this.findUser).filter(_ => _) + const staffAccounts = map(this.staffAccounts, this.findUserByName).filter(_ => _) const groupedStaffAccounts = groupBy(staffAccounts, 'role') return [ @@ -22,7 +22,7 @@ const StaffPanel = { ].filter(group => group.users) }, ...mapGetters([ - 'findUser' + 'findUserByName' ]), ...mapState({ staffAccounts: state => state.instance.staffAccounts diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue index 877a0cc0..2279f21a 100644 --- a/src/components/timeline/timeline.vue +++ b/src/components/timeline/timeline.vue @@ -6,8 +6,8 @@ :timeline-name="timelineName" /> <div - class="rightside-button" v-if="showScrollTop && !embedded" + class="rightside-button" > <button class="button-unstyled scroll-to-top-button" @@ -26,8 +26,8 @@ </div> <template v-if="mobileLayout && !embedded"> <div - class="rightside-button" v-if="showLoadButton" + class="rightside-button" > <button class="button-unstyled loadmore-button" @@ -72,8 +72,14 @@ {{ $t('timeline.up_to_date') }} </div> </template> - <QuickFilterSettings v-if="!embedded" class="rightside-button"/> - <QuickViewSettings v-if="!embedded" class="rightside-button"/> + <QuickFilterSettings + v-if="!embedded" + class="rightside-button" + /> + <QuickViewSettings + v-if="!embedded" + class="rightside-button" + /> </div> <div :class="classes.body"> <div |
