summaryrefslogtreecommitdiffstats
path: root/application/basilisk/components/extensions/ext-contextMenus.js
diff options
context:
space:
mode:
Diffstat (limited to 'application/basilisk/components/extensions/ext-contextMenus.js')
-rw-r--r--application/basilisk/components/extensions/ext-contextMenus.js640
1 files changed, 640 insertions, 0 deletions
diff --git a/application/basilisk/components/extensions/ext-contextMenus.js b/application/basilisk/components/extensions/ext-contextMenus.js
new file mode 100644
index 000000000..34a828f13
--- /dev/null
+++ b/application/basilisk/components/extensions/ext-contextMenus.js
@@ -0,0 +1,640 @@
+/* -*- 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,
+ ExtensionError,
+ IconDetails,
+} = ExtensionUtils;
+
+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 gContextMenuMap = 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 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.
+ 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) {
+ let parentWindow = contextData.menu.ownerGlobal;
+ let extension = root.extension;
+
+ let {icon} = IconDetails.getPreferredIcon(extension.manifest.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 = root.extension.baseURI.resolve(icon);
+
+ if (rootElement.localName == "menu") {
+ rootElement.setAttribute("class", "menu-iconic");
+ } else if (rootElement.localName == "menuitem") {
+ rootElement.setAttribute("class", "menuitem-iconic");
+ }
+ rootElement.setAttribute("image", resolvedURL);
+ }
+
+ 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 = TabManager.activeTab;
+ contextData.pageUrl = contextData.tab.linkedBrowser.currentURI.spec;
+
+ const root = gRootItems.get(contextData.extension);
+ 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;
+ 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 (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);
+ item.extension.emit("webext-contextmenu-menuitem-click", info, tab);
+ });
+
+ return element;
+ },
+
+ 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);
+};
+
+function getContexts(contextData) {
+ let contexts = new Set();
+
+ 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.onPassword) {
+ contexts.add("password");
+ }
+
+ if (contextData.onImage) {
+ contexts.add("image");
+ }
+
+ if (contextData.onVideo) {
+ contexts.add("video");
+ }
+
+ if (contextData.onAudio) {
+ contexts.add("audio");
+ }
+
+ if (contextData.onPageAction) {
+ contexts.add("page_action");
+ }
+
+ if (contextData.onBrowserAction) {
+ contexts.add("browser_action");
+ }
+
+ if (contexts.size === 0) {
+ contexts.add("page");
+ }
+
+ if (contextData.onTab) {
+ contexts.add("tab");
+ } else {
+ contexts.add("all");
+ }
+
+ return contexts;
+}
+
+function MenuItem(extension, createProperties, isRoot = false) {
+ this.extension = extension;
+ 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);
+ }
+
+ // 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 = 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 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 = 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,
+ {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);
+ }
+ },
+
+ 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("linkUrl", contextData.linkUrl);
+ setIfDefined("srcUrl", contextData.srcUrl);
+ setIfDefined("pageUrl", contextData.pageUrl);
+ setIfDefined("frameUrl", contextData.frameUrl);
+ setIfDefined("selectionText", contextData.selectionText);
+
+ if ((this.type === "checkbox") || (this.type === "radio")) {
+ info.checked = this.checked;
+ info.wasChecked = wasChecked;
+ }
+
+ return info;
+ },
+
+ enabledForContext(contextData) {
+ let contexts = getContexts(contextData);
+ if (!this.contexts.some(n => contexts.has(n))) {
+ return false;
+ }
+
+ let docPattern = this.documentUrlMatchPattern;
+ let pageURI = Services.io.newURI(contextData.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(NetUtil.newURI(targetUrl)))) {
+ return false;
+ }
+ }
+
+ return true;
+ },
+};
+
+// While any extensions are active, this Tracker registers to observe/listen
+// for contex-menu events from both content and chrome.
+const contextMenuTracker = {
+ register() {
+ Services.obs.addObserver(this, "on-build-contextmenu", false);
+ for (const window of WindowListManager.browserWindows()) {
+ this.onWindowOpen(window);
+ }
+ WindowListManager.addOpenListener(this.onWindowOpen);
+ },
+
+ unregister() {
+ Services.obs.removeObserver(this, "on-build-contextmenu");
+ for (const window of WindowListManager.browserWindows()) {
+ const menu = window.document.getElementById("tabContextMenu");
+ menu.removeEventListener("popupshowing", this);
+ }
+ WindowListManager.removeOpenListener(this.onWindowOpen);
+ },
+
+ observe(subject, topic, data) {
+ subject = subject.wrappedJSObject;
+ gMenuBuilder.build(subject);
+ },
+
+ onWindowOpen(window) {
+ const menu = window.document.getElementById("tabContextMenu");
+ menu.addEventListener("popupshowing", contextMenuTracker);
+ },
+
+ handleEvent(event) {
+ const menu = event.target;
+ if (menu.id === "tabContextMenu") {
+ const trigger = menu.triggerNode;
+ const tab = trigger.localName === "tab" ? trigger : TabManager.activeTab;
+ const pageUrl = tab.linkedBrowser.currentURI.spec;
+ gMenuBuilder.build({menu, tab, pageUrl, onTab: true});
+ }
+ },
+};
+
+var gExtensionCount = 0;
+/* eslint-disable mozilla/balanced-listeners */
+extensions.on("startup", (type, extension) => {
+ gContextMenuMap.set(extension, new Map());
+ if (++gExtensionCount == 1) {
+ contextMenuTracker.register();
+ }
+});
+
+extensions.on("shutdown", (type, extension) => {
+ gContextMenuMap.delete(extension);
+ gRootItems.delete(extension);
+ if (--gExtensionCount == 0) {
+ contextMenuTracker.unregister();
+ }
+});
+/* eslint-enable mozilla/balanced-listeners */
+
+extensions.registerSchemaAPI("contextMenus", "addon_parent", context => {
+ let {extension} = context;
+ return {
+ contextMenus: {
+ createInternal: function(createProperties) {
+ // Note that the id is required by the schema. If the addon did not set
+ // it, the implementation of contextMenus.create in the child should
+ // have added it.
+ let menuItem = new MenuItem(extension, createProperties);
+ gContextMenuMap.get(extension).set(menuItem.id, menuItem);
+ },
+
+ update: function(id, updateProperties) {
+ let menuItem = gContextMenuMap.get(extension).get(id);
+ if (menuItem) {
+ menuItem.setProps(updateProperties);
+ }
+ },
+
+ remove: function(id) {
+ let menuItem = gContextMenuMap.get(extension).get(id);
+ if (menuItem) {
+ menuItem.remove();
+ }
+ },
+
+ removeAll: function() {
+ let root = gRootItems.get(extension);
+ if (root) {
+ root.remove();
+ }
+ },
+
+ onClicked: new EventManager(context, "contextMenus.onClicked", fire => {
+ let listener = (event, info, tab) => {
+ fire(info, tab);
+ };
+
+ extension.on("webext-contextmenu-menuitem-click", listener);
+ return () => {
+ extension.off("webext-contextmenu-menuitem-click", listener);
+ };
+ }).api(),
+ },
+ };
+});