diff options
Diffstat (limited to 'devtools/client/shared/key-shortcuts.js')
-rw-r--r-- | devtools/client/shared/key-shortcuts.js | 309 |
1 files changed, 309 insertions, 0 deletions
diff --git a/devtools/client/shared/key-shortcuts.js b/devtools/client/shared/key-shortcuts.js new file mode 100644 index 0000000000..7165247ecb --- /dev/null +++ b/devtools/client/shared/key-shortcuts.js @@ -0,0 +1,309 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const Services = require("Services"); +const EventEmitter = require("devtools/shared/event-emitter"); +const isOSX = Services.appinfo.OS === "Darwin"; +const { KeyCodes } = require("devtools/client/shared/keycodes"); + +// List of electron keys mapped to DOM API (DOM_VK_*) key code +const ElectronKeysMapping = { + F1: "DOM_VK_F1", + F2: "DOM_VK_F2", + F3: "DOM_VK_F3", + F4: "DOM_VK_F4", + F5: "DOM_VK_F5", + F6: "DOM_VK_F6", + F7: "DOM_VK_F7", + F8: "DOM_VK_F8", + F9: "DOM_VK_F9", + F10: "DOM_VK_F10", + F11: "DOM_VK_F11", + F12: "DOM_VK_F12", + F13: "DOM_VK_F13", + F14: "DOM_VK_F14", + F15: "DOM_VK_F15", + F16: "DOM_VK_F16", + F17: "DOM_VK_F17", + F18: "DOM_VK_F18", + F19: "DOM_VK_F19", + F20: "DOM_VK_F20", + F21: "DOM_VK_F21", + F22: "DOM_VK_F22", + F23: "DOM_VK_F23", + F24: "DOM_VK_F24", + Space: "DOM_VK_SPACE", + Backspace: "DOM_VK_BACK_SPACE", + Delete: "DOM_VK_DELETE", + Insert: "DOM_VK_INSERT", + Return: "DOM_VK_RETURN", + Enter: "DOM_VK_RETURN", + Up: "DOM_VK_UP", + Down: "DOM_VK_DOWN", + Left: "DOM_VK_LEFT", + Right: "DOM_VK_RIGHT", + Home: "DOM_VK_HOME", + End: "DOM_VK_END", + PageUp: "DOM_VK_PAGE_UP", + PageDown: "DOM_VK_PAGE_DOWN", + Escape: "DOM_VK_ESCAPE", + Esc: "DOM_VK_ESCAPE", + Tab: "DOM_VK_TAB", + VolumeUp: "DOM_VK_VOLUME_UP", + VolumeDown: "DOM_VK_VOLUME_DOWN", + VolumeMute: "DOM_VK_VOLUME_MUTE", + PrintScreen: "DOM_VK_PRINTSCREEN", +}; + +/** + * Helper to listen for keyboard events described in .properties file. + * + * let shortcuts = new KeyShortcuts({ + * window + * }); + * shortcuts.on("Ctrl+F", event => { + * // `event` is the KeyboardEvent which relates to the key shortcuts + * }); + * + * @param DOMWindow window + * The window object of the document to listen events from. + * @param DOMElement target + * Optional DOM Element on which we should listen events from. + * If omitted, we listen for all events fired on `window`. + */ +function KeyShortcuts({ window, target }) { + this.window = window; + this.target = target || window; + this.keys = new Map(); + this.eventEmitter = new EventEmitter(); + this.target.addEventListener("keydown", this); +} + +/* + * Parse an electron-like key string and return a normalized object which + * allow efficient match on DOM key event. The normalized object matches DOM + * API. + * + * @param DOMWindow window + * Any DOM Window object, just to fetch its `KeyboardEvent` object + * @param String str + * The shortcut string to parse, following this document: + * https://github.com/electron/electron/blob/master/docs/api/accelerator.md + */ +KeyShortcuts.parseElectronKey = function(window, str) { + // If a localized string is found but has no value in the properties file, + // getStr will return `null`. See Bug 1569572. + if (typeof str !== "string") { + console.error("Invalid key passed to parseElectronKey, stacktrace below"); + console.trace(); + + return null; + } + + const modifiers = str.split("+"); + let key = modifiers.pop(); + + const shortcut = { + ctrl: false, + meta: false, + alt: false, + shift: false, + // Set for character keys + key: undefined, + // Set for non-character keys + keyCode: undefined, + }; + for (const mod of modifiers) { + if (mod === "Alt") { + shortcut.alt = true; + } else if (["Command", "Cmd"].includes(mod)) { + shortcut.meta = true; + } else if (["CommandOrControl", "CmdOrCtrl"].includes(mod)) { + if (isOSX) { + shortcut.meta = true; + } else { + shortcut.ctrl = true; + } + } else if (["Control", "Ctrl"].includes(mod)) { + shortcut.ctrl = true; + } else if (mod === "Shift") { + shortcut.shift = true; + } else { + console.error("Unsupported modifier:", mod, "from key:", str); + return null; + } + } + + // Plus is a special case. It's a character key and shouldn't be matched + // against a keycode as it is only accessible via Shift/Capslock + if (key === "Plus") { + key = "+"; + } + + if (typeof key === "string" && key.length === 1) { + if (shortcut.alt) { + // When Alt is involved, some platforms (macOS) give different printable characters + // for the `key` value, like `®` for the key `R`. In this case, prefer matching by + // `keyCode` instead. + shortcut.keyCode = KeyCodes[`DOM_VK_${key.toUpperCase()}`]; + shortcut.keyCodeString = key; + } else { + // Match any single character + shortcut.key = key.toLowerCase(); + } + } else if (key in ElectronKeysMapping) { + // Maps the others manually to DOM API DOM_VK_* + key = ElectronKeysMapping[key]; + shortcut.keyCode = KeyCodes[key]; + // Used only to stringify the shortcut + shortcut.keyCodeString = key; + shortcut.key = key; + } else { + console.error("Unsupported key:", key); + return null; + } + + return shortcut; +}; + +KeyShortcuts.stringify = function(shortcut) { + if (shortcut === null) { + // parseElectronKey might return null in several situations. + return ""; + } + + const list = []; + if (shortcut.alt) { + list.push("Alt"); + } + if (shortcut.ctrl) { + list.push("Ctrl"); + } + if (shortcut.meta) { + list.push("Cmd"); + } + if (shortcut.shift) { + list.push("Shift"); + } + let key; + if (shortcut.key) { + key = shortcut.key.toUpperCase(); + } else { + key = shortcut.keyCodeString; + } + list.push(key); + return list.join("+"); +}; + +/* + * Parse an xul-like key string and return an electron-like string. + */ +KeyShortcuts.parseXulKey = function(modifiers, shortcut) { + modifiers = modifiers + .split(",") + .map(mod => { + if (mod == "alt") { + return "Alt"; + } else if (mod == "shift") { + return "Shift"; + } else if (mod == "accel") { + return "CmdOrCtrl"; + } + return mod; + }) + .join("+"); + + if (shortcut.startsWith("VK_")) { + shortcut = shortcut.substr(3); + } + + return modifiers + "+" + shortcut; +}; + +KeyShortcuts.prototype = { + destroy() { + this.target.removeEventListener("keydown", this); + this.keys.clear(); + }, + + doesEventMatchShortcut(event, shortcut) { + if (shortcut.meta != event.metaKey) { + return false; + } + if (shortcut.ctrl != event.ctrlKey) { + return false; + } + if (shortcut.alt != event.altKey) { + return false; + } + if (shortcut.shift != event.shiftKey) { + // Check the `keyCode` to see whether it's a character (see also Bug 1493646) + const char = String.fromCharCode(event.keyCode); + let isAlphabetical = char.length == 1 && char.match(/[a-zA-Z]/); + + // Shift is a special modifier, it may implicitly be required if the expected key + // is a special character accessible via shift. + if (!isAlphabetical) { + isAlphabetical = event.key && event.key.match(/[a-zA-Z]/); + } + + // OSX: distinguish cmd+[key] from cmd+shift+[key] shortcuts (Bug 1300458) + const cmdShortcut = shortcut.meta && !shortcut.alt && !shortcut.ctrl; + if (isAlphabetical || cmdShortcut) { + return false; + } + } + + if (shortcut.keyCode) { + return event.keyCode == shortcut.keyCode; + } else if (event.key in ElectronKeysMapping) { + return ElectronKeysMapping[event.key] === shortcut.key; + } + + // get the key from the keyCode if key is not provided. + const key = event.key || String.fromCharCode(event.keyCode); + + // For character keys, we match if the final character is the expected one. + // But for digits we also accept indirect match to please azerty keyboard, + // which requires Shift to be pressed to get digits. + return ( + key.toLowerCase() == shortcut.key || + (shortcut.key.match(/[0-9]/) && + event.keyCode == shortcut.key.charCodeAt(0)) + ); + }, + + handleEvent(event) { + for (const [key, shortcut] of this.keys) { + if (this.doesEventMatchShortcut(event, shortcut)) { + this.eventEmitter.emit(key, event); + } + } + }, + + on(key, listener) { + if (typeof listener !== "function") { + throw new Error( + "KeyShortcuts.on() expects a function as " + "second argument" + ); + } + if (!this.keys.has(key)) { + const shortcut = KeyShortcuts.parseElectronKey(this.window, key); + // The key string is wrong and we were unable to compute the key shortcut + if (!shortcut) { + return; + } + this.keys.set(key, shortcut); + } + this.eventEmitter.on(key, listener); + }, + + off(key, listener) { + this.eventEmitter.off(key, listener); + }, +}; + +module.exports = KeyShortcuts; |