Source: service/HotkeyService.js

(function() {
    /**
     * @typedef {Help4.service.Service.Params} Help4.service.HotkeyService.Params
     * @property {Help4.engine.crossorigin.CoreEngine} crossOriginEngine
     * @property {Help4.service.CrossOriginMessageService} crossOriginService
     * @property {Object} keyMap
     * @property {Object} hotkeyMap
     */

    /**
     * @augments Help4.service.Service
     */
    Help4.service.HotkeyService = class extends Help4.service.Service {
        /**
         * @override
         * @constructor
         * @param {Help4.service.HotkeyService.Params} params
         */
        constructor(params) {
            super(params, {
                crossOriginEngine: null,
                crossOriginService: null,
                keyMap: {},
                hotkeyMap: {}
            });

            const {_params, _observers} = this;
            this._onCrossOrigin = event => _params.eventBus.fire(event);  // simply propagate
            _params.crossOriginEngine?.onHotkeyEvent.addListener(this._onCrossOrigin);
            _params.crossOriginService?.onHotkeyEvent.addListener(this._onCrossOrigin);
            _observers.window = new Help4.observer.WindowObserver(event => _onEvent.call(this, event));

            this._enabledKeys = {};
        }

        /**
         * keys that are ignored while editing input fields
         * @memberof Help4.service.HotkeyService
         * @type {string[]}
         */
        static IGNORE_INPUT = [
            'back',
            'tab',
            'enter',
            'shift',
            'ctrl',
            'shift_*',
            'ctrl_home',
            'ctrl_end',
            'ctrl_up',
            'ctrl_down',
            'ctrl_left',
            'ctrl_right',
            'ctrl_c',
            'ctrl_x',
            'ctrl_v',
            'ctrl_a',
            'capslock',
            'space',
            'end',
            'home',
            'up',
            'left',
            'down',
            'right',
            'ins',
            'del'
        ]

        /**
         * @param {Object} [event = {}]
         * @returns {?Element}
         */
        static getActiveInputElement(event = {}) {
            if (event._orig) event = event._orig;

            /**
             * @param {Element} startElement
             * @returns {?Element}
             */
            const search = startElement => {
                if (startElement) {
                    if (startElement.tagName === 'INPUT' || startElement.tagName === 'TEXTAREA') return startElement;
                    if (startElement.shadowRoot) return search(startElement.shadowRoot.activeElement);

                    let element = startElement;
                    do {
                        if (Help4.Element.isContentEditable(element)) return element;
                    } while (element = Help4.Element.getParent(element));
                }

                return null;
            }

            const {document} = event?.view || window;
            return search(document.activeElement);
        }

        /**
         * @param {Object} event
         * @returns {?string}
         */
        static getShortcut(event) {
            const key = this.getKey(event);
            return Help4.HOTKEY_MAP_HTML[key];
        }

        /**
         * @param {Object} event
         * @returns {?string}
         */
        static getKey(event) {
            /** @type {Help4.controller.Controller} */ const controller = Help4.getController();
            /** @type {Help4.service.HotkeyService} */ const hotkeyService = controller?.getService('hotkey');
            return hotkeyService?.getKey(event);
        }

        /**
         * @param {Object} event
         * @param {Object} keyMap
         * @returns {boolean}
         */
        static cancelKeyEvent(event, keyMap) {
            const {keyCode, ctrlKey, altKey} = event;

            if (keyCode === 32) {  // ENTER
                if (!Help4.Element.getClassName(event.target).match(Help4.CLASS_PREFIX)) return true;  // XRAY-2824
            } else if (
                !ctrlKey && !altKey && (keyCode < 112 || keyCode > 123) ||  // ctrl / alt not pressed and not a F-Key
                keyCode >= 16 && keyCode <= 18  // pure modifier key - SHIFT, CTRL, ALT
            ) {
                return true;
            }

            return Help4.Event.cancel(event);
        }

        /** destroys the instance */
        destroy() {
            const {_params} = this;
            if (this._onCrossOrigin) {
                _params.crossOriginEngine?.onHotkeyEvent?.removeListener?.(this._onCrossOrigin);
                _params.crossOriginService?.onHotkeyEvent?.removeListener?.(this._onCrossOrigin);
                delete this._onCrossOrigin;
            }

            delete this._enabledKeys;

            super.destroy();
        }

        /**
         * @param {Object} event
         * @returns {string}
         */
        getKey(event) {
            const {keyMap} = this._params;
            const {ctrlKey, altKey, shiftKey, keyCode} = event;

            const key = [
                ctrlKey ? 'ctrl' : '',
                altKey ? 'alt' : '',
                shiftKey ? 'shift' : '',
                keyMap[keyCode] || String.fromCharCode(keyCode).toLowerCase()
            ].join('_');

            return key
            .replace(/_{2,}/g, '_')
            .replace(/^_|_$/g, '');
        }

        /**
         * @param {string} key
         * @returns {boolean}
         */
        hotkeyEnabled(key) {
            return !!this._enabledKeys[key];
        }

        /**
         * @param {string} keys
         * @returns {Help4.service.HotkeyService}
         */
        enableHotkey(...keys) {
            const {_enabledKeys, _observers, _params: {crossOriginEngine, crossOriginService}} = this;

            for (const key of keys) {
                _enabledKeys[key] ??= 0;
                _enabledKeys[key]++;
            }

            // sometimes DOM loads very slowly
            // make sure observer is updated as much as possible
            _observers.window
            .disconnect()
            .observeAll({
                eventObserver: {
                    type: ['keydown', 'keyup'],
                    capture: true
                }
            });

            if (crossOriginEngine) {
                const {recording: type} = crossOriginEngine?.AGENT_TYPE;
                const command = {type, command: 'enableHotkey', params: keys};

                crossOriginEngine
                .sendCommand(command)
                .catch(Help4.noop);

                crossOriginService
                ?.sendCommand(command)
                .catch(Help4.noop);
            }

            return this;
        }

        /**
         * @param {string} keys
         * @returns {Help4.service.HotkeyService}
         */
        disableHotkey(...keys) {
            const {_enabledKeys, _observers, _params: {crossOriginEngine, crossOriginService}} = this;
            for (const key of keys) {
                if (!(--_enabledKeys[key])) delete _enabledKeys[key];
            }

            if (crossOriginEngine) {
                const {recording: type} = crossOriginEngine.AGENT_TYPE;
                const command = {type, command: 'disableHotkey', params: keys};

                crossOriginEngine
                .sendCommand(command)
                .catch(Help4.noop);

                crossOriginService
                ?.sendCommand(command)
                .catch(Help4.noop);
            }

            return this;
        }
    }

    /**
     * @memberof Help4.service.HotkeyService#
     * @private
     * @param {Object} event
     * @returns {boolean}
     */
    function _onEvent(event) {
        if (this.isDestroyed()) return true;

        const {
            /** @type {Help4.controller.Controller} */ controller,
            /** @type {Help4.EventBus} */ eventBus,
            /** @type {Object} */ hotkeyMap,
            /** @type {Object} */ keyMap
        } = this._params;

        const {
            /** @type {boolean} */ CMP4,
            /** @type {boolean} */ isTourMode,
            /** @type {boolean} */ isEditMode
        } = /** @type {Help4.typedef.SystemConfiguration} */ controller.getConfiguration();

        /** @type {string} */ const key = this.getKey(event);
        /** @type {string} */ const hotkey = _keyToHotkey.call(this, key)

        // hotkeys need to be enabled to be executed; XRAY-3266
        if (!hotkey || !this.hotkeyEnabled(hotkey)) return true;

        // some hotkeys only execute if WA is focussed
        if (hotkeyMap[hotkey].focusRequired && !Help4.Element.isClassIncluded(document.activeElement, Help4.CLASS_PREFIX)) return true;

        // tour automation requires a certain key handling; XRAY-3231
        if (!isEditMode) {
            if (CMP4) {
                const instance = Help4.widget.getActiveInstance();
                if (instance?.getName() === 'tour' && instance.getAutoProgressKeys().includes(key)) return true;
            } else if (isTourMode) {
                if (controller.getHandler().getAutoProgressKeys().includes(key)) return true;
            }
        }

        // do not handle certain keys while actively typing into an input field
        if (!_checkInputField.call(this, event, key)) return true;

        if (event.type === 'keyup') eventBus.fire({type: Help4.EventBus.TYPES.hotkey, hotkey});

        // abort keydown and keyup to ensure that no hotkey executes on two functionalities
        return this.constructor.cancelKeyEvent(event, keyMap);
    }

    /**
     * @memberof Help4.service.HotkeyService#
     * @private
     * @param {string} key
     * @returns {?string}
     */
    function _keyToHotkey(key) {
        const {hotkeyMap} = this._params;
        for (let [id, value] of Object.entries(hotkeyMap)) {
            if (value.key === key) return id;
        }
        return null;
    }

    /**
     * @memberof Help4.service.HotkeyService#
     * @private
     * @param {Object} event
     * @param {string} key
     * @returns {boolean}
     */
    function _checkInputField(event, key) {
        if (!this.constructor.getActiveInputElement(event)) return true;  // not within input/textarea field: ok

        // check for keys that are not to be observed during input field editing (such as normal letters or backspace)
        const {IGNORE_INPUT} = this.constructor;
        if (key.length === 1 || IGNORE_INPUT.includes(key)) return false;

        // check for modifier combinations that are not to be observed
        for (const ignored of IGNORE_INPUT) {
            if (ignored.search(/_\*$/) >= 0) {
                const mod = ignored.split('_')[0];
                // mod and simple key is pressed
                if (key.includes(mod + '_') && key.substring(mod.length + 1).length === 1) return false;
            }
        }

        return true;
    }
})();