import { library } from '@fortawesome/fontawesome-svg-core' import { faBasketballBall, faBoxOpen, faBus, faCode, faFlag, faIceCream, faLightbulb, faPaw, faSmile, faSmileBeam, faStickyNote, faUser, } from '@fortawesome/free-solid-svg-icons' import { chunk, debounce, trim } from 'lodash' import Popover from 'src/components/popover/popover.vue' import { defineAsyncComponent } from 'vue' import { ensureFinalFallback } from '../../i18n/languages.js' import Checkbox from '../checkbox/checkbox.vue' import StillImage from '../still-image/still-image.vue' library.add( faBoxOpen, faStickyNote, faSmileBeam, faSmile, faUser, faPaw, faIceCream, faBus, faBasketballBall, faLightbulb, faCode, faFlag, ) const UNICODE_EMOJI_GROUP_ICON = { 'smileys-and-emotion': 'smile', 'people-and-body': 'user', 'animals-and-nature': 'paw', 'food-and-drink': 'ice-cream', 'travel-and-places': 'bus', activities: 'basketball-ball', objects: 'lightbulb', symbols: 'code', flags: 'flag', } const maybeLocalizedKeywords = (emoji, languages, nameLocalizer) => { const res = [emoji.displayText, nameLocalizer(emoji)] if (emoji.annotations) { languages.forEach((lang) => { const keywords = emoji.annotations[lang]?.keywords || [] const name = emoji.annotations[lang]?.name res.push(...keywords.concat([name]).filter((k) => k)) }) } return res } const filterByKeyword = (list, keyword = '', languages, nameLocalizer) => { if (keyword === '') return list const keywordLowercase = keyword.toLowerCase() const orderedEmojiList = [] for (const emoji of list) { const indices = maybeLocalizedKeywords(emoji, languages, nameLocalizer) .map((k) => k.toLowerCase().indexOf(keywordLowercase)) .filter((k) => k > -1) const indexOfKeyword = indices.length ? Math.min(...indices) : -1 if (indexOfKeyword > -1) { if (!Array.isArray(orderedEmojiList[indexOfKeyword])) { orderedEmojiList[indexOfKeyword] = [] } orderedEmojiList[indexOfKeyword].push(emoji) } } return orderedEmojiList.flat() } const getOffset = (elem) => { const style = elem.style.transform const res = /translateY\((\d+)px\)/.exec(style) if (!res) { return 0 } return res[1] } const toHeaderId = (id) => { return id.replace(/^row-\d+-/, '') } const EmojiPicker = { props: { enableStickerPicker: { required: false, type: Boolean, default: true, }, hideCustomEmoji: { required: false, type: Boolean, default: false, }, }, inject: { popoversZLayer: { default: '', }, }, data() { return { keyword: '', activeGroup: 'custom', showingStickers: false, groupsScrolledClass: 'scrolled-top', keepOpen: false, customEmojiTimeout: null, hideCustomEmojiInPicker: false, // Lazy-load only after the first time `showing` becomes true. contentLoaded: false, groupRefs: {}, emojiRefs: {}, filteredEmojiGroups: [], emojiSize: 0, width: 0, } }, components: { StickerPicker: defineAsyncComponent( () => import('../sticker_picker/sticker_picker.vue'), ), Checkbox, StillImage, Popover, }, methods: { groupScroll(e) { e.currentTarget.scrollLeft += e.deltaY + e.deltaX }, updateEmojiSize() { const css = window.getComputedStyle(this.$refs.popover.$el) const fontSize = css.getPropertyValue('font-size') || '1rem' const emojiSize = css.getPropertyValue('--emojiSize') || '2.2rem' const fontSizeUnit = fontSize.replace(/[0-9,.]+/, '').trim() const fontSizeValue = Number(fontSize.replace(/[^0-9,.]+/, '')) const emojiSizeUnit = emojiSize.replace(/[0-9,.]+/, '').trim() const emojiSizeValue = Number(emojiSize.replace(/[^0-9,.]+/, '')) let fontSizeMultiplier if (fontSizeUnit.endsWith('em')) { fontSizeMultiplier = fontSizeValue } else { fontSizeMultiplier = fontSizeValue / 14 } let emojiSizeReal if (emojiSizeUnit.endsWith('em')) { emojiSizeReal = emojiSizeValue * fontSizeMultiplier * 14 } else { emojiSizeReal = emojiSizeValue } const fullEmojiSize = emojiSizeReal + 2 * 0.2 * fontSizeMultiplier * 14 this.emojiSize = fullEmojiSize }, showPicker() { this.$refs.popover.showPopover() this.$nextTick(() => { this.onShowing() }) }, hidePicker() { this.$refs.popover.hidePopover() }, setAnchorEl(el) { this.$refs.popover.setAnchorEl(el) }, setGroupRef(name) { return (el) => { this.groupRefs[name] = el } }, onPopoverShown() { this.$emit('show') }, onPopoverClosed() { this.$emit('close') }, onStickerUploaded(e) { this.$emit('sticker-uploaded', e) }, onStickerUploadFailed(e) { this.$emit('sticker-upload-failed', e) }, onEmoji(emoji) { const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement if (!this.keepOpen) { this.$refs.popover.hidePopover() } this.$emit('emoji', { insertion: value, insertionUrl: emoji.imageUrl, keepOpen: this.keepOpen, }) }, onScroll(startIndex, endIndex, visibleStartIndex, visibleEndIndex) { const target = this.$refs['emoji-groups'].$el this.scrolledGroup(target, visibleStartIndex, visibleEndIndex) }, scrolledGroup(target, start, end) { const top = target.scrollTop + 5 this.$nextTick(() => { this.emojiItems.slice(start, end + 1).forEach((group) => { const headerId = toHeaderId(group.id) const ref = this.groupRefs['group-' + group.id] if (!ref) { return } const elem = ref.$el.parentElement if (!elem) { return } if (elem && getOffset(elem) <= top) { this.activeGroup = headerId } }) this.scrollHeader() }) }, scrollHeader() { // Scroll the active tab's header into view const headerRef = this.groupRefs['group-header-' + this.activeGroup] const left = headerRef.offsetLeft const right = left + headerRef.offsetWidth const headerCont = this.$refs.header const currentScroll = headerCont.scrollLeft const currentScrollRight = currentScroll + headerCont.clientWidth const setScroll = (s) => { headerCont.scrollLeft = s } const margin = 7 // .emoji-tabs-item: padding if (left - margin < currentScroll) { setScroll(left - margin) } else if (right + margin > currentScrollRight) { setScroll(right + margin - headerCont.clientWidth) } }, highlight(groupId) { this.setShowStickers(false) const indexInList = this.emojiItems.findIndex((k) => k.id === groupId) this.$refs['emoji-groups'].scrollToItem(indexInList) }, updateScrolledClass(target) { if (target.scrollTop <= 5) { this.groupsScrolledClass = 'scrolled-top' } else if (target.scrollTop >= target.scrollTopMax - 5) { this.groupsScrolledClass = 'scrolled-bottom' } else { this.groupsScrolledClass = 'scrolled-middle' } }, toggleStickers() { this.showingStickers = !this.showingStickers }, setShowStickers(value) { this.showingStickers = value }, filterByKeyword(list, keyword) { return filterByKeyword( list, keyword, this.languages, this.maybeLocalizedEmojiName, ) }, onShowing() { const oldContentLoaded = this.contentLoaded this.updateEmojiSize() this.recalculateItemPerRow() this.$nextTick(() => { this.$refs.search.focus() }) this.contentLoaded = true this.filteredEmojiGroups = this.getFilteredEmojiGroups() if (!oldContentLoaded) { this.$nextTick(() => { if (this.defaultGroup) { this.highlight(this.defaultGroup) } }) } }, getFilteredEmojiGroups() { return this.allEmojiGroups .map((group) => ({ ...group, emojis: this.filterByKeyword(group.emojis, trim(this.keyword)), })) .filter((group) => group.emojis.length > 0) }, recalculateItemPerRow() { this.$nextTick(() => { if (!this.$refs['emoji-groups']) { return } this.width = this.$refs['emoji-groups'].$el.clientWidth }) }, }, watch: { keyword() { this.onScroll() this.debouncedHandleKeywordChange() }, allCustomGroups() { this.filteredEmojiGroups = this.getFilteredEmojiGroups() }, }, computed: { minItemSize() { return this.emojiSize }, // used to watch it fontSize() { this.$nextTick(() => { this.updateEmojiSize() }) return this.$store.getters.mergedConfig.fontSize }, emojiHeight() { return this.emojiSize }, itemPerRow() { return this.width ? Math.floor(this.width / this.emojiSize) : 6 }, activeGroupView() { return this.showingStickers ? '' : this.activeGroup }, stickersAvailable() { if (this.$store.state.instance.stickers) { return this.$store.state.instance.stickers.length > 0 } return 0 }, allCustomGroups() { if (this.hideCustomEmoji || this.hideCustomEmojiInPicker) { return {} } const emojis = this.$store.getters.groupedCustomEmojis if (emojis.unpacked) { emojis.unpacked.text = this.$t('emoji.unpacked') } return emojis }, defaultGroup() { return Object.keys(this.allCustomGroups)[0] }, unicodeEmojiGroups() { return this.$store.getters.standardEmojiGroupList.map((group) => ({ id: `standard-${group.id}`, text: this.$t(`emoji.unicode_groups.${group.id}`), icon: UNICODE_EMOJI_GROUP_ICON[group.id], emojis: group.emojis, })) }, allEmojiGroups() { return Object.entries(this.allCustomGroups) .map(([, v]) => v) .concat(this.unicodeEmojiGroups) }, stickerPickerEnabled() { return (this.$store.state.instance.stickers || []).length !== 0 }, debouncedHandleKeywordChange() { return debounce(() => { this.filteredEmojiGroups = this.getFilteredEmojiGroups() }, 500) }, emojiItems() { return this.filteredEmojiGroups .map((group) => chunk(group.emojis, this.itemPerRow).map((items, index) => ({ ...group, id: index === 0 ? group.id : `row-${index}-${group.id}`, emojis: items, isFirstRow: index === 0, })), ) .reduce((a, c) => a.concat(c), []) }, languages() { return ensureFinalFallback( this.$store.getters.mergedConfig.interfaceLanguage, ) }, maybeLocalizedEmojiName() { return (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 } }, isInModal() { return this.popoversZLayer === 'modals' }, }, } export default EmojiPicker