Bug 1374477 - Add browser-pageActions.js for Photon page actions. r?Gijs draft
authorDrew Willcoxon <adw@mozilla.com>
Sat, 29 Jul 2017 20:24:58 -0700
changeset 618101 5b86613b29de0e80697c1f96028c0577a3687fa3
parent 618100 e8cf7a82748c1770474367e3708df7f53753389b
child 618102 01d4370e8b8ef30dab46b53d3a0c0dee09a1b602
push id71221
push userdwillcoxon@mozilla.com
push dateSun, 30 Jul 2017 03:26:37 +0000
reviewersGijs
bugs1374477
milestone56.0a1
Bug 1374477 - Add browser-pageActions.js for Photon page actions. r?Gijs MozReview-Commit-ID: DUl7WlSnk4k
browser/base/content/browser-pageActions.js
browser/base/content/browser.js
browser/base/content/global-scripts.inc
browser/base/jar.mn
new file mode 100644
--- /dev/null
+++ b/browser/base/content/browser-pageActions.js
@@ -0,0 +1,649 @@
+/* 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/. */
+
+var BrowserPageActions = {
+  /**
+   * The main page action button in the urlbar (DOM node)
+   */
+  get mainButtonNode() {
+    delete this.mainButtonNode;
+    return this.mainButtonNode = document.getElementById("pageActionButton");
+  },
+
+  /**
+   * The main page action panel DOM node (DOM node)
+   */
+  get panelNode() {
+    delete this.panelNode;
+    return this.panelNode = document.getElementById("pageActionPanel");
+  },
+
+  /**
+   * The photonmultiview node in the main page action panel (DOM node)
+   */
+  get multiViewNode() {
+    delete this.multiViewNode;
+    return this.multiViewNode = document.getElementById("pageActionPanelMultiView");
+  },
+
+  /**
+   * The main panelview node in the main page action panel (DOM node)
+   */
+  get mainViewNode() {
+    delete this.mainViewNode;
+    return this.mainViewNode = document.getElementById("pageActionPanelMainView");
+  },
+
+  /**
+   * The vbox body node in the main panelview node (DOM node)
+   */
+  get mainViewBodyNode() {
+    delete this.mainViewBodyNode;
+    return this.mainViewBodyNode = this.mainViewNode.querySelector(".panel-subview-body");
+  },
+
+  /**
+   * Inits.  Call to init.
+   */
+  init() {
+    for (let action of PageActions.actions) {
+      this.placeAction(action, PageActions.insertBeforeActionIDInUrlbar(action));
+    }
+  },
+
+  /**
+   * Adds or removes as necessary DOM nodes for the given action.
+   *
+   * @param  action (PageActions.Action, required)
+   *         The action to place.
+   * @param  panelInsertBeforeID (string, required)
+   *         The ID of the action in the panel before which the given action
+   *         action should be inserted.
+   * @param  urlbarInsertBeforeID (string, required)
+   *         If the action is shown in the urlbar, then this is ID of the action
+   *         in the urlbar before which the given action should be inserted.
+   */
+  placeAction(action, panelInsertBeforeID, urlbarInsertBeforeID) {
+    if (action.__isSeparator) {
+      this._appendPanelSeparator(action);
+      return;
+    }
+    this.placeActionInPanel(action, panelInsertBeforeID);
+    this.placeActionInUrlbar(action, urlbarInsertBeforeID);
+  },
+
+  /**
+   * Adds or removes as necessary DOM nodes for the action in the panel.
+   *
+   * @param  action (PageActions.Action, required)
+   *         The action to place.
+   * @param  insertBeforeID (string, required)
+   *         The ID of the action in the panel before which the given action
+   *         action should be inserted.
+   */
+  placeActionInPanel(action, insertBeforeID) {
+    let id = this._panelButtonNodeIDForActionID(action.id);
+    let node = document.getElementById(id);
+    if (!node) {
+      let panelViewNode;
+      [node, panelViewNode] = this._makePanelButtonNodeForAction(action);
+      node.id = id;
+      let insertBeforeNode = null;
+      if (insertBeforeID) {
+        let insertBeforeNodeID =
+          this._panelButtonNodeIDForActionID(insertBeforeID);
+        insertBeforeNode = document.getElementById(insertBeforeNodeID);
+      }
+      this.mainViewBodyNode.insertBefore(node, insertBeforeNode);
+      action.onPlacedInPanel(node);
+      if (panelViewNode) {
+        action.subview.onPlaced(panelViewNode);
+      }
+    }
+    return node;
+  },
+
+  _makePanelButtonNodeForAction(action) {
+    let buttonNode = document.createElement("toolbarbutton");
+    buttonNode.classList.add(
+      "subviewbutton",
+      "subviewbutton-iconic",
+      "pageAction-panel-button"
+    );
+    buttonNode.setAttribute("label", action.title);
+    if (action.iconURL) {
+      buttonNode.style.listStyleImage = `url('${action.iconURL}')`;
+    }
+    if (action.nodeAttributes) {
+      for (let name in action.nodeAttributes) {
+        buttonNode.setAttribute(name, action.nodeAttributes[name]);
+      }
+    }
+    let panelViewNode = null;
+    if (action.subview) {
+      buttonNode.classList.add("subviewbutton-nav");
+      panelViewNode = this._makePanelViewNodeForAction(action, false);
+      this.multiViewNode.appendChild(panelViewNode);
+    }
+    buttonNode.addEventListener("command", event => {
+      if (panelViewNode) {
+        action.subview.onShowing(panelViewNode);
+        this.multiViewNode.showSubView(panelViewNode, buttonNode);
+        return;
+      }
+      if (action.wantsIframe) {
+        this._toggleTempPanelForAction(action);
+        return;
+      }
+      this.panelNode.hidePopup();
+      action.onCommand(event, buttonNode);
+    });
+    return [buttonNode, panelViewNode];
+  },
+
+  _makePanelViewNodeForAction(action, forUrlbar) {
+    let panelViewNode = document.createElement("panelview");
+    let placementID = forUrlbar ? "urlbar" : "panel";
+    panelViewNode.id = `pageAction-${placementID}-${action.id}-subview`;
+    panelViewNode.classList.add("PanelUI-subView");
+    let bodyNode = document.createElement("vbox");
+    bodyNode.id = panelViewNode.id + "-body";
+    bodyNode.classList.add("panel-subview-body");
+    panelViewNode.appendChild(bodyNode);
+    for (let button of action.subview.buttons) {
+      let buttonNode = document.createElement("toolbarbutton");
+      let buttonNodeID =
+        forUrlbar ? this._urlbarButtonNodeIDForActionID(action.id) :
+        this._panelButtonNodeIDForActionID(action.id);
+      buttonNodeID += "-" + button.id;
+      buttonNode.id = buttonNodeID;
+      buttonNode.classList.add("subviewbutton", "subviewbutton-iconic");
+      buttonNode.setAttribute("label", button.title);
+      if (button.shortcut) {
+        buttonNode.setAttribute("shortcut", button.shortcut);
+      }
+      if (button.disabled) {
+        buttonNode.setAttribute("disabled", "true");
+      }
+      buttonNode.addEventListener("command", event => {
+        button.onCommand(event, buttonNode);
+      });
+      bodyNode.appendChild(buttonNode);
+    }
+    return panelViewNode;
+  },
+
+  _toggleTempPanelForAction(action) {
+    let panelNodeID = "pageActionTempPanel";
+    let panelNode = document.getElementById(panelNodeID);
+    if (panelNode) {
+      panelNode.hidePopup();
+      return;
+    }
+
+    panelNode = document.createElement("panel");
+    panelNode.id = panelNodeID;
+    panelNode.classList.add("cui-widget-panel");
+    panelNode.setAttribute("role", "group");
+    panelNode.setAttribute("type", "arrow");
+    panelNode.setAttribute("flip", "slide");
+    panelNode.setAttribute("noautofocus", "true");
+
+    let panelViewNode = null;
+    let iframeNode = null;
+
+    if (action.subview) {
+      let multiViewNode = document.createElement("photonpanelmultiview");
+      panelViewNode = this._makePanelViewNodeForAction(action, true);
+      multiViewNode.appendChild(panelViewNode);
+      panelNode.appendChild(multiViewNode);
+    } else if (action.wantsIframe) {
+      iframeNode = document.createElement("iframe");
+      iframeNode.setAttribute("type", "content");
+      panelNode.appendChild(iframeNode);
+    }
+
+    let popupSet = document.getElementById("mainPopupSet");
+    popupSet.appendChild(panelNode);
+    panelNode.addEventListener("popuphidden", () => {
+      panelNode.remove();
+    }, { once: true });
+
+    if (panelViewNode) {
+      action.subview.onPlaced(panelViewNode);
+      action.subview.onShowing(panelViewNode);
+    }
+
+    this.panelNode.hidePopup();
+
+    let urlbarNodeID = this._urlbarButtonNodeIDForActionID(action.id);
+    let anchorNode =
+      document.getElementById(urlbarNodeID) || this.mainButtonNode;
+    panelNode.openPopup(anchorNode, "bottomcenter topright");
+
+    if (iframeNode) {
+      action.onIframeShown(iframeNode, panelNode);
+    }
+  },
+
+  /**
+   * Adds or removes as necessary a DOM node for the given action in the urlbar.
+   *
+   * @param  action (PageActions.Action, required)
+   *         The action to place.
+   * @param  insertBeforeID (string, required)
+   *         If the action is shown in the urlbar, then this is ID of the action
+   *         in the urlbar before which the given action should be inserted.
+   */
+  placeActionInUrlbar(action, insertBeforeID) {
+    let id = this._urlbarButtonNodeIDForActionID(action.id);
+    let node = document.getElementById(id);
+
+    if (!action.shownInUrlbar) {
+      if (node) {
+        if (action.__urlbarNodeInMarkup) {
+          node.hidden = true;
+        } else {
+          node.remove();
+        }
+      }
+      return null;
+    }
+
+    let newlyPlaced = false;
+    if (action.__urlbarNodeInMarkup) {
+      newlyPlaced = node && node.hidden;
+      node.hidden = false;
+    } else if (!node) {
+      newlyPlaced = true;
+      node = this._makeUrlbarButtonNode(action);
+      node.id = id;
+    }
+
+    if (newlyPlaced) {
+      let parentNode = this.mainButtonNode.parentNode;
+      let insertBeforeNode = null;
+      if (insertBeforeID) {
+        let insertBeforeNodeID =
+          this._urlbarButtonNodeIDForActionID(insertBeforeID);
+        insertBeforeNode = document.getElementById(insertBeforeNodeID);
+      }
+      parentNode.insertBefore(node, insertBeforeNode);
+      action.onPlacedInUrlbar(node);
+
+      // urlbar buttons should always have tooltips, so if the node doesn't have
+      // one, then as a last resort use the label of the corresponding panel
+      // button.  Why not set tooltiptext to action.title when the node is
+      // created?  Because the consumer may set a title dynamically.
+      if (!node.hasAttribute("tooltiptext")) {
+        let panelNodeID = this._panelButtonNodeIDForActionID(action.id);
+        let panelNode = document.getElementById(panelNodeID);
+        if (panelNode) {
+          node.setAttribute("tooltiptext", panelNode.getAttribute("label"));
+        }
+      }
+    }
+
+    return node;
+  },
+
+  _makeUrlbarButtonNode(action) {
+    let buttonNode = document.createElement("image");
+    buttonNode.classList.add("urlbar-icon");
+    if (action.tooltip) {
+      buttonNode.setAttribute("tooltiptext", action.tooltip);
+    }
+    if (action.iconURL) {
+      buttonNode.style.listStyleImage = `url('${action.iconURL}')`;
+    }
+    if (action.nodeAttributes) {
+      for (let name in action.nodeAttributes) {
+        buttonNode.setAttribute(name, action.nodeAttributes[name]);
+      }
+    }
+    buttonNode.addEventListener("click", event => {
+      if (event.button != 0) {
+        return;
+      }
+      if (action.subview || action.wantsIframe) {
+        this._toggleTempPanelForAction(action);
+        return;
+      }
+      action.onCommand(event, buttonNode);
+    });
+    return buttonNode;
+  },
+
+  _appendPanelSeparator(action) {
+    let node = document.createElement("toolbarseparator");
+    node.id = this._panelButtonNodeIDForActionID(action.id);
+    this.mainViewBodyNode.appendChild(node);
+  },
+
+  /**
+   * Removes all the DOM nodes of the given action.
+   *
+   * @param  action (PageActions.Action, required)
+   *         The action to remove.
+   */
+  removeAction(action) {
+    this._removeActionFromPanel(action);
+    this._removeActionFromUrlbar(action);
+  },
+
+  _removeActionFromPanel(action) {
+    let id = this._panelButtonNodeIDForActionID(action.id);
+    let node = document.getElementById(id);
+    if (node) {
+      node.remove();
+    }
+    if (action.subview) {
+      let panelViewNodeID = this._panelViewNodeIDFromActionID(action.id);
+      let panelViewNode = document.getElementById(panelViewNodeID);
+      if (panelViewNode) {
+        panelViewNode.remove();
+      }
+    }
+  },
+
+  _removeActionFromUrlbar(action) {
+    let id = this._urlbarButtonNodeIDForActionID(action.id);
+    let node = document.getElementById(id);
+    if (node) {
+      node.remove();
+    }
+  },
+
+  /**
+   * Updates the DOM nodes of an action to reflect its changed iconURL.
+   *
+   * @param  action (PageActions.Action, required)
+   *         The action to update.
+   */
+  updateActionIconURL(action) {
+    let url = action.iconURL ? `url('${action.iconURL}')` : null;
+    let nodeIDs = [
+      this._panelButtonNodeIDForActionID(action.id),
+      this._urlbarButtonNodeIDForActionID(action.id),
+    ];
+    for (let nodeID of nodeIDs) {
+      let node = document.getElementById(nodeID);
+      if (node) {
+        if (url) {
+          node.style.listStyleImage = url;
+        } else {
+          node.style.removeProperty("list-style-image");
+        }
+      }
+    }
+  },
+
+  /**
+   * Updates the DOM nodes of an action to reflect its changed title.
+   *
+   * @param  action (PageActions.Action, required)
+   *         The action to update.
+   */
+  updateActionTitle(action) {
+    let id = this._panelButtonNodeIDForActionID(action.id);
+    let node = document.getElementById(id);
+    if (node) {
+      node.setAttribute("label", action.title);
+    }
+  },
+
+  /**
+   * Returns the action for a node.
+   *
+   * @param  node (DOM node, required)
+   *         A button DOM node, either one that's shown in the page action panel
+   *         or the urlbar.
+   * @return (PageAction.Action) The node's related action, or null if none.
+   */
+  actionForNode(node) {
+    if (!node) {
+      return null;
+    }
+    let actionID = this._actionIDForNodeID(node.id);
+    return PageActions.actionForID(actionID);
+  },
+
+  _panelButtonNodeIDForActionID(actionID) {
+    return "pageAction-panel-" + actionID;
+  },
+
+  _urlbarButtonNodeIDForActionID(actionID) {
+    let action = PageActions.actionForID(actionID);
+    if (action && action.urlbarIDOverride) {
+      return action.urlbarIDOverride;
+    }
+    return "pageAction-urlbar-" + actionID;
+  },
+
+  _actionIDForNodeID(nodeID) {
+    if (!nodeID) {
+      return null;
+    }
+    let match = nodeID.match(/^pageAction-(?:panel|urlbar)-(.+)$/);
+    return match ? match[1] : null;
+  },
+
+  /**
+   * Call this when the main page action button in the urlbar is activated.
+   *
+   * @param  event (DOM event, required)
+   *         The click or whatever event.
+   */
+  mainButtonClicked(event) {
+    event.stopPropagation();
+
+    if ((event.type == "click" && event.button != 0) ||
+        (event.type == "keypress" && event.charCode != KeyEvent.DOM_VK_SPACE &&
+         event.keyCode != KeyEvent.DOM_VK_RETURN)) {
+      return;
+    }
+
+    for (let action of PageActions.actions) {
+      let buttonNodeID = this._panelButtonNodeIDForActionID(action.id);
+      let buttonNode = document.getElementById(buttonNodeID);
+      action.onShowingInPanel(buttonNode);
+    }
+
+    this.panelNode.hidden = false;
+    this.panelNode.openPopup(this.mainButtonNode, {
+      position: "bottomcenter topright",
+      triggerEvent: event,
+    });
+  },
+
+  /**
+   * Call this on the contextmenu event.  Note that this is called before
+   * onContextMenuShowing.
+   *
+   * @param  event (DOM event, required)
+   *         The contextmenu event.
+   */
+  onContextMenu(event) {
+    let node = event.originalTarget;
+    this._contextAction = this.actionForNode(node);
+  },
+
+  /**
+   * Call this on the context menu's popupshowing event.
+   *
+   * @param  event (DOM event, required)
+   *         The popupshowing event.
+   * @param  popup (DOM node, required)
+   *         The context menu popup DOM node.
+   */
+  onContextMenuShowing(event, popup) {
+    if (event.target != popup) {
+      return;
+    }
+    // Right now there's only one item in the context menu, to toggle the
+    // context action's shown-in-urlbar state.  Update it now.
+    let toggleItem = popup.firstChild;
+    let toggleItemLabel = null;
+    if (this._contextAction) {
+      toggleItem.disabled = false;
+      if (this._contextAction.shownInUrlbar) {
+        toggleItemLabel = toggleItem.getAttribute("remove-label");
+      }
+    }
+    if (!toggleItemLabel) {
+      toggleItemLabel = toggleItem.getAttribute("add-label");
+    }
+    toggleItem.label = toggleItemLabel;
+  },
+
+  /**
+   * Call this from the context menu's toggle menu item.
+   */
+  toggleShownInUrlbarForContextAction() {
+    if (!this._contextAction) {
+      return;
+    }
+    this._contextAction.shownInUrlbar = !this._contextAction.shownInUrlbar;
+  },
+
+  _contextAction: null,
+
+  /**
+   * A bunch of strings (labels for actions and the like) are defined in DTD,
+   * but actions are created in JS.  So what we do is add a bunch of attributes
+   * to the page action panel's definition in the markup, whose values hold
+   * these DTD strings.  Then when each built-in action is set up, we get the
+   * related strings from the panel node and set up the action's node with them.
+   *
+   * The convention is to set for example the "title" property in an action's JS
+   * definition to the name of the attribute on the panel node that holds the
+   * actual title string.  Then call this function, passing the action's related
+   * DOM node and the name of the attribute that you are setting on the DOM
+   * node -- "label" or "title" in this example (either will do).
+   *
+   * @param  node (DOM node, required)
+   *         The node of an action you're setting up.
+   * @param  attrName (string, required)
+   *         The name of the attribute *on the node you're setting up*.
+   */
+  takeNodeAttributeFromPanel(node, attrName) {
+    let panelAttrName = node.getAttribute(attrName);
+    if (!panelAttrName && attrName == "title") {
+      attrName = "label";
+      panelAttrName = node.getAttribute(attrName);
+    }
+    if (panelAttrName) {
+      let attrValue = this.panelNode.getAttribute(panelAttrName);
+      if (attrValue) {
+        node.setAttribute(attrName, attrValue);
+      }
+    }
+  },
+};
+
+
+// built-in actions below //////////////////////////////////////////////////////
+
+// bookmark
+BrowserPageActions.bookmark = {
+  onShowingInPanel(buttonNode) {
+    // Update the button label via the bookmark observer.
+    BookmarkingUI.updateBookmarkPageMenuItem();
+  },
+
+  onCommand(event, buttonNode) {
+    BrowserPageActions.panelNode.hidePopup();
+    BookmarkingUI.onStarCommand(event);
+  },
+};
+
+// copy URL
+BrowserPageActions.copyURL = {
+  onPlacedInPanel(buttonNode) {
+    BrowserPageActions.takeNodeAttributeFromPanel(buttonNode, "title");
+  },
+
+  onCommand(event, buttonNode) {
+    BrowserPageActions.panelNode.hidePopup();
+    Cc["@mozilla.org/widget/clipboardhelper;1"]
+      .getService(Ci.nsIClipboardHelper)
+      .copyString(gBrowser.selectedBrowser.currentURI.spec);
+  },
+};
+
+// email link
+BrowserPageActions.emailLink = {
+  onPlacedInPanel(buttonNode) {
+    BrowserPageActions.takeNodeAttributeFromPanel(buttonNode, "title");
+  },
+
+  onCommand(event, buttonNode) {
+    BrowserPageActions.panelNode.hidePopup();
+    MailIntegration.sendLinkForBrowser(gBrowser.selectedBrowser);
+  },
+};
+
+// send to device
+BrowserPageActions.sendToDevice = {
+  onPlacedInPanel(buttonNode) {
+    BrowserPageActions.takeNodeAttributeFromPanel(buttonNode, "title");
+  },
+
+  onSubviewPlaced(panelViewNode) {
+    let bodyNode = panelViewNode.firstChild;
+    for (let node of bodyNode.childNodes) {
+      BrowserPageActions.takeNodeAttributeFromPanel(node, "title");
+      BrowserPageActions.takeNodeAttributeFromPanel(node, "shortcut");
+    }
+  },
+
+  onShowingInPanel(buttonNode) {
+    let browser = gBrowser.selectedBrowser;
+    let url = browser.currentURI.spec;
+    if (gSync.isSendableURI(url)) {
+      buttonNode.removeAttribute("disabled");
+    } else {
+      buttonNode.setAttribute("disabled", "true");
+    }
+  },
+
+  onShowingSubview(panelViewNode) {
+    let browser = gBrowser.selectedBrowser;
+    let url = browser.currentURI.spec;
+    let title = browser.contentTitle;
+
+    let bodyNode = panelViewNode.firstChild;
+
+    // This is on top because it also clears the device list between state
+    // changes.
+    gSync.populateSendTabToDevicesMenu(bodyNode, url, title, (clientId, name, clientType) => {
+      if (!name) {
+        return document.createElement("toolbarseparator");
+      }
+      let item = document.createElement("toolbarbutton");
+      item.classList.add("pageAction-sendToDevice-device", "subviewbutton");
+      if (clientId) {
+        item.classList.add("subviewbutton-iconic");
+      }
+      item.setAttribute("tooltiptext", name);
+      return item;
+    });
+
+    bodyNode.removeAttribute("state");
+    // In the first ~10 sec after startup, Sync may not be loaded and the list
+    // of devices will be empty.
+    if (gSync.syncConfiguredAndLoading) {
+      bodyNode.setAttribute("state", "notready");
+      // Force a background Sync
+      Services.tm.dispatchToMainThread(async () => {
+        await Weave.Service.sync([]);  // [] = clients engine only
+        // There's no way Sync is still syncing at this point, but we check
+        // anyway to avoid infinite looping.
+        if (!window.closed && !gSync.syncConfiguredAndLoading) {
+          this.onShowingSubview(panelViewNode);
+        }
+      });
+    }
+  },
+};
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -36,17 +36,17 @@ XPCOMUtils.defineLazyPreferenceGetter(th
           ReaderParent:false, RecentWindow:false, SafeBrowsing: false,
           SessionStore:false,
           ShortcutUtils:false, SimpleServiceDiscovery:false, SitePermissions:false,
           Social:false, TabCrashHandler:false, TelemetryStopwatch:false,
           Translation:false, UITour:false, Utils:false, UpdateUtils:false,
           Weave:false,
           WebNavigationFrames: false, fxAccounts:false, gDevTools:false,
           gDevToolsBrowser:false, webrtcUI:false, ZoomUI:false,
-          Marionette:false,
+          Marionette:false, PageActions:false,
  */
 
 /**
  * IF YOU ADD OR REMOVE FROM THIS LIST, PLEASE UPDATE THE LIST ABOVE AS WELL.
  * XXX Bug 1325373 is for making eslint detect these automatically.
  */
 [
   ["AboutHome", "resource:///modules/AboutHome.jsm"],
@@ -64,16 +64,17 @@ XPCOMUtils.defineLazyPreferenceGetter(th
   ["E10SUtils", "resource:///modules/E10SUtils.jsm"],
   ["ExtensionsUI", "resource:///modules/ExtensionsUI.jsm"],
   ["FormValidationHandler", "resource:///modules/FormValidationHandler.jsm"],
   ["GMPInstallManager", "resource://gre/modules/GMPInstallManager.jsm"],
   ["LightweightThemeManager", "resource://gre/modules/LightweightThemeManager.jsm"],
   ["Log", "resource://gre/modules/Log.jsm"],
   ["LoginManagerParent", "resource://gre/modules/LoginManagerParent.jsm"],
   ["NewTabUtils", "resource://gre/modules/NewTabUtils.jsm"],
+  ["PageActions", "resource:///modules/PageActions.jsm"],
   ["PageThumbs", "resource://gre/modules/PageThumbs.jsm"],
   ["PluralForm", "resource://gre/modules/PluralForm.jsm"],
   ["Preferences", "resource://gre/modules/Preferences.jsm"],
   ["PrivateBrowsingUtils", "resource://gre/modules/PrivateBrowsingUtils.jsm"],
   ["ProcessHangMonitor", "resource:///modules/ProcessHangMonitor.jsm"],
   ["PromiseUtils", "resource://gre/modules/PromiseUtils.jsm"],
   ["ReaderMode", "resource://gre/modules/ReaderMode.jsm"],
   ["ReaderParent", "resource:///modules/ReaderParent.jsm"],
@@ -1385,16 +1386,17 @@ var gBrowserInit = {
       gURLBar.setAttribute("readonly", "true");
       gURLBar.setAttribute("enablehistory", "false");
     }
 
     // Misc. inits.
     TabletModeUpdater.init();
     CombinedStopReload.init();
     gPrivateBrowsingUI.init();
+    BrowserPageActions.init();
 
     if (window.matchMedia("(-moz-os-version: windows-win8)").matches &&
         window.matchMedia("(-moz-windows-default-theme)").matches) {
       let windowFrameColor = new Color(...Cu.import("resource:///modules/Windows8WindowFrameColor.jsm", {})
                                             .Windows8WindowFrameColor.get());
       // Default to black for foreground text.
       if (!windowFrameColor.isContrastRatioAcceptable(new Color(0, 0, 0))) {
         document.documentElement.setAttribute("darkwindowframe", "true");
@@ -7979,116 +7981,16 @@ var gIdentityHandler = {
     container.appendChild(nameLabel);
     container.appendChild(stateLabel);
     container.appendChild(button);
 
     return container;
   }
 };
 
-var gPageActionButton = {
-  get button() {
-    delete this.button;
-    return this.button = document.getElementById("urlbar-page-action-button");
-  },
-
-  get panel() {
-    delete this.panel;
-    return this.panel = document.getElementById("page-action-panel");
-  },
-
-  get sendToDeviceBody() {
-    delete this.sendToDeviceBody;
-    return this.sendToDeviceBody = document.getElementById("page-action-sendToDeviceView-body");
-  },
-
-  onEvent(event) {
-    event.stopPropagation();
-
-    if ((event.type == "click" && event.button != 0) ||
-        (event.type == "keypress" && event.charCode != KeyEvent.DOM_VK_SPACE &&
-         event.keyCode != KeyEvent.DOM_VK_RETURN)) {
-      return; // Left click, space or enter only
-    }
-
-    this._preparePanelToBeShown();
-    this.panel.hidden = false;
-    this.panel.openPopup(this.button, {
-      position: "bottomcenter topright",
-      triggerEvent: event,
-    });
-  },
-
-  _preparePanelToBeShown() {
-    // Update the bookmark item's label.
-    BookmarkingUI.updateBookmarkPageMenuItem();
-
-    // Update the send-to-device item's disabled state.
-    let browser = gBrowser.selectedBrowser;
-    let url = browser.currentURI.spec;
-    let sendToDeviceItem =
-      document.getElementById("page-action-send-to-device-button");
-    sendToDeviceItem.disabled = !gSync.isSendableURI(url);
-  },
-
-  copyURL() {
-    this.panel.hidePopup();
-    Cc["@mozilla.org/widget/clipboardhelper;1"]
-      .getService(Ci.nsIClipboardHelper)
-      .copyString(gBrowser.selectedBrowser.currentURI.spec);
-  },
-
-  emailLink() {
-    this.panel.hidePopup();
-    MailIntegration.sendLinkForBrowser(gBrowser.selectedBrowser);
-  },
-
-  showSendToDeviceView(subviewButton) {
-    this.setupSendToDeviceView();
-    PanelUI.showSubView("page-action-sendToDeviceView", subviewButton);
-  },
-
-  setupSendToDeviceView() {
-    let browser = gBrowser.selectedBrowser;
-    let url = browser.currentURI.spec;
-    let title = browser.contentTitle;
-    let body = this.sendToDeviceBody;
-
-    // This is on top because it also clears the device list between state changes.
-    gSync.populateSendTabToDevicesMenu(body, url, title, (clientId, name, clientType) => {
-      if (!name) {
-        return document.createElement("toolbarseparator");
-      }
-      let item = document.createElement("toolbarbutton");
-      item.classList.add("page-action-sendToDevice-device", "subviewbutton");
-      if (clientId) {
-        item.classList.add("subviewbutton-iconic");
-      }
-      item.setAttribute("tooltiptext", name);
-      return item;
-    });
-
-    body.removeAttribute("state");
-    // In the first ~10 sec after startup, Sync may not be loaded and the list
-    // of devices will be empty.
-    if (gSync.syncConfiguredAndLoading) {
-      body.setAttribute("state", "notready");
-      // Force a background Sync
-      Services.tm.dispatchToMainThread(async () => {
-        await Weave.Service.sync([]);  // [] = clients engine only
-        // There's no way Sync is still syncing at this point, but we check
-        // anyway to avoid infinite looping.
-        if (!window.closed && !gSync.syncConfiguredAndLoading) {
-          this.setupSendToDeviceView();
-        }
-      });
-    }
-  },
-};
-
 /**
  * Fired on the "marionette-remote-control" system notification,
  * indicating if the browser session is under remote control.
  */
 const gRemoteControl = {
   observe(subject, topic, data) {
     gRemoteControl.updateVisualCue(data);
   },
--- a/browser/base/content/global-scripts.inc
+++ b/browser/base/content/global-scripts.inc
@@ -8,16 +8,17 @@
 # tools/lint/eslint/eslint-plugin-mozilla/lib/environments/browser-window.js
 
 <script type="application/javascript" src="chrome://browser/content/browser.js"/>
 
 <script type="application/javascript" src="chrome://browser/content/browser-captivePortal.js"/>
 <script type="application/javascript" src="chrome://browser/content/browser-compacttheme.js"/>
 <script type="application/javascript" src="chrome://browser/content/browser-feeds.js"/>
 <script type="application/javascript" src="chrome://browser/content/browser-media.js"/>
+<script type="application/javascript" src="chrome://browser/content/browser-pageActions.js"/>
 <script type="application/javascript" src="chrome://browser/content/browser-places.js"/>
 <script type="application/javascript" src="chrome://browser/content/browser-plugins.js"/>
 <script type="application/javascript" src="chrome://browser/content/browser-sidebar.js"/>
 <script type="application/javascript" src="chrome://browser/content/browser-tabsintitlebar.js"/>
 <script type="application/javascript" src="chrome://browser/content/browser-trackingprotection.js"/>
 
 #ifdef MOZ_DATA_REPORTING
 <script type="application/javascript" src="chrome://browser/content/browser-data-submission-info-bar.js"/>
--- a/browser/base/jar.mn
+++ b/browser/base/jar.mn
@@ -69,16 +69,17 @@ browser.jar:
         content/browser/browser-customization.js      (content/browser-customization.js)
         content/browser/browser-data-submission-info-bar.js (content/browser-data-submission-info-bar.js)
         content/browser/browser-compacttheme.js       (content/browser-compacttheme.js)
         content/browser/browser-feeds.js              (content/browser-feeds.js)
         content/browser/browser-fullScreenAndPointerLock.js  (content/browser-fullScreenAndPointerLock.js)
         content/browser/browser-fullZoom.js           (content/browser-fullZoom.js)
         content/browser/browser-gestureSupport.js     (content/browser-gestureSupport.js)
         content/browser/browser-media.js              (content/browser-media.js)
+        content/browser/browser-pageActions.js        (content/browser-pageActions.js)
         content/browser/browser-places.js             (content/browser-places.js)
         content/browser/browser-plugins.js            (content/browser-plugins.js)
         content/browser/browser-safebrowsing.js       (content/browser-safebrowsing.js)
         content/browser/browser-sidebar.js            (content/browser-sidebar.js)
         content/browser/browser-social.js             (content/browser-social.js)
         content/browser/browser-sync.js               (content/browser-sync.js)
 *       content/browser/browser-tabPreviews.xml       (content/browser-tabPreviews.xml)
 #ifdef CAN_DRAW_IN_TITLEBAR