forked from mirrors/gecko-dev
Backed out changeset 0965e956200a (bug 1834725) Backed out changeset 4a8151163607 (bug 1834725) Backed out changeset a56f42223377 (bug 1834725) Backed out changeset a920356b63eb (bug 1834725)
1816 lines
53 KiB
JavaScript
1816 lines
53 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 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 () => {
|
|
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 = "-";
|
|
}
|
|
|
|
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.relatedEditorName) {
|
|
linkedCSSSource = editor.styleSheet.relatedEditorName;
|
|
}
|
|
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`));
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
#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;
|
|
}
|
|
}
|
|
}
|