gecko-dev/browser/base/content/browser-pageActions.js

1391 lines
41 KiB
JavaScript

/* 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/. */
ChromeUtils.defineModuleGetter(
this,
"SearchUIUtils",
"resource:///modules/SearchUIUtils.jsm"
);
var BrowserPageActions = {
_panelNode: null,
/**
* 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() {
// Lazy load the page action panel the first time we need to display it
if (!this._panelNode) {
this.initializePanel();
}
delete this.panelNode;
return (this.panelNode = this._panelNode);
},
/**
* The panelmultiview 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() {
this.placeAllActionsInUrlbar();
this._onPanelShowing = this._onPanelShowing.bind(this);
},
_onPanelShowing() {
this.initializePanel();
for (let action of PageActions.actionsInPanel(window)) {
let buttonNode = this.panelButtonNodeForActionID(action.id);
action.onShowingInPanel(buttonNode);
}
},
placeLazyActionsInPanel() {
let actions = this._actionsToLazilyPlaceInPanel;
this._actionsToLazilyPlaceInPanel = [];
for (let action of actions) {
this._placeActionInPanelNow(action);
}
},
// Actions placed in the panel aren't actually placed until the panel is
// subsequently opened.
_actionsToLazilyPlaceInPanel: [],
/**
* Places all registered actions in the urlbar.
*/
placeAllActionsInUrlbar() {
let urlbarActions = PageActions.actionsInUrlbar(window);
for (let action of urlbarActions) {
this.placeActionInUrlbar(action);
}
},
/**
* Initializes the panel if necessary.
*/
initializePanel() {
// Lazy load the page action panel the first time we need to display it
if (!this._panelNode) {
let template = document.getElementById("pageActionPanelTemplate");
template.replaceWith(template.content);
this._panelNode = document.getElementById("pageActionPanel");
this._panelNode.addEventListener("popupshowing", this._onPanelShowing);
this._panelNode.addEventListener("popuphiding", () => {
this.mainButtonNode.removeAttribute("open");
});
}
for (let action of PageActions.actionsInPanel(window)) {
this.placeActionInPanel(action);
}
this.placeLazyActionsInPanel();
},
/**
* Adds or removes as necessary DOM nodes for the given action.
*
* @param action (PageActions.Action, required)
* The action to place.
*/
placeAction(action) {
this.placeActionInPanel(action);
this.placeActionInUrlbar(action);
},
/**
* Adds or removes as necessary DOM nodes for the action in the panel.
*
* @param action (PageActions.Action, required)
* The action to place.
*/
placeActionInPanel(action) {
if (this._panelNode && this.panelNode.state != "closed") {
this._placeActionInPanelNow(action);
} else {
// Lazily place the action in the panel the next time it opens.
this._actionsToLazilyPlaceInPanel.push(action);
}
},
_placeActionInPanelNow(action) {
if (action.shouldShowInPanel(window)) {
this._addActionToPanel(action);
} else {
this._removeActionFromPanel(action);
}
},
_addActionToPanel(action) {
let id = this.panelButtonNodeIDForActionID(action.id);
let node = document.getElementById(id);
if (node) {
return;
}
this._maybeNotifyBeforePlacedInWindow(action);
node = this._makePanelButtonNodeForAction(action);
node.id = id;
let insertBeforeNode = this._getNextNode(action, false);
this.mainViewBodyNode.insertBefore(node, insertBeforeNode);
this.updateAction(action, null, {
panelNode: node,
});
this._updateActionDisabledInPanel(action, node);
action.onPlacedInPanel(node);
this._addOrRemoveSeparatorsInPanel();
},
_removeActionFromPanel(action) {
let lazyIndex = this._actionsToLazilyPlaceInPanel.findIndex(
a => a.id == action.id
);
if (lazyIndex >= 0) {
this._actionsToLazilyPlaceInPanel.splice(lazyIndex, 1);
}
let node = this.panelButtonNodeForActionID(action.id);
if (!node) {
return;
}
node.remove();
if (action.getWantsSubview(window)) {
let panelViewNodeID = this._panelViewNodeIDForActionID(action.id, false);
let panelViewNode = document.getElementById(panelViewNodeID);
if (panelViewNode) {
panelViewNode.remove();
}
}
this._addOrRemoveSeparatorsInPanel();
},
_addOrRemoveSeparatorsInPanel() {
let actions = PageActions.actionsInPanel(window);
let ids = [
PageActions.ACTION_ID_BUILT_IN_SEPARATOR,
PageActions.ACTION_ID_TRANSIENT_SEPARATOR,
];
for (let id of ids) {
let sep = actions.find(a => a.id == id);
if (sep) {
this._addActionToPanel(sep);
} else {
let node = this.panelButtonNodeForActionID(id);
if (node) {
node.remove();
}
}
}
},
/**
* Returns the node before which an action's node should be inserted.
*
* @param action (PageActions.Action, required)
* The action that will be inserted.
* @param forUrlbar (bool, required)
* True if you're inserting into the urlbar, false if you're inserting
* into the panel.
* @return (DOM node, maybe null) The DOM node before which to insert the
* given action. Null if the action should be inserted at the end.
*/
_getNextNode(action, forUrlbar) {
let actions = forUrlbar
? PageActions.actionsInUrlbar(window)
: PageActions.actionsInPanel(window);
let index = actions.findIndex(a => a.id == action.id);
if (index < 0) {
return null;
}
for (let i = index + 1; i < actions.length; i++) {
let node = forUrlbar
? this.urlbarButtonNodeForActionID(actions[i].id)
: this.panelButtonNodeForActionID(actions[i].id);
if (node) {
return node;
}
}
return null;
},
_maybeNotifyBeforePlacedInWindow(action) {
if (!this._isActionPlacedInWindow(action)) {
action.onBeforePlacedInWindow(window);
}
},
_isActionPlacedInWindow(action) {
if (this.panelButtonNodeForActionID(action.id)) {
return true;
}
let urlbarNode = this.urlbarButtonNodeForActionID(action.id);
return urlbarNode && !urlbarNode.hidden;
},
_makePanelButtonNodeForAction(action) {
if (action.__isSeparator) {
let node = document.createXULElement("toolbarseparator");
return node;
}
let buttonNode = document.createXULElement("toolbarbutton");
buttonNode.classList.add(
"subviewbutton",
"subviewbutton-iconic",
"pageAction-panel-button"
);
if (action.isBadged) {
buttonNode.setAttribute("badged", "true");
}
buttonNode.setAttribute("actionid", action.id);
buttonNode.addEventListener("command", event => {
this.doCommandForAction(action, event, buttonNode);
});
return buttonNode;
},
_makePanelViewNodeForAction(action, forUrlbar) {
let panelViewNode = document.createXULElement("panelview");
panelViewNode.id = this._panelViewNodeIDForActionID(action.id, forUrlbar);
panelViewNode.classList.add("PanelUI-subView");
let bodyNode = document.createXULElement("vbox");
bodyNode.id = panelViewNode.id + "-body";
bodyNode.classList.add("panel-subview-body");
panelViewNode.appendChild(bodyNode);
return panelViewNode;
},
/**
* Shows or hides a panel for an action. You can supply your own panel;
* otherwise one is created.
*
* @param action (PageActions.Action, required)
* The action for which to toggle the panel. If the action is in the
* urlbar, then the panel will be anchored to it. Otherwise, a
* suitable anchor will be used.
* @param panelNode (DOM node, optional)
* The panel to use. This method takes a hands-off approach with
* regard to your panel in terms of attributes, styling, etc.
* @param event (DOM event, optional)
* The event which triggered this panel.
*/
togglePanelForAction(action, panelNode = null, event = null) {
let aaPanelNode = this.activatedActionPanelNode;
if (panelNode) {
// Note that this particular code path will not prevent the panel from
// opening later if PanelMultiView.showPopup was called but the panel has
// not been opened yet.
if (panelNode.state != "closed") {
PanelMultiView.hidePopup(panelNode);
return;
}
if (aaPanelNode) {
PanelMultiView.hidePopup(aaPanelNode);
}
} else if (aaPanelNode) {
PanelMultiView.hidePopup(aaPanelNode);
return;
} else {
panelNode = this._makeActivatedActionPanelForAction(action);
}
// Hide the main panel before showing the action's panel.
PanelMultiView.hidePopup(this.panelNode);
let anchorNode = this.panelAnchorNodeForAction(action);
anchorNode.setAttribute("open", "true");
panelNode.addEventListener(
"popuphiding",
() => {
anchorNode.removeAttribute("open");
},
{ once: true }
);
PanelMultiView.openPopup(panelNode, anchorNode, {
position: "bottomcenter topright",
triggerEvent: event,
}).catch(Cu.reportError);
},
_makeActivatedActionPanelForAction(action) {
let panelNode = document.createXULElement("panel");
panelNode.id = this._activatedActionPanelID;
panelNode.classList.add("cui-widget-panel", "panel-no-padding");
panelNode.setAttribute("actionID", action.id);
panelNode.setAttribute("role", "group");
panelNode.setAttribute("type", "arrow");
panelNode.setAttribute("flip", "slide");
panelNode.setAttribute("noautofocus", "true");
panelNode.setAttribute("tabspecific", "true");
let panelViewNode = null;
let iframeNode = null;
if (action.getWantsSubview(window)) {
let multiViewNode = document.createXULElement("panelmultiview");
panelViewNode = this._makePanelViewNodeForAction(action, true);
multiViewNode.setAttribute("mainViewId", panelViewNode.id);
multiViewNode.appendChild(panelViewNode);
panelNode.appendChild(multiViewNode);
} else if (action.wantsIframe) {
iframeNode = document.createXULElement("iframe");
iframeNode.setAttribute("type", "content");
panelNode.appendChild(iframeNode);
}
let popupSet = document.getElementById("mainPopupSet");
popupSet.appendChild(panelNode);
panelNode.addEventListener(
"popuphidden",
() => {
PanelMultiView.removePopup(panelNode);
},
{ once: true }
);
if (iframeNode) {
panelNode.addEventListener(
"popupshowing",
() => {
action.onIframeShowing(iframeNode, panelNode);
},
{ once: true }
);
panelNode.addEventListener(
"popupshown",
() => {
iframeNode.focus();
},
{ once: true }
);
panelNode.addEventListener(
"popuphiding",
() => {
action.onIframeHiding(iframeNode, panelNode);
},
{ once: true }
);
panelNode.addEventListener(
"popuphidden",
() => {
action.onIframeHidden(iframeNode, panelNode);
},
{ once: true }
);
}
if (panelViewNode) {
action.onSubviewPlaced(panelViewNode);
panelNode.addEventListener(
"popupshowing",
() => {
action.onSubviewShowing(panelViewNode);
},
{ once: true }
);
}
return panelNode;
},
/**
* Returns the node in the urlbar to which popups for the given action should
* be anchored. If the action is null, a sensible anchor is returned.
*
* @param action (PageActions.Action, optional)
* The action you want to anchor.
* @param event (DOM event, optional)
* This is used to display the feedback panel on the right node when
* the command can be invoked from both the main panel and another
* location, such as an activated action panel or a button.
* @return (DOM node) The node to which the action should be anchored.
*/
panelAnchorNodeForAction(action, event) {
if (event && event.target.closest("panel") == this.panelNode) {
return this.mainButtonNode;
}
// Try each of the following nodes in order, using the first that's visible.
let potentialAnchorNodeIDs = [
action && action.anchorIDOverride,
action && this.urlbarButtonNodeIDForActionID(action.id),
this.mainButtonNode.id,
"identity-icon",
"urlbar-search-button",
];
for (let id of potentialAnchorNodeIDs) {
if (id) {
let node = document.getElementById(id);
if (node && !node.hidden) {
let bounds = window.windowUtils.getBoundsWithoutFlushing(node);
if (bounds.height > 0 && bounds.width > 0) {
return node;
}
}
}
}
let id = action ? action.id : "<no action>";
throw new Error(`PageActions: No anchor node for ${id}`);
},
get activatedActionPanelNode() {
return document.getElementById(this._activatedActionPanelID);
},
get _activatedActionPanelID() {
return "pageActionActivatedActionPanel";
},
/**
* Adds or removes as necessary a DOM node for the given action in the urlbar.
*
* @param action (PageActions.Action, required)
* The action to place.
*/
placeActionInUrlbar(action) {
let id = this.urlbarButtonNodeIDForActionID(action.id);
let node = document.getElementById(id);
if (!action.shouldShowInUrlbar(window)) {
if (node) {
if (action.__urlbarNodeInMarkup) {
node.hidden = true;
} else {
node.remove();
}
}
return;
}
let newlyPlaced = false;
if (action.__urlbarNodeInMarkup) {
this._maybeNotifyBeforePlacedInWindow(action);
// Allow the consumer to add the node in response to the
// onBeforePlacedInWindow notification.
node = document.getElementById(id);
if (!node) {
return;
}
newlyPlaced = node.hidden;
node.hidden = false;
} else if (!node) {
newlyPlaced = true;
this._maybeNotifyBeforePlacedInWindow(action);
node = this._makeUrlbarButtonNode(action);
node.id = id;
}
if (!newlyPlaced) {
return;
}
let insertBeforeNode = this._getNextNode(action, true);
this.mainButtonNode.parentNode.insertBefore(node, insertBeforeNode);
this.updateAction(action, null, {
urlbarNode: node,
});
action.onPlacedInUrlbar(node);
},
_makeUrlbarButtonNode(action) {
let buttonNode = document.createXULElement("image");
buttonNode.classList.add("urlbar-icon", "urlbar-page-action");
buttonNode.setAttribute("actionid", action.id);
buttonNode.setAttribute("role", "button");
let commandHandler = event => {
this.doCommandForAction(action, event, buttonNode);
};
buttonNode.addEventListener("click", commandHandler);
buttonNode.addEventListener("keypress", commandHandler);
return buttonNode;
},
/**
* 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);
action.onRemovedFromWindow(window);
},
_removeActionFromUrlbar(action) {
let node = this.urlbarButtonNodeForActionID(action.id);
if (node) {
node.remove();
}
},
/**
* Updates the DOM nodes of an action to reflect either a changed property or
* all properties.
*
* @param action (PageActions.Action, required)
* The action to update.
* @param propertyName (string, optional)
* The name of the property to update. If not given, then DOM nodes
* will be updated to reflect the current values of all properties.
* @param opts (object, optional)
* - panelNode: The action's node in the panel to update.
* - urlbarNode: The action's node in the urlbar to update.
* - value: If a property name is passed, this argument may contain
* its current value, in order to prevent a further look-up.
*/
updateAction(action, propertyName = null, opts = {}) {
let anyNodeGiven = "panelNode" in opts || "urlbarNode" in opts;
let panelNode = anyNodeGiven
? opts.panelNode || null
: this.panelButtonNodeForActionID(action.id);
let urlbarNode = anyNodeGiven
? opts.urlbarNode || null
: this.urlbarButtonNodeForActionID(action.id);
let value = opts.value || undefined;
if (propertyName) {
this[this._updateMethods[propertyName]](
action,
panelNode,
urlbarNode,
value
);
} else {
for (let name of ["iconURL", "title", "tooltip", "wantsSubview"]) {
this[this._updateMethods[name]](action, panelNode, urlbarNode, value);
}
}
},
_updateMethods: {
disabled: "_updateActionDisabled",
iconURL: "_updateActionIconURL",
title: "_updateActionLabeling",
tooltip: "_updateActionTooltip",
wantsSubview: "_updateActionWantsSubview",
},
_updateActionDisabled(
action,
panelNode,
urlbarNode,
disabled = action.getDisabled(window)
) {
if (action.__transient) {
this.placeActionInPanel(action);
} else {
this._updateActionDisabledInPanel(action, panelNode, disabled);
}
this.placeActionInUrlbar(action);
},
_updateActionDisabledInPanel(
action,
panelNode,
disabled = action.getDisabled(window)
) {
if (panelNode) {
if (disabled) {
panelNode.setAttribute("disabled", "true");
} else {
panelNode.removeAttribute("disabled");
}
}
},
_updateActionIconURL(
action,
panelNode,
urlbarNode,
properties = action.getIconProperties(window)
) {
for (let [prop, value] of Object.entries(properties)) {
if (panelNode) {
panelNode.style.setProperty(prop, value);
}
if (urlbarNode) {
urlbarNode.style.setProperty(prop, value);
}
}
},
_updateActionLabeling(
action,
panelNode,
urlbarNode,
title = action.getTitle(window)
) {
let tabCount = gBrowser.selectedTabs.length;
if (panelNode) {
if (action.panelFluentID) {
document.l10n.setAttributes(panelNode, action.panelFluentID, {
tabCount,
});
} else {
panelNode.setAttribute("label", title);
}
}
if (urlbarNode) {
// Some actions (e.g. Save Page to Pocket) have a wrapper node with the
// actual controls inside that wrapper. The wrapper is semantically
// meaningless, so it doesn't get reflected in the accessibility tree.
// In these cases, we don't want to set aria-label because that will
// force the element to be exposed to accessibility.
if (urlbarNode.nodeName != "hbox") {
urlbarNode.setAttribute("aria-label", title);
}
// tooltiptext falls back to the title, so update it too if necessary.
let tooltip = action.getTooltip(window);
if (!tooltip) {
if (action.urlbarFluentID) {
document.l10n.setAttributes(urlbarNode, action.urlbarFluentID, {
tabCount,
});
} else {
urlbarNode.setAttribute("tooltiptext", title);
}
}
}
},
_updateActionTooltip(
action,
panelNode,
urlbarNode,
tooltip = action.getTooltip(window)
) {
if (urlbarNode) {
if (!tooltip) {
tooltip = action.getTitle(window);
}
if (tooltip) {
urlbarNode.setAttribute("tooltiptext", tooltip);
}
}
},
_updateActionWantsSubview(
action,
panelNode,
urlbarNode,
wantsSubview = action.getWantsSubview(window)
) {
if (!panelNode) {
return;
}
let panelViewID = this._panelViewNodeIDForActionID(action.id, false);
let panelViewNode = document.getElementById(panelViewID);
panelNode.classList.toggle("subviewbutton-nav", wantsSubview);
if (!wantsSubview) {
if (panelViewNode) {
panelViewNode.remove();
}
return;
}
if (!panelViewNode) {
panelViewNode = this._makePanelViewNodeForAction(action, false);
this.multiViewNode.appendChild(panelViewNode);
action.onSubviewPlaced(panelViewNode);
}
},
doCommandForAction(action, event, buttonNode) {
// On mac, ctrl-click will send a context menu event from the widget, so we
// don't want to handle the click event when ctrl key is pressed.
if (
event &&
event.type == "click" &&
(event.button != 0 ||
(AppConstants.platform == "macosx" && event.ctrlKey))
) {
return;
}
if (event && event.type == "keypress") {
if (event.key != " " && event.key != "Enter") {
return;
}
event.stopPropagation();
}
// If we're in the panel, open a subview inside the panel:
// Note that we can't use this.panelNode.contains(buttonNode) here
// because of XBL boundaries breaking Element.contains.
if (
action.getWantsSubview(window) &&
buttonNode &&
buttonNode.closest("panel") == this.panelNode
) {
let panelViewNodeID = this._panelViewNodeIDForActionID(action.id, false);
let panelViewNode = document.getElementById(panelViewNodeID);
action.onSubviewShowing(panelViewNode);
this.multiViewNode.showSubView(panelViewNode, buttonNode);
return;
}
// Otherwise, hide the main popup in case it was open:
PanelMultiView.hidePopup(this.panelNode);
let aaPanelNode = this.activatedActionPanelNode;
if (!aaPanelNode || aaPanelNode.getAttribute("actionID") != action.id) {
action.onCommand(event, buttonNode);
}
if (action.getWantsSubview(window) || action.wantsIframe) {
this.togglePanelForAction(action, null, event);
}
},
/**
* 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) If the node has a related action and the action
* is not a separator, then the action is returned. Otherwise null is
* returned.
*/
actionForNode(node) {
if (!node) {
return null;
}
let actionID = this._actionIDForNodeID(node.id);
let action = PageActions.actionForID(actionID);
if (!action) {
// The given node may be an ancestor of a node corresponding to an action,
// like how #star-button is contained in #star-button-box, the latter
// being the bookmark action's node. Look up the ancestor chain.
for (let n = node.parentNode; n && !action; n = n.parentNode) {
if (n.id == "page-action-buttons" || n.localName == "panelview") {
// We reached the page-action-buttons or panelview container.
// Stop looking; no acton was found.
break;
}
actionID = this._actionIDForNodeID(n.id);
action = PageActions.actionForID(actionID);
}
}
return action && !action.__isSeparator ? action : null;
},
/**
* The given action's top-level button in the main panel.
*
* @param actionID (string, required)
* The action ID.
* @return (DOM node) The action's button in the main panel.
*/
panelButtonNodeForActionID(actionID) {
return document.getElementById(this.panelButtonNodeIDForActionID(actionID));
},
/**
* The ID of the given action's top-level button in the main panel.
*
* @param actionID (string, required)
* The action ID.
* @return (string) The ID of the action's button in the main panel.
*/
panelButtonNodeIDForActionID(actionID) {
return `pageAction-panel-${actionID}`;
},
/**
* The given action's button in the urlbar.
*
* @param actionID (string, required)
* The action ID.
* @return (DOM node) The action's urlbar button node.
*/
urlbarButtonNodeForActionID(actionID) {
return document.getElementById(
this.urlbarButtonNodeIDForActionID(actionID)
);
},
/**
* The ID of the given action's button in the urlbar.
*
* @param actionID (string, required)
* The action ID.
* @return (string) The ID of the action's urlbar button node.
*/
urlbarButtonNodeIDForActionID(actionID) {
let action = PageActions.actionForID(actionID);
if (action && action.urlbarIDOverride) {
return action.urlbarIDOverride;
}
return `pageAction-urlbar-${actionID}`;
},
// The ID of the given action's panelview.
_panelViewNodeIDForActionID(actionID, forUrlbar) {
let placementID = forUrlbar ? "urlbar" : "panel";
return `pageAction-${placementID}-${actionID}-subview`;
},
// The ID of the action corresponding to the given top-level button in the
// panel or button in the urlbar.
_actionIDForNodeID(nodeID) {
if (!nodeID) {
return null;
}
let match = nodeID.match(/^pageAction-(?:panel|urlbar)-(.+)$/);
if (match) {
return match[1];
}
// Check all the urlbar ID overrides.
for (let action of PageActions.actions) {
if (action.urlbarIDOverride && action.urlbarIDOverride == nodeID) {
return action.id;
}
}
return 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 (
// On mac, ctrl-click will send a context menu event from the widget, so
// we don't want to bring up the panel when ctrl key is pressed.
(event.type == "mousedown" &&
(event.button != 0 ||
(AppConstants.platform == "macosx" && event.ctrlKey))) ||
(event.type == "keypress" &&
event.charCode != KeyEvent.DOM_VK_SPACE &&
event.keyCode != KeyEvent.DOM_VK_RETURN)
) {
return;
}
// If the activated-action panel is open and anchored to the main button,
// close it.
let panelNode = this.activatedActionPanelNode;
if (panelNode && panelNode.anchorNode.id == this.mainButtonNode.id) {
PanelMultiView.hidePopup(panelNode);
return;
}
if (this.panelNode.state == "open") {
PanelMultiView.hidePopup(this.panelNode);
} else if (this.panelNode.state == "closed") {
this.showPanel(event);
}
},
/**
* Show the page action panel
*
* @param event (DOM event, optional)
* The event that triggers showing the panel. (such as a mouse click,
* if the user clicked something to open the panel)
*/
showPanel(event = null) {
this.panelNode.hidden = false;
this.mainButtonNode.setAttribute("open", "true");
PanelMultiView.openPopup(this.panelNode, this.mainButtonNode, {
position: "bottomcenter topright",
triggerEvent: event,
}).catch(Cu.reportError);
},
/**
* 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.
*/
async onContextMenuShowing(event, popup) {
if (event.target != popup) {
return;
}
this._contextAction = this.actionForNode(popup.triggerNode);
if (!this._contextAction) {
event.preventDefault();
return;
}
let state;
if (this._contextAction._isMozillaAction) {
state = this._contextAction.pinnedToUrlbar
? "builtInPinned"
: "builtInUnpinned";
} else {
state = this._contextAction.pinnedToUrlbar
? "extensionPinned"
: "extensionUnpinned";
}
popup.setAttribute("state", state);
let removeExtension = popup.querySelector(".removeExtensionItem");
let { extensionID } = this._contextAction;
let addon = extensionID && (await AddonManager.getAddonByID(extensionID));
removeExtension.hidden = !addon;
if (addon) {
removeExtension.disabled = !(
addon.permissions & AddonManager.PERM_CAN_UNINSTALL
);
}
},
/**
* Call this from the menu item in the context menu that toggles pinning.
*/
togglePinningForContextAction() {
if (!this._contextAction) {
return;
}
let action = this._contextAction;
this._contextAction = null;
action.pinnedToUrlbar = !action.pinnedToUrlbar;
BrowserUsageTelemetry.recordWidgetChange(
action.id,
action.pinnedToUrlbar ? "page-action-buttons" : null,
"pageaction-context"
);
},
/**
* Call this from the menu item in the context menu that opens about:addons.
*/
openAboutAddonsForContextAction() {
if (!this._contextAction) {
return;
}
let action = this._contextAction;
this._contextAction = null;
AMTelemetry.recordActionEvent({
object: "pageAction",
action: "manage",
extra: { addonId: action.extensionID },
});
let viewID = "addons://detail/" + encodeURIComponent(action.extensionID);
window.BrowserOpenAddonsMgr(viewID);
},
/**
* Call this from the menu item in the context menu that removes an add-on.
*/
removeExtensionForContextAction() {
if (!this._contextAction) {
return;
}
let action = this._contextAction;
this._contextAction = null;
BrowserAddonUI.removeAddon(action.extensionID, "pageAction");
},
_contextAction: null,
/**
* We use this to set an attribute on the DOM node. If the attribute exists,
* then we get the panel node's attribute and set it on the DOM node. Otherwise,
* we get the title string and update the attribute with that value. The point is to map
* attributes on the node to strings on the main panel. Use this for DOM
* nodes that don't correspond to actions, like buttons in subviews.
*
* @param node (DOM node, required)
* The node 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);
}
}
},
/**
* Call this on tab switch or when the current <browser>'s location changes.
*/
onLocationChange() {
for (let action of PageActions.actions) {
action.onLocationChange(window);
}
},
};
/**
* Shows the feedback popup for an action.
*
* @param action (PageActions.Action, required)
* The action associated with the feedback.
* @param event (DOM event, optional)
* The event that triggered the feedback.
* @param messageId (string, optional)
* Can be used to set a message id that is different from the action id.
*/
function showBrowserPageActionFeedback(action, event = null, messageId = null) {
let anchor = BrowserPageActions.panelAnchorNodeForAction(action, event);
ConfirmationHint.show(anchor, messageId || action.id, {
event,
hideArrow: true,
});
}
// built-in actions below //////////////////////////////////////////////////////
// bookmark
BrowserPageActions.bookmark = {
onShowingInPanel(buttonNode) {
if (buttonNode.label == "null") {
BookmarkingUI.updateBookmarkPageMenuItem();
}
},
onCommand(event, buttonNode) {
PanelMultiView.hidePopup(BrowserPageActions.panelNode);
BookmarkingUI.onStarCommand(event);
},
};
// pin tab
BrowserPageActions.pinTab = {
updateState() {
let action = PageActions.actionForID("pinTab");
let { pinned } = gBrowser.selectedTab;
let fluentID;
if (pinned) {
fluentID = "page-action-unpin-tab";
} else {
fluentID = "page-action-pin-tab";
}
let panelButton = BrowserPageActions.panelButtonNodeForActionID(action.id);
if (panelButton) {
document.l10n.setAttributes(panelButton, fluentID + "-panel");
panelButton.toggleAttribute("pinned", pinned);
}
let urlbarButton = BrowserPageActions.urlbarButtonNodeForActionID(
action.id
);
if (urlbarButton) {
document.l10n.setAttributes(urlbarButton, fluentID + "-urlbar");
urlbarButton.toggleAttribute("pinned", pinned);
}
},
onCommand(event, buttonNode) {
if (gBrowser.selectedTab.pinned) {
gBrowser.unpinTab(gBrowser.selectedTab);
} else {
gBrowser.pinTab(gBrowser.selectedTab);
}
},
};
// copy URL
BrowserPageActions.copyURL = {
onCommand(event, buttonNode) {
PanelMultiView.hidePopup(BrowserPageActions.panelNode);
Cc["@mozilla.org/widget/clipboardhelper;1"]
.getService(Ci.nsIClipboardHelper)
.copyString(
gURLBar.makeURIReadable(gBrowser.selectedBrowser.currentURI).displaySpec
);
let action = PageActions.actionForID("copyURL");
showBrowserPageActionFeedback(action, event);
},
};
// email link
BrowserPageActions.emailLink = {
onCommand(event, buttonNode) {
PanelMultiView.hidePopup(BrowserPageActions.panelNode);
MailIntegration.sendLinkForBrowser(gBrowser.selectedBrowser);
},
};
// send to device
BrowserPageActions.sendToDevice = {
onBeforePlacedInWindow(browserWindow) {
this._updateTitle();
gBrowser.addEventListener("TabMultiSelect", event => {
this._updateTitle();
});
},
// The action's title in this window depends on the number of tabs that are
// selected.
_updateTitle() {
let action = PageActions.actionForID("sendToDevice");
let tabCount = gBrowser.selectedTabs.length;
let panelButton = BrowserPageActions.panelButtonNodeForActionID(action.id);
if (panelButton) {
document.l10n.setAttributes(panelButton, action.panelFluentID, {
tabCount,
});
}
let urlbarButton = BrowserPageActions.urlbarButtonNodeForActionID(
action.id
);
if (urlbarButton) {
document.l10n.setAttributes(urlbarButton, action.urlbarFluentID, {
tabCount,
});
}
},
onSubviewPlaced(panelViewNode) {
let bodyNode = panelViewNode.querySelector(".panel-subview-body");
let notReady = document.createXULElement("toolbarbutton");
notReady.classList.add(
"subviewbutton",
"subviewbutton-iconic",
"pageAction-sendToDevice-notReady"
);
document.l10n.setAttributes(notReady, "page-action-send-tab-not-ready");
notReady.setAttribute("disabled", "true");
bodyNode.appendChild(notReady);
for (let node of bodyNode.children) {
BrowserPageActions.takeNodeAttributeFromPanel(node, "title");
BrowserPageActions.takeNodeAttributeFromPanel(node, "shortcut");
}
},
onLocationChange() {
let action = PageActions.actionForID("sendToDevice");
let browser = gBrowser.selectedBrowser;
let url = browser.currentURI;
action.setDisabled(!BrowserUtils.isShareableURL(url), window);
},
onShowingSubview(panelViewNode) {
gSync.populateSendTabToDevicesView(panelViewNode);
},
};
// add search engine
BrowserPageActions.addSearchEngine = {
get action() {
return PageActions.actionForID("addSearchEngine");
},
get engines() {
return gBrowser.selectedBrowser.engines || [];
},
get strings() {
delete this.strings;
let uri = "chrome://browser/locale/search.properties";
return (this.strings = Services.strings.createBundle(uri));
},
updateEngines() {
// As a slight optimization, if the action isn't in the urlbar, don't do
// anything here except disable it. The action's panel nodes are updated
// when the panel is shown.
this.action.setDisabled(!this.engines.length, window);
if (this.action.shouldShowInUrlbar(window)) {
this._updateTitleAndIcon();
}
},
_updateTitleAndIcon() {
if (!this.engines.length) {
return;
}
let title = this.strings.GetStringFromName("searchAddFoundEngine2");
this.action.setTitle(title, window);
this.action.setIconURL(this.engines[0].icon, window);
},
onShowingInPanel() {
this._updateTitleAndIcon();
this.action.setWantsSubview(this.engines.length > 1, window);
let button = BrowserPageActions.panelButtonNodeForActionID(this.action.id);
button.setAttribute("image", this.engines[0].icon);
button.setAttribute("uri", this.engines[0].uri);
button.setAttribute("crop", "center");
},
onSubviewShowing(panelViewNode) {
let body = panelViewNode.querySelector(".panel-subview-body");
while (body.firstChild) {
body.firstChild.remove();
}
for (let engine of this.engines) {
let button = document.createXULElement("toolbarbutton");
button.classList.add("subviewbutton", "subviewbutton-iconic");
button.setAttribute("label", engine.title);
button.setAttribute("image", engine.icon);
button.setAttribute("uri", engine.uri);
button.addEventListener("command", event => {
let panelNode = panelViewNode.closest("panel");
PanelMultiView.hidePopup(panelNode);
this._installEngine(
button.getAttribute("uri"),
button.getAttribute("image")
);
});
body.appendChild(button);
}
},
onCommand(event, buttonNode) {
if (!buttonNode.closest("panel")) {
// The urlbar button was clicked. It should have a subview if there are
// many engines.
let manyEngines = this.engines.length > 1;
this.action.setWantsSubview(manyEngines, window);
if (manyEngines) {
return;
}
}
// Either the panel button or urlbar button was clicked -- not a button in
// the subview -- but in either case, there's only one search engine.
// (Because this method isn't called when the panel button is clicked and it
// shows a subview, and the many-engines case for the urlbar returned early
// above.)
let engine = this.engines[0];
this._installEngine(engine.uri, engine.icon);
},
_installEngine(uri, image) {
SearchUIUtils.addOpenSearchEngine(
uri,
image,
gBrowser.selectedBrowser.browsingContext
)
.then(result => {
if (result) {
showBrowserPageActionFeedback(this.action);
}
})
.catch(console.error);
},
};
// share URL
BrowserPageActions.shareURL = {
onCommand(event, buttonNode) {
let browser = gBrowser.selectedBrowser;
let currentURI = gURLBar.makeURIReadable(browser.currentURI).displaySpec;
this._windowsUIUtils.shareUrl(currentURI, browser.contentTitle);
},
onShowingInPanel(buttonNode) {
this._cached = false;
},
onShowingSubview(panelViewNode) {
let bodyNode = panelViewNode.querySelector(".panel-subview-body");
// We cache the providers + the UI if the user selects the share
// panel multiple times while the panel is open.
if (this._cached && bodyNode.children.length) {
return;
}
let sharingService = this._sharingService;
let url = gBrowser.selectedBrowser.currentURI;
let currentURI = gURLBar.makeURIReadable(url).displaySpec;
let shareProviders = sharingService.getSharingProviders(currentURI);
let fragment = document.createDocumentFragment();
let onCommand = event => {
let shareName = event.target.getAttribute("share-name");
if (shareName) {
sharingService.shareUrl(
shareName,
currentURI,
gBrowser.selectedBrowser.contentTitle
);
} else if (event.target.classList.contains("share-more-button")) {
sharingService.openSharingPreferences();
}
PanelMultiView.hidePopup(BrowserPageActions.panelNode);
};
shareProviders.forEach(function(share) {
let item = document.createXULElement("toolbarbutton");
item.setAttribute("label", share.menuItemTitle);
item.setAttribute("share-name", share.name);
item.setAttribute("image", share.image);
item.classList.add("subviewbutton", "subviewbutton-iconic");
item.addEventListener("command", onCommand);
fragment.appendChild(item);
});
let item = document.createXULElement("toolbarbutton");
document.l10n.setAttributes(item, "page-action-share-more-panel");
item.classList.add(
"subviewbutton",
"subviewbutton-iconic",
"share-more-button"
);
item.addEventListener("command", onCommand);
fragment.appendChild(item);
while (bodyNode.firstChild) {
bodyNode.firstChild.remove();
}
bodyNode.appendChild(fragment);
this._cached = true;
},
};
// Attach sharingService here so tests can override the implementation
XPCOMUtils.defineLazyServiceGetters(BrowserPageActions.shareURL, {
_sharingService: [
"@mozilla.org/widget/macsharingservice;1",
"nsIMacSharingService",
],
_windowsUIUtils: ["@mozilla.org/windows-ui-utils;1", "nsIWindowsUIUtils"],
});