gecko-dev/devtools/client/inspector/rules/new-rules.js

827 строки
25 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/. */
"use strict";
const Services = require("Services");
const ElementStyle = require("devtools/client/inspector/rules/models/element-style");
const {
createFactory,
createElement,
} = require("devtools/client/shared/vendor/react");
const { Provider } = require("devtools/client/shared/vendor/react-redux");
const EventEmitter = require("devtools/shared/event-emitter");
const classListReducer = require("devtools/client/inspector/rules/reducers/class-list");
const pseudoClassesReducer = require("devtools/client/inspector/rules/reducers/pseudo-classes");
const rulesReducer = require("devtools/client/inspector/rules/reducers/rules");
const {
updateClasses,
updateClassPanelExpanded,
} = require("devtools/client/inspector/rules/actions/class-list");
const {
disableAllPseudoClasses,
setPseudoClassLocks,
togglePseudoClass,
} = require("devtools/client/inspector/rules/actions/pseudo-classes");
const {
updateAddRuleEnabled,
updateColorSchemeSimulationHidden,
updateHighlightedSelector,
updatePrintSimulationHidden,
updateRules,
updateSourceLinkEnabled,
} = require("devtools/client/inspector/rules/actions/rules");
const RulesApp = createFactory(
require("devtools/client/inspector/rules/components/RulesApp")
);
const { LocalizationHelper } = require("devtools/shared/l10n");
const INSPECTOR_L10N = new LocalizationHelper(
"devtools/client/locales/inspector.properties"
);
loader.lazyRequireGetter(this, "Tools", "devtools/client/definitions", true);
loader.lazyRequireGetter(
this,
"ClassList",
"devtools/client/inspector/rules/models/class-list"
);
loader.lazyRequireGetter(
this,
"getNodeInfo",
"devtools/client/inspector/rules/utils/utils",
true
);
loader.lazyRequireGetter(
this,
"StyleInspectorMenu",
"devtools/client/inspector/shared/style-inspector-menu"
);
loader.lazyRequireGetter(
this,
"advanceValidate",
"devtools/client/inspector/shared/utils",
true
);
loader.lazyRequireGetter(
this,
"AutocompletePopup",
"devtools/client/shared/autocomplete-popup"
);
loader.lazyRequireGetter(
this,
"InplaceEditor",
"devtools/client/shared/inplace-editor",
true
);
loader.lazyRequireGetter(
this,
"COLOR_SCHEMES",
"devtools/client/inspector/rules/constants",
true
);
const PREF_UA_STYLES = "devtools.inspector.showUserAgentStyles";
class RulesView {
constructor(inspector, window) {
this.cssProperties = inspector.cssProperties;
this.doc = window.document;
this.inspector = inspector;
this.selection = inspector.selection;
this.store = inspector.store;
this.telemetry = inspector.telemetry;
this.toolbox = inspector.toolbox;
this.isNewRulesView = true;
this.showUserAgentStyles = Services.prefs.getBoolPref(PREF_UA_STYLES);
this.store.injectReducer("classList", classListReducer);
this.store.injectReducer("pseudoClasses", pseudoClassesReducer);
this.store.injectReducer("rules", rulesReducer);
this.onAddClass = this.onAddClass.bind(this);
this.onAddRule = this.onAddRule.bind(this);
this.onOpenSourceLink = this.onOpenSourceLink.bind(this);
this.onSelection = this.onSelection.bind(this);
this.onSetClassState = this.onSetClassState.bind(this);
this.onToggleClassPanelExpanded = this.onToggleClassPanelExpanded.bind(
this
);
this.onToggleDeclaration = this.onToggleDeclaration.bind(this);
this.onTogglePrintSimulation = this.onTogglePrintSimulation.bind(this);
this.onToggleColorSchemeSimulation = this.onToggleColorSchemeSimulation.bind(
this
);
this.onTogglePseudoClass = this.onTogglePseudoClass.bind(this);
this.onToolChanged = this.onToolChanged.bind(this);
this.onToggleSelectorHighlighter = this.onToggleSelectorHighlighter.bind(
this
);
this.showContextMenu = this.showContextMenu.bind(this);
this.showDeclarationNameEditor = this.showDeclarationNameEditor.bind(this);
this.showDeclarationValueEditor = this.showDeclarationValueEditor.bind(
this
);
this.showNewDeclarationEditor = this.showNewDeclarationEditor.bind(this);
this.showSelectorEditor = this.showSelectorEditor.bind(this);
this.updateClassList = this.updateClassList.bind(this);
this.updateRules = this.updateRules.bind(this);
this.inspector.sidebar.on("select", this.onSelection);
this.selection.on("detached-front", this.onSelection);
this.selection.on("new-node-front", this.onSelection);
this.toolbox.on("tool-registered", this.onToolChanged);
this.toolbox.on("tool-unregistered", this.onToolChanged);
this.init();
EventEmitter.decorate(this);
}
init() {
if (!this.inspector) {
return;
}
const rulesApp = RulesApp({
onAddClass: this.onAddClass,
onAddRule: this.onAddRule,
onOpenSourceLink: this.onOpenSourceLink,
onSetClassState: this.onSetClassState,
onToggleClassPanelExpanded: this.onToggleClassPanelExpanded,
onToggleColorSchemeSimulation: this.onToggleColorSchemeSimulation,
onToggleDeclaration: this.onToggleDeclaration,
onTogglePrintSimulation: this.onTogglePrintSimulation,
onTogglePseudoClass: this.onTogglePseudoClass,
onToggleSelectorHighlighter: this.onToggleSelectorHighlighter,
showContextMenu: this.showContextMenu,
showDeclarationNameEditor: this.showDeclarationNameEditor,
showDeclarationValueEditor: this.showDeclarationValueEditor,
showNewDeclarationEditor: this.showNewDeclarationEditor,
showSelectorEditor: this.showSelectorEditor,
});
this.initSimulationFeatures();
const provider = createElement(
Provider,
{
id: "ruleview",
key: "ruleview",
store: this.store,
title: INSPECTOR_L10N.getStr("inspector.sidebar.ruleViewTitle"),
},
rulesApp
);
// Exposes the provider to let inspector.js use it in setupSidebar.
this.provider = provider;
}
/**
* Initializes the content-viewer front and enable the print and color scheme simulation
* if they are supported in the current target.
*/
async initSimulationFeatures() {
// In order to query if the content-viewer actor's print and color simulation methods are
// supported, we have to call the content-viewer front so that the actor is lazily loaded.
// This allows us to use `actorHasMethod`. Please see `getActorDescription` for more
// information.
this.contentViewerFront = await this.currentTarget.getFront(
"contentViewer"
);
if (!this.currentTarget.chrome) {
this.store.dispatch(updatePrintSimulationHidden(false));
} else {
this.store.dispatch(updatePrintSimulationHidden(true));
}
// Show the color scheme simulation toggle button if:
// - The feature pref is enabled.
// - Color scheme simulation is supported for the current target.
const isEmulateColorSchemeSupported = await this.currentTarget.actorHasMethod(
"contentViewer",
"getEmulatedColorScheme"
);
if (
Services.prefs.getBoolPref(
"devtools.inspector.color-scheme-simulation.enabled"
) &&
isEmulateColorSchemeSupported
) {
this.store.dispatch(updateColorSchemeSimulationHidden(false));
} else {
this.store.dispatch(updateColorSchemeSimulationHidden(true));
}
}
destroy() {
this.inspector.sidebar.off("select", this.onSelection);
this.selection.off("detached-front", this.onSelection);
this.selection.off("new-node-front", this.onSelection);
this.toolbox.off("tool-registered", this.onToolChanged);
this.toolbox.off("tool-unregistered", this.onToolChanged);
if (this._autocompletePopup) {
this._autocompletePopup.destroy();
this._autocompletePopup = null;
}
if (this._classList) {
this._classList.off("current-node-class-changed", this.refreshClassList);
this._classList.destroy();
this._classList = null;
}
if (this._contextMenu) {
this._contextMenu.destroy();
this._contextMenu = null;
}
if (this.elementStyle) {
this.elementStyle.destroy();
this.elementStyle = null;
}
if (this.contentViewerFront) {
this.contentViewerFront.destroy();
this.contentViewerFront = null;
}
this._dummyElement = null;
this.cssProperties = null;
this.doc = null;
this.inspector = null;
this.pageStyle = null;
this.selection = null;
this.showUserAgentStyles = null;
this.store = null;
this.telemetry = null;
this.toolbox = null;
}
/**
* Get an instance of the AutocompletePopup.
*
* @return {AutocompletePopup}
*/
get autocompletePopup() {
if (!this._autocompletePopup) {
this._autocompletePopup = new AutocompletePopup(this.doc, {
autoSelect: true,
theme: "auto",
});
}
return this._autocompletePopup;
}
/**
* Get an instance of the ClassList model used to manage the list of CSS classes
* applied to the element.
*
* @return {ClassList} used to manage the list of CSS classes applied to the element.
*/
get classList() {
if (!this._classList) {
this._classList = new ClassList(this.inspector);
}
return this._classList;
}
get contextMenu() {
if (!this._contextMenu) {
this._contextMenu = new StyleInspectorMenu(this, { isRuleView: true });
}
return this._contextMenu;
}
/**
* Get the current target the toolbox is debugging.
*
* @return {Target}
*/
get currentTarget() {
return this.inspector.currentTarget;
}
/**
* Creates a dummy element in the document that helps get the computed style in
* TextProperty.
*
* @return {Element} used to get the computed style for text properties.
*/
get dummyElement() {
// To figure out how shorthand properties are interpreted by the
// engine, we will set properties on a dummy element and observe
// how their .style attribute reflects them as computed values.
if (!this._dummyElement) {
this._dummyElement = this.doc.createElement("div");
}
return this._dummyElement;
}
/**
* Get the highlighters overlay from the Inspector.
*
* @return {HighlighterOverlay}.
*/
get highlighters() {
return this.inspector.highlighters;
}
/**
* Returns the grid line names of the grid that the currently selected element is
* contained in.
*
* @return {Object} that contains the names of the cols and rows as arrays
* { cols: [], rows: [] }.
*/
async getGridlineNames() {
const gridLineNames = { cols: [], rows: [] };
const layoutInspector = await this.inspector.walker.getLayoutInspector();
const gridFront = await layoutInspector.getCurrentGrid(
this.selection.nodeFront
);
if (gridFront) {
const gridFragments = gridFront.gridFragments;
for (const gridFragment of gridFragments) {
for (const rowLine of gridFragment.rows.lines) {
gridLineNames.rows = gridLineNames.rows.concat(rowLine.names);
}
for (const colLine of gridFragment.cols.lines) {
gridLineNames.cols = gridLineNames.cols.concat(colLine.names);
}
}
}
// This event is emitted for testing purposes.
this.inspector.emit("grid-line-names-updated");
return gridLineNames;
}
/**
* Get the type of a given node in the Rules view.
*
* @param {DOMNode} node
* The node which we want information about.
* @return {Object|null} containing the following props:
* - rule {Rule} The Rule object.
* - 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.
* - view {String} Always "rule" to indicate the rule view.
* Otherwise, returns null if the node isn't anything we care about.
*/
getNodeInfo(node) {
return getNodeInfo(node, this.elementStyle);
}
/**
* Returns true if the rules panel is visible, and false otherwise.
*/
isPanelVisible() {
return (
this.inspector &&
this.inspector.toolbox &&
this.inspector.sidebar &&
this.inspector.toolbox.currentToolId === "inspector" &&
this.inspector.sidebar.getCurrentTabID() === "newruleview"
);
}
/**
* Check whether a SelectorHighlighter is active for the given selector text.
*
* @param {String} selector
* @return {Boolean}
*/
isSelectorHighlighted(selector) {
const options = this.inspector.highlighters.getOptionsForActiveHighlighter(
this.inspector.highlighters.TYPES.SELECTOR
);
return options?.selector === selector;
}
/**
* Handler for adding the given CSS class value to the current element's class list.
*
* @param {String} value
* The string that contains all classes.
*/
async onAddClass(value) {
await this.classList.addClassName(value);
this.updateClassList();
}
/**
* Handler for adding a new CSS rule.
*/
async onAddRule() {
await this.elementStyle.addNewRule();
}
/**
* Handler for opening the source link of the given rule in the Style Editor.
*
* @param {String} ruleId
* The id of the Rule for opening the source link.
*/
async onOpenSourceLink(ruleId) {
const rule = this.elementStyle.getRule(ruleId);
if (!rule || !Tools.styleEditor.isTargetSupported(this.currentTarget)) {
return;
}
const { sheet, line, column } = rule.generatedLocation;
this.toolbox.viewSourceInStyleEditorByFront(sheet, line, column);
}
/**
* Handler for selection events "detached-front" and "new-node-front" and inspector
* sidbar "select" event. Updates the rules view with the selected node if the panel
* is visible.
*/
onSelection() {
if (!this.isPanelVisible()) {
return;
}
if (!this.selection.isConnected() || !this.selection.isElementNode()) {
this.update();
return;
}
this.update(this.selection.nodeFront);
}
/**
* Handler for toggling a CSS class from the current element's class list. Sets the
* state of given CSS class name to the given checked value.
*
* @param {String} name
* The CSS class name.
* @param {Boolean} checked
* Whether or not the given CSS class is checked.
*/
async onSetClassState(name, checked) {
await this.classList.setClassState(name, checked);
this.updateClassList();
}
/**
* Handler for toggling the expanded property of the class list panel.
*
* @param {Boolean} isClassPanelExpanded
* Whether or not the class list panel is expanded.
*/
onToggleClassPanelExpanded(isClassPanelExpanded) {
if (isClassPanelExpanded) {
this.classList.on("current-node-class-changed", this.updateClassList);
} else {
this.classList.off("current-node-class-changed", this.updateClassList);
}
this.store.dispatch(updateClassPanelExpanded(isClassPanelExpanded));
}
/**
* Handler for toggling the enabled property for a given CSS declaration.
*
* @param {String} ruleId
* The Rule id of the given CSS declaration.
* @param {String} declarationId
* The TextProperty id for the CSS declaration.
*/
onToggleDeclaration(ruleId, declarationId) {
this.elementStyle.toggleDeclaration(ruleId, declarationId);
this.telemetry.recordEvent("edit_rule", "ruleview", null, {
session_id: this.toolbox.sessionId,
});
}
/**
* Handler for toggling color scheme simulation.
*/
async onToggleColorSchemeSimulation() {
const currentState = await this.contentViewerFront.getEmulatedColorScheme();
const index = COLOR_SCHEMES.indexOf(currentState);
const nextState = COLOR_SCHEMES[(index + 1) % COLOR_SCHEMES.length];
await this.contentViewerFront.setEmulatedColorScheme(nextState);
await this.updateElementStyle();
}
/**
* Handler for toggling print media simulation.
*/
async onTogglePrintSimulation() {
const enabled = await this.contentViewerFront.getIsPrintSimulationEnabled();
if (!enabled) {
await this.contentViewerFront.startPrintMediaSimulation();
} else {
await this.contentViewerFront.stopPrintMediaSimulation(false);
}
await this.updateElementStyle();
}
/**
* Handler for toggling a pseudo class in the pseudo class panel. Toggles on and off
* a given pseudo class value.
*
* @param {String} value
* The pseudo class to toggle on or off.
*/
onTogglePseudoClass(value) {
this.store.dispatch(togglePseudoClass(value));
this.inspector.togglePseudoClass(value);
}
/**
* Handler for toggling the selector highlighter for the given selector.
* Highlight/unhighlight all the nodes that match a given set of selectors inside the
* document of the current selected node. Only one selector can be highlighted at a
* time, so calling the method a second time with a different selector will first
* unhighlight the previously highlighted nodes. Calling the method a second time with
* the same select will unhighlight the highlighted nodes.
*
* @param {String} selector
* The selector used to find nodes in the page.
*/
async onToggleSelectorHighlighter(selector) {
if (this.isSelectorHighlighted(selector)) {
await this.inspector.highlighters.hideHighlighterType(
this.inspector.highlighters.TYPES.SELECTOR
);
this.store.dispatch(updateHighlightedSelector(""));
} else {
await this.inspector.highlighters.showHighlighterTypeForNode(
this.inspector.highlighters.TYPES.SELECTOR,
this.inspector.selection.nodeFront,
{
hideInfoBar: true,
hideGuides: true,
selector,
}
);
this.store.dispatch(updateHighlightedSelector(selector));
}
}
/**
* Handler for when the toolbox's tools are registered or unregistered.
* The source links in the rules view should be enabled only while the
* Style Editor is registered because that's where source links point to.
*/
onToolChanged() {
const prevIsSourceLinkEnabled = this.store.getState().rules
.isSourceLinkEnabled;
const isSourceLinkEnabled = this.toolbox.isToolRegistered("styleeditor");
if (prevIsSourceLinkEnabled !== isSourceLinkEnabled) {
this.store.dispatch(updateSourceLinkEnabled(isSourceLinkEnabled));
}
}
/**
* Handler for showing the context menu.
*/
showContextMenu(event) {
this.contextMenu.show(event);
}
/**
* Handler for showing the inplace editor when an editable property name is clicked in
* the rules view.
*
* @param {DOMNode} element
* The declaration name span element to be edited.
* @param {String} ruleId
* The id of the Rule object to be edited.
* @param {String} declarationId
* The id of the TextProperty object to be edited.
*/
showDeclarationNameEditor(element, ruleId, declarationId) {
new InplaceEditor({
advanceChars: ":",
contentType: InplaceEditor.CONTENT_TYPES.CSS_PROPERTY,
cssProperties: this.cssProperties,
done: async (name, commit) => {
if (!commit) {
return;
}
await this.elementStyle.modifyDeclarationName(
ruleId,
declarationId,
name
);
this.telemetry.recordEvent("edit_rule", "ruleview", null, {
session_id: this.toolbox.sessionId,
});
},
element,
popup: this.autocompletePopup,
});
}
/**
* Handler for showing the inplace editor when an editable property value is clicked
* in the rules view.
*
* @param {DOMNode} element
* The declaration value span element to be edited.
* @param {String} ruleId
* The id of the Rule object to be edited.
* @param {String} declarationId
* The id of the TextProperty object to be edited.
*/
showDeclarationValueEditor(element, ruleId, declarationId) {
const rule = this.elementStyle.getRule(ruleId);
if (!rule) {
return;
}
const declaration = rule.getDeclaration(declarationId);
if (!declaration) {
return;
}
new InplaceEditor({
advanceChars: advanceValidate,
contentType: InplaceEditor.CONTENT_TYPES.CSS_VALUE,
cssProperties: this.cssProperties,
cssVariables:
this.elementStyle.variablesMap.get(rule.pseudoElement) || [],
defaultIncrement: declaration.name === "opacity" ? 0.1 : 1,
done: async (value, commit) => {
if (!commit || !value || !value.trim()) {
return;
}
await this.elementStyle.modifyDeclarationValue(
ruleId,
declarationId,
value
);
this.telemetry.recordEvent("edit_rule", "ruleview", null, {
session_id: this.toolbox.sessionId,
});
},
element,
getGridLineNames: this.getGridlineNames,
maxWidth: () => {
// Return the width of the closest declaration container element.
const containerElement = element.closest(".ruleview-propertycontainer");
return containerElement.getBoundingClientRect().width;
},
multiline: true,
popup: this.autocompletePopup,
property: declaration,
showSuggestCompletionOnEmpty: true,
});
}
/**
* Shows the new inplace editor for a new declaration.
*
* @param {DOMNode} element
* A new declaration span element to be edited.
* @param {String} ruleId
* The id of the Rule object to be edited.
* @param {Function} callback
* A callback function that is called when the inplace editor is destroyed.
*/
showNewDeclarationEditor(element, ruleId, callback) {
new InplaceEditor({
advanceChars: ":",
contentType: InplaceEditor.CONTENT_TYPES.CSS_PROPERTY,
cssProperties: this.cssProperties,
destroy: () => {
callback();
},
done: (value, commit) => {
if (!commit || !value || !value.trim()) {
return;
}
this.elementStyle.addNewDeclaration(ruleId, value);
this.telemetry.recordEvent("edit_rule", "ruleview", null, {
session_id: this.toolbox.sessionId,
});
},
element,
popup: this.autocompletePopup,
});
}
/**
* Shows the inplace editor for the a selector.
*
* @param {DOMNode} element
* The selector's span element to show the inplace editor.
* @param {String} ruleId
* The id of the Rule to be modified.
*/
showSelectorEditor(element, ruleId) {
new InplaceEditor({
element,
done: async (value, commit) => {
if (!value || !commit) {
return;
}
// Hide the selector highlighter if it matches the selector being edited.
const rule = this.elementStyle.getRule(ruleId);
if (this.isSelectorHighlighted(rule.selectorText)) {
await this.onToggleSelectorHighlighter(rule.selectorText);
}
await this.elementStyle.modifySelector(ruleId, value);
},
});
}
/**
* Updates the rules view by dispatching the new rules data of the newly selected
* element. This is called when the rules view becomes visible or upon new node
* selection.
*
* @param {NodeFront|null} element
* The NodeFront of the current selected element.
*/
async update(element) {
if (this.elementStyle) {
this.elementStyle.destroy();
}
if (!element) {
this.store.dispatch(disableAllPseudoClasses());
this.store.dispatch(updateAddRuleEnabled(false));
this.store.dispatch(updateClasses([]));
this.store.dispatch(updateRules([]));
return;
}
this.pageStyle = element.inspectorFront.pageStyle;
this.elementStyle = new ElementStyle(
element,
this,
{},
this.pageStyle,
this.showUserAgentStyles
);
this.elementStyle.onChanged = this.updateRules;
await this.updateElementStyle();
}
/**
* Updates the class list panel with the current list of CSS classes.
*/
updateClassList() {
this.store.dispatch(updateClasses(this.classList.currentClasses));
}
/**
* Updates the list of rules for the selected element. This should be called after
* ElementStyle is initialized or if the list of rules for the selected element needs
* to be refresh (e.g. when print media simulation is toggled).
*/
async updateElementStyle() {
await this.elementStyle.populate();
const isAddRuleEnabled =
this.selection.isElementNode() && !this.selection.isAnonymousNode();
this.store.dispatch(updateAddRuleEnabled(isAddRuleEnabled));
this.store.dispatch(
setPseudoClassLocks(this.elementStyle.element.pseudoClassLocks)
);
this.updateClassList();
this.updateRules();
}
/**
* Updates the rules view by dispatching the current rules state. This is called from
* the update() function, and from the ElementStyle's onChange() handler.
*/
updateRules() {
this.store.dispatch(updateRules(this.elementStyle.rules));
}
}
module.exports = RulesView;