1892 lines
56 KiB
JavaScript
1892 lines
56 KiB
JavaScript
/* 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 { PrefObserver } = require("resource://devtools/client/shared/prefs.js");
|
|
|
|
const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js");
|
|
const {
|
|
shortSource,
|
|
} = require("resource://devtools/shared/inspector/css-logic.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",
|
|
NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
|
|
});
|
|
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;
|
|
#prettyPrintButton;
|
|
#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,
|
|
onDestroyed: this.#onResourceDestroyed,
|
|
}
|
|
);
|
|
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.#prettyPrintButton = this.#root.querySelector(
|
|
".style-editor-prettyPrintButton"
|
|
);
|
|
this.#prettyPrintButton.addEventListener(
|
|
"click",
|
|
() => {
|
|
if (!this.selectedEditor) {
|
|
return;
|
|
}
|
|
|
|
this.selectedEditor.prettifySourceText();
|
|
},
|
|
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 () => {
|
|
// When the StyleSheet is mapped to one or many original sources,
|
|
// do not create an editor for the minified StyleSheet.
|
|
const hasValidOriginalSource =
|
|
await this.#tryAddingOriginalStyleSheets(resource);
|
|
if (hasValidOriginalSource) {
|
|
return null;
|
|
}
|
|
// Otherwise, if source-map failed or this is a non-source-map CSS
|
|
// create an editor for it.
|
|
return this.#addStyleSheetEditor(resource);
|
|
})();
|
|
this.#seenSheets.set(resource, promise);
|
|
}
|
|
return this.#seenSheets.get(resource);
|
|
}
|
|
|
|
/**
|
|
* Check if the given StyleSheet relates to an original StyleSheet (via source maps).
|
|
* If one is found, create an editor for the original one.
|
|
*
|
|
* @param {Resource} resource
|
|
* The STYLESHEET resource which is received from resource command.
|
|
* @return Boolean
|
|
* Return true, when we found a viable related original StyleSheet.
|
|
*/
|
|
async #tryAddingOriginalStyleSheets(resource) {
|
|
// Avoid querying the SourceMap if this feature is disabled.
|
|
if (!Services.prefs.getBoolPref(PREF_ORIG_SOURCES)) {
|
|
return false;
|
|
}
|
|
|
|
const sourceMapLoader = this.#toolbox.sourceMapLoader;
|
|
const {
|
|
href,
|
|
nodeHref,
|
|
resourceId: id,
|
|
sourceMapURL,
|
|
sourceMapBaseURL,
|
|
} = resource;
|
|
let sources;
|
|
try {
|
|
sources = await sourceMapLoader.getOriginalURLs({
|
|
id,
|
|
url: href || nodeHref,
|
|
sourceMapBaseURL,
|
|
sourceMapURL,
|
|
});
|
|
} catch (e) {
|
|
// Ignore any source map error, they will be logged
|
|
// via the SourceMapLoader and Toolbox into the Web Console.
|
|
return false;
|
|
}
|
|
|
|
// Return the generated CSS if the source-map failed to be parsed
|
|
// or did not generate any original source.
|
|
if (!sources || !sources.length) {
|
|
return false;
|
|
}
|
|
|
|
// A single generated sheet might map to multiple original
|
|
// sheets, so make editors for each of them.
|
|
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.resourceId = resource.resourceId;
|
|
original.targetFront = resource.targetFront;
|
|
original.atRules = resource.atRules;
|
|
await this.#addStyleSheetEditor(original);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
#removeStyleSheet(resource, editor) {
|
|
this.#seenSheets.delete(resource);
|
|
this.#removeStyleSheetEditor(editor);
|
|
}
|
|
|
|
#getInlineStyleSheetsCount() {
|
|
let count = 0;
|
|
for (const editor of this.editors) {
|
|
if (!editor.styleSheet.href && !editor.styleSheet.constructed) {
|
|
count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
#getNewStyleSheetsCount() {
|
|
let count = 0;
|
|
for (const editor of this.editors) {
|
|
if (editor.isNew) {
|
|
count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
#getConstructedSheetsCount() {
|
|
let count = 0;
|
|
for (const editor of this.editors) {
|
|
if (editor.styleSheet.constructed) {
|
|
count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* Finds the index to be shown in the Style Editor for inline, constructed or
|
|
* user-created style sheets, returns undefined if not any of those.
|
|
*
|
|
* @param {StyleSheet} styleSheet
|
|
* Object representing stylesheet
|
|
* @return {Number}
|
|
* 1-based Integer representing the index of the current stylesheet
|
|
* among all stylesheets of its type (inline, constructed or user-created).
|
|
* Defaults to 0 when non-applicable (e.g. for stylesheet with href)
|
|
*/
|
|
#getNextFriendlyIndex(styleSheet) {
|
|
if (styleSheet.href) {
|
|
return 0;
|
|
}
|
|
|
|
if (styleSheet.isNew) {
|
|
return this.#getNewStyleSheetsCount() + 1;
|
|
}
|
|
|
|
if (styleSheet.constructed) {
|
|
return this.#getConstructedSheetsCount() + 1;
|
|
}
|
|
|
|
return this.#getInlineStyleSheetsCount() + 1;
|
|
}
|
|
|
|
/**
|
|
* 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 = "-";
|
|
}
|
|
|
|
this.#panelDoc.l10n.setArgs(
|
|
summary.querySelector(".stylesheet-rule-count"),
|
|
{
|
|
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.relatedStyleSheet) {
|
|
// Compute a friendly name for the related generated source
|
|
// (relatedStyleSheet is set on original CSS to refer to the generated one)
|
|
linkedCSSSource = shortSource(editor.styleSheet.relatedStyleSheet);
|
|
try {
|
|
linkedCSSSource = decodeURI(linkedCSSSource);
|
|
} catch (e) {}
|
|
}
|
|
text(summary, ".stylesheet-linked-file", linkedCSSSource);
|
|
text(summary, ".stylesheet-title", editor.styleSheet.title || "");
|
|
|
|
// We may need to change the summary visibility as a result of the changes.
|
|
this.handleSummaryVisibility(summary);
|
|
}
|
|
|
|
/**
|
|
* Update the pretty print button.
|
|
* The button will be disabled if the selected file is an original file.
|
|
*/
|
|
#updatePrettyPrintButton() {
|
|
const disable =
|
|
!this.selectedEditor || !!this.selectedEditor.styleSheet.isOriginalSource;
|
|
|
|
// Only update the button if its state needs it
|
|
if (disable !== this.#prettyPrintButton.hasAttribute("disabled")) {
|
|
this.#prettyPrintButton.toggleAttribute("disabled");
|
|
const l10nString = disable
|
|
? "styleeditor-pretty-print-button-disabled"
|
|
: "styleeditor-pretty-print-button";
|
|
this.#window.document.l10n.setAttributes(
|
|
this.#prettyPrintButton,
|
|
l10nString
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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`));
|
|
} else if (rule.type === "property") {
|
|
type.append(
|
|
this.#panelDoc.createTextNode(`${rule.propertyName}\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");
|
|
this.emit("reloaded");
|
|
}
|
|
|
|
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 = () => {};
|
|
|
|
#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 => {
|
|
// The editors are instantiated asynchronously from onResourceAvailable,
|
|
// but we may receive updates right after due to throttling.
|
|
// Ensure waiting for this async work before trying to update the related editors.
|
|
await this.#waitForLoadingStyleSheets();
|
|
|
|
for (const { resource, update } of updates) {
|
|
if (
|
|
update.resourceType === this.#toolbox.resourceCommand.TYPES.STYLESHEET
|
|
) {
|
|
const editor = this.editors.find(
|
|
e => e.resourceId === update.resourceId
|
|
);
|
|
|
|
if (!editor) {
|
|
console.warn(
|
|
"Could not find StyleEditor to apply STYLESHEET resource update"
|
|
);
|
|
continue;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
#onResourceDestroyed = resources => {
|
|
for (const resource of resources) {
|
|
if (
|
|
resource.resourceType !== this.#toolbox.resourceCommand.TYPES.STYLESHEET
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const editorToRemove = this.editors.find(
|
|
editor => editor.styleSheet.resourceId == resource.resourceId
|
|
);
|
|
|
|
if (editorToRemove) {
|
|
const { styleSheet } = editorToRemove;
|
|
this.#removeStyleSheet(styleSheet, editorToRemove);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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.#updatePrettyPrintButton();
|
|
|
|
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,
|
|
onDestroyed: this.#onResourceDestroyed,
|
|
}
|
|
);
|
|
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.#prettyPrintButton = 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;
|
|
}
|
|
}
|
|
}
|