Improve emoji picker performance (#275)
ci/woodpecker/push/woodpecker Pipeline was successful
Details
ci/woodpecker/push/woodpecker Pipeline was successful
Details
A simple virtual scroller is now used for the emoji grid. This avoids loading all emoji images at once, saving network bandwidth and reducing load on the server, while also putting less work on the browser's DOM and layout engine. Co-authored-by: yan <yan@omg.lol> Reviewed-on: #275 Co-authored-by: yanchan09 <yan@omg.lol> Co-committed-by: yanchan09 <yan@omg.lol>pull/279/head
parent
88d5149db5
commit
9e04e4fd80
@ -0,0 +1,133 @@
|
||||
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
|
||||
let 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
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="emoji-grid"
|
||||
:class="scrolledClass"
|
||||
@scroll.passive="onScroll"
|
||||
>
|
||||
<div
|
||||
:style="{
|
||||
height: `${gridHeight}px`,
|
||||
}"
|
||||
>
|
||||
<template v-for="item in visibleItems">
|
||||
<h6
|
||||
v-if="'title' in item && item.title.length"
|
||||
:key="'title-' + item.id"
|
||||
class="emoji-group-title"
|
||||
:style="{
|
||||
top: item.position.y + 'px',
|
||||
left: item.position.x + 'px'
|
||||
}"
|
||||
>
|
||||
{{ item.title }}
|
||||
</h6>
|
||||
<span
|
||||
v-else-if="'emoji' in item"
|
||||
:key="'emoji-' + item.id"
|
||||
class="emoji-item"
|
||||
:title="item.emoji.displayText"
|
||||
:style="{
|
||||
top: item.position.y + 'px',
|
||||
left: item.position.x + 'px'
|
||||
}"
|
||||
@click.stop.prevent="onEmoji(item.emoji)"
|
||||
>
|
||||
<span v-if="!item.emoji.imageUrl">{{ item.emoji.replacement }}</span>
|
||||
<img
|
||||
v-else
|
||||
:src="item.emoji.imageUrl"
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./emoji_grid.js"></script>
|
||||
<style lang="scss" src="./emoji_grid.scss"></style>
|
Loading…
Reference in new issue