Merge branch 'emoji-popovers' into shigusegubu-vue3

* emoji-popovers:
  use anchor for picker
  move keepOpen to picker
  some shitty initial implementation of emoji picker with popover
  fix blinking popup
  fix css
  cleanup
  moved popovers space outside app because otherwise it causes weird issues A LOT
  suggestor popover
This commit is contained in:
Henry Jameson 2022-10-10 00:38:16 +03:00
commit 3d7f5f7be8
10 changed files with 342 additions and 323 deletions

View file

@ -15,5 +15,6 @@
<noscript>To use Pleroma, please enable JavaScript.</noscript> <noscript>To use Pleroma, please enable JavaScript.</noscript>
<div id="app"></div> <div id="app"></div>
<!-- built files will be auto injected --> <!-- built files will be auto injected -->
<div id="popovers" />
</body> </body>
</html> </html>

View file

@ -73,7 +73,6 @@
<UpdateNotification /> <UpdateNotification />
<div id="modal" /> <div id="modal" />
<GlobalNoticeList /> <GlobalNoticeList />
<div id="popovers" />
</div> </div>
</template> </template>

View file

@ -1,5 +1,6 @@
import Completion from '../../services/completion/completion.js' import Completion from '../../services/completion/completion.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue' 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 UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { take } from 'lodash' import { take } from 'lodash'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js' import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
@ -109,18 +110,20 @@ const EmojiInput = {
data () { data () {
return { return {
input: undefined, input: undefined,
caretEl: undefined,
highlighted: 0, highlighted: 0,
caret: 0, caret: 0,
focused: false, focused: false,
blurTimeout: null, blurTimeout: null,
showPicker: false,
temporarilyHideSuggestions: false, temporarilyHideSuggestions: false,
keepOpen: false,
disableClickOutside: false, disableClickOutside: false,
suggestions: [] suggestions: [],
overlayStyle: {},
pickerShown: false
} }
}, },
components: { components: {
Popover,
EmojiPicker, EmojiPicker,
UnicodeDomainIndicator UnicodeDomainIndicator
}, },
@ -128,11 +131,17 @@ const EmojiInput = {
padEmoji () { padEmoji () {
return this.$store.getters.mergedConfig.padEmoji return this.$store.getters.mergedConfig.padEmoji
}, },
preText () {
return this.modelValue.slice(0, this.caret)
},
postText () {
return this.modelValue.slice(this.caret)
},
showSuggestions () { showSuggestions () {
return this.focused && return this.focused &&
this.suggestions && this.suggestions &&
this.suggestions.length > 0 && this.suggestions.length > 0 &&
!this.showPicker && !this.pickerShown &&
!this.temporarilyHideSuggestions !this.temporarilyHideSuggestions
}, },
textAtCaret () { textAtCaret () {
@ -191,10 +200,22 @@ const EmojiInput = {
} }
}, },
mounted () { mounted () {
const { root } = this.$refs const { root, hiddenOverlayCaret, suggestorPopover } = this.$refs
const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea') const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea')
if (!input) return if (!input) return
this.input = input this.input = input
this.caretEl = hiddenOverlayCaret
suggestorPopover.setAnchorEl(this.caretEl)
this.$refs.picker.setAnchorEl(this.caretEl)
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() this.resize()
input.addEventListener('blur', this.onBlur) input.addEventListener('blur', this.onBlur)
input.addEventListener('focus', this.onFocus) input.addEventListener('focus', this.onFocus)
@ -204,6 +225,13 @@ const EmojiInput = {
input.addEventListener('click', this.onClickInput) input.addEventListener('click', this.onClickInput)
input.addEventListener('transitionend', this.onTransition) input.addEventListener('transitionend', this.onTransition)
input.addEventListener('input', this.onInput) input.addEventListener('input', this.onInput)
// FIXME LEAK
input.addEventListener('scroll', (e) => {
this.$refs.hiddenOverlay.scrollTo({
top: this.input.scrollTop,
left: this.input.scrollLeft
})
})
}, },
unmounted () { unmounted () {
const { input } = this const { input } = this
@ -219,28 +247,34 @@ const EmojiInput = {
} }
}, },
watch: { watch: {
showSuggestions: function (newValue) { showSuggestions: function (newValue, oldValue) {
this.$emit('shown', newValue) this.$emit('shown', newValue)
if (newValue) {
this.$refs.suggestorPopover.showPopover()
} else {
this.$refs.suggestorPopover.hidePopover()
}
}, },
textAtCaret: async function (newWord) { textAtCaret: async function (newWord) {
const firstchar = newWord.charAt(0) const firstchar = newWord.charAt(0)
if (newWord === firstchar) {
if (firstchar === ' ') {
this.suggestions = [] this.suggestions = []
if (newWord === firstchar) return }
return
}
const matchedSuggestions = await this.suggest(newWord, this.maybeLocalizedEmojiNamesAndKeywords) const matchedSuggestions = await this.suggest(newWord, this.maybeLocalizedEmojiNamesAndKeywords)
// Async: cancel if textAtCaret has changed during wait // Async: cancel if textAtCaret has changed during wait
if (this.textAtCaret !== newWord) return if (this.textAtCaret !== newWord || matchedSuggestions.length <= 0) {
if (matchedSuggestions.length <= 0) return this.suggestions = []
return
}
this.suggestions = take(matchedSuggestions, 5) this.suggestions = take(matchedSuggestions, 5)
.map(({ imageUrl, ...rest }) => ({ .map(({ imageUrl, ...rest }) => ({
...rest, ...rest,
img: imageUrl || '' img: imageUrl || ''
})) }))
}, this.$refs.suggestorPopover.updateStyles()
suggestions: {
handler (newValue) {
this.$nextTick(this.resize)
},
deep: true
} }
}, },
methods: { methods: {
@ -251,8 +285,8 @@ const EmojiInput = {
if (pickerInput) pickerInput.focus() if (pickerInput) pickerInput.focus()
}, },
triggerShowPicker () { triggerShowPicker () {
this.showPicker = true
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.picker.showPicker()
this.scrollIntoView() this.scrollIntoView()
this.focusPickerInput() this.focusPickerInput()
}) })
@ -265,12 +299,16 @@ const EmojiInput = {
}, 0) }, 0)
}, },
togglePicker () { togglePicker () {
console.log('piick')
this.input.focus() this.input.focus()
this.showPicker = !this.showPicker if (!this.pickerShown) {
if (this.showPicker) { console.log('pick')
this.scrollIntoView() this.scrollIntoView()
this.$refs.picker.showPicker()
this.$refs.picker.startEmojiLoad() this.$refs.picker.startEmojiLoad()
this.$nextTick(this.focusPickerInput) this.$nextTick(this.focusPickerInput)
} else {
this.$refs.picker.hidePicker()
} }
}, },
replace (replacement) { replace (replacement) {
@ -307,7 +345,6 @@ const EmojiInput = {
spaceAfter, spaceAfter,
after after
].join('') ].join('')
this.keepOpen = keepOpen
this.$emit('update:modelValue', newValue) this.$emit('update:modelValue', newValue)
const position = this.caret + (insertion + spaceAfter + spaceBefore).length const position = this.caret + (insertion + spaceAfter + spaceBefore).length
if (!keepOpen) { if (!keepOpen) {
@ -407,8 +444,11 @@ const EmojiInput = {
} }
}) })
}, },
onTransition (e) { onPickerShown () {
this.resize() this.pickerShown = true
},
onPickerClosed () {
this.pickerShown = false
}, },
onBlur (e) { onBlur (e) {
// Clicking on any suggestion removes focus from autocomplete, // Clicking on any suggestion removes focus from autocomplete,
@ -416,7 +456,6 @@ const EmojiInput = {
this.blurTimeout = setTimeout(() => { this.blurTimeout = setTimeout(() => {
this.focused = false this.focused = false
this.setCaret(e) this.setCaret(e)
this.resize()
}, 200) }, 200)
}, },
onClick (e, suggestion) { onClick (e, suggestion) {
@ -428,18 +467,13 @@ const EmojiInput = {
this.blurTimeout = null this.blurTimeout = null
} }
if (!this.keepOpen) {
this.showPicker = false
}
this.focused = true this.focused = true
this.setCaret(e) this.setCaret(e)
this.resize()
this.temporarilyHideSuggestions = false this.temporarilyHideSuggestions = false
}, },
onKeyUp (e) { onKeyUp (e) {
const { key } = e const { key } = e
this.setCaret(e) this.setCaret(e)
this.resize()
// Setting hider in keyUp to prevent suggestions from blinking // Setting hider in keyUp to prevent suggestions from blinking
// when moving away from suggested spot // when moving away from suggested spot
@ -451,7 +485,6 @@ const EmojiInput = {
}, },
onPaste (e) { onPaste (e) {
this.setCaret(e) this.setCaret(e)
this.resize()
}, },
onKeyDown (e) { onKeyDown (e) {
const { ctrlKey, shiftKey, key } = e const { ctrlKey, shiftKey, key } = e
@ -496,58 +529,21 @@ const EmojiInput = {
this.input.focus() this.input.focus()
} }
} }
this.showPicker = false
this.resize()
}, },
onInput (e) { onInput (e) {
this.showPicker = false
this.setCaret(e) this.setCaret(e)
this.resize()
this.$emit('update:modelValue', e.target.value) this.$emit('update:modelValue', e.target.value)
}, },
onClickInput (e) {
this.showPicker = false
},
onClickOutside (e) {
if (this.disableClickOutside) return
this.showPicker = false
},
onStickerUploaded (e) { onStickerUploaded (e) {
this.showPicker = false
this.$emit('sticker-uploaded', e) this.$emit('sticker-uploaded', e)
}, },
onStickerUploadFailed (e) { onStickerUploadFailed (e) {
this.showPicker = false
this.$emit('sticker-upload-Failed', e) this.$emit('sticker-upload-Failed', e)
}, },
setCaret ({ target: { selectionStart } }) { setCaret ({ target: { selectionStart } }) {
this.caret = selectionStart this.caret = selectionStart
}, },
resize () { 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
} }
} }
} }

