refactor: handling keyboard shortcuts (#9242)

* fix: Resolve and go next keyboard shortcuts doesn't work

* refactor: use buildHotKeys instead of  hasPressedCommandPlusAltAndEKey

* feat: install tinykeys

* refactor: use tinykeys

* test: update buildKeyEvents

* fix: remove stray import

* feat: handle action list globally

* feat: allow configuring `allowOnFocusedInput`

* chore: Navigate chat list item

* chore: Navigate dashboard

* feat: Navigate editor top panel

* feat: Toggle file upload

* chore: More keyboard shortcuts

* chore: Update mention selection mixin

* chore: Phone input

* chore: Clean up

* chore: Clean up

* chore: Dropdown and editor

* chore: Enter key to send and clean up

* chore: Rename mixin

* chore: Review fixes

* chore: Removed unused shortcut from modal

* fix: Specs

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Shivam Mishra 2024-04-26 15:41:02 +05:30 committed by GitHub
parent ffd47081bd
commit 47f8b2cd0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 599 additions and 596 deletions

View File

@ -185,7 +185,7 @@ import ConversationBasicFilter from './widgets/conversation/ConversationBasicFil
import ChatTypeTabs from './widgets/ChatTypeTabs.vue'; import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue'; import ConversationItem from './ConversationItem.vue';
import timeMixin from '../mixins/time'; import timeMixin from '../mixins/time';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import conversationMixin from '../mixins/conversations'; import conversationMixin from '../mixins/conversations';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import advancedFilterTypes from './widgets/conversation/advancedFilterItems'; import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
@ -199,11 +199,6 @@ import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages'; import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries'; import countries from 'shared/constants/countries';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper'; import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
import {
hasPressedAltAndJKey,
hasPressedAltAndKKey,
} from 'shared/helpers/KeyboardHelpers';
import { conversationListPageURL } from '../helper/URLHelper'; import { conversationListPageURL } from '../helper/URLHelper';
import { import {
isOnMentionsView, isOnMentionsView,
@ -228,7 +223,7 @@ export default {
mixins: [ mixins: [
timeMixin, timeMixin,
conversationMixin, conversationMixin,
eventListenerMixins, keyboardEventListenerMixins,
alertMixin, alertMixin,
filterMixin, filterMixin,
uiSettingsMixin, uiSettingsMixin,
@ -691,30 +686,40 @@ export default {
lastConversationIndex, lastConversationIndex,
}; };
}, },
handleKeyEvents(e) { handlePreviousConversation() {
if (hasPressedAltAndJKey(e)) { const { allConversations, activeConversationIndex } =
const { allConversations, activeConversationIndex } = this.getKeyboardListenerParams();
this.getKeyboardListenerParams(); if (activeConversationIndex === -1) {
if (activeConversationIndex === -1) { allConversations[0].click();
allConversations[0].click();
}
if (activeConversationIndex >= 1) {
allConversations[activeConversationIndex - 1].click();
}
} }
if (hasPressedAltAndKKey(e)) { if (activeConversationIndex >= 1) {
const { allConversations[activeConversationIndex - 1].click();
allConversations,
activeConversationIndex,
lastConversationIndex,
} = this.getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[lastConversationIndex].click();
} else if (activeConversationIndex < lastConversationIndex) {
allConversations[activeConversationIndex + 1].click();
}
} }
}, },
handleNextConversation() {
const {
allConversations,
activeConversationIndex,
lastConversationIndex,
} = this.getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[lastConversationIndex].click();
} else if (activeConversationIndex < lastConversationIndex) {
allConversations[activeConversationIndex + 1].click();
}
},
getKeyboardEvents() {
return {
'Alt+KeyJ': {
action: () => this.handlePreviousConversation(),
allowOnFocusedInput: true,
},
'Alt+KeyK': {
action: () => this.handleNextConversation(),
allowOnFocusedInput: true,
},
};
},
resetAndFetchData() { resetAndFetchData() {
this.appliedFilter = []; this.appliedFilter = [];
this.resetBulkActions(); this.resetBulkActions();

View File

@ -91,12 +91,7 @@ import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue'; import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import {
hasPressedAltAndEKey,
hasPressedCommandPlusAltAndEKey,
hasPressedAltAndMKey,
} from 'shared/helpers/KeyboardHelpers';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers'; import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue'; import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@ -114,7 +109,7 @@ export default {
WootDropdownMenu, WootDropdownMenu,
CustomSnoozeModal, CustomSnoozeModal,
}, },
mixins: [clickaway, alertMixin, eventListenerMixins], mixins: [clickaway, alertMixin, keyboardEventListenerMixins],
props: { conversationId: { type: [String, Number], required: true } }, props: { conversationId: { type: [String, Number], required: true } },
data() { data() {
return { return {
@ -159,37 +154,52 @@ export default {
bus.$off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation); bus.$off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
}, },
methods: { methods: {
async handleKeyEvents(e) { getKeyboardEvents() {
return {
'Alt+KeyM': {
action: () => this.$refs.arrowDownButton?.$el.click(),
allowOnFocusedInput: true,
},
'Alt+KeyE': this.resolveOrToast,
'$mod+Alt+KeyE': async event => {
const { all, activeIndex, lastIndex } = this.getConversationParams();
await this.resolveOrToast();
if (activeIndex < lastIndex) {
all[activeIndex + 1].click();
} else if (all.length > 1) {
all[0].click();
document.querySelector('.conversations-list').scrollTop = 0;
}
event.preventDefault();
},
};
},
getConversationParams() {
const allConversations = document.querySelectorAll( const allConversations = document.querySelectorAll(
'.conversations-list .conversation' '.conversations-list .conversation'
); );
if (hasPressedAltAndMKey(e)) {
if (this.$refs.arrowDownButton) { const activeConversation = document.querySelector(
this.$refs.arrowDownButton.$el.click(); 'div.conversations-list div.conversation.active'
} );
} const activeConversationIndex = [...allConversations].indexOf(
if (hasPressedAltAndEKey(e)) { activeConversation
const activeConversation = document.querySelector( );
'div.conversations-list div.conversation.active' const lastConversationIndex = allConversations.length - 1;
);
const activeConversationIndex = [...allConversations].indexOf( return {
activeConversation all: allConversations,
); activeIndex: activeConversationIndex,
const lastConversationIndex = allConversations.length - 1; lastIndex: lastConversationIndex,
try { };
await this.toggleStatus(wootConstants.STATUS_TYPE.RESOLVED); },
} catch (error) { async resolveOrToast() {
// error try {
} await this.toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
if (hasPressedCommandPlusAltAndEKey(e)) { } catch (error) {
if (activeConversationIndex < lastConversationIndex) { // error
allConversations[activeConversationIndex + 1].click();
} else if (allConversations.length > 1) {
allConversations[0].click();
document.querySelector('.conversations-list').scrollTop = 0;
}
e.preventDefault();
}
} }
}, },
onCmdSnoozeConversation(snoozeType) { onCmdSnoozeConversation(snoozeType) {

View File

@ -1,5 +1,5 @@
<template> <template>
<aside class="h-full flex"> <aside class="flex h-full">
<primary-sidebar <primary-sidebar
:logo-source="globalConfig.logoThumbnail" :logo-source="globalConfig.logoThumbnail"
:installation-name="globalConfig.installationName" :installation-name="globalConfig.installationName"
@ -36,15 +36,7 @@ import alertMixin from 'shared/mixins/alertMixin';
import PrimarySidebar from './sidebarComponents/Primary.vue'; import PrimarySidebar from './sidebarComponents/Primary.vue';
import SecondarySidebar from './sidebarComponents/Secondary.vue'; import SecondarySidebar from './sidebarComponents/Secondary.vue';
import { import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
hasPressedAltAndCKey,
hasPressedAltAndRKey,
hasPressedAltAndSKey,
hasPressedAltAndVKey,
hasPressedCommandAndForwardSlash,
isEscape,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import router from '../../routes'; import router from '../../routes';
export default { export default {
@ -52,7 +44,7 @@ export default {
PrimarySidebar, PrimarySidebar,
SecondarySidebar, SecondarySidebar,
}, },
mixins: [adminMixin, alertMixin, eventListenerMixins], mixins: [adminMixin, alertMixin, keyboardEventListenerMixins],
props: { props: {
showSecondarySidebar: { showSecondarySidebar: {
type: Boolean, type: Boolean,
@ -173,30 +165,27 @@ export default {
closeKeyShortcutModal() { closeKeyShortcutModal() {
this.$emit('close-key-shortcut-modal'); this.$emit('close-key-shortcut-modal');
}, },
handleKeyEvents(e) { getKeyboardEvents() {
if (hasPressedCommandAndForwardSlash(e)) { return {
this.toggleKeyShortcutModal(); '$mod+Slash': this.toggleKeyShortcutModal,
} '$mod+Escape': this.closeKeyShortcutModal,
if (isEscape(e)) { 'Alt+KeyC': {
this.closeKeyShortcutModal(); action: () => this.navigateToRoute('home'),
} },
'Alt+KeyV': {
if (hasPressedAltAndCKey(e)) { action: () => this.navigateToRoute('contacts_dashboard'),
if (!this.isCurrentRouteSameAsNavigation('home')) { },
router.push({ name: 'home' }); 'Alt+KeyR': {
} action: () => this.navigateToRoute('account_overview_reports'),
} else if (hasPressedAltAndVKey(e)) { },
if (!this.isCurrentRouteSameAsNavigation('contacts_dashboard')) { 'Alt+KeyS': {
router.push({ name: 'contacts_dashboard' }); action: () => this.navigateToRoute('agent_list'),
} },
} else if (hasPressedAltAndRKey(e)) { };
if (!this.isCurrentRouteSameAsNavigation('settings_account_reports')) { },
router.push({ name: 'settings_account_reports' }); navigateToRoute(routeName) {
} if (!this.isCurrentRouteSameAsNavigation(routeName)) {
} else if (hasPressedAltAndSKey(e)) { router.push({ name: routeName });
if (!this.isCurrentRouteSameAsNavigation('agent_list')) {
router.push({ name: 'agent_list' });
}
} }
}, },
isCurrentRouteSameAsNavigation(routeName) { isCurrentRouteSameAsNavigation(routeName) {

View File

@ -40,8 +40,7 @@ import AIAssistanceModal from './AIAssistanceModal.vue';
import adminMixin from 'dashboard/mixins/aiMixin'; import adminMixin from 'dashboard/mixins/aiMixin';
import aiMixin from 'dashboard/mixins/isAdmin'; import aiMixin from 'dashboard/mixins/isAdmin';
import { CMD_AI_ASSIST } from 'dashboard/routes/dashboard/commands/commandBarBusEvents'; import { CMD_AI_ASSIST } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue'; import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
@ -51,7 +50,7 @@ export default {
AICTAModal, AICTAModal,
AIAssistanceCTAButton, AIAssistanceCTAButton,
}, },
mixins: [aiMixin, eventListenerMixins, adminMixin, uiSettingsMixin], mixins: [aiMixin, keyboardEventListenerMixins, adminMixin, uiSettingsMixin],
data: () => ({ data: () => ({
showAIAssistanceModal: false, showAIAssistanceModal: false,
showAICtaModal: false, showAICtaModal: false,
@ -87,14 +86,17 @@ export default {
}, },
methods: { methods: {
onKeyDownHandler(event) { getKeyboardEvents() {
const keyPattern = buildHotKeys(event); return {
const shouldRevertTheContent = '$mod+KeyZ': {
['meta+z', 'ctrl+z'].includes(keyPattern) && !!this.initialMessage; action: () => {
if (shouldRevertTheContent) { if (this.initialMessage) {
this.$emit('replace-text', this.initialMessage); this.$emit('replace-text', this.initialMessage);
this.initialMessage = ''; this.initialMessage = '';
} }
},
},
};
}, },
hideAIAssistanceModal() { hideAIAssistanceModal() {
this.recordAnalytics('DISMISS_AI_SUGGESTION', { this.recordAnalytics('DISMISS_AI_SUGGESTION', {

View File

@ -10,11 +10,10 @@
</template> </template>
<script> <script>
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { hasPressedAltAndNKey } from 'shared/helpers/KeyboardHelpers';
export default { export default {
mixins: [eventListenerMixins], mixins: [keyboardEventListenerMixins],
props: { props: {
items: { items: {
type: Array, type: Array,
@ -31,14 +30,18 @@ export default {
}, },
}, },
methods: { methods: {
handleKeyEvents(e) { getKeyboardEvents() {
if (hasPressedAltAndNKey(e)) { return {
if (this.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) { 'Alt+KeyN': {
this.onTabChange(0); action: () => {
} else { if (this.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
this.onTabChange(this.activeTabIndex + 1); this.onTabChange(0);
} } else {
} this.onTabChange(this.activeTabIndex + 1);
}
},
},
};
}, },
onTabChange(selectedTabIndex) { onTabChange(selectedTabIndex) {
if (this.items[selectedTabIndex].key !== this.activeTab) { if (this.items[selectedTabIndex].key !== this.activeTab) {

View File

@ -31,15 +31,10 @@
<script> <script>
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue'; import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue'; import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import adminMixin from 'dashboard/mixins/isAdmin'; import adminMixin from 'dashboard/mixins/isAdmin';
import {
buildHotKeys,
isEscape,
isActiveElementTypeable,
} from 'shared/helpers/KeyboardHelpers';
export default { export default {
components: { components: {
@ -47,7 +42,7 @@ export default {
LabelDropdown, LabelDropdown,
}, },
mixins: [clickaway, adminMixin, eventListenerMixins], mixins: [clickaway, adminMixin, keyboardEventListenerMixins],
props: { props: {
allLabels: { allLabels: {
@ -88,17 +83,19 @@ export default {
closeDropdownLabel() { closeDropdownLabel() {
this.showSearchDropdownLabel = false; this.showSearchDropdownLabel = false;
}, },
getKeyboardEvents() {
handleKeyEvents(e) { return {
const keyPattern = buildHotKeys(e); KeyL: {
action: e => {
if (keyPattern === 'l' && !isActiveElementTypeable(e)) { this.toggleLabels();
this.toggleLabels(); e.preventDefault();
e.preventDefault(); },
} else if (isEscape(e) && this.showSearchDropdownLabel) { },
this.closeDropdownLabel(); Escape: {
e.preventDefault(); action: () => this.closeDropdownLabel(),
} allowOnFocusedInput: true,
},
};
}, },
}, },
}; };

View File

@ -78,10 +78,8 @@ const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
import { import {
hasPressedEnterAndNotCmdOrShift, hasPressedEnterAndNotCmdOrShift,
hasPressedCommandAndEnter, hasPressedCommandAndEnter,
hasPressedAltAndPKey,
hasPressedAltAndLKey,
} from 'shared/helpers/KeyboardHelpers'; } from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings'; import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import { import {
@ -121,7 +119,7 @@ const createState = (
export default { export default {
name: 'WootMessageEditor', name: 'WootMessageEditor',
components: { TagAgents, CannedResponse, VariableList }, components: { TagAgents, CannedResponse, VariableList },
mixins: [eventListenerMixins, uiSettingsMixin, alertMixin], mixins: [keyboardEventListenerMixins, uiSettingsMixin, alertMixin],
props: { props: {
value: { type: String, default: '' }, value: { type: String, default: '' },
editorId: { type: String, default: '' }, editorId: { type: String, default: '' },
@ -522,13 +520,21 @@ export default {
isCmdPlusEnterToSendEnabled() { isCmdPlusEnterToSendEnabled() {
return isEditorHotKeyEnabled(this.uiSettings, 'cmd_enter'); return isEditorHotKeyEnabled(this.uiSettings, 'cmd_enter');
}, },
handleKeyEvents(e) { getKeyboardEvents() {
if (hasPressedAltAndPKey(e)) { return {
this.focusEditorInputField(); 'Alt+KeyP': {
} action: () => {
if (hasPressedAltAndLKey(e)) { this.focusEditorInputField();
this.focusEditorInputField(); },
} allowOnFocusedInput: true,
},
'Alt+KeyL': {
action: () => {
this.focusEditorInputField();
},
allowOnFocusedInput: true,
},
};
}, },
focusEditorInputField(pos = 'end') { focusEditorInputField(pos = 'end') {
const { tr } = this.editorView.state; const { tr } = this.editorView.state;

View File

@ -25,7 +25,7 @@ import {
} from '@chatwoot/prosemirror-schema'; } from '@chatwoot/prosemirror-schema';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
@ -51,7 +51,7 @@ const createState = (
}; };
export default { export default {
mixins: [eventListenerMixins, uiSettingsMixin, alertMixin], mixins: [keyboardEventListenerMixins, uiSettingsMixin, alertMixin],
props: { props: {
value: { type: String, default: '' }, value: { type: String, default: '' },
editorId: { type: String, default: '' }, editorId: { type: String, default: '' },

View File

@ -136,8 +136,7 @@
<script> <script>
import FileUpload from 'vue-upload-component'; import FileUpload from 'vue-upload-component';
import * as ActiveStorage from 'activestorage'; import * as ActiveStorage from 'activestorage';
import { hasPressedAltAndAKey } from 'shared/helpers/KeyboardHelpers'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import inboxMixin from 'shared/mixins/inboxMixin'; import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@ -154,7 +153,7 @@ import { mapGetters } from 'vuex';
export default { export default {
name: 'ReplyBottomPanel', name: 'ReplyBottomPanel',
components: { FileUpload, VideoCallButton, AIAssistanceButton }, components: { FileUpload, VideoCallButton, AIAssistanceButton },
mixins: [eventListenerMixins, uiSettingsMixin, inboxMixin], mixins: [keyboardEventListenerMixins, uiSettingsMixin, inboxMixin],
props: { props: {
mode: { mode: {
type: String, type: String,
@ -340,10 +339,15 @@ export default {
ActiveStorage.start(); ActiveStorage.start();
}, },
methods: { methods: {
handleKeyEvents(e) { getKeyboardEvents() {
if (hasPressedAltAndAKey(e)) { return {
this.$refs.upload.$children[1].$el.click(); 'Alt+KeyA': {
} action: () => {
this.$refs.upload.$children[1].$el.click();
},
allowOnFocusedInput: true,
},
};
}, },
toggleMessageSignature() { toggleMessageSignature() {
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature); this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="bg-black-50 flex justify-between dark:bg-slate-800"> <div class="flex justify-between bg-black-50 dark:bg-slate-800">
<div class="button-group"> <div class="button-group">
<woot-button <woot-button
variant="clear" variant="clear"
@ -20,7 +20,7 @@
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }} {{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
</woot-button> </woot-button>
</div> </div>
<div class="flex items-center my-0 mx-4"> <div class="flex items-center mx-4 my-0">
<div v-if="isMessageLengthReachingThreshold" class="text-xs"> <div v-if="isMessageLengthReachingThreshold" class="text-xs">
<span :class="charLengthClass"> <span :class="charLengthClass">
{{ characterLengthWarning }} {{ characterLengthWarning }}
@ -48,14 +48,10 @@
<script> <script>
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants'; import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import { import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
hasPressedAltAndPKey,
hasPressedAltAndLKey,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
export default { export default {
name: 'ReplyTopPanel', name: 'ReplyTopPanel',
mixins: [eventListenerMixins], mixins: [keyboardEventListenerMixins],
props: { props: {
mode: { mode: {
type: String, type: String,
@ -99,13 +95,17 @@ export default {
}, },
}, },
methods: { methods: {
handleKeyEvents(e) { getKeyboardEvents() {
if (hasPressedAltAndPKey(e)) { return {
this.handleNoteClick(); 'Alt+KeyP': {
} action: () => this.handleNoteClick(),
if (hasPressedAltAndLKey(e)) { allowOnFocusedInput: true,
this.handleReplyClick(); },
} 'Alt+KeyL': {
action: () => this.handleReplyClick(),
allowOnFocusedInput: true,
},
};
}, },
handleReplyClick() { handleReplyClick() {
this.setReplyMode(REPLY_EDITOR_MODES.REPLY); this.setReplyMode(REPLY_EDITOR_MODES.REPLY);

View File

@ -2,7 +2,7 @@
<div <div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-slate-25 dark:hover:bg-slate-800 group" class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-slate-25 dark:hover:bg-slate-800 group"
:class="{ :class="{
'animate-card-select bg-slate-25 dark:bg-slate-800 border-woot-500': 'active animate-card-select bg-slate-25 dark:bg-slate-800 border-woot-500':
isActiveChat, isActiveChat,
'unread-chat': hasUnread, 'unread-chat': hasUnread,
'has-inbox-name': showInboxName, 'has-inbox-name': showInboxName,

View File

@ -77,11 +77,10 @@
</div> </div>
</template> </template>
<script> <script>
import { hasPressedAltAndOKey } from 'shared/helpers/KeyboardHelpers';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import agentMixin from '../../../mixins/agentMixin.js'; import agentMixin from '../../../mixins/agentMixin.js';
import BackButton from '../BackButton.vue'; import BackButton from '../BackButton.vue';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import inboxMixin from 'shared/mixins/inboxMixin'; import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue'; import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue'; import MoreActions from './MoreActions.vue';
@ -99,7 +98,7 @@ export default {
Thumbnail, Thumbnail,
SLACardLabel, SLACardLabel,
}, },
mixins: [inboxMixin, agentMixin, eventListenerMixins], mixins: [inboxMixin, agentMixin, keyboardEventListenerMixins],
props: { props: {
chat: { chat: {
type: Object, type: Object,
@ -182,10 +181,12 @@ export default {
}, },
methods: { methods: {
handleKeyEvents(e) { getKeyboardEvents() {
if (hasPressedAltAndOKey(e)) { return {
this.$emit('contact-panel-toggle'); 'Alt+KeyO': {
} action: () => this.$emit('contact-panel-toggle'),
},
};
}, },
}, },
}; };

View File

@ -116,13 +116,12 @@ import conversationMixin, {
} from '../../../mixins/conversations'; } from '../../../mixins/conversations';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin'; import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import configMixin from 'shared/mixins/configMixin'; import configMixin from 'shared/mixins/configMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import aiMixin from 'dashboard/mixins/aiMixin'; import aiMixin from 'dashboard/mixins/aiMixin';
// utils // utils
import { getTypingUsersText } from '../../../helper/commons'; import { getTypingUsersText } from '../../../helper/commons';
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper'; import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers';
import { LocalStorage } from 'shared/helpers/localStorage'; import { LocalStorage } from 'shared/helpers/localStorage';
// constants // constants
@ -141,7 +140,7 @@ export default {
mixins: [ mixins: [
conversationMixin, conversationMixin,
inboxMixin, inboxMixin,
eventListenerMixins, keyboardEventListenerMixins,
configMixin, configMixin,
aiMixin, aiMixin,
], ],
@ -418,10 +417,12 @@ export default {
closePopoutReplyBox() { closePopoutReplyBox() {
this.isPopoutReplyBox = false; this.isPopoutReplyBox = false;
}, },
handleKeyEvents(e) { getKeyboardEvents() {
if (isEscape(e)) { return {
this.closePopoutReplyBox(); Escape: {
} action: () => this.closePopoutReplyBox(),
},
};
}, },
addScrollListener() { addScrollListener() {
this.conversationPanel = this.$el.querySelector('.conversation-panel'); this.conversationPanel = this.$el.querySelector('.conversation-panel');

View File

@ -155,6 +155,7 @@
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import CannedResponse from './CannedResponse.vue'; import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue'; import ReplyToMessage from './ReplyToMessage.vue';
@ -178,7 +179,6 @@ import {
replaceVariablesInMessage, replaceVariablesInMessage,
} from '@chatwoot/utils'; } from '@chatwoot/utils';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue'; import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper'; import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin'; import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
@ -225,6 +225,7 @@ export default {
messageFormatterMixin, messageFormatterMixin,
rtlMixin, rtlMixin,
fileUploadMixin, fileUploadMixin,
keyboardEventListenerMixins,
], ],
props: { props: {
popoutReplyBox: { popoutReplyBox: {
@ -701,24 +702,41 @@ export default {
this.$store.dispatch('draftMessages/delete', { key }); this.$store.dispatch('draftMessages/delete', { key });
} }
}, },
handleKeyEvents(e) { getKeyboardEvents() {
const keyCode = buildHotKeys(e); return {
if (keyCode === 'escape') { Escape: {
this.hideEmojiPicker(); action: () => {
this.hideMentions(); this.hideEmojiPicker();
} else if (keyCode === 'meta+k') { this.hideMentions();
const ninja = document.querySelector('ninja-keys'); },
ninja.open(); allowOnFocusedInput: true,
e.preventDefault(); },
} else if (keyCode === 'enter' && this.isAValidEvent('enter')) { '$mod+KeyK': {
this.onSendReply(); action: e => {
e.preventDefault(); e.preventDefault();
} else if ( const ninja = document.querySelector('ninja-keys');
['meta+enter', 'ctrl+enter'].includes(keyCode) && ninja.open();
this.isAValidEvent('cmd_enter') },
) { allowOnFocusedInput: true,
this.onSendReply(); },
} Enter: {
action: e => {
if (this.isAValidEvent('enter')) {
this.onSendReply();
e.preventDefault();
}
},
allowOnFocusedInput: true,
},
'$mod+Enter': {
action: () => {
if (this.isAValidEvent('cmd_enter')) {
this.onSendReply();
}
},
allowOnFocusedInput: true,
},
};
}, },
isAValidEvent(selectedKey) { isAValidEvent(selectedKey) {
return ( return (

View File

@ -75,9 +75,10 @@ export default {
}, },
methods: { methods: {
handleKeyboardEvent(e) { adjustScroll() {
this.processKeyDownEvent(e); this.$nextTick(() => {
this.$el.scrollTop = 50 * this.selectedIndex; this.$el.scrollTop = 50 * this.selectedIndex;
});
}, },
onHover(index) { onHover(index) {
this.selectedIndex = index; this.selectedIndex = index;

View File

@ -182,12 +182,7 @@
<script> <script>
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
isEscape,
hasPressedArrowLeftKey,
hasPressedArrowRightKey,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import timeMixin from 'dashboard/mixins/time'; import timeMixin from 'dashboard/mixins/time';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
@ -205,7 +200,7 @@ export default {
components: { components: {
Thumbnail, Thumbnail,
}, },
mixins: [eventListenerMixins, clickaway, timeMixin], mixins: [keyboardEventListenerMixins, clickaway, timeMixin],
props: { props: {
show: { show: {
type: Boolean, type: Boolean,
@ -304,20 +299,30 @@ export default {
this.activeAttachment = attachment; this.activeAttachment = attachment;
this.activeFileType = type; this.activeFileType = type;
}, },
onKeyDownHandler(e) { getKeyboardEvents() {
if (isEscape(e)) { return {
this.onClose(); Escape: {
} else if (hasPressedArrowLeftKey(e)) { action: () => {
this.onClickChangeAttachment( this.onClose();
this.allAttachments[this.activeImageIndex - 1], },
this.activeImageIndex - 1 },
); ArrowLeft: {
} else if (hasPressedArrowRightKey(e)) { action: () => {
this.onClickChangeAttachment( this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex + 1], this.allAttachments[this.activeImageIndex - 1],
this.activeImageIndex + 1 this.activeImageIndex - 1
); );
} },
},
ArrowRight: {
action: () => {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex + 1],
this.activeImageIndex + 1
);
},
},
};
}, },
onClickDownload() { onClickDownload() {
const { file_type: type, data_url: url } = this.activeAttachment; const { file_type: type, data_url: url } = this.activeAttachment;

View File

@ -74,15 +74,10 @@
<script> <script>
import countries from 'shared/constants/countries.js'; import countries from 'shared/constants/countries.js';
import parsePhoneNumber from 'libphonenumber-js'; import parsePhoneNumber from 'libphonenumber-js';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import {
hasPressedArrowUpKey,
hasPressedArrowDownKey,
isEnter,
} from 'shared/helpers/KeyboardHelpers';
export default { export default {
mixins: [eventListenerMixins], mixins: [keyboardEventListenerMixins],
props: { props: {
value: { value: {
type: [String, Number], type: [String, Number],
@ -183,6 +178,7 @@ export default {
this.$emit('blur', e.target.value); this.$emit('blur', e.target.value);
}, },
dropdownItem() { dropdownItem() {
if (!this.showDropdown) return [];
return Array.from( return Array.from(
this.$refs.dropdown.querySelectorAll( this.$refs.dropdown.querySelectorAll(
'div.country-dropdown div.country-dropdown--item' 'div.country-dropdown div.country-dropdown--item'
@ -190,34 +186,27 @@ export default {
); );
}, },
focusedItem() { focusedItem() {
if (!this.showDropdown) return [];
return Array.from( return Array.from(
this.$refs.dropdown.querySelectorAll('div.country-dropdown div.focus') this.$refs.dropdown.querySelectorAll('div.country-dropdown div.focus')
); );
}, },
focusedItemIndex() { focusedItemIndex() {
if (!this.showDropdown) return -1;
return Array.from(this.dropdownItem()).indexOf(this.focusedItem()[0]); return Array.from(this.dropdownItem()).indexOf(this.focusedItem()[0]);
}, },
onKeyDownHandler(e) { moveUp() {
const { showDropdown, filteredCountriesBySearch, onSelectCountry } = this; if (!this.showDropdown) return;
const { selectedIndex } = this; this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
if (showDropdown) { },
if (hasPressedArrowDownKey(e)) { moveDown() {
e.preventDefault(); if (!this.showDropdown) return;
this.selectedIndex = Math.min( this.selectedIndex = Math.min(
selectedIndex + 1, this.selectedIndex + 1,
filteredCountriesBySearch.length - 1 this.filteredCountriesBySearch.length - 1
); );
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28; this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
} else if (hasPressedArrowUpKey(e)) {
e.preventDefault();
this.selectedIndex = Math.max(selectedIndex - 1, 0);
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
} else if (isEnter(e)) {
e.preventDefault();
onSelectCountry(filteredCountriesBySearch[selectedIndex]);
}
}
}, },
onSelectCountry(country) { onSelectCountry(country) {
this.activeCountryCode = country.id; this.activeCountryCode = country.id;
@ -235,6 +224,33 @@ export default {
this.activeDialCode = number.countryCallingCode; this.activeDialCode = number.countryCallingCode;
} }
}, },
getKeyboardEvents() {
return {
ArrowUp: {
action: e => {
e.preventDefault();
this.moveUp();
},
allowOnFocusedInput: true,
},
ArrowDown: {
action: e => {
e.preventDefault();
this.moveDown();
},
allowOnFocusedInput: true,
},
Enter: {
action: e => {
e.preventDefault();
this.onSelectCountry(
this.filteredCountriesBySearch[this.selectedIndex]
);
},
allowOnFocusedInput: true,
},
};
},
toggleCountryDropdown() { toggleCountryDropdown() {
this.showDropdown = !this.showDropdown; this.showDropdown = !this.showDropdown;
this.selectedIndex = -1; this.selectedIndex = -1;

View File

@ -84,9 +84,7 @@ export default {
}, },
}, },
methods: { methods: {
handleKeyboardEvent(e) { adjustScroll() {},
this.processKeyDownEvent(e);
},
onHover(index) { onHover(index) {
this.selectedIndex = index; this.selectedIndex = index;
}, },

View File

@ -1,12 +1,7 @@
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default { export default {
mounted() { mixins: [keyboardEventListenerMixins],
document.addEventListener('keydown', this.handleKeyboardEvent);
},
beforeDestroy() {
document.removeEventListener('keydown', this.handleKeyboardEvent);
},
methods: { methods: {
moveSelectionUp() { moveSelectionUp() {
if (!this.selectedIndex) { if (!this.selectedIndex) {
@ -14,6 +9,7 @@ export default {
} else { } else {
this.selectedIndex -= 1; this.selectedIndex -= 1;
} }
this.adjustScroll();
}, },
moveSelectionDown() { moveSelectionDown() {
if (this.selectedIndex === this.items.length - 1) { if (this.selectedIndex === this.items.length - 1) {
@ -21,19 +17,46 @@ export default {
} else { } else {
this.selectedIndex += 1; this.selectedIndex += 1;
} }
this.adjustScroll();
}, },
processKeyDownEvent(e) { getKeyboardEvents() {
const keyPattern = buildHotKeys(e); return {
if (['arrowup', 'ctrl+p'].includes(keyPattern)) { ArrowUp: {
this.moveSelectionUp(); action: e => {
e.preventDefault(); this.moveSelectionUp();
} else if (['arrowdown', 'ctrl+n'].includes(keyPattern)) { e.preventDefault();
this.moveSelectionDown(); },
e.preventDefault(); allowOnFocusedInput: true,
} else if (keyPattern === 'enter') { },
this.onSelect(); 'Control+KeyP': {
e.preventDefault(); action: e => {
} this.moveSelectionUp();
e.preventDefault();
},
allowOnFocusedInput: true,
},
ArrowDown: {
action: e => {
this.moveSelectionDown();
e.preventDefault();
},
allowOnFocusedInput: true,
},
'Control+KeyN': {
action: e => {
this.moveSelectionDown();
e.preventDefault();
},
allowOnFocusedInput: true,
},
Enter: {
action: e => {
this.onSelect();
e.preventDefault();
},
allowOnFocusedInput: true,
},
};
}, },
}, },
}; };

View File

@ -1,64 +1,69 @@
import mentionSelectionKeyboardMixin from '../mentionSelectionKeyboardMixin'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { shallowMount } from '@vue/test-utils'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
const localVue = createLocalVue();
const buildComponent = ({ data = {}, methods = {} }) => ({ const buildComponent = ({ data = {}, methods = {} }) => ({
render() {}, render() {},
data() { data() {
return data; return { ...data, selectedIndex: 0, items: [1, 2, 3] };
}, },
methods, methods: { ...methods, onSelect: jest.fn(), adjustScroll: jest.fn() },
mixins: [mentionSelectionKeyboardMixin], mixins: [keyboardEventListenerMixins],
}); });
describe('mentionSelectionKeyboardMixin', () => { describe('mentionSelectionKeyboardMixin', () => {
test('register listeners', () => { let wrapper;
jest.spyOn(document, 'addEventListener');
beforeEach(() => {
const Component = buildComponent({}); const Component = buildComponent({});
shallowMount(Component); wrapper = shallowMount(Component, { localVue });
// undefined expected as the method is not defined in the component
expect(document.addEventListener).toHaveBeenCalledWith(
'keydown',
undefined
);
}); });
test('processKeyDownEvent updates index on arrow up', () => { it('ArrowUp and Control+KeyP update selectedIndex correctly', () => {
const Component = buildComponent({ const preventDefault = jest.fn();
data: { selectedIndex: 0, items: [1, 2, 3] }, const keyboardEvents = wrapper.vm.getKeyboardEvents();
});
const wrapper = shallowMount(Component); if (keyboardEvents && keyboardEvents.ArrowUp) {
wrapper.vm.processKeyDownEvent({ keyboardEvents.ArrowUp.action({ preventDefault });
ctrlKey: true, expect(wrapper.vm.selectedIndex).toBe(2);
key: 'p', expect(preventDefault).toHaveBeenCalled();
preventDefault: jest.fn(), }
});
expect(wrapper.vm.selectedIndex).toBe(2); wrapper.setData({ selectedIndex: 1 });
if (keyboardEvents && keyboardEvents['Control+KeyP']) {
keyboardEvents['Control+KeyP'].action({ preventDefault });
expect(wrapper.vm.selectedIndex).toBe(0);
expect(preventDefault).toHaveBeenCalledTimes(2);
}
}); });
test('processKeyDownEvent updates index on arrow down', () => { it('ArrowDown and Control+KeyN update selectedIndex correctly', () => {
const Component = buildComponent({ const preventDefault = jest.fn();
data: { selectedIndex: 0, items: [1, 2, 3] }, const keyboardEvents = wrapper.vm.getKeyboardEvents();
});
const wrapper = shallowMount(Component); if (keyboardEvents && keyboardEvents.ArrowDown) {
wrapper.vm.processKeyDownEvent({ keyboardEvents.ArrowDown.action({ preventDefault });
key: 'ArrowDown', expect(wrapper.vm.selectedIndex).toBe(1);
preventDefault: jest.fn(), expect(preventDefault).toHaveBeenCalled();
}); }
expect(wrapper.vm.selectedIndex).toBe(1);
wrapper.setData({ selectedIndex: 1 });
if (keyboardEvents && keyboardEvents['Control+KeyN']) {
keyboardEvents['Control+KeyN'].action({ preventDefault });
expect(wrapper.vm.selectedIndex).toBe(2);
expect(preventDefault).toHaveBeenCalledTimes(2);
}
}); });
test('processKeyDownEvent calls select methods on Enter Key', () => { it('Enter key triggers onSelect method', () => {
const onSelectMockFn = jest.fn(); const preventDefault = jest.fn();
const Component = buildComponent({ const keyboardEvents = wrapper.vm.getKeyboardEvents();
data: { selectedIndex: 0, items: [1, 2, 3] },
methods: { onSelect: () => onSelectMockFn('enterKey pressed') }, if (keyboardEvents && keyboardEvents.Enter) {
}); keyboardEvents.Enter.action({ preventDefault });
const wrapper = shallowMount(Component); expect(wrapper.vm.onSelect).toHaveBeenCalled();
wrapper.vm.processKeyDownEvent({ expect(preventDefault).toHaveBeenCalled();
key: 'Enter', }
preventDefault: jest.fn(),
});
expect(onSelectMockFn).toHaveBeenCalledWith('enterKey pressed');
wrapper.vm.onSelect();
}); });
}); });

View File

@ -53,12 +53,6 @@ export const SHORTCUT_KEYS = [
firstKey: 'Alt / ⌥', firstKey: 'Alt / ⌥',
secondKey: 'S', secondKey: 'S',
}, },
{
id: 10,
label: 'SWITCH_CONVERSATION_STATUS',
firstKey: 'Alt / ⌥',
secondKey: 'B',
},
{ {
id: 11, id: 11,
label: 'SWITCH_TO_PRIVATE_NOTE', label: 'SWITCH_TO_PRIVATE_NOTE',

View File

@ -316,7 +316,6 @@
"GO_TO_REPORTS_SIDEBAR": "Go to Reports sidebar", "GO_TO_REPORTS_SIDEBAR": "Go to Reports sidebar",
"MOVE_TO_NEXT_TAB": "Move to next tab in conversation list", "MOVE_TO_NEXT_TAB": "Move to next tab in conversation list",
"GO_TO_SETTINGS": "Go to Settings", "GO_TO_SETTINGS": "Go to Settings",
"SWITCH_CONVERSATION_STATUS": "Switch to the next conversation status",
"SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note", "SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note",
"SWITCH_TO_REPLY": "Switch to Reply", "SWITCH_TO_REPLY": "Switch to Reply",
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown" "TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"

View File

@ -23,12 +23,12 @@
<script> <script>
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import { hasPressedCommandAndEnter } from 'shared/helpers/KeyboardHelpers'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default { export default {
components: { components: {
WootMessageEditor, WootMessageEditor,
}, },
mixins: [keyboardEventListenerMixins],
data() { data() {
return { return {
noteContent: '', noteContent: '',
@ -40,21 +40,14 @@ export default {
return this.noteContent === ''; return this.noteContent === '';
}, },
}, },
mounted() {
document.addEventListener('keydown', this.onMetaEnter);
},
beforeDestroy() {
document.removeEventListener('keydown', this.onMetaEnter);
},
methods: { methods: {
onMetaEnter(e) { getKeyboardEvents() {
if (hasPressedCommandAndEnter(e)) { return {
e.preventDefault(); '$mod+Enter': {
this.onAdd(); action: () => this.onAdd(),
} allowOnFocusedInput: true,
},
};
}, },
onAdd() { onAdd() {
if (this.noteContent !== '') { if (this.noteContent !== '') {

View File

@ -50,13 +50,8 @@ import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue'; import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import adminMixin from 'dashboard/mixins/isAdmin'; import adminMixin from 'dashboard/mixins/isAdmin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin'; import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
import {
buildHotKeys,
isEscape,
isActiveElementTypeable,
} from 'shared/helpers/KeyboardHelpers';
export default { export default {
components: { components: {
@ -65,7 +60,12 @@ export default {
AddLabel, AddLabel,
}, },
mixins: [clickaway, conversationLabelMixin, adminMixin, eventListenerMixins], mixins: [
clickaway,
conversationLabelMixin,
adminMixin,
keyboardEventListenerMixins,
],
props: { props: {
conversationId: { conversationId: {
type: Number, type: Number,
@ -93,16 +93,23 @@ export default {
closeDropdownLabel() { closeDropdownLabel() {
this.showSearchDropdownLabel = false; this.showSearchDropdownLabel = false;
}, },
handleKeyEvents(e) { getKeyboardEvents() {
const keyPattern = buildHotKeys(e); return {
KeyL: {
if (keyPattern === 'l' && !isActiveElementTypeable(e)) { action: e => {
this.toggleLabels(); e.preventDefault();
e.preventDefault(); this.toggleLabels();
} else if (isEscape(e) && this.showSearchDropdownLabel) { },
this.closeDropdownLabel(); },
e.preventDefault(); Escape: {
} action: () => {
if (this.showSearchDropdownLabel) {
this.toggleLabels();
}
},
allowOnFocusedInput: true,
},
};
}, },
}, },
}; };

View File

@ -34,15 +34,11 @@
</template> </template>
<script> <script>
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import {
buildHotKeys,
isActiveElementTypeable,
} from 'shared/helpers/KeyboardHelpers';
export default { export default {
name: 'ChatwootSearch', name: 'ChatwootSearch',
mixins: [eventListenerMixins], mixins: [keyboardEventListenerMixins],
props: { props: {
title: { title: {
type: String, type: String,
@ -71,13 +67,15 @@ export default {
onBlur() { onBlur() {
this.isInputFocused = false; this.isInputFocused = false;
}, },
handleKeyEvents(e) { getKeyboardEvents() {
const keyPattern = buildHotKeys(e); return {
Slash: {
if (keyPattern === '/' && !isActiveElementTypeable(e)) { action: e => {
e.preventDefault(); e.preventDefault();
this.$refs.searchInput.focus(); this.$refs.searchInput.focus();
} },
},
};
}, },
}, },
}; };

View File

@ -35,10 +35,7 @@
<script> <script>
import { debounce } from '@chatwoot/utils'; import { debounce } from '@chatwoot/utils';
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import { import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
isEscape,
isActiveElementTypeable,
} from 'shared/helpers/KeyboardHelpers';
import SearchHeader from './Header.vue'; import SearchHeader from './Header.vue';
import SearchResults from './SearchResults.vue'; import SearchResults from './SearchResults.vue';
@ -55,7 +52,7 @@ export default {
SearchResults, SearchResults,
ArticleView, ArticleView,
}, },
mixins: [clickaway, portalMixin, alertMixin], mixins: [clickaway, portalMixin, alertMixin, keyboardEventListenerMixins],
props: { props: {
selectedPortalSlug: { selectedPortalSlug: {
type: String, type: String,
@ -97,10 +94,6 @@ export default {
mounted() { mounted() {
this.fetchArticlesByQuery(this.searchQuery); this.fetchArticlesByQuery(this.searchQuery);
this.debounceSearch = debounce(this.fetchArticlesByQuery, 500, false); this.debounceSearch = debounce(this.fetchArticlesByQuery, 500, false);
document.body.addEventListener('keydown', this.closeOnEsc);
},
beforeDestroy() {
document.body.removeEventListener('keydown', this.closeOnEsc);
}, },
methods: { methods: {
generateArticleUrl(article) { generateArticleUrl(article) {
@ -158,11 +151,15 @@ export default {
); );
this.onClose(); this.onClose();
}, },
closeOnEsc(e) { getKeyboardEvents() {
if (isEscape(e) && !isActiveElementTypeable(e)) { return {
e.preventDefault(); Escape: {
this.onClose(); action: () => {
} this.onClose();
},
allowOnFocusedInput: true,
},
};
}, },
}, },
}; };

View File

@ -109,9 +109,10 @@ export default {
generateArticleUrl(article) { generateArticleUrl(article) {
return `/hc/${article.portal.slug}/articles/${article.slug}`; return `/hc/${article.portal.slug}/articles/${article.slug}`;
}, },
handleKeyboardEvent(e) { adjustScroll() {
this.processKeyDownEvent(e); this.$nextTick(() => {
this.$el.scrollTop = 102 * this.selectedIndex; this.$el.scrollTop = 102 * this.selectedIndex;
});
}, },
prepareContent(content) { prepareContent(content) {
return this.highlightContent( return this.highlightContent(

View File

@ -8,16 +8,12 @@
</ul> </ul>
</template> </template>
<script> <script>
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import {
hasPressedArrowUpKey,
hasPressedArrowDownKey,
} from 'shared/helpers/KeyboardHelpers';
export default { export default {
name: 'WootDropdownMenu', name: 'WootDropdownMenu',
componentName: 'WootDropdownMenu', componentName: 'WootDropdownMenu',
mixins: [eventListenerMixins], mixins: [keyboardEventListenerMixins],
props: { props: {
placement: { placement: {
@ -31,38 +27,46 @@ export default {
'ul.dropdown li.dropdown-menu__item .button' 'ul.dropdown li.dropdown-menu__item .button'
); );
}, },
activeElementIndex() { getActiveButtonIndex(menuButtons) {
const menuButtons = this.dropdownMenuButtons();
const focusedButton = this.$refs.dropdownMenu.querySelector( const focusedButton = this.$refs.dropdownMenu.querySelector(
'ul.dropdown li.dropdown-menu__item .button:focus' 'ul.dropdown li.dropdown-menu__item .button:focus'
); );
const activeIndex = [...menuButtons].indexOf(focusedButton); return Array.from(menuButtons).indexOf(focusedButton);
return activeIndex;
}, },
handleKeyEvents(e) { getKeyboardEvents() {
const menuButtons = this.dropdownMenuButtons(); const menuButtons = this.dropdownMenuButtons();
const lastElementIndex = menuButtons.length - 1; return {
ArrowUp: {
action: e => {
e.preventDefault();
this.focusPreviousButton(menuButtons);
},
allowOnFocusedInput: true,
},
ArrowDown: {
action: e => {
e.preventDefault();
this.focusNextButton(menuButtons);
},
allowOnFocusedInput: true,
},
};
},
focusPreviousButton(menuButtons) {
const activeIndex = this.getActiveButtonIndex(menuButtons);
const newIndex =
activeIndex >= 1 ? activeIndex - 1 : menuButtons.length - 1;
this.focusButton(menuButtons, newIndex);
},
focusNextButton(menuButtons) {
const activeIndex = this.getActiveButtonIndex(menuButtons);
const newIndex =
activeIndex === menuButtons.length - 1 ? 0 : activeIndex + 1;
this.focusButton(menuButtons, newIndex);
},
focusButton(menuButtons, newIndex) {
if (menuButtons.length === 0) return; if (menuButtons.length === 0) return;
menuButtons[newIndex].focus();
if (hasPressedArrowUpKey(e)) {
const activeIndex = this.activeElementIndex();
if (activeIndex >= 1) {
menuButtons[activeIndex - 1].focus();
} else {
menuButtons[lastElementIndex].focus();
}
}
if (hasPressedArrowDownKey(e)) {
const activeIndex = this.activeElementIndex();
if (activeIndex === lastElementIndex) {
menuButtons[0].focus();
} else {
menuButtons[activeIndex + 1].focus();
}
}
}, },
}, },
}; };

View File

@ -1,9 +1,9 @@
export const isEnter = e => { export const isEnter = e => {
return e.keyCode === 13; return e.key === 'Enter';
}; };
export const isEscape = e => { export const isEscape = e => {
return e.keyCode === 27; return e.key === 'Escape';
}; };
export const hasPressedShift = e => { export const hasPressedShift = e => {
@ -19,118 +19,7 @@ export const hasPressedEnterAndNotCmdOrShift = e => {
}; };
export const hasPressedCommandAndEnter = e => { export const hasPressedCommandAndEnter = e => {
return e.metaKey && e.keyCode === 13; return hasPressedCommand(e) && isEnter(e);
};
export const hasPressedCommandAndForwardSlash = e => {
return e.metaKey && e.keyCode === 191;
};
export const hasPressedAltAndCKey = e => {
return e.altKey && e.keyCode === 67;
};
export const hasPressedAltAndVKey = e => {
return e.altKey && e.keyCode === 86;
};
export const hasPressedAltAndRKey = e => {
return e.altKey && e.keyCode === 82;
};
export const hasPressedAltAndSKey = e => {
return e.altKey && e.keyCode === 83;
};
export const hasPressedAltAndBKey = e => {
return e.altKey && e.keyCode === 66;
};
export const hasPressedAltAndNKey = e => {
return e.altKey && e.keyCode === 78;
};
export const hasPressedAltAndAKey = e => {
return e.altKey && e.keyCode === 65;
};
export const hasPressedAltAndPKey = e => {
return e.altKey && e.keyCode === 80;
};
export const hasPressedAltAndLKey = e => {
return e.altKey && e.keyCode === 76;
};
export const hasPressedAltAndEKey = e => {
return e.altKey && e.keyCode === 69;
};
export const hasPressedCommandPlusAltAndEKey = e => {
return e.metaKey && e.altKey && e.keyCode === 69;
};
export const hasPressedAltAndOKey = e => {
return e.altKey && e.keyCode === 79;
};
export const hasPressedAltAndJKey = e => {
return e.altKey && e.keyCode === 74;
};
export const hasPressedAltAndKKey = e => {
return e.altKey && e.keyCode === 75;
};
export const hasPressedAltAndMKey = e => {
return e.altKey && e.keyCode === 77;
};
export const hasPressedArrowUpKey = e => {
return e.keyCode === 38;
};
export const hasPressedArrowDownKey = e => {
return e.keyCode === 40;
};
export const hasPressedArrowLeftKey = e => {
return e.keyCode === 37;
};
export const hasPressedArrowRightKey = e => {
return e.keyCode === 39;
};
export const hasPressedCommandPlusKKey = e => {
return e.metaKey && e.keyCode === 75;
};
/**
* Returns a string representation of the hotkey pattern based on the provided event object.
* @param {KeyboardEvent} e - The keyboard event object.
* @returns {string} - The hotkey pattern string.
*/
export const buildHotKeys = e => {
const key = e.key.toLowerCase();
if (['shift', 'meta', 'alt', 'control'].includes(key)) {
return key;
}
let hotKeyPattern = '';
if (e.altKey) {
hotKeyPattern += 'alt+';
}
if (e.ctrlKey) {
hotKeyPattern += 'ctrl+';
}
if (e.metaKey && !e.ctrlKey) {
hotKeyPattern += 'meta+';
}
if (e.shiftKey) {
hotKeyPattern += 'shift+';
}
hotKeyPattern += key;
return hotKeyPattern;
}; };
/** /**

View File

@ -3,20 +3,19 @@ import {
isEscape, isEscape,
hasPressedShift, hasPressedShift,
hasPressedCommand, hasPressedCommand,
buildHotKeys,
isActiveElementTypeable, isActiveElementTypeable,
} from '../KeyboardHelpers'; } from '../KeyboardHelpers';
describe('#KeyboardHelpers', () => { describe('#KeyboardHelpers', () => {
describe('#isEnter', () => { describe('#isEnter', () => {
it('return correct values', () => { it('return correct values', () => {
expect(isEnter({ keyCode: 13 })).toEqual(true); expect(isEnter({ key: 'Enter' })).toEqual(true);
}); });
}); });
describe('#isEscape', () => { describe('#isEscape', () => {
it('return correct values', () => { it('return correct values', () => {
expect(isEscape({ keyCode: 27 })).toEqual(true); expect(isEscape({ key: 'Escape' })).toEqual(true);
}); });
}); });
@ -31,14 +30,6 @@ describe('#KeyboardHelpers', () => {
expect(hasPressedCommand({ metaKey: true })).toEqual(true); expect(hasPressedCommand({ metaKey: true })).toEqual(true);
}); });
}); });
describe('#buildHotKeys', () => {
it('returns hot keys', () => {
expect(buildHotKeys({ altKey: true, key: 'alt' })).toEqual('alt');
expect(buildHotKeys({ ctrlKey: true, key: 'a' })).toEqual('ctrl+a');
expect(buildHotKeys({ metaKey: true, key: 'b' })).toEqual('meta+b');
});
});
}); });
describe('isActiveElementTypeable', () => { describe('isActiveElementTypeable', () => {

View File

@ -1,24 +0,0 @@
import { isActiveElementTypeable, isEscape } from '../helpers/KeyboardHelpers';
export default {
mounted() {
document.addEventListener('keydown', this.onKeyDownHandler);
},
beforeDestroy() {
document.removeEventListener('keydown', this.onKeyDownHandler);
},
methods: {
onKeyDownHandler(e) {
const isTypeable = isActiveElementTypeable(e);
if (isTypeable) {
if (isEscape(e)) {
e.target.blur();
}
return;
}
this.handleKeyEvents(e);
},
},
};

View File

@ -0,0 +1,63 @@
import { isActiveElementTypeable, isEscape } from '../helpers/KeyboardHelpers';
import { createKeybindingsHandler } from 'tinykeys';
// this is a store that stores the handler globally, and only gets reset on reload
const taggedHandlers = [];
export default {
mounted() {
const events = this.getKeyboardEvents();
if (events) {
const wrappedEvents = this.wrapEventsInKeybindingsHandler(events);
const keydownHandler = createKeybindingsHandler(wrappedEvents);
this.appendToHandler(keydownHandler);
document.addEventListener('keydown', keydownHandler);
}
},
beforeDestroy() {
if (this.$el && this.$el.dataset.keydownHandlerIndex) {
const handlerToRemove =
taggedHandlers[this.$el.dataset.keydownHandlerIndex];
document.removeEventListener('keydown', handlerToRemove);
}
},
methods: {
appendToHandler(keydownHandler) {
const indexToAppend = taggedHandlers.push(keydownHandler) - 1;
const root = this.$el;
root.dataset.keydownHandlerIndex = indexToAppend;
},
getKeyboardEvents() {
return null;
},
wrapEventsInKeybindingsHandler(events) {
const wrappedEvents = {};
Object.keys(events).forEach(eventName => {
wrappedEvents[eventName] = this.keydownWrapper(events[eventName]);
});
return wrappedEvents;
},
keydownWrapper(handler) {
return e => {
const actionToPerform =
typeof handler === 'function' ? handler : handler.action;
const allowOnFocusedInput =
typeof handler === 'function' ? false : handler.allowOnFocusedInput;
const isTypeable = isActiveElementTypeable(e);
if (isTypeable) {
if (isEscape(e)) {
e.target.blur();
}
if (!allowOnFocusedInput) return;
}
actionToPerform(e);
};
},
},
};

View File

@ -33,8 +33,8 @@
</template> </template>
<script> <script>
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin'; import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import Header from '../../components/playground/Header.vue'; import Header from '../../components/playground/Header.vue';
import UserMessage from '../../components/playground/UserMessage.vue'; import UserMessage from '../../components/playground/UserMessage.vue';
import BotMessage from '../../components/playground/BotMessage.vue'; import BotMessage from '../../components/playground/BotMessage.vue';
@ -47,7 +47,7 @@ export default {
BotMessage, BotMessage,
TypingIndicator, TypingIndicator,
}, },
mixins: [messageFormatterMixin], mixins: [messageFormatterMixin, keyboardEventListenerMixins],
props: { props: {
componentData: { componentData: {
type: Object, type: Object,
@ -67,17 +67,17 @@ export default {
}, },
mounted() { mounted() {
this.focusInput(); this.focusInput();
document.addEventListener('keydown', this.handleKeyEvents);
},
beforeDestroy() {
document.removeEventListener('keydown', this.handleKeyEvents);
}, },
methods: { methods: {
handleKeyEvents(e) { getKeyboardEvents() {
const keyCode = buildHotKeys(e); return {
if (['meta+enter', 'ctrl+enter'].includes(keyCode)) { '$mod+Enter': {
this.onMessageSend(); action: () => {
} this.onMessageSend();
},
allowOnFocusedInput: true,
},
};
}, },
focusInput() { focusInput() {
this.$refs.messageInput.focus(); this.$refs.messageInput.focus();

View File

@ -220,25 +220,26 @@ export default {
}, },
dropdownItem() { dropdownItem() {
// This function is used to get all the items in the dropdown. // This function is used to get all the items in the dropdown.
if (!this.showDropdown) return [];
return Array.from( return Array.from(
this.$refs.dropdown.querySelectorAll( this.$refs.dropdown?.querySelectorAll(
'div.country-dropdown div.country-dropdown--item' 'div.country-dropdown div.country-dropdown--item'
) )
); );
}, },
focusedOrActiveItem(className) { focusedOrActiveItem(className) {
// This function is used to get the focused or active item in the dropdown. // This function is used to get the focused or active item in the dropdown.
if (!this.showDropdown) return [];
return Array.from( return Array.from(
this.$refs.dropdown.querySelectorAll( this.$refs.dropdown?.querySelectorAll(
`div.country-dropdown div.country-dropdown--item.${className}` `div.country-dropdown div.country-dropdown--item.${className}`
) )
); );
}, },
handleKeyboardEvent(e) { adjustScroll() {
if (this.showDropdown) { this.$nextTick(() => {
this.processKeyDownEvent(e);
this.scrollToFocusedOrActiveItem(this.focusedOrActiveItem('focus')); this.scrollToFocusedOrActiveItem(this.focusedOrActiveItem('focus'));
} });
}, },
onSelect() { onSelect() {
this.onSelectCountry(this.items[this.selectedIndex]); this.onSelectCountry(this.items[this.selectedIndex]);

View File

@ -70,6 +70,7 @@
"postcss-loader": "^4.2.0", "postcss-loader": "^4.2.0",
"semver": "7.5.3", "semver": "7.5.3",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"tinykeys": "^2.1.0",
"turbolinks": "^5.2.0", "turbolinks": "^5.2.0",
"url-loader": "^2.0.0", "url-loader": "^2.0.0",
"urlpattern-polyfill": "^6.0.2", "urlpattern-polyfill": "^6.0.2",

View File

@ -19615,6 +19615,11 @@ tinycolor2@^1.1.2:
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803" resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803"
integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA== integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA==
tinykeys@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/tinykeys/-/tinykeys-2.1.0.tgz#1341563e92a7fac9ca90053fddaf2b7553500298"
integrity sha512-/MESnqBD1xItZJn5oGQ4OsNORQgJfPP96XSGoyu4eLpwpL0ifO0SYR5OD76u0YMhMXsqkb0UqvI9+yXTh4xv8Q==
titleize@^3.0.0: titleize@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/titleize/-/titleize-3.0.0.tgz#71c12eb7fdd2558aa8a44b0be83b8a76694acd53" resolved "https://registry.yarnpkg.com/titleize/-/titleize-3.0.0.tgz#71c12eb7fdd2558aa8a44b0be83b8a76694acd53"