/* 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);
}