/* 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; }) };