Bug 1303384 - Part 3: Manage extension shortcuts page r=aswan,Gijs,flod

MozReview-Commit-ID: KeZsoB6qj88

Differential Revision: https://phabricator.services.mozilla.com/D4507

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Mark Striemer 2019-01-12 06:45:17 +00:00
Родитель 50cfffaeaa
Коммит 890829b813
13 изменённых файлов: 677 добавлений и 3 удалений

Просмотреть файл

@ -106,6 +106,7 @@ legacyWarning.description=Missing something? Some extensions are no longer suppo
legacyThemeWarning.description=Missing something? Some themes are no longer supported by %S.
listHeading.extension=Manage Your Extensions
listHeading.shortcuts=Manage Extension Shortcuts
listHeading.theme=Manage Your Themes
listHeading.plugin=Manage Your Plugins
listHeading.locale=Manage Your Languages

Просмотреть файл

@ -275,3 +275,23 @@ extensions-updates-manual-updates-found =
extensions-updates-update-selected =
.label = Install Updates
.tooltiptext = Install available updates in this list
## Extension shortcut management
shortcuts-manage =
.label = Keyboard Shortcuts
shortcuts-empty-message = There are no shortcuts for this extension.
# TODO: Confirm this copy.
shortcuts-no-addons = You don't have any active add-ons.
shortcuts-input =
.placeholder = Type a shortcut
shortcuts-browserAction = Activate extension
shortcuts-pageAction = Activate page action
shortcuts-sidebarAction = Toggle the sidebar
shortcuts-modifier-mac = Include Ctrl, Alt, or ⌘
shortcuts-modifier-other = Include Ctrl or Alt
shortcuts-invalid = Invalid combination
shortcuts-letter = Type a letter
shortcuts-system = Cant override a { -brand-short-name } shortcut

Просмотреть файл

