Bug 942270 - Refactor context menu code. r=bnicholson
authorWes Johnston <wjohnston@mozilla.com>
Fri, 21 Feb 2014 14:05:00 -0800
changeset 191022 f785999a6144a0886c7f39d8598f68e7e6d82163
parent 191021 f02f3bd008d4dc1b538ef3d1c9b857060c846a62
child 191023 907cacf958de4127d7ff8c510819e15e26f4b9e9
push id3503
push userraliiev@mozilla.com
push dateMon, 28 Apr 2014 18:51:11 +0000
treeherdermozilla-beta@c95ac01e332e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbnicholson
bugs942270
milestone30.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 942270 - Refactor context menu code. r=bnicholson
mobile/android/chrome/content/browser.js
--- a/mobile/android/chrome/content/browser.js
+++ b/mobile/android/chrome/content/browser.js
@@ -1913,48 +1913,46 @@ var NativeWindow = {
             delete this.doorhanger._callbacks[id];
           }
         }
       }
     }
   },
   contextmenus: {
     items: {}, //  a list of context menu items that we may show
-    _nativeItemsSeparator: 0, // the index to insert native context menu items at
-    _contextId: 0, // id to assign to new context menu items if they are added
+    DEFAULT_HTML5_ORDER: -1, // Sort order for HTML5 context menu items
 
     init: function() {
       Services.obs.addObserver(this, "Gesture:LongPress", false);
     },
 
     uninit: function() {
       Services.obs.removeObserver(this, "Gesture:LongPress");
     },
 
-    add: function(aName, aSelector, aCallback) {
-      if (!aName)
+    add: function() {
+      let args;
+      if (arguments.length == 1) {
+        args = arguments[0];
+      } else if (arguments.length == 3) {
+        args = {
+          label : arguments[0],
+          selector: arguments[1],
+          callback: arguments[2]
+        };
+      } else {
+        throw "Incorrect number of parameters";
+      }
+
+      if (!args.label)
         throw "Menu items must have a name";
 
-      let item = {
-        name: aName,
-        context: aSelector,
-        callback: aCallback,
-        matches: function(aElt, aX, aY) {
-          return this.context.matches(aElt, aX, aY);
-        },
-        getValue: function(aElt) {
-          return {
-            label: (typeof this.name == "function") ? this.name(aElt) : this.name,
-            id: this.id
-          }
-        }
-      };
-      item.id = this._contextId++;
-      this.items[item.id] = item;
-      return item.id;
+      let cmItem = new ContextMenuItem(args);
+      this.items[cmItem.id] = cmItem;
+      return cmItem.id;
     },
 
     remove: function(aId) {
       delete this.items[aId];
     },
 
     SelectorContext: function(aSelector) {
       return {
@@ -2109,214 +2107,227 @@ var NativeWindow = {
     },
   
     set _target(aTarget) {
       if (aTarget)
         this._targetRef = Cu.getWeakReference(aTarget);
       else this._targetRef = null;
     },
 
-    _addHTMLContextMenuItems: function cm_addContextMenuItems(aMenu, aParent) {
-      for (let i = 0; i < aMenu.childNodes.length; i++) {
-        let item = aMenu.childNodes[i];
-        if (!item.label)
+    _addHTMLContextMenuItemsForElement: function(element) {
+      let htmlMenu = element.contextMenu;
+      if (!htmlMenu)
+        return;
+
+      htmlMenu.QueryInterface(Components.interfaces.nsIHTMLMenu);
+      htmlMenu.sendShowEvent();
+
+      this._addHTMLContextMenuItemsForMenu(htmlMenu, element);
+    },
+
+    _addHTMLContextMenuItemsForMenu: function(menu, target) {
+      for (let i = 0; i < menu.childNodes.length; i++) {
+        let elt = menu.childNodes[i];
+        if (!elt.label)
           continue;
 
-        let id = this._contextId++;
-        let menuitem = {
-          id: id,
-          isGroup: false,
-          callback: (function(aTarget, aX, aY) {
-            // If this is a menu item, show a new context menu with the submenu in it
-            if (item instanceof Ci.nsIDOMHTMLMenuElement) {
-              this.menuitems = [];
-              this._nativeItemsSeparator = 0;
-
-              this._addHTMLContextMenuItems(item, id);
-              this._innerShow(aTarget, aX, aY);
-            } else {
-              // oltherwise just click the item
-              item.click();
-            }
-          }).bind(this),
-
-          getValue: function(aElt) {
-            if (item.hasAttribute("hidden"))
-              return null;
-
-            return {
-              icon: item.icon,
-              label: item.label,
-              id: id,
-              disabled: item.disabled,
-              parent: item instanceof Ci.nsIDOMHTMLMenuElement
-            }
-          }
-        };
-
-        this.menuitems.splice(this._nativeItemsSeparator, 0, menuitem);
-        this._nativeItemsSeparator++;
+        this.menuitems.push(new HTMLContextMenuItem(elt, target));
       }
     },
 
-    _getMenuItemForId: function(aId) {
+    _containsItem: function(aId) {
       if (!this.menuitems)
         return null;
 
-      for (let i = 0; i < this.menuitems.length; i++) {
-        if (this.menuitems[i].id == aId)
-          return this.menuitems[i];
-      }
+      let menu = this.menuitems;
+      for (let i = 0; i < menu.length; i++) {
+        if (menu[i].id == aId)
+          return menu[i];
+      }
+
       return null;
     },
 
+    shouldShow: function() {
+      return this.menuitems.length > 0;
+    },
+
+    _addNativeContextMenuItems: function(element, x, y) {
+      for (let itemId of Object.keys(this.items)) {
+        let item = this.items[itemId];
+
+        if (!this._containsItem(item.id) && item.matches(element, x, y)) {
+          this.menuitems.push(item);
+        }
+      }
+    },
+
     // Checks if there are context menu items to show, and if it finds them
     // sends a contextmenu event to content. We also send showing events to
     // any html5 context menus we are about to show
-    _sendToContent: function(aX, aY) {
-      // find and store the top most element this context menu is being shown for
-      // use the highlighted element if possible, otherwise look for nearby clickable elements
-      // If we still don't find one we fall back to using anything
-      let target = BrowserEventHandler._highlightElement || ElementTouchHelper.elementFromPoint(aX, aY);
+    _sendToContent: function(x, y) {
+      let target = BrowserEventHandler._highlightElement || ElementTouchHelper.elementFromPoint(x, y);
       if (!target)
-        target = ElementTouchHelper.anyElementFromPoint(aX, aY);
+        target = ElementTouchHelper.anyElementFromPoint(x, y);
 
       if (!target)
         return;
 
-      // store a weakref to the target to be used when the context menu event returns
       this._target = target;
 
-      this.menuitems = [];
-      let menuitemsSet = false;
-
       Services.obs.notifyObservers(null, "before-build-contextmenu", "");
-
-      // now walk up the tree and for each node look for any context menu items that apply
-      let element = target;
-      this._nativeItemsSeparator = 0;
-      while (element) {
-        // first check for any html5 context menus that might exist
-        let contextmenu = element.contextMenu;
-        if (contextmenu) {
-          // send this before we build the list to make sure the site can update the menu
-          contextmenu.QueryInterface(Components.interfaces.nsIHTMLMenu);
-          contextmenu.sendShowEvent();
-          this._addHTMLContextMenuItems(contextmenu, null);
-        }
-
-        // then check for any context menu items registered in the ui
-        for (let itemId of Object.keys(this.items)) {
-          let item = this.items[itemId];
-          if (!this._getMenuItemForId(item.id) && item.matches(element, aX, aY)) {
-            this.menuitems.push(item);
-          }
-        }
-
-        element = element.parentNode;
-      }
+      this._buildMenu(x, y);
 
       // only send the contextmenu event to content if we are planning to show a context menu (i.e. not on every long tap)
-      if (this.menuitems.length > 0) {
+      if (this.shouldShow()) {
         let event = target.ownerDocument.createEvent("MouseEvent");
-        event.initMouseEvent("contextmenu", true, true, content,
-                             0, aX, aY, aX, aY, false, false, false, false,
+        event.initMouseEvent("contextmenu", true, true, target.defaultView,
+                             0, x, y, x, y, false, false, false, false,
                              0, null);
         target.ownerDocument.defaultView.addEventListener("contextmenu", this, false);
         target.dispatchEvent(event);
       } else {
-        this._target = null;
-        BrowserEventHandler._cancelTapHighlight();
+        this.menuitems = null;
+        Services.obs.notifyObservers({target: target, x: x, y: y}, "context-menu-not-shown", "");
 
         if (SelectionHandler.canSelect(target)) {
           if (!SelectionHandler.startSelection(target, {
-              mode: SelectionHandler.SELECT_AT_POINT,
-              x: aX,
-              y: aY
-            })) {
+            mode: SelectionHandler.SELECT_AT_POINT,
+            x: x,
+            y: y
+          })) { 
             SelectionHandler.attachCaret(target);
           }
         }
       }
     },
 
+    _getTitle: function(node) {
+      if (node.hasAttribute && node.hasAttribute("title")) {
+        return node.getAttribute("title");
+      }
+      return this._getUrl(node);
+    },
+
+    _getUrl: function(node) {
+      if ((node instanceof Ci.nsIDOMHTMLAnchorElement && node.href) ||
+                 (node instanceof Ci.nsIDOMHTMLAreaElement && node.href)) {
+        return this._getLinkURL(node);
+      } else if (node instanceof Ci.nsIImageLoadingContent && node.currentURI) {
+        return node.currentURI.spec;
+      } else if (node instanceof Ci.nsIDOMHTMLMediaElement) {
+        return (node.currentSrc || node.src);
+      }
+      return "";
+    },
+
+    _buildMenu: function(x, y) {
+      // now walk up the tree and for each node look for any context menu items that apply
+      let element = this._target;
+      this.menuitems = [];
+
+      while (element) {
+        // First check for any html5 context menus that might exist...
+        this._addHTMLContextMenuItemsForElement(element);
+        // then check for any context menu items registered in the ui.
+        this._addNativeContextMenuItems(element, x, y);
+
+        // walk up the tree and find more items to show
+        element = element.parentNode;
+      }
+    },
+
     // Actually shows the native context menu by passing a list of context menu items to
     // show to the Java.
     _show: function(aEvent) {
       let popupNode = this._target;
       this._target = null;
       if (aEvent.defaultPrevented || !popupNode) {
         return;
       }
       this._innerShow(popupNode, aEvent.clientX, aEvent.clientY);
     },
 
-    _innerShow: function(aTarget, aX, aY) {
+    _findTitle: function(node) {
+      let title = "";
+      while(node && !title) {
+        title = this._getTitle(node);
+        node = node.parentNode;
+      }
+      return title;
+    },
+
+    _getItems: function(target) {
+      return this._getItemsInList(target, this.menuitems);
+    },
+
+    _getItemsInList: function(target, list) {
+      let itemArray = [];
+      for (let i = 0; i < list.length; i++) {
+        let t = target;
+        while(t) {
+          if (list[i].matches(t)) {
+            let val = list[i].getValue(t);
+
+            // hidden menu items will return null from getValue
+            if (val) {
+              itemArray.push(val);
+              break;
+            }
+          }
+
+          t = t.parentNode;
+        }
+      }
+      return itemArray;
+    },
+
+    _innerShow: function(target, x, y) {
       Haptic.performSimpleAction(Haptic.LongPress);
 
       // spin through the tree looking for a title for this context menu
-      let node = aTarget;
-      let title ="";
-      while(node && !title) {
-        if (node.hasAttribute && node.hasAttribute("title")) {
-          title = node.getAttribute("title");
-        } else if ((node instanceof Ci.nsIDOMHTMLAnchorElement && node.href) ||
-                (node instanceof Ci.nsIDOMHTMLAreaElement && node.href)) {
-          title = this._getLinkURL(node);
-        } else if (node instanceof Ci.nsIImageLoadingContent && node.currentURI) {
-          title = node.currentURI.spec;
-        } else if (node instanceof Ci.nsIDOMHTMLMediaElement) {
-          title = (node.currentSrc || node.src);
-        }
-        node = node.parentNode;
-      }
-
-      // convert this.menuitems object to an array for sending to native code
-      let itemArray = [];
-      for (let i = 0; i < this.menuitems.length; i++) {
-        let val = this.menuitems[i].getValue(aTarget);
-
-        // hidden menu items will return null from getValue
-        if (val)
-          itemArray.push(val);
-      }
-
-      if (itemArray.length == 0)
-        return;
+      let title = this._findTitle(target);
+
+      this.menuitems.sort((a,b) => {
+        if (a.order == b.order) return 0;
+        return (a.order > b.order) ? 1 : -1;
+      });
 
       let prompt = new Prompt({
-        window: aTarget.ownerDocument.defaultView,
+        window: target.ownerDocument.defaultView,
         title: title
-      }).setSingleChoiceItems(itemArray)
-      .show((function(data) {
-        if (data.button == -1) {
-          // prompt was cancelled
-          return;
+      });
+
+      let items = this._getItems(target);
+      prompt.setSingleChoiceItems(items);
+      prompt.show(this._promptDone.bind(this, target, x, y, items));
+    },
+
+    _promptDone: function(target, x, y, items, data) {
+      if (data.button == -1) {
+        // prompt was cancelled
+        return;
+      }
+
+      let selectedItemId = items[data.list[0]].id;
+      let selectedItem = this._containsItem(selectedItemId);
+      this.menuitems = null;
+
+      if (!selectedItem || !selectedItem.matches || !selectedItem.callback) {
+        return;
+      }
+
+      // for menuitems added using the native UI, pass the dom element that matched that item to the callback
+      while (target) {
+        if (selectedItem.matches(target, x, y)) {
+          selectedItem.callback(target, x, y);
+          break;
         }
-
-        let selectedId = itemArray[data.button].id;
-        let selectedItem = this._getMenuItemForId(selectedId);
-
-        this.menuitems = null;
-        if (selectedItem && selectedItem.callback) {
-          if (selectedItem.matches) {
-            // for menuitems added using the native UI, pass the dom element that matched that item to the callback
-            while (aTarget) {
-              if (selectedItem.matches(aTarget, aX, aY)) {
-                selectedItem.callback.call(selectedItem, aTarget, aX, aY);
-                break;
-              }
-              aTarget = aTarget.parentNode;
-            }
-          } else {
-            // if this was added using the html5 context menu api, just click on the context menu item
-            selectedItem.callback.call(selectedItem, aTarget, aX, aY);
-          }
-        }
-      }).bind(this));
+        target = target.parentNode;
+      }
     },
 
     handleEvent: function(aEvent) {
       BrowserEventHandler._cancelTapHighlight();
       aEvent.target.ownerDocument.defaultView.removeEventListener("contextmenu", this, false);
       this._show(aEvent);
     },
 
@@ -2338,17 +2349,17 @@ var NativeWindow = {
 
     _getLink: function(aElement) {
       if (aElement.nodeType == Ci.nsIDOMNode.ELEMENT_NODE &&
           ((aElement instanceof Ci.nsIDOMHTMLAnchorElement && aElement.href) ||
           (aElement instanceof Ci.nsIDOMHTMLAreaElement && aElement.href) ||
           aElement instanceof Ci.nsIDOMHTMLLinkElement ||
           aElement.getAttributeNS(kXLinkNamespace, "type") == "simple")) {
         try {
-          let url = NativeWindow.contextmenus._getLinkURL(aElement);
+          let url = this._getLinkURL(aElement);
           return Services.io.newURI(url, null, null);
         } catch (e) {}
       }
       return null;
     },
 
     _disableInGuest: function _disableInGuest(selector) {
       return {
@@ -8259,8 +8270,113 @@ var Tabs = {
   // For debugging
   dump: function(aPrefix) {
     let tabs = BrowserApp.tabs;
     for (let i = 0; i < tabs.length; i++) {
       dump(aPrefix + " | " + "Tab [" + tabs[i].browser.contentWindow.location.href + "]: lastTouchedAt:" + tabs[i].lastTouchedAt + ", zombie:" + tabs[i].browser.__SS_restore);
     }
   },
 };
+
+function ContextMenuItem(args) {
+  this.id = uuidgen.generateUUID().toString();
+  this.args = args;
+}
+
+ContextMenuItem.prototype = {
+  get order() {
+    return this.args.order || 0;
+  },
+
+  matches: function(elt, x, y) {
+    return this.args.selector.matches(elt, x, y);
+  },
+
+  callback: function(elt) {
+    this.args.callback(elt);
+  },
+
+  addVal: function(name, elt, defaultValue) {
+    if (!(name in this.args))
+      return defaultValue;
+
+    if (typeof this.args[name] == "function")
+      return this.args[name](elt);
+
+    return this.args[name];
+  },
+
+  getValue: function(elt) {
+    return {
+      id: this.id,
+      label: this.addVal("label", elt),
+      shareData: this.addVal("shareData", elt),
+      icon: this.addVal("icon", elt),
+      isGroup: this.addVal("isGroup", elt, false),
+      inGroup: this.addVal("inGroup", elt, false),
+      disabled: this.addVal("disabled", elt, false),
+      selected: this.addVal("selected", elt, false),
+      isParent: this.addVal("isParent", elt, false),
+    };
+  }
+}
+
+function HTMLContextMenuItem(elt, target) {
+  ContextMenuItem.call(this, { });
+
+  this.menuElementRef = Cu.getWeakReference(elt);
+  this.targetElementRef = Cu.getWeakReference(target);
+}
+
+HTMLContextMenuItem.prototype = Object.create(ContextMenuItem.prototype, {
+  order: {
+    value: NativeWindow.contextmenus.DEFAULT_HTML5_ORDER
+  },
+
+  matches: {
+    value: function(target) {
+      let t = this.targetElementRef.get();
+      return t === target;
+    },
+  },
+
+  callback: {
+    value: function(target) {
+      let elt = this.menuElementRef.get();
+      if (!elt) {
+        return;
+      }
+
+      // If this is a menu item, show a new context menu with the submenu in it
+      if (elt instanceof Ci.nsIDOMHTMLMenuElement) {
+        try {
+          NativeWindow.contextmenus.menuitems = [];
+          NativeWindow.contextmenus._addHTMLContextMenuItemsForMenu(elt, target);
+          NativeWindow.contextmenus._innerShow(target);
+        } catch(ex) {
+          Cu.reportError(ex);
+        }
+      } else {
+        // otherwise just click the menu item
+        elt.click();
+      }
+    },
+  },
+
+  getValue: {
+    value: function(target) {
+      let elt = this.menuElementRef.get();
+      if (!elt)
+        return null;
+
+      if (elt.hasAttribute("hidden"))
+        return null;
+
+      return {
+        id: this.id,
+        icon: elt.icon,
+        label: elt.label,
+        disabled: elt.disabled,
+        menu: elt instanceof Ci.nsIDOMHTMLMenuElement
+      };
+    }
+  },
+});