StyleEditorUI.sys.mjs (58794B)
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 import { 6 loader, 7 require, 8 } from "resource://devtools/shared/loader/Loader.sys.mjs"; 9 10 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 11 12 import { 13 getString, 14 text, 15 showFilePicker, 16 optionsPopupMenu, 17 } from "resource://devtools/client/styleeditor/StyleEditorUtil.sys.mjs"; 18 import { StyleSheetEditor } from "resource://devtools/client/styleeditor/StyleSheetEditor.sys.mjs"; 19 20 const { PrefObserver } = require("resource://devtools/client/shared/prefs.js"); 21 22 const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js"); 23 const { 24 shortSource, 25 } = require("resource://devtools/shared/inspector/css-logic.js"); 26 27 const lazy = {}; 28 29 loader.lazyRequireGetter( 30 lazy, 31 "KeyCodes", 32 "resource://devtools/client/shared/keycodes.js", 33 true 34 ); 35 36 loader.lazyRequireGetter( 37 lazy, 38 "OriginalSource", 39 "resource://devtools/client/styleeditor/original-source.js", 40 true 41 ); 42 43 ChromeUtils.defineESModuleGetters(lazy, { 44 FileUtils: "resource://gre/modules/FileUtils.sys.mjs", 45 NetUtil: "resource://gre/modules/NetUtil.sys.mjs", 46 }); 47 loader.lazyRequireGetter( 48 lazy, 49 "ResponsiveUIManager", 50 "resource://devtools/client/responsive/manager.js" 51 ); 52 loader.lazyRequireGetter( 53 lazy, 54 "openContentLink", 55 "resource://devtools/client/shared/link.js", 56 true 57 ); 58 loader.lazyRequireGetter( 59 lazy, 60 "copyString", 61 "resource://devtools/shared/platform/clipboard.js", 62 true 63 ); 64 65 const LOAD_ERROR = "error-load"; 66 const PREF_AT_RULES_SIDEBAR = "devtools.styleeditor.showAtRulesSidebar"; 67 const PREF_SIDEBAR_WIDTH = "devtools.styleeditor.atRulesSidebarWidth"; 68 const PREF_NAV_WIDTH = "devtools.styleeditor.navSidebarWidth"; 69 const PREF_ORIG_SOURCES = "devtools.source-map.client-service.enabled"; 70 71 const FILTERED_CLASSNAME = "splitview-filtered"; 72 const ALL_FILTERED_CLASSNAME = "splitview-all-filtered"; 73 74 const HTML_NS = "http://www.w3.org/1999/xhtml"; 75 76 /** 77 * StyleEditorUI is controls and builds the UI of the Style Editor, including 78 * maintaining a list of editors for each stylesheet on a debuggee. 79 * 80 * Emits events: 81 * 'editor-added': A new editor was added to the UI 82 * 'editor-selected': An editor was selected 83 * 'error': An error occured 84 * 85 */ 86 export class StyleEditorUI extends EventEmitter { 87 #activeSummary = null; 88 #commands; 89 #contextMenu; 90 #contextMenuStyleSheet; 91 #copyUrlItem; 92 #cssProperties; 93 #filter; 94 #filterInput; 95 #filterInputClearButton; 96 #loadingStyleSheets; 97 #nav; 98 #openLinkNewTabItem; 99 #optionsButton; 100 #optionsMenu; 101 #panelDoc; 102 #prefObserver; 103 #prettyPrintButton; 104 #root; 105 #seenSheets = new Map(); 106 #shortcuts; 107 #side; 108 #sourceMapPrefObserver; 109 #styleSheetBoundToSelect; 110 #styleSheetToSelect; 111 /** 112 * Maps keyed by summary element whose value is an object containing: 113 * - {Element} details: The associated details element (i.e. container for CodeMirror) 114 * - {StyleSheetEditor} editor: The associated editor, for easy retrieval 115 */ 116 #summaryDataMap = new WeakMap(); 117 #toolbox; 118 #tplDetails; 119 #tplSummary; 120 #uiAbortController = new AbortController(); 121 #window; 122 123 /** 124 * @param {Toolbox} toolbox 125 * @param {object} commands Object defined from devtools/shared/commands to interact with the devtools backend 126 * @param {Document} panelDoc 127 * Document of the toolbox panel to populate UI in. 128 * @param {CssProperties} A css properties database. 129 */ 130 constructor(toolbox, commands, panelDoc, cssProperties) { 131 super(); 132 133 this.#toolbox = toolbox; 134 this.#commands = commands; 135 this.#panelDoc = panelDoc; 136 this.#cssProperties = cssProperties; 137 this.#window = this.#panelDoc.defaultView; 138 this.#root = this.#panelDoc.getElementById("style-editor-chrome"); 139 140 this.editors = []; 141 this.selectedEditor = null; 142 this.savedLocations = {}; 143 144 this.#prefObserver = new PrefObserver("devtools.styleeditor."); 145 this.#prefObserver.on( 146 PREF_AT_RULES_SIDEBAR, 147 this.#onAtRulesSidebarPrefChanged 148 ); 149 this.#sourceMapPrefObserver = new PrefObserver( 150 "devtools.source-map.client-service." 151 ); 152 this.#sourceMapPrefObserver.on( 153 PREF_ORIG_SOURCES, 154 this.#onOrigSourcesPrefChanged 155 ); 156 } 157 158 get cssProperties() { 159 return this.#cssProperties; 160 } 161 162 get currentTarget() { 163 return this.#commands.targetCommand.targetFront; 164 } 165 166 /* 167 * Index of selected stylesheet in document.styleSheets 168 */ 169 get selectedStyleSheetIndex() { 170 return this.selectedEditor 171 ? this.selectedEditor.styleSheet.styleSheetIndex 172 : -1; 173 } 174 175 /** 176 * Initiates the style editor ui creation, and start to track TargetCommand updates. 177 * 178 * @param {object} options 179 * @param {object} options.stylesheetToSelect 180 * @param {StyleSheetResource} options.stylesheetToSelect.stylesheet 181 * @param {Integer} options.stylesheetToSelect.line 182 * @param {Integer} options.stylesheetToSelect.column 183 */ 184 async initialize(options = {}) { 185 this.createUI(); 186 187 if (options.stylesheetToSelect) { 188 const { stylesheet, line, column } = options.stylesheetToSelect; 189 // If a stylesheet resource and its location was passed (e.g. user clicked on a stylesheet 190 // location in the rule view), we can directly add it to the list and select it 191 // before watching for resources, for improved performance. 192 if (stylesheet.resourceId) { 193 try { 194 await this.#handleStyleSheetResource(stylesheet); 195 await this.selectStyleSheet( 196 stylesheet, 197 line - 1, 198 column ? column - 1 : 0 199 ); 200 } catch (e) { 201 console.error(e); 202 } 203 } 204 } 205 206 await this.#commands.resourceCommand.watchResources( 207 [this.#commands.resourceCommand.TYPES.DOCUMENT_EVENT], 208 { onAvailable: this.#onResourceAvailable } 209 ); 210 await this.#commands.targetCommand.watchTargets({ 211 types: [this.#commands.targetCommand.TYPES.FRAME], 212 onAvailable: this.#onTargetAvailable, 213 onDestroyed: this.#onTargetDestroyed, 214 }); 215 216 this.#startLoadingStyleSheets(); 217 await this.#commands.resourceCommand.watchResources( 218 [this.#commands.resourceCommand.TYPES.STYLESHEET], 219 { 220 onAvailable: this.#onResourceAvailable, 221 onUpdated: this.#onResourceUpdated, 222 onDestroyed: this.#onResourceDestroyed, 223 } 224 ); 225 await this.#waitForLoadingStyleSheets(); 226 } 227 228 /** 229 * Build the initial UI and wire buttons with event handlers. 230 */ 231 createUI() { 232 this.#filterInput = this.#root.querySelector(".devtools-filterinput"); 233 this.#filterInputClearButton = this.#root.querySelector( 234 ".devtools-searchinput-clear" 235 ); 236 this.#nav = this.#root.querySelector(".splitview-nav"); 237 this.#side = this.#root.querySelector(".splitview-side-details"); 238 this.#tplSummary = this.#root.querySelector( 239 "#splitview-tpl-summary-stylesheet" 240 ); 241 this.#tplDetails = this.#root.querySelector( 242 "#splitview-tpl-details-stylesheet" 243 ); 244 245 const eventListenersConfig = { signal: this.#uiAbortController.signal }; 246 247 // Add click event on the "new stylesheet" button in the toolbar and on the 248 // "append a new stylesheet" link (visible when there are no stylesheets). 249 for (const el of this.#root.querySelectorAll(".style-editor-newButton")) { 250 el.addEventListener( 251 "click", 252 async () => { 253 const stylesheetsFront = 254 await this.currentTarget.getFront("stylesheets"); 255 stylesheetsFront.addStyleSheet(null); 256 this.#clearFilterInput(); 257 }, 258 eventListenersConfig 259 ); 260 } 261 262 this.#root.querySelector(".style-editor-importButton").addEventListener( 263 "click", 264 () => { 265 this.#importFromFile(this._mockImportFile || null, this.#window); 266 this.#clearFilterInput(); 267 }, 268 eventListenersConfig 269 ); 270 271 this.#prettyPrintButton = this.#root.querySelector( 272 ".style-editor-prettyPrintButton" 273 ); 274 this.#prettyPrintButton.addEventListener( 275 "click", 276 () => { 277 if (!this.selectedEditor) { 278 return; 279 } 280 this.#prettyPrintButton.classList.add("pretty"); 281 this.selectedEditor.prettifySourceText(); 282 }, 283 eventListenersConfig 284 ); 285 286 this.#root 287 .querySelector("#style-editor-options") 288 .addEventListener( 289 "click", 290 this.#onOptionsButtonClick, 291 eventListenersConfig 292 ); 293 294 this.#filterInput.addEventListener( 295 "input", 296 this.#onFilterInputChange, 297 eventListenersConfig 298 ); 299 300 this.#filterInputClearButton.addEventListener( 301 "click", 302 () => this.#clearFilterInput(), 303 eventListenersConfig 304 ); 305 306 this.#panelDoc.addEventListener( 307 "contextmenu", 308 () => { 309 this.#contextMenuStyleSheet = null; 310 }, 311 { ...eventListenersConfig, capture: true } 312 ); 313 314 this.#optionsButton = this.#panelDoc.getElementById("style-editor-options"); 315 316 this.#contextMenu = this.#panelDoc.getElementById("sidebar-context"); 317 this.#contextMenu.addEventListener( 318 "popupshowing", 319 this.#updateContextMenuItems, 320 eventListenersConfig 321 ); 322 323 this.#openLinkNewTabItem = this.#panelDoc.getElementById( 324 "context-openlinknewtab" 325 ); 326 this.#openLinkNewTabItem.addEventListener( 327 "command", 328 this.#openLinkNewTab, 329 eventListenersConfig 330 ); 331 332 this.#copyUrlItem = this.#panelDoc.getElementById("context-copyurl"); 333 this.#copyUrlItem.addEventListener( 334 "command", 335 this.#copyUrl, 336 eventListenersConfig 337 ); 338 339 // items list focus and search-on-type handling 340 this.#nav.addEventListener( 341 "keydown", 342 this.#onNavKeyDown, 343 eventListenersConfig 344 ); 345 346 this.#shortcuts = new KeyShortcuts({ 347 window: this.#window, 348 }); 349 this.#shortcuts.on( 350 `CmdOrCtrl+${getString("focusFilterInput.commandkey")}`, 351 this.#onFocusFilterInputKeyboardShortcut 352 ); 353 354 const nav = this.#panelDoc.querySelector(".splitview-controller"); 355 nav.style.width = Services.prefs.getIntPref(PREF_NAV_WIDTH) + "px"; 356 } 357 358 #clearFilterInput() { 359 this.#filterInput.value = ""; 360 this.#onFilterInputChange(); 361 } 362 363 #onFilterInputChange = () => { 364 this.#filter = this.#filterInput.value; 365 this.#filterInputClearButton.toggleAttribute("hidden", !this.#filter); 366 367 for (const summary of this.#nav.childNodes) { 368 // Don't update nav class for every element, we do it after the loop. 369 this.handleSummaryVisibility(summary, { 370 triggerOnFilterStateChange: false, 371 }); 372 } 373 374 this.#onFilterStateChange(); 375 376 if (this.#activeSummary == null) { 377 const firstVisibleSummary = Array.from(this.#nav.childNodes).find( 378 node => !node.classList.contains(FILTERED_CLASSNAME) 379 ); 380 381 if (firstVisibleSummary) { 382 this.setActiveSummary(firstVisibleSummary, { reason: "filter-auto" }); 383 } 384 } 385 }; 386 387 #onFilterStateChange() { 388 const summaries = Array.from(this.#nav.childNodes); 389 const hasVisibleSummary = summaries.some( 390 node => !node.classList.contains(FILTERED_CLASSNAME) 391 ); 392 const allFiltered = !!summaries.length && !hasVisibleSummary; 393 394 this.#nav.classList.toggle(ALL_FILTERED_CLASSNAME, allFiltered); 395 396 this.#filterInput 397 .closest(".devtools-searchbox") 398 .classList.toggle("devtools-searchbox-no-match", !!allFiltered); 399 } 400 401 #onFocusFilterInputKeyboardShortcut = e => { 402 // Prevent the print modal to be displayed. 403 if (e) { 404 e.stopPropagation(); 405 e.preventDefault(); 406 } 407 this.#filterInput.select(); 408 }; 409 410 #onNavKeyDown = event => { 411 function getFocusedItemWithin(nav) { 412 let node = nav.ownerDocument.activeElement; 413 while (node && node.parentNode != nav) { 414 node = node.parentNode; 415 } 416 return node; 417 } 418 419 // do not steal focus from inside iframes or textboxes 420 if ( 421 event.target.ownerDocument != this.#nav.ownerDocument || 422 event.target.tagName == "input" || 423 event.target.tagName == "textarea" || 424 event.target.classList.contains("textbox") 425 ) { 426 return false; 427 } 428 429 // handle keyboard navigation within the items list 430 const visibleElements = Array.from( 431 this.#nav.querySelectorAll(`li:not(.${FILTERED_CLASSNAME})`) 432 ); 433 // Elements have a different visual order (due to the use of order), so 434 // we need to sort them by their data-ordinal attribute 435 visibleElements.sort( 436 (a, b) => a.getAttribute("data-ordinal") - b.getAttribute("data-ordinal") 437 ); 438 439 let elementToFocus; 440 if ( 441 event.keyCode == lazy.KeyCodes.DOM_VK_PAGE_UP || 442 event.keyCode == lazy.KeyCodes.DOM_VK_HOME 443 ) { 444 elementToFocus = visibleElements[0]; 445 } else if ( 446 event.keyCode == lazy.KeyCodes.DOM_VK_PAGE_DOWN || 447 event.keyCode == lazy.KeyCodes.DOM_VK_END 448 ) { 449 elementToFocus = visibleElements.at(-1); 450 } else if (event.keyCode == lazy.KeyCodes.DOM_VK_UP) { 451 const focusedIndex = visibleElements.indexOf( 452 getFocusedItemWithin(this.#nav) 453 ); 454 elementToFocus = visibleElements[focusedIndex - 1]; 455 } else if (event.keyCode == lazy.KeyCodes.DOM_VK_DOWN) { 456 const focusedIndex = visibleElements.indexOf( 457 getFocusedItemWithin(this.#nav) 458 ); 459 elementToFocus = visibleElements[focusedIndex + 1]; 460 } 461 462 if (elementToFocus !== undefined) { 463 event.stopPropagation(); 464 event.preventDefault(); 465 elementToFocus.focus(); 466 return false; 467 } 468 469 return true; 470 }; 471 472 /** 473 * Opens the Options Popup Menu 474 * 475 * @param {number} screenX 476 * @param {number} screenY 477 * Both obtained from the event object, used to position the popup 478 */ 479 #onOptionsButtonClick = ({ screenX, screenY }) => { 480 this.#optionsMenu = optionsPopupMenu( 481 this.#toggleOrigSources, 482 this.#toggleAtRulesSidebar 483 ); 484 485 this.#optionsMenu.once("open", () => { 486 this.#optionsButton.setAttribute("open", true); 487 }); 488 this.#optionsMenu.once("close", () => { 489 this.#optionsButton.removeAttribute("open"); 490 }); 491 492 this.#optionsMenu.popup(screenX, screenY, this.#toolbox.doc); 493 }; 494 495 /** 496 * Be called when changing the original sources pref. 497 */ 498 #onOrigSourcesPrefChanged = async () => { 499 this.#clear(); 500 // When we toggle the source-map preference, we clear the panel and re-fetch the exact 501 // same stylesheet resources from ResourceCommand, but `_addStyleSheet` will trigger 502 // or ignore the additional source-map mapping. 503 this.#root.classList.add("loading"); 504 for (const resource of this.#commands.resourceCommand.getAllResources( 505 this.#commands.resourceCommand.TYPES.STYLESHEET 506 )) { 507 await this.#handleStyleSheetResource(resource); 508 } 509 510 this.#root.classList.remove("loading"); 511 512 this.emit("stylesheets-refreshed"); 513 }; 514 515 /** 516 * Remove all editors and add loading indicator. 517 */ 518 #clear = () => { 519 // remember selected sheet and line number for next load 520 if (this.selectedEditor && this.selectedEditor.sourceEditor) { 521 const href = this.selectedEditor.styleSheet.href; 522 const { line, ch } = this.selectedEditor.sourceEditor.getCursor(); 523 524 this.#styleSheetToSelect = { 525 stylesheet: href, 526 line, 527 col: ch, 528 }; 529 } 530 531 // remember saved file locations 532 for (const editor of this.editors) { 533 if (editor.savedFile) { 534 const identifier = this.getStyleSheetIdentifier(editor.styleSheet); 535 this.savedLocations[identifier] = editor.savedFile; 536 } 537 } 538 539 this.#clearStyleSheetEditors(); 540 // Clear the left sidebar items and their associated elements. 541 while (this.#nav.hasChildNodes()) { 542 this.removeSplitViewItem(this.#nav.firstChild); 543 } 544 545 this.selectedEditor = null; 546 // Here the keys are style sheet actors, and the values are 547 // promises that resolve to the sheet's editor. See |_addStyleSheet|. 548 this.#seenSheets = new Map(); 549 550 this.emit("stylesheets-clear"); 551 }; 552 553 /** 554 * Add an editor for this stylesheet. Add editors for its original sources 555 * instead (e.g. Sass sources), if applicable. 556 * 557 * @param {Resource} resource 558 * The STYLESHEET resource which is received from resource command. 559 * @return {Promise} 560 * A promise that resolves to the style sheet's editor when the style sheet has 561 * been fully loaded. If the style sheet has a source map, and source mapping 562 * is enabled, then the promise resolves to null. 563 */ 564 #addStyleSheet(resource) { 565 if (!this.#seenSheets.has(resource)) { 566 const promise = (async () => { 567 // When the StyleSheet is mapped to one or many original sources, 568 // do not create an editor for the minified StyleSheet. 569 const hasValidOriginalSource = 570 await this.#tryAddingOriginalStyleSheets(resource); 571 if (hasValidOriginalSource) { 572 return null; 573 } 574 // Otherwise, if source-map failed or this is a non-source-map CSS 575 // create an editor for it. 576 return this.#addStyleSheetEditor(resource); 577 })(); 578 this.#seenSheets.set(resource, promise); 579 } 580 return this.#seenSheets.get(resource); 581 } 582 583 /** 584 * Check if the given StyleSheet relates to an original StyleSheet (via source maps). 585 * If one is found, create an editor for the original one. 586 * 587 * @param {Resource} resource 588 * The STYLESHEET resource which is received from resource command. 589 * @return Boolean 590 * Return true, when we found a viable related original StyleSheet. 591 */ 592 async #tryAddingOriginalStyleSheets(resource) { 593 // Avoid querying the SourceMap if this feature is disabled. 594 if (!Services.prefs.getBoolPref(PREF_ORIG_SOURCES)) { 595 return false; 596 } 597 598 const sourceMapLoader = this.#toolbox.sourceMapLoader; 599 const { 600 href, 601 nodeHref, 602 resourceId: id, 603 sourceMapURL, 604 sourceMapBaseURL, 605 } = resource; 606 let sources; 607 try { 608 sources = await sourceMapLoader.getOriginalURLs({ 609 id, 610 url: href || nodeHref, 611 sourceMapBaseURL, 612 sourceMapURL, 613 }); 614 } catch (e) { 615 // Ignore any source map error, they will be logged 616 // via the SourceMapLoader and Toolbox into the Web Console. 617 return false; 618 } 619 620 // Return the generated CSS if the source-map failed to be parsed 621 // or did not generate any original source. 622 if (!sources || !sources.length) { 623 return false; 624 } 625 626 // A single generated sheet might map to multiple original 627 // sheets, so make editors for each of them. 628 for (const { id: originalId, url: originalURL } of sources) { 629 const original = new lazy.OriginalSource( 630 originalURL, 631 originalId, 632 sourceMapLoader 633 ); 634 635 // set so the first sheet will be selected, even if it's a source 636 original.styleSheetIndex = resource.styleSheetIndex; 637 original.relatedStyleSheet = resource; 638 original.resourceId = resource.resourceId; 639 original.targetFront = resource.targetFront; 640 original.atRules = resource.atRules; 641 await this.#addStyleSheetEditor(original); 642 } 643 644 return true; 645 } 646 647 #removeStyleSheet(resource, editor) { 648 this.#seenSheets.delete(resource); 649 this.#removeStyleSheetEditor(editor); 650 } 651 652 #getInlineStyleSheetsCount() { 653 let count = 0; 654 for (const editor of this.editors) { 655 if (!editor.styleSheet.href && !editor.styleSheet.constructed) { 656 count++; 657 } 658 } 659 return count; 660 } 661 662 #getNewStyleSheetsCount() { 663 let count = 0; 664 for (const editor of this.editors) { 665 if (editor.isNew) { 666 count++; 667 } 668 } 669 return count; 670 } 671 672 #getConstructedSheetsCount() { 673 let count = 0; 674 for (const editor of this.editors) { 675 if (editor.styleSheet.constructed) { 676 count++; 677 } 678 } 679 return count; 680 } 681 682 /** 683 * Finds the index to be shown in the Style Editor for inline, constructed or 684 * user-created style sheets, returns undefined if not any of those. 685 * 686 * @param {StyleSheet} styleSheet 687 * Object representing stylesheet 688 * @return {number} 689 * 1-based Integer representing the index of the current stylesheet 690 * among all stylesheets of its type (inline, constructed or user-created). 691 * Defaults to 0 when non-applicable (e.g. for stylesheet with href) 692 */ 693 #getNextFriendlyIndex(styleSheet) { 694 if (styleSheet.href) { 695 return 0; 696 } 697 698 if (styleSheet.isNew) { 699 return this.#getNewStyleSheetsCount() + 1; 700 } 701 702 if (styleSheet.constructed) { 703 return this.#getConstructedSheetsCount() + 1; 704 } 705 706 return this.#getInlineStyleSheetsCount() + 1; 707 } 708 709 /** 710 * Add a new editor to the UI for a source. 711 * 712 * @param {Resource} resource 713 * The resource which is received from resource command. 714 * @return {Promise} that is resolved with the created StyleSheetEditor when 715 * the editor is fully initialized or rejected on error. 716 */ 717 async #addStyleSheetEditor(resource) { 718 const editor = new StyleSheetEditor( 719 resource, 720 this.#window, 721 this.#getNextFriendlyIndex(resource) 722 ); 723 724 editor.on("property-change", this.#summaryChange.bind(this, editor)); 725 editor.on("at-rules-changed", this.#updateAtRulesList.bind(this, editor)); 726 editor.on("linked-css-file", this.#summaryChange.bind(this, editor)); 727 editor.on("linked-css-file-error", this.#summaryChange.bind(this, editor)); 728 editor.on("error", this.#onError); 729 editor.on( 730 "filter-input-keyboard-shortcut", 731 this.#onFocusFilterInputKeyboardShortcut 732 ); 733 734 // onAtRulesChanged fires at-rules-changed, so call the function after 735 // registering the listener in order to ensure to get at-rules-changed event. 736 editor.onAtRulesChanged(resource.atRules); 737 738 this.editors.push(editor); 739 740 try { 741 await editor.fetchSource(); 742 } catch (e) { 743 // if the editor was destroyed while fetching dependencies, we don't want to go further. 744 if (!this.editors.includes(editor)) { 745 return null; 746 } 747 throw e; 748 } 749 750 this.#sourceLoaded(editor); 751 752 if (resource.fileName) { 753 this.emit("test:editor-updated", editor); 754 } 755 756 return editor; 757 } 758 759 /** 760 * Import a style sheet from file and asynchronously create a 761 * new stylesheet on the debuggee for it. 762 * 763 * @param {mixed} file 764 * Optional nsIFile or filename string. 765 * If not set a file picker will be shown. 766 * @param {nsIWindow} parentWindow 767 * Optional parent window for the file picker. 768 */ 769 #importFromFile(file, parentWindow) { 770 const onFileSelected = selectedFile => { 771 if (!selectedFile) { 772 // nothing selected 773 return; 774 } 775 lazy.NetUtil.asyncFetch( 776 { 777 uri: lazy.NetUtil.newURI(selectedFile), 778 loadingNode: this.#window.document, 779 securityFlags: 780 Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT, 781 contentPolicyType: Ci.nsIContentPolicy.TYPE_OTHER, 782 }, 783 async (stream, status) => { 784 if (!Components.isSuccessCode(status)) { 785 this.emit("error", { key: LOAD_ERROR, level: "warning" }); 786 return; 787 } 788 const source = lazy.NetUtil.readInputStreamToString( 789 stream, 790 stream.available() 791 ); 792 stream.close(); 793 794 const stylesheetsFront = 795 await this.currentTarget.getFront("stylesheets"); 796 stylesheetsFront.addStyleSheet(source, selectedFile.path); 797 } 798 ); 799 }; 800 801 showFilePicker(file, false, parentWindow, onFileSelected); 802 } 803 804 /** 805 * Forward any error from a stylesheet. 806 * 807 * @param {data} data 808 * The event data 809 */ 810 #onError = data => { 811 this.emit("error", data); 812 }; 813 814 /** 815 * Toggle the original sources pref. 816 */ 817 #toggleOrigSources() { 818 const isEnabled = Services.prefs.getBoolPref(PREF_ORIG_SOURCES); 819 Services.prefs.setBoolPref(PREF_ORIG_SOURCES, !isEnabled); 820 } 821 822 /** 823 * Toggle the pref for showing the at-rules sidebar (for @media, @layer, @container, …) 824 * in each editor. 825 */ 826 #toggleAtRulesSidebar() { 827 const isEnabled = Services.prefs.getBoolPref(PREF_AT_RULES_SIDEBAR); 828 Services.prefs.setBoolPref(PREF_AT_RULES_SIDEBAR, !isEnabled); 829 } 830 831 /** 832 * Toggle the at-rules sidebar in each editor depending on the setting. 833 */ 834 #onAtRulesSidebarPrefChanged = () => { 835 this.editors.forEach(this.#updateAtRulesList); 836 }; 837 838 /** 839 * This method handles the following cases related to the context 840 * menu items "_openLinkNewTabItem" and "_copyUrlItem": 841 * 842 * 1) There was a stylesheet clicked on and it is external: show and 843 * enable the context menu item 844 * 2) There was a stylesheet clicked on and it is inline: show and 845 * disable the context menu item 846 * 3) There was no stylesheet clicked on (the right click happened 847 * below the list): hide the context menu 848 */ 849 #updateContextMenuItems = async () => { 850 this.#openLinkNewTabItem.hidden = !this.#contextMenuStyleSheet; 851 this.#copyUrlItem.hidden = !this.#contextMenuStyleSheet; 852 853 if (this.#contextMenuStyleSheet) { 854 this.#openLinkNewTabItem.setAttribute( 855 "disabled", 856 !this.#contextMenuStyleSheet.href 857 ); 858 this.#copyUrlItem.setAttribute( 859 "disabled", 860 !this.#contextMenuStyleSheet.href 861 ); 862 } 863 }; 864 865 /** 866 * Open a particular stylesheet in a new tab. 867 */ 868 #openLinkNewTab = () => { 869 if (this.#contextMenuStyleSheet) { 870 lazy.openContentLink(this.#contextMenuStyleSheet.href); 871 } 872 }; 873 874 /** 875 * Copies a stylesheet's URL. 876 */ 877 #copyUrl = () => { 878 if (this.#contextMenuStyleSheet) { 879 lazy.copyString(this.#contextMenuStyleSheet.href); 880 } 881 }; 882 883 /** 884 * Remove a particular stylesheet editor from the UI 885 * 886 * @param {StyleSheetEditor} editor 887 * The editor to remove. 888 */ 889 #removeStyleSheetEditor(editor) { 890 if (editor.summary) { 891 this.removeSplitViewItem(editor.summary); 892 } else { 893 const self = this; 894 this.on("editor-added", function onAdd(added) { 895 if (editor == added) { 896 self.off("editor-added", onAdd); 897 self.removeSplitViewItem(editor.summary); 898 } 899 }); 900 } 901 902 editor.destroy(); 903 this.editors.splice(this.editors.indexOf(editor), 1); 904 } 905 906 /** 907 * Clear all the editors from the UI. 908 */ 909 #clearStyleSheetEditors() { 910 for (const editor of this.editors) { 911 editor.destroy(); 912 } 913 this.editors = []; 914 } 915 916 /** 917 * Called when a StyleSheetEditor's source has been fetched. 918 * Add new sidebar item and editor to the UI 919 * 920 * @param {StyleSheetEditor} editor 921 * Editor to create UI for. 922 */ 923 #sourceLoaded(editor) { 924 // Create the detail and summary nodes from the templates node (declared in index.xhtml) 925 const details = this.#tplDetails.cloneNode(true); 926 details.id = ""; 927 const summary = this.#tplSummary.cloneNode(true); 928 summary.id = ""; 929 930 let ordinal = editor.styleSheet.styleSheetIndex; 931 ordinal = ordinal == -1 ? Number.MAX_SAFE_INTEGER : ordinal; 932 summary.style.order = ordinal; 933 summary.setAttribute("data-ordinal", ordinal); 934 935 const isSystem = !!editor.styleSheet.system; 936 if (isSystem) { 937 summary.classList.add("stylesheet-readonly"); 938 } 939 940 this.#nav.appendChild(summary); 941 this.#side.appendChild(details); 942 943 this.#summaryDataMap.set(summary, { 944 details, 945 editor, 946 }); 947 948 const createdEditor = editor; 949 createdEditor.summary = summary; 950 createdEditor.details = details; 951 952 const eventListenersConfig = { signal: this.#uiAbortController.signal }; 953 954 summary.addEventListener( 955 "click", 956 event => { 957 event.stopPropagation(); 958 this.setActiveSummary(summary); 959 }, 960 eventListenersConfig 961 ); 962 963 const stylesheetToggle = summary.querySelector(".stylesheet-toggle"); 964 if (isSystem) { 965 stylesheetToggle.disabled = true; 966 this.#window.document.l10n.setAttributes( 967 stylesheetToggle, 968 "styleeditor-visibility-toggle-system" 969 ); 970 } else { 971 stylesheetToggle.addEventListener( 972 "click", 973 event => { 974 event.stopPropagation(); 975 event.target.blur(); 976 977 createdEditor.toggleDisabled(); 978 }, 979 eventListenersConfig 980 ); 981 } 982 983 summary.querySelector(".stylesheet-name").addEventListener( 984 "keypress", 985 event => { 986 if (event.keyCode == lazy.KeyCodes.DOM_VK_RETURN) { 987 this.setActiveSummary(summary); 988 } 989 }, 990 eventListenersConfig 991 ); 992 993 summary.querySelector(".stylesheet-saveButton").addEventListener( 994 "click", 995 event => { 996 event.stopPropagation(); 997 event.target.blur(); 998 999 createdEditor.saveToFile(createdEditor.savedFile); 1000 }, 1001 eventListenersConfig 1002 ); 1003 1004 this.#updateSummaryForEditor(createdEditor, summary); 1005 1006 summary.addEventListener( 1007 "contextmenu", 1008 () => { 1009 this.#contextMenuStyleSheet = createdEditor.styleSheet; 1010 }, 1011 eventListenersConfig 1012 ); 1013 1014 summary.addEventListener( 1015 "focus", 1016 function onSummaryFocus(event) { 1017 if (event.target == summary) { 1018 // autofocus the stylesheet name 1019 summary.querySelector(".stylesheet-name").focus(); 1020 } 1021 }, 1022 eventListenersConfig 1023 ); 1024 1025 const sidebar = details.querySelector(".stylesheet-sidebar"); 1026 sidebar.style.width = Services.prefs.getIntPref(PREF_SIDEBAR_WIDTH) + "px"; 1027 1028 const splitter = details.querySelector(".devtools-side-splitter"); 1029 splitter.addEventListener( 1030 "mousemove", 1031 () => { 1032 const sidebarWidth = parseInt(sidebar.style.width, 10); 1033 if (!isNaN(sidebarWidth)) { 1034 Services.prefs.setIntPref(PREF_SIDEBAR_WIDTH, sidebarWidth); 1035 1036 // update all at-rules sidebars for consistency 1037 const sidebars = [ 1038 ...this.#panelDoc.querySelectorAll(".stylesheet-sidebar"), 1039 ]; 1040 for (const atRuleSidebar of sidebars) { 1041 atRuleSidebar.style.width = sidebarWidth + "px"; 1042 } 1043 } 1044 }, 1045 eventListenersConfig 1046 ); 1047 1048 // autofocus if it's a new user-created stylesheet 1049 if (createdEditor.isNew) { 1050 this.#selectEditor(createdEditor); 1051 } 1052 1053 if (this.#isEditorToSelect(createdEditor)) { 1054 this.switchToSelectedSheet(); 1055 } 1056 1057 // If this is the first stylesheet and there is no pending request to 1058 // select a particular style sheet, select this sheet. 1059 if ( 1060 !this.selectedEditor && 1061 !this.#styleSheetBoundToSelect && 1062 createdEditor.styleSheet.styleSheetIndex == 0 && 1063 !summary.classList.contains(FILTERED_CLASSNAME) 1064 ) { 1065 this.#selectEditor(createdEditor); 1066 } 1067 this.emit("editor-added", createdEditor); 1068 } 1069 1070 /** 1071 * Switch to the editor that has been marked to be selected. 1072 * 1073 * @return {Promise} 1074 * Promise that will resolve when the editor is selected. 1075 */ 1076 switchToSelectedSheet() { 1077 const toSelect = this.#styleSheetToSelect; 1078 1079 for (const editor of this.editors) { 1080 if (this.#isEditorToSelect(editor)) { 1081 // The _styleSheetBoundToSelect will always hold the latest pending 1082 // requested style sheet (with line and column) which is not yet 1083 // selected by the source editor. Only after we select that particular 1084 // editor and go the required line and column, it will become null. 1085 this.#styleSheetBoundToSelect = this.#styleSheetToSelect; 1086 this.#styleSheetToSelect = null; 1087 return this.#selectEditor(editor, toSelect.line, toSelect.col); 1088 } 1089 } 1090 1091 return Promise.resolve(); 1092 } 1093 1094 /** 1095 * Returns whether a given editor is the current editor to be selected. Tests 1096 * based on href or underlying stylesheet. 1097 * 1098 * @param {StyleSheetEditor} editor 1099 * The editor to test. 1100 */ 1101 #isEditorToSelect(editor) { 1102 const toSelect = this.#styleSheetToSelect; 1103 if (!toSelect) { 1104 return false; 1105 } 1106 const isHref = 1107 toSelect.stylesheet === null || typeof toSelect.stylesheet == "string"; 1108 1109 return ( 1110 (isHref && editor.styleSheet.href == toSelect.stylesheet) || 1111 toSelect.stylesheet == editor.styleSheet 1112 ); 1113 } 1114 1115 /** 1116 * Select an editor in the UI. 1117 * 1118 * @param {StyleSheetEditor} editor 1119 * Editor to switch to. 1120 * @param {number} line 1121 * Line number to jump to 1122 * @param {number} col 1123 * Column number to jump to 1124 * @return {Promise} 1125 * Promise that will resolve when the editor is selected and ready 1126 * to be used. 1127 */ 1128 #selectEditor(editor, line = null, col = null) { 1129 // Don't go further if the editor was destroyed in the meantime 1130 if (!this.editors.includes(editor)) { 1131 return null; 1132 } 1133 1134 const editorPromise = editor.getSourceEditor().then(() => { 1135 // line/col are null when the style editor is initialized and the first stylesheet 1136 // editor is selected. Unfortunately, this function might be called also when the 1137 // panel is opened from clicking on a CSS warning in the WebConsole panel, in which 1138 // case we have specific line+col. 1139 // There's no guarantee which one could be called first, and it happened that we 1140 // were setting the cursor once for the correct line coming from the webconsole, 1141 // and then re-setting it to the default value (which was <0,0>). 1142 // To avoid the race, we simply don't explicitly set the cursor to any default value, 1143 // which is not a big deal as CodeMirror does init it to <0,0> anyway. 1144 // See Bug 1738124 for more information. 1145 if (line !== null || col !== null) { 1146 editor.setCursor(line, col); 1147 } 1148 this.#styleSheetBoundToSelect = null; 1149 }); 1150 1151 const summaryPromise = this.getEditorSummary(editor).then(summary => { 1152 // Don't go further if the editor was destroyed in the meantime 1153 if (!this.editors.includes(editor)) { 1154 throw new Error("Editor was destroyed"); 1155 } 1156 this.setActiveSummary(summary); 1157 }); 1158 1159 return Promise.all([editorPromise, summaryPromise]); 1160 } 1161 1162 getEditorSummary(editor) { 1163 const self = this; 1164 1165 if (editor.summary) { 1166 return Promise.resolve(editor.summary); 1167 } 1168 1169 return new Promise(resolve => { 1170 this.on("editor-added", function onAdd(selected) { 1171 if (selected == editor) { 1172 self.off("editor-added", onAdd); 1173 resolve(editor.summary); 1174 } 1175 }); 1176 }); 1177 } 1178 1179 getEditorDetails(editor) { 1180 const self = this; 1181 1182 if (editor.details) { 1183 return Promise.resolve(editor.details); 1184 } 1185 1186 return new Promise(resolve => { 1187 this.on("editor-added", function onAdd(selected) { 1188 if (selected == editor) { 1189 self.off("editor-added", onAdd); 1190 resolve(editor.details); 1191 } 1192 }); 1193 }); 1194 } 1195 1196 /** 1197 * Returns an identifier for the given style sheet. 1198 * 1199 * @param {StyleSheet} styleSheet 1200 * The style sheet to be identified. 1201 */ 1202 getStyleSheetIdentifier(styleSheet) { 1203 // Identify inline style sheets by their host page URI and index 1204 // at the page. 1205 return styleSheet.href 1206 ? styleSheet.href 1207 : "inline-" + styleSheet.styleSheetIndex + "-at-" + styleSheet.nodeHref; 1208 } 1209 1210 /** 1211 * Get the OriginalSource object for a given original sourceId returned from 1212 * the sourcemap worker service. 1213 * 1214 * @param {string} sourceId 1215 * The ID to search for from the sourcemap worker. 1216 * 1217 * @return {OriginalSource | null} 1218 */ 1219 getOriginalSourceSheet(sourceId) { 1220 for (const editor of this.editors) { 1221 const { styleSheet } = editor; 1222 if (styleSheet.isOriginalSource && styleSheet.sourceId === sourceId) { 1223 return styleSheet; 1224 } 1225 } 1226 return null; 1227 } 1228 1229 /** 1230 * Given an URL, find a stylesheet resource with that URL, if one has been 1231 * loaded into the editor.js 1232 * 1233 * Do not use this unless you have no other way to get a StyleSheet resource 1234 * multiple sheets could share the same URL, so this will give you _one_ 1235 * of possibly many sheets with that URL. 1236 * 1237 * @param {string} url 1238 * An arbitrary URL to search for. 1239 * 1240 * @return {StyleSheetResource|null} 1241 */ 1242 getStylesheetResourceForGeneratedURL(url) { 1243 for (const styleSheet of this.#seenSheets.keys()) { 1244 const sheetURL = styleSheet.href || styleSheet.nodeHref; 1245 if (!styleSheet.isOriginalSource && sheetURL === url) { 1246 return styleSheet; 1247 } 1248 } 1249 return null; 1250 } 1251 1252 /** 1253 * selects a stylesheet and optionally moves the cursor to a selected line 1254 * 1255 * @param {StyleSheetResource} stylesheet 1256 * Stylesheet to select or href of stylesheet to select 1257 * @param {number} line 1258 * Line to which the caret should be moved (zero-indexed). 1259 * @param {number} col 1260 * Column to which the caret should be moved (zero-indexed). 1261 * @return {Promise} 1262 * Promise that will resolve when the editor is selected and ready 1263 * to be used. 1264 */ 1265 selectStyleSheet(stylesheet, line, col) { 1266 this.#styleSheetToSelect = { 1267 stylesheet, 1268 line, 1269 col, 1270 }; 1271 1272 /* Switch to the editor for this sheet, if it exists yet. 1273 Otherwise each editor will be checked when it's created. */ 1274 return this.switchToSelectedSheet(); 1275 } 1276 1277 /** 1278 * Handler for an editor's 'property-changed' event. 1279 * Update the summary in the UI. 1280 * 1281 * @param {StyleSheetEditor} editor 1282 * Editor for which a property has changed 1283 */ 1284 #summaryChange(editor) { 1285 this.#updateSummaryForEditor(editor); 1286 } 1287 1288 /** 1289 * Update split view summary of given StyleEditor instance. 1290 * 1291 * @param {StyleSheetEditor} editor 1292 * @param {DOMElement} summary 1293 * Optional item's summary element to update. If none, item 1294 * corresponding to passed editor is used. 1295 */ 1296 #updateSummaryForEditor(editor, summary) { 1297 summary = summary || editor.summary; 1298 if (!summary) { 1299 return; 1300 } 1301 1302 let ruleCount = editor.styleSheet.ruleCount; 1303 if (editor.styleSheet.relatedStyleSheet) { 1304 ruleCount = editor.styleSheet.relatedStyleSheet.ruleCount; 1305 } 1306 if (ruleCount === undefined) { 1307 ruleCount = "-"; 1308 } 1309 1310 this.#panelDoc.l10n.setArgs( 1311 summary.querySelector(".stylesheet-rule-count"), 1312 { 1313 ruleCount, 1314 } 1315 ); 1316 1317 summary.classList.toggle("disabled", !!editor.styleSheet.disabled); 1318 summary.classList.toggle("unsaved", !!editor.unsaved); 1319 summary.classList.toggle("linked-file-error", !!editor.linkedCSSFileError); 1320 1321 const label = summary.querySelector(".stylesheet-name > label"); 1322 label.setAttribute("value", editor.friendlyName); 1323 if (editor.styleSheet.href) { 1324 label.setAttribute("tooltiptext", editor.styleSheet.href); 1325 } 1326 1327 let linkedCSSSource = ""; 1328 if (editor.linkedCSSFile) { 1329 linkedCSSSource = PathUtils.filename(editor.linkedCSSFile); 1330 } else if (editor.styleSheet.relatedStyleSheet) { 1331 // Compute a friendly name for the related generated source 1332 // (relatedStyleSheet is set on original CSS to refer to the generated one) 1333 linkedCSSSource = shortSource(editor.styleSheet.relatedStyleSheet); 1334 try { 1335 linkedCSSSource = decodeURI(linkedCSSSource); 1336 } catch (e) {} 1337 } 1338 text(summary, ".stylesheet-linked-file", linkedCSSSource); 1339 text(summary, ".stylesheet-title", editor.styleSheet.title || ""); 1340 1341 // We may need to change the summary visibility as a result of the changes. 1342 this.handleSummaryVisibility(summary); 1343 } 1344 1345 /** 1346 * Update the pretty print button. 1347 * The button will be disabled if: 1348 * - the selected file is read-only 1349 * - OR the selected file is an original file 1350 */ 1351 #updatePrettyPrintButton() { 1352 const isReadOnly = !!this.selectedEditor?.sourceEditor?.config?.readOnly; 1353 const isOriginalSource = 1354 !!this.selectedEditor?.styleSheet?.isOriginalSource; 1355 1356 const disable = !this.selectedEditor || isOriginalSource || isReadOnly; 1357 1358 // Only update the button if its state needs it 1359 if (disable !== this.#prettyPrintButton.hasAttribute("disabled")) { 1360 this.#prettyPrintButton.toggleAttribute("disabled"); 1361 } 1362 this.#prettyPrintButton.classList.toggle( 1363 "pretty", 1364 this.selectedEditor?.isPrettyPrinted || false 1365 ); 1366 let l10nString; 1367 if (disable) { 1368 if (isReadOnly) { 1369 l10nString = "styleeditor-pretty-print-button-disabled-read-only"; 1370 } else if (isOriginalSource) { 1371 l10nString = "styleeditor-pretty-print-button-disabled"; 1372 } 1373 } else { 1374 l10nString = "styleeditor-pretty-print-button"; 1375 } 1376 1377 this.#window.document.l10n.setAttributes( 1378 this.#prettyPrintButton, 1379 l10nString 1380 ); 1381 } 1382 1383 /** 1384 * Update the at-rules sidebar for an editor. Hide if there are no rules 1385 * Display a list of the at-rules (@media, @layer, @container, …) in the editor's associated style sheet. 1386 * Emits a 'at-rules-list-changed' event after updating the UI. 1387 * 1388 * @param {StyleSheetEditor} editor 1389 * Editor to update sidebar of 1390 */ 1391 #updateAtRulesList = editor => { 1392 (async function () { 1393 const details = await this.getEditorDetails(editor); 1394 const rules = editor.atRules; 1395 const showSidebar = Services.prefs.getBoolPref(PREF_AT_RULES_SIDEBAR); 1396 const sidebar = details.querySelector(".stylesheet-sidebar"); 1397 1398 let inSource = false; 1399 1400 const listItems = []; 1401 for (const rule of rules) { 1402 const { line, column } = rule; 1403 1404 let location = { 1405 line, 1406 column, 1407 source: editor.styleSheet.href, 1408 styleSheet: editor.styleSheet, 1409 }; 1410 if (editor.styleSheet.isOriginalSource) { 1411 const styleSheet = editor.cssSheet; 1412 location = await editor.styleSheet.getOriginalLocation( 1413 styleSheet, 1414 line, 1415 column 1416 ); 1417 } 1418 1419 // this at-rule is from a different original source 1420 if (location.source != editor.styleSheet.href) { 1421 continue; 1422 } 1423 inSource = true; 1424 1425 const div = this.#panelDoc.createElementNS(HTML_NS, "div"); 1426 div.classList.add("at-rule-label", rule.type); 1427 div.addEventListener( 1428 "click", 1429 this.#jumpToLocation.bind(this, location) 1430 ); 1431 1432 const ruleTextContainer = this.#panelDoc.createElementNS( 1433 HTML_NS, 1434 "div" 1435 ); 1436 const type = this.#panelDoc.createElementNS(HTML_NS, "span"); 1437 type.className = "at-rule-type"; 1438 type.append(this.#panelDoc.createTextNode(`@${rule.type}\u00A0`)); 1439 if (rule.type == "layer" && rule.layerName) { 1440 type.append(this.#panelDoc.createTextNode(`${rule.layerName}\u00A0`)); 1441 } else if (rule.type === "property") { 1442 type.append( 1443 this.#panelDoc.createTextNode(`${rule.propertyName}\u00A0`) 1444 ); 1445 } else if (rule.type === "position-try") { 1446 type.append( 1447 this.#panelDoc.createTextNode(`${rule.positionTryName}\u00A0`) 1448 ); 1449 } else if (rule.type === "custom-media") { 1450 const parts = []; 1451 const { customMediaName, customMediaQuery } = rule; 1452 for (let i = 0, len = customMediaQuery.length; i < len; i++) { 1453 const media = customMediaQuery[i]; 1454 const queryEl = this.#panelDoc.createElementNS(HTML_NS, "span"); 1455 queryEl.textContent = media.text; 1456 if (!media.matches) { 1457 queryEl.classList.add("media-condition-unmatched"); 1458 } 1459 parts.push(queryEl); 1460 if (len > 1 && i !== len - 1) { 1461 parts.push(", "); 1462 } 1463 } 1464 1465 type.append(`${customMediaName} `, ...parts); 1466 } 1467 1468 const cond = this.#panelDoc.createElementNS(HTML_NS, "span"); 1469 cond.className = "at-rule-condition"; 1470 if (rule.type == "media" && !rule.matches) { 1471 cond.classList.add("media-condition-unmatched"); 1472 } 1473 if (this.#commands.descriptorFront.isLocalTab) { 1474 this.#setConditionContents(cond, rule.conditionText, rule.type); 1475 } else { 1476 cond.textContent = rule.conditionText; 1477 } 1478 1479 const link = this.#panelDoc.createElementNS(HTML_NS, "div"); 1480 link.className = "at-rule-line theme-link"; 1481 if (location.line != -1) { 1482 link.textContent = ":" + location.line; 1483 } 1484 1485 ruleTextContainer.append(type, cond); 1486 div.append(ruleTextContainer, link); 1487 listItems.push(div); 1488 } 1489 1490 const list = details.querySelector(".stylesheet-at-rules-list"); 1491 list.replaceChildren(...listItems); 1492 1493 sidebar.hidden = !showSidebar || !inSource; 1494 1495 this.emit("at-rules-list-changed", editor); 1496 }) 1497 .bind(this)() 1498 .catch(console.error); 1499 }; 1500 1501 /** 1502 * Set the condition text for the at-rule element. 1503 * For media queries, it also injects links to open RDM at a specific size. 1504 * 1505 * @param {HTMLElement} element 1506 * The element corresponding to the media sidebar condition 1507 * @param {string} ruleConditionText 1508 * The rule conditionText 1509 * @param {string} type 1510 * The type of the at-rule (e.g. "media", "layer", "supports", …) 1511 */ 1512 #setConditionContents(element, ruleConditionText, type) { 1513 if (!ruleConditionText) { 1514 return; 1515 } 1516 1517 // For non-media rules, we don't do anything more than displaying the conditionText 1518 // as there are no other condition text that would justify opening RDM at a specific 1519 // size (e.g. `@container` condition is relative to a container size, which varies 1520 // depending the node the rule applies to). 1521 if (type !== "media") { 1522 const node = this.#panelDoc.createTextNode(ruleConditionText); 1523 element.appendChild(node); 1524 return; 1525 } 1526 1527 const minMaxPattern = /(min\-|max\-)(width|height):\s\d+(px)/gi; 1528 1529 let match = minMaxPattern.exec(ruleConditionText); 1530 let lastParsed = 0; 1531 while (match && match.index != minMaxPattern.lastIndex) { 1532 const matchEnd = match.index + match[0].length; 1533 const node = this.#panelDoc.createTextNode( 1534 ruleConditionText.substring(lastParsed, match.index) 1535 ); 1536 element.appendChild(node); 1537 1538 const link = this.#panelDoc.createElementNS(HTML_NS, "a"); 1539 link.href = "#"; 1540 link.className = "media-responsive-mode-toggle"; 1541 link.textContent = ruleConditionText.substring(match.index, matchEnd); 1542 link.addEventListener("click", this.#onMediaConditionClick.bind(this)); 1543 element.appendChild(link); 1544 1545 match = minMaxPattern.exec(ruleConditionText); 1546 lastParsed = matchEnd; 1547 } 1548 1549 const node = this.#panelDoc.createTextNode( 1550 ruleConditionText.substring(lastParsed, ruleConditionText.length) 1551 ); 1552 element.appendChild(node); 1553 } 1554 1555 /** 1556 * Called when a media condition is clicked 1557 * If a responsive mode link is clicked, it will launch it. 1558 * 1559 * @param {object} e 1560 * Event object 1561 */ 1562 #onMediaConditionClick(e) { 1563 const conditionText = e.target.textContent; 1564 const isWidthCond = conditionText.toLowerCase().indexOf("width") > -1; 1565 const mediaVal = parseInt(/\d+/.exec(conditionText), 10); 1566 1567 const options = isWidthCond ? { width: mediaVal } : { height: mediaVal }; 1568 this.#launchResponsiveMode(options); 1569 e.preventDefault(); 1570 e.stopPropagation(); 1571 } 1572 1573 /** 1574 * Launches the responsive mode with a specific width or height. 1575 * 1576 * @param {object} options 1577 * Object with width or/and height properties. 1578 */ 1579 async #launchResponsiveMode(options = {}) { 1580 const tab = this.#commands.descriptorFront.localTab; 1581 const win = tab.ownerDocument.defaultView; 1582 1583 await lazy.ResponsiveUIManager.openIfNeeded(win, tab, { 1584 trigger: "style_editor", 1585 }); 1586 this.emit("responsive-mode-opened"); 1587 1588 lazy.ResponsiveUIManager.getResponsiveUIForTab(tab).setViewportSize( 1589 options 1590 ); 1591 } 1592 1593 /** 1594 * Jump cursor to the editor for a stylesheet and line number for a rule. 1595 * 1596 * @param {object} location 1597 * Location object with 'line', 'column', and 'source' properties. 1598 */ 1599 #jumpToLocation(location) { 1600 const source = location.styleSheet || location.source; 1601 this.selectStyleSheet(source, location.line - 1, location.column - 1); 1602 } 1603 1604 #startLoadingStyleSheets() { 1605 this.#root.classList.add("loading"); 1606 this.#loadingStyleSheets = []; 1607 } 1608 1609 async #waitForLoadingStyleSheets() { 1610 while (this.#loadingStyleSheets?.length > 0) { 1611 const pending = this.#loadingStyleSheets; 1612 this.#loadingStyleSheets = []; 1613 await Promise.all(pending); 1614 } 1615 1616 this.#loadingStyleSheets = null; 1617 this.#root.classList.remove("loading"); 1618 this.emit("reloaded"); 1619 } 1620 1621 async #handleStyleSheetResource(resource) { 1622 try { 1623 // The fileName is in resource means this stylesheet was imported from file by user. 1624 const { fileName } = resource; 1625 let file = fileName ? new lazy.FileUtils.File(fileName) : null; 1626 1627 // recall location of saved file for this sheet after page reload 1628 if (!file) { 1629 const identifier = this.getStyleSheetIdentifier(resource); 1630 const savedFile = this.savedLocations[identifier]; 1631 if (savedFile) { 1632 file = savedFile; 1633 } 1634 } 1635 resource.file = file; 1636 1637 await this.#addStyleSheet(resource); 1638 } catch (e) { 1639 console.error(e); 1640 this.emit("error", { key: LOAD_ERROR, level: "warning" }); 1641 } 1642 } 1643 1644 // onAvailable is a mandatory argument for watchTargets, 1645 // but we don't do anything when a new target gets created. 1646 #onTargetAvailable = () => {}; 1647 1648 #onTargetDestroyed = ({ targetFront }) => { 1649 // Iterate over a copy of the list in order to prevent skipping 1650 // over some items when removing items of this list 1651 const editorsCopy = [...this.editors]; 1652 for (const editor of editorsCopy) { 1653 const { styleSheet } = editor; 1654 if (styleSheet.targetFront == targetFront) { 1655 this.#removeStyleSheet(styleSheet, editor); 1656 } 1657 } 1658 }; 1659 1660 #onResourceAvailable = async resources => { 1661 const promises = []; 1662 for (const resource of resources) { 1663 if ( 1664 resource.resourceType === 1665 this.#commands.resourceCommand.TYPES.STYLESHEET 1666 ) { 1667 const onStyleSheetHandled = this.#handleStyleSheetResource(resource); 1668 1669 if (this.#loadingStyleSheets) { 1670 // In case of reloading/navigating and panel's opening 1671 this.#loadingStyleSheets.push(onStyleSheetHandled); 1672 } 1673 promises.push(onStyleSheetHandled); 1674 continue; 1675 } 1676 1677 if (!resource.targetFront.isTopLevel) { 1678 continue; 1679 } 1680 1681 if ( 1682 resource.name === "will-navigate" && 1683 // When selecting a document in the Browser Toolbox iframe picker, we're getting 1684 // a will-navigate event. In such case, we don't want to clear the list (see Bug 1981937) 1685 (!this.#commands.targetCommand.descriptorFront 1686 .isBrowserProcessDescriptor || 1687 !resource.isFrameSwitching) 1688 ) { 1689 this.#startLoadingStyleSheets(); 1690 this.#clear(); 1691 } else if (resource.name === "dom-complete") { 1692 promises.push(this.#waitForLoadingStyleSheets()); 1693 } 1694 } 1695 await Promise.all(promises); 1696 }; 1697 1698 #onResourceUpdated = async updates => { 1699 // The editors are instantiated asynchronously from onResourceAvailable, 1700 // but we may receive updates right after due to throttling. 1701 // Ensure waiting for this async work before trying to update the related editors. 1702 await this.#waitForLoadingStyleSheets(); 1703 1704 for (const { resource, update } of updates) { 1705 if ( 1706 update.resourceType === this.#commands.resourceCommand.TYPES.STYLESHEET 1707 ) { 1708 const editor = this.editors.find( 1709 e => e.resourceId === update.resourceId 1710 ); 1711 1712 if (!editor) { 1713 console.warn( 1714 "Could not find StyleEditor to apply STYLESHEET resource update" 1715 ); 1716 continue; 1717 } 1718 1719 switch (update.updateType) { 1720 case "style-applied": { 1721 editor.onStyleApplied(update); 1722 break; 1723 } 1724 case "property-change": { 1725 for (const [property, value] of Object.entries( 1726 update.resourceUpdates 1727 )) { 1728 editor.onPropertyChange(property, value); 1729 } 1730 break; 1731 } 1732 case "at-rules-changed": 1733 case "matches-change": { 1734 editor.onAtRulesChanged(resource.atRules); 1735 break; 1736 } 1737 } 1738 } 1739 } 1740 }; 1741 1742 #onResourceDestroyed = resources => { 1743 for (const resource of resources) { 1744 if ( 1745 resource.resourceType !== 1746 this.#commands.resourceCommand.TYPES.STYLESHEET 1747 ) { 1748 continue; 1749 } 1750 1751 const editorToRemove = this.editors.find( 1752 editor => editor.styleSheet.resourceId == resource.resourceId 1753 ); 1754 1755 if (editorToRemove) { 1756 const { styleSheet } = editorToRemove; 1757 this.#removeStyleSheet(styleSheet, editorToRemove); 1758 } 1759 } 1760 }; 1761 1762 /** 1763 * Set the active item's summary element. 1764 * 1765 * @param DOMElement summary 1766 * @param {object} options 1767 * @param {string=} options.reason: Indicates why the summary was selected. It's set to 1768 * "filter-auto" when the summary was automatically selected as the result 1769 * of the previous active summary being filtered out. 1770 */ 1771 setActiveSummary(summary, options = {}) { 1772 if (summary == this.#activeSummary) { 1773 return; 1774 } 1775 1776 if (this.#activeSummary) { 1777 const binding = this.#summaryDataMap.get(this.#activeSummary); 1778 1779 this.#activeSummary.classList.remove("splitview-active"); 1780 binding.details.classList.remove("splitview-active"); 1781 } 1782 1783 this.#activeSummary = summary; 1784 if (!summary) { 1785 this.selectedEditor = null; 1786 return; 1787 } 1788 1789 const { details } = this.#summaryDataMap.get(summary); 1790 summary.classList.add("splitview-active"); 1791 details.classList.add("splitview-active"); 1792 1793 this.showSummaryEditor(summary, options); 1794 } 1795 1796 /** 1797 * Show summary's associated editor 1798 * 1799 * @param DOMElement summary 1800 * @param {object} options 1801 * @param {string=} options.reason: Indicates why the summary was selected. It's set to 1802 * "filter-auto" when the summary was automatically selected as the result 1803 * of the previous active summary being filtered out. 1804 */ 1805 async showSummaryEditor(summary, options) { 1806 const { details, editor } = this.#summaryDataMap.get(summary); 1807 this.selectedEditor = editor; 1808 1809 try { 1810 if (!editor.sourceEditor) { 1811 // only initialize source editor when we switch to this view 1812 const inputElement = details.querySelector(".stylesheet-editor-input"); 1813 await editor.load(inputElement, this.#cssProperties); 1814 } 1815 1816 editor.onShow(options); 1817 1818 this.#updatePrettyPrintButton(); 1819 1820 this.emit("editor-selected", editor); 1821 } catch (e) { 1822 console.error(e); 1823 } 1824 } 1825 1826 /** 1827 * Remove an item from the split view. 1828 * 1829 * @param DOMElement summary 1830 * Summary element of the item to remove. 1831 */ 1832 removeSplitViewItem(summary) { 1833 if (summary == this.#activeSummary) { 1834 this.setActiveSummary(null); 1835 } 1836 1837 const data = this.#summaryDataMap.get(summary); 1838 if (!data) { 1839 return; 1840 } 1841 1842 summary.remove(); 1843 data.details.remove(); 1844 } 1845 1846 /** 1847 * Make the passed element visible or not, depending if it matches the current filter 1848 * 1849 * @param {Element} summary 1850 * @param {object} options 1851 * @param {boolean} options.triggerOnFilterStateChange: Set to false to avoid calling 1852 * #onFilterStateChange directly here. This can be useful when this 1853 * function is called for every item of the list, like in `setFilter`. 1854 */ 1855 handleSummaryVisibility(summary, { triggerOnFilterStateChange = true } = {}) { 1856 if (!this.#filter) { 1857 summary.classList.remove(FILTERED_CLASSNAME); 1858 return; 1859 } 1860 1861 const label = summary.querySelector(".stylesheet-name label"); 1862 const itemText = label.value.toLowerCase(); 1863 const matchesSearch = itemText.includes(this.#filter.toLowerCase()); 1864 summary.classList.toggle(FILTERED_CLASSNAME, !matchesSearch); 1865 1866 if (this.#activeSummary == summary && !matchesSearch) { 1867 this.setActiveSummary(null); 1868 } 1869 1870 if (triggerOnFilterStateChange) { 1871 this.#onFilterStateChange(); 1872 } 1873 } 1874 1875 destroy() { 1876 this.#commands.resourceCommand.unwatchResources( 1877 [ 1878 this.#commands.resourceCommand.TYPES.DOCUMENT_EVENT, 1879 this.#commands.resourceCommand.TYPES.STYLESHEET, 1880 ], 1881 { 1882 onAvailable: this.#onResourceAvailable, 1883 onUpdated: this.#onResourceUpdated, 1884 onDestroyed: this.#onResourceDestroyed, 1885 } 1886 ); 1887 this.#commands.targetCommand.unwatchTargets({ 1888 types: [this.#commands.targetCommand.TYPES.FRAME], 1889 onAvailable: this.#onTargetAvailable, 1890 onDestroyed: this.#onTargetDestroyed, 1891 }); 1892 1893 if (this.#uiAbortController) { 1894 this.#uiAbortController.abort(); 1895 this.#uiAbortController = null; 1896 } 1897 this.#clearStyleSheetEditors(); 1898 1899 this.#seenSheets = null; 1900 this.#filterInput = null; 1901 this.#filterInputClearButton = null; 1902 this.#nav = null; 1903 this.#prettyPrintButton = null; 1904 this.#side = null; 1905 this.#tplDetails = null; 1906 this.#tplSummary = null; 1907 1908 const sidebar = this.#panelDoc.querySelector(".splitview-controller"); 1909 const sidebarWidth = parseInt(sidebar.style.width, 10); 1910 if (!isNaN(sidebarWidth)) { 1911 Services.prefs.setIntPref(PREF_NAV_WIDTH, sidebarWidth); 1912 } 1913 1914 if (this.#sourceMapPrefObserver) { 1915 this.#sourceMapPrefObserver.off( 1916 PREF_ORIG_SOURCES, 1917 this.#onOrigSourcesPrefChanged 1918 ); 1919 this.#sourceMapPrefObserver.destroy(); 1920 this.#sourceMapPrefObserver = null; 1921 } 1922 1923 if (this.#prefObserver) { 1924 this.#prefObserver.off( 1925 PREF_AT_RULES_SIDEBAR, 1926 this.#onAtRulesSidebarPrefChanged 1927 ); 1928 this.#prefObserver.destroy(); 1929 this.#prefObserver = null; 1930 } 1931 1932 if (this.#shortcuts) { 1933 this.#shortcuts.destroy(); 1934 this.#shortcuts = null; 1935 } 1936 } 1937 }