/** * A set of helpers and management functions for dealing with user input from keyboard events. * {@link https://keycode.info/} */ class KeyboardManager { constructor() { this._reset(); } /* -------------------------------------------- */ /** * Begin listening to keyboard events. * @internal */ _activateListeners() { window.addEventListener("keydown", event => this._handleKeyboardEvent(event, false)); window.addEventListener("keyup", event => this._handleKeyboardEvent(event, true)); window.addEventListener("visibilitychange", this._reset.bind(this)); window.addEventListener("compositionend", this._onCompositionEnd.bind(this)); window.addEventListener("focusin", this._onFocusIn.bind(this)); } /* -------------------------------------------- */ /** * The set of key codes which are currently depressed (down) * @type {Set} */ downKeys = new Set(); /* -------------------------------------------- */ /** * The set of movement keys which were recently pressed * @type {Set} */ moveKeys = new Set(); /* -------------------------------------------- */ /** * Allowed modifier keys * @enum {string} */ static MODIFIER_KEYS = { CONTROL: "Control", SHIFT: "Shift", ALT: "Alt" }; /* -------------------------------------------- */ /** * Track which KeyboardEvent#code presses associate with each modifier * @enum {string[]} */ static MODIFIER_CODES = { [this.MODIFIER_KEYS.ALT]: ["AltLeft", "AltRight"], [this.MODIFIER_KEYS.CONTROL]: ["ControlLeft", "ControlRight", "MetaLeft", "MetaRight", "Meta", "OsLeft", "OsRight"], [this.MODIFIER_KEYS.SHIFT]: ["ShiftLeft", "ShiftRight"] }; /* -------------------------------------------- */ /** * Key codes which are "protected" and should not be used because they are reserved for browser-level actions. * @type {string[]} */ static PROTECTED_KEYS = ["F5", "F11", "F12", "PrintScreen", "ScrollLock", "NumLock", "CapsLock"]; /* -------------------------------------------- */ /** * The OS-specific string display for what their Command key is * @type {string} */ static CONTROL_KEY_STRING = navigator.appVersion.includes("Mac") ? "โŒ˜" : "Control"; /* -------------------------------------------- */ /** * A special mapping of how special KeyboardEvent#code values should map to displayed strings or symbols. * Values in this configuration object override any other display formatting rules which may be applied. * @type {Record} */ static KEYCODE_DISPLAY_MAPPING = (() => { const isMac = navigator.appVersion.includes("Mac"); return { ArrowLeft: isMac ? "โ†" : "๐Ÿกธ", ArrowRight: isMac ? "โ†’" : "๐Ÿกบ", ArrowUp: isMac ? "โ†‘" : "๐Ÿกน", ArrowDown: isMac ? "โ†“" : "๐Ÿกป", Backquote: "`", Backslash: "\\", BracketLeft: "[", BracketRight: "]", Comma: ",", Control: this.CONTROL_KEY_STRING, Equal: "=", Meta: isMac ? "โŒ˜" : "โŠž", MetaLeft: isMac ? "โŒ˜" : "โŠž", MetaRight: isMac ? "โŒ˜" : "โŠž", OsLeft: isMac ? "โŒ˜" : "โŠž", OsRight: isMac ? "โŒ˜" : "โŠž", Minus: "-", NumpadAdd: "Numpad+", NumpadSubtract: "Numpad-", Period: ".", Quote: "'", Semicolon: ";", Slash: "/" }; })(); /* -------------------------------------------- */ /** * Test whether an HTMLElement currently has focus. * If so we normally don't want to process keybinding actions. * @type {boolean} */ get hasFocus() { return document.querySelector(":focus") instanceof HTMLElement; } /* -------------------------------------------- */ /* Methods */ /* -------------------------------------------- */ /** * Emulates a key being pressed, triggering the Keyboard event workflow. * @param {boolean} up If True, emulates the `keyup` Event. Else, the `keydown` event * @param {string} code The KeyboardEvent#code which is being pressed * @param {object} [options] Additional options to configure behavior. * @param {boolean} [options.altKey=false] Emulate the ALT modifier as pressed * @param {boolean} [options.ctrlKey=false] Emulate the CONTROL modifier as pressed * @param {boolean} [options.shiftKey=false] Emulate the SHIFT modifier as pressed * @param {boolean} [options.repeat=false] Emulate this as a repeat event * @param {boolean} [options.force=false] Force the event to be handled. * @returns {KeyboardEventContext} */ static emulateKeypress(up, code, {altKey=false, ctrlKey=false, shiftKey=false, repeat=false, force=false}={}) { const event = new KeyboardEvent(`key${up ? "up" : "down"}`, {code, altKey, ctrlKey, shiftKey, repeat}); const context = this.getKeyboardEventContext(event, up); game.keyboard._processKeyboardContext(context, {force}); game.keyboard.downKeys.delete(context.key); return context; } /* -------------------------------------------- */ /** * Format a KeyboardEvent#code into a displayed string. * @param {string} code The input code * @returns {string} The displayed string for this code */ static getKeycodeDisplayString(code) { if ( code in this.KEYCODE_DISPLAY_MAPPING ) return this.KEYCODE_DISPLAY_MAPPING[code]; if ( code.startsWith("Digit") ) return code.replace("Digit", ""); if ( code.startsWith("Key") ) return code.replace("Key", ""); return code; } /* -------------------------------------------- */ /** * Get a standardized keyboard context for a given event. * Every individual keypress is uniquely identified using the KeyboardEvent#code property. * A list of possible key codes is documented here: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code/code_values * * @param {KeyboardEvent} event The originating keypress event * @param {boolean} up A flag for whether the key is down or up * @return {KeyboardEventContext} The standardized context of the event */ static getKeyboardEventContext(event, up=false) { let context = { event: event, key: event.code, isShift: event.shiftKey, isControl: event.ctrlKey || event.metaKey, isAlt: event.altKey, hasModifier: event.shiftKey || event.ctrlKey || event.metaKey || event.altKey, modifiers: [], up: up, repeat: event.repeat }; if ( context.isShift ) context.modifiers.push(this.MODIFIER_KEYS.SHIFT); if ( context.isControl ) context.modifiers.push(this.MODIFIER_KEYS.CONTROL); if ( context.isAlt ) context.modifiers.push(this.MODIFIER_KEYS.ALT); return context; } /* -------------------------------------------- */ /** * Report whether a modifier in KeyboardManager.MODIFIER_KEYS is currently actively depressed. * @param {string} modifier A modifier in MODIFIER_KEYS * @returns {boolean} Is this modifier key currently down (active)? */ isModifierActive(modifier) { return this.constructor.MODIFIER_CODES[modifier].some(k => this.downKeys.has(k)); } /* -------------------------------------------- */ /** * Report whether a core action key is currently actively depressed. * @param {string} action The core action to verify (ex: "target") * @returns {boolean} Is this core action key currently down (active)? */ isCoreActionKeyActive(action) { const binds = game.keybindings.get("core", action); return !!binds?.some(k => this.downKeys.has(k.key)); } /* -------------------------------------------- */ /** * Converts a Keyboard Context event into a string representation, such as "C" or "Control+C" * @param {KeyboardEventContext} context The standardized context of the event * @param {boolean} includeModifiers If True, includes modifiers in the string representation * @return {string} * @private */ static _getContextDisplayString(context, includeModifiers = true) { const parts = [this.getKeycodeDisplayString(context.key)]; if ( includeModifiers && context.hasModifier ) { if ( context.isShift && context.event.key !== "Shift" ) parts.unshift(this.MODIFIER_KEYS.SHIFT); if ( context.isControl && context.event.key !== "Control" ) parts.unshift(this.MODIFIER_KEYS.CONTROL); if ( context.isAlt && context.event.key !== "Alt" ) parts.unshift(this.MODIFIER_KEYS.ALT); } return parts.join("+"); } /* ----------------------------------------- */ /** * Given a standardized pressed key, find all matching registered Keybind Actions. * @param {KeyboardEventContext} context A standardized keyboard event context * @return {KeybindingAction[]} The matched Keybind Actions. May be empty. * @internal */ static _getMatchingActions(context) { let possibleMatches = game.keybindings.activeKeys.get(context.key) ?? []; if ( CONFIG.debug.keybindings ) console.dir(possibleMatches); return possibleMatches.filter(action => KeyboardManager._testContext(action, context)); } /* -------------------------------------------- */ /** * Test whether a keypress context matches the registration for a keybinding action * @param {KeybindingAction} action The keybinding action * @param {KeyboardEventContext} context The keyboard event context * @returns {boolean} Does the context match the action requirements? * @private */ static _testContext(action, context) { if ( context.repeat && !action.repeat ) return false; if ( action.restricted && !game.user.isGM ) return false; // If the context includes no modifiers, we match if the binding has none if ( !context.hasModifier ) return action.requiredModifiers.length === 0; // Test that modifiers match expectation const modifiers = this.MODIFIER_KEYS; const activeModifiers = { [modifiers.CONTROL]: context.isControl, [modifiers.SHIFT]: context.isShift, [modifiers.ALT]: context.isAlt }; for (let [k, v] of Object.entries(activeModifiers)) { // Ignore exact matches to a modifier key if ( this.MODIFIER_CODES[k].includes(context.key) ) continue; // Verify that required modifiers are present if ( action.requiredModifiers.includes(k) ) { if ( !v ) return false; } // No unsupported modifiers can be present for a "down" event else if ( !context.up && !action.optionalModifiers.includes(k) && v ) return false; } return true; } /* -------------------------------------------- */ /** * Given a registered Keybinding Action, executes the action with a given event and context * * @param {KeybindingAction} keybind The registered Keybinding action to execute * @param {KeyboardEventContext} context The gathered context of the event * @return {boolean} Returns true if the keybind was consumed * @private */ static _executeKeybind(keybind, context) { if ( CONFIG.debug.keybindings ) console.log("Executing " + game.i18n.localize(keybind.name)); context.action = keybind.action; let consumed = false; if ( context.up && keybind.onUp ) consumed = keybind.onUp(context); else if ( !context.up && keybind.onDown ) consumed = keybind.onDown(context); return consumed; } /* -------------------------------------------- */ /** * Processes a keyboard event context, checking it against registered keybinding actions * @param {KeyboardEventContext} context The keyboard event context * @param {object} [options] Additional options to configure behavior. * @param {boolean} [options.force=false] Force the event to be handled. * @protected */ _processKeyboardContext(context, {force=false}={}) { // Track the current set of pressed keys if ( context.up ) this.downKeys.delete(context.key); else this.downKeys.add(context.key); // If an input field has focus, don't process Keybinding Actions if ( this.hasFocus && !force ) return; // Open debugging group if ( CONFIG.debug.keybindings ) { console.group(`[${context.up ? 'UP' : 'DOWN'}] Checking for keybinds that respond to ${context.modifiers}+${context.key}`); console.dir(context); } // Check against registered Keybindings const actions = KeyboardManager._getMatchingActions(context); if (actions.length === 0) { if ( CONFIG.debug.keybindings ) { console.log("No matching keybinds"); console.groupEnd(); } return; } // Execute matching Keybinding Actions to see if any consume the event let handled; for ( const action of actions ) { handled = KeyboardManager._executeKeybind(action, context); if ( handled ) break; } // Cancel event since we handled it if ( handled && context.event ) { if ( CONFIG.debug.keybindings ) console.log("Event was consumed"); context.event?.preventDefault(); context.event?.stopPropagation(); } if ( CONFIG.debug.keybindings ) console.groupEnd(); } /* -------------------------------------------- */ /** * Reset tracking for which keys are in the down and released states * @private */ _reset() { this.downKeys = new Set(); this.moveKeys = new Set(); } /* -------------------------------------------- */ /** * Emulate a key-up event for any currently down keys. When emulating, we go backwards such that combinations such as * "CONTROL + S" emulate the "S" first in order to capture modifiers. * @param {object} [options] Options to configure behavior. * @param {boolean} [options.force=true] Force the keyup events to be handled. */ releaseKeys({force=true}={}) { const reverseKeys = Array.from(this.downKeys).reverse(); for ( const key of reverseKeys ) { this.constructor.emulateKeypress(true, key, { force, ctrlKey: this.isModifierActive(this.constructor.MODIFIER_KEYS.CONTROL), shiftKey: this.isModifierActive(this.constructor.MODIFIER_KEYS.SHIFT), altKey: this.isModifierActive(this.constructor.MODIFIER_KEYS.ALT) }); } } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** * Handle a key press into the down position * @param {KeyboardEvent} event The originating keyboard event * @param {boolean} up A flag for whether the key is down or up * @private */ _handleKeyboardEvent(event, up) { if ( event.isComposing ) return; // Ignore IME composition if ( !event.key && !event.code ) return; // Some browsers fire keyup and keydown events when autocompleting values. let context = KeyboardManager.getKeyboardEventContext(event, up); this._processKeyboardContext(context); } /* -------------------------------------------- */ /** * Input events do not fire with isComposing = false at the end of a composition event in Chrome * See: https://github.com/w3c/uievents/issues/202 * @param {CompositionEvent} event */ _onCompositionEnd(event) { return this._handleKeyboardEvent(event, false); } /* -------------------------------------------- */ /** * Release any down keys when focusing a form element. * @param {FocusEvent} event The focus event. * @protected */ _onFocusIn(event) { const formElements = [ HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement, HTMLOptionElement, HTMLButtonElement ]; if ( event.target.isContentEditable || formElements.some(cls => event.target instanceof cls) ) this.releaseKeys(); } }