679 lines
18 KiB
JavaScript
679 lines
18 KiB
JavaScript
/* 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, focusedExtensionId) {
|
|
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);
|
|
if (focusedExtensionId && addon.id === focusedExtensionId) {
|
|
card.classList.add("focused-extension");
|
|
}
|
|
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;
|
|
}
|
|
|
|
// focusExtension() is called from the view-loaded listener so that it doesn't
|
|
// interfere with ScrollOffsets.restore() in view-controller.js.
|
|
function focusExtension() {
|
|
document
|
|
.querySelector(".shortcut.card.focused-extension")
|
|
?.scrollIntoView({ block: "center" });
|
|
}
|
|
|
|
class AddonShortcuts extends HTMLElement {
|
|
connectedCallback() {
|
|
document.addEventListener("view-loaded", focusExtension);
|
|
setDuplicateWarnings();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
document.removeEventListener("view-loaded", focusExtension);
|
|
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 extensionId = this.getAttribute("extension-id");
|
|
let frag;
|
|
|
|
if (addons.length) {
|
|
frag = await renderAddons(addons, extensionId);
|
|
} 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);
|
|
}
|