summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/widgets/tooltip/HTMLTooltip.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shared/widgets/tooltip/HTMLTooltip.js')
-rw-r--r--devtools/client/shared/widgets/tooltip/HTMLTooltip.js638
1 files changed, 638 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/tooltip/HTMLTooltip.js b/devtools/client/shared/widgets/tooltip/HTMLTooltip.js
new file mode 100644
index 000000000..749878220
--- /dev/null
+++ b/devtools/client/shared/widgets/tooltip/HTMLTooltip.js
@@ -0,0 +1,638 @@
+/* -*- 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 :
+ // <panel> <!-- XUL panel used to position the tooltip anywhere on screen -->
+ // <div> <!-- div wrapper used to isolate the tooltip container -->
+ // <div> <! the actual tooltip.container element -->
+ 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 = '<div class="tooltip-filler"></div>';
+ html += '<div class="tooltip-panel"></div>';
+
+ if (this.type === TYPE.ARROW) {
+ html += '<div class="tooltip-arrow"></div>';
+ }
+ 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);
+ }
+};