diff options
Diffstat (limited to 'toolkit/devtools/framework/sidebar.js')
-rw-r--r-- | toolkit/devtools/framework/sidebar.js | 560 |
1 files changed, 560 insertions, 0 deletions
diff --git a/toolkit/devtools/framework/sidebar.js b/toolkit/devtools/framework/sidebar.js new file mode 100644 index 000000000..24f347f2a --- /dev/null +++ b/toolkit/devtools/framework/sidebar.js @@ -0,0 +1,560 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */ +/* 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/. */ + +const {Cu} = require("chrome"); + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); + +var {Promise: promise} = require("resource://gre/modules/Promise.jsm"); +var EventEmitter = require("devtools/toolkit/event-emitter"); +var Telemetry = require("devtools/shared/telemetry"); + +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: + * + * <tabbox id="inspector-sidebar" handleCtrlTab="false" class="devtools-sidebar-tabs"> + * <tabs/> + * <tabpanels flex="1"/> + * </tabbox> + * + * 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 + * <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. + * - <tabid>-ready : After the tab iframe has been loaded + * - <tabid>-selected : After tab <tabid> was selected + * - select : Same as above, but for any tab, the ID is passed with the event + * - <tabid>-unselected : After tab <tabid> 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, "box"); + 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("type", "menu"); + this._allTabsBtn.setAttribute("label", l10n("sidebar.showAllTabs.label")); + this._allTabsBtn.setAttribute("tooltiptext", l10n("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) { + this._addItemToAllTabsMenu(id, tab, tab.hasAttribute("selected")); + } + }, + + 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("box").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, selected=false) { + if (!this._allTabsBtn) { + return; + } + + let item = this._panelDoc.createElementNS(XULNS, "menuitem"); + item.setAttribute("id", "sidebar-alltabs-item-" + id); + item.setAttribute("label", tab.getAttribute("label")); + item.setAttribute("type", "checkbox"); + if (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); + + this._allTabsBtn.querySelector("menupopup").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} tab uniq id + * @param {string} url + */ + addTab: function(id, url, selected=false) { + 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"); + this._tabbox.tabs.appendChild(tab); + tab.setAttribute("label", ""); // Avoid showing "undefined" while the tab is loading + tab.setAttribute("id", this.TAB_ID_PREFIX + id); + + // Add the tab to the allTabs menu if exists + let allTabsItem = this._addItemToAllTabsMenu(id, tab, selected); + + 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); + 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 (selected) { + // For some reason I don't understand, if we call this.select in this + // event loop (after inserting the tab), the tab will never get the + // the "selected" attribute set to true. + this._panelDoc.defaultView.setTimeout(() => { + this.select(id); + }, 10); + } + + 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++); + + // 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 <tabpanel> + */ + 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 and tabpanel. + * @param {Boolean} isVisible True to show the tab/tabpanel, False to hide it. + * @param {String} id The ID of the tab to be hidden. + * @param {String} tabPanelId Optionally pass the ID for the tabPanel if it + * can't be retrieved using the tab ID. This is useful when tabs and tabpanels + * existed before the widget was created. + */ + toggleTab: function(isVisible, id, tabPanelId) { + // 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; + } + + // Toggle the corresponding tabPanel, if one can be found either with the id + // or the provided tabPanelId. + let tabPanel = this.getTabPanel(id); + if (!tabPanel && tabPanelId) { + tabPanel = this.getTabPanel(tabPanelId); + } + if (tabPanel) { + tabPanel.hidden = !isVisible; + } + }, + + /** + * Select a specific tab. + */ + select: function(id) { + let tab = this.getTab(id); + if (tab) { + this._tabbox.selectedTab = tab; + } + }, + + /** + * 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. + */ + show: function() { + if (this._width) { + this._tabbox.width = this._width; + } + this._tabbox.removeAttribute("hidden"); + + 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.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; + }) +} + +XPCOMUtils.defineLazyGetter(this, "l10n", function() { + let bundle = Services.strings.createBundle("chrome://browser/locale/devtools/toolbox.properties"); + let l10n = function(aName, ...aArgs) { + try { + if (aArgs.length == 0) { + return bundle.GetStringFromName(aName); + } else { + return bundle.formatStringFromName(aName, aArgs, aArgs.length); + } + } catch (ex) { + Services.console.logStringMessage("Error reading '" + aName + "'"); + } + }; + return l10n; +}); |