tooltips-overlay.js (17117B)
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 /** 8 * The tooltip overlays are tooltips that appear when hovering over property values and 9 * editor tooltips that appear when clicking swatch based editors. 10 */ 11 12 const flags = require("resource://devtools/shared/flags.js"); 13 const { 14 VIEW_NODE_CSS_QUERY_CONTAINER, 15 VIEW_NODE_CSS_SELECTOR_WARNINGS, 16 VIEW_NODE_FONT_TYPE, 17 VIEW_NODE_IMAGE_URL_TYPE, 18 VIEW_NODE_INACTIVE_CSS, 19 VIEW_NODE_VALUE_TYPE, 20 VIEW_NODE_VARIABLE_TYPE, 21 } = require("resource://devtools/client/inspector/shared/node-types.js"); 22 23 loader.lazyRequireGetter( 24 this, 25 "getCssVariableColor", 26 "resource://devtools/client/shared/theme.js", 27 true 28 ); 29 loader.lazyRequireGetter( 30 this, 31 "HTMLTooltip", 32 "resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js", 33 true 34 ); 35 loader.lazyRequireGetter( 36 this, 37 ["getImageDimensions", "setImageTooltip", "setBrokenImageTooltip"], 38 "resource://devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js", 39 true 40 ); 41 loader.lazyRequireGetter( 42 this, 43 "setVariableTooltip", 44 "resource://devtools/client/shared/widgets/tooltip/VariableTooltipHelper.js", 45 true 46 ); 47 loader.lazyRequireGetter( 48 this, 49 "InactiveCssTooltipHelper", 50 "resource://devtools/client/shared/widgets/tooltip/inactive-css-tooltip-helper.js", 51 false 52 ); 53 loader.lazyRequireGetter( 54 this, 55 "CssCompatibilityTooltipHelper", 56 "resource://devtools/client/shared/widgets/tooltip/css-compatibility-tooltip-helper.js", 57 false 58 ); 59 loader.lazyRequireGetter( 60 this, 61 "CssQueryContainerTooltipHelper", 62 "resource://devtools/client/shared/widgets/tooltip/css-query-container-tooltip-helper.js", 63 false 64 ); 65 loader.lazyRequireGetter( 66 this, 67 "CssSelectorWarningsTooltipHelper", 68 "resource://devtools/client/shared/widgets/tooltip/css-selector-warnings-tooltip-helper.js", 69 false 70 ); 71 72 const PREF_IMAGE_TOOLTIP_SIZE = "devtools.inspector.imagePreviewTooltipSize"; 73 74 // Types of existing tooltips 75 const TOOLTIP_CSS_COMPATIBILITY = "css-compatibility"; 76 const TOOLTIP_CSS_QUERY_CONTAINER = "css-query-info"; 77 const TOOLTIP_CSS_SELECTOR_WARNINGS = "css-selector-warnings"; 78 const TOOLTIP_FONTFAMILY_TYPE = "font-family"; 79 const TOOLTIP_IMAGE_TYPE = "image"; 80 const TOOLTIP_INACTIVE_CSS = "inactive-css"; 81 const TOOLTIP_VARIABLE_TYPE = "variable"; 82 83 /** 84 * Manages all tooltips in the style-inspector. 85 */ 86 class TooltipsOverlay { 87 /** 88 * @param {CssRuleView|CssComputedView} view 89 * Either the rule-view or computed-view panel 90 */ 91 constructor(view) { 92 this.view = view; 93 this._instances = new Map(); 94 95 this._onNewSelection = this._onNewSelection.bind(this); 96 this.view.inspector.selection.on("new-node-front", this._onNewSelection); 97 98 this.addToView(); 99 } 100 get isEditing() { 101 for (const [, tooltip] of this._instances) { 102 if (typeof tooltip.isEditing == "function" && tooltip.isEditing()) { 103 return true; 104 } 105 } 106 return false; 107 } 108 109 /** 110 * Add the tooltips overlay to the view. This will start tracking mouse 111 * movements and display tooltips when needed 112 */ 113 addToView() { 114 if (this._isStarted || this._isDestroyed) { 115 return; 116 } 117 118 this._isStarted = true; 119 120 this.inactiveCssTooltipHelper = new InactiveCssTooltipHelper(); 121 this.compatibilityTooltipHelper = new CssCompatibilityTooltipHelper(); 122 this.cssQueryContainerTooltipHelper = new CssQueryContainerTooltipHelper(); 123 this.cssSelectorWarningsTooltipHelper = 124 new CssSelectorWarningsTooltipHelper(); 125 126 // Instantiate the interactiveTooltip and preview tooltip when the 127 // rule/computed view is hovered over in order to call 128 // `tooltip.startTogglingOnHover`. This will allow the tooltip to be shown 129 // when an appropriate element is hovered over. 130 for (const type of ["interactiveTooltip", "previewTooltip"]) { 131 if (flags.testing) { 132 this.getTooltip(type); 133 } else { 134 // Lazily get the preview tooltip to avoid loading HTMLTooltip. 135 this.view.element.addEventListener( 136 "mousemove", 137 () => { 138 this.getTooltip(type); 139 }, 140 { once: true } 141 ); 142 } 143 } 144 } 145 146 /** 147 * Lazily fetch and initialize the different tooltips that are used in the inspector. 148 * These tooltips are attached to the toolbox document if they require a popup panel. 149 * Otherwise, it is attached to the inspector panel document if it is an inline editor. 150 * 151 * @param {string} name 152 * Identifier name for the tooltip 153 */ 154 getTooltip(name) { 155 let tooltip = this._instances.get(name); 156 if (tooltip) { 157 return tooltip; 158 } 159 const { doc } = this.view.inspector.toolbox; 160 switch (name) { 161 case "colorPicker": { 162 const SwatchColorPickerTooltip = require("resource://devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js"); 163 tooltip = new SwatchColorPickerTooltip(doc, this.view.inspector); 164 break; 165 } 166 case "cubicBezier": { 167 const SwatchCubicBezierTooltip = require("resource://devtools/client/shared/widgets/tooltip/SwatchCubicBezierTooltip.js"); 168 tooltip = new SwatchCubicBezierTooltip(doc); 169 break; 170 } 171 case "linearEaseFunction": { 172 const SwatchLinearEasingFunctionTooltip = require("devtools/client/shared/widgets/tooltip/SwatchLinearEasingFunctionTooltip"); 173 tooltip = new SwatchLinearEasingFunctionTooltip(doc); 174 break; 175 } 176 case "filterEditor": { 177 const SwatchFilterTooltip = require("resource://devtools/client/shared/widgets/tooltip/SwatchFilterTooltip.js"); 178 tooltip = new SwatchFilterTooltip(doc); 179 break; 180 } 181 case "interactiveTooltip": 182 tooltip = new HTMLTooltip(doc, { 183 type: "doorhanger", 184 useXulWrapper: true, 185 noAutoHide: true, 186 }); 187 tooltip.startTogglingOnHover( 188 this.view.element, 189 this.onInteractiveTooltipTargetHover.bind(this), 190 { 191 interactive: true, 192 } 193 ); 194 break; 195 case "previewTooltip": 196 tooltip = new HTMLTooltip(doc, { 197 type: "arrow", 198 useXulWrapper: true, 199 }); 200 tooltip.startTogglingOnHover( 201 this.view.element, 202 this._onPreviewTooltipTargetHover.bind(this) 203 ); 204 break; 205 default: 206 throw new Error(`Unsupported tooltip '${name}'`); 207 } 208 this._instances.set(name, tooltip); 209 return tooltip; 210 } 211 212 /** 213 * Remove the tooltips overlay from the view. This will stop tracking mouse 214 * movements and displaying tooltips 215 */ 216 removeFromView() { 217 if (!this._isStarted || this._isDestroyed) { 218 return; 219 } 220 221 for (const [, tooltip] of this._instances) { 222 tooltip.destroy(); 223 } 224 225 this.inactiveCssTooltipHelper.destroy(); 226 this.compatibilityTooltipHelper.destroy(); 227 228 this._isStarted = false; 229 } 230 231 /** 232 * Given a hovered node info, find out which type of tooltip should be shown, 233 * if any 234 * 235 * @param {object} nodeInfo 236 * @return {string} The tooltip type to be shown, or null 237 */ 238 _getTooltipType({ type, value: prop }) { 239 let tooltipType = null; 240 241 // Image preview tooltip 242 if (type === VIEW_NODE_IMAGE_URL_TYPE) { 243 tooltipType = TOOLTIP_IMAGE_TYPE; 244 } 245 246 // Font preview tooltip 247 if ( 248 (type === VIEW_NODE_VALUE_TYPE && prop.property === "font-family") || 249 type === VIEW_NODE_FONT_TYPE 250 ) { 251 const value = prop.value.toLowerCase(); 252 if (value !== "inherit" && value !== "unset" && value !== "initial") { 253 tooltipType = TOOLTIP_FONTFAMILY_TYPE; 254 } 255 } 256 257 // Inactive CSS tooltip 258 if (type === VIEW_NODE_INACTIVE_CSS) { 259 tooltipType = TOOLTIP_INACTIVE_CSS; 260 } 261 262 // Variable preview tooltip 263 if (type === VIEW_NODE_VARIABLE_TYPE) { 264 tooltipType = TOOLTIP_VARIABLE_TYPE; 265 } 266 267 // Container info tooltip 268 if (type === VIEW_NODE_CSS_QUERY_CONTAINER) { 269 tooltipType = TOOLTIP_CSS_QUERY_CONTAINER; 270 } 271 272 // Selector warnings info tooltip 273 if (type === VIEW_NODE_CSS_SELECTOR_WARNINGS) { 274 tooltipType = TOOLTIP_CSS_SELECTOR_WARNINGS; 275 } 276 277 return tooltipType; 278 } 279 280 _removePreviousInstances() { 281 for (const tooltip of this._instances.values()) { 282 if (tooltip.isVisible()) { 283 if (tooltip.revert) { 284 tooltip.revert(); 285 } 286 tooltip.hide(); 287 } 288 } 289 } 290 291 /** 292 * Executed by the tooltip when the pointer hovers over an element of the 293 * view. Used to decide whether the tooltip should be shown or not and to 294 * actually put content in it. 295 * Checks if the hovered target is a css value we support tooltips for. 296 * 297 * @param {DOMNode} target The currently hovered node 298 * @return {Promise} 299 */ 300 async _onPreviewTooltipTargetHover(target) { 301 const nodeInfo = this.view.getNodeInfo(target); 302 if (!nodeInfo) { 303 // The hovered node isn't something we care about 304 return false; 305 } 306 307 const type = this._getTooltipType(nodeInfo); 308 if (!type) { 309 // There is no tooltip type defined for the hovered node 310 return false; 311 } 312 313 this._removePreviousInstances(); 314 315 const inspector = this.view.inspector; 316 317 if (type === TOOLTIP_IMAGE_TYPE) { 318 try { 319 await this._setImagePreviewTooltip(nodeInfo.value.url); 320 } catch (e) { 321 await setBrokenImageTooltip( 322 this.getTooltip("previewTooltip"), 323 this.view.inspector.panelDoc 324 ); 325 } 326 327 this.sendOpenScalarToTelemetry(type); 328 329 return true; 330 } 331 332 if (type === TOOLTIP_FONTFAMILY_TYPE) { 333 const font = nodeInfo.value.value; 334 const nodeFront = inspector.selection.nodeFront; 335 await this._setFontPreviewTooltip(font, nodeFront); 336 337 this.sendOpenScalarToTelemetry(type); 338 339 if (nodeInfo.type === VIEW_NODE_FONT_TYPE) { 340 // If the hovered element is on the font family span, anchor 341 // the tooltip on the whole property value instead. 342 return target.parentNode; 343 } 344 return true; 345 } 346 347 if ( 348 type === TOOLTIP_VARIABLE_TYPE && 349 nodeInfo.value.value.startsWith("--") 350 ) { 351 const { 352 variable, 353 registeredProperty, 354 startingStyleVariable, 355 variableComputed, 356 outputParserOptions, 357 cssProperties, 358 value, 359 } = nodeInfo.value; 360 await this._setVariablePreviewTooltip({ 361 topSectionText: variable, 362 computed: variableComputed, 363 registeredProperty, 364 startingStyle: startingStyleVariable, 365 outputParserOptions, 366 cssProperties, 367 variableName: value, 368 }); 369 370 this.sendOpenScalarToTelemetry(type); 371 372 return true; 373 } 374 375 return false; 376 } 377 378 /** 379 * Executed by the tooltip when the pointer hovers over an element of the 380 * view. Used to decide whether the tooltip should be shown or not and to 381 * actually put content in it. 382 * Checks if the hovered target is a css value we support tooltips for. 383 * 384 * @param {DOMNode} target 385 * The currently hovered node 386 * @return {boolean} 387 * true if shown, false otherwise. 388 */ 389 async onInteractiveTooltipTargetHover(target) { 390 if (target.classList.contains("ruleview-compatibility-warning")) { 391 const nodeCompatibilityInfo = 392 await this.view.getNodeCompatibilityInfo(target); 393 394 await this.compatibilityTooltipHelper.setContent( 395 nodeCompatibilityInfo, 396 this.getTooltip("interactiveTooltip") 397 ); 398 399 this.sendOpenScalarToTelemetry(TOOLTIP_CSS_COMPATIBILITY); 400 return true; 401 } 402 403 const nodeInfo = this.view.getNodeInfo(target); 404 if (!nodeInfo) { 405 // The hovered node isn't something we care about. 406 return false; 407 } 408 409 const type = this._getTooltipType(nodeInfo); 410 if (!type) { 411 // There is no tooltip type defined for the hovered node. 412 return false; 413 } 414 415 this._removePreviousInstances(); 416 417 if (type === TOOLTIP_INACTIVE_CSS) { 418 // Ensure this is the correct node and not a parent. 419 if (!target.classList.contains("ruleview-inactive-css-warning")) { 420 return false; 421 } 422 423 await this.inactiveCssTooltipHelper.setContent( 424 nodeInfo.value, 425 this.getTooltip("interactiveTooltip") 426 ); 427 428 this.sendOpenScalarToTelemetry(type); 429 430 return true; 431 } 432 433 if (type === TOOLTIP_CSS_QUERY_CONTAINER) { 434 // Ensure this is the correct node and not a parent. 435 if (!target.closest(".container-query .container-query-declaration")) { 436 return false; 437 } 438 439 await this.cssQueryContainerTooltipHelper.setContent( 440 nodeInfo.value, 441 this.getTooltip("interactiveTooltip") 442 ); 443 444 this.sendOpenScalarToTelemetry(type); 445 446 return true; 447 } 448 449 if (type === TOOLTIP_CSS_SELECTOR_WARNINGS) { 450 await this.cssSelectorWarningsTooltipHelper.setContent( 451 nodeInfo.value, 452 this.getTooltip("interactiveTooltip") 453 ); 454 455 this.sendOpenScalarToTelemetry(type); 456 457 return true; 458 } 459 460 return false; 461 } 462 463 /** 464 * Send a telemetry Scalar showing that a tooltip of `type` has been opened. 465 * 466 * @param {string} type 467 * The node type from `devtools/client/inspector/shared/node-types` or the Tooltip type. 468 */ 469 sendOpenScalarToTelemetry(type) { 470 Glean.devtoolsTooltip.shown[type].add(1); 471 } 472 473 /** 474 * Set the content of the preview tooltip to display an image preview. The image URL can 475 * be relative, a call will be made to the debuggee to retrieve the image content as an 476 * imageData URI. 477 * 478 * @param {string} imageUrl 479 * The image url value (may be relative or absolute). 480 * @return {Promise} A promise that resolves when the preview tooltip content is ready 481 */ 482 async _setImagePreviewTooltip(imageUrl) { 483 const doc = this.view.inspector.panelDoc; 484 const maxDim = Services.prefs.getIntPref(PREF_IMAGE_TOOLTIP_SIZE); 485 486 let naturalWidth, naturalHeight; 487 if (imageUrl.startsWith("data:")) { 488 // If the imageUrl already is a data-url, save ourselves a round-trip 489 const size = await getImageDimensions(doc, imageUrl); 490 naturalWidth = size.naturalWidth; 491 naturalHeight = size.naturalHeight; 492 } else { 493 const inspectorFront = this.view.inspector.inspectorFront; 494 const { data, size } = await inspectorFront.getImageDataFromURL( 495 imageUrl, 496 maxDim 497 ); 498 imageUrl = await data.string(); 499 naturalWidth = size.naturalWidth; 500 naturalHeight = size.naturalHeight; 501 } 502 503 await setImageTooltip(this.getTooltip("previewTooltip"), doc, imageUrl, { 504 maxDim, 505 naturalWidth, 506 naturalHeight, 507 }); 508 } 509 510 /** 511 * Set the content of the preview tooltip to display a font family preview. 512 * 513 * @param {string} font 514 * The font family value. 515 * @param {object} nodeFront 516 * The NodeActor that will used to retrieve the dataURL for the font 517 * family tooltip contents. 518 * @return {Promise} A promise that resolves when the preview tooltip content is ready 519 */ 520 async _setFontPreviewTooltip(font, nodeFront) { 521 if ( 522 !font || 523 !nodeFront || 524 typeof nodeFront.getFontFamilyDataURL !== "function" 525 ) { 526 throw new Error("Unable to create font preview tooltip content."); 527 } 528 529 font = font.replace(/"/g, "'"); 530 font = font.replace("!important", ""); 531 font = font.trim(); 532 533 const fillStyle = getCssVariableColor( 534 "--theme-body-color", 535 this.view.inspector.panelWin 536 ); 537 const { data, size: maxDim } = await nodeFront.getFontFamilyDataURL( 538 font, 539 fillStyle 540 ); 541 542 const imageUrl = await data.string(); 543 const doc = this.view.inspector.panelDoc; 544 const { naturalWidth, naturalHeight } = await getImageDimensions( 545 doc, 546 imageUrl 547 ); 548 549 await setImageTooltip(this.getTooltip("previewTooltip"), doc, imageUrl, { 550 hideDimensionLabel: true, 551 hideCheckeredBackground: true, 552 maxDim, 553 naturalWidth, 554 naturalHeight, 555 }); 556 } 557 558 /** 559 * Set the content of the preview tooltip to display a variable preview. 560 * 561 * @param {object} tooltipParams 562 * See VariableTooltipHelper#setVariableTooltip `params`. 563 * @return {Promise} A promise that resolves when the preview tooltip content is ready 564 */ 565 async _setVariablePreviewTooltip(tooltipParams) { 566 const doc = this.view.inspector.panelDoc; 567 await setVariableTooltip( 568 this.getTooltip("previewTooltip"), 569 doc, 570 tooltipParams 571 ); 572 } 573 574 _onNewSelection() { 575 for (const [, tooltip] of this._instances) { 576 tooltip.hide(); 577 } 578 } 579 580 /** 581 * Destroy this overlay instance, removing it from the view 582 */ 583 destroy() { 584 this.removeFromView(); 585 586 this.view.inspector.selection.off("new-node-front", this._onNewSelection); 587 this.view = null; 588 589 this._isDestroyed = true; 590 } 591 } 592 593 module.exports = TooltipsOverlay;