summaryrefslogtreecommitdiff
path: root/devtools/client/styleeditor/StyleEditorUI.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/styleeditor/StyleEditorUI.sys.mjs')
-rw-r--r--devtools/client/styleeditor/StyleEditorUI.sys.mjs1761
1 files changed, 1761 insertions, 0 deletions
diff --git a/devtools/client/styleeditor/StyleEditorUI.sys.mjs b/devtools/client/styleeditor/StyleEditorUI.sys.mjs
new file mode 100644
index 0000000000..dc9b8ab3b5
--- /dev/null
+++ b/devtools/client/styleeditor/StyleEditorUI.sys.mjs
@@ -0,0 +1,1761 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import {
+ loader,
+ require,
+} from "resource://devtools/shared/loader/Loader.sys.mjs";
+
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+
+import {
+ getString,
+ text,
+ showFilePicker,
+ optionsPopupMenu,
+} from "resource://devtools/client/styleeditor/StyleEditorUtil.sys.mjs";
+import { StyleSheetEditor } from "resource://devtools/client/styleeditor/StyleSheetEditor.sys.mjs";
+
+const { PluralForm } = require("resource://devtools/shared/plural-form.js");
+const { PrefObserver } = require("resource://devtools/client/shared/prefs.js");
+
+const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js");
+
+const lazy = {};
+
+loader.lazyRequireGetter(
+ lazy,
+ "KeyCodes",
+ "resource://devtools/client/shared/keycodes.js",
+ true
+);
+
+loader.lazyRequireGetter(
+ lazy,
+ "OriginalSource",
+ "resource://devtools/client/styleeditor/original-source.js",
+ true
+);
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "NetUtil",
+ "resource://gre/modules/NetUtil.jsm"
+);
+loader.lazyRequireGetter(
+ lazy,
+ "ResponsiveUIManager",
+ "resource://devtools/client/responsive/manager.js"
+);
+loader.lazyRequireGetter(
+ lazy,
+ "openContentLink",
+ "resource://devtools/client/shared/link.js",
+ true
+);
+loader.lazyRequireGetter(
+ lazy,
+ "copyString",
+ "resource://devtools/shared/platform/clipboard.js",
+ true
+);
+
+const LOAD_ERROR = "error-load";
+const PREF_AT_RULES_SIDEBAR = "devtools.styleeditor.showAtRulesSidebar";
+const PREF_SIDEBAR_WIDTH = "devtools.styleeditor.atRulesSidebarWidth";
+const PREF_NAV_WIDTH = "devtools.styleeditor.navSidebarWidth";
+const PREF_ORIG_SOURCES = "devtools.source-map.client-service.enabled";
+
+const FILTERED_CLASSNAME = "splitview-filtered";
+const ALL_FILTERED_CLASSNAME = "splitview-all-filtered";
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * StyleEditorUI is controls and builds the UI of the Style Editor, including
+ * maintaining a list of editors for each stylesheet on a debuggee.
+ *
+ * Emits events:
+ * 'editor-added': A new editor was added to the UI
+ * 'editor-selected': An editor was selected
+ * 'error': An error occured
+ *
+ */
+export class StyleEditorUI extends EventEmitter {
+ #activeSummary = null;
+ #commands;
+ #contextMenu;
+ #contextMenuStyleSheet;
+ #copyUrlItem;
+ #cssProperties;
+ #filter;
+ #filterInput;
+ #filterInputClearButton;
+ #loadingStyleSheets;
+ #nav;
+ #openLinkNewTabItem;
+ #optionsButton;
+ #optionsMenu;
+ #panelDoc;
+ #prefObserver;
+ #root;
+ #seenSheets = new Map();
+ #shortcuts;
+ #side;
+ #sourceMapPrefObserver;
+ #styleSheetBoundToSelect;
+ #styleSheetToSelect;
+ /**
+ * Maps keyed by summary element whose value is an object containing:
+ * - {Element} details: The associated details element (i.e. container for CodeMirror)
+ * - {StyleSheetEditor} editor: The associated editor, for easy retrieval
+ */
+ #summaryDataMap = new WeakMap();
+ #toolbox;
+ #tplDetails;
+ #tplSummary;
+ #uiAbortController = new AbortController();
+ #window;
+
+ /**
+ * @param {Toolbox} toolbox
+ * @param {Object} commands Object defined from devtools/shared/commands to interact with the devtools backend
+ * @param {Document} panelDoc
+ * Document of the toolbox panel to populate UI in.
+ * @param {CssProperties} A css properties database.
+ */
+ constructor(toolbox, commands, panelDoc, cssProperties) {
+ super();
+
+ this.#toolbox = toolbox;
+ this.#commands = commands;
+ this.#panelDoc = panelDoc;
+ this.#cssProperties = cssProperties;
+ this.#window = this.#panelDoc.defaultView;
+ this.#root = this.#panelDoc.getElementById("style-editor-chrome");
+
+ this.editors = [];
+ this.selectedEditor = null;
+ this.savedLocations = {};
+
+ this.#prefObserver = new PrefObserver("devtools.styleeditor.");
+ this.#prefObserver.on(
+ PREF_AT_RULES_SIDEBAR,
+ this.#onAtRulesSidebarPrefChanged
+ );
+ this.#sourceMapPrefObserver = new PrefObserver(
+ "devtools.source-map.client-service."
+ );
+ this.#sourceMapPrefObserver.on(
+ PREF_ORIG_SOURCES,
+ this.#onOrigSourcesPrefChanged
+ );
+ }
+
+ get cssProperties() {
+ return this.#cssProperties;
+ }
+
+ get currentTarget() {
+ return this.#commands.targetCommand.targetFront;
+ }
+
+ /*
+ * Index of selected stylesheet in document.styleSheets
+ */
+ get selectedStyleSheetIndex() {
+ return this.selectedEditor
+ ? this.selectedEditor.styleSheet.styleSheetIndex
+ : -1;
+ }
+
+ /**
+ * Initiates the style editor ui creation, and start to track TargetCommand updates.
+ *
+ * @params {Object} options
+ * @params {Object} options.stylesheetToSelect
+ * @params {StyleSheetResource} options.stylesheetToSelect.stylesheet
+ * @params {Integer} options.stylesheetToSelect.line
+ * @params {Integer} options.stylesheetToSelect.column
+ */
+ async initialize(options = {}) {
+ this.createUI();
+
+ if (options.stylesheetToSelect) {
+ const { stylesheet, line, column } = options.stylesheetToSelect;
+ // If a stylesheet resource and its location was passed (e.g. user clicked on a stylesheet
+ // location in the rule view), we can directly add it to the list and select it
+ // before watching for resources, for improved performance.
+ if (stylesheet.resourceId) {
+ try {
+ await this.#handleStyleSheetResource(stylesheet);
+ await this.selectStyleSheet(
+ stylesheet,
+ line - 1,
+ column ? column - 1 : 0
+ );
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+
+ await this.#toolbox.resourceCommand.watchResources(
+ [this.#toolbox.resourceCommand.TYPES.DOCUMENT_EVENT],
+ { onAvailable: this.#onResourceAvailable }
+ );
+ await this.#commands.targetCommand.watchTargets({
+ types: [this.#commands.targetCommand.TYPES.FRAME],
+ onAvailable: this.#onTargetAvailable,
+ onDestroyed: this.#onTargetDestroyed,
+ });
+
+ this.#startLoadingStyleSheets();
+ await this.#toolbox.resourceCommand.watchResources(
+ [this.#toolbox.resourceCommand.TYPES.STYLESHEET],
+ {
+ onAvailable: this.#onResourceAvailable,
+ onUpdated: this.#onResourceUpdated,
+ }
+ );
+ await this.#waitForLoadingStyleSheets();
+ }
+
+ /**
+ * Build the initial UI and wire buttons with event handlers.
+ */
+ createUI() {
+ this.#filterInput = this.#root.querySelector(".devtools-filterinput");
+ this.#filterInputClearButton = this.#root.querySelector(
+ ".devtools-searchinput-clear"
+ );
+ this.#nav = this.#root.querySelector(".splitview-nav");
+ this.#side = this.#root.querySelector(".splitview-side-details");
+ this.#tplSummary = this.#root.querySelector(
+ "#splitview-tpl-summary-stylesheet"
+ );
+ this.#tplDetails = this.#root.querySelector(
+ "#splitview-tpl-details-stylesheet"
+ );
+
+ const eventListenersConfig = { signal: this.#uiAbortController.signal };
+
+ // Add click event on the "new stylesheet" button in the toolbar and on the
+ // "append a new stylesheet" link (visible when there are no stylesheets).
+ for (const el of this.#root.querySelectorAll(".style-editor-newButton")) {
+ el.addEventListener(
+ "click",
+ async () => {
+ const stylesheetsFront = await this.currentTarget.getFront(
+ "stylesheets"
+ );
+ stylesheetsFront.addStyleSheet(null);
+ this.#clearFilterInput();
+ },
+ eventListenersConfig
+ );
+ }
+
+ this.#root.querySelector(".style-editor-importButton").addEventListener(
+ "click",
+ () => {
+ this.#importFromFile(this._mockImportFile || null, this.#window);
+ this.#clearFilterInput();
+ },
+ eventListenersConfig
+ );
+
+ this.#root
+ .querySelector("#style-editor-options")
+ .addEventListener(
+ "click",
+ this.#onOptionsButtonClick,
+ eventListenersConfig
+ );
+
+ this.#filterInput.addEventListener(
+ "input",
+ this.#onFilterInputChange,
+ eventListenersConfig
+ );
+
+ this.#filterInputClearButton.addEventListener(
+ "click",
+ () => this.#clearFilterInput(),
+ eventListenersConfig
+ );
+
+ this.#panelDoc.addEventListener(
+ "contextmenu",
+ () => {
+ this.#contextMenuStyleSheet = null;
+ },
+ { ...eventListenersConfig, capture: true }
+ );
+
+ this.#optionsButton = this.#panelDoc.getElementById("style-editor-options");
+
+ this.#contextMenu = this.#panelDoc.getElementById("sidebar-context");
+ this.#contextMenu.addEventListener(
+ "popupshowing",
+ this.#updateContextMenuItems,
+ eventListenersConfig
+ );
+
+ this.#openLinkNewTabItem = this.#panelDoc.getElementById(
+ "context-openlinknewtab"
+ );
+ this.#openLinkNewTabItem.addEventListener(
+ "command",
+ this.#openLinkNewTab,
+ eventListenersConfig
+ );
+
+ this.#copyUrlItem = this.#panelDoc.getElementById("context-copyurl");
+ this.#copyUrlItem.addEventListener(
+ "command",
+ this.#copyUrl,
+ eventListenersConfig
+ );
+
+ // items list focus and search-on-type handling
+ this.#nav.addEventListener(
+ "keydown",
+ this.#onNavKeyDown,
+ eventListenersConfig
+ );
+
+ this.#shortcuts = new KeyShortcuts({
+ window: this.#window,
+ });
+ this.#shortcuts.on(
+ `CmdOrCtrl+${getString("focusFilterInput.commandkey")}`,
+ this.#onFocusFilterInputKeyboardShortcut
+ );
+
+ const nav = this.#panelDoc.querySelector(".splitview-controller");
+ nav.style.width = Services.prefs.getIntPref(PREF_NAV_WIDTH) + "px";
+ }
+
+ #clearFilterInput() {
+ this.#filterInput.value = "";
+ this.#onFilterInputChange();
+ }
+
+ #onFilterInputChange = () => {
+ this.#filter = this.#filterInput.value;
+ this.#filterInputClearButton.toggleAttribute("hidden", !this.#filter);
+
+ for (const summary of this.#nav.childNodes) {
+ // Don't update nav class for every element, we do it after the loop.
+ this.handleSummaryVisibility(summary, {
+ triggerOnFilterStateChange: false,
+ });
+ }
+
+ this.#onFilterStateChange();
+
+ if (this.#activeSummary == null) {
+ const firstVisibleSummary = Array.from(this.#nav.childNodes).find(
+ node => !node.classList.contains(FILTERED_CLASSNAME)
+ );
+
+ if (firstVisibleSummary) {
+ this.setActiveSummary(firstVisibleSummary, { reason: "filter-auto" });
+ }
+ }
+ };
+
+ #onFilterStateChange() {
+ const summaries = Array.from(this.#nav.childNodes);
+ const hasVisibleSummary = summaries.some(
+ node => !node.classList.contains(FILTERED_CLASSNAME)
+ );
+ const allFiltered = !!summaries.length && !hasVisibleSummary;
+
+ this.#nav.classList.toggle(ALL_FILTERED_CLASSNAME, allFiltered);
+
+ this.#filterInput
+ .closest(".devtools-searchbox")
+ .classList.toggle("devtools-searchbox-no-match", !!allFiltered);
+ }
+
+ #onFocusFilterInputKeyboardShortcut = e => {
+ // Prevent the print modal to be displayed.
+ if (e) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ this.#filterInput.select();
+ };
+
+ #onNavKeyDown = event => {
+ function getFocusedItemWithin(nav) {
+ let node = nav.ownerDocument.activeElement;
+ while (node && node.parentNode != nav) {
+ node = node.parentNode;
+ }
+ return node;
+ }
+
+ // do not steal focus from inside iframes or textboxes
+ if (
+ event.target.ownerDocument != this.#nav.ownerDocument ||
+ event.target.tagName == "input" ||
+ event.target.tagName == "textarea" ||
+ event.target.classList.contains("textbox")
+ ) {
+ return false;
+ }
+
+ // handle keyboard navigation within the items list
+ const visibleElements = Array.from(
+ this.#nav.querySelectorAll(`li:not(.${FILTERED_CLASSNAME})`)
+ );
+ // Elements have a different visual order (due to the use of order), so
+ // we need to sort them by their data-ordinal attribute
+ visibleElements.sort(
+ (a, b) => a.getAttribute("data-ordinal") - b.getAttribute("data-ordinal")
+ );
+
+ let elementToFocus;
+ if (
+ event.keyCode == lazy.KeyCodes.DOM_VK_PAGE_UP ||
+ event.keyCode == lazy.KeyCodes.DOM_VK_HOME
+ ) {
+ elementToFocus = visibleElements[0];
+ } else if (
+ event.keyCode == lazy.KeyCodes.DOM_VK_PAGE_DOWN ||
+ event.keyCode == lazy.KeyCodes.DOM_VK_END
+ ) {
+ elementToFocus = visibleElements.at(-1);
+ } else if (event.keyCode == lazy.KeyCodes.DOM_VK_UP) {
+ const focusedIndex = visibleElements.indexOf(
+ getFocusedItemWithin(this.#nav)
+ );
+ elementToFocus = visibleElements[focusedIndex - 1];
+ } else if (event.keyCode == lazy.KeyCodes.DOM_VK_DOWN) {
+ const focusedIndex = visibleElements.indexOf(
+ getFocusedItemWithin(this.#nav)
+ );
+ elementToFocus = visibleElements[focusedIndex + 1];
+ }
+
+ if (elementToFocus !== undefined) {
+ event.stopPropagation();
+ event.preventDefault();
+ elementToFocus.focus();
+ return false;
+ }
+
+ return true;
+ };
+
+ /**
+ * Opens the Options Popup Menu
+ *
+ * @params {number} screenX
+ * @params {number} screenY
+ * Both obtained from the event object, used to position the popup
+ */
+ #onOptionsButtonClick = ({ screenX, screenY }) => {
+ this.#optionsMenu = optionsPopupMenu(
+ this.#toggleOrigSources,
+ this.#toggleAtRulesSidebar
+ );
+
+ this.#optionsMenu.once("open", () => {
+ this.#optionsButton.setAttribute("open", true);
+ });
+ this.#optionsMenu.once("close", () => {
+ this.#optionsButton.removeAttribute("open");
+ });
+
+ this.#optionsMenu.popup(screenX, screenY, this.#toolbox.doc);
+ };
+
+ /**
+ * Be called when changing the original sources pref.
+ */
+ #onOrigSourcesPrefChanged = async () => {
+ this.#clear();
+ // When we toggle the source-map preference, we clear the panel and re-fetch the exact
+ // same stylesheet resources from ResourceCommand, but `_addStyleSheet` will trigger
+ // or ignore the additional source-map mapping.
+ this.#root.classList.add("loading");
+ for (const resource of this.#toolbox.resourceCommand.getAllResources(
+ this.#toolbox.resourceCommand.TYPES.STYLESHEET
+ )) {
+ await this.#handleStyleSheetResource(resource);
+ }
+
+ this.#root.classList.remove("loading");
+
+ this.emit("stylesheets-refreshed");
+ };
+
+ /**
+ * Remove all editors and add loading indicator.
+ */
+ #clear = () => {
+ // remember selected sheet and line number for next load
+ if (this.selectedEditor && this.selectedEditor.sourceEditor) {
+ const href = this.selectedEditor.styleSheet.href;
+ const { line, ch } = this.selectedEditor.sourceEditor.getCursor();
+
+ this.#styleSheetToSelect = {
+ stylesheet: href,
+ line,
+ col: ch,
+ };
+ }
+
+ // remember saved file locations
+ for (const editor of this.editors) {
+ if (editor.savedFile) {
+ const identifier = this.getStyleSheetIdentifier(editor.styleSheet);
+ this.savedLocations[identifier] = editor.savedFile;
+ }
+ }
+
+ this.#clearStyleSheetEditors();
+ // Clear the left sidebar items and their associated elements.
+ while (this.#nav.hasChildNodes()) {
+ this.removeSplitViewItem(this.#nav.firstChild);
+ }
+
+ this.selectedEditor = null;
+ // Here the keys are style sheet actors, and the values are
+ // promises that resolve to the sheet's editor. See |_addStyleSheet|.
+ this.#seenSheets = new Map();
+
+ this.emit("stylesheets-clear");
+ };
+
+ /**
+ * Add an editor for this stylesheet. Add editors for its original sources
+ * instead (e.g. Sass sources), if applicable.
+ *
+ * @param {Resource} resource
+ * The STYLESHEET resource which is received from resource command.
+ * @return {Promise}
+ * A promise that resolves to the style sheet's editor when the style sheet has
+ * been fully loaded. If the style sheet has a source map, and source mapping
+ * is enabled, then the promise resolves to null.
+ */
+ #addStyleSheet(resource) {
+ if (!this.#seenSheets.has(resource)) {
+ const promise = (async () => {
+ let editor = await this.#addStyleSheetEditor(resource);
+
+ const sourceMapLoader = this.#toolbox.sourceMapLoader;
+
+ if (
+ !sourceMapLoader ||
+ !Services.prefs.getBoolPref(PREF_ORIG_SOURCES)
+ ) {
+ return editor;
+ }
+
+ const {
+ href,
+ nodeHref,
+ resourceId: id,
+ sourceMapURL,
+ sourceMapBaseURL,
+ } = resource;
+ const sources = await sourceMapLoader.getOriginalURLs({
+ id,
+ url: href || nodeHref,
+ sourceMapBaseURL,
+ sourceMapURL,
+ });
+ // A single generated sheet might map to multiple original
+ // sheets, so make editors for each of them.
+ if (sources && sources.length) {
+ const parentEditorName = editor.friendlyName;
+ this.#removeStyleSheetEditor(editor);
+ editor = null;
+
+ for (const { id: originalId, url: originalURL } of sources) {
+ const original = new lazy.OriginalSource(
+ originalURL,
+ originalId,
+ sourceMapLoader
+ );
+
+ // set so the first sheet will be selected, even if it's a source
+ original.styleSheetIndex = resource.styleSheetIndex;
+ original.relatedStyleSheet = resource;
+ original.relatedEditorName = parentEditorName;
+ original.resourceId = resource.resourceId;
+ original.targetFront = resource.targetFront;
+ original.atRules = resource.atRules;
+ await this.#addStyleSheetEditor(original);
+ }
+ }
+
+ return editor;
+ })();
+ this.#seenSheets.set(resource, promise);
+ }
+ return this.#seenSheets.get(resource);
+ }
+
+ #removeStyleSheet(resource, editor) {
+ this.#seenSheets.delete(resource);
+ this.#removeStyleSheetEditor(editor);
+ }
+
+ #getInlineStyleSheetsCount() {
+ return this.editors.filter(editor => !editor.styleSheet.href).length;
+ }
+
+ #getNewStyleSheetsCount() {
+ return this.editors.filter(editor => editor.isNew).length;
+ }
+
+ /**
+ * Finds the index to be shown in the Style Editor for inline or
+ * user-created style sheets, returns undefined if not of either type.
+ *
+ * @param {StyleSheet} styleSheet
+ * Object representing stylesheet
+ * @return {(Number|undefined)}
+ * Optional Integer representing the index of the current stylesheet
+ * among all stylesheets of its type (inline or user-created)
+ */
+ #getNextFriendlyIndex(styleSheet) {
+ if (styleSheet.href) {
+ return undefined;
+ }
+
+ return styleSheet.isNew
+ ? this.#getNewStyleSheetsCount()
+ : this.#getInlineStyleSheetsCount();
+ }
+
+ /**
+ * Add a new editor to the UI for a source.
+ *
+ * @param {Resource} resource
+ * The resource which is received from resource command.
+ * @return {Promise} that is resolved with the created StyleSheetEditor when
+ * the editor is fully initialized or rejected on error.
+ */
+ async #addStyleSheetEditor(resource) {
+ const editor = new StyleSheetEditor(
+ resource,
+ this.#window,
+ this.#getNextFriendlyIndex(resource)
+ );
+
+ editor.on("property-change", this.#summaryChange.bind(this, editor));
+ editor.on("at-rules-changed", this.#updateAtRulesList.bind(this, editor));
+ editor.on("linked-css-file", this.#summaryChange.bind(this, editor));
+ editor.on("linked-css-file-error", this.#summaryChange.bind(this, editor));
+ editor.on("error", this.#onError);
+ editor.on(
+ "filter-input-keyboard-shortcut",
+ this.#onFocusFilterInputKeyboardShortcut
+ );
+
+ // onAtRulesChanged fires at-rules-changed, so call the function after
+ // registering the listener in order to ensure to get at-rules-changed event.
+ editor.onAtRulesChanged(resource.atRules);
+
+ this.editors.push(editor);
+
+ try {
+ await editor.fetchSource();
+ } catch (e) {
+ // if the editor was destroyed while fetching dependencies, we don't want to go further.
+ if (!this.editors.includes(editor)) {
+ return null;
+ }
+ throw e;
+ }
+
+ this.#sourceLoaded(editor);
+
+ if (resource.fileName) {
+ this.emit("test:editor-updated", editor);
+ }
+
+ return editor;
+ }
+
+ /**
+ * Import a style sheet from file and asynchronously create a
+ * new stylesheet on the debuggee for it.
+ *
+ * @param {mixed} file
+ * Optional nsIFile or filename string.
+ * If not set a file picker will be shown.
+ * @param {nsIWindow} parentWindow
+ * Optional parent window for the file picker.
+ */
+ #importFromFile(file, parentWindow) {
+ const onFileSelected = selectedFile => {
+ if (!selectedFile) {
+ // nothing selected
+ return;
+ }
+ lazy.NetUtil.asyncFetch(
+ {
+ uri: lazy.NetUtil.newURI(selectedFile),
+ loadingNode: this.#window.document,
+ securityFlags:
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_OTHER,
+ },
+ async (stream, status) => {
+ if (!Components.isSuccessCode(status)) {
+ this.emit("error", { key: LOAD_ERROR, level: "warning" });
+ return;
+ }
+ const source = lazy.NetUtil.readInputStreamToString(
+ stream,
+ stream.available()
+ );
+ stream.close();
+
+ const stylesheetsFront = await this.currentTarget.getFront(
+ "stylesheets"
+ );
+ stylesheetsFront.addStyleSheet(source, selectedFile.path);
+ }
+ );
+ };
+
+ showFilePicker(file, false, parentWindow, onFileSelected);
+ }
+
+ /**
+ * Forward any error from a stylesheet.
+ *
+ * @param {data} data
+ * The event data
+ */
+ #onError = data => {
+ this.emit("error", data);
+ };
+
+ /**
+ * Toggle the original sources pref.
+ */
+ #toggleOrigSources() {
+ const isEnabled = Services.prefs.getBoolPref(PREF_ORIG_SOURCES);
+ Services.prefs.setBoolPref(PREF_ORIG_SOURCES, !isEnabled);
+ }
+
+ /**
+ * Toggle the pref for showing the at-rules sidebar (for @media, @layer, @container, …)
+ * in each editor.
+ */
+ #toggleAtRulesSidebar() {
+ const isEnabled = Services.prefs.getBoolPref(PREF_AT_RULES_SIDEBAR);
+ Services.prefs.setBoolPref(PREF_AT_RULES_SIDEBAR, !isEnabled);
+ }
+
+ /**
+ * Toggle the at-rules sidebar in each editor depending on the setting.
+ */
+ #onAtRulesSidebarPrefChanged = () => {
+ this.editors.forEach(this.#updateAtRulesList);
+ };
+
+ /**
+ * This method handles the following cases related to the context
+ * menu items "_openLinkNewTabItem" and "_copyUrlItem":
+ *
+ * 1) There was a stylesheet clicked on and it is external: show and
+ * enable the context menu item
+ * 2) There was a stylesheet clicked on and it is inline: show and
+ * disable the context menu item
+ * 3) There was no stylesheet clicked on (the right click happened
+ * below the list): hide the context menu
+ */
+ #updateContextMenuItems = async () => {
+ this.#openLinkNewTabItem.hidden = !this.#contextMenuStyleSheet;
+ this.#copyUrlItem.hidden = !this.#contextMenuStyleSheet;
+
+ if (this.#contextMenuStyleSheet) {
+ this.#openLinkNewTabItem.setAttribute(
+ "disabled",
+ !this.#contextMenuStyleSheet.href
+ );
+ this.#copyUrlItem.setAttribute(
+ "disabled",
+ !this.#contextMenuStyleSheet.href
+ );
+ }
+ };
+
+ /**
+ * Open a particular stylesheet in a new tab.
+ */
+ #openLinkNewTab = () => {
+ if (this.#contextMenuStyleSheet) {
+ lazy.openContentLink(this.#contextMenuStyleSheet.href);
+ }
+ };
+
+ /**
+ * Copies a stylesheet's URL.
+ */
+ #copyUrl = () => {
+ if (this.#contextMenuStyleSheet) {
+ lazy.copyString(this.#contextMenuStyleSheet.href);
+ }
+ };
+
+ /**
+ * Remove a particular stylesheet editor from the UI
+ *
+ * @param {StyleSheetEditor} editor
+ * The editor to remove.
+ */
+ #removeStyleSheetEditor(editor) {
+ if (editor.summary) {
+ this.removeSplitViewItem(editor.summary);
+ } else {
+ const self = this;
+ this.on("editor-added", function onAdd(added) {
+ if (editor == added) {
+ self.off("editor-added", onAdd);
+ self.removeSplitViewItem(editor.summary);
+ }
+ });
+ }
+
+ editor.destroy();
+ this.editors.splice(this.editors.indexOf(editor), 1);
+ }
+
+ /**
+ * Clear all the editors from the UI.
+ */
+ #clearStyleSheetEditors() {
+ for (const editor of this.editors) {
+ editor.destroy();
+ }
+ this.editors = [];
+ }
+
+ /**
+ * Called when a StyleSheetEditor's source has been fetched.
+ * Add new sidebar item and editor to the UI
+ *
+ * @param {StyleSheetEditor} editor
+ * Editor to create UI for.
+ */
+ #sourceLoaded(editor) {
+ // Create the detail and summary nodes from the templates node (declared in index.xhtml)
+ const details = this.#tplDetails.cloneNode(true);
+ details.id = "";
+ const summary = this.#tplSummary.cloneNode(true);
+ summary.id = "";
+
+ let ordinal = editor.styleSheet.styleSheetIndex;
+ ordinal = ordinal == -1 ? Number.MAX_SAFE_INTEGER : ordinal;
+ summary.style.order = ordinal;
+ summary.setAttribute("data-ordinal", ordinal);
+
+ const isSystem = !!editor.styleSheet.system;
+ if (isSystem) {
+ summary.classList.add("stylesheet-system");
+ }
+
+ this.#nav.appendChild(summary);
+ this.#side.appendChild(details);
+
+ this.#summaryDataMap.set(summary, {
+ details,
+ editor,
+ });
+
+ const createdEditor = editor;
+ createdEditor.summary = summary;
+ createdEditor.details = details;
+
+ const eventListenersConfig = { signal: this.#uiAbortController.signal };
+
+ summary.addEventListener(
+ "click",
+ event => {
+ event.stopPropagation();
+ this.setActiveSummary(summary);
+ },
+ eventListenersConfig
+ );
+
+ const stylesheetToggle = summary.querySelector(".stylesheet-toggle");
+ if (isSystem) {
+ stylesheetToggle.disabled = true;
+ this.#window.document.l10n.setAttributes(
+ stylesheetToggle,
+ "styleeditor-visibility-toggle-system"
+ );
+ } else {
+ stylesheetToggle.addEventListener(
+ "click",
+ event => {
+ event.stopPropagation();
+ event.target.blur();
+
+ createdEditor.toggleDisabled();
+ },
+ eventListenersConfig
+ );
+ }
+
+ summary.querySelector(".stylesheet-name").addEventListener(
+ "keypress",
+ event => {
+ if (event.keyCode == lazy.KeyCodes.DOM_VK_RETURN) {
+ this.setActiveSummary(summary);
+ }
+ },
+ eventListenersConfig
+ );
+
+ summary.querySelector(".stylesheet-saveButton").addEventListener(
+ "click",
+ event => {
+ event.stopPropagation();
+ event.target.blur();
+
+ createdEditor.saveToFile(createdEditor.savedFile);
+ },
+ eventListenersConfig
+ );
+
+ this.#updateSummaryForEditor(createdEditor, summary);
+
+ summary.addEventListener(
+ "contextmenu",
+ () => {
+ this.#contextMenuStyleSheet = createdEditor.styleSheet;
+ },
+ eventListenersConfig
+ );
+
+ summary.addEventListener(
+ "focus",
+ function onSummaryFocus(event) {
+ if (event.target == summary) {
+ // autofocus the stylesheet name
+ summary.querySelector(".stylesheet-name").focus();
+ }
+ },
+ eventListenersConfig
+ );
+
+ const sidebar = details.querySelector(".stylesheet-sidebar");
+ sidebar.style.width = Services.prefs.getIntPref(PREF_SIDEBAR_WIDTH) + "px";
+
+ const splitter = details.querySelector(".devtools-side-splitter");
+ splitter.addEventListener(
+ "mousemove",
+ () => {
+ const sidebarWidth = parseInt(sidebar.style.width, 10);
+ if (!isNaN(sidebarWidth)) {
+ Services.prefs.setIntPref(PREF_SIDEBAR_WIDTH, sidebarWidth);
+
+ // update all at-rules sidebars for consistency
+ const sidebars = [
+ ...this.#panelDoc.querySelectorAll(".stylesheet-sidebar"),
+ ];
+ for (const atRuleSidebar of sidebars) {
+ atRuleSidebar.style.width = sidebarWidth + "px";
+ }
+ }
+ },
+ eventListenersConfig
+ );
+
+ // autofocus if it's a new user-created stylesheet
+ if (createdEditor.isNew) {
+ this.#selectEditor(createdEditor);
+ }
+
+ if (this.#isEditorToSelect(createdEditor)) {
+ this.switchToSelectedSheet();
+ }
+
+ // If this is the first stylesheet and there is no pending request to
+ // select a particular style sheet, select this sheet.
+ if (
+ !this.selectedEditor &&
+ !this.#styleSheetBoundToSelect &&
+ createdEditor.styleSheet.styleSheetIndex == 0 &&
+ !summary.classList.contains(FILTERED_CLASSNAME)
+ ) {
+ this.#selectEditor(createdEditor);
+ }
+ this.emit("editor-added", createdEditor);
+ }
+
+ /**
+ * Switch to the editor that has been marked to be selected.
+ *
+ * @return {Promise}
+ * Promise that will resolve when the editor is selected.
+ */
+ switchToSelectedSheet() {
+ const toSelect = this.#styleSheetToSelect;
+
+ for (const editor of this.editors) {
+ if (this.#isEditorToSelect(editor)) {
+ // The _styleSheetBoundToSelect will always hold the latest pending
+ // requested style sheet (with line and column) which is not yet
+ // selected by the source editor. Only after we select that particular
+ // editor and go the required line and column, it will become null.
+ this.#styleSheetBoundToSelect = this.#styleSheetToSelect;
+ this.#styleSheetToSelect = null;
+ return this.#selectEditor(editor, toSelect.line, toSelect.col);
+ }
+ }
+
+ return Promise.resolve();
+ }
+
+ /**
+ * Returns whether a given editor is the current editor to be selected. Tests
+ * based on href or underlying stylesheet.
+ *
+ * @param {StyleSheetEditor} editor
+ * The editor to test.
+ */
+ #isEditorToSelect(editor) {
+ const toSelect = this.#styleSheetToSelect;
+ if (!toSelect) {
+ return false;
+ }
+ const isHref =
+ toSelect.stylesheet === null || typeof toSelect.stylesheet == "string";
+
+ return (
+ (isHref && editor.styleSheet.href == toSelect.stylesheet) ||
+ toSelect.stylesheet == editor.styleSheet
+ );
+ }
+
+ /**
+ * Select an editor in the UI.
+ *
+ * @param {StyleSheetEditor} editor
+ * Editor to switch to.
+ * @param {number} line
+ * Line number to jump to
+ * @param {number} col
+ * Column number to jump to
+ * @return {Promise}
+ * Promise that will resolve when the editor is selected and ready
+ * to be used.
+ */
+ #selectEditor(editor, line = null, col = null) {
+ // Don't go further if the editor was destroyed in the meantime
+ if (!this.editors.includes(editor)) {
+ return null;
+ }
+
+ const editorPromise = editor.getSourceEditor().then(() => {
+ // line/col are null when the style editor is initialized and the first stylesheet
+ // editor is selected. Unfortunately, this function might be called also when the
+ // panel is opened from clicking on a CSS warning in the WebConsole panel, in which
+ // case we have specific line+col.
+ // There's no guarantee which one could be called first, and it happened that we
+ // were setting the cursor once for the correct line coming from the webconsole,
+ // and then re-setting it to the default value (which was <0,0>).
+ // To avoid the race, we simply don't explicitly set the cursor to any default value,
+ // which is not a big deal as CodeMirror does init it to <0,0> anyway.
+ // See Bug 1738124 for more information.
+ if (line !== null || col !== null) {
+ editor.setCursor(line, col);
+ }
+ this.#styleSheetBoundToSelect = null;
+ });
+
+ const summaryPromise = this.getEditorSummary(editor).then(summary => {
+ // Don't go further if the editor was destroyed in the meantime
+ if (!this.editors.includes(editor)) {
+ throw new Error("Editor was destroyed");
+ }
+ this.setActiveSummary(summary);
+ });
+
+ return Promise.all([editorPromise, summaryPromise]);
+ }
+
+ getEditorSummary(editor) {
+ const self = this;
+
+ if (editor.summary) {
+ return Promise.resolve(editor.summary);
+ }
+
+ return new Promise(resolve => {
+ this.on("editor-added", function onAdd(selected) {
+ if (selected == editor) {
+ self.off("editor-added", onAdd);
+ resolve(editor.summary);
+ }
+ });
+ });
+ }
+
+ getEditorDetails(editor) {
+ const self = this;
+
+ if (editor.details) {
+ return Promise.resolve(editor.details);
+ }
+
+ return new Promise(resolve => {
+ this.on("editor-added", function onAdd(selected) {
+ if (selected == editor) {
+ self.off("editor-added", onAdd);
+ resolve(editor.details);
+ }
+ });
+ });
+ }
+
+ /**
+ * Returns an identifier for the given style sheet.
+ *
+ * @param {StyleSheet} styleSheet
+ * The style sheet to be identified.
+ */
+ getStyleSheetIdentifier(styleSheet) {
+ // Identify inline style sheets by their host page URI and index
+ // at the page.
+ return styleSheet.href
+ ? styleSheet.href
+ : "inline-" + styleSheet.styleSheetIndex + "-at-" + styleSheet.nodeHref;
+ }
+
+ /**
+ * Get the OriginalSource object for a given original sourceId returned from
+ * the sourcemap worker service.
+ *
+ * @param {string} sourceId
+ * The ID to search for from the sourcemap worker.
+ *
+ * @return {OriginalSource | null}
+ */
+ getOriginalSourceSheet(sourceId) {
+ for (const editor of this.editors) {
+ const { styleSheet } = editor;
+ if (styleSheet.isOriginalSource && styleSheet.sourceId === sourceId) {
+ return styleSheet;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Given an URL, find a stylesheet resource with that URL, if one has been
+ * loaded into the editor.js
+ *
+ * Do not use this unless you have no other way to get a StyleSheet resource
+ * multiple sheets could share the same URL, so this will give you _one_
+ * of possibly many sheets with that URL.
+ *
+ * @param {string} url
+ * An arbitrary URL to search for.
+ *
+ * @return {StyleSheetResource|null}
+ */
+ getStylesheetResourceForGeneratedURL(url) {
+ for (const styleSheet of this.#seenSheets.keys()) {
+ const sheetURL = styleSheet.href || styleSheet.nodeHref;
+ if (!styleSheet.isOriginalSource && sheetURL === url) {
+ return styleSheet;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * selects a stylesheet and optionally moves the cursor to a selected line
+ *
+ * @param {StyleSheetResource} stylesheet
+ * Stylesheet to select or href of stylesheet to select
+ * @param {Number} line
+ * Line to which the caret should be moved (zero-indexed).
+ * @param {Number} col
+ * Column to which the caret should be moved (zero-indexed).
+ * @return {Promise}
+ * Promise that will resolve when the editor is selected and ready
+ * to be used.
+ */
+ selectStyleSheet(stylesheet, line, col) {
+ this.#styleSheetToSelect = {
+ stylesheet,
+ line,
+ col,
+ };
+
+ /* Switch to the editor for this sheet, if it exists yet.
+ Otherwise each editor will be checked when it's created. */
+ return this.switchToSelectedSheet();
+ }
+
+ /**
+ * Handler for an editor's 'property-changed' event.
+ * Update the summary in the UI.
+ *
+ * @param {StyleSheetEditor} editor
+ * Editor for which a property has changed
+ */
+ #summaryChange(editor) {
+ this.#updateSummaryForEditor(editor);
+ }
+
+ /**
+ * Update split view summary of given StyleEditor instance.
+ *
+ * @param {StyleSheetEditor} editor
+ * @param {DOMElement} summary
+ * Optional item's summary element to update. If none, item
+ * corresponding to passed editor is used.
+ */
+ #updateSummaryForEditor(editor, summary) {
+ summary = summary || editor.summary;
+ if (!summary) {
+ return;
+ }
+
+ let ruleCount = editor.styleSheet.ruleCount;
+ if (editor.styleSheet.relatedStyleSheet) {
+ ruleCount = editor.styleSheet.relatedStyleSheet.ruleCount;
+ }
+ if (ruleCount === undefined) {
+ ruleCount = "-";
+ }
+
+ summary.classList.toggle("disabled", !!editor.styleSheet.disabled);
+ summary.classList.toggle("unsaved", !!editor.unsaved);
+ summary.classList.toggle("linked-file-error", !!editor.linkedCSSFileError);
+
+ const label = summary.querySelector(".stylesheet-name > label");
+ label.setAttribute("value", editor.friendlyName);
+ if (editor.styleSheet.href) {
+ label.setAttribute("tooltiptext", editor.styleSheet.href);
+ }
+
+ let linkedCSSSource = "";
+ if (editor.linkedCSSFile) {
+ linkedCSSSource = PathUtils.filename(editor.linkedCSSFile);
+ } else if (editor.styleSheet.relatedEditorName) {
+ linkedCSSSource = editor.styleSheet.relatedEditorName;
+ }
+ text(summary, ".stylesheet-linked-file", linkedCSSSource);
+ text(summary, ".stylesheet-title", editor.styleSheet.title || "");
+ text(
+ summary,
+ ".stylesheet-rule-count",
+ PluralForm.get(ruleCount, getString("ruleCount.label")).replace(
+ "#1",
+ ruleCount
+ )
+ );
+
+ // We may need to change the summary visibility as a result of the changes.
+ this.handleSummaryVisibility(summary);
+ }
+
+ /**
+ * Update the at-rules sidebar for an editor. Hide if there are no rules
+ * Display a list of the at-rules (@media, @layer, @container, …) in the editor's associated style sheet.
+ * Emits a 'at-rules-list-changed' event after updating the UI.
+ *
+ * @param {StyleSheetEditor} editor
+ * Editor to update sidebar of
+ */
+ #updateAtRulesList = editor => {
+ (async function () {
+ const details = await this.getEditorDetails(editor);
+ const list = details.querySelector(".stylesheet-at-rules-list");
+
+ while (list.firstChild) {
+ list.firstChild.remove();
+ }
+
+ const rules = editor.atRules;
+ const showSidebar = Services.prefs.getBoolPref(PREF_AT_RULES_SIDEBAR);
+ const sidebar = details.querySelector(".stylesheet-sidebar");
+
+ let inSource = false;
+
+ for (const rule of rules) {
+ const { line, column } = rule;
+
+ let location = {
+ line,
+ column,
+ source: editor.styleSheet.href,
+ styleSheet: editor.styleSheet,
+ };
+ if (editor.styleSheet.isOriginalSource) {
+ const styleSheet = editor.cssSheet;
+ location = await editor.styleSheet.getOriginalLocation(
+ styleSheet,
+ line,
+ column
+ );
+ }
+
+ // this at-rule is from a different original source
+ if (location.source != editor.styleSheet.href) {
+ continue;
+ }
+ inSource = true;
+
+ const div = this.#panelDoc.createElementNS(HTML_NS, "div");
+ div.classList.add("at-rule-label", rule.type);
+ div.addEventListener(
+ "click",
+ this.#jumpToLocation.bind(this, location)
+ );
+
+ const ruleTextContainer = this.#panelDoc.createElementNS(
+ HTML_NS,
+ "div"
+ );
+ const type = this.#panelDoc.createElementNS(HTML_NS, "span");
+ type.className = "at-rule-type";
+ type.append(this.#panelDoc.createTextNode(`@${rule.type}\u00A0`));
+ if (rule.type == "layer" && rule.layerName) {
+ type.append(this.#panelDoc.createTextNode(`${rule.layerName}\u00A0`));
+ }
+
+ const cond = this.#panelDoc.createElementNS(HTML_NS, "span");
+ cond.className = "at-rule-condition";
+ if (rule.type == "media" && !rule.matches) {
+ cond.classList.add("media-condition-unmatched");
+ }
+ if (this.#commands.descriptorFront.isLocalTab) {
+ this.#setConditionContents(cond, rule.conditionText, rule.type);
+ } else {
+ cond.textContent = rule.conditionText;
+ }
+
+ const link = this.#panelDoc.createElementNS(HTML_NS, "div");
+ link.className = "at-rule-line theme-link";
+ if (location.line != -1) {
+ link.textContent = ":" + location.line;
+ }
+
+ ruleTextContainer.append(type, cond);
+ div.append(ruleTextContainer, link);
+ list.appendChild(div);
+ }
+
+ sidebar.hidden = !showSidebar || !inSource;
+
+ this.emit("at-rules-list-changed", editor);
+ })
+ .bind(this)()
+ .catch(console.error);
+ };
+
+ /**
+ * Set the condition text for the at-rule element.
+ * For media queries, it also injects links to open RDM at a specific size.
+ *
+ * @param {HTMLElement} element
+ * The element corresponding to the media sidebar condition
+ * @param {String} ruleConditionText
+ * The rule conditionText
+ * @param {String} type
+ * The type of the at-rule (e.g. "media", "layer", "supports", …)
+ */
+ #setConditionContents(element, ruleConditionText, type) {
+ if (!ruleConditionText) {
+ return;
+ }
+
+ // For non-media rules, we don't do anything more than displaying the conditionText
+ // as there are no other condition text that would justify opening RDM at a specific
+ // size (e.g. `@container` condition is relative to a container size, which varies
+ // depending the node the rule applies to).
+ if (type !== "media") {
+ const node = this.#panelDoc.createTextNode(ruleConditionText);
+ element.appendChild(node);
+ return;
+ }
+
+ const minMaxPattern = /(min\-|max\-)(width|height):\s\d+(px)/gi;
+
+ let match = minMaxPattern.exec(ruleConditionText);
+ let lastParsed = 0;
+ while (match && match.index != minMaxPattern.lastIndex) {
+ const matchEnd = match.index + match[0].length;
+ const node = this.#panelDoc.createTextNode(
+ ruleConditionText.substring(lastParsed, match.index)
+ );
+ element.appendChild(node);
+
+ const link = this.#panelDoc.createElementNS(HTML_NS, "a");
+ link.href = "#";
+ link.className = "media-responsive-mode-toggle";
+ link.textContent = ruleConditionText.substring(match.index, matchEnd);
+ link.addEventListener("click", this.#onMediaConditionClick.bind(this));
+ element.appendChild(link);
+
+ match = minMaxPattern.exec(ruleConditionText);
+ lastParsed = matchEnd;
+ }
+
+ const node = this.#panelDoc.createTextNode(
+ ruleConditionText.substring(lastParsed, ruleConditionText.length)
+ );
+ element.appendChild(node);
+ }
+
+ /**
+ * Called when a media condition is clicked
+ * If a responsive mode link is clicked, it will launch it.
+ *
+ * @param {object} e
+ * Event object
+ */
+ #onMediaConditionClick(e) {
+ const conditionText = e.target.textContent;
+ const isWidthCond = conditionText.toLowerCase().indexOf("width") > -1;
+ const mediaVal = parseInt(/\d+/.exec(conditionText), 10);
+
+ const options = isWidthCond ? { width: mediaVal } : { height: mediaVal };
+ this.#launchResponsiveMode(options);
+ e.preventDefault();
+ e.stopPropagation();
+ }
+
+ /**
+ * Launches the responsive mode with a specific width or height.
+ *
+ * @param {object} options
+ * Object with width or/and height properties.
+ */
+ async #launchResponsiveMode(options = {}) {
+ const tab = this.#commands.descriptorFront.localTab;
+ const win = tab.ownerDocument.defaultView;
+
+ await lazy.ResponsiveUIManager.openIfNeeded(win, tab, {
+ trigger: "style_editor",
+ });
+ this.emit("responsive-mode-opened");
+
+ lazy.ResponsiveUIManager.getResponsiveUIForTab(tab).setViewportSize(
+ options
+ );
+ }
+
+ /**
+ * Jump cursor to the editor for a stylesheet and line number for a rule.
+ *
+ * @param {object} location
+ * Location object with 'line', 'column', and 'source' properties.
+ */
+ #jumpToLocation(location) {
+ const source = location.styleSheet || location.source;
+ this.selectStyleSheet(source, location.line - 1, location.column - 1);
+ }
+
+ #startLoadingStyleSheets() {
+ this.#root.classList.add("loading");
+ this.#loadingStyleSheets = [];
+ }
+
+ async #waitForLoadingStyleSheets() {
+ while (this.#loadingStyleSheets?.length > 0) {
+ const pending = this.#loadingStyleSheets;
+ this.#loadingStyleSheets = [];
+ await Promise.all(pending);
+ }
+
+ this.#loadingStyleSheets = null;
+ this.#root.classList.remove("loading");
+ }
+
+ async #handleStyleSheetResource(resource) {
+ try {
+ // The fileName is in resource means this stylesheet was imported from file by user.
+ const { fileName } = resource;
+ let file = fileName ? new lazy.FileUtils.File(fileName) : null;
+
+ // recall location of saved file for this sheet after page reload
+ if (!file) {
+ const identifier = this.getStyleSheetIdentifier(resource);
+ const savedFile = this.savedLocations[identifier];
+ if (savedFile) {
+ file = savedFile;
+ }
+ }
+ resource.file = file;
+
+ await this.#addStyleSheet(resource);
+ } catch (e) {
+ console.error(e);
+ this.emit("error", { key: LOAD_ERROR, level: "warning" });
+ }
+ }
+
+ // onAvailable is a mandatory argument for watchTargets,
+ // but we don't do anything when a new target gets created.
+ #onTargetAvailable = ({ targetFront }) => {};
+
+ #onTargetDestroyed = ({ targetFront }) => {
+ // Iterate over a copy of the list in order to prevent skipping
+ // over some items when removing items of this list
+ const editorsCopy = [...this.editors];
+ for (const editor of editorsCopy) {
+ const { styleSheet } = editor;
+ if (styleSheet.targetFront == targetFront) {
+ this.#removeStyleSheet(styleSheet, editor);
+ }
+ }
+ };
+
+ #onResourceAvailable = async resources => {
+ const promises = [];
+ for (const resource of resources) {
+ if (
+ resource.resourceType === this.#toolbox.resourceCommand.TYPES.STYLESHEET
+ ) {
+ const onStyleSheetHandled = this.#handleStyleSheetResource(resource);
+
+ if (this.#loadingStyleSheets) {
+ // In case of reloading/navigating and panel's opening
+ this.#loadingStyleSheets.push(onStyleSheetHandled);
+ }
+ promises.push(onStyleSheetHandled);
+ continue;
+ }
+
+ if (!resource.targetFront.isTopLevel) {
+ continue;
+ }
+
+ if (resource.name === "will-navigate") {
+ this.#startLoadingStyleSheets();
+ this.#clear();
+ } else if (resource.name === "dom-complete") {
+ promises.push(this.#waitForLoadingStyleSheets());
+ }
+ }
+ await Promise.all(promises);
+ };
+
+ #onResourceUpdated = async updates => {
+ for (const { resource, update } of updates) {
+ if (
+ update.resourceType === this.#toolbox.resourceCommand.TYPES.STYLESHEET
+ ) {
+ const editor = this.editors.find(
+ e => e.resourceId === update.resourceId
+ );
+
+ switch (update.updateType) {
+ case "style-applied": {
+ editor.onStyleApplied(update);
+ break;
+ }
+ case "property-change": {
+ for (const [property, value] of Object.entries(
+ update.resourceUpdates
+ )) {
+ editor.onPropertyChange(property, value);
+ }
+ break;
+ }
+ case "at-rules-changed":
+ case "matches-change": {
+ editor.onAtRulesChanged(resource.atRules);
+ break;
+ }
+ }
+ }
+ }
+ };
+
+ /**
+ * Set the active item's summary element.
+ *
+ * @param DOMElement summary
+ * @param {Object} options
+ * @param {String=} options.reason: Indicates why the summary was selected. It's set to
+ * "filter-auto" when the summary was automatically selected as the result
+ * of the previous active summary being filtered out.
+ */
+ setActiveSummary(summary, options = {}) {
+ if (summary == this.#activeSummary) {
+ return;
+ }
+
+ if (this.#activeSummary) {
+ const binding = this.#summaryDataMap.get(this.#activeSummary);
+
+ this.#activeSummary.classList.remove("splitview-active");
+ binding.details.classList.remove("splitview-active");
+ }
+
+ this.#activeSummary = summary;
+ if (!summary) {
+ this.selectedEditor = null;
+ return;
+ }
+
+ const { details } = this.#summaryDataMap.get(summary);
+ summary.classList.add("splitview-active");
+ details.classList.add("splitview-active");
+
+ this.showSummaryEditor(summary, options);
+ }
+
+ /**
+ * Show summary's associated editor
+ *
+ * @param DOMElement summary
+ * @param {Object} options
+ * @param {String=} options.reason: Indicates why the summary was selected. It's set to
+ * "filter-auto" when the summary was automatically selected as the result
+ * of the previous active summary being filtered out.
+ */
+ async showSummaryEditor(summary, options) {
+ const { details, editor } = this.#summaryDataMap.get(summary);
+ this.selectedEditor = editor;
+
+ try {
+ if (!editor.sourceEditor) {
+ // only initialize source editor when we switch to this view
+ const inputElement = details.querySelector(".stylesheet-editor-input");
+ await editor.load(inputElement, this.#cssProperties);
+ }
+
+ editor.onShow(options);
+
+ this.emit("editor-selected", editor);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+
+ /**
+ * Remove an item from the split view.
+ *
+ * @param DOMElement summary
+ * Summary element of the item to remove.
+ */
+ removeSplitViewItem(summary) {
+ if (summary == this.#activeSummary) {
+ this.setActiveSummary(null);
+ }
+
+ const data = this.#summaryDataMap.get(summary);
+ if (!data) {
+ return;
+ }
+
+ summary.remove();
+ data.details.remove();
+ }
+
+ /**
+ * Make the passed element visible or not, depending if it matches the current filter
+ *
+ * @param {Element} summary
+ * @param {Object} options
+ * @param {Boolean} options.triggerOnFilterStateChange: Set to false to avoid calling
+ * #onFilterStateChange directly here. This can be useful when this
+ * function is called for every item of the list, like in `setFilter`.
+ */
+ handleSummaryVisibility(summary, { triggerOnFilterStateChange = true } = {}) {
+ if (!this.#filter) {
+ summary.classList.remove(FILTERED_CLASSNAME);
+ return;
+ }
+
+ const label = summary.querySelector(".stylesheet-name label");
+ const itemText = label.value.toLowerCase();
+ const matchesSearch = itemText.includes(this.#filter.toLowerCase());
+ summary.classList.toggle(FILTERED_CLASSNAME, !matchesSearch);
+
+ if (this.#activeSummary == summary && !matchesSearch) {
+ this.setActiveSummary(null);
+ }
+
+ if (triggerOnFilterStateChange) {
+ this.#onFilterStateChange();
+ }
+ }
+
+ destroy() {
+ this.#toolbox.resourceCommand.unwatchResources(
+ [
+ this.#toolbox.resourceCommand.TYPES.DOCUMENT_EVENT,
+ this.#toolbox.resourceCommand.TYPES.STYLESHEET,
+ ],
+ {
+ onAvailable: this.#onResourceAvailable,
+ onUpdated: this.#onResourceUpdated,
+ }
+ );
+ this.#commands.targetCommand.unwatchTargets({
+ types: [this.#commands.targetCommand.TYPES.FRAME],
+ onAvailable: this.#onTargetAvailable,
+ onDestroyed: this.#onTargetDestroyed,
+ });
+
+ if (this.#uiAbortController) {
+ this.#uiAbortController.abort();
+ this.#uiAbortController = null;
+ }
+ this.#clearStyleSheetEditors();
+
+ this.#seenSheets = null;
+ this.#filterInput = null;
+ this.#filterInputClearButton = null;
+ this.#nav = null;
+ this.#side = null;
+ this.#tplDetails = null;
+ this.#tplSummary = null;
+
+ const sidebar = this.#panelDoc.querySelector(".splitview-controller");
+ const sidebarWidth = parseInt(sidebar.style.width, 10);
+ if (!isNaN(sidebarWidth)) {
+ Services.prefs.setIntPref(PREF_NAV_WIDTH, sidebarWidth);
+ }
+
+ if (this.#sourceMapPrefObserver) {
+ this.#sourceMapPrefObserver.off(
+ PREF_ORIG_SOURCES,
+ this.#onOrigSourcesPrefChanged
+ );
+ this.#sourceMapPrefObserver.destroy();
+ this.#sourceMapPrefObserver = null;
+ }
+
+ if (this.#prefObserver) {
+ this.#prefObserver.off(
+ PREF_AT_RULES_SIDEBAR,
+ this.#onAtRulesSidebarPrefChanged
+ );
+ this.#prefObserver.destroy();
+ this.#prefObserver = null;
+ }
+
+ if (this.#shortcuts) {
+ this.#shortcuts.destroy();
+ this.#shortcuts = null;
+ }
+ }
+}