Source code

Revision control

Copy as Markdown

Other Tools

/* 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:
*/
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;