summaryrefslogtreecommitdiff
path: root/ext/js/input/hotkey-handler.js
diff options
context:
space:
mode:
authortoasted-nutbread <toasted-nutbread@users.noreply.github.com>2021-02-13 22:52:28 -0500
committerGitHub <noreply@github.com>2021-02-13 22:52:28 -0500
commit6a271e067fa917614f4c81f473533e24c6d04404 (patch)
tree0d81658b1c03aecfbba133425aefc0ea7612338c /ext/js/input/hotkey-handler.js
parentdeed5027cd18bcdb9cb9d13cb7831be0ec5384e8 (diff)
Move mixed/js (#1383)
* Move mixed/js/core.js to js/core.js * Move mixed/js/yomichan.js to js/yomichan.js * Move mixed/js/timer.js to js/debug/timer.js * Move mixed/js/hotkey-handler.js to js/input/hotkey-handler.js * Move mixed/js/hotkey-help-controller.js to js/input/hotkey-help-controller.js * Move mixed/js/hotkey-util.js to js/input/hotkey-util.js * Move mixed/js/audio-system.js to js/input/audio-system.js * Move mixed/js/media-loader.js to js/input/media-loader.js * Move mixed/js/text-to-speech-audio.js to js/input/text-to-speech-audio.js * Move mixed/js/comm.js to js/comm/cross-frame-api.js * Move mixed/js/api.js to js/comm/api.js * Move mixed/js/frame-client.js to js/comm/frame-client.js * Move mixed/js/frame-endpoint.js to js/comm/frame-endpoint.js * Move mixed/js/display.js to js/display/display.js * Move mixed/js/display-audio.js to js/display/display-audio.js * Move mixed/js/display-generator.js to js/display/display-generator.js * Move mixed/js/display-history.js to js/display/display-history.js * Move mixed/js/display-notification.js to js/display/display-notification.js * Move mixed/js/display-profile-selection.js to js/display/display-profile-selection.js * Move mixed/js/japanese.js to js/language/japanese-util.js * Move mixed/js/dictionary-data-util.js to js/language/dictionary-data-util.js * Move mixed/js/document-focus-controller.js to js/dom/document-focus-controller.js * Move mixed/js/document-util.js to js/dom/document-util.js * Move mixed/js/dom-data-binder.js to js/dom/dom-data-binder.js * Move mixed/js/html-template-collection.js to js/dom/html-template-collection.js * Move mixed/js/panel-element.js to js/dom/panel-element.js * Move mixed/js/popup-menu.js to js/dom/popup-menu.js * Move mixed/js/selector-observer.js to js/dom/selector-observer.js * Move mixed/js/scroll.js to js/dom/window-scroll.js * Move mixed/js/text-scanner.js to js/language/text-scanner.js * Move mixed/js/cache-map.js to js/general/cache-map.js * Move mixed/js/object-property-accessor.js to js/general/object-property-accessor.js * Move mixed/js/task-accumulator.js to js/general/task-accumulator.js * Move mixed/js/environment.js to js/background/environment.js * Move mixed/js/dynamic-loader.js to js/scripting/dynamic-loader.js * Move mixed/js/dynamic-loader-sentinel.js to js/scripting/dynamic-loader-sentinel.js
Diffstat (limited to 'ext/js/input/hotkey-handler.js')
-rw-r--r--ext/js/input/hotkey-handler.js267
1 files changed, 267 insertions, 0 deletions
diff --git a/ext/js/input/hotkey-handler.js b/ext/js/input/hotkey-handler.js
new file mode 100644
index 00000000..423410b7
--- /dev/null
+++ b/ext/js/input/hotkey-handler.js
@@ -0,0 +1,267 @@
+/*
+ * Copyright (C) 2021 Yomichan Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/* global
+ * DocumentUtil
+ * api
+ */
+
+/**
+ * Class which handles hotkey events and actions.
+ */
+class HotkeyHandler extends EventDispatcher {
+ /**
+ * Creates a new instance of the class.
+ */
+ constructor() {
+ super();
+ this._actions = new Map();
+ this._hotkeys = new Map();
+ this._hotkeyRegistrations = new Map();
+ this._eventListeners = new EventListenerCollection();
+ this._isPrepared = false;
+ this._hasEventListeners = false;
+ this._forwardFrameId = null;
+ }
+
+ /**
+ * Gets the frame ID used for forwarding hotkeys.
+ */
+ get forwardFrameId() {
+ return this._forwardFrameId;
+ }
+
+ /**
+ * Sets the frame ID used for forwarding hotkeys.
+ */
+ set forwardFrameId(value) {
+ this._forwardFrameId = value;
+ this._updateHotkeyRegistrations();
+ }
+
+ /**
+ * Begins listening to key press events in order to detect hotkeys.
+ */
+ prepare() {
+ this._isPrepared = true;
+ this._updateEventHandlers();
+ api.crossFrame.registerHandlers([
+ ['hotkeyHandler.forwardHotkey', {async: false, handler: this._onMessageForwardHotkey.bind(this)}]
+ ]);
+ }
+
+ /**
+ * Registers a set of actions that this hotkey handler supports.
+ * @param actions An array of `[name, handler]` entries, where `name` is a string and `handler` is a function.
+ */
+ registerActions(actions) {
+ for (const [name, handler] of actions) {
+ this._actions.set(name, handler);
+ }
+ }
+
+ /**
+ * Registers a set of hotkeys for a given scope.
+ * @param scope The scope that the hotkey definitions must be for in order to be activated.
+ * @param hotkeys An array of hotkey definitions of the format `{action, key, modifiers, scopes, enabled}`.
+ * * `action` - a string indicating which action to perform.
+ * * `key` - a keyboard key code indicating which key needs to be pressed.
+ * * `modifiers` - an array of keyboard modifiers which also need to be pressed. Supports: `'alt', 'ctrl', 'shift', 'meta'`.
+ * * `scopes` - an array of scopes for which the hotkey is valid. If this array does not contain `this.scope`, the hotkey will not be registered.
+ * * `enabled` - a boolean indicating whether the hotkey is currently enabled.
+ */
+ registerHotkeys(scope, hotkeys) {
+ let registrations = this._hotkeyRegistrations.get(scope);
+ if (typeof registrations === 'undefined') {
+ registrations = [];
+ this._hotkeyRegistrations.set(scope, registrations);
+ }
+ registrations.push(...hotkeys);
+ this._updateHotkeyRegistrations();
+ }
+
+ /**
+ * Removes all registered hotkeys for a given scope.
+ */
+ clearHotkeys(scope) {
+ const registrations = this._hotkeyRegistrations.get(scope);
+ if (typeof registrations !== 'undefined') {
+ registrations.length = 0;
+ }
+ this._updateHotkeyRegistrations();
+ }
+
+ /**
+ * Assigns a set of hotkeys for a given scope. This is an optimized shorthand for calling
+ * `clearHotkeys`, then calling `registerHotkeys`.
+ * @see registerHotkeys for argument information.
+ */
+ setHotkeys(scope, hotkeys) {
+ let registrations = this._hotkeyRegistrations.get(scope);
+ if (typeof registrations === 'undefined') {
+ registrations = [];
+ this._hotkeyRegistrations.set(scope, registrations);
+ } else {
+ registrations.length = 0;
+ }
+ registrations.push(...hotkeys);
+ this._updateHotkeyRegistrations();
+ }
+
+ /**
+ * Adds a single event listener to a specific event.
+ * @param eventName The string representing the event's name.
+ * @param callback The event listener callback to add.
+ */
+ on(eventName, callback) {
+ const result = super.on(eventName, callback);
+ this._updateHasEventListeners();
+ this._updateEventHandlers();
+ return result;
+ }
+
+ /**
+ * Removes a single event listener from a specific event.
+ * @param eventName The string representing the event's name.
+ * @param callback The event listener callback to add.
+ * @returns `true` if the callback was removed, `false` otherwise.
+ */
+ off(eventName, callback) {
+ const result = super.off(eventName, callback);
+ this._updateHasEventListeners();
+ this._updateEventHandlers();
+ return result;
+ }
+
+ /**
+ * Attempts to simulate an action for a given combination of key and modifiers.
+ * @param key A keyboard key code indicating which key needs to be pressed.
+ * @param modifiers An array of keyboard modifiers which also need to be pressed. Supports: `'alt', 'ctrl', 'shift', 'meta'`.
+ * @returns `true` if an action was performed, `false` otherwise.
+ */
+ simulate(key, modifiers) {
+ const hotkeyInfo = this._hotkeys.get(key);
+ return (
+ typeof hotkeyInfo !== 'undefined' &&
+ this._invokeHandlers(key, modifiers, hotkeyInfo, false)
+ );
+ }
+
+ // Message handlers
+
+ _onMessageForwardHotkey({key, modifiers}) {
+ return this.simulate(key, modifiers);
+ }
+
+ // Private
+
+ _onKeyDown(e) {
+ const key = e.code;
+ const hotkeyInfo = this._hotkeys.get(key);
+ if (typeof hotkeyInfo !== 'undefined') {
+ const eventModifiers = DocumentUtil.getActiveModifiers(e);
+ const canForward = (this._forwardFrameId !== null);
+ if (this._invokeHandlers(key, eventModifiers, hotkeyInfo, canForward)) {
+ e.preventDefault();
+ return;
+ }
+ }
+ this.trigger('keydownNonHotkey', e);
+ }
+
+ _invokeHandlers(key, modifiers, hotkeyInfo, canForward) {
+ for (const {modifiers: handlerModifiers, action} of hotkeyInfo.handlers) {
+ if (!this._areSame(handlerModifiers, modifiers)) { continue; }
+
+ const actionHandler = this._actions.get(action);
+ if (typeof actionHandler !== 'undefined') {
+ const result = actionHandler();
+ if (result !== false) {
+ return true;
+ }
+ }
+ }
+
+ if (canForward && hotkeyInfo.forward) {
+ this._forwardHotkey(key, modifiers);
+ return true;
+ }
+
+ return false;
+ }
+
+ _areSame(set, array) {
+ if (set.size !== array.length) { return false; }
+ for (const value of array) {
+ if (!set.has(value)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ _updateHotkeyRegistrations() {
+ if (this._hotkeys.size === 0 && this._hotkeyRegistrations.size === 0) { return; }
+
+ const canForward = (this._forwardFrameId !== null);
+ this._hotkeys.clear();
+ for (const [scope, registrations] of this._hotkeyRegistrations.entries()) {
+ for (const {action, key, modifiers, scopes, enabled} of registrations) {
+ if (!(enabled && key !== null && action !== '')) { continue; }
+
+ const correctScope = scopes.includes(scope);
+ if (!correctScope && !canForward) { continue; }
+
+ let hotkeyInfo = this._hotkeys.get(key);
+ if (typeof hotkeyInfo === 'undefined') {
+ hotkeyInfo = {handlers: [], forward: false};
+ this._hotkeys.set(key, hotkeyInfo);
+ }
+
+ if (correctScope) {
+ hotkeyInfo.handlers.push({modifiers: new Set(modifiers), action});
+ } else {
+ hotkeyInfo.forward = true;
+ }
+ }
+ }
+ this._updateEventHandlers();
+ }
+
+ _updateHasEventListeners() {
+ this._hasEventListeners = this.hasListeners('keydownNonHotkey');
+ }
+
+ _updateEventHandlers() {
+ if (this._isPrepared && (this._hotkeys.size > 0 || this._hasEventListeners)) {
+ if (this._eventListeners.size !== 0) { return; }
+ this._eventListeners.addEventListener(document, 'keydown', this._onKeyDown.bind(this), false);
+ } else {
+ this._eventListeners.removeAllEventListeners();
+ }
+ }
+
+ async _forwardHotkey(key, modifiers) {
+ const frameId = this._forwardFrameId;
+ if (frameId === null) { throw new Error('No forwarding target'); }
+ try {
+ await api.crossFrame.invoke(frameId, 'hotkeyHandler.forwardHotkey', {key, modifiers});
+ } catch (e) {
+ // NOP
+ }
+ }
+}