key-shortcuts.js (9859B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 8 const isOSX = Services.appinfo.OS === "Darwin"; 9 const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); 10 11 // List of electron keys mapped to DOM API (DOM_VK_*) key code 12 const ElectronKeysMapping = { 13 F1: "DOM_VK_F1", 14 F2: "DOM_VK_F2", 15 F3: "DOM_VK_F3", 16 F4: "DOM_VK_F4", 17 F5: "DOM_VK_F5", 18 F6: "DOM_VK_F6", 19 F7: "DOM_VK_F7", 20 F8: "DOM_VK_F8", 21 F9: "DOM_VK_F9", 22 F10: "DOM_VK_F10", 23 F11: "DOM_VK_F11", 24 F12: "DOM_VK_F12", 25 F13: "DOM_VK_F13", 26 F14: "DOM_VK_F14", 27 F15: "DOM_VK_F15", 28 F16: "DOM_VK_F16", 29 F17: "DOM_VK_F17", 30 F18: "DOM_VK_F18", 31 F19: "DOM_VK_F19", 32 F20: "DOM_VK_F20", 33 F21: "DOM_VK_F21", 34 F22: "DOM_VK_F22", 35 F23: "DOM_VK_F23", 36 F24: "DOM_VK_F24", 37 Space: "DOM_VK_SPACE", 38 Backspace: "DOM_VK_BACK_SPACE", 39 Delete: "DOM_VK_DELETE", 40 Insert: "DOM_VK_INSERT", 41 Return: "DOM_VK_RETURN", 42 Enter: "DOM_VK_RETURN", 43 Up: "DOM_VK_UP", 44 Down: "DOM_VK_DOWN", 45 Left: "DOM_VK_LEFT", 46 Right: "DOM_VK_RIGHT", 47 Home: "DOM_VK_HOME", 48 End: "DOM_VK_END", 49 PageUp: "DOM_VK_PAGE_UP", 50 PageDown: "DOM_VK_PAGE_DOWN", 51 Escape: "DOM_VK_ESCAPE", 52 Esc: "DOM_VK_ESCAPE", 53 Tab: "DOM_VK_TAB", 54 VolumeUp: "DOM_VK_VOLUME_UP", 55 VolumeDown: "DOM_VK_VOLUME_DOWN", 56 VolumeMute: "DOM_VK_VOLUME_MUTE", 57 PrintScreen: "DOM_VK_PRINTSCREEN", 58 }; 59 60 /** 61 * Helper to listen for keyboard events described in .properties file. 62 * 63 * let shortcuts = new KeyShortcuts({ 64 * window 65 * }); 66 * shortcuts.on("Ctrl+F", event => { 67 * // `event` is the KeyboardEvent which relates to the key shortcuts 68 * }); 69 */ 70 class KeyShortcuts { 71 /** 72 * @param {object} options 73 * @param {Window} options.window 74 * The window object of the document to listen events from. 75 * @param {HTMLElement} options.target 76 * Optional DOM Element on which we should listen events from. 77 * If omitted, we listen for all events fired on `window`. 78 */ 79 constructor({ window, target }) { 80 this.window = window; 81 this.target = target || window; 82 this.keys = new Map(); 83 this.eventEmitter = new EventEmitter(); 84 this.target.addEventListener("keydown", this); 85 } 86 /** 87 * Parse an electron-like key string and return a normalized object which 88 * allow efficient match on DOM key event. The normalized object matches DOM 89 * API. 90 * 91 * @param {string} str 92 * The shortcut string to parse, following this document: 93 * https://github.com/electron/electron/blob/master/docs/api/accelerator.md 94 */ 95 static parseElectronKey(str) { 96 // If a localized string is found but has no value in the properties file, 97 // getStr will return `null`. See Bug 1569572. 98 if (typeof str !== "string") { 99 console.error("Invalid key passed to parseElectronKey, stacktrace below"); 100 console.trace(); 101 102 return null; 103 } 104 105 const modifiers = str.split("+"); 106 let key = modifiers.pop(); 107 108 const shortcut = { 109 ctrl: false, 110 meta: false, 111 alt: false, 112 shift: false, 113 // Set for character keys 114 key: undefined, 115 // Set for non-character keys 116 keyCode: undefined, 117 }; 118 for (const mod of modifiers) { 119 if (mod === "Alt") { 120 shortcut.alt = true; 121 } else if (["Command", "Cmd"].includes(mod)) { 122 shortcut.meta = true; 123 } else if (["CommandOrControl", "CmdOrCtrl"].includes(mod)) { 124 if (isOSX) { 125 shortcut.meta = true; 126 } else { 127 shortcut.ctrl = true; 128 } 129 } else if (["Control", "Ctrl"].includes(mod)) { 130 shortcut.ctrl = true; 131 } else if (mod === "Shift") { 132 shortcut.shift = true; 133 } else { 134 console.error("Unsupported modifier:", mod, "from key:", str); 135 return null; 136 } 137 } 138 139 // Plus is a special case. It's a character key and shouldn't be matched 140 // against a keycode as it is only accessible via Shift/Capslock 141 if (key === "Plus") { 142 key = "+"; 143 } 144 145 if (typeof key === "string" && key.length === 1) { 146 if (shortcut.alt) { 147 // When Alt is involved, some platforms (macOS) give different printable characters 148 // for the `key` value, like `®` for the key `R`. In this case, prefer matching by 149 // `keyCode` instead. 150 shortcut.keyCode = KeyCodes[`DOM_VK_${key.toUpperCase()}`]; 151 shortcut.keyCodeString = key; 152 } else { 153 // Match any single character 154 shortcut.key = key.toLowerCase(); 155 } 156 } else if (key in ElectronKeysMapping) { 157 // Maps the others manually to DOM API DOM_VK_* 158 key = ElectronKeysMapping[key]; 159 shortcut.keyCode = KeyCodes[key]; 160 // Used only to stringify the shortcut 161 shortcut.keyCodeString = key; 162 shortcut.key = key; 163 } else { 164 console.error("Unsupported key:", key); 165 return null; 166 } 167 168 return shortcut; 169 } 170 static stringifyShortcut(shortcut) { 171 if (shortcut === null) { 172 // parseElectronKey might return null in several situations. 173 return ""; 174 } 175 176 const list = []; 177 if (shortcut.alt) { 178 list.push("Alt"); 179 } 180 if (shortcut.ctrl) { 181 list.push("Ctrl"); 182 } 183 if (shortcut.meta) { 184 list.push("Cmd"); 185 } 186 if (shortcut.shift) { 187 list.push("Shift"); 188 } 189 let key; 190 if (shortcut.key) { 191 key = shortcut.key.toUpperCase(); 192 } else { 193 key = shortcut.keyCodeString; 194 } 195 list.push(key); 196 return list.join("+"); 197 } 198 /** 199 * Converts an Electron accelerator string into 200 * a pretty Unicode-based hotkey string 201 * 202 * on MacOS: 203 * CommandOrControl+, CmdOrCtrl+, Command+, Control+ => ⌘ 204 * Shift+ => ⇧ 205 * Alt+ => ⌥ 206 * 207 * on other OS: 208 * CommandOrControl+, CmdOrCtrl+ => Ctrl+ 209 * Shift+ => Shift+ 210 * 211 * @param {string} electronKeyString 212 * String containing the Electron accelerator string 213 * @returns Unicode based hotkey string 214 */ 215 static stringifyFromElectronKey(electronKeyString) { 216 // Debugger stores its L10N globally 217 const ctrlString = globalThis.L10N 218 ? globalThis.L10N.getStr("ctrl") 219 : "Ctrl"; 220 221 if (isOSX) { 222 return electronKeyString 223 .replace(/Shift\+/g, "\u21E7") 224 .replace(/Command\+|Cmd\+/g, "\u2318") 225 .replace(/CommandOrControl\+|CmdOrCtrl\+/g, "\u2318") 226 .replace(/Alt\+/g, "\u2325"); 227 } 228 return electronKeyString 229 .replace(/CommandOrControl\+|CmdOrCtrl\+/g, `${ctrlString}+`) 230 .replace(/Shift\+/g, "Shift+"); 231 } 232 /* 233 * Parse an xul-like key string and return an electron-like string. 234 */ 235 static parseXulKey(modifiers, shortcut) { 236 modifiers = modifiers 237 .split(",") 238 .map(mod => { 239 if (mod == "alt") { 240 return "Alt"; 241 } else if (mod == "shift") { 242 return "Shift"; 243 } else if (mod == "accel") { 244 return "CmdOrCtrl"; 245 } 246 return mod; 247 }) 248 .join("+"); 249 250 if (shortcut.startsWith("VK_")) { 251 shortcut = shortcut.substr(3); 252 } 253 254 return modifiers + "+" + shortcut; 255 } 256 destroy() { 257 this.target.removeEventListener("keydown", this); 258 this.keys.clear(); 259 this.eventEmitter.off(); 260 } 261 262 doesEventMatchShortcut(event, shortcut) { 263 if (shortcut.meta != event.metaKey) { 264 return false; 265 } 266 if (shortcut.ctrl != event.ctrlKey) { 267 return false; 268 } 269 if (shortcut.alt != event.altKey) { 270 return false; 271 } 272 if (shortcut.shift != event.shiftKey) { 273 // Check the `keyCode` to see whether it's a character (see also Bug 1493646) 274 const char = String.fromCharCode(event.keyCode); 275 let isAlphabetical = char.length == 1 && char.match(/[a-zA-Z]/); 276 277 // Shift is a special modifier, it may implicitly be required if the expected key 278 // is a special character accessible via shift. 279 if (!isAlphabetical) { 280 isAlphabetical = event.key && event.key.match(/[a-zA-Z]/); 281 } 282 283 // OSX: distinguish cmd+[key] from cmd+shift+[key] shortcuts (Bug 1300458) 284 const cmdShortcut = shortcut.meta && !shortcut.alt && !shortcut.ctrl; 285 if (isAlphabetical || cmdShortcut) { 286 return false; 287 } 288 } 289 290 if (shortcut.keyCode) { 291 return event.keyCode == shortcut.keyCode; 292 } else if (event.key in ElectronKeysMapping) { 293 return ElectronKeysMapping[event.key] === shortcut.key; 294 } 295 296 // get the key from the keyCode if key is not provided. 297 const key = event.key || String.fromCharCode(event.keyCode); 298 299 // For character keys, we match if the final character is the expected one. 300 // But for digits we also accept indirect match to please azerty keyboard, 301 // which requires Shift to be pressed to get digits. 302 return ( 303 key.toLowerCase() == shortcut.key || 304 (shortcut.key.match(/[0-9]/) && 305 event.keyCode == shortcut.key.charCodeAt(0)) 306 ); 307 } 308 309 handleEvent(event) { 310 for (const [key, shortcut] of this.keys) { 311 if (this.doesEventMatchShortcut(event, shortcut)) { 312 this.eventEmitter.emit(key, event); 313 } 314 } 315 } 316 317 on(key, listener) { 318 if (typeof listener !== "function") { 319 throw new Error( 320 "KeyShortcuts.on() expects a function as " + "second argument" 321 ); 322 } 323 if (!this.keys.has(key)) { 324 const shortcut = KeyShortcuts.parseElectronKey(key); 325 // The key string is wrong and we were unable to compute the key shortcut 326 if (!shortcut) { 327 return; 328 } 329 this.keys.set(key, shortcut); 330 } 331 this.eventEmitter.on(key, listener); 332 } 333 334 off(key, listener) { 335 this.eventEmitter.off(key, listener); 336 } 337 } 338 339 module.exports = KeyShortcuts;