/* 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("resource://devtools/shared/event-emitter.js"); loader.lazyRequireGetter( this, "focusableSelector", "resource://devtools/client/shared/focus.js", true ); loader.lazyRequireGetter( this, "TooltipToggle", "resource://devtools/client/shared/widgets/tooltip/TooltipToggle.js", true ); loader.lazyRequireGetter( this, "listenOnce", "resource://devtools/shared/async-utils.js", true ); loader.lazyRequireGetter( this, "DevToolsUtils", "resource://devtools/shared/DevToolsUtils.js" ); 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", DOORHANGER: "doorhanger", }; module.exports.TYPE = TYPE; const ARROW_WIDTH = { normal: 0, arrow: 32, // This is the value calculated for the .tooltip-arrow element in tooltip.css // which includes the arrow width (20px) plus the extra margin added so that // the drop shadow is not cropped (2px each side). doorhanger: 24, }; const ARROW_OFFSET = { normal: 0, // Default offset between the tooltip's edge and the tooltip arrow. arrow: 20, // Match other Firefox menus which use 10px from edge (but subtract the 2px // margin included in the ARROW_WIDTH above). doorhanger: 8, }; const EXTRA_HEIGHT = { normal: 0, // The arrow is 16px tall, but merges on with the panel border arrow: 14, // The doorhanger arrow is 10px tall, but merges on 1px with the panel border doorhanger: 9, }; /** * 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". * @param {Number} offset * Offset between the top of the anchor and the tooltip. * @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 = ( anchorRect, viewportRect, height, pos, offset ) => { const { 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. const availableTop = anchorTop; const 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. const availableHeight = pos === TOP ? availableTop : availableBottom; height = Math.min(height, availableHeight - offset); // 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: Math.round(top), height: Math.round(height), computedPosition: pos, }; }; /** * Calculate the horizontal position & offsets to use for the tooltip. Will * attempt to respect the provided width and position preferences, unless the * available width 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 {DOMRect} windowRect * Bounding rectangle for the window. Used to determine which direction * doorhangers should hang. * @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 {Number} borderRadius * The border radius of the panel. This is added to ARROW_OFFSET to * calculate the distance from the edge of the tooltip to the start * of arrow. It is separate from ARROW_OFFSET since it will vary by * platform. * @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 = ( anchorRect, viewportRect, windowRect, width, type, offset, borderRadius, isRtl, isMenuTooltip ) => { // All tooltips from content should follow the writing direction. // // For tooltips (including doorhanger tooltips) we follow the writing // direction but for menus created using doorhangers the guidelines[1] say // that: // // "Doorhangers opening on the right side of the view show the directional // arrow on the right. // // Doorhangers opening on the left side of the view show the directional // arrow on the left. // // Never place the directional arrow at the center of doorhangers." // // [1] https://design.firefox.com/photon/components/doorhangers.html#directional-arrow // // So for those we need to check if the anchor is more right or left. let hangDirection; if (type === TYPE.DOORHANGER && isMenuTooltip) { const anchorCenter = anchorRect.left + anchorRect.width / 2; const viewCenter = windowRect.left + windowRect.width / 2; hangDirection = anchorCenter >= viewCenter ? "left" : "right"; } else { hangDirection = isRtl ? "left" : "right"; } const anchorWidth = anchorRect.width; // Calculate logical start of anchor relative to the viewport. const anchorStart = hangDirection === "right" ? anchorRect.left - viewportRect.left : viewportRect.right - anchorRect.right; // Calculate tooltip width. const tooltipWidth = Math.min(width, viewportRect.width); // Calculate tooltip start. let tooltipStart = anchorStart + offset; tooltipStart = Math.min(tooltipStart, viewportRect.width - tooltipWidth); tooltipStart = Math.max(0, tooltipStart); // Calculate arrow start (tooltip's start might be updated) const arrowWidth = ARROW_WIDTH[type]; let arrowStart; // Arrow and doorhanger style tooltips may need to be shifted if (type === TYPE.ARROW || type === TYPE.DOORHANGER) { const arrowOffset = ARROW_OFFSET[type] + borderRadius; // Where will the point of the arrow be if we apply the standard offset? const arrowCenter = tooltipStart + arrowOffset + arrowWidth / 2; // How does that compare to the center of the anchor? const anchorCenter = anchorStart + anchorWidth / 2; // If the anchor is too narrow, align the arrow and the anchor center. if (arrowCenter > anchorCenter) { tooltipStart = Math.max(0, tooltipStart - (arrowCenter - anchorCenter)); } // Arrow's start offset relative to the anchor. arrowStart = Math.min(arrowOffset, (anchorWidth - arrowWidth) / 2) | 0; // Translate the coordinate to tooltip container arrowStart += anchorStart - tooltipStart; // Make sure the arrow remains in the tooltip container. arrowStart = Math.min(arrowStart, tooltipWidth - arrowWidth - borderRadius); arrowStart = Math.max(arrowStart, borderRadius); } // Convert from logical coordinates to physical const left = hangDirection === "right" ? viewportRect.left + tooltipStart : viewportRect.right - tooltipStart - tooltipWidth; const arrowLeft = hangDirection === "right" ? arrowStart : tooltipWidth - arrowWidth - arrowStart; return { left: Math.round(left), width: Math.round(tooltipWidth), arrowLeft: Math.round(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) { // getBoxQuads is a non-standard WebAPI which will not work on non-firefox // browser when running launchpad on Chrome. if ( !node.getBoxQuads || !node.getBoxQuads({ relativeTo, createFramesForSuppressedWhitespace: false, })[0] ) { const { top, left, width, height } = node.getBoundingClientRect(); const right = left + width; const bottom = top + height; return { top, right, bottom, left, width, height }; } // Width and Height can be taken from the rect. const { width, height } = node.getBoundingClientRect(); const quadBounds = node .getBoxQuads({ relativeTo, createFramesForSuppressedWhitespace: false })[0] .getBounds(); const top = quadBounds.top; const left = quadBounds.left; // Compute right and bottom coordinates using the rest of the data. const right = left + width; const 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} className * A string separated list of classes to add to the tooltip container * element. * - {Boolean} consumeOutsideClicks * Defaults to true. The tooltip is closed when clicking outside. * Should this event be stopped and consumed or not. * - {String} id * The ID to assign to the tooltip container element. * - {Boolean} isMenuTooltip * Defaults to false. If the tooltip is a menu then this should be set * to true. * - {String} type * Display type of the tooltip. Possible values: "normal", "arrow", and * "doorhanger". * - {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. * - {Boolean} noAutoHide * Defaults to false. If this property is set to false or omitted, the * tooltip will automatically disappear after a few seconds. If this * attribute is set to true, this will not happen and the tooltip will * only hide when the user moves the mouse to another element. */ function HTMLTooltip( toolboxDoc, { className = "", consumeOutsideClicks = true, id = "", isMenuTooltip = false, type = "normal", useXulWrapper = false, noAutoHide = false, } = {} ) { EventEmitter.decorate(this); this.doc = toolboxDoc; this.id = id; this.className = className; this.type = type; this.noAutoHide = noAutoHide; // consumeOutsideClicks cannot be used if the tooltip is not closed on click this.consumeOutsideClicks = this.noAutoHide ? false : consumeOutsideClicks; this.isMenuTooltip = isMenuTooltip; this.useXulWrapper = this._isXULPopupAvailable() && useXulWrapper; this.preferredWidth = "auto"; this.preferredHeight = "auto"; // 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._onMouseup = this._onMouseup.bind(this); this._onXulPanelHidden = this._onXulPanelHidden.bind(this); this.container = this._createContainer(); if (this.useXulWrapper) { // When using a XUL panel as the wrapper, the actual markup for the tooltip is as // follows : // //
this.xulPanelWrapper = this._createXulPanelWrapper(); this.doc.documentElement.appendChild(this.xulPanelWrapper); this.xulPanelWrapper.appendChild(this.container); } else if (this._hasXULRootElement()) { 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. */ 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; }, get toggle() { if (!this._toggle) { this._toggle = new TooltipToggle(this); } return this._toggle; }, /** * Set the preferred width/height of the panel content. * The panel content is set by appending content to `this.panel`. * * @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 the preferred width. * - {Number} height: preferred height for the tooltip container. If * not specified the tooltip container will be measured before being * displayed, and the measured height will be used as the preferred * height. * * For tooltips whose content height may change while being * displayed, the special value Infinity may be used to produce * a flexible container that accommodates resizing content. Note, * however, that when used in combination with the XUL wrapper the * unfilled part of this container will consume all mouse events * making content behind this area inaccessible until the tooltip is * dismissed. */ setContentSize({ width = "auto", height = "auto" } = {}) { this.preferredWidth = width; this.preferredHeight = height; }, /** * Show the tooltip next to the provided anchor element, or update the tooltip position * if it was already visible. 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} options * Optional settings for positioning the tooltip. * @param {String} options.position * Optional, possible values: top|bottom * If layout permits, the tooltip will be displayed on top/bottom * of the anchor. If omitted, the tooltip will be displayed where * more space is available. * @param {Number} options.x * Optional, horizontal offset between the anchor and the tooltip. * @param {Number} options.y * Optional, vertical offset between the anchor and the tooltip. */ async show(anchor, options) { const { left, top } = this._updateContainerBounds(anchor, options); const isTooltipVisible = this.isVisible(); if (this.useXulWrapper) { if (!isTooltipVisible) { await this._showXulWrapperAt(left, top); } else { this._moveXulWrapperTo(left, top); } } else { this.container.style.left = left + "px"; this.container.style.top = top + "px"; } if (isTooltipVisible) { return; } 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; if (this.doc.defaultView) { if (!this._pendingEventListenerPromise) { // On Windows and Linux, if the tooltip is shown on mousedown/click (which is the // case for the MenuButton component for example), attaching the events listeners // on the window right away would trigger the callbacks; which means the tooltip // would be instantly hidden. To prevent such thing, the event listeners are set // on the next tick. this._pendingEventListenerPromise = new Promise(resolve => { this.doc.defaultView.setTimeout(() => { // Update the top window reference each time in case the host changes. this.topWindow = this._getTopWindow(); this.topWindow.addEventListener("click", this._onClick, true); this.topWindow.addEventListener("mouseup", this._onMouseup, true); resolve(); }, 0); }); } await this._pendingEventListenerPromise; this._pendingEventListenerPromise = null; } // This is redundant with tooltip-visible, and tooltip-visible // should only be added from here, after the click listener is set. // Otherwise, code listening to tooltip-visible may be firing a click that would be lost. // Unfortunately, doing this cause many non trivial test failures. this.container.classList.add("tooltip-shown"); this.emit("shown"); }, startTogglingOnHover(baseNode, targetNodeCb, options) { this.toggle.start(baseNode, targetNodeCb, options); }, stopTogglingOnHover() { this.toggle.stop(); }, _updateContainerBounds(anchor, { position, x = 0, y = 0 } = {}) { // Get anchor geometry let anchorRect = getRelativeRect(anchor, this.doc); if (this.useXulWrapper) { anchorRect = this._convertToScreenRect(anchorRect); } const { viewportRect, windowRect } = this._getBoundingRects(anchorRect); // Calculate the horizontal position and width let preferredWidth; // Record the height too since it might save us from having to look it up // later. let measuredHeight; const currentScrollTop = this.panel.scrollTop; if (this.preferredWidth === "auto") { // Reset any styles that constrain the dimensions we want to calculate. this.container.style.width = "auto"; if (this.preferredHeight === "auto") { this.container.style.height = "auto"; } ({ width: preferredWidth, height: measuredHeight } = this._measureContainerSize()); } else { preferredWidth = this.preferredWidth; } const anchorWin = anchor.ownerDocument.defaultView; const anchorCS = anchorWin.getComputedStyle(anchor); const isRtl = anchorCS.direction === "rtl"; let borderRadius = 0; if (this.type === TYPE.DOORHANGER) { borderRadius = parseFloat( anchorCS.getPropertyValue("--theme-arrowpanel-border-radius") ); if (Number.isNaN(borderRadius)) { borderRadius = 0; } } const { left, width, arrowLeft } = calculateHorizontalPosition( anchorRect, viewportRect, windowRect, preferredWidth, this.type, x, borderRadius, isRtl, this.isMenuTooltip ); // If we constrained the width, then any measured height we have is no // longer valid. if (measuredHeight && width !== preferredWidth) { measuredHeight = undefined; } // Apply width and arrow positioning this.container.style.width = width + "px"; if (this.type === TYPE.ARROW || this.type === TYPE.DOORHANGER) { this.arrow.style.left = arrowLeft + "px"; } // Work out how much vertical margin we have. // // This relies on us having set either .tooltip-top or .tooltip-bottom // and on the margins for both being symmetrical. Fortunately the call to // _measureContainerSize above will set .tooltip-top for us and it also // assumes these styles are symmetrical so this should be ok. const panelWindow = this.panel.ownerDocument.defaultView; const panelComputedStyle = panelWindow.getComputedStyle(this.panel); const verticalMargin = parseFloat(panelComputedStyle.marginTop) + parseFloat(panelComputedStyle.marginBottom); // Calculate the vertical position and height let preferredHeight; if (this.preferredHeight === "auto") { if (measuredHeight) { // We already have a valid height measured in a previous step. preferredHeight = measuredHeight; } else { this.container.style.height = "auto"; ({ height: preferredHeight } = this._measureContainerSize()); } preferredHeight += verticalMargin; } else { const themeHeight = EXTRA_HEIGHT[this.type] + verticalMargin; preferredHeight = this.preferredHeight + themeHeight; } const { top, height, computedPosition } = calculateVerticalPosition( anchorRect, viewportRect, preferredHeight, position, y ); this._position = computedPosition; const 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"; this.panel.scrollTop = currentScrollTop; return { left, top }; }, /** * Calculate the following boundary rectangles: * * - Viewport rect: This is the region that limits the tooltip dimensions. * When using a XUL panel wrapper, the tooltip will be able to use the whole * screen (excluding space reserved by the OS for toolbars etc.) and hence * the result will be in screen coordinates. * Otherwise, the tooltip is limited to the tooltip's document. * * - Window rect: This is the bounds of the view in which the tooltip is * presented. It is reported in the same coordinates as the viewport * rect and is used for determining in which direction a doorhanger-type * tooltip should "hang". * When using the XUL panel wrapper this will be the dimensions of the * window in screen coordinates. Otherwise it will be the same as the * viewport rect. * * @param {Object} anchorRect * DOMRect-like object of the target anchor element. * We need to pass this to detect the case when the anchor is not in * the current window (because, the center of the window is in * a different window to the anchor). * * @return {Object} An object with the following properties * viewportRect {Object} DOMRect-like object with the Number * properties: top, right, bottom, left, width, height * representing the viewport rect. * windowRect {Object} DOMRect-like object with the Number * properties: top, right, bottom, left, width, height * representing the window rect. */ _getBoundingRects(anchorRect) { let viewportRect; let windowRect; 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 const { availLeft, availTop, availHeight, availWidth } = this.doc.defaultView.screen; viewportRect = { top: availTop, right: availLeft + availWidth, bottom: availTop + availHeight, left: availLeft, width: availWidth, height: availHeight, }; const { screenX, screenY, outerWidth, outerHeight } = this.doc.defaultView; windowRect = { top: screenY, right: screenX + outerWidth, bottom: screenY + outerHeight, left: screenX, width: outerWidth, height: outerHeight, }; // If the anchor is outside the viewport, it possibly means we have a // multi-monitor environment where the anchor is displayed on a different // monitor to the "current" screen (as determined by the center of the // window). This can happen when, for example, the screen is spread across // two monitors. // // In this case we simply expand viewport in the direction of the anchor // so that we can still calculate the popup position correctly. if (anchorRect.left > viewportRect.right) { const diffWidth = windowRect.right - viewportRect.right; viewportRect.right += diffWidth; viewportRect.width += diffWidth; } if (anchorRect.right < viewportRect.left) { const diffWidth = viewportRect.left - windowRect.left; viewportRect.left -= diffWidth; viewportRect.width += diffWidth; } } else { viewportRect = windowRect = this.doc.documentElement.getBoundingClientRect(); } return { viewportRect, windowRect }; }, _measureContainerSize() { const 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"); // Set either of the tooltip-top or tooltip-bottom styles so that we get an // accurate height. We're assuming that the two styles will be symmetrical // and that we will clear this as necessary later. this.container.classList.add("tooltip-top"); this.container.classList.remove("tooltip-bottom"); const { width, height } = this.container.getBoundingClientRect(); this.container.classList.remove("tooltip-hidden"); if (this.useXulWrapper && !this.isVisible()) { xulParent.appendChild(this.container); } return { width, height }; }, /** * Hide the current tooltip. The event "hidden" will be fired when the tooltip * is hidden. */ async hide({ fromMouseup = false } = {}) { // Exit if the disable autohide setting is in effect or if hide() is called // from a mouseup event and the tooltip has noAutoHide set to true. if ( Services.prefs.getBoolPref("devtools.popup.disable_autohide", false) || (this.noAutoHide && this.isVisible() && fromMouseup) ) { return; } if (!this.isVisible()) { this.emit("hidden"); return; } // If the tooltip is hidden from a mouseup event, wait for a potential click event // to be consumed before removing event listeners. if (fromMouseup) { await new Promise(resolve => this.topWindow.setTimeout(resolve, 0)); } if (this._pendingEventListenerPromise) { this._pendingEventListenerPromise.then(() => this.removeEventListeners()); } else { this.removeEventListeners(); } this.container.classList.remove("tooltip-visible", "tooltip-shown"); if (this.useXulWrapper) { await this._hideXulWrapper(); } this.emit("hidden"); const tooltipHasFocus = this.container.contains(this.doc.activeElement); if (tooltipHasFocus && this._focusedElement) { this._focusedElement.focus(); this._focusedElement = null; } }, removeEventListeners() { this.topWindow.removeEventListener("click", this._onClick, true); this.topWindow.removeEventListener("mouseup", this._onMouseup, true); }, /** * Check if the tooltip is currently displayed. * @return {Boolean} true if the tooltip is visible */ isVisible() { return this.container.classList.contains("tooltip-visible"); }, /** * Destroy the tooltip instance. Hide the tooltip if displayed, remove the * tooltip container from the document. */ destroy() { this.hide(); this.removeEventListeners(); this.container.remove(); if (this.xulPanelWrapper) { this.xulPanelWrapper.remove(); } if (this._toggle) { this._toggle.destroy(); this._toggle = null; } }, _createContainer() { const container = this.doc.createElementNS(XHTML_NS, "div"); container.setAttribute("type", this.type); if (this.id) { container.setAttribute("id", this.id); } container.classList.add("tooltip-container"); if (this.className) { container.classList.add(...this.className.split(" ")); } const filler = this.doc.createElementNS(XHTML_NS, "div"); filler.classList.add("tooltip-filler"); container.appendChild(filler); const panel = this.doc.createElementNS(XHTML_NS, "div"); panel.classList.add("tooltip-panel"); container.appendChild(panel); if (this.type === TYPE.ARROW || this.type === TYPE.DOORHANGER) { const arrow = this.doc.createElementNS(XHTML_NS, "div"); arrow.classList.add("tooltip-arrow"); container.appendChild(arrow); } return container; }, _onClick(e) { if (this._isInTooltipContainer(e.target)) { return; } if (this.consumeOutsideClicks && e.button === 0) { // Consume only left click events (button === 0). e.preventDefault(); e.stopPropagation(); } }, /** * Hide the tooltip on mouseup rather than on click because the surrounding markup * may change on mousedown in a way that prevents a "click" event from being fired. * If the element that received the mousedown and the mouseup are different, click * will not be fired. */ _onMouseup(e) { if (this._isInTooltipContainer(e.target)) { return; } this.hide({ fromMouseup: true }); }, _isInTooltipContainer(node) { // Check if the target is the tooltip arrow. if (this.arrow && this.arrow === node) { return true; } if (typeof node.closest == "function" && node.closest("menupopup")) { // Ignore events from menupopup elements which will not be children of the // tooltip container even if their owner element is in the tooltip. // See Bug 1811002. return true; } const 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() { if (this.isVisible()) { this.hide(); } }, /** * Focus on the first focusable item in the tooltip. * * Returns true if we found something to focus on, false otherwise. */ focus() { const focusableElement = this.panel.querySelector(focusableSelector); if (focusableElement) { focusableElement.focus(); } return !!focusableElement; }, /** * Focus on the last focusable item in the tooltip. * * Returns true if we found something to focus on, false otherwise. */ focusEnd() { const focusableElements = this.panel.querySelectorAll(focusableSelector); if (focusableElements.length) { focusableElements[focusableElements.length - 1].focus(); } return focusableElements.length !== 0; }, _getTopWindow() { return DevToolsUtils.getTopWindow(this.doc.defaultView); }, /** * Check if the tooltip's owner document has XUL root element. */ _hasXULRootElement() { return this.doc.documentElement.namespaceURI === XUL_NS; }, _isXULPopupAvailable() { return this.doc.nodePrincipal.isSystemPrincipal; }, _createXulPanelWrapper() { const panel = this.doc.createXULElement("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("incontentshell", false); panel.setAttribute("noautofocus", true); panel.setAttribute("noautohide", this.noAutoHide); panel.setAttribute("ignorekeys", true); panel.setAttribute("tooltip", "aHTMLTooltip"); // Use type="arrow" to prevent side effects (see Bug 1285206) panel.setAttribute("type", "arrow"); panel.setAttribute("tooltip-type", this.type); panel.setAttribute("flip", "none"); panel.setAttribute("level", "top"); panel.setAttribute("class", "tooltip-xul-wrapper"); // Stop this appearing as an alert to accessibility. panel.setAttribute("role", "presentation"); return panel; }, _showXulWrapperAt(left, top) { this.xulPanelWrapper.addEventListener( "popuphidden", this._onXulPanelHidden ); const onPanelShown = listenOnce(this.xulPanelWrapper, "popupshown"); this.xulPanelWrapper.openPopupAtScreen(left, top, false); return onPanelShown; }, _moveXulWrapperTo(left, top) { // FIXME: moveTo should probably account for margins when called from // script. Our current shadow set-up only supports one margin, so it's fine // to use the margin top in both directions. const margin = parseFloat( this.xulPanelWrapper.ownerGlobal.getComputedStyle(this.xulPanelWrapper) .marginTop ); this.xulPanelWrapper.moveTo(left + margin, top + margin); }, _hideXulWrapper() { this.xulPanelWrapper.removeEventListener( "popuphidden", this._onXulPanelHidden ); if (this.xulPanelWrapper.state === "closed") { // XUL panel is already closed, resolve immediately. return Promise.resolve(); } const 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({ 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, }; }, };