524 lines
14 KiB
JavaScript
524 lines
14 KiB
JavaScript
|
/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
|
||
|
/* vim: set sts=2 sw=2 et tw=80: */
|
||
|
"use strict";
|
||
|
|
||
|
Cu.import("resource://gre/modules/ExtensionUtils.jsm");
|
||
|
Cu.import("resource://gre/modules/MatchPattern.jsm");
|
||
|
Cu.import("resource://gre/modules/Services.jsm");
|
||
|
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||
|
|
||
|
var {
|
||
|
EventManager,
|
||
|
runSafe,
|
||
|
} = ExtensionUtils;
|
||
|
|
||
|
// Map[Extension -> Map[ID -> MenuItem]]
|
||
|
// Note: we want to enumerate all the menu items so
|
||
|
// this cannot be a weak map.
|
||
|
var gContextMenuMap = new Map();
|
||
|
|
||
|
// Map[Extension -> MenuItem]
|
||
|
var gRootItems = new Map();
|
||
|
|
||
|
// Not really used yet, will be used for event pages.
|
||
|
var gOnClickedCallbacksMap = new WeakMap();
|
||
|
|
||
|
// 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;
|
||
|
|
||
|
// When a new contextMenu 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.
|
||
|
var gMenuBuilder = {
|
||
|
build: function(contextData) {
|
||
|
let xulMenu = contextData.menu;
|
||
|
xulMenu.addEventListener("popuphidden", this);
|
||
|
this.xulMenu = xulMenu;
|
||
|
for (let [, root] of gRootItems) {
|
||
|
let rootElement = this.buildElementWithChildren(root, contextData);
|
||
|
if (!rootElement.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) {
|
||
|
let parentWindow = contextData.menu.ownerDocument.defaultView;
|
||
|
let extension = root.extension;
|
||
|
|
||
|
let url = IconDetails.getURL(extension.manifest.icons, parentWindow, extension, 16 /* size */);
|
||
|
let resolvedURL = root.extension.baseURI.resolve(url);
|
||
|
|
||
|
if (rootElement.localName == "menu") {
|
||
|
rootElement.setAttribute("class", "menu-iconic");
|
||
|
} else if (rootElement.localName == "menuitem") {
|
||
|
rootElement.setAttribute("class", "menuitem-iconic");
|
||
|
}
|
||
|
rootElement.setAttribute("image", resolvedURL);
|
||
|
}
|
||
|
|
||
|
xulMenu.appendChild(rootElement);
|
||
|
this.itemsToCleanUp.add(rootElement);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
buildElementWithChildren(item, contextData) {
|
||
|
let element = this.buildSingleElement(item, contextData);
|
||
|
let groupName;
|
||
|
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)) {
|
||
|
let childElement = this.buildElementWithChildren(child, contextData);
|
||
|
// Here element must be a menu element and its first child
|
||
|
// is a menupopup, we have to append its children to this
|
||
|
// menupopup.
|
||
|
element.firstChild.appendChild(childElement);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return element;
|
||
|
},
|
||
|
|
||
|
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;
|
||
|
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;
|
||
|
// 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.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 (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();
|
||
|
if (item.onclick) {
|
||
|
let clickData = item.getClickData(contextData, event);
|
||
|
runSafe(item.extContext, item.onclick, clickData);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
return element;
|
||
|
},
|
||
|
|
||
|
handleEvent: function(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(),
|
||
|
};
|
||
|
|
||
|
function contextMenuObserver(subject, topic, data) {
|
||
|
subject = subject.wrappedJSObject;
|
||
|
gMenuBuilder.build(subject);
|
||
|
}
|
||
|
|
||
|
function getContexts(contextData) {
|
||
|
let contexts = new Set(["all"]);
|
||
|
|
||
|
contexts.add("page");
|
||
|
|
||
|
if (contextData.inFrame) {
|
||
|
contexts.add("frame");
|
||
|
}
|
||
|
|
||
|
if (contextData.isTextSelected) {
|
||
|
contexts.add("selection");
|
||
|
}
|
||
|
|
||
|
if (contextData.onLink) {
|
||
|
contexts.add("link");
|
||
|
}
|
||
|
|
||
|
if (contextData.onEditableArea) {
|
||
|
contexts.add("editable");
|
||
|
}
|
||
|
|
||
|
if (contextData.onImage) {
|
||
|
contexts.add("image");
|
||
|
}
|
||
|
|
||
|
if (contextData.onVideo) {
|
||
|
contexts.add("video");
|
||
|
}
|
||
|
|
||
|
if (contextData.onAudio) {
|
||
|
contexts.add("audio");
|
||
|
}
|
||
|
|
||
|
return contexts;
|
||
|
}
|
||
|
|
||
|
function MenuItem(extension, extContext, createProperties, isRoot = false) {
|
||
|
this.extension = extension;
|
||
|
this.extContext = extContext;
|
||
|
this.children = [];
|
||
|
this.parent = null;
|
||
|
this.tabManager = TabManager.for(extension);
|
||
|
|
||
|
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 MatchPattern(this.documentUrlPatterns);
|
||
|
}
|
||
|
|
||
|
if (createProperties.targetUrlPatterns != null) {
|
||
|
this.targetUrlMatchPattern = new MatchPattern(this.targetUrlPatterns);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
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 = gContextMenuMap.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 = gContextMenuMap.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 Error("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 = gContextMenuMap.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, this.context,
|
||
|
{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 = gContextMenuMap.get(this.extension);
|
||
|
menuMap.delete(this.id);
|
||
|
if (this.root == this) {
|
||
|
gRootItems.delete(this.extension);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
getClickData(contextData, event) {
|
||
|
let mediaType;
|
||
|
if (contextData.onVideo) {
|
||
|
mediaType = "video";
|
||
|
}
|
||
|
if (contextData.onAudio) {
|
||
|
mediaType = "audio";
|
||
|
}
|
||
|
if (contextData.onImage) {
|
||
|
mediaType = "image";
|
||
|
}
|
||
|
|
||
|
let clickData = {
|
||
|
menuItemId: this.id,
|
||
|
};
|
||
|
|
||
|
function setIfDefined(argName, value) {
|
||
|
if (value) {
|
||
|
clickData[argName] = value;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
let tab = contextData.tab ? TabManager.convert(this.extension, contextData.tab)
|
||
|
: undefined;
|
||
|
|
||
|
setIfDefined("parentMenuItemId", this.parentId);
|
||
|
setIfDefined("mediaType", mediaType);
|
||
|
setIfDefined("linkUrl", contextData.linkUrl);
|
||
|
setIfDefined("srcUrl", contextData.srcUrl);
|
||
|
setIfDefined("pageUrl", contextData.pageUrl);
|
||
|
setIfDefined("frameUrl", contextData.frameUrl);
|
||
|
setIfDefined("selectionText", contextData.selectionText);
|
||
|
setIfDefined("editable", contextData.onEditableArea);
|
||
|
setIfDefined("tab", tab);
|
||
|
|
||
|
return clickData;
|
||
|
},
|
||
|
|
||
|
enabledForContext(contextData) {
|
||
|
let contexts = getContexts(contextData);
|
||
|
if (!this.contexts.some(n => contexts.has(n))) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
let docPattern = this.documentUrlMatchPattern;
|
||
|
if (docPattern && !docPattern.matches(contextData.pageUrl)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
let isMedia = contextData.onImage || contextData.onAudio || contextData.onVideo;
|
||
|
let targetPattern = this.targetUrlMatchPattern;
|
||
|
if (isMedia && targetPattern && !targetPattern.matches(contextData.srcURL)) {
|
||
|
// TODO: double check if mediaURL is always set when we need it
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
},
|
||
|
};
|
||
|
|
||
|
var gExtensionCount = 0;
|
||
|
/* eslint-disable mozilla/balanced-listeners */
|
||
|
extensions.on("startup", (type, extension) => {
|
||
|
gContextMenuMap.set(extension, new Map());
|
||
|
gRootItems.delete(extension);
|
||
|
if (++gExtensionCount == 1) {
|
||
|
Services.obs.addObserver(contextMenuObserver,
|
||
|
"on-build-contextmenu",
|
||
|
false);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
extensions.on("shutdown", (type, extension) => {
|
||
|
gContextMenuMap.delete(extension);
|
||
|
if (--gExtensionCount == 0) {
|
||
|
Services.obs.removeObserver(contextMenuObserver,
|
||
|
"on-build-contextmenu");
|
||
|
}
|
||
|
});
|
||
|
/* eslint-enable mozilla/balanced-listeners */
|
||
|
|
||
|
extensions.registerSchemaAPI("contextMenus", "contextMenus", (extension, context) => {
|
||
|
return {
|
||
|
contextMenus: {
|
||
|
create: function(createProperties, callback) {
|
||
|
let menuItem = new MenuItem(extension, context, createProperties);
|
||
|
gContextMenuMap.get(extension).set(menuItem.id, menuItem);
|
||
|
if (callback) {
|
||
|
runSafe(context, callback);
|
||
|
}
|
||
|
return menuItem.id;
|
||
|
},
|
||
|
|
||
|
update: function(id, updateProperties) {
|
||
|
let menuItem = gContextMenuMap.get(extension).get(id);
|
||
|
if (menuItem) {
|
||
|
menuItem.setProps(updateProperties);
|
||
|
}
|
||
|
return Promise.resolve();
|
||
|
},
|
||
|
|
||
|
remove: function(id) {
|
||
|
let menuItem = gContextMenuMap.get(extension).get(id);
|
||
|
if (menuItem) {
|
||
|
menuItem.remove();
|
||
|
}
|
||
|
return Promise.resolve();
|
||
|
},
|
||
|
|
||
|
removeAll: function() {
|
||
|
let root = gRootItems.get(extension);
|
||
|
if (root) {
|
||
|
root.remove();
|
||
|
}
|
||
|
return Promise.resolve();
|
||
|
},
|
||
|
|
||
|
// TODO: implement this once event pages are ready.
|
||
|
onClicked: new EventManager(context, "contextMenus.onClicked", fire => {
|
||
|
let callback = menuItem => {
|
||
|
fire(menuItem.data);
|
||
|
};
|
||
|
|
||
|
gOnClickedCallbacksMap.set(extension, callback);
|
||
|
return () => {
|
||
|
gOnClickedCallbacksMap.delete(extension);
|
||
|
};
|
||
|
}).api(),
|
||
|
},
|
||
|
};
|
||
|
});
|