diff options
Diffstat (limited to 'devtools/client/shared/widgets/tooltip/Tooltip.js')
-rw-r--r-- | devtools/client/shared/widgets/tooltip/Tooltip.js | 410 |
1 files changed, 410 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/tooltip/Tooltip.js b/devtools/client/shared/widgets/tooltip/Tooltip.js new file mode 100644 index 000000000..c3c365152 --- /dev/null +++ b/devtools/client/shared/widgets/tooltip/Tooltip.js @@ -0,0 +1,410 @@ +/* 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 <panel>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 (<condition based on target>) { + * 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 set<type>Content() 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; |