tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

HTMLTooltip.js (37126B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 "use strict";
      6 
      7 const EventEmitter = require("resource://devtools/shared/event-emitter.js");
      8 
      9 const lazy = {};
     10 ChromeUtils.defineESModuleGetters(lazy, {
     11  focusableSelector: "resource://devtools/client/shared/focus.mjs",
     12 });
     13 
     14 loader.lazyRequireGetter(
     15  this,
     16  "TooltipToggle",
     17  "resource://devtools/client/shared/widgets/tooltip/TooltipToggle.js",
     18  true
     19 );
     20 loader.lazyRequireGetter(
     21  this,
     22  "listenOnce",
     23  "resource://devtools/shared/async-utils.js",
     24  true
     25 );
     26 loader.lazyRequireGetter(
     27  this,
     28  "DevToolsUtils",
     29  "resource://devtools/shared/DevToolsUtils.js"
     30 );
     31 
     32 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
     33 const XHTML_NS = "http://www.w3.org/1999/xhtml";
     34 
     35 const POSITION = {
     36  TOP: "top",
     37  BOTTOM: "bottom",
     38 };
     39 
     40 module.exports.POSITION = POSITION;
     41 
     42 const TYPE = {
     43  NORMAL: "normal",
     44  ARROW: "arrow",
     45  DOORHANGER: "doorhanger",
     46 };
     47 
     48 module.exports.TYPE = TYPE;
     49 
     50 const ARROW_WIDTH = {
     51  normal: 0,
     52  arrow: 32,
     53  // This is the value calculated for the .tooltip-arrow element in tooltip.css
     54  // which includes the arrow width (20px) plus the extra margin added so that
     55  // the drop shadow is not cropped (2px each side).
     56  doorhanger: 24,
     57 };
     58 
     59 const ARROW_OFFSET = {
     60  normal: 0,
     61  // Default offset between the tooltip's edge and the tooltip arrow.
     62  arrow: 20,
     63  // Match other Firefox menus which use 10px from edge (but subtract the 2px
     64  // margin included in the ARROW_WIDTH above).
     65  doorhanger: 8,
     66 };
     67 
     68 const EXTRA_HEIGHT = {
     69  normal: 0,
     70  // The arrow is 16px tall, but merges on with the panel border
     71  arrow: 14,
     72  // The doorhanger arrow is 10px tall, but merges on 1px with the panel border
     73  doorhanger: 9,
     74 };
     75 
     76 /**
     77 * Calculate the vertical position & offsets to use for the tooltip. Will attempt to
     78 * respect the provided height and position preferences, unless the available height
     79 * prevents this.
     80 *
     81 * @param {DOMRect} anchorRect
     82 *        Bounding rectangle for the anchor, relative to the tooltip document.
     83 * @param {DOMRect} viewportRect
     84 *        Bounding rectangle for the viewport. top/left can be different from 0 if some
     85 *        space should not be used by tooltips (for instance OS toolbars, taskbars etc.).
     86 * @param {number} height
     87 *        Preferred height for the tooltip.
     88 * @param {string} pos
     89 *        Preferred position for the tooltip. Possible values: "top" or "bottom".
     90 * @param {number} offset
     91 *        Offset between the top of the anchor and the tooltip.
     92 * @return {object}
     93 *         - {Number} top: the top offset for the tooltip.
     94 *         - {Number} height: the height to use for the tooltip container.
     95 *         - {String} computedPosition: Can differ from the preferred position depending
     96 *           on the available height). "top" or "bottom"
     97 */
     98 const calculateVerticalPosition = (
     99  anchorRect,
    100  viewportRect,
    101  height,
    102  pos,
    103  offset
    104 ) => {
    105  const { TOP, BOTTOM } = POSITION;
    106 
    107  let { top: anchorTop, height: anchorHeight } = anchorRect;
    108 
    109  // Translate to the available viewport space before calculating dimensions and position.
    110  anchorTop -= viewportRect.top;
    111 
    112  // Calculate available space for the tooltip.
    113  const availableTop = anchorTop;
    114  const availableBottom = viewportRect.height - (anchorTop + anchorHeight);
    115 
    116  // Find POSITION
    117  let keepPosition = false;
    118  if (pos === TOP) {
    119    keepPosition = availableTop >= height + offset;
    120  } else if (pos === BOTTOM) {
    121    keepPosition = availableBottom >= height + offset;
    122  }
    123  if (!keepPosition) {
    124    pos = availableTop > availableBottom ? TOP : BOTTOM;
    125  }
    126 
    127  // Calculate HEIGHT.
    128  const availableHeight = pos === TOP ? availableTop : availableBottom;
    129  height = Math.min(height, availableHeight - offset);
    130 
    131  // Calculate TOP.
    132  let top =
    133    pos === TOP
    134      ? anchorTop - height - offset
    135      : anchorTop + anchorHeight + offset;
    136 
    137  // Translate back to absolute coordinates by re-including viewport top margin.
    138  top += viewportRect.top;
    139 
    140  return {
    141    top: Math.round(top),
    142    height: Math.round(height),
    143    computedPosition: pos,
    144  };
    145 };
    146 
    147 /**
    148 * Calculate the horizontal position & offsets to use for the tooltip. Will
    149 * attempt to respect the provided width and position preferences, unless the
    150 * available width prevents this.
    151 *
    152 * @param {DOMRect} anchorRect
    153 *        Bounding rectangle for the anchor, relative to the tooltip document.
    154 * @param {DOMRect} viewportRect
    155 *        Bounding rectangle for the viewport. top/left can be different from
    156 *        0 if some space should not be used by tooltips (for instance OS
    157 *        toolbars, taskbars etc.).
    158 * @param {DOMRect} windowRect
    159 *        Bounding rectangle for the window. Used to determine which direction
    160 *        doorhangers should hang.
    161 * @param {number} width
    162 *        Preferred width for the tooltip.
    163 * @param {string} type
    164 *        The tooltip type (e.g. "arrow").
    165 * @param {number} offset
    166 *        Horizontal offset in pixels.
    167 * @param {number} borderRadius
    168 *        The border radius of the panel. This is added to ARROW_OFFSET to
    169 *        calculate the distance from the edge of the tooltip to the start
    170 *        of arrow. It is separate from ARROW_OFFSET since it will vary by
    171 *        platform.
    172 * @param {boolean} isRtl
    173 *        If the anchor is in RTL, the tooltip should be aligned to the right.
    174 * @return {object}
    175 *         - {Number} left: the left offset for the tooltip.
    176 *         - {Number} width: the width to use for the tooltip container.
    177 *         - {Number} arrowLeft: the left offset to use for the arrow element.
    178 */
    179 const calculateHorizontalPosition = (
    180  anchorRect,
    181  viewportRect,
    182  windowRect,
    183  width,
    184  type,
    185  offset,
    186  borderRadius,
    187  isRtl,
    188  isMenuTooltip
    189 ) => {
    190  // All tooltips from content should follow the writing direction.
    191  //
    192  // For tooltips (including doorhanger tooltips) we follow the writing
    193  // direction but for menus created using doorhangers the guidelines[1] say
    194  // that:
    195  //
    196  //   "Doorhangers opening on the right side of the view show the directional
    197  //   arrow on the right.
    198  //
    199  //   Doorhangers opening on the left side of the view show the directional
    200  //   arrow on the left.
    201  //
    202  //   Never place the directional arrow at the center of doorhangers."
    203  //
    204  // [1] https://design.firefox.com/photon/components/doorhangers.html#directional-arrow
    205  //
    206  // So for those we need to check if the anchor is more right or left.
    207  let hangDirection;
    208  if (type === TYPE.DOORHANGER && isMenuTooltip) {
    209    const anchorCenter = anchorRect.left + anchorRect.width / 2;
    210    const viewCenter = windowRect.left + windowRect.width / 2;
    211    hangDirection = anchorCenter >= viewCenter ? "left" : "right";
    212  } else {
    213    hangDirection = isRtl ? "left" : "right";
    214  }
    215 
    216  const anchorWidth = anchorRect.width;
    217 
    218  // Calculate logical start of anchor relative to the viewport.
    219  const anchorStart =
    220    hangDirection === "right"
    221      ? anchorRect.left - viewportRect.left
    222      : viewportRect.right - anchorRect.right;
    223 
    224  // Calculate tooltip width.
    225  const tooltipWidth = Math.min(width, viewportRect.width);
    226 
    227  // Calculate tooltip start.
    228  let tooltipStart = anchorStart + offset;
    229  tooltipStart = Math.min(tooltipStart, viewportRect.width - tooltipWidth);
    230  tooltipStart = Math.max(0, tooltipStart);
    231 
    232  // Calculate arrow start (tooltip's start might be updated)
    233  const arrowWidth = ARROW_WIDTH[type];
    234  let arrowStart;
    235  // Arrow and doorhanger style tooltips may need to be shifted
    236  if (type === TYPE.ARROW || type === TYPE.DOORHANGER) {
    237    const arrowOffset = ARROW_OFFSET[type] + borderRadius;
    238 
    239    // Where will the point of the arrow be if we apply the standard offset?
    240    const arrowCenter = tooltipStart + arrowOffset + arrowWidth / 2;
    241 
    242    // How does that compare to the center of the anchor?
    243    const anchorCenter = anchorStart + anchorWidth / 2;
    244 
    245    // If the anchor is too narrow, align the arrow and the anchor center.
    246    if (arrowCenter > anchorCenter) {
    247      tooltipStart = Math.max(0, tooltipStart - (arrowCenter - anchorCenter));
    248    }
    249    // Arrow's start offset relative to the anchor.
    250    arrowStart = Math.min(arrowOffset, (anchorWidth - arrowWidth) / 2) | 0;
    251    // Translate the coordinate to tooltip container
    252    arrowStart += anchorStart - tooltipStart;
    253    // Make sure the arrow remains in the tooltip container.
    254    arrowStart = Math.min(arrowStart, tooltipWidth - arrowWidth - borderRadius);
    255    arrowStart = Math.max(arrowStart, borderRadius);
    256  }
    257 
    258  // Convert from logical coordinates to physical
    259  const left =
    260    hangDirection === "right"
    261      ? viewportRect.left + tooltipStart
    262      : viewportRect.right - tooltipStart - tooltipWidth;
    263  const arrowLeft =
    264    hangDirection === "right"
    265      ? arrowStart
    266      : tooltipWidth - arrowWidth - arrowStart;
    267 
    268  return {
    269    left: Math.round(left),
    270    width: Math.round(tooltipWidth),
    271    arrowLeft: Math.round(arrowLeft),
    272  };
    273 };
    274 
    275 /**
    276 * Get the bounding client rectangle for a given node, relative to a custom
    277 * reference element (instead of the default for getBoundingClientRect which
    278 * is always the element's ownerDocument).
    279 */
    280 const getRelativeRect = function (node, relativeTo) {
    281  // getBoxQuads is a non-standard WebAPI which will not work on non-firefox
    282  // browser when running launchpad on Chrome.
    283  if (
    284    !node.getBoxQuads ||
    285    !node.getBoxQuads({
    286      relativeTo,
    287      createFramesForSuppressedWhitespace: false,
    288    })[0]
    289  ) {
    290    const { top, left, width, height } = node.getBoundingClientRect();
    291    const right = left + width;
    292    const bottom = top + height;
    293    return { top, right, bottom, left, width, height };
    294  }
    295 
    296  // Width and Height can be taken from the rect.
    297  const { width, height } = node.getBoundingClientRect();
    298 
    299  const quadBounds = node
    300    .getBoxQuads({ relativeTo, createFramesForSuppressedWhitespace: false })[0]
    301    .getBounds();
    302  const top = quadBounds.top;
    303  const left = quadBounds.left;
    304 
    305  // Compute right and bottom coordinates using the rest of the data.
    306  const right = left + width;
    307  const bottom = top + height;
    308 
    309  return { top, right, bottom, left, width, height };
    310 };
    311 
    312 /**
    313 * The HTMLTooltip can display HTML content in a tooltip popup.
    314 */
    315 class HTMLTooltip {
    316  /**
    317   * @param {Document} toolboxDoc
    318   *        The toolbox document to attach the HTMLTooltip popup.
    319   * @param {object} [options={}]
    320   * @param {string} [options.className=""]
    321   *          A string separated list of classes to add to the tooltip container
    322   *          element.
    323   * @param {boolean} [options.consumeOutsideClicks=true]
    324   *          Defaults to true. The tooltip is closed when clicking outside.
    325   *          Should this event be stopped and consumed or not.
    326   * @param {string} [options.id=""]
    327   *          The ID to assign to the tooltip container element.
    328   * @param {boolean} [options.isMenuTooltip=false]
    329   *          Defaults to false. If the tooltip is a menu then this should be set
    330   *          to true.
    331   * @param {string} [options.type="normal"]
    332   *          Display type of the tooltip. Possible values: "normal", "arrow", and
    333   *          "doorhanger".
    334   * @param {boolean} [options.useXulWrapper=false]
    335   *          Defaults to false. If the tooltip is hosted in a XUL document, use a
    336   *          XUL panel in order to use all the screen viewport available.
    337   * @param {boolean} [options.noAutoHide=false]
    338   *          Defaults to false. If this property is set to false or omitted, the
    339   *          tooltip will automatically disappear after a few seconds. If this
    340   *          attribute is set to true, this will not happen and the tooltip will
    341   *          only hide when the user moves the mouse to another element.
    342   */
    343  constructor(
    344    toolboxDoc,
    345    {
    346      className = "",
    347      consumeOutsideClicks = true,
    348      id = "",
    349      isMenuTooltip = false,
    350      type = "normal",
    351      useXulWrapper = false,
    352      noAutoHide = false,
    353    } = {}
    354  ) {
    355    EventEmitter.decorate(this);
    356 
    357    this.doc = toolboxDoc;
    358    this.id = id;
    359    this.className = className;
    360    this.type = type;
    361    this.noAutoHide = noAutoHide;
    362    // consumeOutsideClicks cannot be used if the tooltip is not closed on click
    363    this.consumeOutsideClicks = this.noAutoHide ? false : consumeOutsideClicks;
    364    this.isMenuTooltip = isMenuTooltip;
    365    this.useXulWrapper = this._isXULPopupAvailable() && useXulWrapper;
    366    this.preferredWidth = "auto";
    367    this.preferredHeight = "auto";
    368 
    369    // The top window is used to attach click event listeners to close the tooltip if the
    370    // user clicks on the content page.
    371    this.topWindow = this._getTopWindow();
    372 
    373    this._position = null;
    374 
    375    this._onClick = this._onClick.bind(this);
    376    this._onMouseup = this._onMouseup.bind(this);
    377    this._onXulPanelHidden = this._onXulPanelHidden.bind(this);
    378 
    379    this.container = this._createContainer();
    380    if (this.useXulWrapper) {
    381      // When using a XUL panel as the wrapper, the actual markup for the tooltip is as
    382      // follows :
    383      // <panel> <!-- XUL panel used to position the tooltip anywhere on screen -->
    384      //   <div> <! the actual tooltip-container element -->
    385      this.xulPanelWrapper = this._createXulPanelWrapper();
    386      this.doc.documentElement.appendChild(this.xulPanelWrapper);
    387      this.xulPanelWrapper.appendChild(this.container);
    388    } else if (this._hasXULRootElement()) {
    389      this.doc.documentElement.appendChild(this.container);
    390    } else {
    391      // In non-XUL context the container is ready to use as is.
    392      this.doc.body.appendChild(this.container);
    393    }
    394  }
    395 
    396  /**
    397   * The tooltip panel is the parentNode of the tooltip content.
    398   */
    399  get panel() {
    400    return this.container.querySelector(".tooltip-panel");
    401  }
    402 
    403  /**
    404   * The arrow element. Might be null depending on the tooltip type.
    405   */
    406  get arrow() {
    407    return this.container.querySelector(".tooltip-arrow");
    408  }
    409 
    410  /**
    411   * Retrieve the displayed position used for the tooltip. Null if the tooltip is hidden.
    412   */
    413  get position() {
    414    return this.isVisible() ? this._position : null;
    415  }
    416 
    417  get toggle() {
    418    if (!this._toggle) {
    419      this._toggle = new TooltipToggle(this);
    420    }
    421 
    422    return this._toggle;
    423  }
    424 
    425  /**
    426   * Set the preferred width/height of the panel content.
    427   * The panel content is set by appending content to `this.panel`.
    428   *
    429   * @param {object}
    430   *        - {Number} width: preferred width for the tooltip container. If not specified
    431   *          the tooltip container will be measured before being displayed, and the
    432   *          measured width will be used as the preferred width.
    433   *        - {Number} height: preferred height for the tooltip container. If
    434   *          not specified the tooltip container will be measured before being
    435   *          displayed, and the measured height will be used as the preferred
    436   *          height.
    437   *
    438   *          For tooltips whose content height may change while being
    439   *          displayed, the special value Infinity may be used to produce
    440   *          a flexible container that accommodates resizing content. Note,
    441   *          however, that when used in combination with the XUL wrapper the
    442   *          unfilled part of this container will consume all mouse events
    443   *          making content behind this area inaccessible until the tooltip is
    444   *          dismissed.
    445   */
    446  setContentSize({ width = "auto", height = "auto" } = {}) {
    447    this.preferredWidth = width;
    448    this.preferredHeight = height;
    449  }
    450 
    451  /**
    452   * Update the HTMLTooltip content with a HTMLFragment using fluent for
    453   * localization purposes. Force translation early before measuring the tooltip
    454   * dimensions.
    455   *
    456   * @param {HTMLFragment} fragment
    457   *     The HTMLFragment to use as tooltip content
    458   * @param {object} contentSizeOptions
    459   *     See setContentSize().
    460   */
    461  async setLocalizedFragment(fragment, contentSizeOptions) {
    462    this.panel.innerHTML = "";
    463 
    464    // Because Fluent is async we need to manually translate the fragment and
    465    // then insert it into the tooltip. This is needed in order for the tooltip
    466    // to size to the contents properly and for tests.
    467    await this.doc.l10n.translateFragment(fragment);
    468    this.doc.l10n.pauseObserving();
    469    this.panel.append(fragment);
    470    this.doc.l10n.resumeObserving();
    471 
    472    this.setContentSize(contentSizeOptions);
    473  }
    474 
    475  /**
    476   * Show the tooltip next to the provided anchor element, or update the tooltip position
    477   * if it was already visible. A preferred position can be set.
    478   * The event "shown" will be fired after the tooltip is displayed.
    479   *
    480   * @param {Element} anchor
    481   *        The reference element with which the tooltip should be aligned
    482   * @param {object} options
    483   *        Optional settings for positioning the tooltip.
    484   * @param {string} options.position
    485   *        Optional, possible values: top|bottom
    486   *        If layout permits, the tooltip will be displayed on top/bottom
    487   *        of the anchor. If omitted, the tooltip will be displayed where
    488   *        more space is available.
    489   * @param {number} options.x
    490   *        Optional, horizontal offset between the anchor and the tooltip.
    491   * @param {number} options.y
    492   *        Optional, vertical offset between the anchor and the tooltip.
    493   */
    494  async show(anchor, options) {
    495    const { left, top } = this._updateContainerBounds(anchor, options);
    496    const isTooltipVisible = this.isVisible();
    497 
    498    if (this.useXulWrapper) {
    499      if (!isTooltipVisible) {
    500        await this._showXulWrapperAt(left, top);
    501      } else {
    502        this._moveXulWrapperTo(left, top);
    503      }
    504    } else {
    505      this.container.style.left = left + "px";
    506      this.container.style.top = top + "px";
    507    }
    508 
    509    if (isTooltipVisible) {
    510      return;
    511    }
    512 
    513    this.container.classList.add("tooltip-visible");
    514 
    515    // Keep a pointer on the focused element to refocus it when hiding the tooltip.
    516    this._focusedElement = anchor.ownerDocument.activeElement;
    517 
    518    if (this.doc.defaultView) {
    519      if (!this._pendingEventListenerPromise) {
    520        // On Windows and Linux, if the tooltip is shown on mousedown/click (which is the
    521        // case for the MenuButton component for example), attaching the events listeners
    522        // on the window right away would trigger the callbacks; which means the tooltip
    523        // would be instantly hidden. To prevent such thing, the event listeners are set
    524        // on the next tick.
    525        this._pendingEventListenerPromise = new Promise(resolve => {
    526          this.doc.defaultView.setTimeout(() => {
    527            // Update the top window reference each time in case the host changes.
    528            this.topWindow = this._getTopWindow();
    529            this.topWindow.addEventListener("click", this._onClick, true);
    530            this.topWindow.addEventListener("mouseup", this._onMouseup, true);
    531            resolve();
    532          }, 0);
    533        });
    534      }
    535 
    536      await this._pendingEventListenerPromise;
    537      this._pendingEventListenerPromise = null;
    538    }
    539 
    540    // This is redundant with tooltip-visible, and tooltip-visible
    541    // should only be added from here, after the click listener is set.
    542    // Otherwise, code listening to tooltip-visible may be firing a click that would be lost.
    543    // Unfortunately, doing this cause many non trivial test failures.
    544    this.container.classList.add("tooltip-shown");
    545 
    546    this.emit("shown");
    547  }
    548 
    549  startTogglingOnHover(baseNode, targetNodeCb, options) {
    550    this.toggle.start(baseNode, targetNodeCb, options);
    551  }
    552 
    553  stopTogglingOnHover() {
    554    this.toggle.stop();
    555  }
    556 
    557  _updateContainerBounds(anchor, { position, x = 0, y = 0 } = {}) {
    558    // Get anchor geometry
    559    let anchorRect = getRelativeRect(anchor, this.doc);
    560    if (this.useXulWrapper) {
    561      anchorRect = this._convertToScreenRect(anchorRect);
    562    }
    563 
    564    const { viewportRect, windowRect } = this._getBoundingRects(anchorRect);
    565 
    566    // Calculate the horizontal position and width
    567    let preferredWidth;
    568    // Record the height too since it might save us from having to look it up
    569    // later.
    570    let measuredHeight;
    571    const currentScrollTop = this.panel.scrollTop;
    572    if (this.preferredWidth === "auto") {
    573      // Reset any styles that constrain the dimensions we want to calculate.
    574      this.container.style.width = "auto";
    575      if (this.preferredHeight === "auto") {
    576        this.container.style.height = "auto";
    577      }
    578      ({ width: preferredWidth, height: measuredHeight } =
    579        this._measureContainerSize());
    580    } else {
    581      preferredWidth = this.preferredWidth;
    582    }
    583 
    584    const anchorWin = anchor.ownerDocument.defaultView;
    585    const anchorCS = anchorWin.getComputedStyle(anchor);
    586    const isRtl = anchorCS.direction === "rtl";
    587 
    588    let borderRadius = 0;
    589    if (this.type === TYPE.DOORHANGER) {
    590      borderRadius = parseFloat(
    591        anchorCS.getPropertyValue("--theme-arrowpanel-border-radius")
    592      );
    593      if (Number.isNaN(borderRadius)) {
    594        borderRadius = 0;
    595      }
    596    }
    597 
    598    const { left, width, arrowLeft } = calculateHorizontalPosition(
    599      anchorRect,
    600      viewportRect,
    601      windowRect,
    602      preferredWidth,
    603      this.type,
    604      x,
    605      borderRadius,
    606      isRtl,
    607      this.isMenuTooltip
    608    );
    609 
    610    // If we constrained the width, then any measured height we have is no
    611    // longer valid.
    612    if (measuredHeight && width !== preferredWidth) {
    613      measuredHeight = undefined;
    614    }
    615 
    616    // Apply width and arrow positioning
    617    this.container.style.width = width + "px";
    618    if (this.type === TYPE.ARROW || this.type === TYPE.DOORHANGER) {
    619      this.arrow.style.left = arrowLeft + "px";
    620    }
    621 
    622    // Work out how much vertical margin we have.
    623    //
    624    // This relies on us having set either .tooltip-top or .tooltip-bottom
    625    // and on the margins for both being symmetrical. Fortunately the call to
    626    // _measureContainerSize above will set .tooltip-top for us and it also
    627    // assumes these styles are symmetrical so this should be ok.
    628    const panelWindow = this.panel.ownerDocument.defaultView;
    629    const panelComputedStyle = panelWindow.getComputedStyle(this.panel);
    630    const verticalMargin =
    631      parseFloat(panelComputedStyle.marginTop) +
    632      parseFloat(panelComputedStyle.marginBottom);
    633 
    634    // Calculate the vertical position and height
    635    let preferredHeight;
    636    if (this.preferredHeight === "auto") {
    637      if (measuredHeight) {
    638        // We already have a valid height measured in a previous step.
    639        preferredHeight = measuredHeight;
    640      } else {
    641        this.container.style.height = "auto";
    642        ({ height: preferredHeight } = this._measureContainerSize());
    643      }
    644      preferredHeight += verticalMargin;
    645    } else {
    646      const themeHeight = EXTRA_HEIGHT[this.type] + verticalMargin;
    647      preferredHeight = this.preferredHeight + themeHeight;
    648    }
    649 
    650    const { top, height, computedPosition } = calculateVerticalPosition(
    651      anchorRect,
    652      viewportRect,
    653      preferredHeight,
    654      position,
    655      y
    656    );
    657 
    658    this._position = computedPosition;
    659    const isTop = computedPosition === POSITION.TOP;
    660    this.container.classList.toggle("tooltip-top", isTop);
    661    this.container.classList.toggle("tooltip-bottom", !isTop);
    662 
    663    // If the preferred height is set to Infinity, the tooltip container should grow based
    664    // on its content's height and use as much height as possible.
    665    this.container.classList.toggle(
    666      "tooltip-flexible-height",
    667      this.preferredHeight === Infinity
    668    );
    669 
    670    this.container.style.height = height + "px";
    671    this.panel.scrollTop = currentScrollTop;
    672 
    673    return { left, top };
    674  }
    675 
    676  /**
    677   * Calculate the following boundary rectangles:
    678   *
    679   * - Viewport rect: This is the region that limits the tooltip dimensions.
    680   *   When using a XUL panel wrapper, the tooltip will be able to use the whole
    681   *   screen (excluding space reserved by the OS for toolbars etc.) and hence
    682   *   the result will be in screen coordinates.
    683   *   Otherwise, the tooltip is limited to the tooltip's document.
    684   *
    685   * - Window rect: This is the bounds of the view in which the tooltip is
    686   *   presented. It is reported in the same coordinates as the viewport
    687   *   rect and is used for determining in which direction a doorhanger-type
    688   *   tooltip should "hang".
    689   *   When using the XUL panel wrapper this will be the dimensions of the
    690   *   window in screen coordinates. Otherwise it will be the same as the
    691   *   viewport rect.
    692   *
    693   * @param {object} anchorRect
    694   *        DOMRect-like object of the target anchor element.
    695   *        We need to pass this to detect the case when the anchor is not in
    696   *        the current window (because, the center of the window is in
    697   *        a different window to the anchor).
    698   *
    699   * @return {object} An object with the following properties
    700   *         viewportRect {Object} DOMRect-like object with the Number
    701   *                      properties: top, right, bottom, left, width, height
    702   *                      representing the viewport rect.
    703   *         windowRect   {Object} DOMRect-like object with the Number
    704   *                      properties: top, right, bottom, left, width, height
    705   *                      representing the window rect.
    706   */
    707  _getBoundingRects(anchorRect) {
    708    let viewportRect;
    709    let windowRect;
    710 
    711    if (this.useXulWrapper) {
    712      // availLeft/Top are the coordinates first pixel available on the screen
    713      // for applications (excluding space dedicated for OS toolbars, menus
    714      // etc...)
    715      // availWidth/Height are the dimensions available to applications
    716      // excluding all the OS reserved space
    717      const { availLeft, availTop, availHeight, availWidth } =
    718        this.doc.defaultView.screen;
    719      viewportRect = {
    720        top: availTop,
    721        right: availLeft + availWidth,
    722        bottom: availTop + availHeight,
    723        left: availLeft,
    724        width: availWidth,
    725        height: availHeight,
    726      };
    727 
    728      const { screenX, screenY, outerWidth, outerHeight } =
    729        this.doc.defaultView;
    730      windowRect = {
    731        top: screenY,
    732        right: screenX + outerWidth,
    733        bottom: screenY + outerHeight,
    734        left: screenX,
    735        width: outerWidth,
    736        height: outerHeight,
    737      };
    738 
    739      // If the anchor is outside the viewport, it possibly means we have a
    740      // multi-monitor environment where the anchor is displayed on a different
    741      // monitor to the "current" screen (as determined by the center of the
    742      // window). This can happen when, for example, the screen is spread across
    743      // two monitors.
    744      //
    745      // In this case we simply expand viewport in the direction of the anchor
    746      // so that we can still calculate the popup position correctly.
    747      if (anchorRect.left > viewportRect.right) {
    748        const diffWidth = windowRect.right - viewportRect.right;
    749        viewportRect.right += diffWidth;
    750        viewportRect.width += diffWidth;
    751      }
    752      if (anchorRect.right < viewportRect.left) {
    753        const diffWidth = viewportRect.left - windowRect.left;
    754        viewportRect.left -= diffWidth;
    755        viewportRect.width += diffWidth;
    756      }
    757    } else {
    758      viewportRect = windowRect =
    759        this.doc.documentElement.getBoundingClientRect();
    760    }
    761 
    762    return { viewportRect, windowRect };
    763  }
    764 
    765  _measureContainerSize() {
    766    const xulParent = this.container.parentNode;
    767    if (this.useXulWrapper && !this.isVisible()) {
    768      // Move the container out of the XUL Panel to measure it.
    769      this.doc.documentElement.appendChild(this.container);
    770    }
    771 
    772    this.container.classList.add("tooltip-hidden");
    773    // Set either of the tooltip-top or tooltip-bottom styles so that we get an
    774    // accurate height. We're assuming that the two styles will be symmetrical
    775    // and that we will clear this as necessary later.
    776    this.container.classList.add("tooltip-top");
    777    this.container.classList.remove("tooltip-bottom");
    778    const { width, height } = this.container.getBoundingClientRect();
    779    this.container.classList.remove("tooltip-hidden");
    780 
    781    if (this.useXulWrapper && !this.isVisible()) {
    782      xulParent.appendChild(this.container);
    783    }
    784 
    785    return { width, height };
    786  }
    787 
    788  /**
    789   * Hide the current tooltip. The event "hidden" will be fired when the tooltip
    790   * is hidden.
    791   */
    792  async hide({ fromMouseup = false } = {}) {
    793    // Exit if the disable autohide setting is in effect or if hide() is called
    794    // from a mouseup event and the tooltip has noAutoHide set to true.
    795    if (
    796      Services.prefs.getBoolPref("devtools.popup.disable_autohide", false) ||
    797      (this.noAutoHide && this.isVisible() && fromMouseup)
    798    ) {
    799      return;
    800    }
    801 
    802    if (!this.isVisible()) {
    803      this.emit("hidden");
    804      return;
    805    }
    806 
    807    // If the tooltip is hidden from a mouseup event, wait for a potential click event
    808    // to be consumed before removing event listeners.
    809    if (fromMouseup) {
    810      await new Promise(resolve => this.topWindow.setTimeout(resolve, 0));
    811    }
    812 
    813    if (this._pendingEventListenerPromise) {
    814      this._pendingEventListenerPromise.then(() => this.removeEventListeners());
    815    } else {
    816      this.removeEventListeners();
    817    }
    818 
    819    this.container.classList.remove("tooltip-visible", "tooltip-shown");
    820    if (this.useXulWrapper) {
    821      await this._hideXulWrapper();
    822    }
    823 
    824    this.emit("hidden");
    825 
    826    const tooltipHasFocus =
    827      this.doc.hasFocus() && this.container.contains(this.doc.activeElement);
    828    if (tooltipHasFocus && this._focusedElement) {
    829      this._focusedElement.focus();
    830      this._focusedElement = null;
    831    }
    832  }
    833 
    834  removeEventListeners() {
    835    this.topWindow.removeEventListener("click", this._onClick, true);
    836    this.topWindow.removeEventListener("mouseup", this._onMouseup, true);
    837  }
    838 
    839  /**
    840   * Check if the tooltip is currently displayed.
    841   *
    842   * @return {boolean} true if the tooltip is visible
    843   */
    844  isVisible() {
    845    return this.container.classList.contains("tooltip-visible");
    846  }
    847 
    848  /**
    849   * Destroy the tooltip instance. Hide the tooltip if displayed, remove the
    850   * tooltip container from the document.
    851   */
    852  destroy() {
    853    this.hide();
    854    this.removeEventListeners();
    855    this.container.remove();
    856    if (this.xulPanelWrapper) {
    857      this.xulPanelWrapper.remove();
    858    }
    859    if (this._toggle) {
    860      this._toggle.destroy();
    861      this._toggle = null;
    862    }
    863  }
    864 
    865  _createContainer() {
    866    const container = this.doc.createElementNS(XHTML_NS, "div");
    867    container.setAttribute("type", this.type);
    868 
    869    if (this.id) {
    870      container.setAttribute("id", this.id);
    871    }
    872 
    873    container.classList.add("tooltip-container");
    874    if (this.className) {
    875      container.classList.add(...this.className.split(" "));
    876    }
    877 
    878    const filler = this.doc.createElementNS(XHTML_NS, "div");
    879    filler.classList.add("tooltip-filler");
    880    container.appendChild(filler);
    881 
    882    const panel = this.doc.createElementNS(XHTML_NS, "div");
    883    panel.classList.add("tooltip-panel");
    884    container.appendChild(panel);
    885 
    886    if (this.type === TYPE.ARROW || this.type === TYPE.DOORHANGER) {
    887      const arrow = this.doc.createElementNS(XHTML_NS, "div");
    888      arrow.classList.add("tooltip-arrow");
    889      container.appendChild(arrow);
    890    }
    891    return container;
    892  }
    893 
    894  _onClick(e) {
    895    if (this._isInTooltipContainer(e.target)) {
    896      return;
    897    }
    898 
    899    if (this.consumeOutsideClicks && e.button === 0) {
    900      // Consume only left click events (button === 0).
    901      e.preventDefault();
    902      e.stopPropagation();
    903    }
    904  }
    905 
    906  /**
    907   * Hide the tooltip on mouseup rather than on click because the surrounding markup
    908   * may change on mousedown in a way that prevents a "click" event from being fired.
    909   * If the element that received the mousedown and the mouseup are different, click
    910   * will not be fired.
    911   */
    912  _onMouseup(e) {
    913    if (this._isInTooltipContainer(e.target)) {
    914      return;
    915    }
    916 
    917    this.hide({ fromMouseup: true });
    918  }
    919 
    920  _isInTooltipContainer(node) {
    921    // Check if the target is the tooltip arrow.
    922    if (this.arrow && this.arrow === node) {
    923      return true;
    924    }
    925 
    926    if (typeof node.closest == "function" && node.closest("menupopup")) {
    927      // Ignore events from menupopup elements which will not be children of the
    928      // tooltip container even if their owner element is in the tooltip.
    929      // See Bug 1811002.
    930      return true;
    931    }
    932 
    933    const tooltipWindow = this.panel.ownerDocument.defaultView;
    934    let win = node.ownerDocument.defaultView;
    935 
    936    // Check if the tooltip panel contains the node if they live in the same document.
    937    if (win === tooltipWindow) {
    938      return this.panel.contains(node);
    939    }
    940 
    941    // Check if the node window is in the tooltip container.
    942    while (win.parent && win.parent !== win) {
    943      if (win.parent === tooltipWindow) {
    944        // If the parent window is the tooltip window, check if the tooltip contains
    945        // the current frame element.
    946        return this.panel.contains(win.frameElement);
    947      }
    948      win = win.parent;
    949    }
    950 
    951    return false;
    952  }
    953 
    954  _onXulPanelHidden() {
    955    if (this.isVisible()) {
    956      this.hide();
    957    }
    958  }
    959 
    960  /**
    961   * Focus on the first focusable item in the tooltip.
    962   *
    963   * Returns true if we found something to focus on, false otherwise.
    964   */
    965  focus() {
    966    const focusableElement = this.panel.querySelector(lazy.focusableSelector);
    967    if (focusableElement) {
    968      focusableElement.focus();
    969    }
    970    return !!focusableElement;
    971  }
    972 
    973  /**
    974   * Focus on the last focusable item in the tooltip.
    975   *
    976   * Returns true if we found something to focus on, false otherwise.
    977   */
    978  focusEnd() {
    979    const focusableElements = this.panel.querySelectorAll(
    980      lazy.focusableSelector
    981    );
    982    if (focusableElements.length) {
    983      focusableElements[focusableElements.length - 1].focus();
    984    }
    985    return focusableElements.length !== 0;
    986  }
    987 
    988  _getTopWindow() {
    989    return DevToolsUtils.getTopWindow(this.doc.defaultView);
    990  }
    991 
    992  /**
    993   * Check if the tooltip's owner document has XUL root element.
    994   */
    995  _hasXULRootElement() {
    996    return this.doc.documentElement.namespaceURI === XUL_NS;
    997  }
    998 
    999  _isXULPopupAvailable() {
   1000    return this.doc.nodePrincipal.isSystemPrincipal;
   1001  }
   1002 
   1003  _createXulPanelWrapper() {
   1004    const panel = this.doc.createXULElement("panel");
   1005 
   1006    // XUL panel is only a way to display DOM elements outside of the document viewport,
   1007    // so disable all features that impact the behavior.
   1008    panel.setAttribute("animate", false);
   1009    panel.setAttribute("consumeoutsideclicks", false);
   1010    panel.setAttribute("incontentshell", false);
   1011    panel.setAttribute("noautofocus", true);
   1012    panel.setAttribute("noautohide", this.noAutoHide);
   1013 
   1014    panel.setAttribute("ignorekeys", true);
   1015    panel.setAttribute("tooltip", "aHTMLTooltip");
   1016 
   1017    // Use type="arrow" to prevent side effects (see Bug 1285206)
   1018    panel.setAttribute("type", "arrow");
   1019    panel.setAttribute("tooltip-type", this.type);
   1020 
   1021    panel.setAttribute("flip", "none");
   1022 
   1023    panel.setAttribute("level", "top");
   1024    panel.setAttribute("class", "tooltip-xul-wrapper");
   1025 
   1026    // Stop this appearing as an alert to accessibility.
   1027    panel.setAttribute("role", "presentation");
   1028 
   1029    return panel;
   1030  }
   1031 
   1032  _showXulWrapperAt(left, top) {
   1033    this.xulPanelWrapper.addEventListener(
   1034      "popuphidden",
   1035      this._onXulPanelHidden
   1036    );
   1037    const onPanelShown = listenOnce(this.xulPanelWrapper, "popupshown");
   1038    this.xulPanelWrapper.openPopupAtScreen(left, top, false);
   1039    return onPanelShown;
   1040  }
   1041 
   1042  _moveXulWrapperTo(left, top) {
   1043    // FIXME: moveTo should probably account for margins when called from
   1044    // script. Our current shadow set-up only supports one margin, so it's fine
   1045    // to use the margin top in both directions.
   1046    const margin = parseFloat(
   1047      this.xulPanelWrapper.ownerGlobal.getComputedStyle(this.xulPanelWrapper)
   1048        .marginTop
   1049    );
   1050    this.xulPanelWrapper.moveTo(left + margin, top + margin);
   1051  }
   1052 
   1053  _hideXulWrapper() {
   1054    this.xulPanelWrapper.removeEventListener(
   1055      "popuphidden",
   1056      this._onXulPanelHidden
   1057    );
   1058 
   1059    if (this.xulPanelWrapper.state === "closed") {
   1060      // XUL panel is already closed, resolve immediately.
   1061      return Promise.resolve();
   1062    }
   1063 
   1064    const onPanelHidden = listenOnce(this.xulPanelWrapper, "popuphidden");
   1065    this.xulPanelWrapper.hidePopup();
   1066    return onPanelHidden;
   1067  }
   1068 
   1069  /**
   1070   * Convert from coordinates relative to the tooltip's document, to coordinates relative
   1071   * to the "available" screen. By "available" we mean the screen, excluding the OS bars
   1072   * display on screen edges.
   1073   */
   1074  _convertToScreenRect({ left, top, width, height }) {
   1075    // mozInnerScreenX/Y are the coordinates of the top left corner of the window's
   1076    // viewport, excluding chrome UI.
   1077    left += this.doc.defaultView.mozInnerScreenX;
   1078    top += this.doc.defaultView.mozInnerScreenY;
   1079    return {
   1080      top,
   1081      right: left + width,
   1082      bottom: top + height,
   1083      left,
   1084      width,
   1085      height,
   1086    };
   1087  }
   1088 }
   1089 
   1090 module.exports.HTMLTooltip = HTMLTooltip;