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;