computed.js (54642B)
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 flags = require("resource://devtools/shared/flags.js"); 8 const ToolDefinitions = 9 require("resource://devtools/client/definitions.js").Tools; 10 const CssLogic = require("resource://devtools/shared/inspector/css-logic.js"); 11 const { 12 style: { ELEMENT_STYLE, PRES_HINTS }, 13 } = require("resource://devtools/shared/constants.js"); 14 const OutputParser = require("resource://devtools/client/shared/output-parser.js"); 15 const { PrefObserver } = require("resource://devtools/client/shared/prefs.js"); 16 const { 17 createChild, 18 } = require("resource://devtools/client/inspector/shared/utils.js"); 19 const { 20 VIEW_NODE_SELECTOR_TYPE, 21 VIEW_NODE_PROPERTY_TYPE, 22 VIEW_NODE_VALUE_TYPE, 23 VIEW_NODE_IMAGE_URL_TYPE, 24 VIEW_NODE_FONT_TYPE, 25 } = require("resource://devtools/client/inspector/shared/node-types.js"); 26 const TooltipsOverlay = require("resource://devtools/client/inspector/shared/tooltips-overlay.js"); 27 28 loader.lazyRequireGetter( 29 this, 30 "StyleInspectorMenu", 31 "resource://devtools/client/inspector/shared/style-inspector-menu.js" 32 ); 33 loader.lazyRequireGetter( 34 this, 35 "KeyShortcuts", 36 "resource://devtools/client/shared/key-shortcuts.js" 37 ); 38 loader.lazyRequireGetter( 39 this, 40 "clipboardHelper", 41 "resource://devtools/shared/platform/clipboard.js" 42 ); 43 loader.lazyRequireGetter( 44 this, 45 "openContentLink", 46 "resource://devtools/client/shared/link.js", 47 true 48 ); 49 const lazy = {}; 50 ChromeUtils.defineESModuleGetters(lazy, { 51 getMdnLinkParams: "resource://devtools/shared/mdn.mjs", 52 }); 53 54 const STYLE_INSPECTOR_PROPERTIES = 55 "devtools/shared/locales/styleinspector.properties"; 56 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); 57 const STYLE_INSPECTOR_L10N = new LocalizationHelper(STYLE_INSPECTOR_PROPERTIES); 58 const L10N_TWISTY_EXPAND_LABEL = STYLE_INSPECTOR_L10N.getStr( 59 "rule.twistyExpand.label" 60 ); 61 const L10N_TWISTY_COLLAPSE_LABEL = STYLE_INSPECTOR_L10N.getStr( 62 "rule.twistyCollapse.label" 63 ); 64 const L10N_EMPTY_VARIABLE = STYLE_INSPECTOR_L10N.getStr("rule.variableEmpty"); 65 66 const FILTER_CHANGED_TIMEOUT = 150; 67 68 /** 69 * Helper for long-running processes that should yield occasionally to 70 * the mainloop. 71 */ 72 class UpdateProcess { 73 /** 74 * @param {Window} win 75 * Timeouts will be set on this window when appropriate. 76 * @param {Array} array 77 * The array of items to process. 78 * @param {object} options 79 * Options for the update process: 80 * onItem {function} Will be called with the value of each iteration. 81 * onBatch {function} Will be called after each batch of iterations, 82 * before yielding to the main loop. 83 * onDone {function} Will be called when iteration is complete. 84 * onCancel {function} Will be called if the process is canceled. 85 * threshold {int} How long to process before yielding, in ms. 86 */ 87 constructor(win, array, options) { 88 this.win = win; 89 this.index = 0; 90 this.array = array; 91 92 this.onItem = options.onItem || function () {}; 93 this.onBatch = options.onBatch || function () {}; 94 this.onDone = options.onDone || function () {}; 95 this.onCancel = options.onCancel || function () {}; 96 this.threshold = options.threshold || 45; 97 } 98 99 #canceled = false; 100 #timeout = null; 101 102 /** 103 * Symbol returned when the array of items to process is empty. 104 */ 105 static ITERATION_DONE = Symbol("UpdateProcess iteration done"); 106 107 /** 108 * Schedule a new batch on the main loop. 109 */ 110 schedule() { 111 if (this.#canceled) { 112 return; 113 } 114 this.#timeout = setTimeout(() => this.#timeoutHandler(), 0); 115 } 116 117 /** 118 * Cancel the running process. onItem will not be called again, 119 * and onCancel will be called. 120 */ 121 cancel() { 122 if (this.#timeout) { 123 clearTimeout(this.#timeout); 124 this.#timeout = null; 125 } 126 this.#canceled = true; 127 this.onCancel(); 128 } 129 130 #timeoutHandler() { 131 this.#timeout = null; 132 if (this.#runBatch() === UpdateProcess.ITERATION_DONE) { 133 this.onBatch(); 134 this.onDone(); 135 return; 136 } 137 this.schedule(); 138 } 139 140 #runBatch() { 141 const time = Date.now(); 142 while (!this.#canceled) { 143 const next = this.#next(); 144 if (next === UpdateProcess.ITERATION_DONE) { 145 return next; 146 } 147 148 this.onItem(next); 149 if (Date.now() - time > this.threshold) { 150 this.onBatch(); 151 return null; 152 } 153 } 154 return null; 155 } 156 157 /** 158 * Returns the item at the current index and increases the index. 159 * If all items have already been processed, will return ITERATION_DONE. 160 */ 161 #next() { 162 if (this.index < this.array.length) { 163 return this.array[this.index++]; 164 } 165 return UpdateProcess.ITERATION_DONE; 166 } 167 } 168 169 /** 170 * CssComputedView is a panel that manages the display of a table 171 * sorted by style. There should be one instance of CssComputedView 172 * per style display (of which there will generally only be one). 173 */ 174 class CssComputedView { 175 /** 176 * @param {Inspector} inspector 177 * Inspector toolbox panel 178 * @param {Document} document 179 * The document that will contain the computed view. 180 */ 181 constructor(inspector, document) { 182 this.inspector = inspector; 183 this.styleDocument = document; 184 this.styleWindow = this.styleDocument.defaultView; 185 186 this.propertyViews = []; 187 188 this.#outputParser = new OutputParser(document, inspector.cssProperties); 189 190 // Create bound methods. 191 this.focusWindow = this.focusWindow.bind(this); 192 this.refreshPanel = this.refreshPanel.bind(this); 193 194 const doc = this.styleDocument; 195 this.element = doc.getElementById("computed-property-container"); 196 this.searchField = doc.getElementById("computed-searchbox"); 197 this.searchClearButton = doc.getElementById("computed-searchinput-clear"); 198 this.includeBrowserStylesCheckbox = doc.getElementById( 199 "browser-style-checkbox" 200 ); 201 202 this.#abortController = new AbortController(); 203 const opts = { signal: this.#abortController.signal }; 204 205 this.shortcuts = new KeyShortcuts({ window: this.styleWindow }); 206 this.shortcuts.on( 207 "CmdOrCtrl+F", 208 event => this.#onShortcut("CmdOrCtrl+F", event), 209 opts 210 ); 211 this.shortcuts.on( 212 "Escape", 213 event => this.#onShortcut("Escape", event), 214 opts 215 ); 216 this.styleDocument.addEventListener("copy", this.#onCopy, opts); 217 this.styleDocument.addEventListener("mousedown", this.focusWindow, opts); 218 this.element.addEventListener("click", this.#onClick, opts); 219 this.element.addEventListener("contextmenu", this.#onContextMenu, opts); 220 this.searchField.addEventListener("input", this.#onFilterStyles, opts); 221 this.searchClearButton.addEventListener("click", this.#onClearSearch, opts); 222 this.includeBrowserStylesCheckbox.addEventListener( 223 "input", 224 this.#onIncludeBrowserStyles, 225 opts 226 ); 227 228 if (flags.testing) { 229 // In tests, we start listening immediately to avoid having to simulate a mousemove. 230 this.highlighters.addToView(this); 231 } else { 232 this.element.addEventListener( 233 "mousemove", 234 () => { 235 this.highlighters.addToView(this); 236 }, 237 { once: true, signal: this.#abortController.signal } 238 ); 239 } 240 241 if (!this.inspector.isThreePaneModeEnabled) { 242 // When the rules view is added in 3 pane mode, refresh the Computed view whenever 243 // the rules are changed. 244 this.inspector.once( 245 "ruleview-added", 246 () => { 247 this.ruleView.on("ruleview-changed", this.refreshPanel, opts); 248 }, 249 opts 250 ); 251 } 252 253 if (this.ruleView) { 254 this.ruleView.on("ruleview-changed", this.refreshPanel, opts); 255 } 256 257 this.searchClearButton.hidden = true; 258 259 // No results text. 260 this.noResults = this.styleDocument.getElementById("computed-no-results"); 261 262 // Refresh panel when color unit changed or pref for showing 263 // original sources changes. 264 this.#prefObserver = new PrefObserver("devtools."); 265 this.#prefObserver.on( 266 "devtools.defaultColorUnit", 267 this.#handlePrefChange, 268 opts 269 ); 270 271 // The PageStyle front related to the currently selected element 272 this.viewedElementPageStyle = null; 273 // Flag that is set when the selected element style was updated. This will force 274 // clearing the page style cssLogic cache the next time we're calling getComputed(). 275 this.elementStyleUpdated = false; 276 277 this.createStyleViews(); 278 279 // Add the tooltips and highlightersoverlay 280 this.tooltips = new TooltipsOverlay(this); 281 } 282 283 /** 284 * Lookup a l10n string in the shared styleinspector string bundle. 285 * 286 * @param {string} name 287 * The key to lookup. 288 * @returns {string} localized version of the given key. 289 */ 290 static l10n(name) { 291 try { 292 return STYLE_INSPECTOR_L10N.getStr(name); 293 } catch (ex) { 294 console.log("Error reading '" + name + "'"); 295 throw new Error("l10n error with " + name); 296 } 297 } 298 299 #abortController; 300 #contextMenu; 301 #computed; 302 #createViewsProcess; 303 #createViewsPromise; 304 // Used for cancelling timeouts in the style filter. 305 #filterChangedTimeout = null; 306 #highlighters; 307 #isDestroyed = false; 308 // Cache the list of properties that match the selected element. 309 #matchedProperties = null; 310 #outputParser = null; 311 #prefObserver; 312 #refreshProcess; 313 #sourceFilter; 314 // The element that we're inspecting, and the document that it comes from. 315 #viewedElement = null; 316 317 // Number of visible properties 318 numVisibleProperties = 0; 319 320 get outputParser() { 321 return this.#outputParser; 322 } 323 324 get computed() { 325 return this.#computed; 326 } 327 328 get contextMenu() { 329 if (!this.#contextMenu) { 330 this.#contextMenu = new StyleInspectorMenu(this); 331 } 332 333 return this.#contextMenu; 334 } 335 336 // Get the highlighters overlay from the Inspector. 337 get highlighters() { 338 if (!this.#highlighters) { 339 // highlighters is a lazy getter in the inspector. 340 this.#highlighters = this.inspector.highlighters; 341 } 342 343 return this.#highlighters; 344 } 345 346 get includeBrowserStyles() { 347 return this.includeBrowserStylesCheckbox.checked; 348 } 349 350 get ruleView() { 351 return ( 352 this.inspector.hasPanel("ruleview") && 353 this.inspector.getPanel("ruleview").view 354 ); 355 } 356 357 get viewedElement() { 358 return this.#viewedElement; 359 } 360 361 #handlePrefChange = () => { 362 if (this.#computed) { 363 this.refreshPanel(); 364 } 365 }; 366 367 /** 368 * Update the view with a new selected element. The CssComputedView panel 369 * will show the style information for the given element. 370 * 371 * @param {NodeFront} element 372 * The highlighted node to get styles for. 373 * @returns a promise that will be resolved when highlighting is complete. 374 */ 375 selectElement(element) { 376 if (!element) { 377 if (this.viewedElementPageStyle) { 378 this.viewedElementPageStyle.off( 379 "stylesheet-updated", 380 this.refreshPanel 381 ); 382 this.viewedElementPageStyle = null; 383 } 384 this.#viewedElement = null; 385 this.noResults.hidden = false; 386 387 if (this.#refreshProcess) { 388 this.#refreshProcess.cancel(); 389 } 390 // Hiding all properties 391 for (const propView of this.propertyViews) { 392 propView.refresh(); 393 } 394 return Promise.resolve(undefined); 395 } 396 397 if (element === this.#viewedElement) { 398 return Promise.resolve(undefined); 399 } 400 401 if (this.viewedElementPageStyle) { 402 this.viewedElementPageStyle.off("stylesheet-updated", this.refreshPanel); 403 } 404 this.viewedElementPageStyle = element.inspectorFront.pageStyle; 405 this.viewedElementPageStyle.on("stylesheet-updated", this.refreshPanel, { 406 signal: this.#abortController.signal, 407 }); 408 409 this.#viewedElement = element; 410 411 this.refreshSourceFilter(); 412 413 return this.refreshPanel(); 414 } 415 416 /** 417 * Get the type of a given node in the computed-view 418 * 419 * @param {DOMNode} node 420 * The node which we want information about 421 * @return {object} The type information object contains the following props: 422 * - view {String} Always "computed" to indicate the computed view. 423 * - type {String} One of the VIEW_NODE_XXX_TYPE const in 424 * client/inspector/shared/node-types 425 * - value {Object} Depends on the type of the node 426 * returns null if the node isn't anything we care about 427 */ 428 // eslint-disable-next-line complexity 429 getNodeInfo(node) { 430 if (!node) { 431 return null; 432 } 433 434 const classes = node.classList; 435 436 // Check if the node isn't a selector first since this doesn't require 437 // walking the DOM 438 if ( 439 classes.contains("matched") || 440 classes.contains("bestmatch") || 441 classes.contains("parentmatch") 442 ) { 443 let selectorText = ""; 444 445 for (const child of node.childNodes[1].childNodes) { 446 if (child.nodeType === node.TEXT_NODE) { 447 selectorText += child.textContent; 448 } 449 } 450 return { 451 type: VIEW_NODE_SELECTOR_TYPE, 452 value: selectorText.trim(), 453 }; 454 } 455 456 const propertyView = node.closest(".computed-property-view"); 457 const propertyMatchedSelectors = node.closest(".matchedselectors"); 458 const parent = propertyMatchedSelectors || propertyView; 459 460 if (!parent) { 461 return null; 462 } 463 464 let value, type; 465 466 // Get the property and value for a node that's a property name or value 467 const isHref = 468 classes.contains("theme-link") && !classes.contains("computed-link"); 469 470 if (classes.contains("computed-font-family")) { 471 if (propertyMatchedSelectors) { 472 const view = propertyMatchedSelectors.closest("li"); 473 value = { 474 property: view.querySelector(".computed-property-name").firstChild 475 .textContent, 476 value: node.parentNode.textContent, 477 }; 478 } else if (propertyView) { 479 value = { 480 property: parent.querySelector(".computed-property-name").firstChild 481 .textContent, 482 value: node.parentNode.textContent, 483 }; 484 } else { 485 return null; 486 } 487 } else if ( 488 propertyMatchedSelectors && 489 (classes.contains("computed-other-property-value") || isHref) 490 ) { 491 const view = propertyMatchedSelectors.closest("li"); 492 value = { 493 property: view.querySelector(".computed-property-name").firstChild 494 .textContent, 495 value: node.textContent, 496 }; 497 } else if ( 498 propertyView && 499 (classes.contains("computed-property-name") || 500 classes.contains("computed-property-value") || 501 isHref) 502 ) { 503 value = { 504 property: parent.querySelector(".computed-property-name").firstChild 505 .textContent, 506 value: parent.querySelector(".computed-property-value").textContent, 507 }; 508 } 509 510 // Get the type 511 if (classes.contains("computed-property-name")) { 512 type = VIEW_NODE_PROPERTY_TYPE; 513 } else if ( 514 classes.contains("computed-property-value") || 515 classes.contains("computed-other-property-value") 516 ) { 517 type = VIEW_NODE_VALUE_TYPE; 518 } else if (classes.contains("computed-font-family")) { 519 type = VIEW_NODE_FONT_TYPE; 520 } else if (isHref) { 521 type = VIEW_NODE_IMAGE_URL_TYPE; 522 value.url = node.href; 523 } else { 524 return null; 525 } 526 527 return { 528 view: "computed", 529 type, 530 value, 531 }; 532 } 533 534 #createPropertyViews() { 535 if (this.#createViewsPromise) { 536 return this.#createViewsPromise; 537 } 538 539 this.refreshSourceFilter(); 540 this.numVisibleProperties = 0; 541 const fragment = this.styleDocument.createDocumentFragment(); 542 543 this.#createViewsPromise = new Promise((resolve, reject) => { 544 this.#createViewsProcess = new UpdateProcess( 545 this.styleWindow, 546 CssComputedView.propertyNames, 547 { 548 onItem: propertyName => { 549 // Per-item callback. 550 const propView = new PropertyView(this, propertyName); 551 fragment.append(propView.createListItemElement()); 552 553 if (propView.visible) { 554 this.numVisibleProperties++; 555 } 556 this.propertyViews.push(propView); 557 }, 558 onCancel: () => { 559 reject("#createPropertyViews cancelled"); 560 }, 561 onDone: () => { 562 // Completed callback. 563 this.element.appendChild(fragment); 564 this.noResults.hidden = this.numVisibleProperties > 0; 565 resolve(undefined); 566 }, 567 } 568 ); 569 }); 570 571 this.#createViewsProcess.schedule(); 572 573 return this.#createViewsPromise; 574 } 575 576 isPanelVisible() { 577 return ( 578 this.inspector.toolbox && 579 this.inspector.sidebar && 580 this.inspector.toolbox.currentToolId === "inspector" && 581 this.inspector.sidebar.getCurrentTabID() == "computedview" 582 ); 583 } 584 585 /** 586 * Refresh the panel content. This could be called by a "ruleview-changed" event, but 587 * we avoid the extra processing unless the panel is visible. 588 */ 589 async refreshPanel() { 590 if (!this.#viewedElement || !this.isPanelVisible()) { 591 return; 592 } 593 594 // Capture the current viewed element to return from the promise handler 595 // early if it changed 596 const viewedElement = this.#viewedElement; 597 598 try { 599 // Create the properties views only once for the whole lifecycle of the inspector 600 // via `_createPropertyViews`. 601 // The properties are created without backend data. This queries typical property 602 // names via `DOMWindow.getComputedStyle` on the frontend inspector document. 603 // We then have to manually update the list of PropertyView's for custom properties 604 // based on backend data (`getComputed()`/`computed`). 605 // Also note that PropertyView/PropertyView are refreshed via their refresh method 606 // which will ultimately query `CssComputedView._computed`, which we update in this method. 607 const [computed] = await Promise.all([ 608 this.viewedElementPageStyle.getComputed(this.#viewedElement, { 609 filter: this.#sourceFilter, 610 onlyMatched: !this.includeBrowserStyles, 611 markMatched: true, 612 clearCache: !!this.elementStyleUpdated, 613 }), 614 this.#createPropertyViews(), 615 ]); 616 617 this.elementStyleUpdated = false; 618 619 if (viewedElement !== this.#viewedElement) { 620 return; 621 } 622 623 this.#computed = computed; 624 this.#matchedProperties = new Set(); 625 const customProperties = new Set(); 626 627 for (const name in computed) { 628 if (computed[name].matched) { 629 this.#matchedProperties.add(name); 630 } 631 if (name.startsWith("--")) { 632 customProperties.add(name); 633 } 634 } 635 636 // Removing custom property PropertyViews which won't be used 637 let customPropertiesStartIndex; 638 for (let i = this.propertyViews.length - 1; i >= 0; i--) { 639 const propView = this.propertyViews[i]; 640 641 // custom properties are displayed at the bottom of the list, and we're looping 642 // backward through propertyViews, so if the current item does not represent 643 // a custom property, we can stop looping. 644 if (!propView.isCustomProperty) { 645 customPropertiesStartIndex = i + 1; 646 break; 647 } 648 649 // If the custom property will be used, move to the next item. 650 if (customProperties.has(propView.name)) { 651 customProperties.delete(propView.name); 652 continue; 653 } 654 655 // Otherwise remove property view element 656 if (propView.element) { 657 propView.element.remove(); 658 } 659 660 propView.destroy(); 661 this.propertyViews.splice(i, 1); 662 } 663 664 // At this point, `customProperties` only contains custom property names for 665 // which we don't have a PropertyView yet. 666 let insertIndex = customPropertiesStartIndex; 667 for (const customPropertyName of Array.from(customProperties).sort()) { 668 const propertyView = new PropertyView( 669 this, 670 customPropertyName, 671 // isCustomProperty 672 true 673 ); 674 675 const len = this.propertyViews.length; 676 if (insertIndex !== len) { 677 for (let i = insertIndex; i <= len; i++) { 678 const existingPropView = this.propertyViews[i]; 679 if ( 680 !existingPropView || 681 !existingPropView.isCustomProperty || 682 customPropertyName < existingPropView.name 683 ) { 684 insertIndex = i; 685 break; 686 } 687 } 688 } 689 this.propertyViews.splice(insertIndex, 0, propertyView); 690 691 // Insert the custom property PropertyView at the right spot so we 692 // keep the list ordered. 693 const previousSibling = this.element.childNodes[insertIndex - 1]; 694 previousSibling.insertAdjacentElement( 695 "afterend", 696 propertyView.createListItemElement() 697 ); 698 } 699 700 if (this.#refreshProcess) { 701 this.#refreshProcess.cancel(); 702 } 703 704 this.noResults.hidden = true; 705 706 // Reset visible property count 707 this.numVisibleProperties = 0; 708 709 await new Promise((resolve, reject) => { 710 this.#refreshProcess = new UpdateProcess( 711 this.styleWindow, 712 this.propertyViews, 713 { 714 onItem: propView => { 715 propView.refresh(); 716 }, 717 onCancel: () => { 718 reject("#refreshProcess of computed view cancelled"); 719 }, 720 onDone: () => { 721 this.#refreshProcess = null; 722 this.noResults.hidden = this.numVisibleProperties > 0; 723 724 const searchBox = this.searchField.parentNode; 725 searchBox.classList.toggle( 726 "devtools-searchbox-no-match", 727 !!this.searchField.value.length && !this.numVisibleProperties 728 ); 729 730 this.inspector.emit("computed-view-refreshed"); 731 resolve(undefined); 732 }, 733 } 734 ); 735 this.#refreshProcess.schedule(); 736 }); 737 } catch (e) { 738 console.error(e); 739 } 740 } 741 742 /** 743 * Handle the shortcut events in the computed view. 744 */ 745 #onShortcut = (name, event) => { 746 if (!event.target.closest("#sidebar-panel-computedview")) { 747 return; 748 } 749 // Handle the search box's keypress event. If the escape key is pressed, 750 // clear the search box field. 751 if ( 752 name === "Escape" && 753 event.target === this.searchField && 754 this.#onClearSearch() 755 ) { 756 event.preventDefault(); 757 event.stopPropagation(); 758 } else if (name === "CmdOrCtrl+F") { 759 this.searchField.focus(); 760 event.preventDefault(); 761 } 762 }; 763 764 /** 765 * Set the filter style search value. 766 * 767 * @param {string} value 768 * The search value. 769 */ 770 setFilterStyles(value = "") { 771 this.searchField.value = value; 772 this.searchField.focus(); 773 this.#onFilterStyles(); 774 } 775 776 /** 777 * Called when the user enters a search term in the filter style search box. 778 */ 779 #onFilterStyles = () => { 780 if (this.#filterChangedTimeout) { 781 clearTimeout(this.#filterChangedTimeout); 782 } 783 784 const filterTimeout = this.searchField.value.length 785 ? FILTER_CHANGED_TIMEOUT 786 : 0; 787 this.searchClearButton.hidden = this.searchField.value.length === 0; 788 789 this.#filterChangedTimeout = setTimeout(() => { 790 this.refreshPanel(); 791 this.#filterChangedTimeout = null; 792 }, filterTimeout); 793 }; 794 795 /** 796 * Called when the user clicks on the clear button in the filter style search 797 * box. Returns true if the search box is cleared and false otherwise. 798 */ 799 #onClearSearch = () => { 800 if (this.searchField.value) { 801 this.setFilterStyles(""); 802 return true; 803 } 804 805 return false; 806 }; 807 808 /** 809 * The change event handler for the includeBrowserStyles checkbox. 810 */ 811 #onIncludeBrowserStyles = () => { 812 this.refreshSourceFilter(); 813 this.refreshPanel(); 814 }; 815 816 /** 817 * When includeBrowserStylesCheckbox.checked is false we only display 818 * properties that have matched selectors and have been included by the 819 * document or one of thedocument's stylesheets. If .checked is false we 820 * display all properties including those that come from UA stylesheets. 821 */ 822 refreshSourceFilter() { 823 this.#matchedProperties = null; 824 this.#sourceFilter = this.includeBrowserStyles 825 ? CssLogic.FILTER.UA 826 : CssLogic.FILTER.USER; 827 } 828 829 /** 830 * The CSS as displayed by the UI. 831 */ 832 createStyleViews() { 833 if (CssComputedView.propertyNames) { 834 return; 835 } 836 837 CssComputedView.propertyNames = []; 838 839 // Here we build and cache a list of css properties supported by the browser 840 // We could use any element but let's use the main document's root element 841 const styles = this.styleWindow.getComputedStyle( 842 this.styleDocument.documentElement 843 ); 844 const mozProps = []; 845 for (let i = 0, numStyles = styles.length; i < numStyles; i++) { 846 const prop = styles.item(i); 847 if (prop.startsWith("--")) { 848 // Skip any CSS variables used inside of browser CSS files 849 continue; 850 } else if (prop.startsWith("-")) { 851 mozProps.push(prop); 852 } else { 853 CssComputedView.propertyNames.push(prop); 854 } 855 } 856 857 CssComputedView.propertyNames.sort(); 858 CssComputedView.propertyNames.push.apply( 859 CssComputedView.propertyNames, 860 mozProps.sort() 861 ); 862 863 this.#createPropertyViews().catch(e => { 864 if (!this.#isDestroyed) { 865 console.warn( 866 "The creation of property views was cancelled because " + 867 "the computed-view was destroyed before it was done creating views" 868 ); 869 } else { 870 console.error(e); 871 } 872 }); 873 } 874 875 /** 876 * Get a set of properties that have matched selectors. 877 * 878 * @return {Set} If a property name is in the set, it has matching selectors. 879 */ 880 get matchedProperties() { 881 return this.#matchedProperties || new Set(); 882 } 883 884 /** 885 * Focus the window on mousedown. 886 */ 887 focusWindow() { 888 this.styleWindow.focus(); 889 } 890 891 /** 892 * Context menu handler. 893 */ 894 #onContextMenu = event => { 895 // Call stopPropagation() and preventDefault() here so that avoid to show default 896 // context menu in about:devtools-toolbox. See Bug 1515265. 897 event.stopPropagation(); 898 event.preventDefault(); 899 this.contextMenu.show(event); 900 }; 901 902 #onClick = event => { 903 const target = event.target; 904 905 if (target.nodeName === "a") { 906 event.stopPropagation(); 907 event.preventDefault(); 908 openContentLink(target.href); 909 } 910 }; 911 912 /** 913 * Callback for copy event. Copy selected text. 914 * 915 * @param {Event} event 916 * copy event object. 917 */ 918 #onCopy = event => { 919 const win = this.styleWindow; 920 const text = win.getSelection().toString().trim(); 921 if (text !== "") { 922 this.copySelection(); 923 event.preventDefault(); 924 } 925 }; 926 927 /** 928 * Copy the current selection to the clipboard 929 */ 930 copySelection() { 931 try { 932 const win = this.styleWindow; 933 const text = win.getSelection().toString().trim(); 934 935 clipboardHelper.copyString(text); 936 } catch (e) { 937 console.error(e); 938 } 939 } 940 941 /** 942 * Destructor for CssComputedView. 943 */ 944 destroy() { 945 this.#viewedElement = null; 946 this.#abortController.abort(); 947 this.#abortController = null; 948 949 if (this.viewedElementPageStyle) { 950 this.viewedElementPageStyle = null; 951 } 952 this.#outputParser = null; 953 954 this.#prefObserver.destroy(); 955 956 // Cancel tree construction 957 if (this.#createViewsProcess) { 958 this.#createViewsProcess.cancel(); 959 } 960 if (this.#refreshProcess) { 961 this.#refreshProcess.cancel(); 962 } 963 964 if (this.#contextMenu) { 965 this.#contextMenu.destroy(); 966 this.#contextMenu = null; 967 } 968 969 if (this.#highlighters) { 970 this.#highlighters.removeFromView(this); 971 this.#highlighters = null; 972 } 973 974 this.tooltips.destroy(); 975 976 // Nodes used in templating 977 this.element = null; 978 this.searchField = null; 979 this.searchClearButton = null; 980 this.includeBrowserStylesCheckbox = null; 981 982 // Property views 983 for (const propView of this.propertyViews) { 984 propView.destroy(); 985 } 986 this.propertyViews = null; 987 988 this.inspector = null; 989 this.styleDocument = null; 990 this.styleWindow = null; 991 992 this.#isDestroyed = true; 993 } 994 } 995 996 class PropertyInfo { 997 /** 998 * @param {CssComputedView} tree 999 * The CssComputedView instance we are working with. 1000 * @param {string} name 1001 * The CSS property name 1002 */ 1003 constructor(tree, name) { 1004 this.#tree = tree; 1005 this.name = name; 1006 } 1007 1008 #tree; 1009 1010 get isSupported() { 1011 // There can be a mismatch between the list of properties 1012 // supported on the server and on the client. 1013 // Ideally we should build PropertyInfo only for property names supported on 1014 // the server. See Bug 1722348. 1015 return this.#tree.computed && this.name in this.#tree.computed; 1016 } 1017 1018 get value() { 1019 if (this.isSupported) { 1020 const value = this.#tree.computed[this.name].value; 1021 return value; 1022 } 1023 return null; 1024 } 1025 } 1026 1027 /** 1028 * A container to give easy access to property data from the template engine. 1029 */ 1030 class PropertyView { 1031 /** 1032 * @param {CssComputedView} tree 1033 * The CssComputedView instance we are working with. 1034 * @param {string} name 1035 * The CSS property name for which this PropertyView 1036 * instance will render the rules. 1037 * @param {boolean} isCustomProperty 1038 * Set to true if this will represent a custom property. 1039 */ 1040 constructor(tree, name, isCustomProperty = false) { 1041 this.#tree = tree; 1042 this.name = name; 1043 1044 this.isCustomProperty = isCustomProperty; 1045 1046 if (!this.isCustomProperty) { 1047 this.link = `https://developer.mozilla.org/docs/Web/CSS/Reference/Properties/${name}?${lazy.getMdnLinkParams("computed-panel")}`; 1048 } 1049 1050 this.#propertyInfo = new PropertyInfo(tree, name); 1051 const win = this.#tree.styleWindow; 1052 this.#abortController = new win.AbortController(); 1053 } 1054 1055 // The parent element which contains the open attribute 1056 element = null; 1057 1058 // Destination for property values 1059 valueNode = null; 1060 1061 // Are matched rules expanded? 1062 matchedExpanded = false; 1063 1064 // Matched selector container 1065 matchedSelectorsContainer = null; 1066 1067 // Result of call to getMatchedSelectors 1068 #matchedSelectorResponse = null; 1069 1070 // Matched selector expando 1071 #matchedExpander = null; 1072 1073 // AbortController for event listeners 1074 #abortController = null; 1075 1076 // Cache for matched selector views 1077 #matchedSelectorViews = null; 1078 1079 // The previously selected element used for the selector view caches 1080 #prevViewedElement = null; 1081 1082 // PropertyInfo 1083 #propertyInfo = null; 1084 1085 #tree; 1086 1087 /** 1088 * Get the computed style for the current property. 1089 * 1090 * @return {string} the computed style for the current property of the 1091 * currently highlighted element. 1092 */ 1093 get value() { 1094 return this.propertyInfo.value; 1095 } 1096 1097 /** 1098 * An easy way to access the CssPropertyInfo behind this PropertyView. 1099 */ 1100 get propertyInfo() { 1101 return this.#propertyInfo; 1102 } 1103 1104 /** 1105 * Does the property have any matched selectors? 1106 */ 1107 get hasMatchedSelectors() { 1108 return this.#tree.matchedProperties.has(this.name); 1109 } 1110 1111 /** 1112 * Should this property be visible? 1113 */ 1114 get visible() { 1115 if (!this.#tree.viewedElement) { 1116 return false; 1117 } 1118 1119 if (!this.#tree.includeBrowserStyles && !this.hasMatchedSelectors) { 1120 return false; 1121 } 1122 1123 const searchTerm = this.#tree.searchField.value.toLowerCase(); 1124 const isValidSearchTerm = !!searchTerm.trim().length; 1125 if ( 1126 isValidSearchTerm && 1127 !this.name.toLowerCase().includes(searchTerm) && 1128 !this.value.toLowerCase().includes(searchTerm) 1129 ) { 1130 return false; 1131 } 1132 1133 return this.propertyInfo.isSupported; 1134 } 1135 1136 /** 1137 * Returns the className that should be assigned to the propertyView. 1138 * 1139 * @return {string} 1140 */ 1141 get propertyHeaderClassName() { 1142 return this.visible ? "computed-property-view" : "computed-property-hidden"; 1143 } 1144 1145 /** 1146 * Is the property invalid at computed value time 1147 * 1148 * @returns {boolean} 1149 */ 1150 get invalidAtComputedValueTime() { 1151 return this.#tree.computed[this.name].invalidAtComputedValueTime; 1152 } 1153 1154 /** 1155 * If this is a registered property, returns its syntax (e.g. "<color>") 1156 * 1157 * @returns {Text|undefined} 1158 */ 1159 get registeredPropertySyntax() { 1160 return this.#tree.computed[this.name].registeredPropertySyntax; 1161 } 1162 1163 /** 1164 * If this is a registered property, return its initial-value 1165 * 1166 * @returns {Text|undefined} 1167 */ 1168 get registeredPropertyInitialValue() { 1169 return this.#tree.computed[this.name].registeredPropertyInitialValue; 1170 } 1171 1172 /** 1173 * Create DOM elements for a property 1174 * 1175 * @return {Element} The <li> element 1176 */ 1177 createListItemElement() { 1178 const doc = this.#tree.styleDocument; 1179 const baseEventListenerConfig = { signal: this.#abortController.signal }; 1180 1181 // Build the container element 1182 this.onMatchedToggle = this.onMatchedToggle.bind(this); 1183 this.element = doc.createElement("li"); 1184 this.element.className = this.propertyHeaderClassName; 1185 this.element.addEventListener( 1186 "dblclick", 1187 this.onMatchedToggle, 1188 baseEventListenerConfig 1189 ); 1190 1191 // Make it keyboard navigable 1192 this.element.setAttribute("tabindex", "0"); 1193 this.shortcuts = new KeyShortcuts({ 1194 window: this.#tree.styleWindow, 1195 target: this.element, 1196 }); 1197 this.shortcuts.on("F1", event => { 1198 this.mdnLinkClick(event); 1199 // Prevent opening the options panel 1200 event.preventDefault(); 1201 event.stopPropagation(); 1202 }); 1203 this.shortcuts.on("Return", this.onMatchedToggle); 1204 this.shortcuts.on("Space", this.onMatchedToggle); 1205 1206 const nameContainer = doc.createElement("span"); 1207 nameContainer.className = "computed-property-name-container"; 1208 1209 // Build the twisty expand/collapse 1210 this.#matchedExpander = doc.createElement("div"); 1211 this.#matchedExpander.className = "computed-expander theme-twisty"; 1212 this.#matchedExpander.setAttribute("role", "button"); 1213 this.#matchedExpander.setAttribute("aria-label", L10N_TWISTY_EXPAND_LABEL); 1214 this.#matchedExpander.addEventListener( 1215 "click", 1216 this.onMatchedToggle, 1217 baseEventListenerConfig 1218 ); 1219 1220 // Build the style name element 1221 const nameNode = doc.createElement("span"); 1222 nameNode.classList.add("computed-property-name", "theme-fg-color3"); 1223 1224 // Give it a heading role for screen readers. 1225 nameNode.setAttribute("role", "heading"); 1226 1227 // Reset its tabindex attribute otherwise, if an ellipsis is applied 1228 // it will be reachable via TABing 1229 nameNode.setAttribute("tabindex", ""); 1230 // Avoid english text (css properties) from being altered 1231 // by RTL mode 1232 nameNode.setAttribute("dir", "ltr"); 1233 nameNode.textContent = nameNode.title = this.name; 1234 // Make it hand over the focus to the container 1235 const focusElement = () => this.element.focus(); 1236 nameNode.addEventListener("click", focusElement, baseEventListenerConfig); 1237 1238 // Build the style name ":" separator 1239 const nameSeparator = doc.createElement("span"); 1240 nameSeparator.classList.add("visually-hidden"); 1241 nameSeparator.textContent = ": "; 1242 nameNode.appendChild(nameSeparator); 1243 1244 nameContainer.appendChild(nameNode); 1245 1246 const valueContainer = doc.createElement("span"); 1247 valueContainer.className = "computed-property-value-container"; 1248 1249 // Build the style value element 1250 this.valueNode = doc.createElement("span"); 1251 this.valueNode.classList.add("computed-property-value", "theme-fg-color1"); 1252 // Reset its tabindex attribute otherwise, if an ellipsis is applied 1253 // it will be reachable via TABing 1254 this.valueNode.setAttribute("tabindex", ""); 1255 this.valueNode.setAttribute("dir", "ltr"); 1256 // Make it hand over the focus to the container 1257 this.valueNode.addEventListener( 1258 "click", 1259 focusElement, 1260 baseEventListenerConfig 1261 ); 1262 1263 // Build the style value ";" separator 1264 const valueSeparator = doc.createElement("span"); 1265 valueSeparator.classList.add("visually-hidden"); 1266 valueSeparator.textContent = ";"; 1267 1268 valueContainer.append(this.valueNode, valueSeparator); 1269 1270 // If the value is invalid at computed value time (IACVT), display the same 1271 // warning icon that we have in the rules view for IACVT declarations. 1272 if (this.isCustomProperty) { 1273 this.invalidAtComputedValueTimeNode = doc.createElement("div"); 1274 this.invalidAtComputedValueTimeNode.classList.add( 1275 "invalid-at-computed-value-time-warning" 1276 ); 1277 this.refreshInvalidAtComputedValueTime(); 1278 valueContainer.append(this.invalidAtComputedValueTimeNode); 1279 } 1280 1281 // Build the matched selectors container 1282 this.matchedSelectorsContainer = doc.createElement("div"); 1283 this.matchedSelectorsContainer.classList.add("matchedselectors"); 1284 1285 this.element.append( 1286 this.#matchedExpander, 1287 nameContainer, 1288 valueContainer, 1289 this.matchedSelectorsContainer 1290 ); 1291 1292 return this.element; 1293 } 1294 1295 /** 1296 * Refresh the panel's CSS property value. 1297 */ 1298 refresh() { 1299 const className = this.propertyHeaderClassName; 1300 if (this.element.className !== className) { 1301 this.element.className = className; 1302 } 1303 1304 if (this.#prevViewedElement !== this.#tree.viewedElement) { 1305 this.#matchedSelectorViews = null; 1306 this.#prevViewedElement = this.#tree.viewedElement; 1307 } 1308 1309 if (!this.#tree.viewedElement || !this.visible) { 1310 this.valueNode.textContent = this.valueNode.title = ""; 1311 this.matchedSelectorsContainer.parentNode.hidden = true; 1312 this.matchedSelectorsContainer.textContent = ""; 1313 this.#matchedExpander.removeAttribute("open"); 1314 this.#matchedExpander.setAttribute( 1315 "aria-label", 1316 L10N_TWISTY_EXPAND_LABEL 1317 ); 1318 return; 1319 } 1320 1321 this.#tree.numVisibleProperties++; 1322 1323 this.valueNode.innerHTML = ""; 1324 // No need to pass the baseURI argument here as computed URIs are never relative. 1325 this.valueNode.appendChild(this.#parseValue(this.propertyInfo.value)); 1326 1327 this.refreshInvalidAtComputedValueTime(); 1328 this.refreshMatchedSelectors(); 1329 } 1330 1331 /** 1332 * Refresh the panel matched rules. 1333 */ 1334 refreshMatchedSelectors() { 1335 const hasMatchedSelectors = this.hasMatchedSelectors; 1336 this.matchedSelectorsContainer.parentNode.hidden = !hasMatchedSelectors; 1337 1338 if (hasMatchedSelectors) { 1339 this.#matchedExpander.classList.add("computed-expandable"); 1340 } else { 1341 this.#matchedExpander.classList.remove("computed-expandable"); 1342 } 1343 1344 if (this.matchedExpanded && hasMatchedSelectors) { 1345 return this.#tree.viewedElementPageStyle 1346 .getMatchedSelectors(this.#tree.viewedElement, this.name) 1347 .then(matched => { 1348 if (!this.matchedExpanded) { 1349 return; 1350 } 1351 1352 this.#matchedSelectorResponse = matched; 1353 1354 this.#buildMatchedSelectors(); 1355 this.#matchedExpander.setAttribute("open", ""); 1356 this.#matchedExpander.setAttribute( 1357 "aria-label", 1358 L10N_TWISTY_COLLAPSE_LABEL 1359 ); 1360 this.#tree.inspector.emit("computed-view-property-expanded"); 1361 }) 1362 .catch(console.error); 1363 } 1364 1365 this.matchedSelectorsContainer.innerHTML = ""; 1366 this.#matchedExpander.removeAttribute("open"); 1367 this.#matchedExpander.setAttribute("aria-label", L10N_TWISTY_EXPAND_LABEL); 1368 this.#tree.inspector.emit("computed-view-property-collapsed"); 1369 return Promise.resolve(undefined); 1370 } 1371 1372 /** 1373 * Show/Hide IACVT icon and sets its title attribute 1374 */ 1375 refreshInvalidAtComputedValueTime() { 1376 if (!this.isCustomProperty) { 1377 return; 1378 } 1379 1380 if (!this.invalidAtComputedValueTime) { 1381 this.invalidAtComputedValueTimeNode.setAttribute("hidden", ""); 1382 this.invalidAtComputedValueTimeNode.removeAttribute("title"); 1383 } else { 1384 this.invalidAtComputedValueTimeNode.removeAttribute("hidden", ""); 1385 this.invalidAtComputedValueTimeNode.setAttribute( 1386 "title", 1387 STYLE_INSPECTOR_L10N.getFormatStr( 1388 "rule.warningInvalidAtComputedValueTime.title", 1389 `"${this.registeredPropertySyntax}"` 1390 ) 1391 ); 1392 } 1393 } 1394 1395 get matchedSelectors() { 1396 return this.#matchedSelectorResponse; 1397 } 1398 1399 #buildMatchedSelectors() { 1400 const frag = this.element.ownerDocument.createDocumentFragment(); 1401 1402 for (const selector of this.matchedSelectorViews) { 1403 const p = createChild(frag, "p"); 1404 const span = createChild(p, "span", { 1405 class: "rule-link", 1406 }); 1407 1408 if (selector.source) { 1409 const link = createChild(span, "a", { 1410 target: "_blank", 1411 class: "computed-link theme-link", 1412 title: selector.longSource, 1413 sourcelocation: selector.source, 1414 tabindex: "0", 1415 textContent: selector.source, 1416 }); 1417 link.addEventListener("click", selector.openStyleEditor); 1418 const shortcuts = new KeyShortcuts({ 1419 window: this.#tree.styleWindow, 1420 target: link, 1421 }); 1422 shortcuts.on("Return", () => selector.openStyleEditor()); 1423 } 1424 1425 const status = createChild(p, "span", { 1426 dir: "ltr", 1427 class: "rule-text theme-fg-color3 " + selector.statusClass, 1428 title: selector.statusText, 1429 }); 1430 1431 // Add an explicit status text span for screen readers. 1432 // They won't pick up the title from the status span. 1433 createChild(status, "span", { 1434 dir: "ltr", 1435 class: "visually-hidden", 1436 textContent: selector.statusText + " ", 1437 }); 1438 1439 const selectorEl = createChild(status, "div", { 1440 class: "fix-get-selection computed-other-property-selector", 1441 textContent: selector.sourceText, 1442 }); 1443 if ( 1444 selector.selectorInfo.rule.type === ELEMENT_STYLE || 1445 selector.selectorInfo.rule.type === PRES_HINTS 1446 ) { 1447 selectorEl.classList.add("alternative-selector"); 1448 } 1449 1450 const valueDiv = createChild(status, "div", { 1451 class: 1452 "fix-get-selection computed-other-property-value theme-fg-color1", 1453 }); 1454 valueDiv.appendChild( 1455 this.#parseValue( 1456 selector.selectorInfo.value, 1457 selector.selectorInfo.rule.href 1458 ) 1459 ); 1460 1461 // If the value is invalid at computed value time (IACVT), display the same 1462 // warning icon that we have in the rules view for IACVT declarations. 1463 if (selector.selectorInfo.invalidAtComputedValueTime) { 1464 createChild(status, "div", { 1465 class: "invalid-at-computed-value-time-warning", 1466 title: STYLE_INSPECTOR_L10N.getFormatStr( 1467 "rule.warningInvalidAtComputedValueTime.title", 1468 `"${selector.selectorInfo.registeredPropertySyntax}"` 1469 ), 1470 }); 1471 } 1472 } 1473 1474 if (this.registeredPropertyInitialValue !== undefined) { 1475 const p = createChild(frag, "p"); 1476 const status = createChild(p, "span", { 1477 dir: "ltr", 1478 class: "rule-text theme-fg-color3", 1479 }); 1480 1481 createChild(status, "div", { 1482 class: "fix-get-selection", 1483 textContent: "initial-value", 1484 }); 1485 1486 const valueDiv = createChild(status, "div", { 1487 class: 1488 "fix-get-selection computed-other-property-value theme-fg-color1", 1489 }); 1490 valueDiv.appendChild( 1491 this.#parseValue(this.registeredPropertyInitialValue) 1492 ); 1493 } 1494 1495 this.matchedSelectorsContainer.innerHTML = ""; 1496 this.matchedSelectorsContainer.appendChild(frag); 1497 } 1498 1499 /** 1500 * Parse a property value using the OutputParser. 1501 * 1502 * @param {string} value 1503 * @param {string} baseURI 1504 * @returns {DocumentFragment|Element} 1505 */ 1506 #parseValue(value, baseURI) { 1507 if (this.isCustomProperty && value === "") { 1508 const doc = this.#tree.styleDocument; 1509 const el = doc.createElement("span"); 1510 el.classList.add("empty-css-variable"); 1511 el.append(doc.createTextNode(`<${L10N_EMPTY_VARIABLE}>`)); 1512 return el; 1513 } 1514 1515 // Sadly, because this fragment is added to the template by DOM Templater 1516 // we lose any events that are attached. This means that URLs will open in a 1517 // new window. At some point we should fix this by stopping using the 1518 // templater. 1519 return this.#tree.outputParser.parseCssProperty(this.name, value, { 1520 colorSwatchClass: "inspector-swatch inspector-colorswatch", 1521 colorSwatchReadOnly: true, 1522 colorClass: "computed-color", 1523 urlClass: "theme-link", 1524 fontFamilyClass: "computed-font-family", 1525 baseURI, 1526 }); 1527 } 1528 1529 /** 1530 * Provide access to the matched SelectorViews that we are currently 1531 * displaying. 1532 */ 1533 get matchedSelectorViews() { 1534 if (!this.#matchedSelectorViews) { 1535 this.#matchedSelectorViews = []; 1536 this.#matchedSelectorResponse.forEach(selectorInfo => { 1537 const selectorView = new SelectorView(this.#tree, selectorInfo); 1538 this.#matchedSelectorViews.push(selectorView); 1539 }, this); 1540 } 1541 return this.#matchedSelectorViews; 1542 } 1543 1544 /** 1545 * The action when a user expands matched selectors. 1546 * 1547 * @param {Event} event 1548 * Used to determine the class name of the targets click 1549 * event. 1550 */ 1551 onMatchedToggle(event) { 1552 if (event.shiftKey) { 1553 return; 1554 } 1555 this.matchedExpanded = !this.matchedExpanded; 1556 this.refreshMatchedSelectors(); 1557 event.preventDefault(); 1558 } 1559 1560 /** 1561 * The action when a user clicks on the MDN help link for a property. 1562 */ 1563 mdnLinkClick() { 1564 if (!this.link) { 1565 return; 1566 } 1567 openContentLink(this.link); 1568 } 1569 1570 /** 1571 * Destroy this property view, removing event listeners 1572 */ 1573 destroy() { 1574 if (this.#matchedSelectorViews) { 1575 for (const view of this.#matchedSelectorViews) { 1576 view.destroy(); 1577 } 1578 } 1579 1580 if (this.#abortController) { 1581 this.#abortController.abort(); 1582 this.#abortController = null; 1583 } 1584 1585 if (this.shortcuts) { 1586 this.shortcuts.destroy(); 1587 } 1588 1589 this.shortcuts = null; 1590 this.element = null; 1591 this.#matchedExpander = null; 1592 this.valueNode = null; 1593 } 1594 } 1595 1596 /** 1597 * A container to give us easy access to display data from a CssRule 1598 */ 1599 class SelectorView { 1600 /** 1601 * @param CssComputedView tree 1602 * the owning CssComputedView 1603 * @param selectorInfo 1604 */ 1605 constructor(tree, selectorInfo) { 1606 this.#tree = tree; 1607 this.selectorInfo = selectorInfo; 1608 this.#cacheStatusNames(); 1609 1610 this.openStyleEditor = this.openStyleEditor.bind(this); 1611 1612 const rule = this.selectorInfo.rule; 1613 if (rule?.parentStyleSheet) { 1614 // This always refers to the generated location. 1615 const sheet = rule.parentStyleSheet; 1616 const sourceSuffix = rule.line > 0 ? ":" + rule.line : ""; 1617 this.source = CssLogic.shortSource(sheet) + sourceSuffix; 1618 this.longSource = CssLogic.longSource(sheet) + sourceSuffix; 1619 1620 this.#generatedLocation = { 1621 sheet, 1622 href: sheet.href || sheet.nodeHref, 1623 line: rule.line, 1624 column: rule.column, 1625 }; 1626 this.#unsubscribeCallback = 1627 this.#tree.inspector.toolbox.sourceMapURLService.subscribeByID( 1628 this.#generatedLocation.sheet.resourceId, 1629 this.#generatedLocation.line, 1630 this.#generatedLocation.column, 1631 this.#updateLocation 1632 ); 1633 } 1634 } 1635 1636 #generatedLocation; 1637 #href; 1638 #tree; 1639 #unsubscribeCallback; 1640 1641 /** 1642 * Decode for cssInfo.rule.status 1643 * 1644 * @see SelectorView.prototype.#cacheStatusNames 1645 * @see CssLogic.STATUS 1646 */ 1647 static STATUS_NAMES = [ 1648 // "Parent Match", "Matched", "Best Match" 1649 ]; 1650 1651 static CLASS_NAMES = ["parentmatch", "matched", "bestmatch"]; 1652 1653 /** 1654 * Cache localized status names. 1655 * 1656 * These statuses are localized inside the styleinspector.properties string 1657 * bundle. 1658 * 1659 * @see css-logic.js - the CssLogic.STATUS array. 1660 */ 1661 #cacheStatusNames() { 1662 if (SelectorView.STATUS_NAMES.length) { 1663 return; 1664 } 1665 1666 for (const status in CssLogic.STATUS) { 1667 const i = CssLogic.STATUS[status]; 1668 if (i > CssLogic.STATUS.UNMATCHED) { 1669 const value = CssComputedView.l10n("rule.status." + status); 1670 // Replace normal spaces with non-breaking spaces 1671 SelectorView.STATUS_NAMES[i] = value.replace(/ /g, "\u00A0"); 1672 } 1673 } 1674 } 1675 1676 /** 1677 * A localized version of cssRule.status 1678 */ 1679 get statusText() { 1680 return SelectorView.STATUS_NAMES[this.selectorInfo.status]; 1681 } 1682 1683 /** 1684 * Get class name for selector depending on status 1685 */ 1686 get statusClass() { 1687 return SelectorView.CLASS_NAMES[this.selectorInfo.status - 1]; 1688 } 1689 1690 get href() { 1691 if (this.#href) { 1692 return this.#href; 1693 } 1694 const sheet = this.selectorInfo.rule.parentStyleSheet; 1695 this.#href = sheet ? sheet.href : "#"; 1696 return this.#href; 1697 } 1698 1699 get sourceText() { 1700 return this.selectorInfo.sourceText; 1701 } 1702 1703 get value() { 1704 return this.selectorInfo.value; 1705 } 1706 1707 /** 1708 * Update the text of the source link to reflect whether we're showing 1709 * original sources or not. This is a callback for 1710 * SourceMapURLService.subscribe, which see. 1711 * 1712 * @param {object | null} originalLocation 1713 * The original position object (url/line/column) or null. 1714 */ 1715 #updateLocation = originalLocation => { 1716 if (!this.#tree.element) { 1717 return; 1718 } 1719 1720 // Update |currentLocation| to be whichever location is being 1721 // displayed at the moment. 1722 let currentLocation = this.#generatedLocation; 1723 if (originalLocation) { 1724 const { url, line, column } = originalLocation; 1725 currentLocation = { href: url, line, column }; 1726 } 1727 1728 const selector = '[sourcelocation="' + this.source + '"]'; 1729 const link = this.#tree.element.querySelector(selector); 1730 if (link) { 1731 const text = 1732 CssLogic.shortSource(currentLocation) + ":" + currentLocation.line; 1733 link.textContent = text; 1734 } 1735 1736 this.#tree.inspector.emit("computed-view-sourcelinks-updated"); 1737 }; 1738 1739 /** 1740 * When a css link is clicked this method is called in order to either: 1741 * 1. Open the link in view source (for chrome stylesheets). 1742 * 2. Open the link in the style editor. 1743 * 1744 * We can only view stylesheets contained in document.styleSheets inside the 1745 * style editor. 1746 */ 1747 openStyleEditor() { 1748 const inspector = this.#tree.inspector; 1749 const rule = this.selectorInfo.rule; 1750 1751 // The style editor can only display stylesheets coming from content because 1752 // chrome stylesheets are not listed in the editor's stylesheet selector. 1753 // 1754 // If the stylesheet is a content stylesheet we send it to the style 1755 // editor else we display it in the view source window. 1756 const parentStyleSheet = rule.parentStyleSheet; 1757 if (!parentStyleSheet || parentStyleSheet.isSystem) { 1758 inspector.toolbox.viewSource(rule.href, rule.line); 1759 return; 1760 } 1761 1762 const { sheet, line, column } = this.#generatedLocation; 1763 if (ToolDefinitions.styleEditor.isToolSupported(inspector.toolbox)) { 1764 inspector.toolbox.viewSourceInStyleEditorByResource(sheet, line, column); 1765 } 1766 } 1767 1768 /** 1769 * Destroy this selector view, removing event listeners 1770 */ 1771 destroy() { 1772 if (this.#unsubscribeCallback) { 1773 this.#unsubscribeCallback(); 1774 } 1775 } 1776 } 1777 1778 class ComputedViewTool { 1779 /** 1780 * @param {Inspector} inspector 1781 * @param {Window} window 1782 */ 1783 constructor(inspector, window) { 1784 this.inspector = inspector; 1785 this.document = window.document; 1786 1787 this.computedView = new CssComputedView(this.inspector, this.document); 1788 1789 this.onDetachedFront = this.onDetachedFront.bind(this); 1790 this.onSelected = this.onSelected.bind(this); 1791 this.refresh = this.refresh.bind(this); 1792 this.onPanelSelected = this.onPanelSelected.bind(this); 1793 1794 this.#abortController = new AbortController(); 1795 const opts = { signal: this.#abortController.signal }; 1796 this.inspector.selection.on("detached-front", this.onDetachedFront, opts); 1797 this.inspector.selection.on("new-node-front", this.onSelected, opts); 1798 this.inspector.selection.on("pseudoclass", this.refresh, opts); 1799 this.inspector.sidebar.on( 1800 "computedview-selected", 1801 this.onPanelSelected, 1802 opts 1803 ); 1804 this.inspector.styleChangeTracker.on( 1805 "style-changed", 1806 () => { 1807 // `refresh` may not actually update the styles if the computed panel is hidden 1808 // so use a flag to force updating the element styles the next time the computed 1809 // panel refreshes. 1810 this.computedView.elementStyleUpdated = true; 1811 this.refresh(); 1812 }, 1813 opts 1814 ); 1815 1816 this.computedView.selectElement(null); 1817 1818 this.onSelected(); 1819 } 1820 1821 #abortController; 1822 1823 isPanelVisible() { 1824 if (!this.computedView) { 1825 return false; 1826 } 1827 return this.computedView.isPanelVisible(); 1828 } 1829 1830 onDetachedFront() { 1831 this.onSelected(false); 1832 } 1833 1834 async onSelected(selectElement = true) { 1835 // Ignore the event if the view has been destroyed, or if it's inactive. 1836 // But only if the current selection isn't null. If it's been set to null, 1837 // let the update go through as this is needed to empty the view on 1838 // navigation. 1839 if (!this.computedView) { 1840 return; 1841 } 1842 1843 const isInactive = 1844 !this.isPanelVisible() && this.inspector.selection.nodeFront; 1845 if (isInactive) { 1846 return; 1847 } 1848 1849 if ( 1850 !this.inspector.selection.isConnected() || 1851 !this.inspector.selection.isElementNode() 1852 ) { 1853 this.computedView.selectElement(null); 1854 return; 1855 } 1856 1857 if (selectElement) { 1858 const done = this.inspector.updating("computed-view"); 1859 await this.computedView.selectElement(this.inspector.selection.nodeFront); 1860 done(); 1861 } 1862 } 1863 1864 refresh() { 1865 if (this.isPanelVisible()) { 1866 this.computedView.refreshPanel(); 1867 } 1868 } 1869 1870 onPanelSelected() { 1871 if ( 1872 this.inspector.selection.nodeFront === this.computedView.viewedElement 1873 ) { 1874 this.refresh(); 1875 } else { 1876 this.onSelected(); 1877 } 1878 } 1879 1880 destroy() { 1881 this.#abortController.abort(); 1882 this.computedView.destroy(); 1883 1884 this.computedView = 1885 this.document = 1886 this.inspector = 1887 this.#abortController = 1888 null; 1889 } 1890 } 1891 1892 exports.CssComputedView = CssComputedView; 1893 exports.ComputedViewTool = ComputedViewTool; 1894 exports.PropertyView = PropertyView;