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
7var EXPORTED_SYMBOLS = ["ShortcutUtils"];
8
9const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
10const { XPCOMUtils } = ChromeUtils.import(
11  "resource://gre/modules/XPCOMUtils.jsm"
12);
13
14XPCOMUtils.defineLazyModuleGetters(this, {
15  AppConstants: "resource://gre/modules/AppConstants.jsm",
16});
17
18XPCOMUtils.defineLazyGetter(this, "PlatformKeys", function() {
19  return Services.strings.createBundle(
20    "chrome://global-platform/locale/platformKeys.properties"
21  );
22});
23
24XPCOMUtils.defineLazyGetter(this, "Keys", function() {
25  return Services.strings.createBundle(
26    "chrome://global/locale/keys.properties"
27  );
28});
29
30var ShortcutUtils = {
31  IS_VALID: "valid",
32  INVALID_KEY: "invalid_key",
33  INVALID_MODIFIER: "invalid_modifier",
34  INVALID_COMBINATION: "invalid_combination",
35  DUPLICATE_MODIFIER: "duplicate_modifier",
36  MODIFIER_REQUIRED: "modifier_required",
37
38  CLOSE_TAB: "CLOSE_TAB",
39  CYCLE_TABS: "CYCLE_TABS",
40  TOGGLE_CARET_BROWSING: "TOGGLE_CARET_BROWSING",
41  MOVE_TAB_BACKWARD: "MOVE_TAB_BACKWARD",
42  MOVE_TAB_FORWARD: "MOVE_TAB_FORWARD",
43  NEXT_TAB: "NEXT_TAB",
44  PREVIOUS_TAB: "PREVIOUS_TAB",
45
46  /**
47   * Prettifies the modifier keys for an element.
48   *
49   * @param Node aElemKey
50   *        The key element to get the modifiers from.
51   * @return string
52   *         A prettified and properly separated modifier keys string.
53   */
54  prettifyShortcut(aElemKey) {
55    let elemString = this.getModifierString(aElemKey.getAttribute("modifiers"));
56    let key = this.getKeyString(
57      aElemKey.getAttribute("keycode"),
58      aElemKey.getAttribute("key")
59    );
60    return elemString + key;
61  },
62
63  getModifierString(elemMod) {
64    let elemString = "";
65    let haveCloverLeaf = false;
66
67    if (elemMod.match("accel")) {
68      if (Services.appinfo.OS == "Darwin") {
69        haveCloverLeaf = true;
70      } else {
71        elemString +=
72          PlatformKeys.GetStringFromName("VK_CONTROL") +
73          PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR");
74      }
75    }
76    if (elemMod.match("access")) {
77      if (Services.appinfo.OS == "Darwin") {
78        elemString +=
79          PlatformKeys.GetStringFromName("VK_CONTROL") +
80          PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR");
81      } else {
82        elemString +=
83          PlatformKeys.GetStringFromName("VK_ALT") +
84          PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR");
85      }
86    }
87    if (elemMod.match("os")) {
88      elemString +=
89        PlatformKeys.GetStringFromName("VK_WIN") +
90        PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR");
91    }
92    if (elemMod.match("shift")) {
93      elemString +=
94        PlatformKeys.GetStringFromName("VK_SHIFT") +
95        PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR");
96    }
97    if (elemMod.match("alt")) {
98      elemString +=
99        PlatformKeys.GetStringFromName("VK_ALT") +
100        PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR");
101    }
102    if (elemMod.match("ctrl") || elemMod.match("control")) {
103      elemString +=
104        PlatformKeys.GetStringFromName("VK_CONTROL") +
105        PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR");
106    }
107    if (elemMod.match("meta")) {
108      elemString +=
109        PlatformKeys.GetStringFromName("VK_META") +
110        PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR");
111    }
112
113    if (haveCloverLeaf) {
114      elemString +=
115        PlatformKeys.GetStringFromName("VK_META") +
116        PlatformKeys.GetStringFromName("MODIFIER_SEPARATOR");
117    }
118
119    return elemString;
120  },
121
122  getKeyString(keyCode, keyAttribute) {
123    let key;
124    if (keyCode) {
125      keyCode = keyCode.toUpperCase();
126      if (AppConstants.platform == "macosx") {
127        // Return fancy Unicode symbols for some keys.
128        switch (keyCode) {
129          case "VK_LEFT":
130            return "\u2190"; // U+2190 LEFTWARDS ARROW
131          case "VK_RIGHT":
132            return "\u2192"; // U+2192 RIGHTWARDS ARROW
133        }
134      }
135      try {
136        let bundle = keyCode == "VK_RETURN" ? PlatformKeys : Keys;
137        // Some keys might not exist in the locale file, which will throw.
138        key = bundle.GetStringFromName(keyCode);
139      } catch (ex) {
140        Cu.reportError("Error finding " + keyCode + ": " + ex);
141        key = keyCode.replace(/^VK_/, "");
142      }
143    } else {
144      key = keyAttribute.toUpperCase();
145    }
146
147    return key;
148  },
149
150  getKeyAttribute(chromeKey) {
151    if (/^[A-Z]$/.test(chromeKey)) {
152      // We use the key attribute for single characters.
153      return ["key", chromeKey];
154    }
155    return ["keycode", this.getKeycodeAttribute(chromeKey)];
156  },
157
158  /**
159   * Determines the corresponding XUL keycode from the given chrome key.
160   *
161   * For example:
162   *
163   *    input     |  output
164   *    ---------------------------------------
165   *    "PageUp"  |  "VK_PAGE_UP"
166   *    "Delete"  |  "VK_DELETE"
167   *
168   * @param {string} chromeKey The chrome key (e.g. "PageUp", "Space", ...)
169   * @returns {string} The constructed value for the Key's 'keycode' attribute.
170   */
171  getKeycodeAttribute(chromeKey) {
172    if (/^[0-9]/.test(chromeKey)) {
173      return `VK_${chromeKey}`;
174    }
175    return `VK${chromeKey.replace(/([A-Z])/g, "_$&").toUpperCase()}`;
176  },
177
178  findShortcut(aElemCommand) {
179    let document = aElemCommand.ownerDocument;
180    return document.querySelector(
181      'key[command="' + aElemCommand.getAttribute("id") + '"]'
182    );
183  },
184
185  chromeModifierKeyMap: {
186    Alt: "alt",
187    Command: "accel",
188    Ctrl: "accel",
189    MacCtrl: "control",
190    Shift: "shift",
191  },
192
193  /**
194   * Determines the corresponding XUL modifiers from the chrome modifiers.
195   *
196   * For example:
197   *
198   *    input             |   output
199   *    ---------------------------------------
200   *    ["Ctrl", "Shift"] |   "accel,shift"
201   *    ["MacCtrl"]       |   "control"
202   *
203   * @param {Array} chromeModifiers The array of chrome modifiers.
204   * @returns {string} The constructed value for the Key's 'modifiers' attribute.
205   */
206  getModifiersAttribute(chromeModifiers) {
207    return Array.from(chromeModifiers, modifier => {
208      return ShortcutUtils.chromeModifierKeyMap[modifier];
209    })
210      .sort()
211      .join(",");
212  },
213
214  /**
215   * Validate if a shortcut string is valid and return an error code if it
216   * isn't valid.
217   *
218   * For example:
219   *
220   *    input            |   output
221   *    ---------------------------------------
222   *    "Ctrl+Shift+A"   |   IS_VALID
223   *    "Shift+F"        |   MODIFIER_REQUIRED
224   *    "Command+>"      |   INVALID_KEY
225   *
226   * @param {string} string The shortcut string.
227   * @returns {string} The code for the validation result.
228   */
229  validate(string) {
230    // A valid shortcut key for a webextension manifest
231    const MEDIA_KEYS = /^(MediaNextTrack|MediaPlayPause|MediaPrevTrack|MediaStop)$/;
232    const BASIC_KEYS = /^([A-Z0-9]|Comma|Period|Home|End|PageUp|PageDown|Space|Insert|Delete|Up|Down|Left|Right)$/;
233    const FUNCTION_KEYS = /^(F[1-9]|F1[0-2])$/;
234
235    if (MEDIA_KEYS.test(string.trim())) {
236      return this.IS_VALID;
237    }
238
239    let modifiers = string.split("+").map(s => s.trim());
240    let key = modifiers.pop();
241
242    let chromeModifiers = modifiers.map(
243      m => ShortcutUtils.chromeModifierKeyMap[m]
244    );
245    // If the modifier wasn't found it will be undefined.
246    if (chromeModifiers.some(modifier => !modifier)) {
247      return this.INVALID_MODIFIER;
248    }
249
250    switch (modifiers.length) {
251      case 0:
252        // A lack of modifiers is only allowed with function keys.
253        if (!FUNCTION_KEYS.test(key)) {
254          return this.MODIFIER_REQUIRED;
255        }
256        break;
257      case 1:
258        // Shift is only allowed on its own with function keys.
259        if (chromeModifiers[0] == "shift" && !FUNCTION_KEYS.test(key)) {
260          return this.MODIFIER_REQUIRED;
261        }
262        break;
263      case 2:
264        if (chromeModifiers[0] == chromeModifiers[1]) {
265          return this.DUPLICATE_MODIFIER;
266        }
267        break;
268      default:
269        return this.INVALID_COMBINATION;
270    }
271
272    if (!BASIC_KEYS.test(key) && !FUNCTION_KEYS.test(key)) {
273      return this.INVALID_KEY;
274    }
275
276    return this.IS_VALID;
277  },
278
279  /**
280   * Attempt to find a key for a given shortcut string, such as
281   * "Ctrl+Shift+A" and determine if it is a system shortcut.
282   *
283   * @param {Object} win The window to look for key elements in.
284   * @param {string} value The shortcut string.
285   * @returns {boolean} Whether a system shortcut was found or not.
286   */
287  isSystem(win, value) {
288    let modifiers = value.split("+");
289    let chromeKey = modifiers.pop();
290    let modifiersString = this.getModifiersAttribute(modifiers);
291    let keycode = this.getKeycodeAttribute(chromeKey);
292
293    let baseSelector = "key";
294    if (modifiers.length) {
295      baseSelector += `[modifiers="${modifiersString}"]`;
296    }
297
298    let keyEl = win.document.querySelector(
299      [
300        `${baseSelector}[key="${chromeKey}"]`,
301        `${baseSelector}[key="${chromeKey.toLowerCase()}"]`,
302        `${baseSelector}[keycode="${keycode}"]`,
303      ].join(",")
304    );
305    return keyEl && !keyEl.closest("keyset").id.startsWith("ext-keyset-id");
306  },
307
308  /**
309   * Determine what action a KeyboardEvent should perform, if any.
310   *
311   * @param {KeyboardEvent} event The event to check for a related system action.
312   * @returns {string} A string identifying the action, or null if no action is found.
313   */
314  // eslint-disable-next-line complexity
315  getSystemActionForEvent(event, { rtl } = {}) {
316    switch (event.keyCode) {
317      case event.DOM_VK_TAB:
318        if (event.ctrlKey && !event.altKey && !event.metaKey) {
319          return ShortcutUtils.CYCLE_TABS;
320        }
321        break;
322      case event.DOM_VK_F7:
323        // shift + F7 is the default DevTools shortcut for the Style Editor.
324        if (!event.shiftKey) {
325          return ShortcutUtils.TOGGLE_CARET_BROWSING;
326        }
327        break;
328      case event.DOM_VK_PAGE_UP:
329        if (
330          event.ctrlKey &&
331          !event.shiftKey &&
332          !event.altKey &&
333          !event.metaKey
334        ) {
335          return ShortcutUtils.PREVIOUS_TAB;
336        }
337        if (
338          event.ctrlKey &&
339          event.shiftKey &&
340          !event.altKey &&
341          !event.metaKey
342        ) {
343          return ShortcutUtils.MOVE_TAB_BACKWARD;
344        }
345        break;
346      case event.DOM_VK_PAGE_DOWN:
347        if (
348          event.ctrlKey &&
349          !event.shiftKey &&
350          !event.altKey &&
351          !event.metaKey
352        ) {
353          return ShortcutUtils.NEXT_TAB;
354        }
355        if (
356          event.ctrlKey &&
357          event.shiftKey &&
358          !event.altKey &&
359          !event.metaKey
360        ) {
361          return ShortcutUtils.MOVE_TAB_FORWARD;
362        }
363        break;
364      case event.DOM_VK_LEFT:
365        if (
366          event.metaKey &&
367          event.altKey &&
368          !event.shiftKey &&
369          !event.ctrlKey
370        ) {
371          return ShortcutUtils.PREVIOUS_TAB;
372        }
373        break;
374      case event.DOM_VK_RIGHT:
375        if (
376          event.metaKey &&
377          event.altKey &&
378          !event.shiftKey &&
379          !event.ctrlKey
380        ) {
381          return ShortcutUtils.NEXT_TAB;
382        }
383        break;
384    }
385
386    if (AppConstants.platform == "macosx") {
387      if (!event.altKey && event.metaKey) {
388        switch (event.charCode) {
389          case "}".charCodeAt(0):
390            if (rtl) {
391              return ShortcutUtils.PREVIOUS_TAB;
392            }
393            return ShortcutUtils.NEXT_TAB;
394          case "{".charCodeAt(0):
395            if (rtl) {
396              return ShortcutUtils.NEXT_TAB;
397            }
398            return ShortcutUtils.PREVIOUS_TAB;
399        }
400      }
401    }
402    // Not on Mac from now on.
403    if (AppConstants.platform != "macosx") {
404      if (
405        event.ctrlKey &&
406        !event.shiftKey &&
407        !event.metaKey &&
408        event.keyCode == KeyEvent.DOM_VK_F4
409      ) {
410        return ShortcutUtils.CLOSE_TAB;
411      }
412    }
413
414    return null;
415  },
416};
417
418Object.freeze(ShortcutUtils);
419