@ -280,9 +280,14 @@ function isDiscoverEnabled() {
function setSearchLabel(type) {
let searchLabel = document.getElementById("search-label");
if (type == "extension" || type == "theme") {
let keyMap = {
extension: "extension",
shortcuts: "extension",
theme: "theme",
};
if (type in keyMap) {
searchLabel
.textContent = gStrings.ext.GetStringFromName(`searchLabel.${type}`);
.textContent = gStrings.ext.GetStringFromName(`searchLabel.${keyMap[type]}`);
searchLabel.hidden = false;
} else {
searchLabel.textContent = "";
@ -692,6 +697,7 @@ var gViewController = {
this.viewObjects.legacy = gLegacyView;
this.viewObjects.detail = gDetailView;
this.viewObjects.updates = gUpdatesView;
this.viewObjects.shortcuts = gShortcutsView;
for (let type in this.viewObjects) {
let view = this.viewObjects[type];
@ -1383,6 +1389,15 @@ var gViewController = {
gViewController.loadView("addons://list/extension");
},
},
cmd_showShortcuts: {
isEnabled() {
return true;
},
doCommand() {
gViewController.loadView("addons://shortcuts/shortcuts");
},
},
},
supportsCommand(aCommand) {
@ -2448,6 +2463,9 @@ var gListView = {
}
}
// Only show the manage shortcuts button for extensions.
document.getElementById("manage-shortcuts").hidden = this._type != "extension";
this.filterDisabledUnsigned(showOnlyDisabledUnsigned);
let legacyNotice = document.getElementById("legacy-extensions-notice");
if (showLegacyInfo) {
@ -2474,6 +2492,7 @@ var gListView = {
hide() {
gEventManager.unregisterInstallListener(this);
doPendingUninstalls(this._listBox);
document.getElementById("manage-shortcuts").hidden = true;
},
filterDisabledUnsigned(aFilter = true) {
@ -3471,6 +3490,39 @@ var gUpdatesView = {
},
};
var gShortcutsView = {
node: null,
loaded: null,
initialize() {
this.node = document.getElementById("shortcuts-view");
this.node.loadURI("chrome://mozapps/content/extensions/shortcuts.html", {
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
});
// Store a Promise for when the contentWindow will exist.
this.loaded = new Promise(resolve => this.node.addEventListener("load", resolve, {once: true}));
},
async show() {
// Ensure the Extensions category is selected in case of refresh/restart.
gCategories.select("addons://list/extension");
await this.loaded;
await this.node.contentWindow.render();
gViewController.notifyViewChanged();
},
refresh() {
return this.show();
},
hide() {},
getSelectedAddon() {
return null;
},
};
var gDragDrop = {
onDragOver(aEvent) {
if (!XPINSTALL_ENABLED) {

Просмотреть файл

@ -105,6 +105,7 @@
<command id="cmd_resetAddonAutoUpdate"/>
<command id="cmd_showUnsignedExtensions"/>
<command id="cmd_showAllExtensions"/>
<command id="cmd_showShortcuts"/>
</commandset>
<!-- view commands - these act on the selected addon -->
@ -251,6 +252,8 @@
command="cmd_restartApp"/>
</hbox>
<button id="manage-shortcuts" data-l10n-id="shortcuts-manage" command="cmd_showShortcuts" hidden="true"/>
<toolbarbutton id="header-utils-btn" type="menu"
data-l10n-id="tools-menu">
<menupopup id="utils-menu">
@ -357,6 +360,9 @@
<richlistbox id="addon-list" class="list" flex="1"/>
</vbox>
<!-- extension shortcuts view -->
<browser id="shortcuts-view" type="content" flex="1" disablehistory="true"/>
<!-- legacy extensions view -->
<vbox id="legacy-view" flex="1" class="view-pane" align="stretch" tabindex="0">
<vbox id="legacy-extensions-info">

Просмотреть файл

@ -0,0 +1,82 @@
.body {
margin-inline-start: 28px;
}
.shortcut.card {
/* Preferences content is 664px and the cards have 16px of left/right padding. */
width: 632px;
margin-bottom: 16px;
}
.shortcut.card:first-of-type {
margin-top: 8px;
}
.shortcut.card:hover {
box-shadow: var(--card-shadow);
}
.card-heading-icon {
width: 24px;
height: 24px;
margin-inline-end: 16px;
}
.card-heading {
display: flex;
font-weight: 600;
}
.shortcuts-empty-label {
margin-top: 16px;
}
.shortcut-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10px;
}
.shortcut-input {
font-size: 12px;
padding: 4px 8px;
}
.extension-heading {
display: flex;
}
.error-message {
--error-background: var(--red-60);
color: white;
display: flex;
flex-direction: column;
position: absolute;
visibility: hidden;
}
.error-message-icon {
margin-left: 10px;
width: 14px;
height: 8px;
fill: var(--error-background);
stroke: var(--error-background);
-moz-context-properties: fill, stroke;
}
.error-message-label {
background-color: var(--error-background);
border-radius: 2px;
margin: 0;
padding: 5px 10px;
}
.error-message-arrow {
background-color: var(--error-background);
content: "";
max-height: 8px;
width: 8px;
transform: translate(4px, -6px) rotate(45deg);
position: absolute;
}

Просмотреть файл

@ -0,0 +1,51 @@
<!-- 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/. -->
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="chrome://global/skin/in-content/common.css" type="text/css"/>
<link rel="stylesheet" href="chrome://mozapps/content/extensions/shortcuts.css" type="text/css"/>
<link rel="localization" href="branding/brand.ftl"/>
<link rel="localization" href="toolkit/about/aboutAddons.ftl"/>
<script type="application/javascript" src="chrome://mozapps/content/extensions/shortcuts.js"></script>
</head>
<body id="body">
<div class="body">
<div class="error-message">
<img class="error-message-icon" src="chrome://global/skin/arrow/panelarrow-vertical.svg"/>
<div class="error-message-label"></div>
</div>
<div id="addon-shortcuts"></div>
<template id="card-template">
<div class="card shortcut">
<div class="card-heading">
<img class="card-heading-icon addon-icon"/>
<span class="addon-name"></span>
</div>
</div>
</template>
<template id="shortcut-row-template">
<div class="shortcut-row">
<label class="shortcut-label"></label>
<input class="shortcut-input" data-l10n-id="shortcuts-input" type="text" readonly/>
</div>
</template>
<template id="shortcuts-empty-template">
<div class="shortcuts-empty-label" data-l10n-id="shortcuts-empty-message"></div>
</template>
<template id="shortcuts-no-addons">
<div data-l10n-id="shortcuts-no-addons"></div>
</template>
</div>
</body>
</html>

Просмотреть файл

@ -0,0 +1,321 @@
/* 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/. */
/* exported render */
"use strict";
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetters(this, {
AddonManager: "resource://gre/modules/AddonManager.jsm",
AppConstants: "resource://gre/modules/AppConstants.jsm",
ShortcutUtils: "resource://gre/modules/ShortcutUtils.jsm",
});
let templatesLoaded = false;
const templates = {};
function loadTemplates() {
if (templatesLoaded) return;
templatesLoaded = true;
templates.card = document.getElementById("card-template");
templates.row = document.getElementById("shortcut-row-template");
templates.empty = document.getElementById("shortcuts-empty-template");
templates.noAddons = document.getElementById("shortcuts-no-addons");
}
function extensionForAddonId(id) {
let policy = WebExtensionPolicy.getByID(id);
return policy && policy.extension;
}
let builtInNames = new Map([
["_execute_browser_action", "shortcuts-browserAction"],
["_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 > 0) {
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(input, messageId) {
if (!error) error = document.querySelector(".error-message");
let {x, y, height} = input.getBoundingClientRect();
error.style.top = `${y + window.scrollY + height - 5}px`;
error.style.left = `${x}px`;
document.l10n.setAttributes(
error.querySelector(".error-message-label"), messageId);
error.style.visibility = "visible";
}
function inputBlurred(e) {
if (!error) error = document.querySelector(".error-message");
error.style.visibility = "hidden";
e.target.value = getShortcutValue(e.target.getAttribute("shortcut"));
}
function clearValue(e) {
e.target.value = "";
}
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("+");
}
function onShortcutChange(e) {
let input = e.target;
if (e.key == "Escape") {
input.blur();
return;
}
if (e.key == "Tab") {
return;
}
e.preventDefault();
e.stopPropagation();
let shortcutString = getShortcutForEvent(e);
input.value = getShortcutValue(shortcutString);
if (e.type == "keyup" || shortcutString.length == 0) {
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;
}
// Update the shortcut if it isn't reserved.
let addonId = input.closest(".card").getAttribute("addon-id");
let extension = extensionForAddonId(addonId);
// This is async, but we're not awaiting it to keep the handler sync.
extension.shortcuts.updateCommand({
name: input.getAttribute("name"),
shortcut: shortcutString,
});
input.setAttribute("shortcut", shortcutString);
input.blur();
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;
}
}
async function renderAddons(addons) {
let frag = document.createDocumentFragment();
for (let addon of addons) {
let extension = extensionForAddonId(addon.id);
// Skip this extension if it isn't a webextension.
if (!extension) continue;
let card = document.importNode(
templates.card.content, true).firstElementChild;
let icon = AddonManager.getPreferredIconURL(addon, 24, window);
card.setAttribute("addon-id", addon.id);
card.querySelector(".addon-icon").src = icon;
card.querySelector(".addon-name").textContent = addon.name;
if (extension.shortcuts) {
let commands = await extension.shortcuts.allCommands();
for (let command of commands) {
let row = document.importNode(templates.row.content, 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", clearValue);
card.appendChild(row);
}
} else {
card.appendChild(document.importNode(templates.empty.content, true));
}
frag.appendChild(card);
}
return frag;
}
async function render() {
loadTemplates();
let allAddons = await AddonManager.getAddonsByTypes(["extension"]);
let addons = allAddons
.filter(addon => !addon.isSystem && addon.isActive)
.sort((a, b) => a.name.localeCompare(b.name));
let frag;
if (addons.length > 0) {
frag = await renderAddons(addons);
} else {
frag = document.importNode(templates.noAddons.content, true);
}
let container = document.getElementById("addon-shortcuts");
container.textContent = "";
container.appendChild(frag);
}

Просмотреть файл

@ -5,6 +5,9 @@
toolkit.jar:
% content mozapps %content/mozapps/
content/mozapps/extensions/default-theme-icon.svg (content/default-theme-icon.svg)
content/mozapps/extensions/shortcuts.html (content/shortcuts.html)
content/mozapps/extensions/shortcuts.css (content/shortcuts.css)
content/mozapps/extensions/shortcuts.js (content/shortcuts.js)
#ifndef MOZ_FENNEC
* content/mozapps/extensions/extensions.xul (content/extensions.xul)
content/mozapps/extensions/extensions.css (content/extensions.css)

Просмотреть файл

@ -5,6 +5,10 @@ module.exports = {
"plugin:mozilla/browser-test"
],
"env": {
"webextensions": true,
},
"rules": {
"no-unused-vars": ["error", {"args": "none", "varsIgnorePattern": "^end_test$"}],
}

Просмотреть файл

@ -81,7 +81,7 @@ skip-if = verify
[browser_legacy.js]
[browser_legacy_pre57.js]
[browser_list.js]
[browser_theme_previews.js]
[browser_manage_shortcuts.js]
[browser_manualupdates.js]
[browser_pluginprefs.js]
[browser_pluginprefs_is_not_disabled.js]
@ -92,6 +92,7 @@ skip-if = verify
[browser_sorting_plugins.js]
[browser_tabsettings.js]
[browser_task_next_test.js]
[browser_theme_previews.js]
[browser_types.js]
[browser_uninstalling.js]
[browser_updateid.js]

Просмотреть файл

@ -0,0 +1,126 @@
"use strict";
let gManagerWindow;
let gCategoryUtilities;
const {PromiseTestUtils} = ChromeUtils.import("resource://testing-common/PromiseTestUtils.jsm", {});
PromiseTestUtils.whitelistRejectionsGlobally(/Message manager disconnected/);
add_task(async function testUpdatingCommands() {
let commands = {
commandOne: {
suggested_key: {default: "Shift+Alt+4"},
},
commandTwo: {
description: "Command Two!",
suggested_key: {default: "Alt+4"},
},
_execute_browser_action: {
suggested_key: {default: "Shift+Alt+5"},
},
};
let extension = ExtensionTestUtils.loadExtension({
manifest: {
commands,
browser_action: {default_popup: "popup.html"},
},
background() {
browser.commands.onCommand.addListener(commandName => {
browser.test.sendMessage("oncommand", commandName);
});
browser.test.sendMessage("ready");
},
useAddonManager: "temporary",
});
await extension.startup();
await extension.awaitMessage("ready");
gManagerWindow = await open_manager(null);
gCategoryUtilities = new CategoryUtilities(gManagerWindow);
await gCategoryUtilities.openType("extension");
async function checkShortcut(name, key, modifiers) {
EventUtils.synthesizeKey(key, modifiers);
let message = await extension.awaitMessage("oncommand");
is(message, name, `Expected onCommand listener to fire with the correct name: ${name}`);
}
// Check that the original shortcuts work.
await checkShortcut("commandOne", "4", {shiftKey: true, altKey: true});
await checkShortcut("commandTwo", "4", {altKey: true});
// There should be a manage shortcuts link.
let doc = gManagerWindow.document;
let shortcutsLink = doc.getElementById("manage-shortcuts");
ok(!shortcutsLink.hidden, "The shortcuts link is visible");
// Open the shortcuts view.
shortcutsLink.click();
await wait_for_view_load(gManagerWindow);
doc = doc.getElementById("shortcuts-view").contentDocument;
let card = doc.querySelector(`.card[addon-id="${extension.id}"]`);
ok(card, `There is a card for the extension`);
let inputs = card.querySelectorAll(".shortcut-input");
is(inputs.length, Object.keys(commands).length, "There is an input for each command");
for (let input of inputs) {
// Change the shortcut.
input.focus();
EventUtils.synthesizeKey("7", {shiftKey: true, altKey: true});
// Wait for the shortcut attribute to change.
await BrowserTestUtils.waitForCondition(
() => input.getAttribute("shortcut") == "Alt+Shift+7");
// Check that the change worked (but skip if browserAction).
if (input.getAttribute("name") != "_execute_browser_action") {
await checkShortcut(input.getAttribute("name"), "7", {shiftKey: true, altKey: true});
}
// Change it again so it doesn't conflict with the next command.
input.focus();
EventUtils.synthesizeKey("9", {shiftKey: true, altKey: true});
await BrowserTestUtils.waitForCondition(
() => input.getAttribute("shortcut") == "Alt+Shift+9");
}
// Check that errors can be shown.
let input = inputs[0];
let error = doc.querySelector(".error-message");
let label = error.querySelector(".error-message-label");
is(error.style.visibility, "hidden", "The error is initially hidden");
// Try a shortcut with only shift for a modifier.
input.focus();
EventUtils.synthesizeKey("J", {shiftKey: true});
let possibleErrors = ["shortcuts-modifier-mac", "shortcuts-modifier-other"];
ok(possibleErrors.includes(label.dataset.l10nId), `The message is set`);
is(error.style.visibility, "visible", "The error is shown");
// Escape should clear the focus and hide the error.
is(doc.activeElement, input, "The input is focused");
EventUtils.synthesizeKey("Escape", {});
ok(doc.activeElement != input, "The input is no longer focused");
is(error.style.visibility, "hidden", "The error is hidden");
// Check the label uses the description first, and has a default for the special cases.
function checkLabel(name, value) {
let input = doc.querySelector(`.shortcut-input[name="${name}"]`);
let label = input.previousElementSibling;
if (label.dataset.l10nId) {
is(label.dataset.l10nId, value, "The l10n-id is set");
} else {
is(label.textContent, value, "The textContent is set");
}
}
checkLabel("commandOne", "commandOne");
checkLabel("commandTwo", "Command Two!");
checkLabel("_execute_browser_action", "shortcuts-browserAction");
await close_manager(gManagerWindow);
await extension.unload();
});

Просмотреть файл

@ -250,11 +250,17 @@ button.warning {
}
}
#manage-shortcuts {
margin: 0 4px;
min-height: 30px;
}
#header-utils-btn {
-moz-appearance: none;
border: 1px solid var(--in-content-box-border-color);
border-radius: 2px;
line-height: 20px;
min-height: 30px;
background-color: var(--in-content-page-background);
padding-right: 10px;
padding-left: 10px;

Просмотреть файл

@ -67,6 +67,7 @@
--grey-90-a50: rgba(12, 12, 13, 0.5);
--red-50: #ff0039;
--red-50-a30: rgba(255, 0, 57, 0.3);
--red-60: #d70022;
--yellow-50: #ffe900;
--yellow-90: #3e2800;