Source code

Revision control

Copy as Markdown

Other Tools

/* 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/. */
"use strict";
const flags = require("resource://devtools/shared/flags.js");
const ToolDefinitions =
require("resource://devtools/client/definitions.js").Tools;
const CssLogic = require("resource://devtools/shared/inspector/css-logic.js");
const {
style: { ELEMENT_STYLE },
} = require("resource://devtools/shared/constants.js");
const OutputParser = require("resource://devtools/client/shared/output-parser.js");
const { PrefObserver } = require("resource://devtools/client/shared/prefs.js");
const {
createChild,
} = require("resource://devtools/client/inspector/shared/utils.js");
const {
VIEW_NODE_SELECTOR_TYPE,
VIEW_NODE_PROPERTY_TYPE,
VIEW_NODE_VALUE_TYPE,
VIEW_NODE_IMAGE_URL_TYPE,
VIEW_NODE_FONT_TYPE,
} = require("resource://devtools/client/inspector/shared/node-types.js");
const TooltipsOverlay = require("resource://devtools/client/inspector/shared/tooltips-overlay.js");
loader.lazyRequireGetter(
this,
"StyleInspectorMenu",
"resource://devtools/client/inspector/shared/style-inspector-menu.js"
);
loader.lazyRequireGetter(
this,
"KeyShortcuts",
"resource://devtools/client/shared/key-shortcuts.js"
);
loader.lazyRequireGetter(
this,
"clipboardHelper",
"resource://devtools/shared/platform/clipboard.js"
);
loader.lazyRequireGetter(
this,
"openContentLink",
"resource://devtools/client/shared/link.js",
true
);
const STYLE_INSPECTOR_PROPERTIES =
"devtools/shared/locales/styleinspector.properties";
const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
const STYLE_INSPECTOR_L10N = new LocalizationHelper(STYLE_INSPECTOR_PROPERTIES);
const L10N_TWISTY_EXPAND_LABEL = STYLE_INSPECTOR_L10N.getStr(
"rule.twistyExpand.label"
);
const L10N_TWISTY_COLLAPSE_LABEL = STYLE_INSPECTOR_L10N.getStr(
"rule.twistyCollapse.label"
);
const L10N_EMPTY_VARIABLE = STYLE_INSPECTOR_L10N.getStr("rule.variableEmpty");
const FILTER_CHANGED_TIMEOUT = 150;
/**
* Helper for long-running processes that should yield occasionally to
* the mainloop.
*/
class UpdateProcess {
/**
* @param {Window} win
* Timeouts will be set on this window when appropriate.
* @param {Array} array
* The array of items to process.
* @param {Object} options
* Options for the update process:
* onItem {function} Will be called with the value of each iteration.
* onBatch {function} Will be called after each batch of iterations,
* before yielding to the main loop.
* onDone {function} Will be called when iteration is complete.
* onCancel {function} Will be called if the process is canceled.
* threshold {int} How long to process before yielding, in ms.
*/
constructor(win, array, options) {
this.win = win;
this.index = 0;
this.array = array;
this.onItem = options.onItem || function () {};
this.onBatch = options.onBatch || function () {};
this.onDone = options.onDone || function () {};
this.onCancel = options.onCancel || function () {};
this.threshold = options.threshold || 45;
}
#canceled = false;
#timeout = null;
/**
* Symbol returned when the array of items to process is empty.
*/
static ITERATION_DONE = Symbol("UpdateProcess iteration done");
/**
* Schedule a new batch on the main loop.
*/
schedule() {
if (this.#canceled) {
return;
}
this.#timeout = setTimeout(() => this.#timeoutHandler(), 0);
}
/**
* Cancel the running process. onItem will not be called again,
* and onCancel will be called.
*/
cancel() {
if (this.#timeout) {
clearTimeout(this.#timeout);
this.#timeout = null;
}
this.#canceled = true;
this.onCancel();
}
#timeoutHandler() {
this.#timeout = null;
if (this.#runBatch() === UpdateProcess.ITERATION_DONE) {
this.onBatch();
this.onDone();
return;
}
this.schedule();
}
#runBatch() {
const time = Date.now();
while (!this.#canceled) {
const next = this.#next();
if (next === UpdateProcess.ITERATION_DONE) {
return next;
}
this.onItem(next);
if (Date.now() - time > this.threshold) {
this.onBatch();
return null;
}
}
return null;
}
/**
* Returns the item at the current index and increases the index.
* If all items have already been processed, will return ITERATION_DONE.
*/
#next() {
if (this.index < this.array.length) {
return this.array[this.index++];
}
return UpdateProcess.ITERATION_DONE;
}
}
/**
* CssComputedView is a panel that manages the display of a table
* sorted by style. There should be one instance of CssComputedView
* per style display (of which there will generally only be one).
*/
class CssComputedView {
/**
* @param {Inspector} inspector
* Inspector toolbox panel
* @param {Document} document
* The document that will contain the computed view.
*/
constructor(inspector, document) {
this.inspector = inspector;
this.styleDocument = document;
this.styleWindow = this.styleDocument.defaultView;
this.propertyViews = [];
this.#outputParser = new OutputParser(document, inspector.cssProperties);
// Create bound methods.
this.focusWindow = this.focusWindow.bind(this);
this.refreshPanel = this.refreshPanel.bind(this);
const doc = this.styleDocument;
this.element = doc.getElementById("computed-property-container");
this.searchField = doc.getElementById("computed-searchbox");
this.searchClearButton = doc.getElementById("computed-searchinput-clear");
this.includeBrowserStylesCheckbox = doc.getElementById(
"browser-style-checkbox"
);
this.#abortController = new AbortController();
const opts = { signal: this.#abortController.signal };
this.shortcuts = new KeyShortcuts({ window: this.styleWindow });
this.shortcuts.on(
"CmdOrCtrl+F",
event => this.#onShortcut("CmdOrCtrl+F", event),
opts
);
this.shortcuts.on(
"Escape",
event => this.#onShortcut("Escape", event),
opts
);
this.styleDocument.addEventListener("copy", this.#onCopy, opts);
this.styleDocument.addEventListener("mousedown", this.focusWindow, opts);
this.element.addEventListener("click", this.#onClick, opts);
this.element.addEventListener("contextmenu", this.#onContextMenu, opts);
this.searchField.addEventListener("input", this.#onFilterStyles, opts);
this.searchClearButton.addEventListener("click", this.#onClearSearch, opts);
this.includeBrowserStylesCheckbox.addEventListener(
"input",
this.#onIncludeBrowserStyles,
opts
);
if (flags.testing) {
// In tests, we start listening immediately to avoid having to simulate a mousemove.
this.highlighters.addToView(this);
} else {
this.element.addEventListener(
"mousemove",
() => {
this.highlighters.addToView(this);
},
{ once: true, signal: this.#abortController.signal }
);
}
if (!this.inspector.is3PaneModeEnabled) {
// When the rules view is added in 3 pane mode, refresh the Computed view whenever
// the rules are changed.
this.inspector.once(
"ruleview-added",
() => {
this.ruleView.on("ruleview-changed", this.refreshPanel, opts);
},
opts
);
}
if (this.ruleView) {
this.ruleView.on("ruleview-changed", this.refreshPanel, opts);
}
this.searchClearButton.hidden = true;
// No results text.
this.noResults = this.styleDocument.getElementById("computed-no-results");
// Refresh panel when color unit changed or pref for showing
// original sources changes.
this.#prefObserver = new PrefObserver("devtools.");
this.#prefObserver.on(
"devtools.defaultColorUnit",
this.#handlePrefChange,
opts
);
// The PageStyle front related to the currently selected element
this.viewedElementPageStyle = null;
this.createStyleViews();
// Add the tooltips and highlightersoverlay
this.tooltips = new TooltipsOverlay(this);
}
/**
* Lookup a l10n string in the shared styleinspector string bundle.
*
* @param {String} name
* The key to lookup.
* @returns {String} localized version of the given key.
*/
static l10n(name) {
try {
return STYLE_INSPECTOR_L10N.getStr(name);
} catch (ex) {
console.log("Error reading '" + name + "'");
throw new Error("l10n error with " + name);
}
}
#abortController;
#contextMenu;
#computed;
#createViewsProcess;
#createViewsPromise;
// Used for cancelling timeouts in the style filter.
#filterChangedTimeout = null;
#highlighters;
#isDestroyed = false;
// Cache the list of properties that match the selected element.
#matchedProperties = null;
#outputParser = null;
#prefObserver;
#refreshProcess;
#sourceFilter;
// The element that we're inspecting, and the document that it comes from.
#viewedElement = null;
// Number of visible properties
numVisibleProperties = 0;
get outputParser() {
return this.#outputParser;
}
get computed() {
return this.#computed;
}
get contextMenu() {
if (!this.#contextMenu) {
this.#contextMenu = new StyleInspectorMenu(this);
}
return this.#contextMenu;
}
// Get the highlighters overlay from the Inspector.
get highlighters() {
if (!this.#highlighters) {
// highlighters is a lazy getter in the inspector.
this.#highlighters = this.inspector.highlighters;
}
return this.#highlighters;
}
get includeBrowserStyles() {
return this.includeBrowserStylesCheckbox.checked;
}
get ruleView() {
return (
this.inspector.hasPanel("ruleview") &&
this.inspector.getPanel("ruleview").view
);
}
get viewedElement() {
return this.#viewedElement;
}
#handlePrefChange = () => {
if (this.#computed) {
this.refreshPanel();
}
};
/**
* Update the view with a new selected element. The CssComputedView panel
* will show the style information for the given element.
*
* @param {NodeFront} element
* The highlighted node to get styles for.
* @returns a promise that will be resolved when highlighting is complete.
*/
selectElement(element) {
if (!element) {
if (this.viewedElementPageStyle) {
this.viewedElementPageStyle.off(
"stylesheet-updated",
this.refreshPanel
);
this.viewedElementPageStyle = null;
}
this.#viewedElement = null;
this.noResults.hidden = false;
if (this.#refreshProcess) {
this.#refreshProcess.cancel();
}
// Hiding all properties
for (const propView of this.propertyViews) {
propView.refresh();
}
return Promise.resolve(undefined);
}
if (element === this.#viewedElement) {
return Promise.resolve(undefined);
}
if (this.viewedElementPageStyle) {
this.viewedElementPageStyle.off("stylesheet-updated", this.refreshPanel);
}
this.viewedElementPageStyle = element.inspectorFront.pageStyle;
this.viewedElementPageStyle.on("stylesheet-updated", this.refreshPanel, {
signal: this.#abortController.signal,
});
this.#viewedElement = element;
this.refreshSourceFilter();
return this.refreshPanel();
}
/**
* Get the type of a given node in the computed-view
*
* @param {DOMNode} node
* The node which we want information about
* @return {Object} The type information object contains the following props:
* - view {String} Always "computed" to indicate the computed view.
* - type {String} One of the VIEW_NODE_XXX_TYPE const in
* client/inspector/shared/node-types
* - value {Object} Depends on the type of the node
* returns null if the node isn't anything we care about
*/
// eslint-disable-next-line complexity
getNodeInfo(node) {
if (!node) {
return null;
}
const classes = node.classList;
// Check if the node isn't a selector first since this doesn't require
// walking the DOM
if (
classes.contains("matched") ||
classes.contains("bestmatch") ||
classes.contains("parentmatch")
) {
let selectorText = "";
for (const child of node.childNodes[1].childNodes) {
if (child.nodeType === node.TEXT_NODE) {
selectorText += child.textContent;
}
}
return {
type: VIEW_NODE_SELECTOR_TYPE,
value: selectorText.trim(),
};
}
const propertyView = node.closest(".computed-property-view");
const propertyMatchedSelectors = node.closest(".matchedselectors");
const parent = propertyMatchedSelectors || propertyView;
if (!parent) {
return null;
}
let value, type;
// Get the property and value for a node that's a property name or value
const isHref =
classes.contains("theme-link") && !classes.contains("computed-link");
if (classes.contains("computed-font-family")) {
if (propertyMatchedSelectors) {
const view = propertyMatchedSelectors.closest("li");
value = {
property: view.querySelector(".computed-property-name").firstChild
.textContent,
value: node.parentNode.textContent,
};
} else if (propertyView) {
value = {
property: parent.querySelector(".computed-property-name").firstChild
.textContent,
value: node.parentNode.textContent,
};
} else {
return null;
}
} else if (
propertyMatchedSelectors &&
(classes.contains("computed-other-property-value") || isHref)
) {
const view = propertyMatchedSelectors.closest("li");
value = {
property: view.querySelector(".computed-property-name").firstChild
.textContent,
value: node.textContent,
};
} else if (
propertyView &&
(classes.contains("computed-property-name") ||
classes.contains("computed-property-value") ||
isHref)
) {
value = {
property: parent.querySelector(".computed-property-name").firstChild
.textContent,
value: parent.querySelector(".computed-property-value").textContent,
};
}
// Get the type
if (classes.contains("computed-property-name")) {
type = VIEW_NODE_PROPERTY_TYPE;
} else if (
classes.contains("computed-property-value") ||
classes.contains("computed-other-property-value")
) {
type = VIEW_NODE_VALUE_TYPE;
} else if (classes.contains("computed-font-family")) {
type = VIEW_NODE_FONT_TYPE;
} else if (isHref) {
type = VIEW_NODE_IMAGE_URL_TYPE;
value.url = node.href;
} else {
return null;
}
return {
view: "computed",
type,
value,
};
}
#createPropertyViews() {
if (this.#createViewsPromise) {
return this.#createViewsPromise;
}
this.refreshSourceFilter();
this.numVisibleProperties = 0;
const fragment = this.styleDocument.createDocumentFragment();
this.#createViewsPromise = new Promise((resolve, reject) => {
this.#createViewsProcess = new UpdateProcess(
this.styleWindow,
CssComputedView.propertyNames,
{
onItem: propertyName => {
// Per-item callback.
const propView = new PropertyView(this, propertyName);
fragment.append(propView.createListItemElement());
if (propView.visible) {
this.numVisibleProperties++;
}
this.propertyViews.push(propView);
},
onCancel: () => {
reject("#createPropertyViews cancelled");
},
onDone: () => {
// Completed callback.
this.element.appendChild(fragment);
this.noResults.hidden = this.numVisibleProperties > 0;
resolve(undefined);
},
}
);
});
this.#createViewsProcess.schedule();
return this.#createViewsPromise;
}
isPanelVisible() {
return (
this.inspector.toolbox &&
this.inspector.sidebar &&
this.inspector.toolbox.currentToolId === "inspector" &&
this.inspector.sidebar.getCurrentTabID() == "computedview"
);
}
/**
* Refresh the panel content. This could be called by a "ruleview-changed" event, but
* we avoid the extra processing unless the panel is visible.
*/
async refreshPanel() {
if (!this.#viewedElement || !this.isPanelVisible()) {
return;
}
// Capture the current viewed element to return from the promise handler
// early if it changed
const viewedElement = this.#viewedElement;
try {
// Create the properties views only once for the whole lifecycle of the inspector
// via `_createPropertyViews`.
// The properties are created without backend data. This queries typical property
// names via `DOMWindow.getComputedStyle` on the frontend inspector document.
// We then have to manually update the list of PropertyView's for custom properties
// based on backend data (`getComputed()`/`computed`).
// Also note that PropertyView/PropertyView are refreshed via their refresh method
// which will ultimately query `CssComputedView._computed`, which we update in this method.
const [computed] = await Promise.all([
this.viewedElementPageStyle.getComputed(this.#viewedElement, {
filter: this.#sourceFilter,
onlyMatched: !this.includeBrowserStyles,
markMatched: true,
}),
this.#createPropertyViews(),
]);
if (viewedElement !== this.#viewedElement) {
return;
}
this.#computed = computed;
this.#matchedProperties = new Set();
const customProperties = new Set();
for (const name in computed) {
if (computed[name].matched) {
this.#matchedProperties.add(name);
}
if (name.startsWith("--")) {
customProperties.add(name);
}
}
// Removing custom property PropertyViews which won't be used
let customPropertiesStartIndex;
for (let i = this.propertyViews.length - 1; i >= 0; i--) {
const propView = this.propertyViews[i];
// custom properties are displayed at the bottom of the list, and we're looping
// backward through propertyViews, so if the current item does not represent
// a custom property, we can stop looping.
if (!propView.isCustomProperty) {
customPropertiesStartIndex = i + 1;
break;
}
// If the custom property will be used, move to the next item.
if (customProperties.has(propView.name)) {
customProperties.delete(propView.name);
continue;
}
// Otherwise remove property view element
if (propView.element) {
propView.element.remove();
}
propView.destroy();
this.propertyViews.splice(i, 1);
}
// At this point, `customProperties` only contains custom property names for
// which we don't have a PropertyView yet.
let insertIndex = customPropertiesStartIndex;
for (const customPropertyName of Array.from(customProperties).sort()) {
const propertyView = new PropertyView(
this,
customPropertyName,
// isCustomProperty
true
);
const len = this.propertyViews.length;
if (insertIndex !== len) {
for (let i = insertIndex; i <= len; i++) {
const existingPropView = this.propertyViews[i];
if (
!existingPropView ||
!existingPropView.isCustomProperty ||
customPropertyName < existingPropView.name
) {
insertIndex = i;
break;
}
}
}
this.propertyViews.splice(insertIndex, 0, propertyView);
// Insert the custom property PropertyView at the right spot so we
// keep the list ordered.
const previousSibling = this.element.childNodes[insertIndex - 1];
previousSibling.insertAdjacentElement(
"afterend",
propertyView.createListItemElement()
);
}
if (this.#refreshProcess) {
this.#refreshProcess.cancel();
}
this.noResults.hidden = true;
// Reset visible property count
this.numVisibleProperties = 0;
await new Promise((resolve, reject) => {
this.#refreshProcess = new UpdateProcess(
this.styleWindow,
this.propertyViews,
{
onItem: propView => {
propView.refresh();
},
onCancel: () => {
reject("#refreshProcess of computed view cancelled");
},
onDone: () => {
this.#refreshProcess = null;
this.noResults.hidden = this.numVisibleProperties > 0;
const searchBox = this.searchField.parentNode;
searchBox.classList.toggle(
"devtools-searchbox-no-match",
!!this.searchField.value.length && !this.numVisibleProperties
);
this.inspector.emit("computed-view-refreshed");
resolve(undefined);
},
}
);
this.#refreshProcess.schedule();
});
} catch (e) {
console.error(e);
}
}
/**
* Handle the shortcut events in the computed view.
*/
#onShortcut = (name, event) => {
if (!event.target.closest("#sidebar-panel-computedview")) {
return;
}
// Handle the search box's keypress event. If the escape key is pressed,
// clear the search box field.
if (
name === "Escape" &&
event.target === this.searchField &&
this.#onClearSearch()
) {
event.preventDefault();
event.stopPropagation();
} else if (name === "CmdOrCtrl+F") {
this.searchField.focus();
event.preventDefault();
}
};
/**
* Set the filter style search value.
* @param {String} value
* The search value.
*/
setFilterStyles(value = "") {
this.searchField.value = value;
this.searchField.focus();
this.#onFilterStyles();
}
/**
* Called when the user enters a search term in the filter style search box.
*/
#onFilterStyles = () => {
if (this.#filterChangedTimeout) {
clearTimeout(this.#filterChangedTimeout);
}
const filterTimeout = this.searchField.value.length
? FILTER_CHANGED_TIMEOUT
: 0;
this.searchClearButton.hidden = this.searchField.value.length === 0;
this.#filterChangedTimeout = setTimeout(() => {
this.refreshPanel();
this.#filterChangedTimeout = null;
}, filterTimeout);
};
/**
* Called when the user clicks on the clear button in the filter style search
* box. Returns true if the search box is cleared and false otherwise.
*/
#onClearSearch = () => {
if (this.searchField.value) {
this.setFilterStyles("");
return true;
}
return false;
};
/**
* The change event handler for the includeBrowserStyles checkbox.
*/
#onIncludeBrowserStyles = () => {
this.refreshSourceFilter();
this.refreshPanel();
};
/**
* When includeBrowserStylesCheckbox.checked is false we only display
* properties that have matched selectors and have been included by the
* document or one of thedocument's stylesheets. If .checked is false we
* display all properties including those that come from UA stylesheets.
*/
refreshSourceFilter() {
this.#matchedProperties = null;
this.#sourceFilter = this.includeBrowserStyles
? CssLogic.FILTER.UA
: CssLogic.FILTER.USER;
}
/**
* The CSS as displayed by the UI.
*/
createStyleViews() {
if (CssComputedView.propertyNames) {
return;
}
CssComputedView.propertyNames = [];
// Here we build and cache a list of css properties supported by the browser
// We could use any element but let's use the main document's root element
const styles = this.styleWindow.getComputedStyle(
this.styleDocument.documentElement
);
const mozProps = [];
for (let i = 0, numStyles = styles.length; i < numStyles; i++) {
const prop = styles.item(i);
if (prop.startsWith("--")) {
// Skip any CSS variables used inside of browser CSS files
continue;
} else if (prop.startsWith("-")) {
mozProps.push(prop);
} else {
CssComputedView.propertyNames.push(prop);
}
}
CssComputedView.propertyNames.sort();
CssComputedView.propertyNames.push.apply(
CssComputedView.propertyNames,
mozProps.sort()
);
this.#createPropertyViews().catch(e => {
if (!this.#isDestroyed) {
console.warn(
"The creation of property views was cancelled because " +
"the computed-view was destroyed before it was done creating views"
);
} else {
console.error(e);
}
});
}
/**
* Get a set of properties that have matched selectors.
*
* @return {Set} If a property name is in the set, it has matching selectors.
*/
get matchedProperties() {
return this.#matchedProperties || new Set();
}
/**
* Focus the window on mousedown.
*/
focusWindow() {
this.styleWindow.focus();
}
/**
* Context menu handler.
*/
#onContextMenu = event => {
// Call stopPropagation() and preventDefault() here so that avoid to show default
// context menu in about:devtools-toolbox. See Bug 1515265.
event.stopPropagation();
event.preventDefault();
this.contextMenu.show(event);
};
#onClick = event => {
const target = event.target;
if (target.nodeName === "a") {
event.stopPropagation();
event.preventDefault();
openContentLink(target.href);
}
};
/**
* Callback for copy event. Copy selected text.
*
* @param {Event} event
* copy event object.
*/
#onCopy = event => {
const win = this.styleWindow;
const text = win.getSelection().toString().trim();
if (text !== "") {
this.copySelection();
event.preventDefault();
}
};
/**
* Copy the current selection to the clipboard
*/
copySelection() {
try {
const win = this.styleWindow;
const text = win.getSelection().toString().trim();
clipboardHelper.copyString(text);
} catch (e) {
console.error(e);
}
}
/**
* Destructor for CssComputedView.
*/
destroy() {
this.#viewedElement = null;
this.#abortController.abort();
this.#abortController = null;
if (this.viewedElementPageStyle) {
this.viewedElementPageStyle = null;
}
this.#outputParser = null;
this.#prefObserver.destroy();
// Cancel tree construction
if (this.#createViewsProcess) {
this.#createViewsProcess.cancel();
}
if (this.#refreshProcess) {
this.#refreshProcess.cancel();
}
if (this.#contextMenu) {
this.#contextMenu.destroy();
this.#contextMenu = null;
}
if (this.#highlighters) {
this.#highlighters.removeFromView(this);
this.#highlighters = null;
}
this.tooltips.destroy();
// Nodes used in templating
this.element = null;
this.searchField = null;
this.searchClearButton = null;
this.includeBrowserStylesCheckbox = null;
// Property views
for (const propView of this.propertyViews) {
propView.destroy();
}
this.propertyViews = null;
this.inspector = null;
this.styleDocument = null;
this.styleWindow = null;
this.#isDestroyed = true;
}
}
class PropertyInfo {
/*
* @param {CssComputedView} tree
* The CssComputedView instance we are working with.
* @param {String} name
* The CSS property name
*/
constructor(tree, name) {
this.#tree = tree;
this.name = name;
}
#tree;
get isSupported() {
// There can be a mismatch between the list of properties
// supported on the server and on the client.
// Ideally we should build PropertyInfo only for property names supported on
// the server. See Bug 1722348.
return this.#tree.computed && this.name in this.#tree.computed;
}
get value() {
if (this.isSupported) {
const value = this.#tree.computed[this.name].value;
return value;
}
return null;
}
}
/**
* A container to give easy access to property data from the template engine.
*/
class PropertyView {
/*
* @param {CssComputedView} tree
* The CssComputedView instance we are working with.
* @param {String} name
* The CSS property name for which this PropertyView
* instance will render the rules.
* @param {Boolean} isCustomProperty
* Set to true if this will represent a custom property.
*/
constructor(tree, name, isCustomProperty = false) {
this.#tree = tree;
this.name = name;
this.isCustomProperty = isCustomProperty;
if (!this.isCustomProperty) {
}
this.#propertyInfo = new PropertyInfo(tree, name);
const win = this.#tree.styleWindow;
this.#abortController = new win.AbortController();
}
// The parent element which contains the open attribute
element = null;
// Destination for property values
valueNode = null;
// Are matched rules expanded?
matchedExpanded = false;
// Matched selector container
matchedSelectorsContainer = null;
// Result of call to getMatchedSelectors
#matchedSelectorResponse = null;
// Matched selector expando
#matchedExpander = null;
// AbortController for event listeners
#abortController = null;
// Cache for matched selector views
#matchedSelectorViews = null;
// The previously selected element used for the selector view caches
#prevViewedElement = null;
// PropertyInfo
#propertyInfo = null;
#tree;
/**
* Get the computed style for the current property.
*
* @return {String} the computed style for the current property of the
* currently highlighted element.
*/
get value() {
return this.propertyInfo.value;
}
/**
* An easy way to access the CssPropertyInfo behind this PropertyView.
*/
get propertyInfo() {
return this.#propertyInfo;
}
/**
* Does the property have any matched selectors?
*/
get hasMatchedSelectors() {
return this.#tree.matchedProperties.has(this.name);
}
/**
* Should this property be visible?
*/
get visible() {
if (!this.#tree.viewedElement) {
return false;
}
if (!this.#tree.includeBrowserStyles && !this.hasMatchedSelectors) {
return false;
}
const searchTerm = this.#tree.searchField.value.toLowerCase();
const isValidSearchTerm = !!searchTerm.trim().length;
if (
isValidSearchTerm &&
!this.name.toLowerCase().includes(searchTerm) &&
!this.value.toLowerCase().includes(searchTerm)
) {
return false;
}
return this.propertyInfo.isSupported;
}
/**
* Returns the className that should be assigned to the propertyView.
*
* @return {String}
*/
get propertyHeaderClassName() {
return this.visible ? "computed-property-view" : "computed-property-hidden";
}
/**
* Is the property invalid at computed value time
*
* @returns {Boolean}
*/
get invalidAtComputedValueTime() {
return this.#tree.computed[this.name].invalidAtComputedValueTime;
}
/**
* If this is a registered property, returns its syntax (e.g. "<color>")
*
* @returns {Text|undefined}
*/
get registeredPropertySyntax() {
return this.#tree.computed[this.name].registeredPropertySyntax;
}
/**
* If this is a registered property, return its initial-value
*
* @returns {Text|undefined}
*/
get registeredPropertyInitialValue() {
return this.#tree.computed[this.name].registeredPropertyInitialValue;
}
/**
* Create DOM elements for a property
*
* @return {Element} The <li> element
*/
createListItemElement() {
const doc = this.#tree.styleDocument;
const baseEventListenerConfig = { signal: this.#abortController.signal };
// Build the container element
this.onMatchedToggle = this.onMatchedToggle.bind(this);
this.element = doc.createElement("li");
this.element.className = this.propertyHeaderClassName;
this.element.addEventListener(
"dblclick",
this.onMatchedToggle,
baseEventListenerConfig
);
// Make it keyboard navigable
this.element.setAttribute("tabindex", "0");
this.shortcuts = new KeyShortcuts({
window: this.#tree.styleWindow,
target: this.element,
});
this.shortcuts.on("F1", event => {
this.mdnLinkClick(event);
// Prevent opening the options panel
event.preventDefault();
event.stopPropagation();
});
this.shortcuts.on("Return", this.onMatchedToggle);
this.shortcuts.on("Space", this.onMatchedToggle);
const nameContainer = doc.createElement("span");
nameContainer.className = "computed-property-name-container";
// Build the twisty expand/collapse
this.#matchedExpander = doc.createElement("div");
this.#matchedExpander.className = "computed-expander theme-twisty";
this.#matchedExpander.setAttribute("role", "button");
this.#matchedExpander.setAttribute("aria-label", L10N_TWISTY_EXPAND_LABEL);
this.#matchedExpander.addEventListener(
"click",
this.onMatchedToggle,
baseEventListenerConfig
);
// Build the style name element
const nameNode = doc.createElement("span");
nameNode.classList.add("computed-property-name", "theme-fg-color3");
// Give it a heading role for screen readers.
nameNode.setAttribute("role", "heading");
// Reset its tabindex attribute otherwise, if an ellipsis is applied
// it will be reachable via TABing
nameNode.setAttribute("tabindex", "");
// Avoid english text (css properties) from being altered
// by RTL mode
nameNode.setAttribute("dir", "ltr");
nameNode.textContent = nameNode.title = this.name;
// Make it hand over the focus to the container
const focusElement = () => this.element.focus();
nameNode.addEventListener("click", focusElement, baseEventListenerConfig);
// Build the style name ":" separator
const nameSeparator = doc.createElement("span");
nameSeparator.classList.add("visually-hidden");
nameSeparator.textContent = ": ";
nameNode.appendChild(nameSeparator);
nameContainer.appendChild(nameNode);
const valueContainer = doc.createElement("span");
valueContainer.className = "computed-property-value-container";
// Build the style value element
this.valueNode = doc.createElement("span");
this.valueNode.classList.add("computed-property-value", "theme-fg-color1");
// Reset its tabindex attribute otherwise, if an ellipsis is applied
// it will be reachable via TABing
this.valueNode.setAttribute("tabindex", "");
this.valueNode.setAttribute("dir", "ltr");
// Make it hand over the focus to the container
this.valueNode.addEventListener(
"click",
focusElement,
baseEventListenerConfig
);
// Build the style value ";" separator
const valueSeparator = doc.createElement("span");
valueSeparator.classList.add("visually-hidden");
valueSeparator.textContent = ";";
valueContainer.append(this.valueNode, valueSeparator);
// If the value is invalid at computed value time (IACVT), display the same
// warning icon that we have in the rules view for IACVT declarations.
if (this.isCustomProperty) {
this.invalidAtComputedValueTimeNode = doc.createElement("div");
this.invalidAtComputedValueTimeNode.classList.add(
"invalid-at-computed-value-time-warning"
);
this.refreshInvalidAtComputedValueTime();
valueContainer.append(this.invalidAtComputedValueTimeNode);
}
// Build the matched selectors container
this.matchedSelectorsContainer = doc.createElement("div");
this.matchedSelectorsContainer.classList.add("matchedselectors");
this.element.append(
this.#matchedExpander,
nameContainer,
valueContainer,
this.matchedSelectorsContainer
);
return this.element;
}
/**
* Refresh the panel's CSS property value.
*/
refresh() {
const className = this.propertyHeaderClassName;
if (this.element.className !== className) {
this.element.className = className;
}
if (this.#prevViewedElement !== this.#tree.viewedElement) {
this.#matchedSelectorViews = null;
this.#prevViewedElement = this.#tree.viewedElement;
}
if (!this.#tree.viewedElement || !this.visible) {
this.valueNode.textContent = this.valueNode.title = "";
this.matchedSelectorsContainer.parentNode.hidden = true;
this.matchedSelectorsContainer.textContent = "";
this.#matchedExpander.removeAttribute("open");
this.#matchedExpander.setAttribute(
"aria-label",
L10N_TWISTY_EXPAND_LABEL
);
return;
}
this.#tree.numVisibleProperties++;
this.valueNode.innerHTML = "";
// No need to pass the baseURI argument here as computed URIs are never relative.
this.valueNode.appendChild(this.#parseValue(this.propertyInfo.value));
this.refreshInvalidAtComputedValueTime();
this.refreshMatchedSelectors();
}
/**
* Refresh the panel matched rules.
*/
refreshMatchedSelectors() {
const hasMatchedSelectors = this.hasMatchedSelectors;
this.matchedSelectorsContainer.parentNode.hidden = !hasMatchedSelectors;
if (hasMatchedSelectors) {
this.#matchedExpander.classList.add("computed-expandable");
} else {
this.#matchedExpander.classList.remove("computed-expandable");
}
if (this.matchedExpanded && hasMatchedSelectors) {
return this.#tree.viewedElementPageStyle
.getMatchedSelectors(this.#tree.viewedElement, this.name)
.then(matched => {
if (!this.matchedExpanded) {
return;
}
this.#matchedSelectorResponse = matched;
this.#buildMatchedSelectors();
this.#matchedExpander.setAttribute("open", "");
this.#matchedExpander.setAttribute(
"aria-label",
L10N_TWISTY_COLLAPSE_LABEL
);
this.#tree.inspector.emit("computed-view-property-expanded");
})
.catch(console.error);
}
this.matchedSelectorsContainer.innerHTML = "";
this.#matchedExpander.removeAttribute("open");
this.#matchedExpander.setAttribute("aria-label", L10N_TWISTY_EXPAND_LABEL);
this.#tree.inspector.emit("computed-view-property-collapsed");
return Promise.resolve(undefined);
}
/**
* Show/Hide IACVT icon and sets its title attribute
*/
refreshInvalidAtComputedValueTime() {
if (!this.isCustomProperty) {
return;
}
if (!this.invalidAtComputedValueTime) {
this.invalidAtComputedValueTimeNode.setAttribute("hidden", "");
this.invalidAtComputedValueTimeNode.removeAttribute("title");
} else {
this.invalidAtComputedValueTimeNode.removeAttribute("hidden", "");
this.invalidAtComputedValueTimeNode.setAttribute(
"title",
STYLE_INSPECTOR_L10N.getFormatStr(
"rule.warningInvalidAtComputedValueTime.title",
`"${this.registeredPropertySyntax}"`
)
);
}
}
get matchedSelectors() {
return this.#matchedSelectorResponse;
}
#buildMatchedSelectors() {
const frag = this.element.ownerDocument.createDocumentFragment();
for (const selector of this.matchedSelectorViews) {
const p = createChild(frag, "p");
const span = createChild(p, "span", {
class: "rule-link",
});
const link = createChild(span, "a", {
target: "_blank",
class: "computed-link theme-link",
title: selector.longSource,
sourcelocation: selector.source,
tabindex: "0",
textContent: selector.source,
});
link.addEventListener("click", selector.openStyleEditor);
const shortcuts = new KeyShortcuts({
window: this.#tree.styleWindow,
target: link,
});
shortcuts.on("Return", () => selector.openStyleEditor());
const status = createChild(p, "span", {
dir: "ltr",
class: "rule-text theme-fg-color3 " + selector.statusClass,
title: selector.statusText,
});
// Add an explicit status text span for screen readers.
// They won't pick up the title from the status span.
createChild(status, "span", {
dir: "ltr",
class: "visually-hidden",
textContent: selector.statusText + " ",
});
createChild(status, "div", {
class: "fix-get-selection",
textContent: selector.sourceText,
});
const valueDiv = createChild(status, "div", {
class:
"fix-get-selection computed-other-property-value theme-fg-color1",
});
valueDiv.appendChild(
this.#parseValue(
selector.selectorInfo.value,
selector.selectorInfo.rule.href
)
);
// If the value is invalid at computed value time (IACVT), display the same
// warning icon that we have in the rules view for IACVT declarations.
if (selector.selectorInfo.invalidAtComputedValueTime) {
createChild(status, "div", {
class: "invalid-at-computed-value-time-warning",
title: STYLE_INSPECTOR_L10N.getFormatStr(
"rule.warningInvalidAtComputedValueTime.title",
`"${selector.selectorInfo.registeredPropertySyntax}"`
),
});
}
}
if (this.registeredPropertyInitialValue !== undefined) {
const p = createChild(frag, "p");
const status = createChild(p, "span", {
dir: "ltr",
class: "rule-text theme-fg-color3",
});
createChild(status, "div", {
class: "fix-get-selection",
textContent: "initial-value",
});
const valueDiv = createChild(status, "div", {
class:
"fix-get-selection computed-other-property-value theme-fg-color1",
});
valueDiv.appendChild(
this.#parseValue(this.registeredPropertyInitialValue)
);
}
this.matchedSelectorsContainer.innerHTML = "";
this.matchedSelectorsContainer.appendChild(frag);
}
/**
* Parse a property value using the OutputParser.
*
* @param {String} value
* @param {String} baseURI
* @returns {DocumentFragment|Element}
*/
#parseValue(value, baseURI) {
if (this.isCustomProperty && value === "") {
const doc = this.#tree.styleDocument;
const el = doc.createElement("span");
el.classList.add("empty-css-variable");
el.append(doc.createTextNode(`<${L10N_EMPTY_VARIABLE}>`));
return el;
}
// Sadly, because this fragment is added to the template by DOM Templater
// we lose any events that are attached. This means that URLs will open in a
// new window. At some point we should fix this by stopping using the
// templater.
return this.#tree.outputParser.parseCssProperty(this.name, value, {
colorSwatchClass: "inspector-swatch inspector-colorswatch",
colorSwatchReadOnly: true,
colorClass: "computed-color",
urlClass: "theme-link",
fontFamilyClass: "computed-font-family",
baseURI,
});
}
/**
* Provide access to the matched SelectorViews that we are currently
* displaying.
*/
get matchedSelectorViews() {
if (!this.#matchedSelectorViews) {
this.#matchedSelectorViews = [];
this.#matchedSelectorResponse.forEach(selectorInfo => {
const selectorView = new SelectorView(this.#tree, selectorInfo);
this.#matchedSelectorViews.push(selectorView);
}, this);
}
return this.#matchedSelectorViews;
}
/**
* The action when a user expands matched selectors.
*
* @param {Event} event
* Used to determine the class name of the targets click
* event.
*/
onMatchedToggle(event) {
if (event.shiftKey) {
return;
}
this.matchedExpanded = !this.matchedExpanded;
this.refreshMatchedSelectors();
event.preventDefault();
}
/**
* The action when a user clicks on the MDN help link for a property.
*/
mdnLinkClick() {
if (!this.link) {
return;
}
openContentLink(this.link);
}
/**
* Destroy this property view, removing event listeners
*/
destroy() {
if (this.#matchedSelectorViews) {
for (const view of this.#matchedSelectorViews) {
view.destroy();
}
}
if (this.#abortController) {
this.#abortController.abort();
this.#abortController = null;
}
if (this.shortcuts) {
this.shortcuts.destroy();
}
this.shortcuts = null;
this.element = null;
this.#matchedExpander = null;
this.valueNode = null;
}
}
/**
* A container to give us easy access to display data from a CssRule
*/
class SelectorView {
/**
* @param CssComputedView tree
* the owning CssComputedView
* @param selectorInfo
*/
constructor(tree, selectorInfo) {
this.#tree = tree;
this.selectorInfo = selectorInfo;
this.#cacheStatusNames();
this.openStyleEditor = this.openStyleEditor.bind(this);
const rule = this.selectorInfo.rule;
if (!rule || !rule.parentStyleSheet || rule.type == ELEMENT_STYLE) {
this.source = CssLogic.l10n("rule.sourceElement");
this.longSource = this.source;
} else {
// This always refers to the generated location.
const sheet = rule.parentStyleSheet;
const sourceSuffix = rule.line > 0 ? ":" + rule.line : "";
this.source = CssLogic.shortSource(sheet) + sourceSuffix;
this.longSource = CssLogic.longSource(sheet) + sourceSuffix;
this.#generatedLocation = {
sheet,
href: sheet.href || sheet.nodeHref,
line: rule.line,
column: rule.column,
};
this.#unsubscribeCallback =
this.#tree.inspector.toolbox.sourceMapURLService.subscribeByID(
this.#generatedLocation.sheet.resourceId,
this.#generatedLocation.line,
this.#generatedLocation.column,
this.#updateLocation
);
}
}
#generatedLocation;
#href;
#tree;
#unsubscribeCallback;
/**
* Decode for cssInfo.rule.status
* @see SelectorView.prototype.#cacheStatusNames
* @see CssLogic.STATUS
*/
static STATUS_NAMES = [
// "Parent Match", "Matched", "Best Match"
];
static CLASS_NAMES = ["parentmatch", "matched", "bestmatch"];
/**
* Cache localized status names.
*
* These statuses are localized inside the styleinspector.properties string
* bundle.
* @see css-logic.js - the CssLogic.STATUS array.
*/
#cacheStatusNames() {
if (SelectorView.STATUS_NAMES.length) {
return;
}
for (const status in CssLogic.STATUS) {
const i = CssLogic.STATUS[status];
if (i > CssLogic.STATUS.UNMATCHED) {
const value = CssComputedView.l10n("rule.status." + status);
// Replace normal spaces with non-breaking spaces
SelectorView.STATUS_NAMES[i] = value.replace(/ /g, "\u00A0");
}
}
}
/**
* A localized version of cssRule.status
*/
get statusText() {
return SelectorView.STATUS_NAMES[this.selectorInfo.status];
}
/**
* Get class name for selector depending on status
*/
get statusClass() {
return SelectorView.CLASS_NAMES[this.selectorInfo.status - 1];
}
get href() {
if (this.#href) {
return this.#href;
}
const sheet = this.selectorInfo.rule.parentStyleSheet;
this.#href = sheet ? sheet.href : "#";
return this.#href;
}
get sourceText() {
return this.selectorInfo.sourceText;
}
get value() {
return this.selectorInfo.value;
}
/**
* Update the text of the source link to reflect whether we're showing
* original sources or not. This is a callback for
* SourceMapURLService.subscribe, which see.
*
* @param {Object | null} originalLocation
* The original position object (url/line/column) or null.
*/
#updateLocation = originalLocation => {
if (!this.#tree.element) {
return;
}
// Update |currentLocation| to be whichever location is being
// displayed at the moment.
let currentLocation = this.#generatedLocation;
if (originalLocation) {
const { url, line, column } = originalLocation;
currentLocation = { href: url, line, column };
}
const selector = '[sourcelocation="' + this.source + '"]';
const link = this.#tree.element.querySelector(selector);
if (link) {
const text =
CssLogic.shortSource(currentLocation) + ":" + currentLocation.line;
link.textContent = text;
}
this.#tree.inspector.emit("computed-view-sourcelinks-updated");
};
/**
* When a css link is clicked this method is called in order to either:
* 1. Open the link in view source (for chrome stylesheets).
* 2. Open the link in the style editor.
*
* We can only view stylesheets contained in document.styleSheets inside the
* style editor.
*/
openStyleEditor() {
const inspector = this.#tree.inspector;
const rule = this.selectorInfo.rule;
// The style editor can only display stylesheets coming from content because
// chrome stylesheets are not listed in the editor's stylesheet selector.
//
// If the stylesheet is a content stylesheet we send it to the style
// editor else we display it in the view source window.
const parentStyleSheet = rule.parentStyleSheet;
if (!parentStyleSheet || parentStyleSheet.isSystem) {
inspector.toolbox.viewSource(rule.href, rule.line);
return;
}
const { sheet, line, column } = this.#generatedLocation;
if (ToolDefinitions.styleEditor.isToolSupported(inspector.toolbox)) {
inspector.toolbox.viewSourceInStyleEditorByResource(sheet, line, column);
}
}
/**
* Destroy this selector view, removing event listeners
*/
destroy() {
if (this.#unsubscribeCallback) {
this.#unsubscribeCallback();
}
}
}
class ComputedViewTool {
/**
* @param {Inspector} inspector
* @param {Window} window
*/
constructor(inspector, window) {
this.inspector = inspector;
this.document = window.document;
this.computedView = new CssComputedView(this.inspector, this.document);
this.onDetachedFront = this.onDetachedFront.bind(this);
this.onSelected = this.onSelected.bind(this);
this.refresh = this.refresh.bind(this);
this.onPanelSelected = this.onPanelSelected.bind(this);
this.#abortController = new AbortController();
const opts = { signal: this.#abortController.signal };
this.inspector.selection.on("detached-front", this.onDetachedFront, opts);
this.inspector.selection.on("new-node-front", this.onSelected, opts);
this.inspector.selection.on("pseudoclass", this.refresh, opts);
this.inspector.sidebar.on(
"computedview-selected",
this.onPanelSelected,
opts
);
this.inspector.styleChangeTracker.on("style-changed", this.refresh, opts);
this.computedView.selectElement(null);
this.onSelected();
}
#abortController;
isPanelVisible() {
if (!this.computedView) {
return false;
}
return this.computedView.isPanelVisible();
}
onDetachedFront() {
this.onSelected(false);
}
async onSelected(selectElement = true) {
// Ignore the event if the view has been destroyed, or if it's inactive.
// But only if the current selection isn't null. If it's been set to null,
// let the update go through as this is needed to empty the view on
// navigation.
if (!this.computedView) {
return;
}
const isInactive =
!this.isPanelVisible() && this.inspector.selection.nodeFront;
if (isInactive) {
return;
}
if (
!this.inspector.selection.isConnected() ||
!this.inspector.selection.isElementNode()
) {
this.computedView.selectElement(null);
return;
}
if (selectElement) {
const done = this.inspector.updating("computed-view");
await this.computedView.selectElement(this.inspector.selection.nodeFront);
done();
}
}
refresh() {
if (this.isPanelVisible()) {
this.computedView.refreshPanel();
}
}
onPanelSelected() {
if (
this.inspector.selection.nodeFront === this.computedView.viewedElement
) {
this.refresh();
} else {
this.onSelected();
}
}
destroy() {
this.#abortController.abort();
this.computedView.destroy();
this.computedView =
this.document =
this.inspector =
this.#abortController =
null;
}
}
exports.CssComputedView = CssComputedView;
exports.ComputedViewTool = ComputedViewTool;
exports.PropertyView = PropertyView;