gecko-dev/browser/components/extensions/ext-menus.js
2017-08-08 13:18:14 +02:00

689 lines
19 KiB
JavaScript

/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
"use strict";
// The ext-* files are imported into the same scopes.
/* import-globals-from ext-browser.js */
Cu.import("resource://gre/modules/Services.jsm");
var {
ExtensionError,
} = ExtensionUtils;
Cu.import("resource://gre/modules/ExtensionParent.jsm");
var {
IconDetails,
} = ExtensionParent;
const ACTION_MENU_TOP_LEVEL_LIMIT = 6;
// Map[Extension -> Map[ID -> MenuItem]]
// Note: we want to enumerate all the menu items so
// this cannot be a weak map.
var gMenuMap = new Map();
// Map[Extension -> MenuItem]
var gRootItems = new Map();
// If id is not specified for an item we use an integer.
var gNextMenuItemID = 0;
// Used to assign unique names to radio groups.
var gNextRadioGroupID = 0;
// The max length of a menu item's label.
var gMaxLabelLength = 64;
var gMenuBuilder = {
// When a new menu is opened, this function is called and
// we populate the |xulMenu| with all the items from extensions
// to be displayed. We always clear all the items again when
// popuphidden fires.
build(contextData) {
let firstItem = true;
let xulMenu = contextData.menu;
xulMenu.addEventListener("popuphidden", this);
this.xulMenu = xulMenu;
for (let [, root] of gRootItems) {
let rootElement = this.buildElementWithChildren(root, contextData);
if (!rootElement.firstChild || !rootElement.firstChild.childNodes.length) {
// If the root has no visible children, there is no reason to show
// the root menu item itself either.
continue;
}
rootElement.setAttribute("ext-type", "top-level-menu");
rootElement = this.removeTopLevelMenuIfNeeded(rootElement);
// Display the extension icon on the root element.
if (root.extension.manifest.icons) {
this.setMenuItemIcon(rootElement, root.extension, contextData, root.extension.manifest.icons);
}
if (firstItem) {
firstItem = false;
const separator = xulMenu.ownerDocument.createElement("menuseparator");
this.itemsToCleanUp.add(separator);
xulMenu.append(separator);
}
xulMenu.appendChild(rootElement);
this.itemsToCleanUp.add(rootElement);
}
},
// Builds a context menu for browserAction and pageAction buttons.
buildActionContextMenu(contextData) {
const {menu} = contextData;
contextData.tab = tabTracker.activeTab;
contextData.pageUrl = contextData.tab.linkedBrowser.currentURI.spec;
const root = gRootItems.get(contextData.extension);
if (!root) {
return;
}
const children = this.buildChildren(root, contextData);
const visible = children.slice(0, ACTION_MENU_TOP_LEVEL_LIMIT);
if (visible.length) {
this.xulMenu = menu;
menu.addEventListener("popuphidden", this);
const separator = menu.ownerDocument.createElement("menuseparator");
menu.insertBefore(separator, menu.firstChild);
this.itemsToCleanUp.add(separator);
for (const child of visible) {
this.itemsToCleanUp.add(child);
menu.insertBefore(child, separator);
}
}
},
buildElementWithChildren(item, contextData) {
const element = this.buildSingleElement(item, contextData);
const children = this.buildChildren(item, contextData);
if (children.length) {
element.firstChild.append(...children);
}
return element;
},
buildChildren(item, contextData) {
let groupName;
let children = [];
for (let child of item.children) {
if (child.type == "radio" && !child.groupName) {
if (!groupName) {
groupName = `webext-radio-group-${gNextRadioGroupID++}`;
}
child.groupName = groupName;
} else {
groupName = null;
}
if (child.enabledForContext(contextData)) {
children.push(this.buildElementWithChildren(child, contextData));
}
}
return children;
},
removeTopLevelMenuIfNeeded(element) {
// If there is only one visible top level element we don't need the
// root menu element for the extension.
let menuPopup = element.firstChild;
if (menuPopup && menuPopup.childNodes.length == 1) {
let onlyChild = menuPopup.firstChild;
// Keep single checkbox items in the submenu on Linux since
// the extension icon overlaps the checkbox otherwise.
if (AppConstants.platform === "linux" && onlyChild.getAttribute("type") === "checkbox") {
return element;
}
onlyChild.remove();
return onlyChild;
}
return element;
},
buildSingleElement(item, contextData) {
let doc = contextData.menu.ownerDocument;
let element;
if (item.children.length > 0) {
element = this.createMenuElement(doc, item);
} else if (item.type == "separator") {
element = doc.createElement("menuseparator");
} else {
element = doc.createElement("menuitem");
}
return this.customizeElement(element, item, contextData);
},
createMenuElement(doc, item) {
let element = doc.createElement("menu");
// Menu elements need to have a menupopup child for its menu items.
let menupopup = doc.createElement("menupopup");
element.appendChild(menupopup);
return element;
},
customizeElement(element, item, contextData) {
let label = item.title;
if (label) {
if (contextData.isTextSelected && label.indexOf("%s") > -1) {
let selection = contextData.selectionText.trim();
// The rendering engine will truncate the title if it's longer than 64 characters.
// But if it makes sense let's try truncate selection text only, to handle cases like
// 'look up "%s" in MyDictionary' more elegantly.
let maxSelectionLength = gMaxLabelLength - label.length + 2;
if (maxSelectionLength > 4) {
selection = selection.substring(0, maxSelectionLength - 3) + "...";
}
label = label.replace(/%s/g, selection);
}
element.setAttribute("label", label);
}
if (item.id && item.extension && item.extension.id) {
element.setAttribute("id",
`${makeWidgetId(item.extension.id)}_${item.id}`);
}
if (item.icons) {
this.setMenuItemIcon(element, item.extension, contextData, item.icons);
}
if (item.type == "checkbox") {
element.setAttribute("type", "checkbox");
if (item.checked) {
element.setAttribute("checked", "true");
}
} else if (item.type == "radio") {
element.setAttribute("type", "radio");
element.setAttribute("name", item.groupName);
if (item.checked) {
element.setAttribute("checked", "true");
}
}
if (!item.enabled) {
element.setAttribute("disabled", "true");
}
element.addEventListener("command", event => { // eslint-disable-line mozilla/balanced-listeners
if (event.target !== event.currentTarget) {
return;
}
const wasChecked = item.checked;
if (item.type == "checkbox") {
item.checked = !item.checked;
} else if (item.type == "radio") {
// Deselect all radio items in the current radio group.
for (let child of item.parent.children) {
if (child.type == "radio" && child.groupName == item.groupName) {
child.checked = false;
}
}
// Select the clicked radio item.
item.checked = true;
}
item.tabManager.addActiveTabPermission();
let tab = item.tabManager.convert(contextData.tab);
let info = item.getClickInfo(contextData, wasChecked);
const map = {shiftKey: "Shift", altKey: "Alt", metaKey: "Command", ctrlKey: "Ctrl"};
info.modifiers = Object.keys(map).filter(key => event[key]).map(key => map[key]);
if (event.ctrlKey && AppConstants.platform === "macosx") {
info.modifiers.push("MacCtrl");
}
// Allow menus to open various actions supported in webext prior
// to notifying onclicked.
let actionFor = {
_execute_page_action: global.pageActionFor,
_execute_browser_action: global.browserActionFor,
_execute_sidebar_action: global.sidebarActionFor,
}[item.command];
if (actionFor) {
let win = event.target.ownerGlobal;
actionFor(item.extension).triggerAction(win);
}
item.extension.emit("webext-menu-menuitem-click", info, tab);
});
return element;
},
setMenuItemIcon(element, extension, contextData, icons) {
let parentWindow = contextData.menu.ownerGlobal;
let {icon} = IconDetails.getPreferredIcon(icons, extension,
16 * parentWindow.devicePixelRatio);
// The extension icons in the manifest are not pre-resolved, since
// they're sometimes used by the add-on manager when the extension is
// not enabled, and its URLs are not resolvable.
let resolvedURL = extension.baseURI.resolve(icon);
if (element.localName == "menu") {
element.setAttribute("class", "menu-iconic");
} else if (element.localName == "menuitem") {
element.setAttribute("class", "menuitem-iconic");
}
element.setAttribute("image", resolvedURL);
},
handleEvent(event) {
if (this.xulMenu != event.target || event.type != "popuphidden") {
return;
}
delete this.xulMenu;
let target = event.target;
target.removeEventListener("popuphidden", this);
for (let item of this.itemsToCleanUp) {
item.remove();
}
this.itemsToCleanUp.clear();
},
itemsToCleanUp: new Set(),
};
// Called from pageAction or browserAction popup.
global.actionContextMenu = function(contextData) {
gMenuBuilder.buildActionContextMenu(contextData);
};
const contextsMap = {
onAudio: "audio",
onEditableArea: "editable",
inFrame: "frame",
onImage: "image",
onLink: "link",
onPassword: "password",
isTextSelected: "selection",
onVideo: "video",
onBrowserAction: "browser_action",
onPageAction: "page_action",
onTab: "tab",
inToolsMenu: "tools_menu",
};
const getMenuContexts = contextData => {
let contexts = new Set();
for (const [key, value] of Object.entries(contextsMap)) {
if (contextData[key]) {
contexts.add(value);
}
}
if (contexts.size === 0) {
contexts.add("page");
}
// New non-content contexts supported in Firefox are not part of "all".
if (!contextData.onTab && !contextData.inToolsMenu) {
contexts.add("all");
}
return contexts;
};
function MenuItem(extension, createProperties, isRoot = false) {
this.extension = extension;
this.children = [];
this.parent = null;
this.tabManager = extension.tabManager;
this.setDefaults();
this.setProps(createProperties);
if (!this.hasOwnProperty("_id")) {
this.id = gNextMenuItemID++;
}
// If the item is not the root and has no parent
// it must be a child of the root.
if (!isRoot && !this.parent) {
this.root.addChild(this);
}
}
MenuItem.prototype = {
setProps(createProperties) {
for (let propName in createProperties) {
if (createProperties[propName] === null) {
// Omitted optional argument.
continue;
}
this[propName] = createProperties[propName];
}
if (createProperties.documentUrlPatterns != null) {
this.documentUrlMatchPattern = new MatchPatternSet(this.documentUrlPatterns);
}
if (createProperties.targetUrlPatterns != null) {
this.targetUrlMatchPattern = new MatchPatternSet(this.targetUrlPatterns);
}
// If a child MenuItem does not specify any contexts, then it should
// inherit the contexts specified from its parent.
if (createProperties.parentId && !createProperties.contexts) {
this.contexts = this.parent.contexts;
}
},
setDefaults() {
this.setProps({
type: "normal",
checked: false,
contexts: ["all"],
enabled: true,
});
},
set id(id) {
if (this.hasOwnProperty("_id")) {
throw new Error("Id of a MenuItem cannot be changed");
}
let isIdUsed = gMenuMap.get(this.extension).has(id);
if (isIdUsed) {
throw new Error("Id already exists");
}
this._id = id;
},
get id() {
return this._id;
},
ensureValidParentId(parentId) {
if (parentId === undefined) {
return;
}
let menuMap = gMenuMap.get(this.extension);
if (!menuMap.has(parentId)) {
throw new Error("Could not find any MenuItem with id: " + parentId);
}
for (let item = menuMap.get(parentId); item; item = item.parent) {
if (item === this) {
throw new ExtensionError("MenuItem cannot be an ancestor (or self) of its new parent.");
}
}
},
set parentId(parentId) {
this.ensureValidParentId(parentId);
if (this.parent) {
this.parent.detachChild(this);
}
if (parentId === undefined) {
this.root.addChild(this);
} else {
let menuMap = gMenuMap.get(this.extension);
menuMap.get(parentId).addChild(this);
}
},
get parentId() {
return this.parent ? this.parent.id : undefined;
},
addChild(child) {
if (child.parent) {
throw new Error("Child MenuItem already has a parent.");
}
this.children.push(child);
child.parent = this;
},
detachChild(child) {
let idx = this.children.indexOf(child);
if (idx < 0) {
throw new Error("Child MenuItem not found, it cannot be removed.");
}
this.children.splice(idx, 1);
child.parent = null;
},
get root() {
let extension = this.extension;
if (!gRootItems.has(extension)) {
let root = new MenuItem(extension,
{title: extension.name},
/* isRoot = */ true);
gRootItems.set(extension, root);
}
return gRootItems.get(extension);
},
remove() {
if (this.parent) {
this.parent.detachChild(this);
}
let children = this.children.slice(0);
for (let child of children) {
child.remove();
}
let menuMap = gMenuMap.get(this.extension);
menuMap.delete(this.id);
if (this.root == this) {
gRootItems.delete(this.extension);
}
},
getClickInfo(contextData, wasChecked) {
let mediaType;
if (contextData.onVideo) {
mediaType = "video";
}
if (contextData.onAudio) {
mediaType = "audio";
}
if (contextData.onImage) {
mediaType = "image";
}
let info = {
menuItemId: this.id,
editable: contextData.onEditableArea || contextData.onPassword,
};
function setIfDefined(argName, value) {
if (value !== undefined) {
info[argName] = value;
}
}
setIfDefined("parentMenuItemId", this.parentId);
setIfDefined("mediaType", mediaType);
setIfDefined("linkText", contextData.linkText);
setIfDefined("linkUrl", contextData.linkUrl);
setIfDefined("srcUrl", contextData.srcUrl);
setIfDefined("pageUrl", contextData.pageUrl);
setIfDefined("frameUrl", contextData.frameUrl);
setIfDefined("frameId", contextData.frameId);
setIfDefined("selectionText", contextData.selectionText);
if ((this.type === "checkbox") || (this.type === "radio")) {
info.checked = this.checked;
info.wasChecked = wasChecked;
}
return info;
},
enabledForContext(contextData) {
let contexts = getMenuContexts(contextData);
if (!this.contexts.some(n => contexts.has(n))) {
return false;
}
let docPattern = this.documentUrlMatchPattern;
let pageURI = Services.io.newURI(contextData[contextData.inFrame ? "frameUrl" : "pageUrl"]);
if (docPattern && !docPattern.matches(pageURI)) {
return false;
}
let targetPattern = this.targetUrlMatchPattern;
if (targetPattern) {
let targetUrls = [];
if (contextData.onImage || contextData.onAudio || contextData.onVideo) {
// TODO: double check if srcUrl is always set when we need it
targetUrls.push(contextData.srcUrl);
}
if (contextData.onLink) {
targetUrls.push(contextData.linkUrl);
}
if (!targetUrls.some(targetUrl => targetPattern.matches(Services.io.newURI(targetUrl)))) {
return false;
}
}
return true;
},
};
// While any extensions are active, this Tracker registers to observe/listen
// for menu events from both Tools and context menus, both content and chrome.
const menuTracker = {
menuIds: ["menu_ToolsPopup", "tabContextMenu"],
register() {
Services.obs.addObserver(this, "on-build-contextmenu");
for (const window of windowTracker.browserWindows()) {
this.onWindowOpen(window);
}
windowTracker.addOpenListener(this.onWindowOpen);
},
unregister() {
Services.obs.removeObserver(this, "on-build-contextmenu");
for (const window of windowTracker.browserWindows()) {
for (const id of this.menuIds) {
const menu = window.document.getElementById(id);
menu.removeEventListener("popupshowing", this);
}
}
windowTracker.removeOpenListener(this.onWindowOpen);
},
observe(subject, topic, data) {
subject = subject.wrappedJSObject;
gMenuBuilder.build(subject);
},
onWindowOpen(window) {
for (const id of menuTracker.menuIds) {
const menu = window.document.getElementById(id);
menu.addEventListener("popupshowing", menuTracker);
}
},
handleEvent(event) {
const menu = event.target;
if (menu.id === "menu_ToolsPopup") {
const tab = tabTracker.activeTab;
const pageUrl = tab.linkedBrowser.currentURI.spec;
gMenuBuilder.build({menu, tab, pageUrl, inToolsMenu: true});
}
if (menu.id === "tabContextMenu") {
const trigger = menu.triggerNode;
const tab = trigger.localName === "tab" ? trigger : tabTracker.activeTab;
const pageUrl = tab.linkedBrowser.currentURI.spec;
gMenuBuilder.build({menu, tab, pageUrl, onTab: true});
}
},
};
this.menusInternal = class extends ExtensionAPI {
constructor(extension) {
super(extension);
if (!gMenuMap.size) {
menuTracker.register();
}
gMenuMap.set(extension, new Map());
}
onShutdown(reason) {
let {extension} = this;
if (gMenuMap.has(extension)) {
gMenuMap.delete(extension);
gRootItems.delete(extension);
if (!gMenuMap.size) {
menuTracker.unregister();
}
}
}
getAPI(context) {
let {extension} = context;
return {
menusInternal: {
create: function(createProperties) {
// Note that the id is required by the schema. If the addon did not set
// it, the implementation of menus.create in the child should
// have added it.
let menuItem = new MenuItem(extension, createProperties);
gMenuMap.get(extension).set(menuItem.id, menuItem);
},
update: function(id, updateProperties) {
let menuItem = gMenuMap.get(extension).get(id);
if (menuItem) {
menuItem.setProps(updateProperties);
}
},
remove: function(id) {
let menuItem = gMenuMap.get(extension).get(id);
if (menuItem) {
menuItem.remove();
}
},
removeAll: function() {
let root = gRootItems.get(extension);
if (root) {
root.remove();
}
},
onClicked: new EventManager(context, "menusInternal.onClicked", fire => {
let listener = (event, info, tab) => {
context.withPendingBrowser(tab.linkedBrowser,
() => fire.sync(info, tab));
};
extension.on("webext-menu-menuitem-click", listener);
return () => {
extension.off("webext-menu-menuitem-click", listener);
};
}).api(),
},
};
}
};