mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-03 12:37:56 +00:00
feat: Rewrite keyboardEventListener mixin to a composable (#9831)
This commit is contained in:
@@ -1,14 +1,15 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { ref } from 'vue';
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
import VirtualList from 'vue-virtual-scroll-list';
|
import VirtualList from 'vue-virtual-scroll-list';
|
||||||
|
|
||||||
import ChatListHeader from './ChatListHeader.vue';
|
import ChatListHeader from './ChatListHeader.vue';
|
||||||
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
|
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
|
||||||
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||||
import ConversationItem from './ConversationItem.vue';
|
import ConversationItem from './ConversationItem.vue';
|
||||||
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';
|
||||||
@@ -41,7 +42,7 @@ export default {
|
|||||||
IntersectionObserver,
|
IntersectionObserver,
|
||||||
VirtualList,
|
VirtualList,
|
||||||
},
|
},
|
||||||
mixins: [conversationMixin, keyboardEventListenerMixins, filterMixin],
|
mixins: [conversationMixin, filterMixin],
|
||||||
provide() {
|
provide() {
|
||||||
return {
|
return {
|
||||||
// Actions to be performed on virtual list item and context menu.
|
// Actions to be performed on virtual list item and context menu.
|
||||||
@@ -89,8 +90,63 @@ export default {
|
|||||||
setup() {
|
setup() {
|
||||||
const { uiSettings } = useUISettings();
|
const { uiSettings } = useUISettings();
|
||||||
|
|
||||||
|
const conversationListRef = ref(null);
|
||||||
|
|
||||||
|
const getKeyboardListenerParams = () => {
|
||||||
|
const allConversations = conversationListRef.value.querySelectorAll(
|
||||||
|
'div.conversations-list div.conversation'
|
||||||
|
);
|
||||||
|
const activeConversation = conversationListRef.value.querySelector(
|
||||||
|
'div.conversations-list div.conversation.active'
|
||||||
|
);
|
||||||
|
const activeConversationIndex = [...allConversations].indexOf(
|
||||||
|
activeConversation
|
||||||
|
);
|
||||||
|
const lastConversationIndex = allConversations.length - 1;
|
||||||
|
return {
|
||||||
|
allConversations,
|
||||||
|
activeConversation,
|
||||||
|
activeConversationIndex,
|
||||||
|
lastConversationIndex,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const handlePreviousConversation = () => {
|
||||||
|
const { allConversations, activeConversationIndex } =
|
||||||
|
getKeyboardListenerParams();
|
||||||
|
if (activeConversationIndex === -1) {
|
||||||
|
allConversations[0].click();
|
||||||
|
}
|
||||||
|
if (activeConversationIndex >= 1) {
|
||||||
|
allConversations[activeConversationIndex - 1].click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleNextConversation = () => {
|
||||||
|
const {
|
||||||
|
allConversations,
|
||||||
|
activeConversationIndex,
|
||||||
|
lastConversationIndex,
|
||||||
|
} = getKeyboardListenerParams();
|
||||||
|
if (activeConversationIndex === -1) {
|
||||||
|
allConversations[lastConversationIndex].click();
|
||||||
|
} else if (activeConversationIndex < lastConversationIndex) {
|
||||||
|
allConversations[activeConversationIndex + 1].click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const keyboardEvents = {
|
||||||
|
'Alt+KeyJ': {
|
||||||
|
action: () => handlePreviousConversation(),
|
||||||
|
allowOnFocusedInput: true,
|
||||||
|
},
|
||||||
|
'Alt+KeyK': {
|
||||||
|
action: () => handleNextConversation(),
|
||||||
|
allowOnFocusedInput: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
useKeyboardEvents(keyboardEvents, conversationListRef);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uiSettings,
|
uiSettings,
|
||||||
|
conversationListRef,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -113,7 +169,7 @@ export default {
|
|||||||
isContextMenuOpen: false,
|
isContextMenuOpen: false,
|
||||||
appliedFilter: [],
|
appliedFilter: [],
|
||||||
infiniteLoaderOptions: {
|
infiniteLoaderOptions: {
|
||||||
root: this.$refs.conversationList,
|
root: this.$refs.conversationListRef,
|
||||||
rootMargin: '100px 0px 100px 0px',
|
rootMargin: '100px 0px 100px 0px',
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -490,58 +546,6 @@ export default {
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
getKeyboardListenerParams() {
|
|
||||||
const allConversations = this.$refs.conversationList.querySelectorAll(
|
|
||||||
'div.conversations-list div.conversation'
|
|
||||||
);
|
|
||||||
const activeConversation = this.$refs.conversationList.querySelector(
|
|
||||||
'div.conversations-list div.conversation.active'
|
|
||||||
);
|
|
||||||
const activeConversationIndex = [...allConversations].indexOf(
|
|
||||||
activeConversation
|
|
||||||
);
|
|
||||||
const lastConversationIndex = allConversations.length - 1;
|
|
||||||
return {
|
|
||||||
allConversations,
|
|
||||||
activeConversation,
|
|
||||||
activeConversationIndex,
|
|
||||||
lastConversationIndex,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
handlePreviousConversation() {
|
|
||||||
const { allConversations, activeConversationIndex } =
|
|
||||||
this.getKeyboardListenerParams();
|
|
||||||
if (activeConversationIndex === -1) {
|
|
||||||
allConversations[0].click();
|
|
||||||
}
|
|
||||||
if (activeConversationIndex >= 1) {
|
|
||||||
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();
|
||||||
@@ -927,7 +931,7 @@ export default {
|
|||||||
@assignTeam="onAssignTeamsForBulk"
|
@assignTeam="onAssignTeamsForBulk"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
ref="conversationList"
|
ref="conversationListRef"
|
||||||
class="flex-1 conversations-list"
|
class="flex-1 conversations-list"
|
||||||
:class="{ 'overflow-hidden': isContextMenuOpen }"
|
:class="{ 'overflow-hidden': isContextMenuOpen }"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { ref } from 'vue';
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { getSidebarItems } from './config/default-sidebar';
|
import { getSidebarItems } from './config/default-sidebar';
|
||||||
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
import { useRoute, useRouter } from 'dashboard/composables/route';
|
||||||
|
|
||||||
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 keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
import { routesWithPermissions } from '../../routes';
|
||||||
import router, { routesWithPermissions } from '../../routes';
|
|
||||||
import { hasPermissions } from '../../helper/permissionsHelper';
|
import { hasPermissions } from '../../helper/permissionsHelper';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -13,7 +15,6 @@ export default {
|
|||||||
PrimarySidebar,
|
PrimarySidebar,
|
||||||
SecondarySidebar,
|
SecondarySidebar,
|
||||||
},
|
},
|
||||||
mixins: [keyboardEventListenerMixins],
|
|
||||||
props: {
|
props: {
|
||||||
showSecondarySidebar: {
|
showSecondarySidebar: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -24,6 +25,52 @@ export default {
|
|||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const sidebarRef = ref(null);
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const toggleKeyShortcutModal = () => {
|
||||||
|
emit('openKeyShortcutModal');
|
||||||
|
};
|
||||||
|
const closeKeyShortcutModal = () => {
|
||||||
|
emit('closeKeyShortcutModal');
|
||||||
|
};
|
||||||
|
const isCurrentRouteSameAsNavigation = routeName => {
|
||||||
|
return route.name === routeName;
|
||||||
|
};
|
||||||
|
const navigateToRoute = routeName => {
|
||||||
|
if (!isCurrentRouteSameAsNavigation(routeName)) {
|
||||||
|
router.push({ name: routeName });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const keyboardEvents = {
|
||||||
|
'$mod+Slash': {
|
||||||
|
action: toggleKeyShortcutModal,
|
||||||
|
},
|
||||||
|
'$mod+Escape': {
|
||||||
|
action: closeKeyShortcutModal,
|
||||||
|
},
|
||||||
|
'Alt+KeyC': {
|
||||||
|
action: () => navigateToRoute('home'),
|
||||||
|
},
|
||||||
|
'Alt+KeyV': {
|
||||||
|
action: () => navigateToRoute('contacts_dashboard'),
|
||||||
|
},
|
||||||
|
'Alt+KeyR': {
|
||||||
|
action: () => navigateToRoute('account_overview_reports'),
|
||||||
|
},
|
||||||
|
'Alt+KeyS': {
|
||||||
|
action: () => navigateToRoute('agent_list'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
useKeyboardEvents(keyboardEvents, sidebarRef);
|
||||||
|
|
||||||
|
return {
|
||||||
|
toggleKeyShortcutModal,
|
||||||
|
sidebarRef,
|
||||||
|
};
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showOptionsMenu: false,
|
showOptionsMenu: false,
|
||||||
@@ -131,38 +178,6 @@ export default {
|
|||||||
this.$store.dispatch('customViews/get', this.activeCustomView);
|
this.$store.dispatch('customViews/get', this.activeCustomView);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
toggleKeyShortcutModal() {
|
|
||||||
this.$emit('openKeyShortcutModal');
|
|
||||||
},
|
|
||||||
closeKeyShortcutModal() {
|
|
||||||
this.$emit('closeKeyShortcutModal');
|
|
||||||
},
|
|
||||||
getKeyboardEvents() {
|
|
||||||
return {
|
|
||||||
'$mod+Slash': this.toggleKeyShortcutModal,
|
|
||||||
'$mod+Escape': this.closeKeyShortcutModal,
|
|
||||||
'Alt+KeyC': {
|
|
||||||
action: () => this.navigateToRoute('home'),
|
|
||||||
},
|
|
||||||
'Alt+KeyV': {
|
|
||||||
action: () => this.navigateToRoute('contacts_dashboard'),
|
|
||||||
},
|
|
||||||
'Alt+KeyR': {
|
|
||||||
action: () => this.navigateToRoute('account_overview_reports'),
|
|
||||||
},
|
|
||||||
'Alt+KeyS': {
|
|
||||||
action: () => this.navigateToRoute('agent_list'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
navigateToRoute(routeName) {
|
|
||||||
if (!this.isCurrentRouteSameAsNavigation(routeName)) {
|
|
||||||
router.push({ name: routeName });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isCurrentRouteSameAsNavigation(routeName) {
|
|
||||||
return this.$route.name === routeName;
|
|
||||||
},
|
|
||||||
toggleSupportChatWindow() {
|
toggleSupportChatWindow() {
|
||||||
window.$chatwoot.toggle();
|
window.$chatwoot.toggle();
|
||||||
},
|
},
|
||||||
@@ -180,7 +195,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class="flex h-full">
|
<aside ref="sidebarRef" class="flex h-full">
|
||||||
<PrimarySidebar
|
<PrimarySidebar
|
||||||
:logo-source="globalConfig.logoThumbnail"
|
:logo-source="globalConfig.logoThumbnail"
|
||||||
:installation-name="globalConfig.installationName"
|
:installation-name="globalConfig.installationName"
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
|
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
|
||||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ReplyTopPanel',
|
name: 'ReplyTopPanel',
|
||||||
mixins: [keyboardEventListenerMixins],
|
|
||||||
props: {
|
props: {
|
||||||
mode: {
|
mode: {
|
||||||
type: String,
|
type: String,
|
||||||
default: REPLY_EDITOR_MODES.REPLY,
|
default: REPLY_EDITOR_MODES.REPLY,
|
||||||
},
|
},
|
||||||
setReplyMode: {
|
|
||||||
type: Function,
|
|
||||||
default: () => {},
|
|
||||||
},
|
|
||||||
isMessageLengthReachingThreshold: {
|
isMessageLengthReachingThreshold: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: () => false,
|
default: () => false,
|
||||||
@@ -26,6 +22,36 @@ export default {
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const replyTopRef = ref(null);
|
||||||
|
|
||||||
|
const setReplyMode = mode => {
|
||||||
|
emit('setReplyMode', mode);
|
||||||
|
};
|
||||||
|
const handleReplyClick = () => {
|
||||||
|
setReplyMode(REPLY_EDITOR_MODES.REPLY);
|
||||||
|
};
|
||||||
|
const handleNoteClick = () => {
|
||||||
|
setReplyMode(REPLY_EDITOR_MODES.NOTE);
|
||||||
|
};
|
||||||
|
const keyboardEvents = {
|
||||||
|
'Alt+KeyP': {
|
||||||
|
action: () => handleNoteClick(),
|
||||||
|
allowOnFocusedInput: true,
|
||||||
|
},
|
||||||
|
'Alt+KeyL': {
|
||||||
|
action: () => handleReplyClick(),
|
||||||
|
allowOnFocusedInput: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
useKeyboardEvents(keyboardEvents, replyTopRef);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleReplyClick,
|
||||||
|
handleNoteClick,
|
||||||
|
replyTopRef,
|
||||||
|
};
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
replyButtonClass() {
|
replyButtonClass() {
|
||||||
return {
|
return {
|
||||||
@@ -46,31 +72,14 @@ export default {
|
|||||||
: `${this.charactersRemaining} ${CHAR_LENGTH_WARNING.UNDER_50}`;
|
: `${this.charactersRemaining} ${CHAR_LENGTH_WARNING.UNDER_50}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
|
||||||
getKeyboardEvents() {
|
|
||||||
return {
|
|
||||||
'Alt+KeyP': {
|
|
||||||
action: () => this.handleNoteClick(),
|
|
||||||
allowOnFocusedInput: true,
|
|
||||||
},
|
|
||||||
'Alt+KeyL': {
|
|
||||||
action: () => this.handleReplyClick(),
|
|
||||||
allowOnFocusedInput: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
handleReplyClick() {
|
|
||||||
this.setReplyMode(REPLY_EDITOR_MODES.REPLY);
|
|
||||||
},
|
|
||||||
handleNoteClick() {
|
|
||||||
this.setReplyMode(REPLY_EDITOR_MODES.NOTE);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex justify-between bg-black-50 dark:bg-slate-800">
|
<div
|
||||||
|
ref="replyTopRef"
|
||||||
|
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"
|
||||||
|
|||||||
@@ -1091,10 +1091,10 @@ export default {
|
|||||||
/>
|
/>
|
||||||
<ReplyTopPanel
|
<ReplyTopPanel
|
||||||
:mode="replyType"
|
:mode="replyType"
|
||||||
:set-reply-mode="setReplyMode"
|
|
||||||
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
||||||
:characters-remaining="charactersRemaining"
|
:characters-remaining="charactersRemaining"
|
||||||
:popout-reply-box="popoutReplyBox"
|
:popout-reply-box="popoutReplyBox"
|
||||||
|
@setReplyMode="setReplyMode"
|
||||||
@click="$emit('click')"
|
@click="$emit('click')"
|
||||||
/>
|
/>
|
||||||
<ArticleSearchPopover
|
<ArticleSearchPopover
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
|
||||||
|
import {
|
||||||
|
LAYOUT_QWERTY,
|
||||||
|
LAYOUT_QWERTZ,
|
||||||
|
LAYOUT_AZERTY,
|
||||||
|
} from 'shared/helpers/KeyboardHelpers';
|
||||||
|
|
||||||
|
describe('useDetectKeyboardLayout', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.cw_keyboard_layout = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns cached layout if available', async () => {
|
||||||
|
window.cw_keyboard_layout = LAYOUT_QWERTY;
|
||||||
|
const layout = await useDetectKeyboardLayout();
|
||||||
|
expect(layout).toBe(LAYOUT_QWERTY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect QWERTY layout using modern method', async () => {
|
||||||
|
navigator.keyboard = {
|
||||||
|
getLayoutMap: vi.fn().mockResolvedValue(
|
||||||
|
new Map([
|
||||||
|
['KeyQ', 'q'],
|
||||||
|
['KeyW', 'w'],
|
||||||
|
['KeyE', 'e'],
|
||||||
|
['KeyR', 'r'],
|
||||||
|
['KeyT', 't'],
|
||||||
|
['KeyY', 'y'],
|
||||||
|
])
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const layout = await useDetectKeyboardLayout();
|
||||||
|
expect(layout).toBe(LAYOUT_QWERTY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect QWERTZ layout using modern method', async () => {
|
||||||
|
navigator.keyboard = {
|
||||||
|
getLayoutMap: vi.fn().mockResolvedValue(
|
||||||
|
new Map([
|
||||||
|
['KeyQ', 'q'],
|
||||||
|
['KeyW', 'w'],
|
||||||
|
['KeyE', 'e'],
|
||||||
|
['KeyR', 'r'],
|
||||||
|
['KeyT', 't'],
|
||||||
|
['KeyY', 'z'],
|
||||||
|
])
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const layout = await useDetectKeyboardLayout();
|
||||||
|
expect(layout).toBe(LAYOUT_QWERTZ);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect AZERTY layout using modern method', async () => {
|
||||||
|
navigator.keyboard = {
|
||||||
|
getLayoutMap: vi.fn().mockResolvedValue(
|
||||||
|
new Map([
|
||||||
|
['KeyQ', 'a'],
|
||||||
|
['KeyW', 'z'],
|
||||||
|
['KeyE', 'e'],
|
||||||
|
['KeyR', 'r'],
|
||||||
|
['KeyT', 't'],
|
||||||
|
['KeyY', 'y'],
|
||||||
|
])
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const layout = await useDetectKeyboardLayout();
|
||||||
|
expect(layout).toBe(LAYOUT_AZERTY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use legacy method if navigator.keyboard is not available', async () => {
|
||||||
|
navigator.keyboard = undefined;
|
||||||
|
|
||||||
|
const layout = await useDetectKeyboardLayout();
|
||||||
|
expect([LAYOUT_QWERTY, LAYOUT_QWERTZ, LAYOUT_AZERTY]).toContain(layout);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cache the detected layout', async () => {
|
||||||
|
navigator.keyboard = {
|
||||||
|
getLayoutMap: vi.fn().mockResolvedValue(
|
||||||
|
new Map([
|
||||||
|
['KeyQ', 'q'],
|
||||||
|
['KeyW', 'w'],
|
||||||
|
['KeyE', 'e'],
|
||||||
|
['KeyR', 'r'],
|
||||||
|
['KeyT', 't'],
|
||||||
|
['KeyY', 'y'],
|
||||||
|
])
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const layout = await useDetectKeyboardLayout();
|
||||||
|
expect(layout).toBe(LAYOUT_QWERTY);
|
||||||
|
|
||||||
|
const layoutAgain = await useDetectKeyboardLayout();
|
||||||
|
expect(layoutAgain).toBe(LAYOUT_QWERTY);
|
||||||
|
expect(navigator.keyboard.getLayoutMap).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { unref } from 'vue';
|
||||||
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
|
||||||
|
describe('useKeyboardEvents', () => {
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(useKeyboardEvents).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a function', () => {
|
||||||
|
expect(useKeyboardEvents).toBeInstanceOf(Function);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set up listeners on mount and remove them on unmount', async () => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
const elRef = unref({ value: el });
|
||||||
|
const events = {
|
||||||
|
'ALT+KeyL': () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mountedMock = vi.fn();
|
||||||
|
const unmountedMock = vi.fn();
|
||||||
|
useKeyboardEvents(events, elRef);
|
||||||
|
mountedMock();
|
||||||
|
unmountedMock();
|
||||||
|
|
||||||
|
expect(mountedMock).toHaveBeenCalled();
|
||||||
|
expect(unmountedMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
LAYOUT_QWERTY,
|
||||||
|
LAYOUT_QWERTZ,
|
||||||
|
LAYOUT_AZERTY,
|
||||||
|
} from 'shared/helpers/KeyboardHelpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects the keyboard layout using a legacy method by creating a hidden input and dispatching a key event.
|
||||||
|
* @returns {Promise<string>} A promise that resolves to the detected keyboard layout.
|
||||||
|
*/
|
||||||
|
async function detectLegacy() {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.style.position = 'fixed';
|
||||||
|
input.style.top = '-100px';
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const keyboardEvent = new KeyboardEvent('keypress', {
|
||||||
|
key: 'y',
|
||||||
|
keyCode: 89,
|
||||||
|
which: 89,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = e => {
|
||||||
|
document.body.removeChild(input);
|
||||||
|
document.removeEventListener('keypress', handler);
|
||||||
|
if (e.key === 'z') {
|
||||||
|
resolve(LAYOUT_QWERTY);
|
||||||
|
} else if (e.key === 'y') {
|
||||||
|
resolve(LAYOUT_QWERTZ);
|
||||||
|
} else {
|
||||||
|
resolve(LAYOUT_AZERTY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keypress', handler);
|
||||||
|
input.dispatchEvent(keyboardEvent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects the keyboard layout using the modern navigator.keyboard API.
|
||||||
|
* @returns {Promise<string>} A promise that resolves to the detected keyboard layout.
|
||||||
|
*/
|
||||||
|
async function detect() {
|
||||||
|
const map = await navigator.keyboard.getLayoutMap();
|
||||||
|
const q = map.get('KeyQ');
|
||||||
|
const w = map.get('KeyW');
|
||||||
|
const e = map.get('KeyE');
|
||||||
|
const r = map.get('KeyR');
|
||||||
|
const t = map.get('KeyT');
|
||||||
|
const y = map.get('KeyY');
|
||||||
|
|
||||||
|
return [q, w, e, r, t, y].join('').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses either the modern or legacy method to detect the keyboard layout, caching the result.
|
||||||
|
* @returns {Promise<string>} A promise that resolves to the detected keyboard layout.
|
||||||
|
*/
|
||||||
|
export async function useDetectKeyboardLayout() {
|
||||||
|
const cachedLayout = window.cw_keyboard_layout;
|
||||||
|
if (cachedLayout) {
|
||||||
|
return cachedLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout = navigator.keyboard ? await detect() : await detectLegacy();
|
||||||
|
window.cw_keyboard_layout = layout;
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
115
app/javascript/dashboard/composables/useKeyboardEvents.js
Normal file
115
app/javascript/dashboard/composables/useKeyboardEvents.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { onMounted, onBeforeUnmount, unref } from 'vue';
|
||||||
|
import {
|
||||||
|
isActiveElementTypeable,
|
||||||
|
isEscape,
|
||||||
|
keysToModifyInQWERTZ,
|
||||||
|
LAYOUT_QWERTZ,
|
||||||
|
} from 'shared/helpers/KeyboardHelpers';
|
||||||
|
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
|
||||||
|
import { createKeybindingsHandler } from 'tinykeys';
|
||||||
|
|
||||||
|
const keyboardListenerMap = new WeakMap();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if the keyboard event should be ignored based on the element type and handler settings.
|
||||||
|
* @param {Event} e - The event object.
|
||||||
|
* @param {Object|Function} handler - The handler configuration or function.
|
||||||
|
* @returns {boolean} - True if the event should be ignored, false otherwise.
|
||||||
|
*/
|
||||||
|
const shouldIgnoreEvent = (e, handler) => {
|
||||||
|
const isTypeable = isActiveElementTypeable(e);
|
||||||
|
const allowOnFocusedInput =
|
||||||
|
typeof handler === 'function' ? false : handler.allowOnFocusedInput;
|
||||||
|
|
||||||
|
if (isTypeable) {
|
||||||
|
if (isEscape(e)) {
|
||||||
|
e.target.blur();
|
||||||
|
}
|
||||||
|
return !allowOnFocusedInput;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the event handler to include custom logic before executing the handler.
|
||||||
|
* @param {Function} handler - The original event handler.
|
||||||
|
* @returns {Function} - The wrapped handler.
|
||||||
|
*/
|
||||||
|
const keydownWrapper = handler => {
|
||||||
|
return e => {
|
||||||
|
if (shouldIgnoreEvent(e, handler)) return;
|
||||||
|
// extract the action to perform from the handler
|
||||||
|
|
||||||
|
const actionToPerform =
|
||||||
|
typeof handler === 'function' ? handler : handler.action;
|
||||||
|
actionToPerform(e);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps all provided keyboard events in handlers that respect the current keyboard layout.
|
||||||
|
* @param {Object} events - The object containing event names and their handlers.
|
||||||
|
* @returns {Object} - The object with event names possibly modified based on the keyboard layout and wrapped handlers.
|
||||||
|
*/
|
||||||
|
async function wrapEventsInKeybindingsHandler(events) {
|
||||||
|
const wrappedEvents = {};
|
||||||
|
const currentLayout = await useDetectKeyboardLayout();
|
||||||
|
|
||||||
|
Object.keys(events).forEach(originalEventName => {
|
||||||
|
const modifiedEventName =
|
||||||
|
currentLayout === LAYOUT_QWERTZ &&
|
||||||
|
keysToModifyInQWERTZ.has(originalEventName)
|
||||||
|
? `Shift+${originalEventName}`
|
||||||
|
: originalEventName;
|
||||||
|
|
||||||
|
wrappedEvents[modifiedEventName] = keydownWrapper(
|
||||||
|
events[originalEventName]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return wrappedEvents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up keyboard event listeners on the specified element.
|
||||||
|
* @param {Element} root - The DOM element to attach listeners to.
|
||||||
|
* @param {Object} events - The events to listen for.
|
||||||
|
*/
|
||||||
|
const setupListeners = (root, events) => {
|
||||||
|
if (root instanceof Element && events) {
|
||||||
|
const keydownHandler = createKeybindingsHandler(events);
|
||||||
|
const handler = window.addEventListener('keydown', keydownHandler);
|
||||||
|
keyboardListenerMap.set(root, handler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes keyboard event listeners from the specified element.
|
||||||
|
* @param {Element} root - The DOM element to remove listeners from.
|
||||||
|
*/
|
||||||
|
const removeListeners = root => {
|
||||||
|
if (root instanceof Element) {
|
||||||
|
const handlerToRemove = keyboardListenerMap.get(root);
|
||||||
|
document.removeEventListener('keydown', handlerToRemove);
|
||||||
|
keyboardListenerMap.delete(root);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue composable to handle keyboard events with support for different keyboard layouts.
|
||||||
|
* @param {Object} keyboardEvents - The keyboard events to handle.
|
||||||
|
* @param {ref} elRef - A Vue ref to the element to attach the keyboard events to.
|
||||||
|
*/
|
||||||
|
export function useKeyboardEvents(keyboardEvents, elRef = null) {
|
||||||
|
onMounted(async () => {
|
||||||
|
const el = unref(elRef);
|
||||||
|
const getKeyboardEvents = () => keyboardEvents || null;
|
||||||
|
const events = getKeyboardEvents();
|
||||||
|
const wrappedEvents = await wrapEventsInKeybindingsHandler(events);
|
||||||
|
setupListeners(el, wrappedEvents);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
const el = unref(elRef);
|
||||||
|
removeListeners(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -22,6 +22,14 @@ export const hasPressedCommandAndEnter = e => {
|
|||||||
return hasPressedCommand(e) && isEnter(e);
|
return hasPressedCommand(e) && isEnter(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// If layout is QWERTZ then we add the Shift+keysToModify to fix an known issue
|
||||||
|
// https://github.com/chatwoot/chatwoot/issues/9492
|
||||||
|
export const keysToModifyInQWERTZ = new Set(['Alt+KeyP', 'Alt+KeyL']);
|
||||||
|
|
||||||
|
export const LAYOUT_QWERTY = 'QWERTY';
|
||||||
|
export const LAYOUT_QWERTZ = 'QWERTZ';
|
||||||
|
export const LAYOUT_AZERTY = 'AZERTY';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines whether the active element is typeable.
|
* Determines whether the active element is typeable.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user