bug 1253418 - implement contextMenu page_action and browser_action contexts draft
authorTomislav Jovanovic <tomica@gmail.com>
Wed, 09 Nov 2016 21:03:33 +0100
changeset 440455 e2a3410748eaa976c79ed1b85663b54308ed00f4
parent 440454 05e5b12f41df270b31955ff7e6d09245c1f83a7a
child 537370 7627547f10e3e909e13c007c5d53cf7d3623ccae
push id36220
push userbmo:tomica@gmail.com
push dateThu, 17 Nov 2016 14:39:39 +0000
bugs1253418
milestone53.0a1
bug 1253418 - implement contextMenu page_action and browser_action contexts MozReview-Commit-ID: Ftp77zmxo4B
browser/components/extensions/ext-browserAction.js
browser/components/extensions/ext-contextMenus.js
browser/components/extensions/ext-pageAction.js
browser/components/extensions/test/browser/browser.ini
browser/components/extensions/test/browser/browser_ext_commands_getAll.js
browser/components/extensions/test/browser/browser_ext_contextMenus_actionMenus.js
browser/components/extensions/test/browser/head.js
--- a/browser/components/extensions/ext-browserAction.js
+++ b/browser/components/extensions/ext-browserAction.js
@@ -102,17 +102,20 @@ BrowserAction.prototype = {
         }
       },
 
       onCreated: node => {
         node.classList.add("badged-button");
         node.classList.add("webextension-browser-action");
         node.setAttribute("constrain-size", "true");
 
-        node.onmousedown = event => this.handleEvent(event);
+        /* eslint-disable mozilla/balanced-listeners */
+        node.addEventListener("mousedown", this);
+        node.addEventListener("contextmenu", this);
+        /* eslint-enable mozilla/balanced-listeners */
 
         this.updateButton(node, this.defaults);
       },
 
       onViewShowing: event => {
         let document = event.target.ownerDocument;
         let tabbrowser = document.defaultView.gBrowser;
 
@@ -222,16 +225,20 @@ BrowserAction.prototype = {
               this.pendingPopupTimeout = setTimeout(() => this.clearPopup(),
                                                     POPUP_PRELOAD_TIMEOUT_MS);
             } else {
               this.clearPopup();
             }
           }
         }
         break;
+
+      case "contextmenu":
+        global.actionContextMenu(this.extension, button, {onBrowserAction: true});
+        break;
     }
   },
 
   /**
    * Returns a potentially pre-loaded popup for the given URL in the given
    * window. If a matching pre-load popup already exists, returns that.
    * Otherwise, initializes a new one.
    *
--- a/browser/components/extensions/ext-contextMenus.js
+++ b/browser/components/extensions/ext-contextMenus.js
@@ -8,16 +8,18 @@ Cu.import("resource://gre/modules/Servic
 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();
 
@@ -25,22 +27,22 @@ var gRootItems = new Map();
 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) {
+  // 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 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.
@@ -70,39 +72,71 @@ var gMenuBuilder = {
         rootElement.setAttribute("image", resolvedURL);
       }
 
       xulMenu.appendChild(rootElement);
       this.itemsToCleanUp.add(rootElement);
     }
   },
 
+  // Builds a context menu for browserAction and pageAction buttons.
+  buildActionContextMenu(extension, button, contextData) {
+    const document = button.ownerDocument;
+    const parent = document.getElementById("mainPopupSet");
+    const menu = document.createElement("menupopup");
+    menu.id = "Extensions-ActionContextMenu";
+
+    contextData.menu = menu;
+    contextData.tab = TabManager.activeTab;
+    contextData.pageUrl = contextData.tab.linkedBrowser.currentURI.spec;
+
+    const root = gRootItems.get(extension);
+    const children = this.buildChildren(root, contextData);
+    const visible = children.slice(0, ACTION_MENU_TOP_LEVEL_LIMIT);
+    menu.append(...visible);
+
+    if (visible.length) {
+      parent.append(menu);
+      button.setAttribute("context", menu.id);
+
+      this.xulMenu = menu;
+      this.itemsToCleanUp.add(menu);
+      menu.addEventListener("popuphidden", this);
+    } else {
+      button.removeAttribute("context");
+    }
+  },
+
   buildElementWithChildren(item, contextData) {
-    let element = this.buildSingleElement(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)) {
-        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);
+        children.push(this.buildElementWithChildren(child, contextData));
       }
     }
-
-    return element;
+    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;
@@ -193,33 +227,37 @@ var gMenuBuilder = {
       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: function(event) {
+  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(),
 };
 
+global.actionContextMenu = function(extension, button, contextData) {
+  gMenuBuilder.buildActionContextMenu(extension, button, contextData);
+};
+
 function contextMenuObserver(subject, topic, data) {
   subject = subject.wrappedJSObject;
   gMenuBuilder.build(subject);
 }
 
 function getContexts(contextData) {
   let contexts = new Set(["all"]);
 
@@ -250,16 +288,24 @@ function getContexts(contextData) {
   if (contextData.onAudio) {
     contexts.add("audio");
   }
 
   if (contexts.size == 1) {
     contexts.add("page");
   }
 
+  if (contextData.onPageAction) {
+    contexts.add("page_action");
+  }
+
+  if (contextData.onBrowserAction) {
+    contexts.add("browser_action");
+  }
+
   return contexts;
 }
 
 function MenuItem(extension, createProperties, isRoot = false) {
   this.extension = extension;
   this.children = [];
   this.parent = null;
   this.tabManager = TabManager.for(extension);
--- a/browser/components/extensions/ext-pageAction.js
+++ b/browser/components/extensions/ext-pageAction.js
@@ -114,22 +114,28 @@ PageAction.prototype = {
   // container in the given window.
   addButton(window) {
     let document = window.document;
 
     let button = document.createElement("image");
     button.id = this.id;
     button.setAttribute("class", "urlbar-icon");
 
-    button.addEventListener("click", event => { // eslint-disable-line mozilla/balanced-listeners
+    /* eslint-disable mozilla/balanced-listeners */
+    button.addEventListener("click", event => {
       if (event.button == 0) {
         this.handleClick(window);
       }
     });
 
