/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js"); const isOSX = Services.appinfo.OS === "Darwin"; const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); // 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;