diff --git a/src/components/emoji_grid/emoji_grid.js b/src/components/emoji_grid/emoji_grid.js new file mode 100644 index 00000000..236051ea --- /dev/null +++ b/src/components/emoji_grid/emoji_grid.js @@ -0,0 +1,125 @@ +const EMOJI_SIZE = 32 + 8; +const GROUP_TITLE_HEIGHT = 24; +const BUFFER_SIZE = 3*EMOJI_SIZE; + +const EmojiGrid = { + props: { + groups: { + required: true, + type: Array + } + }, + data() { + return { + containerWidth: 0, + containerHeight: 0, + scrollPos: 0, + resizeObserver: null, + } + }, + mounted() { + const rect = this.$refs.container.getBoundingClientRect(); + this.containerWidth = rect.width; + this.containerHeight = rect.height; + this.resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + this.containerWidth = entry.contentRect.width; + this.containerHeight = entry.contentRect.height; + } + }) + this.resizeObserver.observe(this.$refs.container); + }, + beforeUnmount() { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + }, + watch: { + groups() { + // Scroll to top when grid content changes + if (this.$refs.container) { + this.$refs.container.scrollTo(0, 0); + } + }, + activeGroup(group) { + this.$emit('activeGroup', group); + } + }, + methods: { + onScroll() { + this.scrollPos = this.$refs.container.scrollTop + }, + onEmoji(emoji) { + this.$emit('emoji', emoji) + }, + scrollToItem(itemId) { + const container = this.$refs.container; + if (!container) return; + + for (const item of this.itemList) { + if (item.id === itemId) { + container.scrollTo(0, item.position.y); + return; + } + } + } + }, + computed: { + // Total height of scroller content + gridHeight() { + if (this.itemList.length == 0) return 0; + const lastItem = this.itemList[this.itemList.length - 1]; + return lastItem.position.y + ('title' in lastItem ? GROUP_TITLE_HEIGHT : EMOJI_SIZE) + }, + activeGroup() { + const items = this.itemList; + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if ('title' in item && item.position.y <= this.scrollPos) { + return item.id; + } + } + return null; + }, + itemList() { + const items = []; + let x = 0, y = 0; + for (const group of this.groups) { + items.push({ position: {x, y}, id: group.id, title: group.text }); + if (group.text.length) { + y += GROUP_TITLE_HEIGHT; + } + for (const emoji of group.emojis) { + items.push({ position: {x, y}, id: `${group.id}-${emoji.displayText}`, emoji }) + x += EMOJI_SIZE; + if (x + EMOJI_SIZE > this.containerWidth) { + y += EMOJI_SIZE; + x = 0; + } + } + if (x > 0) { + y += EMOJI_SIZE; + x = 0; + } + } + return items; + }, + visibleItems() { + const startPos = this.scrollPos - BUFFER_SIZE; + const endPos = this.scrollPos + this.containerHeight + BUFFER_SIZE; + return this.itemList.filter(i => { + return i.position.y >= startPos && i.position.y < endPos; + }); + }, + scrolledClass() { + if (this.scrollPos <= 5) { + return 'scrolled-top' + } else if (this.scrollPos >= this.gridHeight - this.containerHeight - 5) { + return 'scrolled-bottom' + } else { + return 'scrolled-middle' + } + } + } +} + +export default EmojiGrid diff --git a/src/components/emoji_grid/emoji_grid.scss b/src/components/emoji_grid/emoji_grid.scss new file mode 100644 index 00000000..5d5b153f --- /dev/null +++ b/src/components/emoji_grid/emoji_grid.scss @@ -0,0 +1,60 @@ +.emoji { + &-grid { + flex: 1 1 1px; + position: relative; + overflow: auto; + user-select: none; + mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat, + linear-gradient(to bottom, white 0, transparent 100%) top no-repeat, + linear-gradient(to top, white, white); + transition: mask-size 150ms; + mask-size: 100% 20px, 100% 20px, auto; + // Autoprefixed seem to ignore this one, and also syntax is different + -webkit-mask-composite: xor; + mask-composite: exclude; + &.scrolled { + &-top { + mask-size: 100% 20px, 100% 0, auto; + } + &-bottom { + mask-size: 100% 0, 100% 20px, auto; + } + } + margin-left: 5px; + min-height: 200px; + } + + &-group-title { + position: absolute; + font-size: 0.85em; + width: 100%; + margin: 0; + height: 24px; + display: flex; + align-items: end; + + &.disabled { + display: none; + } + } + + &-item { + position: absolute; + width: 32px; + height: 32px; + box-sizing: border-box; + display: flex; + font-size: 32px; + align-items: center; + justify-content: center; + margin: 4px; + + cursor: pointer; + + img { + object-fit: contain; + max-width: 100%; + max-height: 100%; + } + } +} \ No newline at end of file diff --git a/src/components/emoji_grid/emoji_grid.vue b/src/components/emoji_grid/emoji_grid.vue new file mode 100644 index 00000000..94732319 --- /dev/null +++ b/src/components/emoji_grid/emoji_grid.vue @@ -0,0 +1,48 @@ + + + + diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js index 902ec384..eb70244e 100644 --- a/src/components/emoji_input/emoji_input.js +++ b/src/components/emoji_input/emoji_input.js @@ -202,7 +202,6 @@ const EmojiInput = { }, triggerShowPicker () { this.showPicker = true - this.$refs.picker.startEmojiLoad() this.$nextTick(() => { this.scrollIntoView() this.focusPickerInput() @@ -220,7 +219,6 @@ const EmojiInput = { this.showPicker = !this.showPicker if (this.showPicker) { this.scrollIntoView() - this.$refs.picker.startEmojiLoad() this.$nextTick(this.focusPickerInput) } }, diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js index 6617a937..a102dbf4 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 EmojiGrid from '../emoji_grid/emoji_grid.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { faBoxOpen, @@ -14,13 +15,6 @@ library.add( faSmileBeam ) -// At widest, approximately 20 emoji are visible in a row, -// loading 3 rows, could be overkill for narrow picker -const LOAD_EMOJI_BY = 60 - -// When to start loading new batch emoji, in pixels -const LOAD_EMOJI_MARGIN = 64 - const EmojiPicker = { props: { enableStickerPicker: { @@ -34,16 +28,13 @@ const EmojiPicker = { keyword: '', activeGroup: 'standard', showingStickers: false, - groupsScrolledClass: 'scrolled-top', - keepOpen: false, - customEmojiBufferSlice: LOAD_EMOJI_BY, - customEmojiTimeout: null, - customEmojiLoadAllConfirmed: false + keepOpen: false } }, components: { StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')), - Checkbox + Checkbox, + EmojiGrid }, methods: { onStickerUploaded (e) { @@ -56,12 +47,6 @@ const EmojiPicker = { const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen }) }, - onScroll (e) { - const target = (e && e.target) || this.$refs['emoji-groups'] - this.updateScrolledClass(target) - this.scrolledGroup(target) - this.triggerLoadMore(target) - }, onWheel (e) { e.preventDefault() this.$refs['emoji-tabs'].scrollBy(e.deltaY, 0) @@ -69,68 +54,12 @@ const EmojiPicker = { highlight (key) { this.setShowStickers(false) this.activeGroup = key - }, - 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' + if (this.keyword.length) { + this.$refs.emojiGrid.scrollToItem(key) } }, - triggerLoadMore (target) { - const ref = this.$refs['group-end-custom'] - if (!ref) return - const bottom = ref.offsetTop + ref.offsetHeight - - const scrollerBottom = target.scrollTop + target.clientHeight - const scrollerTop = target.scrollTop - const scrollerMax = target.scrollHeight - - // Loads more emoji when they come into view - const approachingBottom = bottom - scrollerBottom < LOAD_EMOJI_MARGIN - // Always load when at the very top in case there's no scroll space yet - const atTop = scrollerTop < 5 - // Don't load when looking at unicode category or at the very bottom - const bottomAboveViewport = bottom < scrollerTop || scrollerBottom === scrollerMax - if (!bottomAboveViewport && (approachingBottom || atTop)) { - this.loadEmoji() - } - }, - scrolledGroup (target) { - const top = target.scrollTop + 5 - this.$nextTick(() => { - this.emojisView.forEach(group => { - const ref = this.$refs['group-' + group.id] - if (ref.offsetTop <= top) { - this.activeGroup = group.id - } - }) - }) - }, - loadEmoji () { - const allLoaded = this.customEmojiBuffer.length === this.filteredEmoji.length - - if (allLoaded) { - return - } - - this.customEmojiBufferSlice += LOAD_EMOJI_BY - }, - startEmojiLoad (forceUpdate = false) { - if (!forceUpdate) { - this.keyword = '' - } - this.$nextTick(() => { - this.$refs['emoji-groups'].scrollTop = 0 - }) - const bufferSize = this.customEmojiBuffer.length - const bufferPrefilledAll = bufferSize === this.filteredEmoji.length - if (bufferPrefilledAll && !forceUpdate) { - return - } - this.customEmojiBufferSlice = LOAD_EMOJI_BY + onActiveGroup (group) { + this.activeGroup = group }, toggleStickers () { this.showingStickers = !this.showingStickers @@ -146,13 +75,6 @@ const EmojiPicker = { }) } }, - watch: { - keyword () { - this.customEmojiLoadAllConfirmed = false - this.onScroll() - this.startEmojiLoad(true) - } - }, computed: { activeGroupView () { return this.showingStickers ? '' : this.activeGroup @@ -168,9 +90,6 @@ const EmojiPicker = { this.$store.state.instance.customEmoji || [] ) }, - customEmojiBuffer () { - return this.filteredEmoji.slice(0, this.customEmojiBufferSlice) - }, emojis () { const standardEmojis = this.$store.state.instance.emoji || [] const customEmojis = this.sortedEmoji diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss index a37b2a40..6f2c1c22 100644 --- a/src/components/emoji_picker/emoji_picker.scss +++ b/src/components/emoji_picker/emoji_picker.scss @@ -69,10 +69,6 @@ flex-grow: 1; } - .emoji-groups { - min-height: 200px; - } - .additional-tabs { border-left: 1px solid; border-left-color: $fallback--icon; @@ -151,76 +147,12 @@ } } - .emoji { - &-search { - padding: 5px; - flex: 0 0 auto; + .emoji-search { + padding: 5px; + flex: 0 0 auto; - input { - width: 100%; - } + input { + width: 100%; } - - &-groups { - flex: 1 1 1px; - position: relative; - overflow: auto; - user-select: none; - mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat, - linear-gradient(to bottom, white 0, transparent 100%) top no-repeat, - linear-gradient(to top, white, white); - transition: mask-size 150ms; - mask-size: 100% 20px, 100% 20px, auto; - // Autoprefixed seem to ignore this one, and also syntax is different - -webkit-mask-composite: xor; - mask-composite: exclude; - &.scrolled { - &-top { - mask-size: 100% 20px, 100% 0, auto; - } - &-bottom { - mask-size: 100% 0, 100% 20px, auto; - } - } - } - - &-group { - display: flex; - align-items: center; - flex-wrap: wrap; - padding-left: 5px; - justify-content: left; - - &-title { - font-size: 0.85em; - width: 100%; - margin: 0; - - &.disabled { - display: none; - } - } - } - - &-item { - width: 32px; - height: 32px; - box-sizing: border-box; - display: flex; - font-size: 32px; - align-items: center; - justify-content: center; - margin: 4px; - - cursor: pointer; - - img { - object-fit: contain; - max-width: 100%; - max-height: 100%; - } - } - } - } diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue index 00ffb9d2..fe2e39b2 100644 --- a/src/components/emoji_picker/emoji_picker.vue +++ b/src/components/emoji_picker/emoji_picker.vue @@ -2,9 +2,9 @@
+
-
-
- {{ group.text }} -
- - {{ emoji.replacement }} - - - -
-
-
{{ $t('emoji.keep_open') }}