import { JSUtils } from "../JSUtils.js"; import { StringUtils } from "../StringUtils.js"; import { META_KEYS } from "./constants/meta-keys.js"; import { IGetMappedKeys, IKeyAppliedResponse, IKeyboardMappedKeysElements, IKeyboardOptions } from "./interface.js"; import { keyCodeEventValues } from "./keyCodes/index.js"; /** * `KeyboardManager` é reponsável por gerenciar eventos de teclado. */ export class KeyboardManager { private _options: IKeyboardOptions; private _shadowRoots: ShadowRoot [] = []; private _mappedElements: IKeyboardMappedKeysElements = {}; /** * Construtor para a classe Keyboard. * * @param {IKeyboardOptions} options - opções para o gerenciamento de eventos de teclado. */ constructor(options?: Partial) { this._options = { type: 'keydown', propagate: false, element: document.body, debounceTime: 0, enableShadowDom: false, ...options } if(this._options.enableShadowDom) { this._shadowRoots = this.findAllNestedShadowRoots(this._options.element); } } private findAllNestedShadowRoots(element: HTMLElement | Element | ChildNode | Document , results: ShadowRoot [] = []): ShadowRoot [] { if (!element) { return results; } const shadowRoot = (element as HTMLElement)?.shadowRoot; if (shadowRoot) { results.push(shadowRoot); shadowRoot.querySelectorAll("*").forEach((child) => { this.findAllNestedShadowRoots(child, results); }); } element.childNodes.forEach((child) => { this.findAllNestedShadowRoots(child, results); }); return results; } private addEventListenerToNestedShadowRoots(event: 'keydown' | 'keyup', callback: VoidFunction) { this._shadowRoots.forEach((shadowRoot) => { shadowRoot.addEventListener(event, callback); }); } private removeEventListenerToNestedShadowRoots(event: 'keydown' | 'keyup', callback: VoidFunction) { this._shadowRoots.forEach((shadowRoot) => { shadowRoot.removeEventListener(event, callback); }); } /** * Associa um evento de teclado com uma função * * @param {string} keyMap - Chave de mapeamento de teclado. * @param {VoidFunction} callback - Função a ser executada quando o evento de teclado for disparado. * @param {IKeyboardOptions} [options] - Configurações dos eventos de teclado. * @return {KeyboardManager} O objeto `KeyboardManager` */ public bind(keyMap: string, callback: VoidFunction, options?: Partial): KeyboardManager { if (this._mappedElements?.[keyMap]) { throw new Error("Event already exists."); } const bindOptions = { ...this._options, ...options } const debounceFunction: VoidFunction = JSUtils.debounceLeading( this.handleListenerEvent, bindOptions.debounceTime, this, ) .bind(this, keyMap, callback, bindOptions.propagate); this._mappedElements = { ...this._mappedElements, [keyMap]: { callback: debounceFunction, options: bindOptions } } bindOptions.element.addEventListener(bindOptions.type, debounceFunction); if(bindOptions.enableShadowDom) { this.addEventListenerToNestedShadowRoots(bindOptions.type, debounceFunction); } return this; } /** * Remove todos os eventos de teclado ou de uma lista informada * */ public unbindAllShortcutKeys(listShortcutKeys?: string[]): void { const mappedKeys = [...Object.keys(listShortcutKeys || this._mappedElements)]; mappedKeys?.forEach(keyMap => this.unbind(keyMap)); } /** * Remove um evento de teclado * * @param {string} keyMap - Chave de mapeamento de teclado. * @return {KeyboardManager} - O objeto `KeyboardManager` */ public unbind(keyMap: string): KeyboardManager { const unbindElement = this._mappedElements?.[keyMap]; if (!unbindElement) { throw new Error("KeyMap not exists."); } delete this._mappedElements[keyMap]; const { callback, options: { element, type } } = unbindElement; element.removeEventListener(type, callback); if(unbindElement.options.enableShadowDom) { this.removeEventListenerToNestedShadowRoots(type, callback); } return this; } /** * Obtém as chaves mapeadas. * * @return {Array} chaves mapeadas com descrições */ public get mappedKeys(): Array { return Object.entries(this._mappedElements).map( ([key, value]) => ({ [key]: value.options.description || key }) ) } /** * Executa uma função quando um evento de teclado for disparado * * @param {string} keyMap - Chave de mapeamento de teclado * @param {VoidFunction} callback - Função a ser executada * @param {boolean} propagate - Se o evento de teclado deve ser propagado * @param {KeyboardEvent} event - O evento de teclado */ private handleListenerEvent(keyMap: string, callback: VoidFunction, propagate: boolean, event: KeyboardEvent) { const { isKeyApplied, modifiedList } = this.keyAppliedWithModifiers(keyMap, event.keyCode); if (isKeyApplied && this.checkModifiersIsApplied(event, modifiedList)) { this.verifyAndStopPropagation(event, propagate); (callback || (() => {}))(); } } /** * Verifica se um evento de teclado foi disparado * * @param {string} keyMap - Chave de mapeamento de teclado * @param {number} pressedKeyCode - Código do evento de teclado pressionado * @return {IKeyAppliedResponse} Retorna se o evento de teclado foi disparado e as teclas modificadoras aplicadas ao evento */ private keyAppliedWithModifiers(keyMap: string, pressedKeyCode: number): IKeyAppliedResponse { const keys = keyMap.split("+"); let isKeyApplied = false; const modifiedList = []; for (let key of keys) { key = key.trim(); if ((META_KEYS as Array).includes(key.toLowerCase())) { modifiedList.push(key); continue; } const pressedKey = keyCodeEventValues.reduce( (acc: Array, { key: checkKey, keyCode }) => { if (checkKey == key || checkKey == StringUtils.getOppositeCase(key)) { acc.push(keyCode); } return acc; }, []); if (pressedKey.includes(pressedKeyCode)) { isKeyApplied = true; } } return { isKeyApplied, modifiedList }; } /** * Verifica se todas as teclas modificadoras foram aplicadas ao evento * * @param {KeyboardEvent} event - O evento de teclado * @param {Array} modifiedList - As teclas modificadoras * @return {boolean} Retorna se todas as teclas modificadoras foram aplicadas */ private checkModifiersIsApplied(event: KeyboardEvent, modifiedList: Array): boolean { const eventModifiers: Record = { shift: event.shiftKey, ctrl: event.ctrlKey, alt: event.altKey, meta: event.metaKey, }; if (modifiedList.length === 0) { return !( eventModifiers.shift || eventModifiers.ctrl || eventModifiers.alt || eventModifiers.meta ); } let hasAllModifiedApplied = true; for (const modifier of modifiedList) { if (!eventModifiers[modifier]) { hasAllModifiedApplied = false; break; } } return hasAllModifiedApplied; } /** * Verifica e impede que o evento de teclado seja propagado * * @param {KeyboardEvent} event - O evento de teclado * @param {boolean} propagate - Se o evento de teclado deve ser propagado * @return {void} */ private verifyAndStopPropagation(event: KeyboardEvent, propagate: boolean): void { if (!propagate) { event?.preventDefault?.(); event?.stopPropagation?.(); } } }