/* 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"; const { Cc, Ci, Cu } = require("chrome"); const promise = require("promise"); const Services = require("Services"); const { TargetFactory } = require("devtools/client/framework/target"); const Telemetry = require("devtools/client/shared/telemetry"); const NS_XHTML = "http://www.w3.org/1999/xhtml"; const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; const Node = Ci.nsIDOMNode; loader.lazyImporter(this, "PluralForm", "resource://gre/modules/PluralForm.jsm"); loader.lazyImporter(this, "EventEmitter", "resource://devtools/shared/event-emitter.js"); loader.lazyGetter(this, "prefBranch", function () { return Services.prefs.getBranch(null) .QueryInterface(Ci.nsIPrefBranch2); }); loader.lazyGetter(this, "toolboxStrings", function () { return Services.strings.createBundle("chrome://devtools/locale/toolbox.properties"); }); loader.lazyRequireGetter(this, "gcliInit", "devtools/shared/gcli/commands/index"); loader.lazyRequireGetter(this, "util", "gcli/util/util"); loader.lazyRequireGetter(this, "ConsoleServiceListener", "devtools/shared/webconsole/utils", true); loader.lazyRequireGetter(this, "gDevTools", "devtools/client/framework/devtools", true); loader.lazyRequireGetter(this, "gDevToolsBrowser", "devtools/client/framework/devtools-browser", true); /** * A collection of utilities to help working with commands */ var CommandUtils = { /** * Utility to ensure that things are loaded in the correct order */ createRequisition: function (target, options) { if (!gcliInit) { return promise.reject("Unable to load gcli"); } return gcliInit.getSystem(target).then(system => { var Requisition = require("gcli/cli").Requisition; return new Requisition(system, options); }); }, /** * Destroy the remote side of the requisition as well as the local side */ destroyRequisition: function (requisition, target) { requisition.destroy(); gcliInit.releaseSystem(target); }, /** * Read a toolbarSpec from preferences * @param pref The name of the preference to read */ getCommandbarSpec: function (pref) { let value = prefBranch.getComplexValue(pref, Ci.nsISupportsString).data; return JSON.parse(value); }, /** * A toolbarSpec is an array of strings each of which is a GCLI command. * * Warning: this method uses the unload event of the window that owns the * buttons that are of type checkbox. this means that we don't properly * unregister event handlers until the window is destroyed. */ createButtons: function (toolbarSpec, target, document, requisition) { return util.promiseEach(toolbarSpec, typed => { // Ask GCLI to parse the typed string (doesn't execute it) return requisition.update(typed).then(() => { let button = document.createElement("toolbarbutton"); // Ignore invalid commands let command = requisition.commandAssignment.value; if (command == null) { throw new Error("No command '" + typed + "'"); } if (command.buttonId != null) { button.id = command.buttonId; if (command.buttonClass != null) { button.className = command.buttonClass; } } else { button.setAttribute("text-as-image", "true"); button.setAttribute("label", command.name); button.className = "devtools-toolbarbutton"; } if (command.tooltipText != null) { button.setAttribute("tooltiptext", command.tooltipText); } else if (command.description != null) { button.setAttribute("tooltiptext", command.description); } button.addEventListener("click", () => { requisition.updateExec(typed); }, false); // Allow the command button to be toggleable if (command.state) { button.setAttribute("autocheck", false); /** * The onChange event should be called with an event object that * contains a target property which specifies which target the event * applies to. For legacy reasons the event object can also contain * a tab property. */ let onChange = (eventName, ev) => { if (ev.target == target || ev.tab == target.tab) { let updateChecked = (checked) => { if (checked) { button.setAttribute("checked", true); } else if (button.hasAttribute("checked")) { button.removeAttribute("checked"); } }; // isChecked would normally be synchronous. An annoying quirk // of the 'csscoverage toggle' command forces us to accept a // promise here, but doing Promise.resolve(reply).then(...) here // makes this async for everyone, which breaks some tests so we // treat non-promise replies separately to keep then synchronous. let reply = command.state.isChecked(target); if (typeof reply.then == "function") { reply.then(updateChecked, console.error); } else { updateChecked(reply); } } }; command.state.onChange(target, onChange); onChange("", { target: target }); document.defaultView.addEventListener("unload", () => { if (command.state.offChange) { command.state.offChange(target, onChange); } }, false); } requisition.clear(); return button; }); }); }, /** * A helper function to create the environment object that is passed to * GCLI commands. * @param targetContainer An object containing a 'target' property which * reflects the current debug target */ createEnvironment: function (container, targetProperty = "target") { if (!container[targetProperty].toString || !/TabTarget/.test(container[targetProperty].toString())) { throw new Error("Missing target"); } return { get target() { if (!container[targetProperty].toString || !/TabTarget/.test(container[targetProperty].toString())) { throw new Error("Removed target"); } return container[targetProperty]; }, get chromeWindow() { return this.target.tab.ownerDocument.defaultView; }, get chromeDocument() { return this.target.tab.ownerDocument.defaultView.document; }, get window() { // throw new Error("environment.window is not available in runAt:client commands"); return this.chromeWindow.gBrowser.contentWindowAsCPOW; }, get document() { // throw new Error("environment.document is not available in runAt:client commands"); return this.chromeWindow.gBrowser.contentDocumentAsCPOW; } }; }, }; exports.CommandUtils = CommandUtils; /** * Due to a number of panel bugs we need a way to check if we are running on * Linux. See the comments for TooltipPanel and OutputPanel for further details. * * When bug 780102 is fixed all isLinux checks can be removed and we can revert * to using panels. */ loader.lazyGetter(this, "isLinux", function () { return OS == "Linux"; }); loader.lazyGetter(this, "isMac", function () { return OS == "Darwin"; }); loader.lazyGetter(this, "OS", function () { let os = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime).OS; return os; }); /** * A component to manage the global developer toolbar, which contains a GCLI * and buttons for various developer tools. * @param aChromeWindow The browser window to which this toolbar is attached */ function DeveloperToolbar(aChromeWindow) { this._chromeWindow = aChromeWindow; this.target = null; // Will be setup when show() is called this._doc = aChromeWindow.document; this._telemetry = new Telemetry(); this._errorsCount = {}; this._warningsCount = {}; this._errorListeners = {}; this._onToolboxReady = this._onToolboxReady.bind(this); this._onToolboxDestroyed = this._onToolboxDestroyed.bind(this); EventEmitter.decorate(this); } exports.DeveloperToolbar = DeveloperToolbar; /** * Inspector notifications dispatched through the nsIObserverService */ const NOTIFICATIONS = { /** DeveloperToolbar.show() has been called, and we're working on it */ LOAD: "developer-toolbar-load", /** DeveloperToolbar.show() has completed */ SHOW: "developer-toolbar-show", /** DeveloperToolbar.hide() has been called */ HIDE: "developer-toolbar-hide" }; /** * Attach notification constants to the object prototype so tests etc can * use them without needing to import anything */ DeveloperToolbar.prototype.NOTIFICATIONS = NOTIFICATIONS; /** * Is the toolbar open? */ Object.defineProperty(DeveloperToolbar.prototype, "visible", { get: function () { return this._element && !this._element.hidden; }, enumerable: true }); var _gSequenceId = 0; /** * Getter for a unique ID. */ Object.defineProperty(DeveloperToolbar.prototype, "sequenceId", { get: function () { return _gSequenceId++; }, enumerable: true }); /** * Create the element to insert within browser UI */ DeveloperToolbar.prototype.createToolbar = function () { if (this._element) { return; } let toolbar = this._doc.createElement("toolbar"); toolbar.setAttribute("id", "developer-toolbar"); toolbar.setAttribute("hidden", "true"); let close = this._doc.createElement("toolbarbutton"); close.setAttribute("id", "developer-toolbar-closebutton"); close.setAttribute("class", "close-icon"); close.setAttribute("oncommand", "DeveloperToolbar.hide();"); let closeTooltip = toolboxStrings.GetStringFromName("toolbar.closeButton.tooltip"); close.setAttribute("tooltiptext", closeTooltip); let stack = this._doc.createElement("stack"); stack.setAttribute("flex", "1"); let input = this._doc.createElement("textbox"); input.setAttribute("class", "gclitoolbar-input-node"); input.setAttribute("rows", "1"); stack.appendChild(input); let hbox = this._doc.createElement("hbox"); hbox.setAttribute("class", "gclitoolbar-complete-node"); stack.appendChild(hbox); let toolboxBtn = this._doc.createElement("toolbarbutton"); toolboxBtn.setAttribute("id", "developer-toolbar-toolbox-button"); toolboxBtn.setAttribute("class", "developer-toolbar-button"); let toolboxTooltip = toolboxStrings.GetStringFromName("toolbar.toolsButton.tooltip"); toolboxBtn.setAttribute("tooltiptext", toolboxTooltip); toolboxBtn.addEventListener("command", function (event) { let window = event.target.ownerDocument.defaultView; gDevToolsBrowser.toggleToolboxCommand(window.gBrowser); }); this._errorCounterButton = toolboxBtn; this._errorCounterButton._defaultTooltipText = toolboxTooltip; // On Mac, the close button is on the left, // while it is on the right on every other platforms. if (isMac) { toolbar.appendChild(close); toolbar.appendChild(stack); toolbar.appendChild(toolboxBtn); } else { toolbar.appendChild(stack); toolbar.appendChild(toolboxBtn); toolbar.appendChild(close); } this._element = toolbar; let bottomBox = this._doc.getElementById("browser-bottombox"); if (bottomBox) { bottomBox.appendChild(this._element); } else { // SeaMonkey does not have a "browser-bottombox". let statusBar = this._doc.getElementById("status-bar"); if (statusBar) statusBar.parentNode.insertBefore(this._element, statusBar); } }; /** * Called from browser.xul in response to menu-click or keyboard shortcut to * toggle the toolbar */ DeveloperToolbar.prototype.toggle = function () { if (this.visible) { return this.hide().catch(console.error); } else { return this.show(true).catch(console.error); } }; /** * Called from browser.xul in response to menu-click or keyboard shortcut to * toggle the toolbar */ DeveloperToolbar.prototype.focus = function () { if (this.visible) { this._input.focus(); return promise.resolve(); } else { return this.show(true); } }; /** * Called from browser.xul in response to menu-click or keyboard shortcut to * toggle the toolbar */ DeveloperToolbar.prototype.focusToggle = function () { if (this.visible) { // If we have focus then the active element is the HTML input contained // inside the xul input element let active = this._chromeWindow.document.activeElement; let position = this._input.compareDocumentPosition(active); if (position & Node.DOCUMENT_POSITION_CONTAINED_BY) { this.hide(); } else { this._input.focus(); } } else { this.show(true); } }; /** * Even if the user has not clicked on 'Got it' in the intro, we only show it * once per session. * Warning this is slightly messed up because this.DeveloperToolbar is not the * same as this.DeveloperToolbar when in browser.js context. */ DeveloperToolbar.introShownThisSession = false; /** * Show the developer toolbar */ DeveloperToolbar.prototype.show = function (focus) { if (this._showPromise != null) { return this._showPromise; } // hide() is async, so ensure we don't need to wait for hide() to finish var waitPromise = this._hidePromise || promise.resolve(); this._showPromise = waitPromise.then(() => { this.createToolbar(); Services.prefs.setBoolPref("devtools.toolbar.visible", true); this._telemetry.toolOpened("developertoolbar"); this._notify(NOTIFICATIONS.LOAD); this._input = this._doc.querySelector(".gclitoolbar-input-node"); // Initializing GCLI can only be done when we've got content windows to // write to, so this needs to be done asynchronously. let panelPromises = [ TooltipPanel.create(this), OutputPanel.create(this) ]; return promise.all(panelPromises).then(panels => { [ this.tooltipPanel, this.outputPanel ] = panels; this._doc.getElementById("menu_devToolbar").setAttribute("checked", "true"); this.target = TargetFactory.forTab(this._chromeWindow.gBrowser.selectedTab); const options = { environment: CommandUtils.createEnvironment(this, "target"), document: this.outputPanel.document, }; return CommandUtils.createRequisition(this.target, options).then(requisition => { this.requisition = requisition; // The `value` may still be undefined on the XUL binding if // we fetch it early let value = this._input.value || ""; return this.requisition.update(value).then(() => { const Inputter = require("gcli/mozui/inputter").Inputter; const Completer = require("gcli/mozui/completer").Completer; const Tooltip = require("gcli/mozui/tooltip").Tooltip; const FocusManager = require("gcli/ui/focus").FocusManager; this.onOutput = this.requisition.commandOutputManager.onOutput; this.focusManager = new FocusManager(this._doc, requisition.system.settings); this.inputter = new Inputter({ requisition: this.requisition, focusManager: this.focusManager, element: this._input, }); this.completer = new Completer({ requisition: this.requisition, inputter: this.inputter, backgroundElement: this._doc.querySelector(".gclitoolbar-stack-node"), element: this._doc.querySelector(".gclitoolbar-complete-node"), }); this.tooltip = new Tooltip({ requisition: this.requisition, focusManager: this.focusManager, inputter: this.inputter, element: this.tooltipPanel.hintElement, }); this.inputter.tooltip = this.tooltip; this.focusManager.addMonitoredElement(this.outputPanel._frame); this.focusManager.addMonitoredElement(this._element); this.focusManager.onVisibilityChange.add(this.outputPanel._visibilityChanged, this.outputPanel); this.focusManager.onVisibilityChange.add(this.tooltipPanel._visibilityChanged, this.tooltipPanel); this.onOutput.add(this.outputPanel._outputChanged, this.outputPanel); let tabbrowser = this._chromeWindow.gBrowser; tabbrowser.tabContainer.addEventListener("TabSelect", this, false); tabbrowser.tabContainer.addEventListener("TabClose", this, false); tabbrowser.addEventListener("load", this, true); tabbrowser.addEventListener("beforeunload", this, true); gDevTools.on("toolbox-ready", this._onToolboxReady); gDevTools.on("toolbox-destroyed", this._onToolboxDestroyed); this._initErrorsCount(tabbrowser.selectedTab); this._element.hidden = false; if (focus) { // If the toolbar was just inserted, the may still have // its binding in process of being applied and not be focusable yet let waitForBinding = () => { // Bail out if the toolbar has been destroyed in the meantime if (!this._input) { return; } // mInputField is a xbl field of if (typeof this._input.mInputField != "undefined") { this._input.focus(); this._notify(NOTIFICATIONS.SHOW); } else { this._input.ownerDocument.defaultView.setTimeout(waitForBinding, 50); } }; waitForBinding(); } else { this._notify(NOTIFICATIONS.SHOW); } if (!DeveloperToolbar.introShownThisSession) { let intro = require("gcli/ui/intro"); intro.maybeShowIntro(this.requisition.commandOutputManager, this.requisition.conversionContext); DeveloperToolbar.introShownThisSession = true; } this._showPromise = null; }); }); }); }); return this._showPromise; }; /** * Hide the developer toolbar. */ DeveloperToolbar.prototype.hide = function () { // If we're already in the process of hiding, just use the other promise if (this._hidePromise != null) { return this._hidePromise; } // show() is async, so ensure we don't need to wait for show() to finish var waitPromise = this._showPromise || promise.resolve(); this._hidePromise = waitPromise.then(() => { this._element.hidden = true; Services.prefs.setBoolPref("devtools.toolbar.visible", false); this._doc.getElementById("menu_devToolbar").setAttribute("checked", "false"); this.destroy(); this._telemetry.toolClosed("developertoolbar"); this._notify(NOTIFICATIONS.HIDE); this._hidePromise = null; }); return this._hidePromise; }; /** * Initialize the listeners needed for tracking the number of errors for a given * tab. * * @private * @param nsIDOMNode tab the xul:tab for which you want to track the number of * errors. */ DeveloperToolbar.prototype._initErrorsCount = function (tab) { let tabId = tab.linkedPanel; if (tabId in this._errorsCount) { this._updateErrorsCount(); return; } let window = tab.linkedBrowser.contentWindow; let listener = new ConsoleServiceListener(window, { onConsoleServiceMessage: this._onPageError.bind(this, tabId), }); listener.init(); this._errorListeners[tabId] = listener; this._errorsCount[tabId] = 0; this._warningsCount[tabId] = 0; let messages = listener.getCachedMessages(); messages.forEach(this._onPageError.bind(this, tabId)); this._updateErrorsCount(); }; /** * Stop the listeners needed for tracking the number of errors for a given * tab. * * @private * @param nsIDOMNode tab the xul:tab for which you want to stop tracking the * number of errors. */ DeveloperToolbar.prototype._stopErrorsCount = function (tab) { let tabId = tab.linkedPanel; if (!(tabId in this._errorsCount) || !(tabId in this._warningsCount)) { this._updateErrorsCount(); return; } this._errorListeners[tabId].destroy(); delete this._errorListeners[tabId]; delete this._errorsCount[tabId]; delete this._warningsCount[tabId]; this._updateErrorsCount(); }; /** * Hide the developer toolbar */ DeveloperToolbar.prototype.destroy = function () { if (this._input == null) { return; // Already destroyed } let tabbrowser = this._chromeWindow.gBrowser; tabbrowser.tabContainer.removeEventListener("TabSelect", this, false); tabbrowser.tabContainer.removeEventListener("TabClose", this, false); tabbrowser.removeEventListener("load", this, true); tabbrowser.removeEventListener("beforeunload", this, true); gDevTools.off("toolbox-ready", this._onToolboxReady); gDevTools.off("toolbox-destroyed", this._onToolboxDestroyed); Array.prototype.forEach.call(tabbrowser.tabs, this._stopErrorsCount, this); this.focusManager.removeMonitoredElement(this.outputPanel._frame); this.focusManager.removeMonitoredElement(this._element); this.focusManager.onVisibilityChange.remove(this.outputPanel._visibilityChanged, this.outputPanel); this.focusManager.onVisibilityChange.remove(this.tooltipPanel._visibilityChanged, this.tooltipPanel); this.onOutput.remove(this.outputPanel._outputChanged, this.outputPanel); this.tooltip.destroy(); this.completer.destroy(); this.inputter.destroy(); this.focusManager.destroy(); this.outputPanel.destroy(); this.tooltipPanel.destroy(); delete this._input; CommandUtils.destroyRequisition(this.requisition, this.target); this.target = undefined; this._element.remove(); delete this._element; }; /** * Utility for sending notifications * @param topic a NOTIFICATION constant */ DeveloperToolbar.prototype._notify = function (topic) { let data = { toolbar: this }; data.wrappedJSObject = data; Services.obs.notifyObservers(data, topic, null); }; /** * Update various parts of the UI when the current tab changes */ DeveloperToolbar.prototype.handleEvent = function (ev) { if (ev.type == "TabSelect" || ev.type == "load") { if (this.visible) { let tab = this._chromeWindow.gBrowser.selectedTab; this.target = TargetFactory.forTab(tab); gcliInit.getSystem(this.target).then(system => { this.requisition.system = system; }, error => { if (!this._chromeWindow.gBrowser.getBrowserForTab(tab)) { // The tab was closed, suppress the error and print a warning as the // destroyed tab was likely the cause. console.warn("An error occurred as the tab was closed while " + "updating Developer Toolbar state. The error was: ", error); return; } // Propagate other errors as they're more likely to cause real issues // and thus should cause tests to fail. throw error; }); if (ev.type == "TabSelect") { this._initErrorsCount(ev.target); } } } else if (ev.type == "TabClose") { this._stopErrorsCount(ev.target); } else if (ev.type == "beforeunload") { this._onPageBeforeUnload(ev); } }; /** * Update toolbox toggle button when toolbox goes on and off */ DeveloperToolbar.prototype._onToolboxReady = function () { this._errorCounterButton.setAttribute("checked", "true"); }; DeveloperToolbar.prototype._onToolboxDestroyed = function () { this._errorCounterButton.setAttribute("checked", "false"); }; /** * Count a page error received for the currently selected tab. This * method counts the JavaScript exceptions received and CSS errors/warnings. * * @private * @param string tabId the ID of the tab from where the page error comes. * @param object pageError the page error object received from the * PageErrorListener. */ DeveloperToolbar.prototype._onPageError = function (tabId, pageError) { if (pageError.category == "CSS Parser" || pageError.category == "CSS Loader") { return; } if ((pageError.flags & pageError.warningFlag) || (pageError.flags & pageError.strictFlag)) { this._warningsCount[tabId]++; } else { this._errorsCount[tabId]++; } this._updateErrorsCount(tabId); }; /** * The |beforeunload| event handler. This function resets the errors count when * a different page starts loading. * * @private * @param nsIDOMEvent ev the beforeunload DOM event. */ DeveloperToolbar.prototype._onPageBeforeUnload = function (ev) { let window = ev.target.defaultView; if (window.top !== window) { return; } let tabs = this._chromeWindow.gBrowser.tabs; Array.prototype.some.call(tabs, function (tab) { if (tab.linkedBrowser.contentWindow === window) { let tabId = tab.linkedPanel; if (tabId in this._errorsCount || tabId in this._warningsCount) { this._errorsCount[tabId] = 0; this._warningsCount[tabId] = 0; this._updateErrorsCount(tabId); } return true; } return false; }, this); }; /** * Update the page errors count displayed in the Web Console button for the * currently selected tab. * * @private * @param string [changedTabId] Optional. The tab ID that had its page errors * count changed. If this is provided and it doesn't match the currently * selected tab, then the button is not updated. */ DeveloperToolbar.prototype._updateErrorsCount = function (changedTabId) { let tabId = this._chromeWindow.gBrowser.selectedTab.linkedPanel; if (changedTabId && tabId != changedTabId) { return; } let errors = this._errorsCount[tabId]; let warnings = this._warningsCount[tabId]; let btn = this._errorCounterButton; if (errors) { let errorsText = toolboxStrings .GetStringFromName("toolboxToggleButton.errors"); errorsText = PluralForm.get(errors, errorsText).replace("#1", errors); let warningsText = toolboxStrings .GetStringFromName("toolboxToggleButton.warnings"); warningsText = PluralForm.get(warnings, warningsText).replace("#1", warnings); let tooltiptext = toolboxStrings .formatStringFromName("toolboxToggleButton.tooltip", [errorsText, warningsText], 2); btn.setAttribute("error-count", errors); btn.setAttribute("tooltiptext", tooltiptext); } else { btn.removeAttribute("error-count"); btn.setAttribute("tooltiptext", btn._defaultTooltipText); } this.emit("errors-counter-updated"); }; /** * Reset the errors counter for the given tab. * * @param nsIDOMElement tab The xul:tab for which you want to reset the page * errors counters. */ DeveloperToolbar.prototype.resetErrorsCount = function (tab) { let tabId = tab.linkedPanel; if (tabId in this._errorsCount || tabId in this._warningsCount) { this._errorsCount[tabId] = 0; this._warningsCount[tabId] = 0; this._updateErrorsCount(tabId); } }; /** * Creating a OutputPanel is asynchronous */ function OutputPanel() { throw new Error("Use OutputPanel.create()"); } /** * Panel to handle command line output. * * There is a tooltip bug on Windows and OSX that prevents tooltips from being * positioned properly (bug 786975). There is a Gnome panel bug on Linux that * causes ugly focus issues (https://bugzilla.gnome.org/show_bug.cgi?id=621848). * We now use a tooltip on Linux and a panel on OSX & Windows. * * If a panel has no content and no height it is not shown when openPopup is * called on Windows and OSX (bug 692348) ... this prevents the panel from * appearing the first time it is shown. Setting the panel's height to 1px * before calling openPopup works around this issue as we resize it ourselves * anyway. * * @param devtoolbar The parent DeveloperToolbar object */ OutputPanel.create = function (devtoolbar) { var outputPanel = Object.create(OutputPanel.prototype); return outputPanel._init(devtoolbar); }; /** * @private See OutputPanel.create */ OutputPanel.prototype._init = function (devtoolbar) { this._devtoolbar = devtoolbar; this._input = this._devtoolbar._input; this._toolbar = this._devtoolbar._doc.getElementById("developer-toolbar"); /* */ // TODO: Switch back from tooltip to panel when metacity focus issue is fixed: // https://bugzilla.mozilla.org/show_bug.cgi?id=780102 this._panel = this._devtoolbar._doc.createElement(isLinux ? "tooltip" : "panel"); this._panel.id = "gcli-output"; this._panel.classList.add("gcli-panel"); if (isLinux) { this.canHide = false; this._onpopuphiding = this._onpopuphiding.bind(this); this._panel.addEventListener("popuphiding", this._onpopuphiding, true); } else { this._panel.setAttribute("noautofocus", "true"); this._panel.setAttribute("noautohide", "true"); // Bug 692348: On Windows and OSX if a panel has no content and no height // openPopup fails to display it. Setting the height to 1px alows the panel // to be displayed before has content or a real height i.e. the first time // it is displayed. this._panel.setAttribute("height", "1px"); } this._toolbar.parentElement.insertBefore(this._panel, this._toolbar); this._frame = this._devtoolbar._doc.createElementNS(NS_XHTML, "iframe"); this._frame.id = "gcli-output-frame"; this._frame.setAttribute("src", "chrome://devtools/content/commandline/commandlineoutput.xhtml"); this._frame.setAttribute("sandbox", "allow-same-origin"); this._panel.appendChild(this._frame); this.displayedOutput = undefined; this._update = this._update.bind(this); // Wire up the element from the iframe, and resolve the promise let deferred = promise.defer(); let onload = () => { this._frame.removeEventListener("load", onload, true); this.document = this._frame.contentDocument; this._copyTheme(); this._div = this.document.getElementById("gcli-output-root"); this._div.classList.add("gcli-row-out"); this._div.setAttribute("aria-live", "assertive"); let styles = this._toolbar.ownerDocument.defaultView .getComputedStyle(this._toolbar); this._div.setAttribute("dir", styles.direction); deferred.resolve(this); }; this._frame.addEventListener("load", onload, true); return deferred.promise; }; /* Copy the current devtools theme attribute into the iframe, so it can be styled correctly. */ OutputPanel.prototype._copyTheme = function () { if (this.document) { let theme = this._devtoolbar._doc.documentElement.getAttribute("devtoolstheme"); this.document.documentElement.setAttribute("devtoolstheme", theme); } }; /** * Prevent the popup from hiding if it is not permitted via this.canHide. */ OutputPanel.prototype._onpopuphiding = function (ev) { // TODO: When we switch back from tooltip to panel we can remove this hack: // https://bugzilla.mozilla.org/show_bug.cgi?id=780102 if (isLinux && !this.canHide) { ev.preventDefault(); } }; /** * Display the OutputPanel. */ OutputPanel.prototype.show = function () { if (isLinux) { this.canHide = false; } // We need to reset the iframe size in order for future size calculations to // be correct this._frame.style.minHeight = this._frame.style.maxHeight = 0; this._frame.style.minWidth = 0; this._copyTheme(); this._panel.openPopup(this._input, "before_start", 0, 0, false, false, null); this._resize(); this._input.focus(); }; /** * Internal helper to set the height of the output panel to fit the available * content; */ OutputPanel.prototype._resize = function () { if (this._panel == null || this.document == null || !this._panel.state == "closed") { return; } // Set max panel width to match any content with a max of the width of the // browser window. let maxWidth = this._panel.ownerDocument.documentElement.clientWidth; // Adjust max width according to OS. // We'd like to put this in CSS but we can't: // body { width: calc(min(-5px, max-content)); } // #_panel { max-width: -5px; } switch (OS) { case "Linux": maxWidth -= 5; break; case "Darwin": maxWidth -= 25; break; case "WINNT": maxWidth -= 5; break; } this.document.body.style.width = "-moz-max-content"; let style = this._frame.contentWindow.getComputedStyle(this.document.body); let frameWidth = parseInt(style.width, 10); let width = Math.min(maxWidth, frameWidth); this.document.body.style.width = width + "px"; // Set the width of the iframe. this._frame.style.minWidth = width + "px"; this._panel.style.maxWidth = maxWidth + "px"; // browserAdjustment is used to correct the panel height according to the // browsers borders etc. const browserAdjustment = 15; // Set max panel height to match any content with a max of the height of the // browser window. let maxHeight = this._panel.ownerDocument.documentElement.clientHeight - browserAdjustment; let height = Math.min(maxHeight, this.document.documentElement.scrollHeight); // Set the height of the iframe. Setting iframe.height does not work. this._frame.style.minHeight = this._frame.style.maxHeight = height + "px"; // Set the height and width of the panel to match the iframe. this._panel.sizeTo(width, height); // Move the panel to the correct position in the case that it has been // positioned incorrectly. let screenX = this._input.boxObject.screenX; let screenY = this._toolbar.boxObject.screenY; this._panel.moveTo(screenX, screenY - height); }; /** * Called by GCLI when a command is executed. */ OutputPanel.prototype._outputChanged = function (ev) { if (ev.output.hidden) { return; } this.remove(); this.displayedOutput = ev.output; if (this.displayedOutput.completed) { this._update(); } else { this.displayedOutput.promise.then(this._update, this._update) .then(null, console.error); } }; /** * Called when displayed Output says it's changed or from outputChanged, which * happens when there is a new displayed Output. */ OutputPanel.prototype._update = function () { // destroy has been called, bail out if (this._div == null) { return; } // Empty this._div while (this._div.hasChildNodes()) { this._div.removeChild(this._div.firstChild); } if (this.displayedOutput.data != null) { let context = this._devtoolbar.requisition.conversionContext; this.displayedOutput.convert("dom", context).then(node => { if (node == null) { return; } while (this._div.hasChildNodes()) { this._div.removeChild(this._div.firstChild); } var links = node.querySelectorAll("*[href]"); for (var i = 0; i < links.length; i++) { links[i].setAttribute("target", "_blank"); } this._div.appendChild(node); this.show(); }); } }; /** * Detach listeners from the currently displayed Output. */ OutputPanel.prototype.remove = function () { if (isLinux) { this.canHide = true; } if (this._panel && this._panel.hidePopup) { this._panel.hidePopup(); } if (this.displayedOutput) { delete this.displayedOutput; } }; /** * Detach listeners from the currently displayed Output. */ OutputPanel.prototype.destroy = function () { this.remove(); this._panel.removeEventListener("popuphiding", this._onpopuphiding, true); this._panel.removeChild(this._frame); this._toolbar.parentElement.removeChild(this._panel); delete this._devtoolbar; delete this._input; delete this._toolbar; delete this._onpopuphiding; delete this._panel; delete this._frame; delete this._content; delete this._div; delete this.document; }; /** * Called by GCLI to indicate that we should show or hide one either the * tooltip panel or the output panel. */ OutputPanel.prototype._visibilityChanged = function (ev) { if (ev.outputVisible === true) { // this.show is called by _outputChanged } else { if (isLinux) { this.canHide = true; } this._panel.hidePopup(); } }; /** * Creating a TooltipPanel is asynchronous */ function TooltipPanel() { throw new Error("Use TooltipPanel.create()"); } /** * Panel to handle tooltips. * * There is a tooltip bug on Windows and OSX that prevents tooltips from being * positioned properly (bug 786975). There is a Gnome panel bug on Linux that * causes ugly focus issues (https://bugzilla.gnome.org/show_bug.cgi?id=621848). * We now use a tooltip on Linux and a panel on OSX & Windows. * * If a panel has no content and no height it is not shown when openPopup is * called on Windows and OSX (bug 692348) ... this prevents the panel from * appearing the first time it is shown. Setting the panel's height to 1px * before calling openPopup works around this issue as we resize it ourselves * anyway. * * @param devtoolbar The parent DeveloperToolbar object */ TooltipPanel.create = function (devtoolbar) { var tooltipPanel = Object.create(TooltipPanel.prototype); return tooltipPanel._init(devtoolbar); }; /** * @private See TooltipPanel.create */ TooltipPanel.prototype._init = function (devtoolbar) { let deferred = promise.defer(); let chromeDocument = devtoolbar._doc; this._devtoolbar = devtoolbar; this._input = devtoolbar._doc.querySelector(".gclitoolbar-input-node"); this._toolbar = devtoolbar._doc.querySelector("#developer-toolbar"); this._dimensions = { start: 0, end: 0 }; /* */ // TODO: Switch back from tooltip to panel when metacity focus issue is fixed: // https://bugzilla.mozilla.org/show_bug.cgi?id=780102 this._panel = devtoolbar._doc.createElement(isLinux ? "tooltip" : "panel"); this._panel.id = "gcli-tooltip"; this._panel.classList.add("gcli-panel"); if (isLinux) { this.canHide = false; this._onpopuphiding = this._onpopuphiding.bind(this); this._panel.addEventListener("popuphiding", this._onpopuphiding, true); } else { this._panel.setAttribute("noautofocus", "true"); this._panel.setAttribute("noautohide", "true"); // Bug 692348: On Windows and OSX if a panel has no content and no height // openPopup fails to display it. Setting the height to 1px alows the panel // to be displayed before has content or a real height i.e. the first time // it is displayed. this._panel.setAttribute("height", "1px"); } this._toolbar.parentElement.insertBefore(this._panel, this._toolbar); this._frame = devtoolbar._doc.createElementNS(NS_XHTML, "iframe"); this._frame.id = "gcli-tooltip-frame"; this._frame.setAttribute("src", "chrome://devtools/content/commandline/commandlinetooltip.xhtml"); this._frame.setAttribute("flex", "1"); this._frame.setAttribute("sandbox", "allow-same-origin"); this._panel.appendChild(this._frame); /** * Wire up the element from the iframe, and resolve the promise. */ let onload = () => { this._frame.removeEventListener("load", onload, true); this.document = this._frame.contentDocument; this._copyTheme(); this.hintElement = this.document.getElementById("gcli-tooltip-root"); this._connector = this.document.getElementById("gcli-tooltip-connector"); let styles = this._toolbar.ownerDocument.defaultView .getComputedStyle(this._toolbar); this.hintElement.setAttribute("dir", styles.direction); deferred.resolve(this); }; this._frame.addEventListener("load", onload, true); return deferred.promise; }; /* Copy the current devtools theme attribute into the iframe, so it can be styled correctly. */ TooltipPanel.prototype._copyTheme = function () { if (this.document) { let theme = this._devtoolbar._doc.documentElement.getAttribute("devtoolstheme"); this.document.documentElement.setAttribute("devtoolstheme", theme); } }; /** * Prevent the popup from hiding if it is not permitted via this.canHide. */ TooltipPanel.prototype._onpopuphiding = function (ev) { // TODO: When we switch back from tooltip to panel we can remove this hack: // https://bugzilla.mozilla.org/show_bug.cgi?id=780102 if (isLinux && !this.canHide) { ev.preventDefault(); } }; /** * Display the TooltipPanel. */ TooltipPanel.prototype.show = function (dimensions) { if (!dimensions) { dimensions = { start: 0, end: 0 }; } this._dimensions = dimensions; // This is nasty, but displaying the panel causes it to re-flow, which can // change the size it should be, so we need to resize the iframe after the // panel has displayed this._panel.ownerDocument.defaultView.setTimeout(() => { this._resize(); }, 0); if (isLinux) { this.canHide = false; } this._copyTheme(); this._resize(); this._panel.openPopup(this._input, "before_start", dimensions.start * 10, 0, false, false, null); this._input.focus(); }; /** * One option is to spend lots of time taking an average width of characters * in the current font, dynamically, and weighting for the frequency of use of * various characters, or even to render the given string off screen, and then * measure the width. * Or we could do this... */ const AVE_CHAR_WIDTH = 4.5; /** * Display the TooltipPanel. */ TooltipPanel.prototype._resize = function () { if (this._panel == null || this.document == null || !this._panel.state == "closed") { return; } let offset = 10 + Math.floor(this._dimensions.start * AVE_CHAR_WIDTH); this._panel.style.marginLeft = offset + "px"; /* // Bug 744906: UX review - Not sure if we want this code to fatten connector // with param width let width = Math.floor(this._dimensions.end * AVE_CHAR_WIDTH); width = Math.min(width, 100); width = Math.max(width, 10); this._connector.style.width = width + "px"; */ this._frame.height = this.document.body.scrollHeight; }; /** * Hide the TooltipPanel. */ TooltipPanel.prototype.remove = function () { if (isLinux) { this.canHide = true; } if (this._panel && this._panel.hidePopup) { this._panel.hidePopup(); } }; /** * Hide the TooltipPanel. */ TooltipPanel.prototype.destroy = function () { this.remove(); this._panel.removeEventListener("popuphiding", this._onpopuphiding, true); this._panel.removeChild(this._frame); this._toolbar.parentElement.removeChild(this._panel); delete this._connector; delete this._dimensions; delete this._input; delete this._onpopuphiding; delete this._panel; delete this._frame; delete this._toolbar; delete this._content; delete this.document; delete this.hintElement; }; /** * Called by GCLI to indicate that we should show or hide one either the * tooltip panel or the output panel. */ TooltipPanel.prototype._visibilityChanged = function (ev) { if (ev.tooltipVisible === true) { this.show(ev.dimensions); } else { if (isLinux) { this.canHide = true; } this._panel.hidePopup(); } };