gecko-dev/browser/devtools/framework/sidebar.js

561 lines
16 KiB
JavaScript

/* -*- 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 toolbar and insert it first in the tabbox
let allTabsToolbar = this._panelDoc.createElementNS(XULNS, "toolbar");
this._tabbox.insertBefore(allTabsToolbar, tabs);
// Move the tabs inside and make them flex
allTabsToolbar.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");
allTabsToolbar.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("toolbar").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;
});