mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 11:37:58 +00:00
feat: Rewrite keyboardEventListener mixin to a composable (#9831)
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import VirtualList from 'vue-virtual-scroll-list';
|
||||
|
||||
import ChatListHeader from './ChatListHeader.vue';
|
||||
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
|
||||
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import conversationMixin from '../mixins/conversations';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
|
||||
@@ -41,7 +42,7 @@ export default {
|
||||
IntersectionObserver,
|
||||
VirtualList,
|
||||
},
|
||||
mixins: [conversationMixin, keyboardEventListenerMixins, filterMixin],
|
||||
mixins: [conversationMixin, filterMixin],
|
||||
provide() {
|
||||
return {
|
||||
// Actions to be performed on virtual list item and context menu.
|
||||
@@ -89,8 +90,63 @@ export default {
|
||||
setup() {
|
||||
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 {
|
||||
uiSettings,
|
||||
conversationListRef,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -113,7 +169,7 @@ export default {
|
||||
isContextMenuOpen: false,
|
||||
appliedFilter: [],
|
||||
infiniteLoaderOptions: {
|
||||
root: this.$refs.conversationList,
|
||||
root: this.$refs.conversationListRef,
|
||||
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() {
|
||||
this.appliedFilter = [];
|
||||
this.resetBulkActions();
|
||||
@@ -927,7 +931,7 @@ export default {
|
||||
@assignTeam="onAssignTeamsForBulk"
|
||||
/>
|
||||
<div
|
||||
ref="conversationList"
|
||||
ref="conversationListRef"
|
||||
class="flex-1 conversations-list"
|
||||
:class="{ 'overflow-hidden': isContextMenuOpen }"
|
||||
>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
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 SecondarySidebar from './sidebarComponents/Secondary.vue';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import router, { routesWithPermissions } from '../../routes';
|
||||
import { routesWithPermissions } from '../../routes';
|
||||
import { hasPermissions } from '../../helper/permissionsHelper';
|
||||
|
||||
export default {
|
||||
@@ -13,7 +15,6 @@ export default {
|
||||
PrimarySidebar,
|
||||
SecondarySidebar,
|
||||
},
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
props: {
|
||||
showSecondarySidebar: {
|
||||
type: Boolean,
|
||||
@@ -24,6 +25,52 @@ export 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() {
|
||||
return {
|
||||
showOptionsMenu: false,
|
||||
@@ -131,38 +178,6 @@ export default {
|
||||
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() {
|
||||
window.$chatwoot.toggle();
|
||||
},
|
||||
@@ -180,7 +195,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="flex h-full">
|
||||
<aside ref="sidebarRef" class="flex h-full">
|
||||
<PrimarySidebar
|
||||
:logo-source="globalConfig.logoThumbnail"
|
||||
:installation-name="globalConfig.installationName"
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
export default {
|
||||
name: 'ReplyTopPanel',
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: REPLY_EDITOR_MODES.REPLY,
|
||||
},
|
||||
setReplyMode: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
isMessageLengthReachingThreshold: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
@@ -26,6 +22,36 @@ export default {
|
||||
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: {
|
||||
replyButtonClass() {
|
||||
return {
|
||||
@@ -46,31 +72,14 @@ export default {
|
||||
: `${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>
|
||||
|
||||
<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">
|
||||
<woot-button
|
||||
variant="clear"
|
||||
|
||||
@@ -1091,10 +1091,10 @@ export default {
|
||||
/>
|
||||
<ReplyTopPanel
|
||||
:mode="replyType"
|
||||
:set-reply-mode="setReplyMode"
|
||||
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
||||
:characters-remaining="charactersRemaining"
|
||||
:popout-reply-box="popoutReplyBox"
|
||||
@setReplyMode="setReplyMode"
|
||||
@click="$emit('click')"
|
||||
/>
|
||||
<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);
|
||||
};
|
||||
|
||||
// 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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user