/* -*- 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/. */ "use strict"; const EventEmitter = require("devtools/shared/event-emitter"); const {TooltipToggle} = require("devtools/client/shared/widgets/tooltip/TooltipToggle"); const {listenOnce} = require("devtools/shared/async-utils"); const {Task} = require("devtools/shared/task"); const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; const XHTML_NS = "http://www.w3.org/1999/xhtml"; const POSITION = { TOP: "top", BOTTOM: "bottom", }; module.exports.POSITION = POSITION; const TYPE = { NORMAL: "normal", ARROW: "arrow", }; module.exports.TYPE = TYPE; const ARROW_WIDTH = 32; // Default offset between the tooltip's left edge and the tooltip arrow. const ARROW_OFFSET = 20; const EXTRA_HEIGHT = { "normal": 0, // The arrow is 16px tall, but merges on 3px with the panel border "arrow": 13, }; const EXTRA_BORDER = { "normal": 0, "arrow": 3, }; /** * Calculate the vertical position & offsets to use for the tooltip. Will attempt to * respect the provided height and position preferences, unless the available height * prevents this. * * @param {DOMRect} anchorRect * Bounding rectangle for the anchor, relative to the tooltip document. * @param {DOMRect} viewportRect * Bounding rectangle for the viewport. top/left can be different from 0 if some * space should not be used by tooltips (for instance OS toolbars, taskbars etc.). * @param {Number} height * Preferred height for the tooltip. * @param {String} pos * Preferred position for the tooltip. Possible values: "top" or "bottom". * @return {Object} * - {Number} top: the top offset for the tooltip. * - {Number} height: the height to use for the tooltip container. * - {String} computedPosition: Can differ from the preferred position depending * on the available height). "top" or "bottom" */ const calculateVerticalPosition = function (anchorRect, viewportRect, height, pos, offset) { let {TOP, BOTTOM} = POSITION; let {top: anchorTop, height: anchorHeight} = anchorRect; // Translate to the available viewport space before calculating dimensions and position. anchorTop -= viewportRect.top; // Calculate available space for the tooltip. let availableTop = anchorTop; let availableBottom = viewportRect.height - (anchorTop + anchorHeight); // Find POSITION let keepPosition = false; if (pos === TOP) { keepPosition = availableTop >= height + offset; } else if (pos === BOTTOM) { keepPosition = availableBottom >= height + offset; } if (!keepPosition) { pos = availableTop > availableBottom ? TOP : BOTTOM; } // Calculate HEIGHT. let availableHeight = pos === TOP ? availableTop : availableBottom; height = Math.min(height, availableHeight - offset); height = Math.floor(height); // Calculate TOP. let top = pos === TOP ? anchorTop - height - offset : anchorTop + anchorHeight + offset; // Translate back to absolute coordinates by re-including viewport top margin. top += viewportRect.top; return {top, height, computedPosition: pos}; }; /** * Calculate the vertical position & offsets to use for the tooltip. Will attempt to * respect the provided height and position preferences, unless the available height * prevents this. * * @param {DOMRect} anchorRect * Bounding rectangle for the anchor, relative to the tooltip document. * @param {DOMRect} viewportRect * Bounding rectangle for the viewport. top/left can be different from 0 if some * space should not be used by tooltips (for instance OS toolbars, taskbars etc.). * @param {Number} width * Preferred width for the tooltip. * @param {String} type * The tooltip type (e.g. "arrow"). * @param {Number} offset * Horizontal offset in pixels. * @param {Boolean} isRtl * If the anchor is in RTL, the tooltip should be aligned to the right. * @return {Object} * - {Number} left: the left offset for the tooltip. * - {Number} width: the width to use for the tooltip container. * - {Number} arrowLeft: the left offset to use for the arrow element. */ const calculateHorizontalPosition = function (anchorRect, viewportRect, width, type, offset, isRtl) { let anchorWidth = anchorRect.width; let anchorStart = isRtl ? anchorRect.right : anchorRect.left; // Translate to the available viewport space before calculating dimensions and position. anchorStart -= viewportRect.left; // Calculate WIDTH. width = Math.min(width, viewportRect.width); // Calculate LEFT. // By default the tooltip is aligned with the anchor left edge. Unless this // makes it overflow the viewport, in which case is shifts to the left. let left = anchorStart + offset - (isRtl ? width : 0); left = Math.min(left, viewportRect.width - width); left = Math.max(0, left); // Calculate ARROW LEFT (tooltip's LEFT might be updated) let arrowLeft; // Arrow style tooltips may need to be shifted to the left if (type === TYPE.ARROW) { let arrowCenter = left + ARROW_OFFSET + ARROW_WIDTH / 2; let anchorCenter = anchorStart + anchorWidth / 2; // If the anchor is too narrow, align the arrow and the anchor center. if (arrowCenter > anchorCenter) { left = Math.max(0, left - (arrowCenter - anchorCenter)); } // Arrow's left offset relative to the anchor. arrowLeft = Math.min(ARROW_OFFSET, (anchorWidth - ARROW_WIDTH) / 2) | 0; // Translate the coordinate to tooltip container arrowLeft += anchorStart - left; // Make sure the arrow remains in the tooltip container. arrowLeft = Math.min(arrowLeft, width - ARROW_WIDTH); arrowLeft = Math.max(arrowLeft, 0); } // Translate back to absolute coordinates by re-including viewport left margin. left += viewportRect.left; return {left, width, arrowLeft}; }; /** * Get the bounding client rectangle for a given node, relative to a custom * reference element (instead of the default for getBoundingClientRect which * is always the element's ownerDocument). */ const getRelativeRect = function (node, relativeTo) { // Width and Height can be taken from the rect. let {width, height} = node.getBoundingClientRect(); let quads = node.getBoxQuads({relativeTo}); let top = quads[0].bounds.top; let left = quads[0].bounds.left; // Compute right and bottom coordinates using the rest of the data. let right = left + width; let bottom = top + height; return {top, right, bottom, left, width, height}; }; /** * The HTMLTooltip can display HTML content in a tooltip popup. * * @param {Document} toolboxDoc * The toolbox document to attach the HTMLTooltip popup. * @param {Object} * - {String} type * Display type of the tooltip. Possible values: "normal", "arrow" * - {Boolean} autofocus * Defaults to false. Should the tooltip be focused when opening it. * - {Boolean} consumeOutsideClicks * Defaults to true. The tooltip is closed when clicking outside. * Should this event be stopped and consumed or not. * - {Boolean} useXulWrapper * Defaults to false. If the tooltip is hosted in a XUL document, use a XUL panel * in order to use all the screen viewport available. * - {String} stylesheet * Style sheet URL to apply to the tooltip content. */ function HTMLTooltip(toolboxDoc, { type = "normal", autofocus = false, consumeOutsideClicks = true, useXulWrapper = false, stylesheet = "", } = {}) { EventEmitter.decorate(this); this.doc = toolboxDoc; this.type = type; this.autofocus = autofocus; this.consumeOutsideClicks = consumeOutsideClicks; this.useXulWrapper = this._isXUL() && useXulWrapper; // The top window is used to attach click event listeners to close the tooltip if the // user clicks on the content page. this.topWindow = this._getTopWindow(); this._position = null; this._onClick = this._onClick.bind(this); this._onXulPanelHidden = this._onXulPanelHidden.bind(this); this._toggle = new TooltipToggle(this); this.startTogglingOnHover = this._toggle.start.bind(this._toggle); this.stopTogglingOnHover = this._toggle.stop.bind(this._toggle); this.container = this._createContainer(); if (stylesheet) { this._applyStylesheet(stylesheet); } if (this.useXulWrapper) { // When using a XUL panel as the wrapper, the actual markup for the tooltip is as // follows : // //
//
this.xulPanelWrapper = this._createXulPanelWrapper(); let inner = this.doc.createElementNS(XHTML_NS, "div"); inner.classList.add("tooltip-xul-wrapper-inner"); this.doc.documentElement.appendChild(this.xulPanelWrapper); this.xulPanelWrapper.appendChild(inner); inner.appendChild(this.container); } else if (this._isXUL()) { this.doc.documentElement.appendChild(this.container); } else { // In non-XUL context the container is ready to use as is. this.doc.body.appendChild(this.container); } } module.exports.HTMLTooltip = HTMLTooltip; HTMLTooltip.prototype = { /** * The tooltip panel is the parentNode of the tooltip content provided in * setContent(). */ get panel() { return this.container.querySelector(".tooltip-panel"); }, /** * The arrow element. Might be null depending on the tooltip type. */ get arrow() { return this.container.querySelector(".tooltip-arrow"); }, /** * Retrieve the displayed position used for the tooltip. Null if the tooltip is hidden. */ get position() { return this.isVisible() ? this._position : null; }, /** * Set the tooltip content element. The preferred width/height should also be * specified here. * * @param {Element} content * The tooltip content, should be a HTML element. * @param {Object} * - {Number} width: preferred width for the tooltip container. If not specified * the tooltip container will be measured before being displayed, and the * measured width will be used as preferred width. * - {Number} height: optional, preferred height for the tooltip container. If * not specified, the tooltip will be able to use all the height available. */ setContent: function (content, {width = "auto", height = Infinity} = {}) { this.preferredWidth = width; this.preferredHeight = height; this.panel.innerHTML = ""; this.panel.appendChild(content); }, /** * Show the tooltip next to the provided anchor element. A preferred position * can be set. The event "shown" will be fired after the tooltip is displayed. * * @param {Element} anchor * The reference element with which the tooltip should be aligned * @param {Object} * - {String} position: optional, possible values: top|bottom * If layout permits, the tooltip will be displayed on top/bottom * of the anchor. If ommitted, the tooltip will be displayed where * more space is available. * - {Number} x: optional, horizontal offset between the anchor and the tooltip * - {Number} y: optional, vertical offset between the anchor and the tooltip */ show: Task.async(function* (anchor, {position, x = 0, y = 0} = {}) { // Get anchor geometry let anchorRect = getRelativeRect(anchor, this.doc); if (this.useXulWrapper) { anchorRect = this._convertToScreenRect(anchorRect); } // Get viewport size let viewportRect = this._getViewportRect(); let themeHeight = EXTRA_HEIGHT[this.type] + 2 * EXTRA_BORDER[this.type]; let preferredHeight = this.preferredHeight + themeHeight; let {top, height, computedPosition} = calculateVerticalPosition(anchorRect, viewportRect, preferredHeight, position, y); this._position = computedPosition; // Apply height before measuring the content width (if width="auto"). let isTop = computedPosition === POSITION.TOP; this.container.classList.toggle("tooltip-top", isTop); this.container.classList.toggle("tooltip-bottom", !isTop); // If the preferred height is set to Infinity, the tooltip container should grow based // on its content's height and use as much height as possible. this.container.classList.toggle("tooltip-flexible-height", this.preferredHeight === Infinity); this.container.style.height = height + "px"; let preferredWidth; if (this.preferredWidth === "auto") { preferredWidth = this._measureContainerWidth(); } else { let themeWidth = 2 * EXTRA_BORDER[this.type]; preferredWidth = this.preferredWidth + themeWidth; } let anchorWin = anchor.ownerDocument.defaultView; let isRtl = anchorWin.getComputedStyle(anchor).direction === "rtl"; let {left, width, arrowLeft} = calculateHorizontalPosition( anchorRect, viewportRect, preferredWidth, this.type, x, isRtl); this.container.style.width = width + "px"; if (this.type === TYPE.ARROW) { this.arrow.style.left = arrowLeft + "px"; } if (this.useXulWrapper) { yield this._showXulWrapperAt(left, top); } else { this.container.style.left = left + "px"; this.container.style.top = top + "px"; } this.container.classList.add("tooltip-visible"); // Keep a pointer on the focused element to refocus it when hiding the tooltip. this._focusedElement = this.doc.activeElement; this.doc.defaultView.clearTimeout(this.attachEventsTimer); this.attachEventsTimer = this.doc.defaultView.setTimeout(() => { this._maybeFocusTooltip(); // Updated the top window reference each time in case the host changes. this.topWindow = this._getTopWindow(); this.topWindow.addEventListener("click", this._onClick, true); this.emit("shown"); }, 0); }), /** * Calculate the rect of the viewport that limits the tooltip dimensions. When using a * XUL panel wrapper, the viewport will be able to use the whole screen (excluding space * reserved by the OS for toolbars etc.). Otherwise, the viewport is limited to the * tooltip's document. * * @return {Object} DOMRect-like object with the Number properties: top, right, bottom, * left, width, height */ _getViewportRect: function () { if (this.useXulWrapper) { // availLeft/Top are the coordinates first pixel available on the screen for // applications (excluding space dedicated for OS toolbars, menus etc...) // availWidth/Height are the dimensions available to applications excluding all // the OS reserved space let {availLeft, availTop, availHeight, availWidth} = this.doc.defaultView.screen; return { top: availTop, right: availLeft + availWidth, bottom: availTop + availHeight, left: availLeft, width: availWidth, height: availHeight, }; } return this.doc.documentElement.getBoundingClientRect(); }, _measureContainerWidth: function () { let xulParent = this.container.parentNode; if (this.useXulWrapper && !this.isVisible()) { // Move the container out of the XUL Panel to measure it. this.doc.documentElement.appendChild(this.container); } this.container.classList.add("tooltip-hidden"); this.container.style.width = "auto"; let width = this.container.getBoundingClientRect().width; this.container.classList.remove("tooltip-hidden"); if (this.useXulWrapper && !this.isVisible()) { xulParent.appendChild(this.container); } return width; }, /** * Hide the current tooltip. The event "hidden" will be fired when the tooltip * is hidden. */ hide: Task.async(function* () { this.doc.defaultView.clearTimeout(this.attachEventsTimer); if (!this.isVisible()) { this.emit("hidden"); return; } this.topWindow.removeEventListener("click", this._onClick, true); this.container.classList.remove("tooltip-visible"); if (this.useXulWrapper) { yield this._hideXulWrapper(); } this.emit("hidden"); let tooltipHasFocus = this.container.contains(this.doc.activeElement); if (tooltipHasFocus && this._focusedElement) { this._focusedElement.focus(); this._focusedElement = null; } }), /** * Check if the tooltip is currently displayed. * @return {Boolean} true if the tooltip is visible */ isVisible: function () { return this.container.classList.contains("tooltip-visible"); }, /** * Destroy the tooltip instance. Hide the tooltip if displayed, remove the * tooltip container from the document. */ destroy: function () { this.hide(); this.container.remove(); if (this.xulPanelWrapper) { this.xulPanelWrapper.remove(); } }, _createContainer: function () { let container = this.doc.createElementNS(XHTML_NS, "div"); container.setAttribute("type", this.type); container.classList.add("tooltip-container"); let html = '
'; html += '
'; if (this.type === TYPE.ARROW) { html += '
'; } container.innerHTML = html; return container; }, _onClick: function (e) { if (this._isInTooltipContainer(e.target)) { return; } this.hide(); if (this.consumeOutsideClicks && e.button === 0) { // Consume only left click events (button === 0). e.preventDefault(); e.stopPropagation(); } }, _isInTooltipContainer: function (node) { // Check if the target is the tooltip arrow. if (this.arrow && this.arrow === node) { return true; } let tooltipWindow = this.panel.ownerDocument.defaultView; let win = node.ownerDocument.defaultView; // Check if the tooltip panel contains the node if they live in the same document. if (win === tooltipWindow) { return this.panel.contains(node); } // Check if the node window is in the tooltip container. while (win.parent && win.parent !== win) { if (win.parent === tooltipWindow) { // If the parent window is the tooltip window, check if the tooltip contains // the current frame element. return this.panel.contains(win.frameElement); } win = win.parent; } return false; }, _onXulPanelHidden: function () { if (this.isVisible()) { this.hide(); } }, /** * If the tootlip is configured to autofocus and a focusable element can be found, * focus it. */ _maybeFocusTooltip: function () { // Simplied selector targetting elements that can receive the focus, full version at // http://stackoverflow.com/questions/1599660/which-html-elements-can-receive-focus . let focusableSelector = "a, button, iframe, input, select, textarea"; let focusableElement = this.panel.querySelector(focusableSelector); if (this.autofocus && focusableElement) { focusableElement.focus(); } }, _getTopWindow: function () { return this.doc.defaultView.top; }, /** * Check if the tooltip's owner document is a XUL document. */ _isXUL: function () { return this.doc.documentElement.namespaceURI === XUL_NS; }, _createXulPanelWrapper: function () { let panel = this.doc.createElementNS(XUL_NS, "panel"); // XUL panel is only a way to display DOM elements outside of the document viewport, // so disable all features that impact the behavior. panel.setAttribute("animate", false); panel.setAttribute("consumeoutsideclicks", false); panel.setAttribute("noautofocus", true); panel.setAttribute("ignorekeys", true); panel.setAttribute("tooltip", "aHTMLTooltip"); // Use type="arrow" to prevent side effects (see Bug 1285206) panel.setAttribute("type", "arrow"); panel.setAttribute("level", "top"); panel.setAttribute("class", "tooltip-xul-wrapper"); return panel; }, _showXulWrapperAt: function (left, top) { this.xulPanelWrapper.addEventListener("popuphidden", this._onXulPanelHidden); let onPanelShown = listenOnce(this.xulPanelWrapper, "popupshown"); this.xulPanelWrapper.openPopupAtScreen(left, top, false); return onPanelShown; }, _hideXulWrapper: function () { this.xulPanelWrapper.removeEventListener("popuphidden", this._onXulPanelHidden); if (this.xulPanelWrapper.state === "closed") { // XUL panel is already closed, resolve immediately. return Promise.resolve(); } let onPanelHidden = listenOnce(this.xulPanelWrapper, "popuphidden"); this.xulPanelWrapper.hidePopup(); return onPanelHidden; }, /** * Convert from coordinates relative to the tooltip's document, to coordinates relative * to the "available" screen. By "available" we mean the screen, excluding the OS bars * display on screen edges. */ _convertToScreenRect: function ({left, top, width, height}) { // mozInnerScreenX/Y are the coordinates of the top left corner of the window's // viewport, excluding chrome UI. left += this.doc.defaultView.mozInnerScreenX; top += this.doc.defaultView.mozInnerScreenY; return {top, right: left + width, bottom: top + height, left, width, height}; }, /** * Apply a scoped stylesheet to the container so that this css file only * applies to it. */ _applyStylesheet: function (url) { let style = this.doc.createElementNS(XHTML_NS, "style"); style.setAttribute("scoped", "true"); url = url.replace(/"/g, "\\\""); style.textContent = `@import url("${url}");`; this.container.appendChild(style); } };