Skip to content

Commit 6d286b4

Browse files
committed
feat: Implement Emoji suggester component - MEED-9225 - Meeds-io/MIPs#164 (#4963)
Implement Emoji suggester component
1 parent 205fcb1 commit 6d286b4

File tree

5 files changed

+278
-2
lines changed

5 files changed

+278
-2
lines changed
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
<!--
2+
This file is part of the Meeds project (https://meeds.io/).
3+
4+
Copyright (C) 2025 Meeds Association [email protected]
5+
6+
This program is free software; you can redistribute it and/or
7+
modify it under the terms of the GNU Lesser General Public
8+
License as published by the Free Software Foundation; either
9+
version 3 of the License, or (at your option) any later version.
10+
This program is distributed in the hope that it will be useful,
11+
but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13+
Lesser General Public License for more details.
14+
15+
You should have received a copy of the GNU Lesser General Public License
16+
along with this program; if not, write to the Free Software Foundation,
17+
Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18+
-->
19+
20+
<template>
21+
<v-menu
22+
v-model="visible"
23+
:position-x="position.x"
24+
:position-y="position.y"
25+
:close-on-content-click="false"
26+
:min-width="minWidth"
27+
:max-height="400"
28+
:content-class="`border-radius-8 specific-scrollbar ${menuContentClass}`"
29+
absolute
30+
top
31+
offset-x
32+
offset-y>
33+
<emoji-suggester-list
34+
v-if="filteredEmojis.length"
35+
ref="emojiSuggesterList"
36+
:focused-index.sync="focusedIndex"
37+
:suggestions="filteredEmojis"
38+
@select="selectEmoji" />
39+
</v-menu>
40+
</template>
41+
<script>
42+
43+
export default {
44+
data() {
45+
return {
46+
visible: false,
47+
query: '',
48+
currentRange: null,
49+
position: {x: 0, y: 0},
50+
composerElement: null,
51+
shortCodeRegex: /:[a-zA-Z0-9:_-]{2,32}$/,
52+
focusedIndex: -1,
53+
};
54+
},
55+
props: {
56+
composerId: {
57+
type: String,
58+
default: null
59+
},
60+
minWidth: {
61+
type: Number,
62+
default: null
63+
}
64+
},
65+
computed: {
66+
menuContentClass() {
67+
return !this.filteredEmojis?.length && 'elevation-0';
68+
},
69+
emojis() {
70+
return this.$emojiBank;
71+
},
72+
filteredEmojis() {
73+
if (!this.query.startsWith(':') || this.query.length < 3) {
74+
return [];
75+
}
76+
const lcQuery = this.query.toLowerCase();
77+
const allEmojis = this.emojis.categories.flatMap(cat => cat.emojis);
78+
79+
return allEmojis
80+
.filter(e => e.shortcodes?.some(sc => sc.toLowerCase().startsWith(lcQuery)));
81+
}
82+
},
83+
mounted() {
84+
const composer = document.getElementById(this.composerId);
85+
if (!composer) {
86+
return;
87+
}
88+
this.composerElement = composer;
89+
composer.addEventListener('input', this.onInput);
90+
composer.addEventListener('keyup', this.onInput);
91+
composer.addEventListener('keydown', this.onKeyDown, {capture: true});
92+
composer.addEventListener('click', this.onInput);
93+
},
94+
beforeDestroy() {
95+
const composer = document.getElementById(this.composerId);
96+
if (!composer) {
97+
return;
98+
}
99+
composer.removeEventListener('input', this.onInput);
100+
composer.removeEventListener('keyup', this.onInput);
101+
composer.removeEventListener('keydown', this.onKeyDown, {capture: true});
102+
composer.removeEventListener('click', this.onInput);
103+
},
104+
methods: {
105+
onInput() {
106+
const selection = window.getSelection();
107+
if (!selection || !selection.rangeCount) {
108+
return;
109+
}
110+
111+
const range = selection.getRangeAt(0);
112+
this.currentRange = range.cloneRange();
113+
114+
const word = this.getCurrentShortcode(range);
115+
this.query = word;
116+
117+
if (word.startsWith(':') && word.length >= 3) {
118+
const rect = range.getBoundingClientRect();
119+
this.position = {
120+
x: rect.left + window.scrollX,
121+
y: (rect.bottom + window.scrollY) - 20
122+
};
123+
this.visible = true;
124+
} else {
125+
this.hide();
126+
}
127+
},
128+
onKeyDown(e) {
129+
if (!this.visible || !this.filteredEmojis.length) {
130+
return;
131+
}
132+
133+
const handledKeys = ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'];
134+
if (!handledKeys.includes(e.key)) {
135+
return;
136+
}
137+
e.preventDefault();
138+
e.stopImmediatePropagation();
139+
140+
if (e.key === 'ArrowDown') {
141+
this.focusedIndex = (this.focusedIndex + 1) % this.filteredEmojis.length;
142+
this.$refs.emojiSuggesterList?.moveToItem(1);
143+
} else if (e.key === 'ArrowUp') {
144+
this.focusedIndex = (this.focusedIndex - 1 + this.filteredEmojis.length) % this.filteredEmojis.length;
145+
this.$refs.emojiSuggesterList?.moveToItem(-1);
146+
} else if (e.key === 'Enter') {
147+
const emoji = this.filteredEmojis[this.focusedIndex];
148+
if (emoji) {
149+
this.selectEmoji(emoji);
150+
}
151+
} else if (e.key === 'Escape') {
152+
this.hide();
153+
}
154+
},
155+
getShortcodeRange(range) {
156+
if (!range || !range.startContainer || range.startContainer.nodeType !== Node.TEXT_NODE) {
157+
return null;
158+
}
159+
160+
const text = range.startContainer.textContent.slice(0, range.startOffset);
161+
const match = text.match(this.shortCodeRegex);
162+
163+
if (!match) {
164+
return null;
165+
}
166+
167+
const start = range.startOffset - match[0].length;
168+
const newRange = document.createRange();
169+
newRange.setStart(range.startContainer, start);
170+
newRange.setEnd(range.startContainer, range.startOffset);
171+
return newRange;
172+
},
173+
getCurrentShortcode(range) {
174+
const container = range.startContainer;
175+
if (!container || container.nodeType !== Node.TEXT_NODE) {
176+
return '';
177+
}
178+
179+
const text = container.textContent.slice(0, range.startOffset);
180+
const match = text.match(this.shortCodeRegex);
181+
return match ? match[0] : '';
182+
},
183+
selectEmoji(item) {
184+
this.hide();
185+
const range = this.getShortcodeRange(this.currentRange);
186+
if (!range) {
187+
return;
188+
}
189+
this.$emit('select-emoji', item.emoji, range);
190+
},
191+
hide() {
192+
this.visible = false;
193+
this.focusedIndex = -1;
194+
}
195+
},
196+
};
197+
</script>
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<!--
2+
This file is part of the Meeds project (https://meeds.io/).
3+
4+
Copyright (C) 2025 Meeds Association [email protected]
5+
6+
This program is free software; you can redistribute it and/or
7+
modify it under the terms of the GNU Lesser General Public
8+
License as published by the Free Software Foundation; either
9+
version 3 of the License, or (at your option) any later version.
10+
This program is distributed in the hope that it will be useful,
11+
but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13+
Lesser General Public License for more details.
14+
15+
You should have received a copy of the GNU Lesser General Public License
16+
along with this program; if not, write to the Free Software Foundation,
17+
Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18+
-->
19+
20+
<template>
21+
<v-list
22+
class="pa-0"
23+
dense>
24+
<v-list-item
25+
ref="emojiItems"
26+
v-for="(item, index) in suggestions"
27+
:key="item.unicode"
28+
:class="{ 'v-list-item--active': index === focusedIndex }"
29+
@click="$emit('select', item)">
30+
<v-list-item-title>
31+
{{ item.emoji }} {{ item.shortcodes[0] }}
32+
</v-list-item-title>
33+
</v-list-item>
34+
</v-list>
35+
</template>
36+
37+
<script>
38+
39+
export default {
40+
props: {
41+
suggestions: {
42+
type: Array,
43+
default: () => []
44+
},
45+
focusedIndex: {
46+
type: Number,
47+
default: 0
48+
}
49+
},
50+
data() {
51+
return {
52+
localFocusedIndex: this.focusedIndex
53+
};
54+
},
55+
watch: {
56+
focusedIndex() {
57+
this.localFocusedIndex = this.focusedIndex;
58+
}
59+
},
60+
methods: {
61+
moveToItem(offset) {
62+
const max = this.suggestions.length - 1;
63+
this.localFocusedIndex = Math.max(0, Math.min(max, this.localFocusedIndex + offset));
64+
this.$emit('update:focusedIndex', this.localFocusedIndex);
65+
this.$nextTick(() => {
66+
const item = this.$refs.emojiItems?.[this.localFocusedIndex];
67+
if (item?.$el?.scrollIntoView) {
68+
item.$el.scrollIntoView({block: 'nearest'});
69+
}
70+
});
71+
}
72+
}
73+
};
74+
</script>

webapp/src/main/webapp/vue-apps/emoji-picker/components/view/EmojiPickerList.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
<v-btn
5757
v-for="emoji in item.emojis"
5858
:key="emoji.unicode"
59-
:title="emoji.name"
59+
:title="emoji.shortcodes?.[0]"
6060
class="pa-1 btn btn-default no-border"
6161
icon
6262
@click="selectEmoji(emoji)">

webapp/src/main/webapp/vue-apps/emoji-picker/initComponents.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,17 @@ import EmojiPickerButton from './components/EmojiPickerButton.vue';
2323
import EmojiPickerQuickEmojis from './components/view/EmojiPickerQuickEmojis.vue';
2424
import EmojiPickerList from './components/view/EmojiPickerList.vue';
2525
import EmojiPickerListCategory from './components/view/EmojiPickerListCategory.vue';
26+
import EmojiSuggester from './components/suggester/EmojiSuggester.vue';
27+
import EmojiSuggestionList from './components/suggester/EmojiSuggestionList.vue';
2628

2729
const components = {
2830
'emoji-picker': EmojiPicker,
2931
'emoji-picker-button': EmojiPickerButton,
3032
'emoji-picker-quick-emojis': EmojiPickerQuickEmojis,
3133
'emoji-picker-list': EmojiPickerList,
32-
'emoji-picker-list-category': EmojiPickerListCategory
34+
'emoji-picker-list-category': EmojiPickerListCategory,
35+
'emoji-suggester': EmojiSuggester,
36+
'emoji-suggester-list': EmojiSuggestionList
3337
};
3438

3539
for (const key in components) {

webapp/src/main/webapp/vue-apps/emoji-picker/main.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Promise.all([
3232
exoi18n.loadLanguageAsync(lang, url),
3333
fetch(emojiBankUrl).then(res => res.json())
3434
]).then(([i18n, emojiBank]) => {
35+
Object.defineProperty(Vue.prototype, '$emojiBank', {value: emojiBank,});
3536
Vue.createApp({
3637
data() {
3738
return {

0 commit comments

Comments
 (0)