tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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;