feat: Adds support for selecting emojis using the keyboard (#10055)

This commit is contained in:
Sivin Varghese
2024-09-04 11:32:54 +05:30
committed by GitHub
parent 3a0e68030a
commit a3732c8f51
10 changed files with 388 additions and 89 deletions

View File

@@ -17,6 +17,8 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
import TagAgents from '../conversation/TagAgents.vue';
import CannedResponse from '../conversation/CannedResponse.vue';
import VariableList from '../conversation/VariableList.vue';
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
import {
appendSignature,
removeSignature,
@@ -24,6 +26,7 @@ import {
scrollCursorIntoView,
findNodeToInsertImage,
setURLWithQueryAndSize,
getContentNode,
} from 'dashboard/helper/editorHelper';
const TYPING_INDICATOR_IDLE_TIME = 4000;
@@ -35,10 +38,8 @@ import {
} from 'shared/helpers/KeyboardHelpers';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { useUISettings } from 'dashboard/composables/useUISettings';
import {
replaceVariablesInMessage,
createTypingIndicator,
} from '@chatwoot/utils';
import { createTypingIndicator } from '@chatwoot/utils';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
@@ -71,7 +72,12 @@ const createState = (
export default {
name: 'WootMessageEditor',
components: { TagAgents, CannedResponse, VariableList },
components: {
TagAgents,
CannedResponse,
VariableList,
KeyboardEmojiSelector,
},
mixins: [keyboardEventListenerMixins],
props: {
value: { type: String, default: '' },
@@ -119,9 +125,11 @@ export default {
showUserMentions: false,
showCannedMenu: false,
showVariables: false,
showEmojiMenu: false,
mentionSearchKey: '',
cannedSearchTerm: '',
variableSearchTerm: '',
emojiSearchTerm: '',
editorView: null,
range: null,
state: undefined,
@@ -169,7 +177,7 @@ export default {
this.editorView = args.view;
this.range = args.range;
this.mentionSearchKey = args.text.replace('@', '');
this.mentionSearchKey = args.text;
return false;
},
@@ -198,7 +206,7 @@ export default {
this.editorView = args.view;
this.range = args.range;
this.cannedSearchTerm = args.text.replace('/', '');
this.cannedSearchTerm = args.text;
return false;
},
onExit: () => {
@@ -226,7 +234,7 @@ export default {
this.editorView = args.view;
this.range = args.range;
this.variableSearchTerm = args.text.replace('{{', '');
this.variableSearchTerm = args.text;
return false;
},
onExit: () => {
@@ -238,6 +246,31 @@ export default {
return event.keyCode === 13 && this.showVariables;
},
}),
suggestionsPlugin({
matcher: triggerCharacters(':', 1), // Trigger after ':' and at least 1 characters
suggestionClass: '',
onEnter: args => {
this.showEmojiMenu = true;
this.emojiSearchTerm = args.text || '';
this.range = args.range;
this.editorView = args.view;
return false;
},
onChange: args => {
this.editorView = args.view;
this.range = args.range;
this.emojiSearchTerm = args.text;
return false;
},
onExit: () => {
this.emojiSearchTerm = '';
this.showEmojiMenu = false;
return false;
},
onKeyDown: ({ event }) => {
return event.keyCode === 13 && this.showEmojiMenu;
},
}),
];
},
sendWithSignature() {
@@ -267,6 +300,8 @@ export default {
},
editorId() {
this.showCannedMenu = false;
this.showEmojiMenu = false;
this.showVariables = false;
this.cannedSearchTerm = '';
this.reloadState(this.value);
},
@@ -517,57 +552,36 @@ export default {
this.editorView.dispatch(tr.setSelection(selection));
this.editorView.focus();
},
insertMentionNode(mentionItem) {
/**
* Inserts special content (mention, canned response, variable, emoji) into the editor.
* @param {string} type - The type of special content to insert. Possible values: 'mention', 'canned_response', 'variable', 'emoji'.
* @param {Object|string} content - The content to insert, depending on the type.
*/
insertSpecialContent(type, content) {
if (!this.editorView) {
return null;
}
const node = this.editorView.state.schema.nodes.mention.create({
userId: mentionItem.id,
userFullName: mentionItem.name,
});
this.insertNodeIntoEditor(node, this.range.from, this.range.to);
this.$track(CONVERSATION_EVENTS.USED_MENTIONS);
return false;
},
insertCannedResponse(cannedItem) {
const updatedMessage = replaceVariablesInMessage({
message: cannedItem,
variables: this.variables,
});
if (!this.editorView) {
return null;
return;
}
let node = new MessageMarkdownTransformer(messageSchema).parse(
updatedMessage
let { node, from, to } = getContentNode(
this.editorView,
type,
content,
this.range,
this.variables
);
const from =
node.textContent === updatedMessage
? this.range.from
: this.range.from - 1;
this.insertNodeIntoEditor(node, from, this.range.to);
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
return false;
},
insertVariable(variable) {
if (!this.editorView) {
return null;
}
const content = `{{${variable}}}`;
let node = this.editorView.state.schema.text(content);
const { from, to } = this.range;
if (!node) return;
this.insertNodeIntoEditor(node, from, to);
this.showVariables = false;
this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
return false;
const event_map = {
mention: CONVERSATION_EVENTS.USED_MENTIONS,
cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE,
variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE,
emoji: CONVERSATION_EVENTS.INSERTED_AN_EMOJI,
};
this.$track(event_map[type]);
},
openFileBrowser() {
this.$refs.imageUpload.click();
@@ -687,17 +701,22 @@ export default {
<TagAgents
v-if="showUserMentions && isPrivate"
:search-key="mentionSearchKey"
@click="insertMentionNode"
@click="content => insertSpecialContent('mention', content)"
/>
<CannedResponse
v-if="shouldShowCannedResponses"
:search-key="cannedSearchTerm"
@click="insertCannedResponse"
@click="content => insertSpecialContent('cannedResponse', content)"
/>
<VariableList
v-if="shouldShowVariables"
:search-key="variableSearchTerm"
@click="insertVariable"
@click="content => insertSpecialContent('variable', content)"
/>
<KeyboardEmojiSelector
v-if="showEmojiMenu"
:search-key="emojiSearchTerm"
@click="emoji => insertSpecialContent('emoji', emoji)"
/>
<input
ref="imageUpload"

View File

@@ -0,0 +1,68 @@
<script setup>
import { shallowRef, computed, onMounted } from 'vue';
import emojis from 'shared/components/emoji/emojisGroup.json';
import MentionBox from '../mentions/MentionBox.vue';
const props = defineProps({
searchKey: {
type: String,
default: '',
},
});
const emit = defineEmits(['click']);
const allEmojis = shallowRef([]);
const items = computed(() => {
if (!props.searchKey) return [];
const searchTerm = props.searchKey.toLowerCase();
return allEmojis.value.filter(emoji =>
emoji.searchString.includes(searchTerm)
);
});
function loadEmojis() {
allEmojis.value = emojis.flatMap(group =>
group.emojis.map(emoji => ({
...emoji,
searchString: `${emoji.slug} ${emoji.name}`.toLowerCase(),
}))
);
}
function handleMentionClick(item = {}) {
emit('click', item.emoji);
}
onMounted(() => {
loadEmojis();
});
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<MentionBox
v-if="items.length"
type="emoji"
:items="items"
@mentionSelect="handleMentionClick"
>
<template #default="{ item, selected }">
<span
class="max-w-full inline-flex items-center gap-0.5 min-w-0 mb-0 text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 truncate"
>
{{ item.emoji }}
<p
class="relative mb-0 truncate bottom-px"
:class="{
'text-woot-500 dark:text-woot-500': selected,
'font-normal': !selected,
}"
>
:{{ item.slug }}
</p>
</span>
</template>
</MentionBox>
</template>

View File

@@ -41,14 +41,11 @@ export default {
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<MentionBox
v-if="items.length"
:items="items"
@mentionSelect="handleMentionClick"
>
<template slot-scope="{ item }">
<strong>{{ item.label }}</strong> - {{ item.description }}
</template>
</MentionBox>
/>
</template>

View File

@@ -56,20 +56,14 @@ export default {
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<MentionBox
v-if="items.length"
type="variable"
:items="items"
@mentionSelect="handleVariableClick"
>
<template slot-scope="{ item }">
<span class="text-capitalize variable--list-label">
{{ item.description }}
</span>
({{ item.label }})
</template>
</MentionBox>
/>
</template>
<style scoped>

View File

@@ -90,22 +90,24 @@ const variableKey = (item = {}) => {
}"
@click="onListItemSelection(index)"
>
<p
class="max-w-full min-w-0 mb-0 overflow-hidden text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
:class="{
'text-woot-500 dark:text-woot-500': index === selectedIndex,
}"
>
{{ item.description }}
</p>
<p
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-slate-500 dark:text-slate-300 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
:class="{
'text-woot-500 dark:text-woot-500': index === selectedIndex,
}"
>
{{ variableKey(item) }}
</p>
<slot :item="item" :index="index" :selected="index === selectedIndex">
<p
class="max-w-full min-w-0 mb-0 overflow-hidden text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
:class="{
'text-woot-500 dark:text-woot-500': index === selectedIndex,
}"
>
{{ item.description }}
</p>
<p
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-slate-500 dark:text-slate-300 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
:class="{
'text-woot-500 dark:text-woot-500': index === selectedIndex,
}"
>
{{ variableKey(item) }}
</p>
</slot>
</button>
</woot-dropdown-item>
</ul>