View file

@ -1,11 +1,16 @@
<template> <template>
<div <div
ref="root" ref="root"
v-click-outside="onClickOutside"
class="emoji-input" class="emoji-input"
:class="{ 'with-picker': !hideEmojiButton }" :class="{ 'with-picker': !hideEmojiButton }"
> >
<slot /> <slot />
<!-- TODO: make the 'x' disappear if at the end maybe? -->
<div class="hidden-overlay" :style="overlayStyle" ref="hiddenOverlay">
<span>{{ preText }}</span>
<span class="caret" ref="hiddenOverlayCaret">x</span>
<span>{{ postText }}</span>
</div>
<template v-if="enableEmojiPicker"> <template v-if="enableEmojiPicker">
<button <button
v-if="!hideEmojiButton" v-if="!hideEmojiButton"
@ -18,20 +23,21 @@
<EmojiPicker <EmojiPicker
v-if="enableEmojiPicker" v-if="enableEmojiPicker"
ref="picker" ref="picker"
:class="{ hide: !showPicker }"
:showing="showPicker"
:enable-sticker-picker="enableStickerPicker" :enable-sticker-picker="enableStickerPicker"
class="emoji-picker-panel" class="emoji-picker-panel"
@emoji="insert" @emoji="insert"
@sticker-uploaded="onStickerUploaded" @sticker-uploaded="onStickerUploaded"
@sticker-upload-failed="onStickerUploadFailed" @sticker-upload-failed="onStickerUploadFailed"
@show="onPickerShown"
@close="onPickerClosed"
/> />
</template> </template>
<div <Popover
ref="panel"
class="autocomplete-panel" class="autocomplete-panel"
:class="{ hide: !showSuggestions }" placement="bottom"
ref="suggestorPopover"
> >
<template #content>
<div <div
ref="panel-body" ref="panel-body"
class="autocomplete-panel-body" class="autocomplete-panel-body"
@ -70,7 +76,8 @@
</div> </div>
</div> </div>
</div> </div>
</div> </template>
</Popover>
</div> </div>
</template> </template>
@ -102,6 +109,7 @@
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
} }
} }
.emoji-picker-panel { .emoji-picker-panel {
position: absolute; position: absolute;
z-index: 20; z-index: 20;
@ -112,34 +120,33 @@
} }
} }
.autocomplete { input, textarea {
flex: 1 0 auto;
}
.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 { &-panel {
position: absolute; position: absolute;
z-index: 20;
margin-top: 2px;
&.hide {
display: none
}
&-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);
}
} }
&-item { &-item {
@ -191,10 +198,5 @@
--icon: var(--selectedMenuPopoverIcon, $fallback--icon); --icon: var(--selectedMenuPopoverIcon, $fallback--icon);
} }
} }
}
input, textarea {
flex: 1 0 auto;
}
} }
</style> </style>

