highlighters-overlay.js (64942B)
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 { 8 safeAsyncMethod, 9 } = require("resource://devtools/shared/async-utils.js"); 10 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 11 const WalkerEventListener = require("resource://devtools/client/inspector/shared/walker-event-listener.js"); 12 const { 13 VIEW_NODE_VALUE_TYPE, 14 VIEW_NODE_SHAPE_POINT_TYPE, 15 } = require("resource://devtools/client/inspector/shared/node-types.js"); 16 17 const { TYPES } = ChromeUtils.importESModule( 18 "resource://devtools/shared/highlighters.mjs" 19 ); 20 21 loader.lazyRequireGetter( 22 this, 23 "parseURL", 24 "resource://devtools/client/shared/source-utils.js", 25 true 26 ); 27 loader.lazyRequireGetter( 28 this, 29 "asyncStorage", 30 "resource://devtools/shared/async-storage.js" 31 ); 32 loader.lazyRequireGetter( 33 this, 34 "gridsReducer", 35 "resource://devtools/client/inspector/grids/reducers/grids.js" 36 ); 37 loader.lazyRequireGetter( 38 this, 39 "highlighterSettingsReducer", 40 "resource://devtools/client/inspector/grids/reducers/highlighter-settings.js" 41 ); 42 loader.lazyRequireGetter( 43 this, 44 "flexboxReducer", 45 "resource://devtools/client/inspector/flexbox/reducers/flexbox.js" 46 ); 47 loader.lazyRequireGetter( 48 this, 49 "deepEqual", 50 "resource://devtools/shared/DevToolsUtils.js", 51 true 52 ); 53 loader.lazyGetter(this, "HighlightersBundle", () => { 54 return new Localization(["devtools/shared/highlighters.ftl"], true); 55 }); 56 57 const DEFAULT_HIGHLIGHTER_COLOR = "#9400FF"; 58 const SUBGRID_PARENT_ALPHA = 0.5; 59 60 /** 61 * While refactoring to an abstracted way to show and hide highlighters, 62 * we did not update all tests and code paths which listen for exact events. 63 * 64 * When we show or hide highlighters we reference this mapping to 65 * emit events that consumers may be listening to. 66 * 67 * This list should go away as we incrementally rewrite tests to use 68 * abstract event names with data payloads indicating the highlighter. 69 * 70 * DO NOT OPTIMIZE THIS MAPPING AS CONCATENATED SUBSTRINGS! 71 * It makes it difficult to do project-wide searches for exact matches. 72 */ 73 const HIGHLIGHTER_EVENTS = { 74 [TYPES.GRID]: { 75 shown: "grid-highlighter-shown", 76 hidden: "grid-highlighter-hidden", 77 }, 78 [TYPES.GEOMETRY]: { 79 shown: "geometry-editor-highlighter-shown", 80 hidden: "geometry-editor-highlighter-hidden", 81 }, 82 [TYPES.SHAPES]: { 83 shown: "shapes-highlighter-shown", 84 hidden: "shapes-highlighter-hidden", 85 }, 86 [TYPES.TRANSFORM]: { 87 shown: "css-transform-highlighter-shown", 88 hidden: "css-transform-highlighter-hidden", 89 }, 90 }; 91 92 // Tool IDs mapped by highlighter type. Used to log telemetry for opening & closing tools. 93 const GLEAN_TOOL_IDS = { 94 [TYPES.FLEXBOX]: "flexbox_highlighter", 95 [TYPES.GRID]: "grid_highlighter", 96 }; 97 98 // Glean counter names mapped by highlighter type. Used to log telemetry about highlighter triggers. 99 const GLEAN_COUNTER_NAMES = { 100 [TYPES.FLEXBOX]: { 101 layout: "devtoolsLayoutFlexboxhighlighter", 102 markup: "devtoolsMarkupFlexboxhighlighter", 103 rule: "devtoolsRulesFlexboxhighlighter", 104 }, 105 106 [TYPES.GRID]: { 107 grid: "devtoolsGridGridinspector", 108 markup: "devtoolsMarkupGridinspector", 109 rule: "devtoolsRulesGridinspector", 110 }, 111 }; 112 113 /** 114 * HighlightersOverlay manages the visibility of highlighters in the Inspector. 115 */ 116 class HighlightersOverlay { 117 /** 118 * @param {Inspector} inspector 119 * Inspector toolbox panel. 120 */ 121 constructor(inspector) { 122 this.inspector = inspector; 123 this.store = this.inspector.store; 124 125 this.telemetry = this.inspector.telemetry; 126 this.maxGridHighlighters = Services.prefs.getIntPref( 127 "devtools.gridinspector.maxHighlighters" 128 ); 129 130 // Collection of instantiated highlighter actors like FlexboxHighlighter, 131 // ShapesHighlighter and GeometryEditorHighlighter. 132 this.highlighters = {}; 133 // Map of grid container node to an object with the grid highlighter instance 134 // and, if the node is a subgrid, the parent grid node and parent grid highlighter. 135 // Ex: {NodeFront} => { 136 // highlighter: {CustomHighlighterFront}, 137 // parentGridNode: {NodeFront|null}, 138 // parentGridHighlighter: {CustomHighlighterFront|null} 139 // } 140 this.gridHighlighters = new Map(); 141 // Collection of instantiated in-context editors, like ShapesInContextEditor, which 142 // behave like highlighters but with added editing capabilities that need to map value 143 // changes to properties in the Rule view. 144 this.editors = {}; 145 146 // Highlighter state. 147 this.state = { 148 // Map of grid container NodeFront to the their stored grid options 149 // Used to restore grid highlighters on reload (should be migrated to 150 // #restorableHighlighters in Bug 1572652). 151 grids: new Map(), 152 // Shape Path Editor highlighter options. 153 // Used as a cache for the latest configuration when showing the highlighter. 154 // It is reused and augmented when hovering coordinates in the Rules view which 155 // mark the corresponding points in the highlighter overlay. 156 shapes: {}, 157 }; 158 159 // NodeFront of element that is highlighted by the geometry editor. 160 this.geometryEditorHighlighterShown = null; 161 // Name of the highlighter shown on mouse hover. 162 this.hoveredHighlighterShown = null; 163 // NodeFront of the shape that is highlighted 164 this.shapesHighlighterShown = null; 165 166 this.onClick = this.onClick.bind(this); 167 this.onDisplayChange = this.onDisplayChange.bind(this); 168 this.onMarkupMutation = this.onMarkupMutation.bind(this); 169 170 this.onMouseMove = this.onMouseMove.bind(this); 171 this.onMouseOut = this.onMouseOut.bind(this); 172 this.hideAllHighlighters = this.hideAllHighlighters.bind(this); 173 this.hideFlexboxHighlighter = this.hideFlexboxHighlighter.bind(this); 174 this.hideGridHighlighter = this.hideGridHighlighter.bind(this); 175 this.hideShapesHighlighter = this.hideShapesHighlighter.bind(this); 176 this.showFlexboxHighlighter = this.showFlexboxHighlighter.bind(this); 177 this.showGridHighlighter = this.showGridHighlighter.bind(this); 178 this.showShapesHighlighter = this.showShapesHighlighter.bind(this); 179 this.onShapesHighlighterShown = this.onShapesHighlighterShown.bind(this); 180 this.onShapesHighlighterHidden = this.onShapesHighlighterHidden.bind(this); 181 182 // Catch unexpected errors from async functions if the manager has been destroyed. 183 this.hideHighlighterType = safeAsyncMethod( 184 this.hideHighlighterType.bind(this), 185 () => this.destroyed 186 ); 187 this.showHighlighterTypeForNode = safeAsyncMethod( 188 this.showHighlighterTypeForNode.bind(this), 189 () => this.destroyed 190 ); 191 this.showGridHighlighter = safeAsyncMethod( 192 this.showGridHighlighter.bind(this), 193 () => this.destroyed 194 ); 195 this.restoreState = safeAsyncMethod( 196 this.restoreState.bind(this), 197 () => this.destroyed 198 ); 199 200 // Add inspector events, not specific to a given view. 201 this.inspector.on("markupmutation", this.onMarkupMutation); 202 203 this.resourceCommand = this.inspector.commands.resourceCommand; 204 this.resourceCommand.watchResources( 205 [this.resourceCommand.TYPES.ROOT_NODE], 206 { onAvailable: this.#onResourceAvailable } 207 ); 208 209 this.walkerEventListener = new WalkerEventListener(this.inspector, { 210 "display-change": this.onDisplayChange, 211 }); 212 213 if (this.toolbox.win.matchMedia("(prefers-reduced-motion)").matches) { 214 this.#showSimpleHighlightersMessage(); 215 } 216 217 EventEmitter.decorate(this); 218 } 219 220 // Map of active highlighter types to objects with the highlighted nodeFront and the 221 // highlighter instance. Ex: "BoxModelHighlighter" => { nodeFront, highlighter } 222 // It will fully replace this.highlighters when all highlighter consumers are updated 223 // to use it as the single source of truth for which highlighters are visible. 224 #activeHighlighters = new Map(); 225 // Map of highlighter types to symbols. Showing highlighters is an async operation, 226 // until it doesn't complete, this map will be populated with the requested type and 227 // a unique symbol identifying that request. Once completed, the entry is removed. 228 #pendingHighlighters = new Map(); 229 // Map of highlighter types to objects with metadata used to restore active 230 // highlighters after a page reload. 231 #restorableHighlighters = new Map(); 232 233 #lastHovered = null; 234 235 get inspectorFront() { 236 return this.inspector.inspectorFront; 237 } 238 239 get target() { 240 return this.inspector.currentTarget; 241 } 242 243 get toolbox() { 244 return this.inspector.toolbox; 245 } 246 247 /** 248 * Optionally run some operations right after showing a highlighter of a given type, 249 * but before notifying consumers by emitting the "highlighter-shown" event. 250 * 251 * This is a chance to run some non-essential operations like: logging telemetry data, 252 * storing metadata about the highlighter to enable restoring it after refresh, etc. 253 * 254 * @param {string} type 255 * Highlighter type shown. 256 * @param {NodeFront} nodeFront 257 * Node front of the element that was highlighted. 258 * @param {Options} options 259 * Optional object with options passed to the highlighter. 260 */ 261 #afterShowHighlighterTypeForNode(type, nodeFront, options) { 262 switch (type) { 263 // Log telemetry for showing the flexbox and grid highlighters. 264 case TYPES.FLEXBOX: 265 case TYPES.GRID: { 266 const toolID = GLEAN_TOOL_IDS[type]; 267 if (toolID) { 268 this.telemetry.toolOpened(toolID, this); 269 } 270 271 const counterName = GLEAN_COUNTER_NAMES[type]?.[options?.trigger]; 272 if (counterName) { 273 Glean[counterName].opened.add(1); 274 } 275 276 break; 277 } 278 } 279 280 // Set metadata necessary to restore the active highlighter upon page refresh. 281 if (type === TYPES.FLEXBOX) { 282 const { url } = this.target; 283 const selectors = [...this.inspector.selectionCssSelectors]; 284 285 this.#restorableHighlighters.set(type, { 286 options, 287 selectors, 288 type, 289 url, 290 }); 291 } 292 } 293 294 /** 295 * Optionally run some operations before showing a highlighter of a given type. 296 * 297 * Depending its type, before showing a new instance of a highlighter, we may do extra 298 * operations, like hiding another visible highlighter, or preventing the show 299 * operation, for example due to a duplicate call with the same arguments. 300 * 301 * Returns a promise that resovles with a boolean indicating whether to skip showing 302 * the highlighter with these arguments. 303 * 304 * @param {string} type 305 * Highlighter type to show. 306 * @param {NodeFront} nodeFront 307 * Node front of the element to be highlighted. 308 * @param {Options} options 309 * Optional object with options to pass to the highlighter. 310 * @return {Promise} 311 */ 312 async #beforeShowHighlighterTypeForNode(type, nodeFront, options) { 313 // Get the data associated with the visible highlighter of this type, if any. 314 const { 315 highlighter: activeHighlighter, 316 nodeFront: activeNodeFront, 317 options: activeOptions, 318 timer: activeTimer, 319 } = this.getDataForActiveHighlighter(type); 320 321 // There isn't an active highlighter of this type. Early return, proceed with showing. 322 if (!activeHighlighter) { 323 return false; 324 } 325 326 // Whether conditions are met to skip showing the highlighter (ex: duplicate calls). 327 let skipShow = false; 328 329 // Clear any autohide timer associated with this highlighter type. 330 // This clears any existing timer for duplicate calls to show() if: 331 // - called with different options.duration 332 // - called once with options.duration, then without (see deepEqual() above) 333 clearTimeout(activeTimer); 334 335 switch (type) { 336 // Hide the visible selector highlighter if called for the same node, 337 // but with a different selector. 338 case TYPES.SELECTOR: 339 if ( 340 nodeFront === activeNodeFront && 341 options?.selector !== activeOptions?.selector 342 ) { 343 await this.hideHighlighterType(TYPES.SELECTOR); 344 } 345 break; 346 347 // For others, hide the existing highlighter before showing it for a different node. 348 // Else, if the node is the same and options are the same, skip a duplicate call. 349 // Duplicate calls to show the highlighter for the same node are allowed 350 // if the options are different (for example, when scheduling autohide). 351 default: 352 if (nodeFront !== activeNodeFront) { 353 await this.hideHighlighterType(type); 354 } else if (deepEqual(options, activeOptions)) { 355 skipShow = true; 356 } 357 } 358 359 return skipShow; 360 } 361 362 /** 363 * Optionally run some operations before hiding a highlighter of a given type. 364 * Runs only if a highlighter of that type exists. 365 * 366 * @param {string} type 367 * highlighter type 368 * @return {Promise} 369 */ 370 #beforeHideHighlighterType(type) { 371 switch (type) { 372 // Log telemetry for hiding the flexbox and grid highlighters. 373 case TYPES.FLEXBOX: 374 case TYPES.GRID: { 375 const toolID = GLEAN_TOOL_IDS[type]; 376 const conditions = { 377 [TYPES.FLEXBOX]: () => { 378 // always stop the timer when the flexbox highlighter is about to be hidden. 379 return true; 380 }, 381 [TYPES.GRID]: () => { 382 // stop the timer only once the last grid highlighter is about to be hidden. 383 return this.gridHighlighters.size === 1; 384 }, 385 }; 386 387 if (toolID && conditions[type].call(this)) { 388 this.telemetry.toolClosed(toolID, this); 389 } 390 391 break; 392 } 393 } 394 } 395 396 /** 397 * Get the maximum number of possible active highlighter instances of a given type. 398 * 399 * @param {string} type 400 * Highlighter type 401 * @return {number} 402 * Default 1 403 */ 404 #getMaxActiveHighlighters(type) { 405 let max; 406 407 switch (type) { 408 // Grid highligthters are special (there is a parent-child relationship between 409 // subgrid and parent grid) so we suppport multiple visible instances. 410 // Grid highlighters are performance-intensive and this limit is somewhat arbitrary 411 // to guard against performance degradation. 412 case TYPES.GRID: 413 max = this.maxGridHighlighters; 414 break; 415 // By default, for all other highlighter types, only one instance may visible. 416 // Before showing a new highlighter, any other instance will be hidden. 417 default: 418 max = 1; 419 } 420 421 return max; 422 } 423 424 /** 425 * Get a highlighter instance of the given type for the given node front. 426 * 427 * @param {string} type 428 * Highlighter type. 429 * @param {NodeFront} nodeFront 430 * Node front of the element to be highlighted with the requested highlighter. 431 * @return {Promise} 432 * Promise which resolves with a highlighter instance 433 */ 434 async #getHighlighterTypeForNode(type, nodeFront) { 435 const { inspectorFront } = nodeFront; 436 const max = this.#getMaxActiveHighlighters(type); 437 let highlighter; 438 439 // If only one highlighter instance may be visible, get a highlighter front 440 // and cache it to return it on future requests. 441 // Otherwise, return a new highlighter front every time and clean-up manually. 442 if (max === 1) { 443 highlighter = await inspectorFront.getOrCreateHighlighterByType(type); 444 } else { 445 highlighter = await inspectorFront.getHighlighterByType(type); 446 } 447 448 return highlighter; 449 } 450 451 /** 452 * Get the currently active highlighter of a given type. 453 * 454 * @param {string} type 455 * Highlighter type. 456 * @return {Highlighter|null} 457 * Highlighter instance 458 * or null if no highlighter of that type is active. 459 */ 460 getActiveHighlighter(type) { 461 if (!this.#activeHighlighters.has(type)) { 462 return null; 463 } 464 465 const { highlighter } = this.#activeHighlighters.get(type); 466 return highlighter; 467 } 468 469 /** 470 * Get an object with data associated with the active highlighter of a given type. 471 * This data object contains: 472 * - nodeFront: NodeFront of the highlighted node 473 * - highlighter: Highlighter instance 474 * - options: Configuration options passed to the highlighter 475 * - timer: (Optional) index of timer set with setTimout() to autohide the highlighter 476 * Returns an empty object if a highlighter of the given type is not active. 477 * 478 * @param {string} type 479 * Highlighter type. 480 * @return {object} 481 */ 482 getDataForActiveHighlighter(type) { 483 if (!this.#activeHighlighters.has(type)) { 484 return {}; 485 } 486 487 return this.#activeHighlighters.get(type); 488 } 489 490 /** 491 * Get the configuration options of the active highlighter of a given type. 492 * 493 * @param {string} type 494 * Highlighter type. 495 * @return {object} 496 */ 497 getOptionsForActiveHighlighter(type) { 498 const { options } = this.getDataForActiveHighlighter(type); 499 return options; 500 } 501 502 /** 503 * Get the node front highlighted by a given highlighter type. 504 * 505 * @param {string} type 506 * Highlighter type. 507 * @return {NodeFront|null} 508 * Node front of the element currently being highlighted 509 * or null if no highlighter of that type is active. 510 */ 511 getNodeForActiveHighlighter(type) { 512 if (!this.#activeHighlighters.has(type)) { 513 return null; 514 } 515 516 const { nodeFront } = this.#activeHighlighters.get(type); 517 return nodeFront; 518 } 519 520 /** 521 * Highlight a given node front with a given type of highlighter. 522 * 523 * Highlighters are shown for one node at a time. Before showing the same highlighter 524 * type on another node, it will first be hidden from the previously highlighted node. 525 * In pages with frames running in different processes, this ensures highlighters from 526 * other frames do not stay visible. 527 * 528 * @param {string} type 529 * Highlighter type to show. 530 * @param {NodeFront} nodeFront 531 * Node front of the element to be highlighted. 532 * @param {Options} options 533 * Optional object with options to pass to the highlighter. 534 * @return {Promise} 535 */ 536 async showHighlighterTypeForNode(type, nodeFront, options) { 537 const promise = this.#beforeShowHighlighterTypeForNode( 538 type, 539 nodeFront, 540 options 541 ); 542 543 // Set a pending highlighter in order to detect if, while we were awaiting, there was 544 // a more recent request to highlight a node with the same type, or a request to hide 545 // the highlighter. Then we will abort this one in favor of the newer one. 546 // This needs to be done before the 'await' in order to be synchronous, but after 547 // calling #beforeShowHighlighterTypeForNode, since it can call hideHighlighterType. 548 const id = Symbol(); 549 this.#pendingHighlighters.set(type, id); 550 const skipShow = await promise; 551 552 if (this.#pendingHighlighters.get(type) !== id) { 553 return; 554 } else if (skipShow || nodeFront.isDestroyed()) { 555 this.#pendingHighlighters.delete(type); 556 return; 557 } 558 559 const highlighter = await this.#getHighlighterTypeForNode(type, nodeFront); 560 561 if (this.#pendingHighlighters.get(type) !== id) { 562 return; 563 } 564 this.#pendingHighlighters.delete(type); 565 566 // Set a timer to automatically hide the highlighter if a duration is provided. 567 const timer = this.scheduleAutoHideHighlighterType(type, options?.duration); 568 // TODO: support case for multiple highlighter instances (ex: multiple grids) 569 this.#activeHighlighters.set(type, { 570 nodeFront, 571 highlighter, 572 options, 573 timer, 574 }); 575 await highlighter.show(nodeFront, options); 576 this.#afterShowHighlighterTypeForNode(type, nodeFront, options); 577 578 // Emit any type-specific highlighter shown event for tests 579 // which have not yet been updated to listen for the generic event 580 if (HIGHLIGHTER_EVENTS[type]?.shown) { 581 this.emit(HIGHLIGHTER_EVENTS[type].shown, nodeFront, options); 582 } 583 this.emit("highlighter-shown", { type, highlighter, nodeFront, options }); 584 } 585 586 /** 587 * Set a timer to automatically hide all highlighters of a given type after a delay. 588 * 589 * @param {string} type 590 * Highlighter type to hide. 591 * @param {number | undefined} duration 592 * Delay in milliseconds after which to hide the highlighter. 593 * If a duration is not provided, return early without scheduling a task. 594 * @return {number | undefined} 595 * Index of the scheduled task returned by setTimeout(). 596 */ 597 scheduleAutoHideHighlighterType(type, duration) { 598 if (!duration) { 599 return undefined; 600 } 601 602 const timer = setTimeout(async () => { 603 await this.hideHighlighterType(type); 604 clearTimeout(timer); 605 }, duration); 606 607 return timer; 608 } 609 610 /** 611 * Hide all instances of a given highlighter type. 612 * 613 * @param {string} type 614 * Highlighter type to hide. 615 * @return {Promise} 616 */ 617 async hideHighlighterType(type) { 618 if (this.#pendingHighlighters.has(type)) { 619 // Abort pending highlighters for the given type. 620 this.#pendingHighlighters.delete(type); 621 } 622 if (!this.#activeHighlighters.has(type)) { 623 return; 624 } 625 626 const data = this.getDataForActiveHighlighter(type); 627 const { highlighter, nodeFront, timer } = data; 628 // Clear any autohide timer associated with this highlighter type. 629 clearTimeout(timer); 630 // Remove any metadata used to restore this highlighter type on page refresh. 631 this.#restorableHighlighters.delete(type); 632 this.#activeHighlighters.delete(type); 633 this.#beforeHideHighlighterType(type); 634 await highlighter.hide(); 635 636 // Emit any type-specific highlighter hidden event for tests 637 // which have not yet been updated to listen for the generic event 638 if (HIGHLIGHTER_EVENTS[type]?.hidden) { 639 this.emit(HIGHLIGHTER_EVENTS[type].hidden, nodeFront); 640 } 641 this.emit("highlighter-hidden", { type, ...data }); 642 } 643 644 /** 645 * Returns true if the grid highlighter can be toggled on/off for the given node, and 646 * false otherwise. A grid container can be toggled on if the max grid highlighters 647 * is only 1 or less than the maximum grid highlighters that can be displayed or if 648 * the grid highlighter already highlights the given node. 649 * 650 * @param {NodeFront} node 651 * Grid container NodeFront. 652 * @return {boolean} 653 */ 654 canGridHighlighterToggle(node) { 655 return ( 656 this.maxGridHighlighters === 1 || 657 this.gridHighlighters.size < this.maxGridHighlighters || 658 this.gridHighlighters.has(node) 659 ); 660 } 661 662 /** 663 * Returns true when the maximum number of grid highlighter instances is reached. 664 * FIXME: Bug 1572652 should address this constraint. 665 * 666 * @return {boolean} 667 */ 668 isGridHighlighterLimitReached() { 669 return this.gridHighlighters.size === this.maxGridHighlighters; 670 } 671 672 /** 673 * Returns whether `node` is somewhere inside the DOM of the rule view. 674 * 675 * @param {DOMNode} node 676 * @return {boolean} 677 */ 678 isRuleView(node) { 679 return !!node.closest("#ruleview-panel"); 680 } 681 682 /** 683 * Add the highlighters overlay to the view. This will start tracking mouse events 684 * and display highlighters when needed. 685 * 686 * @param {CssRuleView|CssComputedView|LayoutView} view 687 * Either the rule-view or computed-view panel to add the highlighters overlay. 688 */ 689 addToView(view) { 690 const el = view.element; 691 el.addEventListener("click", this.onClick, true); 692 el.addEventListener("mousemove", this.onMouseMove); 693 el.addEventListener("mouseout", this.onMouseOut); 694 el.ownerDocument.defaultView.addEventListener("mouseout", this.onMouseOut); 695 } 696 697 /** 698 * Remove the overlay from the given view. This will stop tracking mouse movement and 699 * showing highlighters. 700 * 701 * @param {CssRuleView|CssComputedView|LayoutView} view 702 * Either the rule-view or computed-view panel to remove the highlighters 703 * overlay. 704 */ 705 removeFromView(view) { 706 const el = view.element; 707 el.removeEventListener("click", this.onClick, true); 708 el.removeEventListener("mousemove", this.onMouseMove); 709 el.removeEventListener("mouseout", this.onMouseOut); 710 } 711 712 /** 713 * Toggle the shapes highlighter for the given node. 714 * 715 * @param {NodeFront} node 716 * The NodeFront of the element with a shape to highlight. 717 * @param {object} options 718 * Object used for passing options to the shapes highlighter. 719 * @param {TextProperty} textProperty 720 * TextProperty where to write changes. 721 */ 722 async toggleShapesHighlighter(node, options, textProperty) { 723 const shapesEditor = await this.getInContextEditor(node, "shapesEditor"); 724 if (!shapesEditor) { 725 return; 726 } 727 shapesEditor.toggle(node, options, textProperty); 728 } 729 730 /** 731 * Show the shapes highlighter for the given node. 732 * This method delegates to the in-context shapes editor. 733 * 734 * @param {NodeFront} node 735 * The NodeFront of the element with a shape to highlight. 736 * @param {object} options 737 * Object used for passing options to the shapes highlighter. 738 */ 739 async showShapesHighlighter(node, options) { 740 const shapesEditor = await this.getInContextEditor(node, "shapesEditor"); 741 if (!shapesEditor) { 742 return; 743 } 744 shapesEditor.show(node, options); 745 } 746 747 /** 748 * Called after the shape highlighter was shown. 749 * 750 * @param {object} data 751 * Data associated with the event. 752 * Contains: 753 * - {NodeFront} node: The NodeFront of the element that is highlighted. 754 * - {Object} options: Options that were passed to ShapesHighlighter.show() 755 */ 756 onShapesHighlighterShown(data) { 757 const { node, options } = data; 758 this.shapesHighlighterShown = node; 759 this.state.shapes.options = options; 760 this.emit("shapes-highlighter-shown", node, options); 761 } 762 763 /** 764 * Hide the shapes highlighter if visible. 765 * This method delegates the to the in-context shapes editor which wraps 766 * the shapes highlighter with additional functionality. 767 * 768 * @param {NodeFront} node. 769 */ 770 async hideShapesHighlighter(node) { 771 const shapesEditor = await this.getInContextEditor(node, "shapesEditor"); 772 if (!shapesEditor) { 773 return; 774 } 775 shapesEditor.hide(); 776 } 777 778 /** 779 * Called after the shapes highlighter was hidden. 780 */ 781 onShapesHighlighterHidden() { 782 this.emit( 783 "shapes-highlighter-hidden", 784 this.shapesHighlighterShown, 785 this.state.shapes.options 786 ); 787 this.shapesHighlighterShown = null; 788 this.state.shapes = {}; 789 } 790 791 /** 792 * Show the shapes highlighter for the given element, with the given point highlighted. 793 * 794 * @param {NodeFront} node 795 * The NodeFront of the element to highlight. 796 * @param {string} point 797 * The point to highlight in the shapes highlighter. 798 */ 799 async hoverPointShapesHighlighter(node, point) { 800 if (node == this.shapesHighlighterShown) { 801 const options = Object.assign({}, this.state.shapes.options); 802 options.hoverPoint = point; 803 await this.showShapesHighlighter(node, options); 804 } 805 } 806 807 /** 808 * Returns the flexbox highlighter color for the given node. 809 */ 810 async getFlexboxHighlighterColor() { 811 // Load the Redux slice for flexbox if not yet available. 812 const state = this.store.getState(); 813 if (!state.flexbox) { 814 this.store.injectReducer("flexbox", flexboxReducer); 815 } 816 817 // Attempt to get the flexbox highlighter color from the Redux store. 818 const { flexbox } = this.store.getState(); 819 const color = flexbox.color; 820 821 if (color) { 822 return color; 823 } 824 825 // If the flexbox inspector has not been initialized, attempt to get the flexbox 826 // highlighter from the async storage. 827 const customHostColors = 828 (await asyncStorage.getItem("flexboxInspectorHostColors")) || {}; 829 830 // Get the hostname, if there is no hostname, fall back on protocol 831 // ex: `data:` uri, and `about:` pages 832 let hostname; 833 try { 834 hostname = 835 parseURL(this.target.url).hostname || 836 parseURL(this.target.url).protocol; 837 } catch (e) { 838 this.#handleRejection(e); 839 } 840 841 return hostname && customHostColors[hostname] 842 ? customHostColors[hostname] 843 : DEFAULT_HIGHLIGHTER_COLOR; 844 } 845 846 /** 847 * Toggle the flexbox highlighter for the given flexbox container element. 848 * 849 * @param {NodeFront} node 850 * The NodeFront of the flexbox container element to highlight. 851 * @param {string} trigger 852 * String name matching "layout", "markup" or "rule" to indicate where the 853 * flexbox highlighter was toggled on from. "layout" represents the layout view. 854 * "markup" represents the markup view. "rule" represents the rule view. 855 */ 856 async toggleFlexboxHighlighter(node, trigger) { 857 const highlightedNode = this.getNodeForActiveHighlighter(TYPES.FLEXBOX); 858 if (node == highlightedNode) { 859 await this.hideFlexboxHighlighter(node); 860 return; 861 } 862 863 await this.showFlexboxHighlighter(node, {}, trigger); 864 } 865 866 /** 867 * Show the flexbox highlighter for the given flexbox container element. 868 * 869 * @param {NodeFront} node 870 * The NodeFront of the flexbox container element to highlight. 871 * @param {object} options 872 * Object used for passing options to the flexbox highlighter. 873 * @param {string} trigger 874 * String name matching "layout", "markup" or "rule" to indicate where the 875 * flexbox highlighter was toggled on from. "layout" represents the layout view. 876 * "markup" represents the markup view. "rule" represents the rule view. 877 * Will be passed as an option even though the highlighter doesn't use it 878 * in order to log telemetry in #afterShowHighlighterTypeForNode() 879 */ 880 async showFlexboxHighlighter(node, options, trigger) { 881 const color = await this.getFlexboxHighlighterColor(node); 882 await this.showHighlighterTypeForNode(TYPES.FLEXBOX, node, { 883 ...options, 884 trigger, 885 color, 886 }); 887 } 888 889 /** 890 * Hide the flexbox highlighter if any instance is visible. 891 */ 892 async hideFlexboxHighlighter() { 893 await this.hideHighlighterType(TYPES.FLEXBOX); 894 } 895 896 /** 897 * Create a grid highlighter settings object for the provided nodeFront. 898 * 899 * @param {NodeFront} nodeFront 900 * The NodeFront for which we need highlighter settings. 901 */ 902 getGridHighlighterSettings(nodeFront) { 903 // Load the Redux slices for grids and grid highlighter settings if not yet available. 904 const state = this.store.getState(); 905 if (!state.grids) { 906 this.store.injectReducer("grids", gridsReducer); 907 } 908 909 if (!state.highlighterSettings) { 910 this.store.injectReducer( 911 "highlighterSettings", 912 highlighterSettingsReducer 913 ); 914 } 915 916 // Get grids and grid highlighter settings from the latest Redux state 917 // in case they were just added above. 918 const { grids, highlighterSettings } = this.store.getState(); 919 const grid = grids.find(g => g.nodeFront === nodeFront); 920 const color = grid ? grid.color : DEFAULT_HIGHLIGHTER_COLOR; 921 const zIndex = grid ? grid.zIndex : 0; 922 return Object.assign({}, highlighterSettings, { color, zIndex }); 923 } 924 925 /** 926 * Return a list of all node fronts that are highlighted with a Grid highlighter. 927 * 928 * @return {Array} 929 */ 930 getHighlightedGridNodes() { 931 return [...Array.from(this.gridHighlighters.keys())]; 932 } 933 934 /** 935 * Toggle the grid highlighter for the given grid container element. 936 * 937 * @param {NodeFront} node 938 * The NodeFront of the grid container element to highlight. 939 * @param {string} trigger 940 * String name matching "grid", "markup" or "rule" to indicate where the 941 * grid highlighter was toggled on from. "grid" represents the grid view. 942 * "markup" represents the markup view. "rule" represents the rule view. 943 */ 944 async toggleGridHighlighter(node, trigger) { 945 if (this.gridHighlighters.has(node)) { 946 await this.hideGridHighlighter(node); 947 return; 948 } 949 950 await this.showGridHighlighter(node, {}, trigger); 951 } 952 953 /** 954 * Show the grid highlighter for the given grid container element. 955 * Allow as many active highlighter instances as permitted by the 956 * maxGridHighlighters limit (default 3). 957 * 958 * Logic of showing grid highlighters: 959 * - GRID: 960 * - Show a highlighter for a grid container when explicitly requested 961 * (ex. click badge in Markup view) and count it against the limit. 962 * - When the limit of active highlighters is reached, do no show any more 963 * until other instances are hidden. If configured to show only one instance, 964 * hide the existing highlighter before showing a new one. 965 * 966 * - SUBGRID: 967 * - When a highlighter for a subgrid is shown, also show a highlighter for its parent 968 * grid, but with faded-out colors (serves as a visual reference for the subgrid) 969 * - The "active" state of the highlighter for the parent grid is not reflected 970 * in the UI (checkboxes in the Layout panel, badges in the Markup view, etc.) 971 * - The highlighter for the parent grid DOES NOT count against the highlighter limit 972 * - If the highlighter for the parent grid is explicitly requested to be shown 973 * (ex: click badge in Markup view), show it in full color and reflect its "active" 974 * state in the UI (checkboxes in the Layout panel, badges in the Markup view) 975 * - When a highlighter for a subgrid is hidden, also hide the highlighter for its 976 * parent grid; if the parent grid was explicitly requested separately, keep the 977 * highlighter for the parent grid visible, but show it in full color. 978 * 979 * @param {NodeFront} node 980 * The NodeFront of the grid container element to highlight. 981 * @param {object} options 982 * Object used for passing options to the grid highlighter. 983 * @param {string} trigger 984 * String name matching "grid", "markup" or "rule" to indicate where the 985 * grid highlighter was toggled on from. "grid" represents the grid view. 986 * "markup" represents the markup view. "rule" represents the rule view. 987 */ 988 async showGridHighlighter(node, options, trigger) { 989 if (!this.gridHighlighters.has(node)) { 990 // If only one grid highlighter can be shown at a time, hide the other instance. 991 // Otherwise, if the max highlighter limit is reached, do not show another one. 992 if (this.maxGridHighlighters === 1) { 993 await this.hideGridHighlighter( 994 this.gridHighlighters.keys().next().value 995 ); 996 } else if (this.gridHighlighters.size === this.maxGridHighlighters) { 997 return; 998 } 999 } 1000 1001 // If the given node is already highlighted as the parent grid for a subgrid, 1002 // hide the parent grid highlighter because it will be explicitly shown below. 1003 const isHighlightedAsParentGrid = Array.from(this.gridHighlighters.values()) 1004 .map(value => value.parentGridNode) 1005 .includes(node); 1006 if (isHighlightedAsParentGrid) { 1007 await this.hideParentGridHighlighter(node); 1008 } 1009 1010 // Show a translucent highlight of the parent grid container if the given node is 1011 // a subgrid and the parent grid container is not already explicitly highlighted. 1012 let parentGridNode = null; 1013 let parentGridHighlighter = null; 1014 if (node.displayType === "subgrid") { 1015 parentGridNode = await node.walkerFront.getParentGridNode(node); 1016 parentGridHighlighter = 1017 await this.showParentGridHighlighter(parentGridNode); 1018 } 1019 1020 // When changing highlighter colors, we call highlighter.show() again with new options 1021 // Reuse the active highlighter instance if present; avoid creating new highlighters 1022 let highlighter; 1023 if (this.gridHighlighters.has(node)) { 1024 highlighter = this.gridHighlighters.get(node).highlighter; 1025 } 1026 1027 if (!highlighter) { 1028 highlighter = await this.#getHighlighterTypeForNode(TYPES.GRID, node); 1029 } 1030 1031 this.gridHighlighters.set(node, { 1032 highlighter, 1033 parentGridNode, 1034 parentGridHighlighter, 1035 }); 1036 1037 options = { ...options, ...this.getGridHighlighterSettings(node) }; 1038 await highlighter.show(node, options); 1039 1040 this.#afterShowHighlighterTypeForNode(TYPES.GRID, node, { 1041 ...options, 1042 trigger, 1043 }); 1044 1045 try { 1046 // Save grid highlighter state. 1047 const { url } = this.target; 1048 1049 const selectors = 1050 await this.inspector.commands.inspectorCommand.getNodeFrontSelectorsFromTopDocument( 1051 node 1052 ); 1053 1054 this.state.grids.set(node, { selectors, options, url }); 1055 1056 // Emit the NodeFront of the grid container element that the grid highlighter was 1057 // shown for, and its options for testing the highlighter setting options. 1058 this.emit("grid-highlighter-shown", node, options); 1059 1060 // XXX: Shim to use generic highlighter events until addressing Bug 1572652 1061 // Ensures badges in the Markup view reflect the state of the grid highlighter. 1062 this.emit("highlighter-shown", { 1063 type: TYPES.GRID, 1064 nodeFront: node, 1065 highlighter, 1066 options, 1067 }); 1068 } catch (e) { 1069 this.#handleRejection(e); 1070 } 1071 } 1072 1073 /** 1074 * Show the grid highlighter for the given subgrid's parent grid container element. 1075 * The parent grid highlighter is shown with faded-out colors, as opposed 1076 * to the full-color grid highlighter shown when calling showGridHighlighter(). 1077 * If the grid container is already explicitly highlighted (i.e. standalone grid), 1078 * skip showing the another grid highlighter for it. 1079 * 1080 * @param {NodeFront} node 1081 * The NodeFront of the parent grid container element to highlight. 1082 * @returns {Promise} 1083 * Resolves with either the highlighter instance or null if it was skipped. 1084 */ 1085 async showParentGridHighlighter(node) { 1086 const isHighlighted = Array.from(this.gridHighlighters.keys()).includes( 1087 node 1088 ); 1089 1090 if (!node || isHighlighted) { 1091 return null; 1092 } 1093 1094 // Get the parent grid highlighter for the parent grid container if one already exists 1095 let highlighter = this.getParentGridHighlighter(node); 1096 if (!highlighter) { 1097 highlighter = await this.#getHighlighterTypeForNode(TYPES.GRID, node); 1098 } 1099 const options = { 1100 ...this.getGridHighlighterSettings(node), 1101 // Configure the highlighter with faded-out colors. 1102 globalAlpha: SUBGRID_PARENT_ALPHA, 1103 isParent: true, 1104 }; 1105 await highlighter.show(node, options); 1106 1107 this.emitForTests("highlighter-shown", { 1108 type: TYPES.GRID, 1109 nodeFront: node, 1110 highlighter, 1111 options, 1112 }); 1113 1114 return highlighter; 1115 } 1116 1117 /** 1118 * Get the parent grid highlighter associated with the given node 1119 * if the node is a parent grid container for a highlighted subgrid. 1120 * 1121 * @param {NodeFront} node 1122 * NodeFront of the parent grid container for a subgrid. 1123 * @return {CustomHighlighterFront|null} 1124 */ 1125 getParentGridHighlighter(node) { 1126 // Find the highlighter map value for the subgrid whose parent grid is the given node. 1127 const value = Array.from(this.gridHighlighters.values()).find( 1128 ({ parentGridNode }) => { 1129 return parentGridNode === node; 1130 } 1131 ); 1132 1133 if (!value) { 1134 return null; 1135 } 1136 1137 const { parentGridHighlighter } = value; 1138 return parentGridHighlighter; 1139 } 1140 1141 /** 1142 * Restore the parent grid highlighter for a subgrid. 1143 * 1144 * A grid node can be highlighted both explicitly (ex: by clicking a badge in the 1145 * Markup view) and implicitly, as a parent grid for a subgrid. 1146 * 1147 * An explicit grid highlighter overwrites a subgrid's parent grid highlighter. 1148 * After an explicit grid highlighter for a node is hidden, but that node is also the 1149 * parent grid container for a subgrid which is still highlighted, restore the implicit 1150 * parent grid highlighter. 1151 * 1152 * @param {NodeFront} node 1153 * NodeFront for a grid node which may also be a subgrid's parent grid 1154 * container. 1155 * @return {Promise} 1156 */ 1157 async restoreParentGridHighlighter(node) { 1158 // Find the highlighter map entry for the subgrid whose parent grid is the given node. 1159 const entry = Array.from(this.gridHighlighters.entries()).find( 1160 ([, value]) => { 1161 return value?.parentGridNode === node; 1162 } 1163 ); 1164 1165 if (!Array.isArray(entry)) { 1166 return; 1167 } 1168 1169 const [highlightedSubgridNode, data] = entry; 1170 if (!data.parentGridHighlighter) { 1171 const parentGridHighlighter = await this.showParentGridHighlighter(node); 1172 this.gridHighlighters.set(highlightedSubgridNode, { 1173 ...data, 1174 parentGridHighlighter, 1175 }); 1176 } 1177 } 1178 1179 /** 1180 * Hide the grid highlighter for the given grid container element. 1181 * 1182 * @param {NodeFront} node 1183 * The NodeFront of the grid container element to unhighlight. 1184 */ 1185 async hideGridHighlighter(node) { 1186 const { highlighter, parentGridNode } = 1187 this.gridHighlighters.get(node) || {}; 1188 1189 if (!highlighter) { 1190 return; 1191 } 1192 1193 // Hide the subgrid's parent grid highlighter, if any. 1194 if (parentGridNode) { 1195 await this.hideParentGridHighlighter(parentGridNode); 1196 } 1197 1198 this.#beforeHideHighlighterType(TYPES.GRID); 1199 // Don't just hide the highlighter, destroy the front instance to release memory. 1200 // If another highlighter is shown later, a new front will be created. 1201 highlighter.destroy(); 1202 this.gridHighlighters.delete(node); 1203 this.state.grids.delete(node); 1204 1205 // It's possible we just destroyed the grid highlighter for a node which also serves 1206 // as a subgrid's parent grid. If so, restore the parent grid highlighter. 1207 await this.restoreParentGridHighlighter(node); 1208 1209 // Emit the NodeFront of the grid container element that the grid highlighter was 1210 // hidden for. 1211 this.emit("grid-highlighter-hidden", node); 1212 1213 // XXX: Shim to use generic highlighter events until addressing Bug 1572652 1214 // Ensures badges in the Markup view reflect the state of the grid highlighter. 1215 this.emit("highlighter-hidden", { 1216 type: TYPES.GRID, 1217 nodeFront: node, 1218 }); 1219 } 1220 1221 /** 1222 * Hide the parent grid highlighter for the given parent grid container element. 1223 * If there are multiple subgrids with the same parent grid, do not hide the parent 1224 * grid highlighter. 1225 * 1226 * @param {NodeFront} node 1227 * The NodeFront of the parent grid container element to unhiglight. 1228 */ 1229 async hideParentGridHighlighter(node) { 1230 let count = 0; 1231 let parentGridHighlighter; 1232 let subgridNode; 1233 for (const [key, value] of this.gridHighlighters.entries()) { 1234 if (value.parentGridNode === node) { 1235 parentGridHighlighter = value.parentGridHighlighter; 1236 subgridNode = key; 1237 count++; 1238 } 1239 } 1240 1241 if (!parentGridHighlighter || count > 1) { 1242 return; 1243 } 1244 1245 // Destroy the highlighter front instance to release memory. 1246 parentGridHighlighter.destroy(); 1247 1248 // Update the grid highlighter entry to indicate the parent grid highlighter is gone. 1249 this.gridHighlighters.set(subgridNode, { 1250 ...this.gridHighlighters.get(subgridNode), 1251 parentGridHighlighter: null, 1252 }); 1253 } 1254 1255 /** 1256 * Toggle the geometry editor highlighter for the given element. 1257 * 1258 * @param {NodeFront} node 1259 * The NodeFront of the element to highlight. 1260 */ 1261 async toggleGeometryHighlighter(node) { 1262 if (node == this.geometryEditorHighlighterShown) { 1263 await this.hideGeometryEditor(); 1264 return; 1265 } 1266 1267 await this.showGeometryEditor(node); 1268 } 1269 1270 /** 1271 * Show the geometry editor highlightor for the given element. 1272 * 1273 * @param {NodeFront} node 1274 * THe NodeFront of the element to highlight. 1275 */ 1276 async showGeometryEditor(node) { 1277 const highlighter = await this.#getHighlighterTypeForNode( 1278 TYPES.GEOMETRY, 1279 node 1280 ); 1281 if (!highlighter) { 1282 return; 1283 } 1284 1285 const isShown = await highlighter.show(node); 1286 if (!isShown) { 1287 return; 1288 } 1289 1290 this.emit("geometry-editor-highlighter-shown"); 1291 this.geometryEditorHighlighterShown = node; 1292 } 1293 1294 /** 1295 * Hide the geometry editor highlighter. 1296 */ 1297 async hideGeometryEditor() { 1298 if (!this.geometryEditorHighlighterShown) { 1299 return; 1300 } 1301 1302 const highlighter = 1303 this.geometryEditorHighlighterShown.inspectorFront.getKnownHighlighter( 1304 TYPES.GEOMETRY 1305 ); 1306 1307 if (!highlighter) { 1308 return; 1309 } 1310 1311 await highlighter.hide(); 1312 1313 this.emit("geometry-editor-highlighter-hidden"); 1314 this.geometryEditorHighlighterShown = null; 1315 } 1316 1317 /** 1318 * Restores the saved flexbox highlighter state. 1319 */ 1320 async restoreFlexboxState() { 1321 const state = this.#restorableHighlighters.get(TYPES.FLEXBOX); 1322 if (!state) { 1323 return; 1324 } 1325 1326 this.#restorableHighlighters.delete(TYPES.FLEXBOX); 1327 await this.restoreState(TYPES.FLEXBOX, state, this.showFlexboxHighlighter); 1328 } 1329 1330 /** 1331 * Restores the saved grid highlighter state. 1332 */ 1333 async restoreGridState() { 1334 // The NodeFronts that are used as the keys in the grid state Map are no longer in the 1335 // tree after a reload. To clean up the grid state, we create a copy of the values of 1336 // the grid state before restoring and clear it. 1337 const values = [...this.state.grids.values()]; 1338 this.state.grids.clear(); 1339 1340 try { 1341 for (const gridState of values) { 1342 await this.restoreState( 1343 TYPES.GRID, 1344 gridState, 1345 this.showGridHighlighter 1346 ); 1347 } 1348 } catch (e) { 1349 this.#handleRejection(e); 1350 } 1351 } 1352 1353 /** 1354 * Helper function called by restoreFlexboxState, restoreGridState. 1355 * Restores the saved highlighter state for the given highlighter 1356 * and their state. 1357 * 1358 * @param {string} type 1359 * Highlighter type to be restored. 1360 * @param {object} state 1361 * Object containing the metadata used to restore the highlighter. 1362 * {Array} state.selectors 1363 * Array of CSS selector which identifies the node to be highlighted. 1364 * If the node is in the top-level document, the array contains just one item. 1365 * Otherwise, if the node is nested within a stack of iframes, each iframe is 1366 * identified by its unique selector; the last item in the array identifies 1367 * the target node within its host iframe document. 1368 * {Object} state.options 1369 * Configuration options to use when showing the highlighter. 1370 * {String} state.url 1371 * URL of the top-level target when the metadata was stored. Used to identify 1372 * if there was a page refresh or a navigation away to a different page. 1373 * @param {Function} showFunction 1374 * The function that shows the highlighter 1375 * @return {Promise} that resolves when the highlighter was restored and shown. 1376 */ 1377 async restoreState(type, state, showFunction) { 1378 const { selectors = [], options, url } = state; 1379 1380 if (!selectors.length || url !== this.target.url) { 1381 // Bail out if no selector was saved, or if we are on a different page. 1382 this.emit(`highlighter-discarded`, { type }); 1383 return; 1384 } 1385 1386 const nodeFront = 1387 await this.inspector.commands.inspectorCommand.findNodeFrontFromSelectors( 1388 selectors 1389 ); 1390 1391 if (nodeFront) { 1392 await showFunction(nodeFront, options); 1393 this.emit(`highlighter-restored`, { type }); 1394 } else { 1395 this.emit(`highlighter-discarded`, { type }); 1396 } 1397 } 1398 1399 /** 1400 * Get an instance of an in-context editor for the given type. 1401 * 1402 * In-context editors behave like highlighters but with added editing capabilities which 1403 * need to write value changes back to something, like to properties in the Rule view. 1404 * They typically exist in the context of the page, like the ShapesInContextEditor. 1405 * 1406 * @param {NodeFront} node. 1407 * @param {string} type 1408 * Type of in-context editor. Currently supported: "shapesEditor" 1409 * @return {object | null} 1410 * Reference to instance for given type of in-context editor or null. 1411 */ 1412 async getInContextEditor(node, type) { 1413 if (this.editors[type]) { 1414 return this.editors[type]; 1415 } 1416 1417 let editor; 1418 1419 switch (type) { 1420 case "shapesEditor": { 1421 const highlighter = await this.#getHighlighterTypeForNode( 1422 TYPES.SHAPES, 1423 node 1424 ); 1425 if (!highlighter) { 1426 return null; 1427 } 1428 const ShapesInContextEditor = require("resource://devtools/client/shared/widgets/ShapesInContextEditor.js"); 1429 1430 editor = new ShapesInContextEditor( 1431 highlighter, 1432 this.inspector, 1433 this.state 1434 ); 1435 editor.on("show", this.onShapesHighlighterShown); 1436 editor.on("hide", this.onShapesHighlighterHidden); 1437 break; 1438 } 1439 default: 1440 throw new Error(`Unsupported in-context editor '${name}'`); 1441 } 1442 1443 this.editors[type] = editor; 1444 1445 return editor; 1446 } 1447 1448 /** 1449 * Get a highlighter front given a type. It will only be initialized once. 1450 * 1451 * @param {string} type 1452 * The highlighter type. One of this.highlighters. 1453 * @return {Promise} that resolves to the highlighter 1454 */ 1455 async #getHighlighter(type) { 1456 if (this.highlighters[type]) { 1457 return this.highlighters[type]; 1458 } 1459 1460 let highlighter; 1461 1462 try { 1463 highlighter = await this.inspectorFront.getHighlighterByType(type); 1464 } catch (e) { 1465 this.#handleRejection(e); 1466 } 1467 1468 if (!highlighter) { 1469 return null; 1470 } 1471 1472 this.highlighters[type] = highlighter; 1473 return highlighter; 1474 } 1475 1476 /** 1477 * Ignore unexpected errors from async function calls 1478 * if HighlightersOverlay has been destroyed. 1479 * 1480 * @param {Error} error 1481 */ 1482 #handleRejection = error => { 1483 if (!this.destroyed) { 1484 console.error(error); 1485 } 1486 }; 1487 1488 /** 1489 * Toggle the class "active" on the given shape point in the rule view if the current 1490 * inspector selection is highlighted by the shapes highlighter. 1491 * 1492 * @param {NodeFront} node 1493 * The NodeFront of the shape point to toggle 1494 * @param {boolean} active 1495 * Whether the shape point should be active 1496 */ 1497 _toggleShapePointActive(node, active) { 1498 if (this.inspector.selection.nodeFront != this.shapesHighlighterShown) { 1499 return; 1500 } 1501 1502 node.classList.toggle("active", active); 1503 } 1504 1505 /** 1506 * Hide the currently shown hovered highlighter. 1507 */ 1508 #hideHoveredHighlighter() { 1509 if ( 1510 !this.hoveredHighlighterShown || 1511 !this.highlighters[this.hoveredHighlighterShown] 1512 ) { 1513 return; 1514 } 1515 1516 // For some reason, the call to highlighter.hide doesn't always return a 1517 // promise. This causes some tests to fail when trying to install a 1518 // rejection handler on the result of the call. To avoid this, check 1519 // whether the result is truthy before installing the handler. 1520 const onHidden = this.highlighters[this.hoveredHighlighterShown].hide(); 1521 if (onHidden) { 1522 onHidden.catch(console.error); 1523 } 1524 1525 this.hoveredHighlighterShown = null; 1526 this.emit("css-transform-highlighter-hidden"); 1527 } 1528 1529 /** 1530 * Given a node front and a function that hides the given node's highlighter, hides 1531 * the highlighter if the node front is no longer in the DOM tree. This is called 1532 * from the "markupmutation" event handler. 1533 * 1534 * @param {NodeFront} node 1535 * The NodeFront of a highlighted DOM node. 1536 * @param {Function} hideHighlighter 1537 * The function that will hide the highlighter of the highlighted node. 1538 */ 1539 async #hideHighlighterIfDeadNode(node, hideHighlighter) { 1540 if (!node) { 1541 return; 1542 } 1543 1544 try { 1545 const isInTree = 1546 node.walkerFront && (await node.walkerFront.isInDOMTree(node)); 1547 if (!isInTree) { 1548 await hideHighlighter(node); 1549 } 1550 } catch (e) { 1551 this.#handleRejection(e); 1552 } 1553 } 1554 1555 /** 1556 * Is the current hovered node a css transform property value in the 1557 * computed-view. 1558 * 1559 * @param {object} nodeInfo 1560 * @return {boolean} 1561 */ 1562 #isComputedViewTransform(nodeInfo) { 1563 if (nodeInfo.view != "computed") { 1564 return false; 1565 } 1566 return ( 1567 nodeInfo.type === VIEW_NODE_VALUE_TYPE && 1568 nodeInfo.value.property === "transform" 1569 ); 1570 } 1571 1572 /** 1573 * Does the current clicked node have the shapes highlighter toggle in the 1574 * rule-view. 1575 * 1576 * @param {DOMNode} node 1577 * @return {boolean} 1578 */ 1579 #isRuleViewShapeSwatch(node) { 1580 return ( 1581 this.isRuleView(node) && node.classList.contains("inspector-shapeswatch") 1582 ); 1583 } 1584 1585 /** 1586 * Is the current hovered node a css transform property value in the rule-view. 1587 * 1588 * @param {object} nodeInfo 1589 * @return {boolean} 1590 */ 1591 #isRuleViewTransform(nodeInfo) { 1592 if (nodeInfo.view != "rule") { 1593 return false; 1594 } 1595 const isTransform = 1596 nodeInfo.type === VIEW_NODE_VALUE_TYPE && 1597 nodeInfo.value.property === "transform"; 1598 const isEnabled = 1599 nodeInfo.value.enabled && 1600 !nodeInfo.value.overridden && 1601 !nodeInfo.value.pseudoElement; 1602 return isTransform && isEnabled; 1603 } 1604 1605 /** 1606 * Is the current hovered node a highlightable shape point in the rule-view. 1607 * 1608 * @param {object} nodeInfo 1609 * @return {boolean} 1610 */ 1611 isRuleViewShapePoint(nodeInfo) { 1612 if (nodeInfo.view != "rule") { 1613 return false; 1614 } 1615 const isShape = 1616 nodeInfo.type === VIEW_NODE_SHAPE_POINT_TYPE && 1617 (nodeInfo.value.property === "clip-path" || 1618 nodeInfo.value.property === "shape-outside"); 1619 const isEnabled = 1620 nodeInfo.value.enabled && 1621 !nodeInfo.value.overridden && 1622 !nodeInfo.value.pseudoElement; 1623 return ( 1624 isShape && 1625 isEnabled && 1626 nodeInfo.value.toggleActive && 1627 !this.state.shapes.options.transformMode 1628 ); 1629 } 1630 1631 onClick(event) { 1632 if (this.#isRuleViewShapeSwatch(event.target)) { 1633 event.stopPropagation(); 1634 1635 const view = this.inspector.getPanel("ruleview").view; 1636 const nodeInfo = view.getNodeInfo(event.target); 1637 1638 this.toggleShapesHighlighter( 1639 this.inspector.selection.nodeFront, 1640 { 1641 mode: event.target.dataset.mode, 1642 transformMode: event.metaKey || event.ctrlKey, 1643 }, 1644 nodeInfo.value.textProperty 1645 ); 1646 } 1647 } 1648 1649 /** 1650 * Handler for "display-change" events from walker fronts. Hides the flexbox or 1651 * grid highlighter if their respective node is no longer a flex container or 1652 * grid container. 1653 * 1654 * @param {Array} nodes 1655 * An array of nodeFronts 1656 */ 1657 async onDisplayChange(nodes) { 1658 const highlightedGridNodes = this.getHighlightedGridNodes(); 1659 1660 for (const node of nodes) { 1661 const display = node.displayType; 1662 1663 // Hide the flexbox highlighter if the node is no longer a flexbox container. 1664 if ( 1665 display !== "flex" && 1666 display !== "inline-flex" && 1667 node == this.getNodeForActiveHighlighter(TYPES.FLEXBOX) 1668 ) { 1669 await this.hideFlexboxHighlighter(node); 1670 return; 1671 } 1672 1673 // Hide the grid highlighter if the node is no longer a grid container. 1674 if ( 1675 display !== "grid" && 1676 display !== "inline-grid" && 1677 display !== "subgrid" && 1678 highlightedGridNodes.includes(node) 1679 ) { 1680 await this.hideGridHighlighter(node); 1681 return; 1682 } 1683 } 1684 } 1685 1686 onMouseMove(event) { 1687 // Bail out if the target is the same as for the last mousemove. 1688 if (event.target === this.#lastHovered) { 1689 return; 1690 } 1691 1692 // Only one highlighter can be displayed at a time, hide the currently shown. 1693 this.#hideHoveredHighlighter(); 1694 1695 this.#lastHovered = event.target; 1696 1697 const view = this.isRuleView(this.#lastHovered) 1698 ? this.inspector.getPanel("ruleview").view 1699 : this.inspector.getPanel("computedview").computedView; 1700 const nodeInfo = view.getNodeInfo(event.target); 1701 if (!nodeInfo) { 1702 return; 1703 } 1704 1705 if (this.isRuleViewShapePoint(nodeInfo)) { 1706 const { point } = nodeInfo.value; 1707 this.hoverPointShapesHighlighter( 1708 this.inspector.selection.nodeFront, 1709 point 1710 ); 1711 return; 1712 } 1713 1714 // Choose the type of highlighter required for the hovered node. 1715 let type; 1716 if ( 1717 this.#isRuleViewTransform(nodeInfo) || 1718 this.#isComputedViewTransform(nodeInfo) 1719 ) { 1720 type = TYPES.TRANSFORM; 1721 } 1722 1723 if (type) { 1724 this.hoveredHighlighterShown = type; 1725 const node = this.inspector.selection.nodeFront; 1726 this.#getHighlighter(type).then(highlighter => 1727 highlighter.show(node).then(shown => { 1728 if (shown) { 1729 this.emit("css-transform-highlighter-shown", highlighter); 1730 } 1731 }) 1732 ); 1733 } 1734 } 1735 1736 onMouseOut(event) { 1737 // Only hide the highlighter if the mouse leaves the currently hovered node. 1738 if ( 1739 !this.#lastHovered || 1740 (event && this.#lastHovered.contains(event.relatedTarget)) 1741 ) { 1742 return; 1743 } 1744 1745 // Otherwise, hide the highlighter. 1746 const view = this.isRuleView(this.#lastHovered) 1747 ? this.inspector.getPanel("ruleview").view 1748 : this.inspector.getPanel("computedview").computedView; 1749 const nodeInfo = view.getNodeInfo(this.#lastHovered); 1750 if (nodeInfo && this.isRuleViewShapePoint(nodeInfo)) { 1751 this.hoverPointShapesHighlighter( 1752 this.inspector.selection.nodeFront, 1753 null 1754 ); 1755 } 1756 this.#lastHovered = null; 1757 this.#hideHoveredHighlighter(); 1758 } 1759 1760 /** 1761 * Handler function called when a new root-node has been added in the 1762 * inspector. Nodes may have been added / removed and highlighters should 1763 * be updated. 1764 */ 1765 #onResourceAvailable = async resources => { 1766 for (const resource of resources) { 1767 if ( 1768 resource.resourceType !== this.resourceCommand.TYPES.ROOT_NODE || 1769 // It might happen that the ROOT_NODE resource (which is a Front) is already 1770 // destroyed, and in such case we want to ignore it. 1771 resource.isDestroyed() 1772 ) { 1773 // Only handle root-node resources. 1774 // Note that we could replace this with DOCUMENT_EVENT resources, since 1775 // the actual root-node resource is not used here. 1776 continue; 1777 } 1778 1779 if (resource.targetFront.isTopLevel && resource.isTopLevelDocument) { 1780 // The topmost root node will lead to the destruction and recreation of 1781 // the MarkupView, and highlighters will be refreshed afterwards. This is 1782 // handled by the inspector. 1783 continue; 1784 } 1785 1786 await this.#hideOrphanedHighlighters(); 1787 } 1788 }; 1789 1790 /** 1791 * Handler function for "markupmutation" events. Hides the flexbox/grid/shapes 1792 * highlighter if the flexbox/grid/shapes container is no longer in the DOM tree. 1793 */ 1794 async onMarkupMutation(mutations) { 1795 const hasInterestingMutation = mutations.some( 1796 mut => mut.type === "childList" 1797 ); 1798 if (!hasInterestingMutation) { 1799 // Bail out if the mutations did not remove nodes, or if no grid highlighter is 1800 // displayed. 1801 return; 1802 } 1803 1804 await this.#hideOrphanedHighlighters(); 1805 } 1806 1807 /** 1808 * Hide every active highlighter whose nodeFront is no longer present in the DOM. 1809 * Returns a promise that resolves when all orphaned highlighters are hidden. 1810 * 1811 * @return {Promise} 1812 */ 1813 async #hideOrphanedHighlighters() { 1814 await this.#hideHighlighterIfDeadNode( 1815 this.shapesHighlighterShown, 1816 this.hideShapesHighlighter 1817 ); 1818 1819 // Hide all active highlighters whose nodeFront is no longer attached. 1820 const promises = []; 1821 for (const [type, data] of this.#activeHighlighters) { 1822 promises.push( 1823 this.#hideHighlighterIfDeadNode(data.nodeFront, () => { 1824 return this.hideHighlighterType(type); 1825 }) 1826 ); 1827 } 1828 1829 const highlightedGridNodes = this.getHighlightedGridNodes(); 1830 for (const node of highlightedGridNodes) { 1831 promises.push( 1832 this.#hideHighlighterIfDeadNode(node, this.hideGridHighlighter) 1833 ); 1834 } 1835 1836 return Promise.all(promises); 1837 } 1838 1839 /** 1840 * Hides any visible highlighter and clear internal state. This should be called to 1841 * have a clean slate, for example when the page navigates or when a given frame is 1842 * selected in the iframe picker. 1843 */ 1844 async hideAllHighlighters() { 1845 this.destroyEditors(); 1846 1847 // Hide any visible highlighters and clear any timers set to autohide highlighters. 1848 for (const { highlighter, timer } of this.#activeHighlighters.values()) { 1849 await highlighter.hide(); 1850 clearTimeout(timer); 1851 } 1852 1853 this.#activeHighlighters.clear(); 1854 this.#pendingHighlighters.clear(); 1855 this.gridHighlighters.clear(); 1856 1857 this.geometryEditorHighlighterShown = null; 1858 this.hoveredHighlighterShown = null; 1859 this.shapesHighlighterShown = null; 1860 } 1861 1862 /** 1863 * Display a message about the simple highlighters which can be enabled for 1864 * users relying on prefers-reduced-motion. This message will be a toolbox 1865 * notification, which will contain a button to open the settings panel and 1866 * will no longer be displayed if the user decides to explicitly close the 1867 * message. 1868 */ 1869 #showSimpleHighlightersMessage() { 1870 const pref = "devtools.inspector.simple-highlighters.message-dismissed"; 1871 const messageDismissed = Services.prefs.getBoolPref(pref, false); 1872 if (messageDismissed) { 1873 return; 1874 } 1875 const notificationBox = this.inspector.toolbox.getNotificationBox(); 1876 const message = HighlightersBundle.formatValueSync( 1877 "simple-highlighters-message" 1878 ); 1879 1880 notificationBox.appendNotification( 1881 message, 1882 "simple-highlighters-message", 1883 null, 1884 notificationBox.PRIORITY_INFO_MEDIUM, 1885 [ 1886 { 1887 label: HighlightersBundle.formatValueSync( 1888 "simple-highlighters-settings-button" 1889 ), 1890 callback: async () => { 1891 const { panelDoc } = await this.toolbox.selectTool("options"); 1892 const option = panelDoc.querySelector( 1893 "[data-pref='devtools.inspector.simple-highlighters-reduced-motion']" 1894 ).parentNode; 1895 option.scrollIntoView({ block: "center" }); 1896 option.classList.add("options-panel-highlight"); 1897 1898 // Emit a test-only event to know when the settings panel is opened. 1899 this.toolbox.emitForTests("test-highlighters-settings-opened"); 1900 }, 1901 }, 1902 ], 1903 evt => { 1904 if (evt === "removed") { 1905 // Flip the preference when the message is dismissed. 1906 Services.prefs.setBoolPref(pref, true); 1907 } 1908 } 1909 ); 1910 } 1911 1912 /** 1913 * Destroy and clean-up all instances of in-context editors. 1914 */ 1915 destroyEditors() { 1916 for (const type in this.editors) { 1917 this.editors[type].off("show"); 1918 this.editors[type].off("hide"); 1919 this.editors[type].destroy(); 1920 } 1921 1922 this.editors = {}; 1923 } 1924 1925 /** 1926 * Destroy and clean-up all instances of highlighters. 1927 */ 1928 destroyHighlighters() { 1929 // Destroy all highlighters and clear any timers set to autohide highlighters. 1930 const values = [ 1931 ...this.#activeHighlighters.values(), 1932 ...this.gridHighlighters.values(), 1933 ]; 1934 for (const { highlighter, parentGridHighlighter, timer } of values) { 1935 if (highlighter) { 1936 highlighter.destroy(); 1937 } 1938 1939 if (parentGridHighlighter) { 1940 parentGridHighlighter.destroy(); 1941 } 1942 1943 if (timer) { 1944 clearTimeout(timer); 1945 } 1946 } 1947 1948 this.#activeHighlighters.clear(); 1949 this.#pendingHighlighters.clear(); 1950 this.gridHighlighters.clear(); 1951 1952 for (const type in this.highlighters) { 1953 if (this.highlighters[type]) { 1954 this.highlighters[type].finalize(); 1955 this.highlighters[type] = null; 1956 } 1957 } 1958 } 1959 1960 /** 1961 * Destroy this overlay instance, removing it from the view and destroying 1962 * all initialized highlighters. 1963 */ 1964 destroy() { 1965 this.inspector.off("markupmutation", this.onMarkupMutation); 1966 this.resourceCommand.unwatchResources( 1967 [this.resourceCommand.TYPES.ROOT_NODE], 1968 { onAvailable: this.#onResourceAvailable } 1969 ); 1970 1971 this.walkerEventListener.destroy(); 1972 this.walkerEventListener = null; 1973 1974 this.destroyEditors(); 1975 this.destroyHighlighters(); 1976 1977 this.#lastHovered = null; 1978 1979 this.inspector = null; 1980 this.state = null; 1981 this.store = null; 1982 this.telemetry = null; 1983 1984 this.geometryEditorHighlighterShown = null; 1985 this.hoveredHighlighterShown = null; 1986 this.shapesHighlighterShown = null; 1987 1988 this.destroyed = true; 1989 } 1990 } 1991 1992 HighlightersOverlay.TYPES = HighlightersOverlay.prototype.TYPES = TYPES; 1993 1994 module.exports = HighlightersOverlay;