This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View File

@@ -0,0 +1,430 @@
/**
* 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<string>}
*/
downKeys = new Set();
/* -------------------------------------------- */
/**
* The set of movement keys which were recently pressed
* @type {Set<string>}
*/
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<string, string>}
*/
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();
}
}