View file

@ -1,5 +1,6 @@
import { defineAsyncComponent } from 'vue' import { defineAsyncComponent } from 'vue'
import Checkbox from '../checkbox/checkbox.vue' import Checkbox from '../checkbox/checkbox.vue'
import Popover from 'src/components/popover/popover.vue'
import StillImage from '../still-image/still-image.vue' import StillImage from '../still-image/still-image.vue'
import { ensureFinalFallback } from '../../i18n/languages.js' import { ensureFinalFallback } from '../../i18n/languages.js'
import lozad from 'lozad' import lozad from 'lozad'
@ -87,10 +88,6 @@ const EmojiPicker = {
required: false, required: false,
type: Boolean, type: Boolean,
default: false default: false
},
showing: {
required: true,
type: Boolean
} }
}, },
data () { data () {
@ -111,15 +108,32 @@ const EmojiPicker = {
components: { components: {
StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')), StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
Checkbox, Checkbox,
StillImage StillImage,
Popover
}, },
methods: { methods: {
showPicker () {
this.$refs.popover.showPopover()
this.onShowing()
},
hidePicker () {
this.$refs.popover.hidePopover()
},
setAnchorEl (el) {
this.$refs.popover.setAnchorEl(el)
},
setGroupRef (name) { setGroupRef (name) {
return el => { this.groupRefs[name] = el } return el => { this.groupRefs[name] = el }
}, },
setEmojiRef (name) { setEmojiRef (name) {
return el => { this.emojiRefs[name] = el } return el => { this.emojiRefs[name] = el }
}, },
onPopoverShown () {
this.$emit('show')
},
onPopoverClosed () {
this.$emit('close')
},
onStickerUploaded (e) { onStickerUploaded (e) {
this.$emit('sticker-uploaded', e) this.$emit('sticker-uploaded', e)
}, },
@ -128,6 +142,9 @@ const EmojiPicker = {
}, },
onEmoji (emoji) { onEmoji (emoji) {
const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement
if (!this.keepOpen) {
this.$refs.popover.hidePopover()
}
this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen }) this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen })
}, },
onScroll (e) { onScroll (e) {
@ -251,16 +268,6 @@ const EmojiPicker = {
allCustomGroups () { allCustomGroups () {
this.waitForDomAndInitializeLazyLoad() this.waitForDomAndInitializeLazyLoad()
this.filteredEmojiGroups = this.getFilteredEmojiGroups() this.filteredEmojiGroups = this.getFilteredEmojiGroups()
},
showing (val) {
if (val) {
this.onShowing()
}
}
},
mounted () {
if (this.showing) {
this.onShowing()
} }
}, },
destroyed () { destroyed () {

View file

@ -6,14 +6,10 @@ $emoji-picker-header-picture-height: 32px;
$emoji-picker-emoji-size: 32px; $emoji-picker-emoji-size: 32px;
.emoji-picker { .emoji-picker {
width: 25em;
max-width: 100vw;
display: flex; display: flex;
flex-direction: column; 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: $fallback--bg;
background-color: var(--popover, $fallback--bg); background-color: var(--popover, $fallback--bg);
color: $fallback--link; color: $fallback--link;

View file

@ -1,7 +1,12 @@
<template> <template>
<div <Popover
class="emoji-picker panel panel-default panel-body" trigger="click"
popover-class="emoji-picker popover-default"
ref="popover"
@show="onPopoverShown"
@close="onPopoverClosed"
> >
<template #content>
<div class="heading"> <div class="heading">
<span <span
ref="header" ref="header"
@ -123,7 +128,8 @@
/> />
</div> </div>
</div> </div>
</div> </template>
</Popover>
</template> </template>
<script src="./emoji_picker.js"></script> <script src="./emoji_picker.js"></script>

View file

@ -51,6 +51,10 @@ const Popover = {
// lockReEntry is a flag that is set when mouse cursor is leaving the popover's content // 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 // 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 // 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, lockReEntry: false,
hidden: true, hidden: true,
styles: {}, styles: {},
@ -59,10 +63,15 @@ const Popover = {
// used to avoid blinking if hovered onto popover // used to avoid blinking if hovered onto popover
graceTimeout: null, graceTimeout: null,
parentPopover: null, parentPopover: null,
disableClickOutside: false,
childrenShown: new Set() childrenShown: new Set()
} }
}, },
methods: { methods: {
setAnchorEl (el) {
this.anchorEl = el
this.updateStyles()
},
containerBoundingClientRect () { containerBoundingClientRect () {
const container = this.boundToSelector ? this.$el.closest(this.boundToSelector) : this.$el.offsetParent const container = this.boundToSelector ? this.$el.closest(this.boundToSelector) : this.$el.offsetParent
return container.getBoundingClientRect() return container.getBoundingClientRect()
@ -75,7 +84,7 @@ const Popover = {
// Popover will be anchored around this element, trigger ref is the container, so // 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. // 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 // SVGs don't have offsetWidth/Height, use fallback
const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight
const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
@ -226,6 +235,10 @@ const Popover = {
}, },
showPopover () { showPopover () {
if (this.disabled) return if (this.disabled) return
this.disableClickOutside = true
setTimeout(() => {
this.disableClickOutside = false
}, 0)
const wasHidden = this.hidden const wasHidden = this.hidden
this.hidden = false this.hidden = false
this.parentPopover && this.parentPopover.onChildPopoverState(this, true) this.parentPopover && this.parentPopover.onChildPopoverState(this, true)
@ -286,6 +299,7 @@ const Popover = {
} }
}, },
onClickOutside (e) { onClickOutside (e) {
if (this.disableClickOutside) return
if (this.hidden) return if (this.hidden) return
if (this.$refs.content && this.$refs.content.contains(e.target)) return if (this.$refs.content && this.$refs.content.contains(e.target)) return
if (this.$el.contains(e.target)) return if (this.$el.contains(e.target)) return
@ -319,6 +333,7 @@ const Popover = {
} }
}, },
mounted () { mounted () {
this.teleport = true
let scrollable = this.$refs.trigger.closest('.column.-scrollable') || let scrollable = this.$refs.trigger.closest('.column.-scrollable') ||
this.$refs.trigger.closest('.mobile-notifications') this.$refs.trigger.closest('.mobile-notifications')
if (!scrollable) scrollable = window if (!scrollable) scrollable = window

View file

@ -11,7 +11,7 @@
> >
<slot name="trigger" /> <slot name="trigger" />
</button> </button>
<teleport to="#popovers"> <teleport :disabled="!teleport" to="#popovers">
<transition name="fade"> <transition name="fade">
<div <div
v-if="!hidden" v-if="!hidden"

View file

@ -501,7 +501,6 @@ const PostStatusForm = {
if (target.value === '') { if (target.value === '') {
target.style.height = null target.style.height = null
this.$emit('resize') this.$emit('resize')
this.$refs['emoji-input'].resize()
return return
} }
@ -588,8 +587,6 @@ const PostStatusForm = {
} else { } else {
scrollerRef.scrollTop = targetScroll scrollerRef.scrollTop = targetScroll
} }
this.$refs['emoji-input'].resize()
}, },
showEmojiPicker () { showEmojiPicker () {
this.$refs.textarea.focus() this.$refs.textarea.focus()