summaryrefslogtreecommitdiffstats
path: root/toolkit/mozapps/extensions/content/shortcuts.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/mozapps/extensions/content/shortcuts.js')
-rw-r--r--toolkit/mozapps/extensions/content/shortcuts.js659
1 files changed, 659 insertions, 0 deletions
diff --git a/toolkit/mozapps/extensions/content/shortcuts.js b/toolkit/mozapps/extensions/content/shortcuts.js
new file mode 100644
index 0000000000..516ed21088
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/shortcuts.js
@@ -0,0 +1,659 @@
+/* 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/. */
+/* import-globals-from aboutaddonsCommon.js */
+
+"use strict";
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionShortcutKeyMap: "resource://gre/modules/ExtensionShortcuts.sys.mjs",
+ ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs",
+});
+
+{
+ const FALLBACK_ICON = "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+ const COLLAPSE_OPTIONS = {
+ limit: 5, // We only want to show 5 when collapsed.
+ allowOver: 1, // Avoid collapsing to hide 1 row.
+ };
+
+ let templatesLoaded = false;
+ let shortcutKeyMap = new ExtensionShortcutKeyMap();
+ const templates = {};
+
+ function loadTemplates() {
+ if (templatesLoaded) {
+ return;
+ }
+ templatesLoaded = true;
+
+ templates.view = document.getElementById("shortcut-view");
+ templates.card = document.getElementById("shortcut-card-template");
+ templates.row = document.getElementById("shortcut-row-template");
+ templates.noAddons = document.getElementById("shortcuts-no-addons");
+ templates.expandRow = document.getElementById("expand-row-template");
+ templates.noShortcutAddons = document.getElementById(
+ "shortcuts-no-commands-template"
+ );
+ }
+
+ function extensionForAddonId(id) {
+ let policy = WebExtensionPolicy.getByID(id);
+ return policy && policy.extension;
+ }
+
+ let builtInNames = new Map([
+ ["_execute_action", "shortcuts-browserAction2"],
+ ["_execute_browser_action", "shortcuts-browserAction2"],
+ ["_execute_page_action", "shortcuts-pageAction"],
+ ["_execute_sidebar_action", "shortcuts-sidebarAction"],
+ ]);
+ let getCommandDescriptionId = command => {
+ if (!command.description && builtInNames.has(command.name)) {
+ return builtInNames.get(command.name);
+ }
+ return null;
+ };
+
+ const _functionKeys = [
+ "F1",
+ "F2",
+ "F3",
+ "F4",
+ "F5",
+ "F6",
+ "F7",
+ "F8",
+ "F9",
+ "F10",
+ "F11",
+ "F12",
+ ];
+ const functionKeys = new Set(_functionKeys);
+ const validKeys = new Set([
+ "Home",
+ "End",
+ "PageUp",
+ "PageDown",
+ "Insert",
+ "Delete",
+ "0",
+ "1",
+ "2",
+ "3",
+ "4",
+ "5",
+ "6",
+ "7",
+ "8",
+ "9",
+ ..._functionKeys,
+ "MediaNextTrack",
+ "MediaPlayPause",
+ "MediaPrevTrack",
+ "MediaStop",
+ "A",
+ "B",
+ "C",
+ "D",
+ "E",
+ "F",
+ "G",
+ "H",
+ "I",
+ "J",
+ "K",
+ "L",
+ "M",
+ "N",
+ "O",
+ "P",
+ "Q",
+ "R",
+ "S",
+ "T",
+ "U",
+ "V",
+ "W",
+ "X",
+ "Y",
+ "Z",
+ "Up",
+ "Down",
+ "Left",
+ "Right",
+ "Comma",
+ "Period",
+ "Space",
+ ]);
+
+ /**
+ * Trim a valid prefix from an event string.
+ *
+ * "Digit3" ~> "3"
+ * "ArrowUp" ~> "Up"
+ * "W" ~> "W"
+ *
+ * @param {string} string The input string.
+ * @returns {string} The trimmed string, or unchanged.
+ */
+ function trimPrefix(string) {
+ return string.replace(/^(?:Digit|Numpad|Arrow)/, "");
+ }
+
+ const remapKeys = {
+ ",": "Comma",
+ ".": "Period",
+ " ": "Space",
+ };
+ /**
+ * Map special keys to their shortcut name.
+ *
+ * "," ~> "Comma"
+ * " " ~> "Space"
+ *
+ * @param {string} string The input string.
+ * @returns {string} The remapped string, or unchanged.
+ */
+ function remapKey(string) {
+ if (remapKeys.hasOwnProperty(string)) {
+ return remapKeys[string];
+ }
+ return string;
+ }
+
+ const keyOptions = [
+ e => String.fromCharCode(e.which), // A letter?
+ e => e.code.toUpperCase(), // A letter.
+ e => trimPrefix(e.code), // Digit3, ArrowUp, Numpad9.
+ e => trimPrefix(e.key), // Digit3, ArrowUp, Numpad9.
+ e => remapKey(e.key), // Comma, Period, Space.
+ ];
+ /**
+ * Map a DOM event to a shortcut string character.
+ *
+ * For example:
+ *
+ * "a" ~> "A"
+ * "Digit3" ~> "3"
+ * "," ~> "Comma"
+ *
+ * @param {object} event A KeyboardEvent.
+ * @returns {string} A string corresponding to the pressed key.
+ */
+ function getStringForEvent(event) {
+ for (let option of keyOptions) {
+ let value = option(event);
+ if (validKeys.has(value)) {
+ return value;
+ }
+ }
+
+ return "";
+ }
+
+ function getShortcutValue(shortcut) {
+ if (!shortcut) {
+ // Ensure the shortcut is a string, even if it is unset.
+ return null;
+ }
+
+ let modifiers = shortcut.split("+");
+ let key = modifiers.pop();
+
+ if (modifiers.length) {
+ let modifiersAttribute = ShortcutUtils.getModifiersAttribute(modifiers);
+ let displayString =
+ ShortcutUtils.getModifierString(modifiersAttribute) + key;
+ return displayString;
+ }
+
+ if (functionKeys.has(key)) {
+ return key;
+ }
+
+ return null;
+ }
+
+ let error;
+
+ function setError(...args) {
+ setInputMessage("error", ...args);
+ }
+
+ function setWarning(...args) {
+ setInputMessage("warning", ...args);
+ }
+
+ function setInputMessage(type, input, messageId, args) {
+ let { x, y, height, right } = input.getBoundingClientRect();
+ error.style.top = `${y + window.scrollY + height - 5}px`;
+
+ if (document.dir == "ltr") {
+ error.style.left = `${x}px`;
+ error.style.right = null;
+ } else {
+ error.style.right = `${document.documentElement.clientWidth - right}px`;
+ error.style.left = null;
+ }
+
+ error.setAttribute("type", type);
+ document.l10n.setAttributes(
+ error.querySelector(".error-message-label"),
+ messageId,
+ args
+ );
+ error.style.visibility = "visible";
+ }
+
+ function inputBlurred(e) {
+ error.style.visibility = "hidden";
+ e.target.value = getShortcutValue(e.target.getAttribute("shortcut"));
+ }
+
+ function onFocus(e) {
+ e.target.value = "";
+
+ let warning = e.target.getAttribute("warning");
+ if (warning) {
+ setWarning(e.target, warning);
+ }
+ }
+
+ function getShortcutForEvent(e) {
+ let modifierMap;
+
+ if (AppConstants.platform == "macosx") {
+ modifierMap = {
+ MacCtrl: e.ctrlKey,
+ Alt: e.altKey,
+ Command: e.metaKey,
+ Shift: e.shiftKey,
+ };
+ } else {
+ modifierMap = {
+ Ctrl: e.ctrlKey,
+ Alt: e.altKey,
+ Shift: e.shiftKey,
+ };
+ }
+
+ return Object.entries(modifierMap)
+ .filter(([key, isDown]) => isDown)
+ .map(([key]) => key)
+ .concat(getStringForEvent(e))
+ .join("+");
+ }
+
+ async function buildDuplicateShortcutsMap(addons) {
+ await shortcutKeyMap.buildForAddonIds(addons.map(addon => addon.id));
+ }
+
+ function recordShortcut(shortcut, addonName, commandName) {
+ shortcutKeyMap.recordShortcut(shortcut, addonName, commandName);
+ }
+
+ function removeShortcut(shortcut, addonName, commandName) {
+ shortcutKeyMap.removeShortcut(shortcut, addonName, commandName);
+ }
+
+ function getAddonName(shortcut) {
+ return shortcutKeyMap.getFirstAddonName(shortcut);
+ }
+
+ function setDuplicateWarnings() {
+ let warningHolder = document.getElementById("duplicate-warning-messages");
+ clearWarnings(warningHolder);
+ for (let [shortcut, addons] of shortcutKeyMap) {
+ if (addons.size > 1) {
+ warningHolder.appendChild(createDuplicateWarningBar(shortcut));
+ markDuplicates(shortcut);
+ }
+ }
+ }
+
+ function clearWarnings(warningHolder) {
+ warningHolder.textContent = "";
+ let inputs = document.querySelectorAll(".shortcut-input[warning]");
+ for (let input of inputs) {
+ input.removeAttribute("warning");
+ let row = input.closest(".shortcut-row");
+ if (row.hasAttribute("hide-before-expand")) {
+ row
+ .closest(".card")
+ .querySelector(".expand-button")
+ .removeAttribute("warning");
+ }
+ }
+ }
+
+ function createDuplicateWarningBar(shortcut) {
+ let messagebar = document.createElement("message-bar");
+ messagebar.setAttribute("type", "warning");
+
+ let message = document.createElement("span");
+ document.l10n.setAttributes(
+ message,
+ "shortcuts-duplicate-warning-message",
+ { shortcut }
+ );
+
+ messagebar.append(message);
+ return messagebar;
+ }
+
+ function markDuplicates(shortcut) {
+ let inputs = document.querySelectorAll(
+ `.shortcut-input[shortcut="${shortcut}"]`
+ );
+ for (let input of inputs) {
+ input.setAttribute("warning", "shortcuts-duplicate");
+ let row = input.closest(".shortcut-row");
+ if (row.hasAttribute("hide-before-expand")) {
+ row
+ .closest(".card")
+ .querySelector(".expand-button")
+ .setAttribute("warning", "shortcuts-duplicate");
+ }
+ }
+ }
+
+ function onShortcutChange(e) {
+ let input = e.target;
+
+ if (e.key == "Escape") {
+ input.blur();
+ return;
+ }
+
+ if (e.key == "Tab") {
+ return;
+ }
+
+ if (!e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
+ if (e.key == "Delete" || e.key == "Backspace") {
+ // Avoid triggering back-navigation.
+ e.preventDefault();
+ assignShortcutToInput(input, "");
+ return;
+ }
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Some system actions aren't in the keyset, handle them independantly.
+ if (ShortcutUtils.getSystemActionForEvent(e)) {
+ e.defaultCancelled = true;
+ setError(input, "shortcuts-system");
+ return;
+ }
+
+ let shortcutString = getShortcutForEvent(e);
+ input.value = getShortcutValue(shortcutString);
+
+ if (e.type == "keyup" || !shortcutString.length) {
+ return;
+ }
+
+ let validation = ShortcutUtils.validate(shortcutString);
+ switch (validation) {
+ case ShortcutUtils.IS_VALID:
+ // Show an error if this is already a system shortcut.
+ let chromeWindow = window.windowRoot.ownerGlobal;
+ if (ShortcutUtils.isSystem(chromeWindow, shortcutString)) {
+ setError(input, "shortcuts-system");
+ break;
+ }
+
+ // Check if shortcut is already assigned.
+ if (shortcutKeyMap.has(shortcutString)) {
+ setError(input, "shortcuts-exists", {
+ addon: getAddonName(shortcutString),
+ });
+ } else {
+ // Update the shortcut if it isn't reserved or assigned.
+ assignShortcutToInput(input, shortcutString);
+ }
+ break;
+ case ShortcutUtils.MODIFIER_REQUIRED:
+ if (AppConstants.platform == "macosx") {
+ setError(input, "shortcuts-modifier-mac");
+ } else {
+ setError(input, "shortcuts-modifier-other");
+ }
+ break;
+ case ShortcutUtils.INVALID_COMBINATION:
+ setError(input, "shortcuts-invalid");
+ break;
+ case ShortcutUtils.INVALID_KEY:
+ setError(input, "shortcuts-letter");
+ break;
+ }
+ }
+
+ function onShortcutRemove(e) {
+ let removeButton = e.target;
+ let input = removeButton.parentNode.querySelector(".shortcut-input");
+ if (input.getAttribute("shortcut")) {
+ input.value = "";
+ assignShortcutToInput(input, "");
+ }
+ }
+
+ function assignShortcutToInput(input, shortcutString) {
+ let addonId = input.closest(".card").getAttribute("addon-id");
+ let extension = extensionForAddonId(addonId);
+
+ let oldShortcut = input.getAttribute("shortcut");
+ let addonName = input.closest(".card").getAttribute("addon-name");
+ let commandName = input.getAttribute("name");
+
+ removeShortcut(oldShortcut, addonName, commandName);
+ recordShortcut(shortcutString, addonName, commandName);
+
+ // This is async, but we're not awaiting it to keep the handler sync.
+ extension.shortcuts.updateCommand({
+ name: commandName,
+ shortcut: shortcutString,
+ });
+ input.setAttribute("shortcut", shortcutString);
+ input.blur();
+ setDuplicateWarnings();
+ }
+
+ function renderNoShortcutAddons(addons) {
+ let fragment = document.importNode(
+ templates.noShortcutAddons.content,
+ true
+ );
+ let list = fragment.querySelector(".shortcuts-no-commands-list");
+ for (let addon of addons) {
+ let addonItem = document.createElement("li");
+ addonItem.textContent = addon.name;
+ addonItem.setAttribute("addon-id", addon.id);
+ list.appendChild(addonItem);
+ }
+
+ return fragment;
+ }
+
+ async function renderAddons(addons) {
+ let frag = document.createDocumentFragment();
+ let noShortcutAddons = [];
+
+ await buildDuplicateShortcutsMap(addons);
+
+ let isDuplicate = command => {
+ if (command.shortcut) {
+ let dupes = shortcutKeyMap.get(command.shortcut);
+ return dupes.size > 1;
+ }
+ return false;
+ };
+
+ for (let addon of addons) {
+ let extension = extensionForAddonId(addon.id);
+
+ // Skip this extension if it isn't a webextension.
+ if (!extension) {
+ continue;
+ }
+
+ if (extension.shortcuts) {
+ let card = document.importNode(
+ templates.card.content,
+ true
+ ).firstElementChild;
+ let icon = AddonManager.getPreferredIconURL(addon, 24, window);
+ card.setAttribute("addon-id", addon.id);
+ card.setAttribute("addon-name", addon.name);
+ card.querySelector(".addon-icon").src = icon || FALLBACK_ICON;
+ card.querySelector(".addon-name").textContent = addon.name;
+
+ let commands = await extension.shortcuts.allCommands();
+
+ // Sort the commands so the ones with shortcuts are at the top.
+ commands.sort((a, b) => {
+ if (isDuplicate(a) && isDuplicate(b)) {
+ return 0;
+ }
+ if (isDuplicate(a)) {
+ return -1;
+ }
+ if (isDuplicate(b)) {
+ return 1;
+ }
+ // Boolean compare the shortcuts to see if they're both set or unset.
+ if (!a.shortcut == !b.shortcut) {
+ return 0;
+ }
+ if (a.shortcut) {
+ return -1;
+ }
+ return 1;
+ });
+
+ let { limit, allowOver } = COLLAPSE_OPTIONS;
+ let willHideCommands = commands.length > limit + allowOver;
+ let firstHiddenInput;
+
+ for (let i = 0; i < commands.length; i++) {
+ let command = commands[i];
+
+ let row = document.importNode(
+ templates.row.content,
+ true
+ ).firstElementChild;
+
+ if (willHideCommands && i >= limit) {
+ row.setAttribute("hide-before-expand", "true");
+ }
+
+ let label = row.querySelector(".shortcut-label");
+ let descriptionId = getCommandDescriptionId(command);
+ if (descriptionId) {
+ document.l10n.setAttributes(label, descriptionId);
+ } else {
+ label.textContent = command.description || command.name;
+ }
+ let input = row.querySelector(".shortcut-input");
+ input.value = getShortcutValue(command.shortcut);
+ input.setAttribute("name", command.name);
+ input.setAttribute("shortcut", command.shortcut);
+ input.addEventListener("keydown", onShortcutChange);
+ input.addEventListener("keyup", onShortcutChange);
+ input.addEventListener("blur", inputBlurred);
+ input.addEventListener("focus", onFocus);
+
+ let removeButton = row.querySelector(".shortcut-remove-button");
+ removeButton.addEventListener("click", onShortcutRemove);
+
+ if (willHideCommands && i == limit) {
+ firstHiddenInput = input;
+ }
+
+ card.appendChild(row);
+ }
+
+ // Add an expand button, if needed.
+ if (willHideCommands) {
+ let row = document.importNode(templates.expandRow.content, true);
+ let button = row.querySelector(".expand-button");
+ let numberToShow = commands.length - limit;
+ let setLabel = type => {
+ document.l10n.setAttributes(
+ button,
+ `shortcuts-card-${type}-button`,
+ {
+ numberToShow,
+ }
+ );
+ };
+
+ setLabel("expand");
+ button.addEventListener("click", event => {
+ let expanded = card.hasAttribute("expanded");
+ if (expanded) {
+ card.removeAttribute("expanded");
+ setLabel("expand");
+ } else {
+ card.setAttribute("expanded", "true");
+ setLabel("collapse");
+ // If this as a keyboard event then focus the next input.
+ if (event.mozInputSource == MouseEvent.MOZ_SOURCE_KEYBOARD) {
+ firstHiddenInput.focus();
+ }
+ }
+ });
+ card.appendChild(row);
+ }
+
+ frag.appendChild(card);
+ } else if (!addon.hidden) {
+ noShortcutAddons.push({ id: addon.id, name: addon.name });
+ }
+ }
+
+ if (noShortcutAddons.length) {
+ frag.appendChild(renderNoShortcutAddons(noShortcutAddons));
+ }
+
+ return frag;
+ }
+
+ class AddonShortcuts extends HTMLElement {
+ connectedCallback() {
+ setDuplicateWarnings();
+ }
+
+ disconnectedCallback() {
+ error = null;
+ }
+
+ async render() {
+ loadTemplates();
+ let allAddons = await AddonManager.getAddonsByTypes(["extension"]);
+ let addons = allAddons
+ .filter(addon => addon.isActive)
+ .sort((a, b) => a.name.localeCompare(b.name));
+ let frag;
+
+ if (addons.length) {
+ frag = await renderAddons(addons);
+ } else {
+ frag = document.importNode(templates.noAddons.content, true);
+ }
+
+ this.textContent = "";
+ this.appendChild(document.importNode(templates.view.content, true));
+ error = this.querySelector(".error-message");
+ this.appendChild(frag);
+ }
+ }
+ customElements.define("addon-shortcuts", AddonShortcuts);
+}