+    button.addEventListener("contextmenu", event => {
+      global.actionContextMenu(this.extension, button, {onPageAction: true});
+    });
+    /* eslint-enable mozilla/balanced-listeners */
+
     document.getElementById("urlbar-icons").appendChild(button);
 
     return button;
   },
 
   // Returns the page action button for the given window, creating it if
   // it doesn't already exist.
   getButton(window) {
--- a/browser/components/extensions/test/browser/browser.ini
+++ b/browser/components/extensions/test/browser/browser.ini
@@ -29,16 +29,17 @@ tags = webextensions
 [browser_ext_browserAction_popup_resize.js]
 [browser_ext_browserAction_simple.js]
 [browser_ext_commands_execute_browser_action.js]
 [browser_ext_commands_execute_page_action.js]
 [browser_ext_commands_getAll.js]
 [browser_ext_commands_onCommand.js]
 [browser_ext_contentscript_connect.js]
 [browser_ext_contextMenus.js]
+[browser_ext_contextMenus_actionMenus.js]
 [browser_ext_contextMenus_checkboxes.js]
 [browser_ext_contextMenus_icons.js]
 [browser_ext_contextMenus_onclick.js]
 [browser_ext_contextMenus_radioGroups.js]
 [browser_ext_contextMenus_uninstall.js]
 [browser_ext_contextMenus_urlPatterns.js]
 [browser_ext_currentWindow.js]
 [browser_ext_getViews.js]
--- a/browser/components/extensions/test/browser/browser_ext_commands_getAll.js
+++ b/browser/components/extensions/test/browser/browser_ext_commands_getAll.js
@@ -1,14 +1,12 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
-var {AppConstants} = Cu.import("resource://gre/modules/AppConstants.jsm");
-
 add_task(function* () {
   let extension = ExtensionTestUtils.loadExtension({
     manifest: {
       "name": "Commands Extension",
       "commands": {
         "with-desciption": {
           "suggested_key": {
             "default": "Ctrl+Shift+Y",
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_contextMenus_actionMenus.js
@@ -0,0 +1,61 @@
+/* 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/. */
+"use strict";
+
+add_task(function* () {
+  const manifest = {
+    page_action: {},
+    browser_action: {},
+    permissions: ["contextMenus"],
+  };
+
+  async function background() {
+    const contexts = ["page_action", "browser_action"];
+
+    const parentId = browser.contextMenus.create({contexts, title: "parent"});
+    await browser.contextMenus.create({contexts, parentId, title: "click A"});
+    await browser.contextMenus.create({contexts, parentId, title: "click B"});
+
+    for (let i = 1; i < 9; i++) {
+      await browser.contextMenus.create({contexts, title: `click ${i}`});
+    }
+
+    browser.contextMenus.onClicked.addListener((info, tab) => {
+      browser.test.sendMessage("click", {info, tab});
+    });
+
+    const [tab] = await browser.tabs.query({active: true});
+    await browser.pageAction.show(tab.id);
+    browser.test.sendMessage("ready", tab.id);
+  }
+
+  const extension = ExtensionTestUtils.loadExtension({manifest, background});
+  const tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com/");
+
+  yield extension.startup();
+  const tabId = yield extension.awaitMessage("ready");
+
+  for (const kind of ["page", "browser"]) {
+    const menu = yield openActionContextMenu(extension, kind);
+    const [submenu, second, , , , last] = menu.children;
+
+    is(menu.children.length, 6, "Correct maximum number of menu items");
+
+    is(submenu.tagName, "menu", "Correct submenu type");
+    is(submenu.label, "parent", "Correct submenu title");
+    is(submenu.firstChild.children.length, 2, "Correct number of submenu items");
+
+    is(second.tagName, "menuitem", "Second menu item type is correct");
+    is(second.label, "click 1", "Second menu item title is correct");
+    is(last.label, "click 5", "Last menu item title is correct");
+
+    yield closeActionContextMenu(last);
+    const {info, tab} = yield extension.awaitMessage("click");
+    is(info.pageUrl, "http://example.com/", "Click info pageUrl is correct");
+    is(tab.id, tabId, "Click event tab ID is correct");
+  }
+
+  yield extension.unload();
+  yield BrowserTestUtils.removeTab(tab);
+});
--- a/browser/components/extensions/test/browser/head.js
+++ b/browser/components/extensions/test/browser/head.js
@@ -5,23 +5,25 @@
 /* exported CustomizableUI makeWidgetId focusWindow forceGC
  *          getBrowserActionWidget
  *          clickBrowserAction clickPageAction
  *          getBrowserActionPopup getPageActionPopup
  *          closeBrowserAction closePageAction
  *          promisePopupShown promisePopupHidden
  *          openContextMenu closeContextMenu
  *          openExtensionContextMenu closeExtensionContextMenu
+ *          openActionContextMenu closeActionContextMenu
  *          imageBuffer getListStyleImage getPanelForNode
  *          awaitExtensionPanel awaitPopupResize
  *          promiseContentDimensions alterContent
  */
 
-var {AppConstants} = Cu.import("resource://gre/modules/AppConstants.jsm");
-var {CustomizableUI} = Cu.import("resource:///modules/CustomizableUI.jsm");
+const {AppConstants} = Cu.import("resource://gre/modules/AppConstants.jsm");
+const {CustomizableUI} = Cu.import("resource:///modules/CustomizableUI.jsm");
+const {promiseEvent} = Cu.import("resource://gre/modules/ExtensionUtils.jsm", {});
 
 // Bug 1239884: Our tests occasionally hit a long GC pause at unpredictable
 // times in debug builds, which results in intermittent timeouts. Until we have
 // a better solution, we force a GC after certain strategic tests, which tend to
 // accumulate a high number of unreaped windows.
 function forceGC() {
   if (AppConstants.DEBUG) {
     Cu.forceGC();
@@ -224,16 +226,39 @@ function* openExtensionContextMenu(selec
 
 function* closeExtensionContextMenu(itemToSelect) {
   let contentAreaContextMenu = document.getElementById("contentAreaContextMenu");
   let popupHiddenPromise = BrowserTestUtils.waitForEvent(contentAreaContextMenu, "popuphidden");
   EventUtils.synthesizeMouseAtCenter(itemToSelect, {});
   yield popupHiddenPromise;
 }
 
+function* openActionContextMenu(extension, kind, win = window) {
+  const id = `${makeWidgetId(extension.id)}-${kind}-action`;
+  const button = win.document.getElementById(id);
+  SetPageProxyState("valid");
+
+  const shown = promiseEvent(win, "popupshown");
+  EventUtils.synthesizeMouseAtCenter(button, {type: "contextmenu"}, win);
+  yield shown;
+
+  return document.getElementById("Extensions-ActionContextMenu");
+}
+
+function closeActionContextMenu(itemToSelect, win = window) {
+  const menu = win.document.getElementById("Extensions-ActionContextMenu");
+  const hidden = promiseEvent(menu, "popuphidden");
+  if (itemToSelect) {
+    EventUtils.synthesizeMouseAtCenter(itemToSelect, {}, win);
+  } else {
+    menu.hidePopup();
+  }
+  return hidden;
+}
+
 function getPageActionPopup(extension, win = window) {
   let panelId = makeWidgetId(extension.id) + "-panel";
   return win.document.getElementById(panelId);
 }
 
 function clickPageAction(extension, win = window) {
   // This would normally be set automatically on navigation, and cleared
   // when the user types a value into the URL bar, to show and hide page