bug 1253418 - implement contextMenu page_action and browser_action contexts r=kmag
authorTomislav Jovanovic <tomica@gmail.com>
Wed, 09 Nov 2016 21:03:33 +0100
changeset 324021 7065b2e2f667e8d2605b0aa8d9eb4755cce9c4e1
parent 324020 55bbce8d1013afe70fdd2c79b793174164a699b0
child 324022 843b7449f803d0940942c072550731af70fd0fe9
push id24
push usermaklebus@msu.edu
push dateTue, 20 Dec 2016 03:11:33 +0000
reviewerskmag
bugs1253418
milestone53.0a1
bug 1253418 - implement contextMenu page_action and browser_action contexts r=kmag 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-common.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
@@ -86,25 +86,27 @@ BrowserAction.prototype = {
       defaultArea: CustomizableUI.AREA_NAVBAR,
 
       onBeforeCreated: document => {
         let view = document.createElementNS(XUL_NS, "panelview");
         view.id = this.viewId;
         view.setAttribute("flex", "1");
 
         document.getElementById("PanelUI-multiView").appendChild(view);
+        document.addEventListener("popupshowing", this);
       },
 
       onDestroyed: document => {
         let view = document.getElementById(this.viewId);
         if (view) {
           this.clearPopup();
           CustomizableUI.hidePanelForNode(view);
           view.remove();
         }
+        document.removeEventListener("popupshowing", this);
       },
 
       onCreated: node => {
         node.classList.add("badged-button");
         node.classList.add("webextension-browser-action");
         node.setAttribute("constrain-size", "true");
 
         node.onmousedown = event => this.handleEvent(event);
@@ -222,16 +224,30 @@ BrowserAction.prototype = {
               this.pendingPopupTimeout = setTimeout(() => this.clearPopup(),
                                                     POPUP_PRELOAD_TIMEOUT_MS);
             } else {
               this.clearPopup();
             }
           }
         }
         break;
+
+      case "popupshowing":
+        const menu = event.target;
+        const trigger = menu.triggerNode;
+        const node = window.document.getElementById(this.id);
+
+        if (menu.localName === "menupopup" && node && isAncestorOrSelf(node, trigger)) {
+          global.actionContextMenu({
+            extension: this.extension,
+            onBrowserAction: true,
+            menu: menu,
+          });
+        }
+        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,69 @@ var gMenuBuilder = {
         rootElement.setAttribute("image", resolvedURL);
       }
 
       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) {
-    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 +225,38 @@ 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(),
 };
 
+// Called from pageAction or browserAction popup.
+global.actionContextMenu = function(contextData) {
+  gMenuBuilder.buildActionContextMenu(contextData);
+};
+
 function contextMenuObserver(subject, topic, data) {
   subject = subject.wrappedJSObject;
   gMenuBuilder.build(subject);
 }
 
 function getContexts(contextData) {
   let contexts = new Set(["all"]);
 
@@ -250,16 +287,24 @@ function getContexts(contextData) {
   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 == 1) {
     contexts.add("page");
   }
 
   return contexts;
 }
 
 function MenuItem(extension, createProperties, isRoot = false) {
--- a/browser/components/extensions/ext-pageAction.js
+++ b/browser/components/extensions/ext-pageAction.js
@@ -114,21 +114,18 @@ 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
-      if (event.button == 0) {
-        this.handleClick(window);
-      }
-    });
+    button.addEventListener("click", this); // eslint-disable-line mozilla/balanced-listeners
+    document.addEventListener("popupshowing", this);
 
     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.
@@ -151,16 +148,41 @@ PageAction.prototype = {
    */
   triggerAction(window) {
     let pageAction = pageActionMap.get(this.extension);
     if (pageAction.getProperty(window.gBrowser.selectedTab, "show")) {
       pageAction.handleClick(window);
     }
   },
 
+  handleEvent(event) {
+    const window = event.target.ownerDocument.defaultView;
+
+    switch (event.type) {
+      case "click":
+        if (event.button === 0) {
+          this.handleClick(window);
+        }
+        break;
+
+      case "popupshowing":
+        const menu = event.target;
+        const trigger = menu.triggerNode;
+
+        if (menu.localName === "menupopup" && trigger && trigger.id === this.id) {
+          global.actionContextMenu({
+            extension: this.extension,
+            onPageAction: true,
+            menu: menu,
+          });
+        }
+        break;
+    }
+  },
+
   // Handles a click event on the page action button for the given
   // window.
   // If the page action has a |popup| property, a panel is opened to
   // that URL. Otherwise, a "click" event is emitted, and dispatched to
   // the any click listeners in the add-on.
   handleClick(window) {
     let tab = window.gBrowser.selectedTab;
     let popupURL = this.tabContext.get(tab).popup;
@@ -187,16 +209,17 @@ PageAction.prototype = {
   },
 
   shutdown() {
     this.tabContext.shutdown();
 
     for (let window of WindowListManager.browserWindows()) {
       if (this.buttons.has(window)) {
         this.buttons.get(window).remove();
+        window.removeEventListener("popupshowing", this);
       }
     }
   },
 };
 
 /* eslint-disable mozilla/balanced-listeners */
 extensions.on("manifest_page_action", (type, directive, extension, manifest) => {
   let pageAction = new PageAction(manifest.page_action, extension);
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -28,16 +28,17 @@ support-files =
 [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, separator] = menu.children;
+
+    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");
+    is(separator.tagName, "menuseparator", "Separator after last menu item");
+
+    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 BrowserTestUtils.removeTab(tab);
+  yield extension.unload();
+});
--- a/browser/components/extensions/test/browser/head.js
+++ b/browser/components/extensions/test/browser/head.js
@@ -5,23 +5,24 @@
 /* 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");
 
 // We run tests under two different configurations, from browser.ini and
 // browser-remote.ini. When running from browser-remote.ini, the tests are
 // copied to the sub-directory "test-oop-extensions", which we detect here, and
 // use to select our configuration.
 if (gTestPath.includes("test-oop-extensions")) {
   SpecialPowers.pushPrefEnv({set: [
     ["dom.ipc.processCount", 1],
@@ -235,16 +236,40 @@ 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 menu = win.document.getElementById("toolbar-context-menu");
+  const id = `${makeWidgetId(extension.id)}-${kind}-action`;
+  const button = win.document.getElementById(id);
+  SetPageProxyState("valid");
+
+  const shown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+  EventUtils.synthesizeMouseAtCenter(button, {type: "contextmenu"}, win);
+  yield shown;
+
+  return menu;
+}
+
+function closeActionContextMenu(itemToSelect, win = window) {
+  const menu = win.document.getElementById("toolbar-context-menu");
+  const hidden = BrowserTestUtils.waitForEvent(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