summaryrefslogtreecommitdiff
path: root/toolkit/devtools/framework/sidebar.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/devtools/framework/sidebar.js')
-rw-r--r--toolkit/devtools/framework/sidebar.js560
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;
+});