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/. */
/* 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",
"F13",
"F14",
"F15",
"F16",
"F17",
"F18",
"F19",
];
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(([, 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("moz-message-bar");
messagebar.setAttribute("type", "warning");
document.l10n.setAttributes(
messagebar,
"shortcuts-duplicate-warning-message2",
{ shortcut }
);
messagebar.setAttribute("data-l10n-attrs", "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.inputSource == 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);
}