/* 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 defer = require("devtools/shared/defer"); const EventEmitter = require("devtools/shared/event-emitter"); const {KeyCodes} = require("devtools/client/shared/keycodes"); const {TooltipToggle} = require("devtools/client/shared/widgets/tooltip/TooltipToggle"); const XHTML_NS = "http://www.w3.org/1999/xhtml"; const ESCAPE_KEYCODE = KeyCodes.DOM_VK_ESCAPE; const POPUP_EVENTS = ["shown", "hidden", "showing", "hiding"]; /** * Tooltip widget. * * This widget is intended at any tool that may need to show rich content in the * form of floating panels. * A common use case is image previewing in the CSS rule view, but more complex * use cases may include color pickers, object inspection, etc... * * Tooltips are based on XUL (namely XUL arrow-type s), and therefore * need a XUL Document to live in. * This is pretty much the only requirement they have on their environment. * * The way to use a tooltip is simply by instantiating a tooltip yourself and * attaching some content in it, or using one of the ready-made content types. * * A convenient `startTogglingOnHover` method may avoid having to register event * handlers yourself if the tooltip has to be shown when hovering over a * specific element or group of elements (which is usually the most common case) */ /** * Tooltip class. * * Basic usage: * let t = new Tooltip(xulDoc); * t.content = someXulContent; * t.show(); * t.hide(); * t.destroy(); * * Better usage: * let t = new Tooltip(xulDoc); * t.startTogglingOnHover(container, target => { * if () { * t.content = el; * return true; * } * }); * t.destroy(); * * @param {XULDocument} doc * The XUL document hosting this tooltip * @param {Object} options * Optional options that give options to consumers: * - consumeOutsideClick {Boolean} Wether the first click outside of the * tooltip should close the tooltip and be consumed or not. * Defaults to false. * - closeOnKeys {Array} An array of key codes that should close the * tooltip. Defaults to [27] (escape key). * - closeOnEvents [{emitter: {Object}, event: {String}, * useCapture: {Boolean}}] * Provide an optional list of emitter objects and event names here to * trigger the closing of the tooltip when these events are fired by the * emitters. The emitter objects should either implement * on/off(event, cb) or addEventListener/removeEventListener(event, cb). * Defaults to []. * For instance, the following would close the tooltip whenever the * toolbox selects a new tool and when a DOM node gets scrolled: * new Tooltip(doc, { * closeOnEvents: [ * {emitter: toolbox, event: "select"}, * {emitter: myContainer, event: "scroll", useCapture: true} * ] * }); * - noAutoFocus {Boolean} Should the focus automatically go to the panel * when it opens. Defaults to true. * * Fires these events: * - showing : just before the tooltip shows * - shown : when the tooltip is shown * - hiding : just before the tooltip closes * - hidden : when the tooltip gets hidden * - keypress : when any key gets pressed, with keyCode */ function Tooltip(doc, { consumeOutsideClick = false, closeOnKeys = [ESCAPE_KEYCODE], noAutoFocus = true, closeOnEvents = [], } = {}) { EventEmitter.decorate(this); this.doc = doc; this.consumeOutsideClick = consumeOutsideClick; this.closeOnKeys = closeOnKeys; this.noAutoFocus = noAutoFocus; this.closeOnEvents = closeOnEvents; this.panel = this._createPanel(); // Create tooltip toggle helper and decorate the Tooltip instance with // shortcut methods. this._toggle = new TooltipToggle(this); this.startTogglingOnHover = this._toggle.start.bind(this._toggle); this.stopTogglingOnHover = this._toggle.stop.bind(this._toggle); // Emit show/hide events when the panel does. for (let eventName of POPUP_EVENTS) { this["_onPopup" + eventName] = (name => { return e => { if (e.target === this.panel) { this.emit(name); } }; })(eventName); this.panel.addEventListener("popup" + eventName, this["_onPopup" + eventName], false); } // Listen to keypress events to close the tooltip if configured to do so let win = this.doc.querySelector("window"); this._onKeyPress = event => { if (this.panel.hidden) { return; } this.emit("keypress", event.keyCode); if (this.closeOnKeys.indexOf(event.keyCode) !== -1 && this.isShown()) { event.stopPropagation(); this.hide(); } }; win.addEventListener("keypress", this._onKeyPress, false); // Listen to custom emitters' events to close the tooltip this.hide = this.hide.bind(this); for (let {emitter, event, useCapture} of this.closeOnEvents) { for (let add of ["addEventListener", "on"]) { if (add in emitter) { emitter[add](event, this.hide, useCapture); break; } } } } Tooltip.prototype = { defaultPosition: "before_start", // px defaultOffsetX: 0, // px defaultOffsetY: 0, // px /** * Show the tooltip. It might be wise to append some content first if you * don't want the tooltip to be empty. You may access the content of the * tooltip by setting a XUL node to t.content. * @param {node} anchor * Which node should the tooltip be shown on * @param {string} position [optional] * Optional tooltip position. Defaults to before_start * https://developer.mozilla.org/en-US/docs/XUL/PopupGuide/Positioning * @param {number} x, y [optional] * The left and top offset coordinates, in pixels. */ show: function (anchor, position = this.defaultPosition, x = this.defaultOffsetX, y = this.defaultOffsetY) { this.panel.hidden = false; this.panel.openPopup(anchor, position, x, y); }, /** * Hide the tooltip */ hide: function () { this.panel.hidden = true; this.panel.hidePopup(); }, isShown: function () { return this.panel && this.panel.state !== "closed" && this.panel.state !== "hiding"; }, setSize: function (width, height) { this.panel.sizeTo(width, height); }, /** * Empty the tooltip's content */ empty: function () { while (this.panel.hasChildNodes()) { this.panel.removeChild(this.panel.firstChild); } }, /** * Gets this panel's visibility state. * @return boolean */ isHidden: function () { return this.panel.state == "closed" || this.panel.state == "hiding"; }, /** * Gets if this panel has any child nodes. * @return boolean */ isEmpty: function () { return !this.panel.hasChildNodes(); }, /** * Get rid of references and event listeners */ destroy: function () { this.hide(); for (let eventName of POPUP_EVENTS) { this.panel.removeEventListener("popup" + eventName, this["_onPopup" + eventName], false); } let win = this.doc.querySelector("window"); win.removeEventListener("keypress", this._onKeyPress, false); for (let {emitter, event, useCapture} of this.closeOnEvents) { for (let remove of ["removeEventListener", "off"]) { if (remove in emitter) { emitter[remove](event, this.hide, useCapture); break; } } } this.content = null; this._toggle.destroy(); this.doc = null; this.panel.remove(); this.panel = null; }, /** * Returns the outer container node (that includes the arrow etc.). Happens * to be identical to this.panel here, can be different element in other * Tooltip implementations. */ get container() { return this.panel; }, /** * Set the content of this tooltip. Will first empty the tooltip and then * append the new content element. * Consider using one of the setContent() functions instead. * @param {node} content * A node that can be appended in the tooltip XUL element */ set content(content) { if (this.content == content) { return; } this.empty(); this.panel.removeAttribute("clamped-dimensions"); this.panel.removeAttribute("clamped-dimensions-no-min-height"); this.panel.removeAttribute("clamped-dimensions-no-max-or-min-height"); this.panel.removeAttribute("wide"); if (content) { this.panel.appendChild(content); } }, get content() { return this.panel.firstChild; }, /** * Sets some text as the content of this tooltip. * * @param {array} messages * A list of text messages. * @param {string} messagesClass [optional] * A style class for the text messages. * @param {string} containerClass [optional] * A style class for the text messages container. */ setTextContent: function ( { messages, messagesClass, containerClass }, extraButtons = []) { messagesClass = messagesClass || "default-tooltip-simple-text-colors"; containerClass = containerClass || "default-tooltip-simple-text-colors"; let vbox = this.doc.createElement("vbox"); vbox.className = "devtools-tooltip-simple-text-container " + containerClass; vbox.setAttribute("flex", "1"); for (let text of messages) { let description = this.doc.createElement("description"); description.setAttribute("flex", "1"); description.className = "devtools-tooltip-simple-text " + messagesClass; description.textContent = text; vbox.appendChild(description); } for (let { label, className, command } of extraButtons) { let button = this.doc.createElement("button"); button.className = className; button.setAttribute("label", label); button.addEventListener("command", command); vbox.appendChild(button); } this.content = vbox; }, /** * Load a document into an iframe, and set the iframe * to be the tooltip's content. * * Used by tooltips that want to load their interface * into an iframe from a URL. * * @param {string} width * Width of the iframe. * @param {string} height * Height of the iframe. * @param {string} url * URL of the document to load into the iframe. * * @return {promise} A promise which is resolved with * the iframe. * * This function creates an iframe, loads the specified document * into it, sets the tooltip's content to the iframe, and returns * a promise. * * When the document is loaded, the function gets the content window * and resolves the promise with the content window. */ setIFrameContent: function ({width, height}, url) { let def = defer(); // Create an iframe let iframe = this.doc.createElementNS(XHTML_NS, "iframe"); iframe.setAttribute("transparent", true); iframe.setAttribute("width", width); iframe.setAttribute("height", height); iframe.setAttribute("flex", "1"); iframe.setAttribute("tooltip", "aHTMLTooltip"); iframe.setAttribute("class", "devtools-tooltip-iframe"); // Wait for the load to initialize the widget function onLoad() { iframe.removeEventListener("load", onLoad, true); def.resolve(iframe); } iframe.addEventListener("load", onLoad, true); // load the document from url into the iframe iframe.setAttribute("src", url); // Put the iframe in the tooltip this.content = iframe; return def.promise; }, /** * Create the tooltip panel */ _createPanel() { let panel = this.doc.createElement("panel"); panel.setAttribute("hidden", true); panel.setAttribute("ignorekeys", true); panel.setAttribute("animate", false); panel.setAttribute("consumeoutsideclicks", this.consumeOutsideClick); panel.setAttribute("noautofocus", this.noAutoFocus); panel.setAttribute("type", "arrow"); panel.setAttribute("level", "top"); panel.setAttribute("class", "devtools-tooltip theme-tooltip-panel"); this.doc.querySelector("window").appendChild(panel); return panel; } }; module.exports = Tooltip;