/* 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";
var Services = require("Services");
var {Task} = require("devtools/shared/task");
var EventEmitter = require("devtools/shared/event-emitter");
var Telemetry = require("devtools/client/shared/telemetry");
const {LocalizationHelper} = require("devtools/shared/l10n");
const L10N = new LocalizationHelper("devtools/client/locales/toolbox.properties");
const XULNS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
/**
* ToolSidebar provides methods to register tabs in the sidebar.
* It's assumed that the sidebar contains a xul:tabbox.
* Typically, you'll want the tabbox parameter to be a XUL tabbox like this:
*
*
*
* The ToolSidebar API has a method to add new tabs, so the tabs and tabpanels
* nodes can be empty. But they can also already contain items before the
* ToolSidebar is created.
*
* Tabs added through the addTab method are only identified by an ID and a URL
* which is used as the href of an iframe node that is inserted in the newly
* created tabpanel.
* Tabs already present before the ToolSidebar is created may contain anything.
* However, these tabs must have ID attributes if it is required for the various
* methods that accept an ID as argument to work here.
*
* @param {Node} tabbox
* node;
* @param {ToolPanel} panel
* Related ToolPanel instance;
* @param {String} uid
* Unique ID
* @param {Object} options
* - hideTabstripe: Should the tabs be hidden. Defaults to false
* - showAllTabsMenu: Should a drop-down menu be displayed in case tabs
* become hidden. Defaults to false.
* - disableTelemetry: By default, switching tabs on and off in the sidebar
* will record tool usage in telemetry, pass this option to true to avoid it.
*
* Events raised:
* - new-tab-registered : After a tab has been added via addTab. The tab ID
* is passed with the event. This however, is raised before the tab iframe
* is fully loaded.
* - -ready : After the tab iframe has been loaded
* - -selected : After tab was selected
* - select : Same as above, but for any tab, the ID is passed with the event
* - -unselected : After tab is unselected
*/
function ToolSidebar(tabbox, panel, uid, options = {}) {
EventEmitter.decorate(this);
this._tabbox = tabbox;
this._uid = uid;
this._panelDoc = this._tabbox.ownerDocument;
this._toolPanel = panel;
this._options = options;
this._onTabBoxOverflow = this._onTabBoxOverflow.bind(this);
this._onTabBoxUnderflow = this._onTabBoxUnderflow.bind(this);
try {
this._width = Services.prefs.getIntPref("devtools.toolsidebar-width." + this._uid);
} catch (e) {}
if (!options.disableTelemetry) {
this._telemetry = new Telemetry();
}
this._tabbox.tabpanels.addEventListener("select", this, true);
this._tabs = new Map();
// Check for existing tabs in the DOM and add them.
this.addExistingTabs();
if (this._options.hideTabstripe) {
this._tabbox.setAttribute("hidetabs", "true");
}
if (this._options.showAllTabsMenu) {
this.addAllTabsMenu();
}
this._toolPanel.emit("sidebar-created", this);
}
exports.ToolSidebar = ToolSidebar;
ToolSidebar.prototype = {
TAB_ID_PREFIX: "sidebar-tab-",
TABPANEL_ID_PREFIX: "sidebar-panel-",
/**
* Add a "…" button at the end of the tabstripe that toggles a dropdown menu
* containing the list of all tabs if any become hidden due to lack of room.
*
* If the ToolSidebar was created with the "showAllTabsMenu" option set to
* true, this is already done automatically. If not, you may call this
* function at any time to add the menu.
*/
addAllTabsMenu: function () {
if (this._allTabsBtn) {
return;
}
let tabs = this._tabbox.tabs;
// Create a container and insert it first in the tabbox
let allTabsContainer = this._panelDoc.createElementNS(XULNS, "stack");
this._tabbox.insertBefore(allTabsContainer, tabs);
// Move the tabs inside and make them flex
allTabsContainer.appendChild(tabs);
tabs.setAttribute("flex", "1");
// Create the dropdown menu next to the tabs
this._allTabsBtn = this._panelDoc.createElementNS(XULNS, "toolbarbutton");
this._allTabsBtn.setAttribute("class", "devtools-sidebar-alltabs");
this._allTabsBtn.setAttribute("end", "0");
this._allTabsBtn.setAttribute("top", "0");
this._allTabsBtn.setAttribute("width", "15");
this._allTabsBtn.setAttribute("type", "menu");
this._allTabsBtn.setAttribute("tooltiptext",
L10N.getStr("sidebar.showAllTabs.tooltip"));
this._allTabsBtn.setAttribute("hidden", "true");
allTabsContainer.appendChild(this._allTabsBtn);
let menuPopup = this._panelDoc.createElementNS(XULNS, "menupopup");
this._allTabsBtn.appendChild(menuPopup);
// Listening to tabs overflow event to toggle the alltabs button
tabs.addEventListener("overflow", this._onTabBoxOverflow, false);
tabs.addEventListener("underflow", this._onTabBoxUnderflow, false);
// Add menuitems to the alltabs menu if there are already tabs in the
// sidebar
for (let [id, tab] of this._tabs) {
let item = this._addItemToAllTabsMenu(id, tab, {
selected: tab.hasAttribute("selected")
});
if (tab.hidden) {
item.hidden = true;
}
}
},
removeAllTabsMenu: function () {
if (!this._allTabsBtn) {
return;
}
let tabs = this._tabbox.tabs;
tabs.removeEventListener("overflow", this._onTabBoxOverflow, false);
tabs.removeEventListener("underflow", this._onTabBoxUnderflow, false);
// Moving back the tabs as a first child of the tabbox
this._tabbox.insertBefore(tabs, this._tabbox.tabpanels);
this._tabbox.querySelector("stack").remove();
this._allTabsBtn = null;
},
_onTabBoxOverflow: function () {
this._allTabsBtn.removeAttribute("hidden");
},
_onTabBoxUnderflow: function () {
this._allTabsBtn.setAttribute("hidden", "true");
},
/**
* Add an item in the allTabs menu for a given tab.
*/
_addItemToAllTabsMenu: function (id, tab, options) {
if (!this._allTabsBtn) {
return;
}
let item = this._panelDoc.createElementNS(XULNS, "menuitem");
let idPrefix = "sidebar-alltabs-item-";
item.setAttribute("id", idPrefix + id);
item.setAttribute("label", tab.getAttribute("label"));
item.setAttribute("type", "checkbox");
if (options.selected) {
item.setAttribute("checked", true);
}
// The auto-checking of menuitems in this menu doesn't work, so let's do
// it manually
item.setAttribute("autocheck", false);
let menu = this._allTabsBtn.querySelector("menupopup");
if (options.insertBefore) {
let referenceItem = menu.querySelector(`#${idPrefix}${options.insertBefore}`);
menu.insertBefore(item, referenceItem);
} else {
menu.appendChild(item);
}
item.addEventListener("click", () => {
this._tabbox.selectedTab = tab;
}, false);
tab.allTabsMenuItem = item;
return item;
},
/**
* Register a tab. A tab is a document.
* The document must have a title, which will be used as the name of the tab.
*
* @param {string} id The unique id for this tab.
* @param {string} url The URL of the document to load in this new tab.
* @param {Object} options A set of options for this new tab:
* - {Boolean} selected Set to true to make this new tab selected by default.
* - {String} insertBefore By default, the new tab is appended at the end of the
* tabbox, pass the ID of an existing tab to insert it before that tab instead.
*/
addTab: function (id, url, options = {}) {
let iframe = this._panelDoc.createElementNS(XULNS, "iframe");
iframe.className = "iframe-" + id;
iframe.setAttribute("flex", "1");
iframe.setAttribute("src", url);
iframe.tooltip = "aHTMLTooltip";
// Creating the tab and adding it to the tabbox
let tab = this._panelDoc.createElementNS(XULNS, "tab");
tab.setAttribute("id", this.TAB_ID_PREFIX + id);
tab.setAttribute("crop", "end");
// Avoid showing "undefined" while the tab is loading
tab.setAttribute("label", "");
if (options.insertBefore) {
let referenceTab = this.getTab(options.insertBefore);
this._tabbox.tabs.insertBefore(tab, referenceTab);
} else {
this._tabbox.tabs.appendChild(tab);
}
// Add the tab to the allTabs menu if exists
let allTabsItem = this._addItemToAllTabsMenu(id, tab, options);
let onIFrameLoaded = (event) => {
let doc = event.target;
let win = doc.defaultView;
tab.setAttribute("label", doc.title);
if (allTabsItem) {
allTabsItem.setAttribute("label", doc.title);
}
iframe.removeEventListener("load", onIFrameLoaded, true);
if ("setPanel" in win) {
win.setPanel(this._toolPanel, iframe);
}
this.emit(id + "-ready");
};
iframe.addEventListener("load", onIFrameLoaded, true);
let tabpanel = this._panelDoc.createElementNS(XULNS, "tabpanel");
tabpanel.setAttribute("id", this.TABPANEL_ID_PREFIX + id);
tabpanel.appendChild(iframe);
if (options.insertBefore) {
let referenceTabpanel = this.getTabPanel(options.insertBefore);
this._tabbox.tabpanels.insertBefore(tabpanel, referenceTabpanel);
} else {
this._tabbox.tabpanels.appendChild(tabpanel);
}
this._tooltip = this._panelDoc.createElementNS(XULNS, "tooltip");
this._tooltip.id = "aHTMLTooltip";
tabpanel.appendChild(this._tooltip);
this._tooltip.page = true;
tab.linkedPanel = this.TABPANEL_ID_PREFIX + id;
// We store the index of this tab.
this._tabs.set(id, tab);
if (options.selected) {
this._selectTabSoon(id);
}
this.emit("new-tab-registered", id);
},
untitledTabsIndex: 0,
/**
* Search for existing tabs in the markup that aren't know yet and add them.
*/
addExistingTabs: function () {
let knownTabs = [...this._tabs.values()];
for (let tab of this._tabbox.tabs.querySelectorAll("tab")) {
if (knownTabs.indexOf(tab) !== -1) {
continue;
}
// Find an ID for this unknown tab
let id = tab.getAttribute("id") || "untitled-tab-" + (this.untitledTabsIndex++);
// If the existing tab contains the tab ID prefix, extract the ID of the
// tab
if (id.startsWith(this.TAB_ID_PREFIX)) {
id = id.split(this.TAB_ID_PREFIX).pop();
}
// Register the tab
this._tabs.set(id, tab);
this.emit("new-tab-registered", id);
}
},
/**
* Remove an existing tab.
* @param {String} tabId The ID of the tab that was used to register it, or
* the tab id attribute value if the tab existed before the sidebar got created.
* @param {String} tabPanelId Optional. If provided, this ID will be used
* instead of the tabId to retrieve and remove the corresponding
*/
removeTab: Task.async(function* (tabId, tabPanelId) {
// Remove the tab if it can be found
let tab = this.getTab(tabId);
if (!tab) {
return;
}
let win = this.getWindowForTab(tabId);
if (win && ("destroy" in win)) {
yield win.destroy();
}
tab.remove();
// Also remove the tabpanel
let panel = this.getTabPanel(tabPanelId || tabId);
if (panel) {
panel.remove();
}
this._tabs.delete(tabId);
this.emit("tab-unregistered", tabId);
}),
/**
* Show or hide a specific tab.
* @param {Boolean} isVisible True to show the tab/tabpanel, False to hide it.
* @param {String} id The ID of the tab to be hidden.
*/
toggleTab: function (isVisible, id) {
// Toggle the tab.
let tab = this.getTab(id);
if (!tab) {
return;
}
tab.hidden = !isVisible;
// Toggle the item in the allTabs menu.
if (this._allTabsBtn) {
this._allTabsBtn.querySelector("#sidebar-alltabs-item-" + id).hidden = !isVisible;
}
},
/**
* Select a specific tab.
*/
select: function (id) {
let tab = this.getTab(id);
if (tab) {
this._tabbox.selectedTab = tab;
}
},
/**
* Hack required to select a tab right after it was created.
*
* @param {String} id
* The sidebar tab id to select.
*/
_selectTabSoon: function (id) {
this._panelDoc.defaultView.setTimeout(() => {
this.select(id);
}, 0);
},
/**
* Return the id of the selected tab.
*/
getCurrentTabID: function () {
let currentID = null;
for (let [id, tab] of this._tabs) {
if (this._tabbox.tabs.selectedItem == tab) {
currentID = id;
break;
}
}
return currentID;
},
/**
* Returns the requested tab panel based on the id.
* @param {String} id
* @return {DOMNode}
*/
getTabPanel: function (id) {
// Search with and without the ID prefix as there might have been existing
// tabpanels by the time the sidebar got created
return this._tabbox.tabpanels.querySelector("#" + this.TABPANEL_ID_PREFIX + id + ", #" + id);
},
/**
* Return the tab based on the provided id, if one was registered with this id.
* @param {String} id
* @return {DOMNode}
*/
getTab: function (id) {
return this._tabs.get(id);
},
/**
* Event handler.
*/
handleEvent: function (event) {
if (event.type !== "select" || this._destroyed) {
return;
}
if (this._currentTool == this.getCurrentTabID()) {
// Tool hasn't changed.
return;
}
let previousTool = this._currentTool;
this._currentTool = this.getCurrentTabID();
if (previousTool) {
if (this._telemetry) {
this._telemetry.toolClosed(previousTool);
}
this.emit(previousTool + "-unselected");
}
if (this._telemetry) {
this._telemetry.toolOpened(this._currentTool);
}
this.emit(this._currentTool + "-selected");
this.emit("select", this._currentTool);
// Handlers for "select"/"...-selected"/"...-unselected" events might have
// destroyed the sidebar in the meantime.
if (this._destroyed) {
return;
}
// Handle menuitem selection if the allTabsMenu is there by unchecking all
// items except the selected one.
let tab = this._tabbox.selectedTab;
if (tab.allTabsMenuItem) {
for (let otherItem of this._allTabsBtn.querySelectorAll("menuitem")) {
otherItem.removeAttribute("checked");
}
tab.allTabsMenuItem.setAttribute("checked", true);
}
},
/**
* Toggle sidebar's visibility state.
*/
toggle: function () {
if (this._tabbox.hasAttribute("hidden")) {
this.show();
} else {
this.hide();
}
},
/**
* Show the sidebar.
*
* @param {String} id
* The sidebar tab id to select.
*/
show: function (id) {
if (this._width) {
this._tabbox.width = this._width;
}
this._tabbox.removeAttribute("hidden");
// If an id is given, select the corresponding sidebar tab and record the
// tool opened.
if (id) {
this._currentTool = id;
if (this._telemetry) {
this._telemetry.toolOpened(this._currentTool);
}
this._selectTabSoon(id);
}
this.emit("show");
},
/**
* Show the sidebar.
*/
hide: function () {
Services.prefs.setIntPref("devtools.toolsidebar-width." + this._uid, this._tabbox.width);
this._tabbox.setAttribute("hidden", "true");
this._panelDoc.activeElement.blur();
this.emit("hide");
},
/**
* Return the window containing the tab content.
*/
getWindowForTab: function (id) {
if (!this._tabs.has(id)) {
return null;
}
// Get the tabpanel and make sure it contains an iframe
let panel = this.getTabPanel(id);
if (!panel || !panel.firstChild || !panel.firstChild.contentWindow) {
return;
}
return panel.firstChild.contentWindow;
},
/**
* Clean-up.
*/
destroy: Task.async(function* () {
if (this._destroyed) {
return;
}
this._destroyed = true;
Services.prefs.setIntPref("devtools.toolsidebar-width." + this._uid, this._tabbox.width);
if (this._allTabsBtn) {
this.removeAllTabsMenu();
}
this._tabbox.tabpanels.removeEventListener("select", this, true);
// Note that we check for the existence of this._tabbox.tabpanels at each
// step as the container window may have been closed by the time one of the
// panel's destroy promise resolves.
while (this._tabbox.tabpanels && this._tabbox.tabpanels.hasChildNodes()) {
let panel = this._tabbox.tabpanels.firstChild;
let win = panel.firstChild.contentWindow;
if (win && ("destroy" in win)) {
yield win.destroy();
}
panel.remove();
}
while (this._tabbox.tabs && this._tabbox.tabs.hasChildNodes()) {
this._tabbox.tabs.removeChild(this._tabbox.tabs.firstChild);
}
if (this._currentTool && this._telemetry) {
this._telemetry.toolClosed(this._currentTool);
}
this._toolPanel.emit("sidebar-destroyed", this);
this._tabs = null;
this._tabbox = null;
this._panelDoc = null;
this._toolPanel = null;
})
};