stylesheets-manager.js (34781B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 8 const { 9 getSourcemapBaseURL, 10 } = require("resource://devtools/server/actors/utils/source-map-utils.js"); 11 12 loader.lazyRequireGetter( 13 this, 14 ["addPseudoClassLock", "removePseudoClassLock"], 15 "resource://devtools/server/actors/highlighters/utils/markup.js", 16 true 17 ); 18 loader.lazyRequireGetter( 19 this, 20 "loadSheet", 21 "resource://devtools/shared/layout/utils.js", 22 true 23 ); 24 loader.lazyRequireGetter( 25 this, 26 ["getStyleSheetOwnerNode", "getStyleSheetText"], 27 "resource://devtools/server/actors/utils/stylesheet-utils.js", 28 true 29 ); 30 31 const TRANSITION_PSEUDO_CLASS = ":-moz-styleeditor-transitioning"; 32 const TRANSITION_DURATION_MS = 500; 33 const TRANSITION_BUFFER_MS = 1000; 34 const TRANSITION_RULE_SELECTOR = `:root${TRANSITION_PSEUDO_CLASS}, :root${TRANSITION_PSEUDO_CLASS} *:not(:-moz-native-anonymous)`; 35 const TRANSITION_SHEET = 36 "data:text/css;charset=utf-8," + 37 encodeURIComponent(` 38 ${TRANSITION_RULE_SELECTOR} { 39 transition-duration: ${TRANSITION_DURATION_MS}ms !important; 40 transition-delay: 0ms !important; 41 transition-timing-function: ease-out !important; 42 transition-property: all !important; 43 } 44 `); 45 46 // The possible kinds of style-applied events. 47 // UPDATE_PRESERVING_RULES means that the update is guaranteed to 48 // preserve the number and order of rules on the style sheet. 49 // UPDATE_GENERAL covers any other kind of change to the style sheet. 50 const UPDATE_PRESERVING_RULES = 0; 51 const UPDATE_GENERAL = 1; 52 53 // If the user edits a stylesheet, we stash a copy of the edited text 54 // here, keyed by the stylesheet. This way, if the tools are closed 55 // and then reopened, the edited text will be available. A weak map 56 // is used so that navigation by the user will eventually cause the 57 // edited text to be collected. 58 const modifiedStyleSheets = new WeakMap(); 59 60 /** 61 * Manage stylesheets related to a given Target Actor. 62 * 63 * @fires stylesheet-updated: emitted when there was changes in a stylesheet 64 * First arg is an object with the following properties: 65 * - resourceId {String}: The id that was assigned to the stylesheet 66 * - updateKind {String}: Which kind of update it is ("style-applied", 67 * "at-rules-changed", "matches-change", "property-change") 68 * - updates {Object}: The update data 69 */ 70 class StyleSheetsManager extends EventEmitter { 71 #abortController; 72 // Map<resourceId, AbortController> 73 #mqlChangeAbortControllerMap = new Map(); 74 #styleSheetCount = 0; 75 #styleSheetMap = new Map(); 76 #styleSheetCreationData; 77 #targetActor; 78 #transitionSheetLoaded; 79 #transitionTimeout; 80 #watchListeners = { 81 onAvailable: [], 82 onUpdated: [], 83 onDestroyed: [], 84 }; 85 86 /** 87 * @param TargetActor targetActor 88 * The target actor from which we should observe stylesheet changes. 89 */ 90 constructor(targetActor) { 91 super(); 92 93 this.#targetActor = targetActor; 94 } 95 96 #setEventListenersIfNeeded() { 97 if (this.#abortController) { 98 return; 99 } 100 101 this.#abortController = new AbortController(); 102 const { signal } = this.#abortController; 103 104 // Listen for new stylesheet being added via StyleSheetApplicableStateChanged 105 if (this.#targetActor.chromeEventHandler) { 106 this.#targetActor.chromeEventHandler.addEventListener( 107 "StyleSheetApplicableStateChanged", 108 this.#onApplicableStateChanged, 109 { capture: true, signal } 110 ); 111 this.#targetActor.chromeEventHandler.addEventListener( 112 "StyleSheetRemoved", 113 this.#onStylesheetRemoved, 114 { capture: true, signal } 115 ); 116 } 117 118 this.#watchStyleSheetChangeEvents(); 119 this.#targetActor.on("window-ready", this.#onTargetActorWindowReady, { 120 signal, 121 }); 122 } 123 124 /** 125 * Calling this function will make the StyleSheetsManager start the event listeners needed 126 * to watch for stylesheet additions and modifications. 127 * This resolves once it notified about existing stylesheets. 128 * 129 * @param {object} options 130 * @param {Function} onAvailable: Function that will be called when a stylesheet is 131 * registered, but also with already registered stylesheets 132 * if ignoreExisting is not set to true. 133 * This is called with a single object parameter with the following properties: 134 * - {String} resourceId: The id that was assigned to the stylesheet 135 * - {StyleSheet} styleSheet: The actual stylesheet object 136 * - {Object} creationData: An object with: 137 * - {boolean} isCreatedByDevTools: Was the stylesheet created 138 * by DevTools (e.g. by the user clicking the new stylesheet 139 * button in the styleeditor) 140 * - {String} fileName 141 * @param {Function} onUpdated: Function that will be called when a stylesheet is updated 142 * This is called with a single object parameter with the following properties: 143 * - {String} resourceId: The id that was assigned to the stylesheet 144 * - {String} updateKind: Which kind of update it is ("style-applied", 145 * "at-rules-changed", "matches-change", "property-change") 146 * - {Object} updates : The update data 147 * @param {Function} onDestroyed: Function that will be called when a stylesheet is removed 148 * This is called with a single object parameter with the following properties: 149 * - {String} resourceId: The id that was assigned to the stylesheet 150 * @param {boolean} ignoreExisting: Pass to true to avoid onAvailable to be called with 151 * already registered stylesheets. 152 */ 153 async watch({ onAvailable, onUpdated, onDestroyed, ignoreExisting = false }) { 154 if (!onAvailable && !onUpdated && !onDestroyed) { 155 throw new Error("Expect onAvailable, onUpdated or onDestroyed"); 156 } 157 158 if (onAvailable) { 159 if (typeof onAvailable !== "function") { 160 throw new Error("onAvailable should be a function"); 161 } 162 163 // Don't register the listener yet if we're ignoring existing stylesheets, we'll do 164 // that at the end of the function, after we processed existing stylesheets. 165 } 166 167 if (onUpdated) { 168 if (typeof onUpdated !== "function") { 169 throw new Error("onUpdated should be a function"); 170 } 171 this.#watchListeners.onUpdated.push(onUpdated); 172 } 173 174 if (onDestroyed) { 175 if (typeof onDestroyed !== "function") { 176 throw new Error("onDestroyed should be a function"); 177 } 178 this.#watchListeners.onDestroyed.push(onDestroyed); 179 } 180 181 // Process existing stylesheets 182 const promises = []; 183 for (const window of this.#targetActor.windows) { 184 promises.push(this.#getStyleSheetsForWindow(window)); 185 } 186 187 this.#setEventListenersIfNeeded(); 188 189 // Finally, notify about existing stylesheets 190 const styleSheets = await Promise.all(promises); 191 const styleSheetsData = styleSheets.flat().map(styleSheet => ({ 192 styleSheet, 193 resourceId: this.#registerStyleSheet(styleSheet), 194 })); 195 196 let registeredStyleSheetsPromises; 197 if (onAvailable && ignoreExisting !== true) { 198 registeredStyleSheetsPromises = styleSheetsData.map( 199 ({ resourceId, styleSheet }) => onAvailable({ resourceId, styleSheet }) 200 ); 201 } 202 203 // Only register the listener after we went over the list of existing stylesheets 204 // so the listener is not triggered by possible calls to #registerStyleSheet earlier. 205 if (onAvailable) { 206 this.#watchListeners.onAvailable.push(onAvailable); 207 } 208 209 if (registeredStyleSheetsPromises) { 210 await Promise.all(registeredStyleSheetsPromises); 211 } 212 } 213 214 /** 215 * Remove the passed listeners 216 * 217 * @param {object} options: See this.watch 218 */ 219 unwatch({ onAvailable, onUpdated, onDestroyed }) { 220 if (!this.#watchListeners) { 221 return; 222 } 223 224 if (onAvailable) { 225 const index = this.#watchListeners.onAvailable.indexOf(onAvailable); 226 if (index !== -1) { 227 this.#watchListeners.onAvailable.splice(index, 1); 228 } 229 } 230 231 if (onUpdated) { 232 const index = this.#watchListeners.onUpdated.indexOf(onUpdated); 233 if (index !== -1) { 234 this.#watchListeners.onUpdated.splice(index, 1); 235 } 236 } 237 238 if (onDestroyed) { 239 const index = this.#watchListeners.onDestroyed.indexOf(onDestroyed); 240 if (index !== -1) { 241 this.#watchListeners.onDestroyed.splice(index, 1); 242 } 243 } 244 } 245 246 #watchStyleSheetChangeEvents() { 247 for (const window of this.#targetActor.windows) { 248 this.#watchStyleSheetChangeEventsForWindow(window); 249 } 250 } 251 252 #onTargetActorWindowReady = ({ window }) => { 253 this.#watchStyleSheetChangeEventsForWindow(window); 254 }; 255 256 #watchStyleSheetChangeEventsForWindow(window) { 257 // We have to set this flag in order to get the 258 // StyleSheetApplicableStateChanged and StyleSheetRemoved events. See Document.webidl. 259 window.document.styleSheetChangeEventsEnabled = true; 260 } 261 262 #unwatchStyleSheetChangeEvents() { 263 for (const window of this.#targetActor.windows) { 264 window.document.styleSheetChangeEventsEnabled = false; 265 } 266 } 267 268 /** 269 * Create a new style sheet in the document with the given text. 270 * 271 * @param {Document} document 272 * Document that the new style sheet belong to. 273 * @param {Element} parent 274 * The element into which we'll append the <style> element 275 * @param {string} text 276 * Content of style sheet. 277 * @param {string} fileName 278 * If the stylesheet adding is from file, `fileName` indicates the path. 279 */ 280 async addStyleSheet(document, parent, text, fileName) { 281 const style = document.createElementNS( 282 "http://www.w3.org/1999/xhtml", 283 "style" 284 ); 285 style.setAttribute("type", "text/css"); 286 style.setDevtoolsAsTriggeringPrincipal(); 287 288 if (text) { 289 style.appendChild(document.createTextNode(text)); 290 } 291 292 // This triggers StyleSheetApplicableStateChanged event. 293 parent.appendChild(style); 294 295 // This promise will be resolved when the resource for this stylesheet is available. 296 let resolve = null; 297 const promise = new Promise(r => { 298 resolve = r; 299 }); 300 301 if (!this.#styleSheetCreationData) { 302 this.#styleSheetCreationData = new WeakMap(); 303 } 304 this.#styleSheetCreationData.set(style.sheet, { 305 isCreatedByDevTools: true, 306 fileName, 307 resolve, 308 }); 309 310 await promise; 311 312 return style.sheet; 313 } 314 315 /** 316 * Return resourceId of the given style sheet or create one if the stylesheet wasn't 317 * registered yet. 318 * 319 * @param {StyleSheet} styleSheet 320 * @returns {string} resourceId 321 */ 322 getStyleSheetResourceId(styleSheet) { 323 const existingResourceId = this.#findStyleSheetResourceId(styleSheet); 324 if (existingResourceId) { 325 return existingResourceId; 326 } 327 328 // If we couldn't find an associated resourceId, that means the stylesheet isn't 329 // registered yet. Calling #registerStyleSheet will register it and return the 330 // associated resourceId it computed for it. 331 return this.#registerStyleSheet(styleSheet); 332 } 333 334 /** 335 * Return the associated resourceId of the given registered style sheet, or null if the 336 * stylesheet wasn't registered yet. 337 * 338 * @param {StyleSheet} styleSheet 339 * @returns {string} resourceId 340 */ 341 #findStyleSheetResourceId(styleSheet) { 342 for (const [ 343 resourceId, 344 existingStyleSheet, 345 ] of this.#styleSheetMap.entries()) { 346 if (styleSheet === existingStyleSheet) { 347 return resourceId; 348 } 349 } 350 351 return null; 352 } 353 354 /** 355 * Return owner node of the style sheet of the given resource id. 356 * 357 * @param {string} resourceId 358 * The id associated with the stylesheet 359 * @returns {Element|null} 360 */ 361 getOwnerNode(resourceId) { 362 const styleSheet = this.#styleSheetMap.get(resourceId); 363 return styleSheet.ownerNode; 364 } 365 366 /** 367 * Return the index of given stylesheet of the given resource id. 368 * 369 * @param {string} resourceId 370 * The id associated with the stylesheet 371 * @returns {number} 372 */ 373 getStyleSheetIndex(resourceId) { 374 const styleSheet = this.#styleSheetMap.get(resourceId); 375 376 const styleSheets = InspectorUtils.getAllStyleSheets( 377 this.#targetActor.window.document, 378 true 379 ); 380 let i = 0; 381 for (const sheet of styleSheets) { 382 if (!this.#shouldListSheet(sheet)) { 383 continue; 384 } 385 if (sheet == styleSheet) { 386 return i; 387 } 388 i++; 389 } 390 return -1; 391 } 392 393 /** 394 * Get the text of a stylesheet given its resourceId. 395 * 396 * @param {string} resourceId 397 * The id associated with the stylesheet 398 * @returns {string} 399 */ 400 async getText(resourceId) { 401 const styleSheet = this.#styleSheetMap.get(resourceId); 402 403 const modifiedText = modifiedStyleSheets.get(styleSheet); 404 405 // modifiedText is the content of the stylesheet updated by update function. 406 // In case not updating, this is undefined. 407 if (modifiedText !== undefined) { 408 return modifiedText; 409 } 410 411 return getStyleSheetText(styleSheet); 412 } 413 414 /** 415 * Toggle the disabled property of the stylesheet 416 * 417 * @param {string} resourceId 418 * The id associated with the stylesheet 419 * @return {boolean} the disabled state after toggling. 420 */ 421 toggleDisabled(resourceId) { 422 const styleSheet = this.#styleSheetMap.get(resourceId); 423 styleSheet.disabled = !styleSheet.disabled; 424 425 this.#notifyPropertyChanged(resourceId, "disabled", styleSheet.disabled); 426 427 return styleSheet.disabled; 428 } 429 430 /** 431 * Update the style sheet in place with new text. 432 * 433 * @param {string} resourceId 434 * @param {string} text 435 * New text. 436 * @param {object} options 437 * @param {boolean} options.transition 438 * Whether to do CSS transition for change. Defaults to false. 439 * @param {number} options.kind 440 * Either UPDATE_PRESERVING_RULES or UPDATE_GENERAL. Defaults to UPDATE_GENERAL. 441 * @param {string} options.cause 442 * Indicates the cause of this update (e.g. "styleeditor") if this was called 443 * from the stylesheet to be edited by the user from the StyleEditor. 444 */ 445 async setStyleSheetText( 446 resourceId, 447 text, 448 { transition = false, kind = UPDATE_GENERAL, cause = "" } = {} 449 ) { 450 const styleSheet = this.#styleSheetMap.get(resourceId); 451 InspectorUtils.parseStyleSheet(styleSheet, text); 452 modifiedStyleSheets.set(styleSheet, text); 453 454 // getStyleSheetRuleCountAndAtRules can be costly, so only call it when needed, 455 // i.e. when the whole stylesheet is modified, not when a rule body is. 456 let atRules, ruleCount; 457 if (kind !== UPDATE_PRESERVING_RULES) { 458 ({ atRules, ruleCount } = 459 this.getStyleSheetRuleCountAndAtRules(styleSheet)); 460 this.#notifyPropertyChanged(resourceId, "ruleCount", ruleCount); 461 } 462 463 if (transition) { 464 this.#startTransition(resourceId, kind, cause); 465 } else { 466 this.#onStyleSheetUpdated({ 467 resourceId, 468 updateKind: "style-applied", 469 updates: { 470 event: { kind, cause }, 471 }, 472 }); 473 } 474 475 if (kind !== UPDATE_PRESERVING_RULES) { 476 this.#onStyleSheetUpdated({ 477 resourceId, 478 updateKind: "at-rules-changed", 479 updates: { 480 resourceUpdates: { atRules }, 481 }, 482 }); 483 } 484 } 485 486 /** 487 * Applies a transition to the stylesheet document so any change made by the user in the 488 * client will be animated so it's more visible. 489 * 490 * @param {string} resourceId 491 * The id associated with the stylesheet 492 * @param {number} kind 493 * Either UPDATE_PRESERVING_RULES or UPDATE_GENERAL 494 * @param {string} cause 495 * Indicates the cause of this update (e.g. "styleeditor") if this was called 496 * from the stylesheet to be edited by the user from the StyleEditor. 497 */ 498 #startTransition(resourceId, kind, cause) { 499 const styleSheet = this.#styleSheetMap.get(resourceId); 500 const document = styleSheet.associatedDocument; 501 const window = document.ownerGlobal; 502 503 if (!this.#transitionSheetLoaded) { 504 this.#transitionSheetLoaded = true; 505 // We don't remove this sheet. It uses an internal selector that 506 // we only apply via locks, so there's no need to load and unload 507 // it all the time. 508 loadSheet(window, TRANSITION_SHEET); 509 } 510 511 addPseudoClassLock(document.documentElement, TRANSITION_PSEUDO_CLASS); 512 513 // Set up clean up and commit after transition duration (+buffer) 514 // @see #onTransitionEnd 515 window.clearTimeout(this.#transitionTimeout); 516 this.#transitionTimeout = window.setTimeout( 517 this.#onTransitionEnd.bind(this, resourceId, kind, cause), 518 TRANSITION_DURATION_MS + TRANSITION_BUFFER_MS 519 ); 520 } 521 522 /** 523 * @param {string} resourceId 524 * The id associated with the stylesheet 525 * @param {number} kind 526 * Either UPDATE_PRESERVING_RULES or UPDATE_GENERAL 527 * @param {string} cause 528 * Indicates the cause of this update (e.g. "styleeditor") if this was called 529 * from the stylesheet to be edited by the user from the StyleEditor. 530 */ 531 #onTransitionEnd(resourceId, kind, cause) { 532 const styleSheet = this.#styleSheetMap.get(resourceId); 533 const document = styleSheet.associatedDocument; 534 535 this.#transitionTimeout = null; 536 removePseudoClassLock(document.documentElement, TRANSITION_PSEUDO_CLASS); 537 538 this.#onStyleSheetUpdated({ 539 resourceId, 540 updateKind: "style-applied", 541 updates: { 542 event: { kind, cause }, 543 }, 544 }); 545 } 546 547 /** 548 * Retrieve the CSSRuleList of a given stylesheet 549 * 550 * @param {StyleSheet} styleSheet 551 * @returns {CSSRuleList} 552 */ 553 #getCSSRules(styleSheet) { 554 try { 555 return styleSheet.cssRules; 556 } catch (e) { 557 // sheet isn't loaded yet 558 } 559 560 if (!styleSheet.ownerNode) { 561 return Promise.resolve([]); 562 } 563 564 return new Promise(resolve => { 565 styleSheet.ownerNode.addEventListener( 566 "load", 567 () => resolve(styleSheet.cssRules), 568 { once: true } 569 ); 570 }); 571 } 572 573 /** 574 * Get the stylesheets imported by a given stylesheet (via @import) 575 * 576 * @param {Document} document 577 * @param {StyleSheet} styleSheet 578 * @returns Array<StyleSheet> 579 */ 580 async #getImportedStyleSheets(document, styleSheet) { 581 const importedStyleSheets = []; 582 583 for (const rule of await this.#getCSSRules(styleSheet)) { 584 const ruleClassName = ChromeUtils.getClassName(rule); 585 if (ruleClassName == "CSSImportRule") { 586 // With the Gecko style system, the associated styleSheet may be null 587 // if it has already been seen because an import cycle for the same 588 // URL. With Stylo, the styleSheet will exist (which is correct per 589 // the latest CSSOM spec), so we also need to check ancestors for the 590 // same URL to avoid cycles. 591 if ( 592 !rule.styleSheet || 593 this.#haveAncestorWithSameURL(rule.styleSheet) || 594 !this.#shouldListSheet(rule.styleSheet) 595 ) { 596 continue; 597 } 598 599 importedStyleSheets.push(rule.styleSheet); 600 601 // recurse imports in this stylesheet as well 602 const children = await this.#getImportedStyleSheets( 603 document, 604 rule.styleSheet 605 ); 606 importedStyleSheets.push(...children); 607 } else if (ruleClassName != "CSSCharsetRule") { 608 // @import rules must precede all others except @charset 609 break; 610 } 611 } 612 613 return importedStyleSheets; 614 } 615 616 /** 617 * Retrieve the total number of rules (including nested ones) and 618 * all the at-rules of a given stylesheet. 619 * 620 * @param {StyleSheet} styleSheet 621 * @returns {object} An object of the following shape: 622 * - {Integer} ruleCount: The total number of rules in the stylesheet 623 * - {Array<Object>} atRules: An array of object of the following shape: 624 * - type {String} 625 * - conditionText {String} 626 * - matches {Boolean}: true if the media rule matches the current state of the document 627 * - layerName {String} 628 * - line {Number} 629 * - column {Number} 630 */ 631 getStyleSheetRuleCountAndAtRules(styleSheet) { 632 const resourceId = this.#findStyleSheetResourceId(styleSheet); 633 if (!resourceId) { 634 return []; 635 } 636 637 if (this.#mqlChangeAbortControllerMap.has(resourceId)) { 638 this.#mqlChangeAbortControllerMap.get(resourceId).abort(); 639 this.#mqlChangeAbortControllerMap.delete(resourceId); 640 } 641 642 // Accessing the stylesheet associated window might be slow due to cross compartment 643 // wrappers, so only retrieve it if it's needed. 644 let win; 645 const getStyleSheetAssociatedWindow = () => { 646 if (!win) { 647 win = styleSheet.associatedDocument?.ownerGlobal; 648 } 649 return win; 650 }; 651 652 // This returns the following type of at-rules: 653 // - CSSMediaRule 654 // - CSSContainerRule 655 // - CSSSupportsRule 656 // - CSSLayerBlockRule 657 // New types can be added from InpsectorUtils.cpp `CollectAtRules` 658 const { atRules: styleSheetRules, ruleCount } = 659 InspectorUtils.getStyleSheetRuleCountAndAtRules(styleSheet); 660 const atRules = []; 661 for (const rule of styleSheetRules) { 662 const className = ChromeUtils.getClassName(rule); 663 if (className === "CSSMediaRule") { 664 let matches = false; 665 666 try { 667 const associatedWin = getStyleSheetAssociatedWindow(); 668 const mql = associatedWin.matchMedia(rule.media.mediaText); 669 matches = mql.matches; 670 671 let ac = this.#mqlChangeAbortControllerMap.get(resourceId); 672 if (!ac) { 673 ac = new associatedWin.AbortController(); 674 this.#mqlChangeAbortControllerMap.set(resourceId, ac); 675 } 676 677 const index = atRules.length; 678 mql.addEventListener( 679 "change", 680 () => this.#onMatchesChange(resourceId, index, mql), 681 { 682 signal: ac.signal, 683 } 684 ); 685 } catch (e) { 686 // Ignored 687 } 688 689 atRules.push({ 690 type: "media", 691 conditionText: rule.conditionText, 692 matches, 693 line: InspectorUtils.getRelativeRuleLine(rule), 694 column: InspectorUtils.getRuleColumn(rule), 695 }); 696 } else if (className === "CSSContainerRule") { 697 atRules.push({ 698 type: "container", 699 conditionText: rule.conditionText, 700 line: InspectorUtils.getRelativeRuleLine(rule), 701 column: InspectorUtils.getRuleColumn(rule), 702 }); 703 } else if (className === "CSSSupportsRule") { 704 atRules.push({ 705 type: "support", 706 conditionText: rule.conditionText, 707 line: InspectorUtils.getRelativeRuleLine(rule), 708 column: InspectorUtils.getRuleColumn(rule), 709 }); 710 } else if (className === "CSSLayerBlockRule") { 711 atRules.push({ 712 type: "layer", 713 layerName: rule.name, 714 line: InspectorUtils.getRelativeRuleLine(rule), 715 column: InspectorUtils.getRuleColumn(rule), 716 }); 717 } else if (className === "CSSPropertyRule") { 718 atRules.push({ 719 type: "property", 720 propertyName: rule.name, 721 line: InspectorUtils.getRelativeRuleLine(rule), 722 column: InspectorUtils.getRuleColumn(rule), 723 }); 724 } else if (className === "CSSPositionTryRule") { 725 atRules.push({ 726 type: "position-try", 727 positionTryName: rule.name, 728 line: InspectorUtils.getRelativeRuleLine(rule), 729 column: InspectorUtils.getRuleColumn(rule), 730 }); 731 } else if (className === "CSSCustomMediaRule") { 732 const customMediaQuery = []; 733 if (typeof rule.query === "boolean") { 734 customMediaQuery.push({ 735 text: rule.query.toString(), 736 matches: rule.query === true, 737 }); 738 } else { 739 // if query is not a boolean, it's a MediaList 740 for (let i = 0, len = rule.query.length; i < len; i++) { 741 customMediaQuery.push({ 742 text: rule.query[i], 743 // For now always consider the media query as matching. 744 // This should be changed as part of Bug 2006379 745 matches: true, 746 }); 747 } 748 } 749 atRules.push({ 750 type: "custom-media", 751 customMediaName: rule.name, 752 customMediaQuery, 753 line: InspectorUtils.getRelativeRuleLine(rule), 754 column: InspectorUtils.getRuleColumn(rule), 755 }); 756 } 757 } 758 return { 759 ruleCount, 760 atRules, 761 }; 762 } 763 764 /** 765 * Called when the status of a media query support changes (i.e. it now matches, or it 766 * was matching but isn't anymore) 767 * 768 * @param {string} resourceId 769 * The id associated with the stylesheet 770 * @param {number} index 771 * The index of the media rule relatively to all the other at-rules of the stylesheet 772 * @param {MediaQueryList} mql 773 * The result of matchMedia for the given media rule 774 */ 775 #onMatchesChange(resourceId, index, mql) { 776 this.#onStyleSheetUpdated({ 777 resourceId, 778 updateKind: "matches-change", 779 updates: { 780 nestedResourceUpdates: [ 781 { 782 path: ["atRules", index, "matches"], 783 value: mql.matches, 784 }, 785 ], 786 }, 787 }); 788 } 789 790 /** 791 * Get the node href of a given stylesheet 792 * 793 * @param {StyleSheet} styleSheet 794 * @returns {string} 795 */ 796 getNodeHref(styleSheet) { 797 const { ownerNode } = styleSheet; 798 if (!ownerNode) { 799 return null; 800 } 801 802 if (ownerNode.nodeType == ownerNode.DOCUMENT_NODE) { 803 return ownerNode.location.href; 804 } 805 806 if (ownerNode.ownerDocument?.location) { 807 return ownerNode.ownerDocument.location.href; 808 } 809 810 return null; 811 } 812 813 /** 814 * Get the sourcemap base url of a given stylesheet 815 * 816 * @param {StyleSheet} styleSheet 817 * @returns {string} 818 */ 819 getSourcemapBaseURL(styleSheet) { 820 // When the style is injected via nsIDOMWindowUtils.loadSheet, even 821 // the parent style sheet has no owner, so default back to target actor 822 // document 823 const ownerNode = getStyleSheetOwnerNode(styleSheet); 824 const ownerDocument = ownerNode 825 ? ownerNode.ownerDocument 826 : this.#targetActor.window; 827 828 return getSourcemapBaseURL( 829 // Technically resolveSourceURL should be used here alongside 830 // "this.rawSheet.sourceURL", but the style inspector does not support 831 // /*# sourceURL=*/ in CSS, so we're omitting it here (bug 880831). 832 styleSheet.href || this.getNodeHref(styleSheet), 833 ownerDocument 834 ); 835 } 836 837 /** 838 * Get all the stylesheets for a given window 839 * 840 * @param {Window} window 841 * @returns {Array<StyleSheet>} 842 */ 843 async #getStyleSheetsForWindow(window) { 844 const { document } = window; 845 const documentOnly = !document.nodePrincipal.isSystemPrincipal; 846 847 const styleSheets = []; 848 849 for (const styleSheet of InspectorUtils.getAllStyleSheets( 850 document, 851 documentOnly 852 )) { 853 if (!this.#shouldListSheet(styleSheet)) { 854 continue; 855 } 856 857 styleSheets.push(styleSheet); 858 859 // Get all sheets, including imported ones 860 const importedStyleSheets = await this.#getImportedStyleSheets( 861 document, 862 styleSheet 863 ); 864 styleSheets.push(...importedStyleSheets); 865 } 866 867 return styleSheets; 868 } 869 870 /** 871 * Returns true if a given stylesheet has an ancestor with the same url it has 872 * 873 * @param {StyleSheet} styleSheet 874 * @returns {boolean} 875 */ 876 #haveAncestorWithSameURL(styleSheet) { 877 const href = styleSheet.href; 878 while (styleSheet.parentStyleSheet) { 879 if (styleSheet.parentStyleSheet.href == href) { 880 return true; 881 } 882 styleSheet = styleSheet.parentStyleSheet; 883 } 884 return false; 885 } 886 887 /** 888 * Helper function called when a property changed in a given stylesheet 889 * 890 * @param {string} resourceId 891 * The id of the stylesheet the change occured in 892 * @param {string} property 893 * The property that was changed 894 * @param {string} value 895 * The value of the property 896 */ 897 #notifyPropertyChanged(resourceId, property, value) { 898 this.#onStyleSheetUpdated({ 899 resourceId, 900 updateKind: "property-change", 901 updates: { resourceUpdates: { [property]: value } }, 902 }); 903 } 904 905 /** 906 * Event handler that is called when the state of applicable of style sheet is changed. 907 * 908 * For now, StyleSheetApplicableStateChanged event will be called at following timings. 909 * - Append <link> of stylesheet to document 910 * - Append <style> to document 911 * - Change disable attribute of stylesheet object 912 * - Change disable attribute of <link> to false 913 * - Stylesheet is constructed. 914 * When appending <link>, <style> or changing `disabled` attribute to false, 915 * `applicable` is passed as true. The other hand, when changing `disabled` 916 * to true, this will be false. 917 * 918 * NOTE: StyleSheetApplicableStateChanged is _not_ called when removing the <link>/<style>, 919 * but a StyleSheetRemovedEvent is emitted in such case (see #onStyleSheetRemoved) 920 * 921 * @param {StyleSheetApplicableStateChangedEvent} 922 * The triggering event. 923 */ 924 #onApplicableStateChanged = ({ applicable, stylesheet: styleSheet }) => { 925 if ( 926 // Have interest in applicable stylesheet only. 927 applicable && 928 styleSheet.associatedDocument && 929 (!this.#targetActor.ignoreSubFrames || 930 styleSheet.associatedDocument.ownerGlobal === 931 this.#targetActor.window) && 932 this.#shouldListSheet(styleSheet) && 933 !this.#haveAncestorWithSameURL(styleSheet) 934 ) { 935 this.#registerStyleSheet(styleSheet); 936 } 937 }; 938 939 /** 940 * Event handler that is called when a style sheet is removed. 941 * 942 * @param {StyleSheetRemovedEvent} 943 * The triggering event. 944 */ 945 #onStylesheetRemoved = event => { 946 this.#unregisterStyleSheet(event.stylesheet); 947 }; 948 949 /** 950 * If the stylesheet isn't registered yet, this function will generate an associated 951 * resourceId and call registered `onAvailable` listeners. 952 * 953 * @param {StyleSheet} styleSheet 954 * @returns {string} the associated resourceId 955 */ 956 #registerStyleSheet(styleSheet) { 957 const existingResourceId = this.#findStyleSheetResourceId(styleSheet); 958 // If the stylesheet is already registered, there's no need to notify about it again. 959 if (existingResourceId) { 960 return existingResourceId; 961 } 962 963 // It's important to prefix the resourceId with the target actorID so we can't have 964 // duplicated resource ids when the client connects to multiple targets. 965 const resourceId = `${this.#targetActor.actorID}:stylesheet:${this 966 .#styleSheetCount++}`; 967 this.#styleSheetMap.set(resourceId, styleSheet); 968 969 const creationData = this.#styleSheetCreationData?.get(styleSheet); 970 this.#styleSheetCreationData?.delete(styleSheet); 971 972 const onAvailablePromises = []; 973 for (const onAvailable of this.#watchListeners.onAvailable) { 974 onAvailablePromises.push( 975 onAvailable({ 976 resourceId, 977 styleSheet, 978 creationData, 979 }) 980 ); 981 } 982 983 // creationData exists if this stylesheet was created via `addStyleSheet`. 984 if (creationData) { 985 // We resolve the promise once the watcher sent the resources to the client, 986 // so `addStyleSheet` calls can be fullfilled. 987 Promise.all(onAvailablePromises).then(() => creationData?.resolve()); 988 } 989 return resourceId; 990 } 991 992 /** 993 * If the stylesheet is registered, this function will call registered `onDestroyed` 994 * listeners with the stylesheet resourceId. 995 * 996 * @param {StyleSheet} styleSheet 997 */ 998 #unregisterStyleSheet(styleSheet) { 999 const existingResourceId = this.#findStyleSheetResourceId(styleSheet); 1000 if (!existingResourceId) { 1001 return; 1002 } 1003 1004 this.#styleSheetMap.delete(existingResourceId); 1005 this.#styleSheetCreationData?.delete(styleSheet); 1006 if (this.#mqlChangeAbortControllerMap.has(existingResourceId)) { 1007 this.#mqlChangeAbortControllerMap.get(existingResourceId).abort(); 1008 this.#mqlChangeAbortControllerMap.delete(existingResourceId); 1009 } 1010 1011 for (const onDestroyed of this.#watchListeners.onDestroyed) { 1012 onDestroyed({ 1013 resourceId: existingResourceId, 1014 }); 1015 } 1016 } 1017 1018 #onStyleSheetUpdated(data) { 1019 this.emit("stylesheet-updated", data); 1020 1021 for (const onUpdated of this.#watchListeners.onUpdated) { 1022 onUpdated(data); 1023 } 1024 } 1025 1026 /** 1027 * Returns true if the passed styleSheet should be handled. 1028 * 1029 * @param {StyleSheet} styleSheet 1030 * @returns {boolean} 1031 */ 1032 #shouldListSheet(styleSheet) { 1033 const href = styleSheet.href?.toLowerCase(); 1034 // FIXME(bug 1826538): Make accessiblecaret.css and similar UA-widget 1035 // sheets system sheets, then remove this special-case. 1036 if ( 1037 href === "resource://gre-resources/accessiblecaret.css" || 1038 href === "resource://gre-resources/details.css" || 1039 (href === "resource://devtools-highlighter-styles/highlighters.css" && 1040 this.#targetActor.sessionContext.type !== "all") 1041 ) { 1042 return false; 1043 } 1044 return true; 1045 } 1046 1047 /** 1048 * The StyleSheetsManager instance is managed by the target, so this will be called when 1049 * the target gets destroyed. 1050 */ 1051 destroy() { 1052 // Cleanup 1053 if (this.#abortController) { 1054 this.#abortController.abort(); 1055 } 1056 if (this.#mqlChangeAbortControllerMap) { 1057 for (const ac of this.#mqlChangeAbortControllerMap.values()) { 1058 ac.abort(); 1059 } 1060 } 1061 1062 try { 1063 this.#unwatchStyleSheetChangeEvents(); 1064 } catch (e) { 1065 console.error( 1066 "Error when destroying StyleSheet manager for", 1067 this.#targetActor, 1068 ": ", 1069 e 1070 ); 1071 } 1072 1073 this.#styleSheetMap.clear(); 1074 this.#abortController = null; 1075 this.#mqlChangeAbortControllerMap = null; 1076 this.#styleSheetCreationData = null; 1077 this.#styleSheetMap = null; 1078 this.#targetActor = null; 1079 this.#watchListeners = null; 1080 } 1081 } 1082 1083 module.exports = { 1084 StyleSheetsManager, 1085 UPDATE_GENERAL, 1086 UPDATE_PRESERVING_RULES, 1087 };